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:
Harwinder Sekhon
2026-08-07 09:08:17 -04:00
parent edefd90aaa
commit d1d002a4f7
7 changed files with 745 additions and 33 deletions
+63
View File
@@ -9,6 +9,7 @@ A perfect technical setup can be destroyed by an unexpected NFP print.
"""
import hashlib
import html
import logging
import re
import threading
@@ -103,6 +104,44 @@ def _parse_release_value(raw: Optional[str]) -> Optional[float]:
return float(match.group(1)) * multiplier
_DESC_MAX_CHARS = 500
_MAX_LINK_CHARS = 2048
def _clean_html_text(raw: Optional[str],
limit: int = _DESC_MAX_CHARS) -> Optional[str]:
"""RSS <description> → plain text: unescape entities, strip tags,
collapse whitespace, truncate. Unescape and strip both run twice
because feeds routinely double-encode (&amp;lt;p&amp;gt; wrapping
real markup) — the second unescape can reveal fresh tags."""
if not raw:
return None
text = html.unescape(raw)
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
if not text:
return None
if len(text) > limit:
text = text[:limit].rstrip() + "…"
return text
def _safe_link(raw: Optional[str]) -> Optional[str]:
"""Article URL for display: http(s) only (an RSS <link> is untrusted
and lands in an href), bounded length (Google News redirect URLs run
1-2 KB; anything past that is bloat, not a link)."""
if not raw:
return None
link = raw.strip()
if not link.lower().startswith(("http://", "https://")):
return None
if len(link) > _MAX_LINK_CHARS:
return None
return link
@dataclass
class EconomicEvent:
"""Single economic calendar event."""
@@ -155,6 +194,11 @@ class NewsSentiment:
# Per-currency score: monetary-policy keywords flip sign for XAU
# (hawkish is dollar-bullish but gold-bearish).
sentiment_by_currency: Dict[str, float] = field(default_factory=dict)
# Display-only enrichment (dashboard detail dialog). Optional with
# defaults so existing constructor calls keep working.
link: Optional[str] = None # article URL (Google News = redirect)
description: Optional[str] = None # plain text, tags stripped, ≤500 chars
publisher: Optional[str] = None # Google News <source> text, else None
@dataclass
@@ -742,6 +786,21 @@ class NewsEngine:
currencies = self._extract_currencies(title)
if currencies:
link_el = item.find("link")
desc_el = item.find("description")
src_el = item.find("source")
# itertext() survives CDATA and stray child
# elements where .text stops at the first child.
desc = _clean_html_text(
"".join(desc_el.itertext())
if desc_el is not None else None)
# Google News descriptions are often just the
# headline again inside an anchor — worthless.
if desc and desc.strip() == title.strip():
desc = None
publisher = None
if src_el is not None:
publisher = (src_el.text or "").strip() or None
headlines.append(NewsSentiment(
headline=title,
source=feed_url,
@@ -753,6 +812,10 @@ class NewsEngine:
cur: self._analyze_headline_sentiment(title, cur)
for cur in currencies
},
link=_safe_link(
link_el.text if link_el is not None else None),
description=desc,
publisher=publisher,
))
ok_feeds += 1