59f69400a8
None of these touch the signal path. The 12-month M15 book (2024-03-31..2025-03-30, $0.65, cached Oanda bars) is byte-identical before and after (md5 c59d3e7e685c3c880b614640adfea74b, 375 trades), and signal/indicator logging to logs/signals.db is unchanged — verified end to end: per-TF indicators for all 3 TFs, 14 candles each, MTF and reasons all still persisted. agent_panel: the >=2-take quorum guards the CHAIR's synthesizer spend, but ran BEFORE the macro branch — so the shipped default (verdict_source="macro", which never calls the synthesizer) silently dropped a good macro verdict whenever the other three personas were slow. Quorum moved below the macro path. analyze_signal_log: _forward_return clamped to the last available bar when the horizon had not elapsed, reporting a near-zero "forward move" for every row logged within `hours` of the end of the data — on every run, blended into the hit-rate/mean-move aggregate. Now returns None. analyze_signal_log: persona_scorecard iterated raw rows through an index that maps EVERY member row of an event, so a re-logged scan appended the same event R twice. One vote per event, matching news_outcome_buckets. Measured inflation on the live DB: 1.03x. signal_log: dedup required a non-negative logged_at gap, but logged_at is stamped before the row wins the write lock (busy_timeout 5 s), so two processes logging one setup at a boundary can invert timestamp order against id order and seed a second event. Now abs(gap); same in backfill_seeds. Zero occurrences in the current 366 setup rows. advisory_outcome: the catch-all turned real evaluator faults into the same UNRESOLVABLE used for legitimate weekend gaps, with nothing in the log. Still fail-safe, now logged. llm_client: only the "cap" warn-once flag was ever reset, so in a daemon the second and every later network/http/parse outage logged at DEBUG only. Cleared on UTC-day roll and re-armed by a recovered call. dashboard.js: renderCards() redraws the selector for every timeframe but was gated on the SELECTED one changing, leaving the other buttons stale (up to 4 h on H4) while renderHero's rail already showed the new value. news_summary.php: atomic_write treated a short write (disk full) as success, publishing a truncated body the cache-first branch then served forever; and the daily cap was charged before the no-key check and before the stampede lock re-checked the cache, so a click burst could exhaust the 150/day budget with zero LLM calls. migrate_signal_log_news: logged_at is ISO with 'T' while SQLite's datetime() uses a space, so string-comparing them put the whole cutoff calendar day on the wrong side and it never pruned. NOTE: this now deletes one day more than before — it only runs when invoked by hand. Tests 414 -> 422 (new Module 28, one per fix). The existing "<2 valid takes" test was retargeted at the chair — the case it actually protects — rather than deleted, with new coverage for macro-alone. Not fixed, recorded for the owner: - Gate 6c's boost condition is textually identical to the C-grade definition, so every C in a "transitioning" regime is promoted to B before gate 6e reads it, making sell_min_quality's exclusion of "C" a no-op on M15/H4 (H1 and M5 are immune by other means). The backtester runs the same code, so every pinned book already includes this — changing it is a rule-1 revalidation event. - Safe-haven/geopolitical headlines score exactly 0.00 in the keyword fallback and are now dropped entirely by the ±0.1 neutral filter, contradicting the platform's own gold doctrine. Bites only when DeepSeek is capped/disabled/failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ah5EB1goi5inQmSd37Ne6a
269 lines
10 KiB
PHP
269 lines
10 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. 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) {
|
|
// 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. 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;
|