d1d002a4f7
- 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
246 lines
9.5 KiB
PHP
246 lines
9.5 KiB
PHP
<?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;
|