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
+130
View File
@@ -5295,6 +5295,97 @@ def _():
NewsEngine._source_health = saved
@test("NewsEngine: RSS parser captures link/description/publisher")
def _():
cfg = NewsConfig()
cfg.rss_feeds = ["https://news.google.com/rss/search?q=gold"]
ne = NewsEngine(cfg)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
# Google-News-shaped item: redirect <link>, HTML-escaped <description>,
# <source> carrying the real publisher.
rss = (b"<rss><channel><item>"
b"<title>Gold surges as Fed signals rate cuts - Reuters</title>"
b"<link>https://news.google.com/rss/articles/CBMiAbC?oc=5</link>"
b"<description>&lt;a href=\"https://x\"&gt;Gold &amp;amp; dollar "
b"diverge as the Fed turns dovish.&lt;/a&gt;</description>"
b"<source url=\"https://www.reuters.com\">Reuters</source>"
b"</item></channel></rss>")
class _Sess:
def get(self, url, timeout=None):
return type("R", (), {"status_code": 200, "content": rss})()
ne._session = _Sess()
try:
heads = ne._fetch_news_sentiment_raw()
assert len(heads) == 1, heads
h = heads[0]
assert h.link == "https://news.google.com/rss/articles/CBMiAbC?oc=5"
# tags stripped, entities (incl. double-encoded &amp;amp;) decoded
assert h.description == "Gold & dollar diverge as the Fed turns dovish."
assert h.publisher == "Reuters"
finally:
NewsEngine._source_health = saved
@test("NewsEngine: enrichment fields default None / reject unsafe values")
def _():
cfg = NewsConfig()
cfg.rss_feeds = ["https://feed.example/rss"]
ne = NewsEngine(cfg)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
long_link = b"https://a.example/" + b"x" * 2100
rss = (b"<rss><channel>"
# bare item: no link/description/source at all
b"<item><title>Dollar steady before Fed minutes</title></item>"
# javascript: link + description that merely echoes the title
b"<item><title>Gold slips on strong dollar data</title>"
b"<link>javascript:alert(1)</link>"
b"<description>Gold slips on strong dollar data</description></item>"
# over-long link
b"<item><title>Fed officials split on rate cuts</title>"
b"<link>" + long_link + b"</link></item>"
b"</channel></rss>")
class _Sess:
def get(self, url, timeout=None):
return type("R", (), {"status_code": 200, "content": rss})()
ne._session = _Sess()
try:
heads = ne._fetch_news_sentiment_raw()
assert len(heads) == 3, heads
by_title = {h.headline: h for h in heads}
bare = by_title["Dollar steady before Fed minutes"]
assert bare.link is None and bare.description is None \
and bare.publisher is None
js = by_title["Gold slips on strong dollar data"]
assert js.link is None # non-http(s) scheme rejected
assert js.description is None # identical to title → dropped
assert by_title["Fed officials split on rate cuts"].link is None
finally:
NewsEngine._source_health = saved
@test("NewsEngine: _clean_html_text strips/unescapes/truncates")
def _():
from news_engine import _clean_html_text, _safe_link
assert _clean_html_text(None) is None
assert _clean_html_text("") is None
assert _clean_html_text("<p></p>") is None
assert _clean_html_text("<p>Fed <b>holds</b>\n rates</p>") == "Fed holds rates"
assert _clean_html_text("&amp;lt;b&amp;gt;x&amp;lt;/b&amp;gt; &amp; y") == "x & y"
long = _clean_html_text("a" * 600)
assert len(long) == 501 and long.endswith("…")
assert _safe_link(" https://a.example/x ") == "https://a.example/x"
assert _safe_link("ftp://a.example/x") is None
assert _safe_link("javascript:alert(1)") is None
assert _safe_link("https://" + "x" * 2100) is None
assert _safe_link(None) is None
@test("/status renders news/calendar/AI health with ⚠️ degradations")
def _():
import telegram_bot as tb
@@ -5540,6 +5631,45 @@ def _():
assert payload["headlines"][1]["score"] is None # NaN → None
assert payload["events"][0]["event"] == "ISM"
assert payload["sentiment"] == {"XAU": 0.6, "USD": -0.1}
# Every headline carries the 40-hex id the PHP endpoint whitelists on.
import re as _re
assert all(_re.fullmatch(r"[0-9a-f]{40}", h["id"])
for h in payload["headlines"])
@test("dashboard: headline_to_dict id matches LLM cache scheme + enrichment")
def _():
import hashlib
h = NewsSentiment(
headline="Gold surges", source="http://feed/b",
timestamp=datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc),
currencies_mentioned=["XAU"], sentiment_score=0.6,
keywords=["surge"], sentiment_by_currency={"XAU": 0.6},
link="https://x.example/a", description="Body.", publisher="Reuters")
d = dpub.headline_to_dict(h)
# Same sha1(headline) scheme as NewsEngine._llm_sent_cache — the id is
# the join key news_summary.php validates against.
assert d["id"] == hashlib.sha1(b"Gold surges").hexdigest()
assert d["link"] == "https://x.example/a"
assert d["description"] == "Body."
assert d["publisher"] == "Reuters"
# Pre-enrichment NewsSentiment (defaults) still serializes cleanly.
bare = NewsSentiment(
headline="Old", source="s", timestamp=None,
currencies_mentioned=[], sentiment_score=0.0, keywords=[])
d2 = dpub.headline_to_dict(bare)
assert d2["link"] is None and d2["description"] is None \
and d2["publisher"] is None
json.dumps([d, d2], allow_nan=False)
@test("dashboard: install_static installs page + PHP endpoint atomically")
def _():
with tempfile.TemporaryDirectory() as tmp:
pub = dpub.DashboardPublisher(out_dir=tmp)
pub.install_static()
assert sorted(os.listdir(tmp)) == \
["dashboard.html", "news_summary.php"] # both installed, no .tmp
@test("dashboard: publish is atomic and leaves no temp file")