dashboard: clickable news — detail dialogs + on-demand AI summary endpoint

- news_engine: RSS parser captures link/description/publisher (three new
  optional NewsSentiment fields); _clean_html_text/_safe_link helpers
- news.json: headlines carry id = sha1(headline) + enrichment fields
- static/news_summary.php (NEW): on-demand DeepSeek gold-impact summary,
  whitelist-gated against news.json ids, cache-forever in ai/, fail-closed
  file-based daily cap (150/day), key at /etc/gold-dashboard/deepseek.key;
  installed to the docroot by install_static
- dashboard.html: newswire + calendar detail dialogs (score tiles, source
  description, article link, AI summary with loading/error states);
  delegated on stable containers, dialog keyed by id across polls
- tests: +6 (357 total); docs: README-dashboard §6 one-time setup
  (key file, apache-owned ai/, SELinux httpd_can_network_connect), CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aq1jZ6jBsBdEC2xT3u46SA
This commit is contained in:
Harwinder Sekhon
2026-08-07 09:08:17 -04:00
parent a42ef16113
commit 15a4ed7831
7 changed files with 745 additions and 33 deletions
+212 -11
View File
@@ -322,8 +322,8 @@ details.more .reasons { margin-top: 4px; }
.rail-chip { cursor: pointer; }
.rail-chip:hover, .rail-chip:focus-visible { border-color: var(--accent); }
/* ── Timeframe detail dialog ──────────────────────────────────── */
#tf-detail {
/* ── Detail dialogs (timeframe + news) ────────────────────────── */
#tf-detail, #news-detail {
/* the global `* { margin: 0 }` reset wipes the UA stylesheet's `margin: auto`,
which is what centres a modal dialog — restore it explicitly. */
position: fixed; inset: 0; margin: auto;
@@ -333,9 +333,10 @@ details.more .reasons { margin-top: 4px; }
background: var(--card); color: var(--ink);
font-family: var(--sans); font-size: 16px;
}
#tf-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
#tf-detail[open] { display: flex; flex-direction: column; }
#detail-head {
#news-detail { width: min(680px, 94vw); }
#tf-detail::backdrop, #news-detail::backdrop { background: rgba(var(--ink-rgb),0.5); }
#tf-detail[open], #news-detail[open] { display: flex; flex-direction: column; }
#detail-head, #nd-head {
flex: 0 0 auto;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 14px 18px; background: var(--card2);
@@ -347,13 +348,17 @@ details.more .reasons { margin-top: 4px; }
}
#detail-head .d-price { font-family: var(--mono); font-size: 19px; font-weight: 600; }
#detail-head .d-sub { font-size: 13px; color: var(--muted); }
#detail-close {
#detail-close, #nd-close {
margin-left: auto; font: inherit; font-size: 15px; line-height: 1;
padding: 7px 13px; border-radius: 6px; cursor: pointer;
background: var(--card); color: var(--ink2); border: 1px solid var(--line-strong);
}
#detail-close:hover { border-color: var(--accent); color: var(--accent-bright); }
#detail-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
#detail-close:hover, #nd-close:hover { border-color: var(--accent); color: var(--accent-bright); }
#detail-body, #nd-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 18px 20px; }
#nd-title {
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--muted); font-weight: 600;
}
.d-sec { margin-top: 20px; }
.d-sec > h3 {
@@ -435,6 +440,12 @@ details.more .reasons { margin-top: 4px; }
.d-note b { color: var(--ink); }
.sub-h { font-size: 12px; color: var(--muted); font-weight: 600; margin: 11px 0 5px; }
/* news detail dialog */
.nd-headline { font-size: 18px; font-weight: 650; line-height: 1.35; margin-top: 14px; }
.nd-meta { font-size: 12.5px; color: var(--muted); margin-top: 5px; }
a.d-action { display: block; text-decoration: none; color: var(--accent-bright); }
a.d-action:hover { border-color: var(--accent); }
/* ── News section ─────────────────────────────────────────────── */
#news-wrap { display: grid; gap: 14px; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
@media (max-width: 1100px) { #news-wrap { grid-template-columns: 1fr; } }
@@ -467,6 +478,8 @@ details.more .reasons { margin-top: 4px; }
}
#cal-table tr.past { opacity: 0.55; }
#cal-table tr.next-up td:first-child { box-shadow: inset 3px 0 0 var(--accent); padding-left: 7px; }
#cal-table tr[data-ev-key] { cursor: pointer; }
#cal-table tr[data-ev-key]:hover td { background: var(--accent-wash); }
#cal-table .t { white-space: nowrap; color: var(--ink2); }
#cal-table .cur { font-family: var(--mono); color: var(--muted); }
#cal-table .ev { color: var(--ink); }
@@ -514,6 +527,8 @@ details.more .reasons { margin-top: 4px; }
padding: 7px 0; border-bottom: 1px solid var(--line);
}
.wire-item:last-child { border-bottom: none; }
.wire-item[data-news-id] { cursor: pointer; padding: 7px 6px; margin: 0 -6px; border-radius: 6px; }
.wire-item[data-news-id]:hover { background: var(--accent-wash); }
.wire-score {
font-family: var(--mono); font-size: 13px; font-weight: 700;
padding: 2px 7px; border-radius: 5px; white-space: nowrap;
@@ -569,6 +584,14 @@ footer {
<div id="detail-body"></div>
</dialog>
<dialog id="news-detail" aria-labelledby="nd-title">
<div id="nd-head">
<span id="nd-title">News detail</span>
<button id="nd-close" type="button" aria-label="Close news detail">Close ✕</button>
</div>
<div id="nd-body"></div>
</dialog>
<div class="eyebrow">Fundamentals</div>
<div id="news-wrap">
<section class="panel">
@@ -1056,7 +1079,8 @@ function renderCalendar() {
? `<span class="surp ${e.surprise}" title="actual ${e.surprise === "positive" ? "above" :
e.surprise === "negative" ? "below" : "matches"} forecast (numeric only — not gold direction)">${
e.surprise === "positive" ? "▲" : e.surprise === "negative" ? "▼" : "="}</span>` : "";
rows += `<tr class="${past ? "past" : ""}${nextUp ? " next-up" : ""}">
rows += `<tr class="${past ? "past" : ""}${nextUp ? " next-up" : ""}"
data-ev-key="${esc(evKey(e))}" tabindex="0" role="button" aria-haspopup="dialog">
<td class="t num">${esc(time)}</td>
<td class="cur">${esc(e.currency)}</td>
<td><span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med" :
@@ -1109,11 +1133,14 @@ function renderWire() {
const scoreTxt = s == null ? "·" : (s > 0 ? "+" : "") + s.toFixed(1);
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
return `<div class="wire-item">
/* data-news-id only when the payload carries an id — after a daemon
restart the new page briefly meets a pre-upgrade news.json */
const open = h.id ? ` data-news-id="${h.id}" tabindex="0" role="button" aria-haspopup="dialog"` : "";
return `<div class="wire-item"${open}>
<span class="wire-score ${cls}" title="Headline sentiment ${esc(String(s ?? "n/a"))} (−1…+1)${
h.currencies?.length ? " · " + esc(h.currencies.join(", ")) : ""}">${scoreTxt}</span>
<span class="wire-h">${esc(h.headline)}</span>
<span class="wire-meta">${esc(host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
<span class="wire-meta">${esc(h.publisher || host)}${h.timestamp ? " · " + fmtAge(h.timestamp) : ""}</span></div>`;
};
const head = items.slice(0, 12).map(item).join("");
const rest = items.slice(12);
@@ -1122,6 +1149,179 @@ function renderWire() {
<div class="wire">${rest.map(item).join("")}</div></details>` : "");
}
/* ── news / calendar detail dialog ────────────────────────────── */
const ndlg = $("news-detail");
let ndState = null; // {kind:"wire", id, snap} | {kind:"cal", key, snap}
const aiMemo = new Map(); // headline id -> {summary, generated_at} (successes only)
const aiErr = new Map(); // headline id -> error code (cleared on reopen → retry)
const aiPending = new Set();
/* stable across polls — the array index is not (new headlines shift it) */
const evKey = e => `${e.time}|${e.currency}|${e.event}`;
const findHeadline = id => (news?.headlines || []).find(h => h.id === id);
const findEvent = key => (news?.events || []).find(e => evKey(e) === key);
const AI_ERR_MSG = {
unknown_id: "This headline has left the live feed — no summary available.",
cap_exceeded: "Daily AI budget reached — try again tomorrow.",
no_key: "AI summaries are not configured on this server.",
};
async function fetchAiSummary(id) {
aiPending.add(id);
try {
const res = await fetch("news_summary.php?id=" + encodeURIComponent(id));
const data = await res.json().catch(() => null);
if (res.ok && data?.summary) aiMemo.set(id, data);
else aiErr.set(id, data?.error || `http_${res.status}`);
} catch {
aiErr.set(id, "network");
} finally {
aiPending.delete(id);
if (ndState?.kind === "wire" && ndState.id === id) renderNewsDlg();
}
}
function scoreTiles(h) {
const cls = v => v > 0.05 ? "buy" : v < -0.05 ? "sell" : "";
let tiles = tile("Overall sentiment", signed(h.score, 2), cls(h.score));
const xau = h.score_by_currency?.XAU;
if (xau != null) tiles += tile("Gold (XAU) effect", signed(xau, 2), cls(xau));
for (const [cur, v] of Object.entries(h.score_by_currency || {})) {
if (cur !== "XAU") tiles += tile(cur, signed(v, 2), cls(v));
}
let out = `<div class="d-stats">${tiles}</div>`;
if (h.keywords?.length) {
out += `<div class="nd-meta">Matched keywords: ${esc(h.keywords.join(", "))}</div>`;
}
out += `<div class="nd-meta">Scores range −1 (bearish) to +1 (bullish); the gold
score flips monetary-policy keywords (hawkish = dollar-bullish, gold-bearish).</div>`;
return out;
}
function aiSection(id) {
if (aiMemo.has(id)) {
const m = aiMemo.get(id);
return `<div class="d-note">${esc(m.summary)}</div>` + (m.generated_at
? `<div class="nd-meta">generated ${fmtAge(m.generated_at)}</div>` : "");
}
if (aiPending.has(id)) return `<div class="d-note">Generating gold-impact summary…</div>`;
if (aiErr.has(id)) {
return `<div class="d-note">${esc(AI_ERR_MSG[aiErr.get(id)] || "AI summary unavailable right now.")}</div>`;
}
return `<div class="d-note">Generating gold-impact summary…</div>`;
}
function wireDlgHTML() {
const fresh = findHeadline(ndState.id);
const h = fresh || ndState.snap;
if (!h) return "";
let host = "";
try { host = new URL(h.source).hostname.replace(/^www\./, ""); } catch {}
const when = h.timestamp
? `${new Date(h.timestamp).toLocaleString(undefined,
{dateStyle: "medium", timeStyle: "short"})} · ${fmtAge(h.timestamp)}` : "";
let out = `<div class="nd-headline">${esc(h.headline)}</div>
<div class="nd-meta">${esc(h.publisher || host)}${when ? " · " + when : ""}</div>`;
if (!fresh) out += `<div class="nd-meta">No longer in the live feed — showing saved copy.</div>`;
out += sec("Score", scoreTiles(h));
if (h.description) out += sec("From the source", `<div class="d-note">${esc(h.description)}</div>`);
out += sec("AI gold-impact summary", aiSection(ndState.id));
if (h.link && /^https?:\/\//.test(h.link)) {
out += `<a class="d-action" href="${esc(h.link)}" target="_blank"
rel="noopener noreferrer">Read full article ↗</a>`;
}
return out;
}
function calDlgHTML() {
const fresh = findEvent(ndState.key);
const e = fresh || ndState.snap;
if (!e) return "";
const t = Date.parse(e.time) / 1000;
const when = new Date(e.time).toLocaleString(undefined, {dateStyle: "full", timeStyle: "short"});
const impact = `<span class="imp ${esc(e.impact)}">${e.impact === "medium" ? "Med"
: e.impact.charAt(0).toUpperCase() + e.impact.slice(1)}</span>`;
let out = `<div class="nd-headline">${esc(e.event)}</div>
<div class="nd-meta">${esc(e.currency)} · ${when}</div>`;
/* live countdown only for FUTURE events — tick()'s modulo wrap on a
passed epoch would show a bogus restarting timer for released ones */
const timing = t > nowServer()
? `releases in <span class="num" data-close="${t}">--:--</span>`
: `released ${fmtAge(e.time)}`;
out += sec("Timing", `<div class="d-note">${impact} impact · ${timing}</div>`);
const surpTxt = {positive: "actual above forecast", negative: "actual below forecast",
inline: "actual matches forecast"}[e.surprise];
out += sec("Numbers", `<div class="d-stats">${
tile("Forecast", esc(e.forecast ?? "—")) +
tile("Previous", esc(e.previous ?? "—")) +
tile("Actual", esc(e.actual ?? "—"),
e.surprise === "positive" ? "buy" : e.surprise === "negative" ? "sell" : "")
}</div>` + (surpTxt
? `<div class="nd-meta">${surpTxt} (numeric only — not gold direction)</div>` : ""));
return out;
}
function renderNewsDlg() {
if (!ndState || !ndlg.open) return;
const scroll = $("nd-body").scrollTop;
$("nd-title").textContent = ndState.kind === "wire" ? "Newswire" : "Economic calendar";
$("nd-body").innerHTML = ndState.kind === "wire" ? wireDlgHTML() : calDlgHTML();
$("nd-body").scrollTop = scroll;
tick();
}
function openNewsDetail(id) {
const h = findHeadline(id);
if (!h) return;
ndState = {kind: "wire", id, snap: h};
aiErr.delete(id); // failures retry on reopen; successes are memoised
if (!ndlg.open) ndlg.showModal();
if (!aiMemo.has(id) && !aiPending.has(id)) fetchAiSummary(id);
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
function openCalDetail(key) {
const e = findEvent(key);
if (!e) return;
ndState = {kind: "cal", key, snap: e};
if (!ndlg.open) ndlg.showModal();
renderNewsDlg();
$("nd-body").scrollTop = 0;
$("nd-close").focus();
}
$("nd-close").addEventListener("click", () => ndlg.close());
ndlg.addEventListener("close", () => { ndState = null; });
ndlg.addEventListener("click", e => {
if (e.target !== ndlg) return;
const b = ndlg.getBoundingClientRect();
if (e.clientX < b.left || e.clientX > b.right || e.clientY < b.top || e.clientY > b.bottom) ndlg.close();
});
/* Delegated on the stable containers (renderWire/renderCalendar replace
innerHTML every poll). Deliberately NOT wireOpeners(): its details/
summary guard would swallow the 28 wire items nested in the
"more headlines" <details>. A click on that toggle itself finds no
[data-news-id] ancestor and falls through to the native behaviour. */
function wireNewsOpeners(host, attr, open) {
host.addEventListener("click", e => {
const el = e.target.closest(`[${attr}]`);
if (el) open(el.getAttribute(attr));
});
host.addEventListener("keydown", e => {
if (e.key !== "Enter" && e.key !== " ") return;
const el = e.target.closest?.(`[${attr}]`);
if (!el || e.target !== el) return; // let inner controls keep their own keys
e.preventDefault();
open(el.getAttribute(attr));
});
}
wireNewsOpeners($("wire"), "data-news-id", openNewsDetail);
wireNewsOpeners($("cal-body"), "data-ev-key", openCalDetail);
/* ── polling ──────────────────────────────────────────────────── */
async function fetchJSON(name) {
/* no-cache = revalidate with the server every time (If-Modified-Since);
@@ -1150,6 +1350,7 @@ async function poll() {
$("banner-fail").style.display = "none";
renderHero(); renderContext(); renderCards(); renderCalendar(); renderWire();
renderDetail(); // keep an open detail view in sync with the new scan
renderNewsDlg(); // same for an open news/calendar dialog
tick(); // countdowns appear immediately, not after the first 1 s tick
/* one-shot refetch shortly after the next expected publish, so a
+245
View File
@@ -0,0 +1,245 @@
<?php
/**
* On-demand gold-impact AI summary for one published headline.
*
* The ONLY dynamic piece of the otherwise-static dashboard. Serves
* GET ?id=<sha1-of-headline> and returns {"summary": ..., "generated_at": ...}.
*
* Abuse model (the page is public):
* - WHITELIST: the id must match a headline in the local news.json —
* arbitrary text can never reach the LLM through this endpoint.
* - CACHE FOREVER: headlines are immutable, so each id costs at most
* one DeepSeek call, ever (ai/<id>.json). Cache is checked BEFORE
* the whitelist on purpose: summaries stay reachable after the
* headline ages out of the 24 h feed window.
* - DAILY CAP: file-based UTC-day counter, FAIL CLOSED on counter I/O
* errors (a perms mistake must not mean unmetered spend).
*
* DeepSeek doctrine (mirrors llm_client.py — deliberate local copy, the
* dashboard never imports bot code and this file never reads the repo's
* config.json):
* - STATIC byte-identical system prompt (prefix-cache pricing).
* - "thinking": disabled is MANDATORY for V4-Flash (else it burns the
* timeout reasoning), temperature 0, json response_format.
* - One attempt, no retries; every failure returns a JSON error the
* page renders as a graceful fallback.
*
* Key file lives OUTSIDE the docroot: /etc/gold-dashboard/deepseek.key
* (root:apache 640). Override paths/caps via Apache SetEnv:
* DEEPSEEK_KEY_FILE, AI_SUMMARY_DAILY_CAP.
*/
const NEWS_FILE = __DIR__ . '/news.json';
const CACHE_DIR = __DIR__ . '/ai'; // apache-owned; created at deploy time
const API_URL = 'https://api.deepseek.com/chat/completions';
const MODEL = 'deepseek-v4-flash';
const TIMEOUT = 12;
const MAX_TOKENS = 200;
const SUMMARY_MAX_CHARS = 400;
// .ht_ prefix: Apache's default <Files ".ht*"> rule denies these even
// though they sit in the public docroot.
const CAP_FILE = CACHE_DIR . '/.ht_cap.json';
const SYSTEM_PROMPT = <<<'PROMPT'
You are a forex/gold news analyst. You receive one news item as JSON:
{"headline", "description", "score", "xau_score", "keywords", "currencies"}.
"xau_score" is the platform's automated gold-effect score for this item,
-1.0 (gold down) to 1.0 (gold up); "score" is overall market sentiment.
Both are automated and can be wrong — judge the news yourself.
In 1-2 plain-English sentences (max 45 words) explain what this news
means for the GOLD (XAU/USD) price and WHY it is bullish, bearish, or
neutral. If your reading clearly contradicts xau_score, add "(the
automated score disagrees)". Never write the words "xau_score" or
"score" — a general reader sees this text.
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
geopolitical stress / weak dollar are gold-bullish.
Reply with ONLY JSON: {"summary": "..."}
PROMPT;
function respond(int $status, array $obj, string $cache = 'no-store'): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: ' . $cache);
echo json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
function atomic_write(string $path, string $data): bool
{
$tmp = $path . '.tmp.' . getmypid();
if (@file_put_contents($tmp, $data) === false) {
return false;
}
if (!@rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
// ── 1. id validation ────────────────────────────────────────────────
$id = $_GET['id'] ?? '';
if (!preg_match('/^[a-f0-9]{40}$/', $id)) {
respond(400, ['error' => 'bad_id']);
}
// ── 2. cache first (immutable headlines → summaries never change) ───
$cachePath = CACHE_DIR . '/' . $id . '.json';
$cached = @file_get_contents($cachePath);
if ($cached !== false && $cached !== '') {
http_response_code(200);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: max-age=3600');
echo $cached;
exit;
}
// ── 3. whitelist against the published feed ─────────────────────────
$news = json_decode((string)@file_get_contents(NEWS_FILE), true);
if (!is_array($news)) {
respond(503, ['error' => 'no_feed']);
}
$hit = null;
foreach ($news['headlines'] ?? [] as $h) {
// Fallback sha1(): a publisher that predates the id field still
// serves a valid whitelist during the restart window.
$hid = $h['id'] ?? sha1($h['headline'] ?? '');
if ($hid === $id) {
$hit = $h;
break;
}
}
if ($hit === null) {
respond(404, ['error' => 'unknown_id',
'message' => 'Headline is no longer in the feed.']);
}
// ── 4. daily cap (fail closed) ──────────────────────────────────────
$dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150);
$capFh = @fopen(CAP_FILE, 'c+');
if ($capFh === false || !flock($capFh, LOCK_EX)) {
respond(503, ['error' => 'cap_io']);
}
$today = gmdate('Y-m-d');
$cap = json_decode((string)stream_get_contents($capFh), true);
if (!is_array($cap) || ($cap['day'] ?? '') !== $today) {
$cap = ['day' => $today, 'count' => 0];
}
if ($cap['count'] >= $dailyCap) {
flock($capFh, LOCK_UN);
fclose($capFh);
respond(429, ['error' => 'cap_exceeded']);
}
// Increment BEFORE the LLM call — attempts count against the cap
// (same semantics as llm_client._under_cap).
$cap['count']++;
rewind($capFh);
if (ftruncate($capFh, 0) === false
|| fwrite($capFh, json_encode($cap)) === false) {
flock($capFh, LOCK_UN);
fclose($capFh);
respond(503, ['error' => 'cap_io']);
}
fflush($capFh);
flock($capFh, LOCK_UN);
fclose($capFh);
// ── 5. API key ──────────────────────────────────────────────────────
$keyFile = getenv('DEEPSEEK_KEY_FILE') ?: '/etc/gold-dashboard/deepseek.key';
$apiKey = trim((string)@file_get_contents($keyFile));
if ($apiKey === '') {
respond(503, ['error' => 'no_key']);
}
// ── 6. per-id stampede lock, then re-check the cache ────────────────
$lockFh = @fopen(CACHE_DIR . '/.ht_lock_' . $id, 'c');
if ($lockFh !== false) {
flock($lockFh, LOCK_EX); // blocks while a concurrent winner generates
$cached = @file_get_contents($cachePath);
if ($cached !== false && $cached !== '') {
flock($lockFh, LOCK_UN);
fclose($lockFh);
http_response_code(200);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: max-age=3600');
echo $cached;
exit;
}
}
// ── 7. DeepSeek call (exact chat_json body shape) ───────────────────
$userPayload = json_encode([
'headline' => $hit['headline'] ?? '',
'description' => $hit['description'] ?? null,
'score' => $hit['score'] ?? null,
'xau_score' => $hit['score_by_currency']['XAU'] ?? null,
'keywords' => $hit['keywords'] ?? [],
'currencies' => $hit['currencies'] ?? [],
], JSON_UNESCAPED_UNICODE);
$body = json_encode([
'model' => MODEL,
'messages' => [
['role' => 'system', 'content' => SYSTEM_PROMPT],
['role' => 'user', 'content' => $userPayload],
],
'response_format' => ['type' => 'json_object'],
'temperature' => 0,
'thinking' => ['type' => 'disabled'],
'max_tokens' => MAX_TOKENS,
]);
$ch = curl_init(API_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: application/json',
'Authorization: Bearer ' . $apiKey],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => TIMEOUT,
]);
$raw = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// ── 8. parse (any failure → 502, never cached) ──────────────────────
$summary = null;
if ($raw !== false && $httpCode === 200) {
$resp = json_decode($raw, true);
$content = $resp['choices'][0]['message']['content'] ?? '';
$parsed = json_decode((string)$content, true);
if (is_array($parsed) && is_string($parsed['summary'] ?? null)
&& trim($parsed['summary']) !== '') {
$summary = trim($parsed['summary']);
if (mb_strlen($summary) > SUMMARY_MAX_CHARS) {
$summary = mb_substr($summary, 0, SUMMARY_MAX_CHARS) . '…';
}
}
}
if ($lockFh !== false && $summary === null) {
flock($lockFh, LOCK_UN);
fclose($lockFh);
}
if ($summary === null) {
respond(502, ['error' => 'llm_failed']);
}
// ── 9. cache + respond ──────────────────────────────────────────────
$out = json_encode(['summary' => $summary, 'generated_at' => gmdate('c')],
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!atomic_write($cachePath, $out)) {
// Degrade to per-click generation rather than a broken feature —
// the daily cap still bounds spend.
error_log('news_summary.php: cache write failed for ' . $cachePath);
}
if ($lockFh !== false) {
flock($lockFh, LOCK_UN);
fclose($lockFh);
}
http_response_code(200);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: max-age=3600');
echo $out;