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/.json). Cache is checked BEFORE * the whitelist on purpose: summaries stay reachable after the * headline ages out of the 4 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 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. Write for someone who has never traded: short simple sentences, no acronyms or abbreviations (say "the US central bank", not "the Fed"; "interest rates", not "yields"). 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(); $written = @file_put_contents($tmp, $data); // A SHORT write (disk full / quota) returns a byte count, NOT false. // Renaming that into place would publish a truncated JSON body which // the cache-first branch above then serves forever. if ($written === false || $written !== strlen($data)) { @unlink($tmp); 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) { $hid = $h['id'] ?? ''; if ($hid === $id) { $hit = $h; break; } } if ($hit === null) { respond(404, ['error' => 'unknown_id', 'message' => 'Headline is no longer in the feed.']); } // ── 4. 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']); } // ── 5. 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; } } $releaseLock = function () use (&$lockFh) { if ($lockFh !== false) { @flock($lockFh, LOCK_UN); @fclose($lockFh); $lockFh = false; } }; // ── 6. daily cap (fail closed) ────────────────────────────────────── // Counted HERE, not before the lock: a burst of clicks on one headline // makes exactly one DeepSeek call, and the losers are served from the // re-checked cache above — charging them against the budget (and // charging a request that then found no key) could exhaust the whole // 150/day allowance with zero LLM calls made. $dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150); $capFh = @fopen(CAP_FILE, 'c+'); if ($capFh === false || !flock($capFh, LOCK_EX)) { $releaseLock(); 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); $releaseLock(); 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); $releaseLock(); respond(503, ['error' => 'cap_io']); } fflush($capFh); flock($capFh, LOCK_UN); fclose($capFh); // ── 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;