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 Claude-Session: https://claude.ai/code/session_01Aq1jZ6jBsBdEC2xT3u46SA
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user