07c5f9a5c2
- New oracle/ package (11 modules) with unified CLI (python -m oracle) - Source tiers: Tier 1 (arxiv/github/hf), Tier 2 (rss/hn), Tier 3 (reddit) - Composite verdicts: PUBLISH/WATCH/ARCHIVE/DROP based on signal score + age - Content-hash dedup: SHA-256[:16] normalized, atomic at insert time - Multi-variant editions: 4 YAML configs (default/research/devops/brief) - Variant engine: filter → rank → render (HTML + JSON, themed) - Per-adapter timeout (10s) + threading fallback - Consolidated 12 root scripts → thin wrappers + oracle/ package - Archived stale scripts (_engagement, _live_compare, reddit_proof) - Updated .gitignore, README.md, schema.sql
360 lines
14 KiB
Python
360 lines
14 KiB
Python
"""Render Athena entries into a static news site.
|
|
|
|
Two-layer layout: Top News (fresh today) + aging Stack (everything else).
|
|
Read-only against oracle.db. Designed for a 20-min cron run.
|
|
|
|
Variant-aware: render_variant_html() builds themed pages from pre-filtered items.
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
import html
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
from collections import OrderedDict
|
|
from typing import Optional
|
|
|
|
from oracle.clickability import fetch_items, compute_index, decay_index
|
|
from oracle.config import DB_PATH, WEBROOT, FALLBACK_WEBROOT, HALF_LIFE_H, TOP_N
|
|
|
|
|
|
def _clean_summary(raw):
|
|
"""Extract the most readable field from summary JSON."""
|
|
if not raw:
|
|
return ""
|
|
try:
|
|
d = json.loads(raw)
|
|
if isinstance(d, dict):
|
|
for k in ("one_liner", "key_technical_point", "potential_use_case"):
|
|
v = d.get(k)
|
|
if isinstance(v, str) and v.strip():
|
|
return re.sub(r"\\+|_|`", "", v).strip()
|
|
except Exception:
|
|
pass
|
|
return re.sub(r"\\+|_|`", "", raw).strip()
|
|
|
|
|
|
# ── Theme palette ──────────────────────────────────────────────────────────
|
|
|
|
THEMES = {
|
|
"dark": {
|
|
"--bg": "#0b0e14",
|
|
"--card": "#141925",
|
|
"--fg": "#e6e9ef",
|
|
"--mut": "#8b93a7",
|
|
"--border": "#1f2533",
|
|
},
|
|
"light": {
|
|
"--bg": "#f8f9fa",
|
|
"--card": "#ffffff",
|
|
"--fg": "#1a1a2e",
|
|
"--mut": "#6b7280",
|
|
"--border": "#e5e7eb",
|
|
},
|
|
"midnight": {
|
|
"--bg": "#0a0a1a",
|
|
"--card": "#111128",
|
|
"--fg": "#c8d0e0",
|
|
"--mut": "#5a6480",
|
|
"--border": "#1a1a3a",
|
|
},
|
|
}
|
|
|
|
|
|
def _theme_css(display: dict) -> str:
|
|
"""Generate CSS variables for a variant display config."""
|
|
theme_name = display.get("theme", "dark")
|
|
palette = THEMES.get(theme_name, THEMES["dark"])
|
|
accent = display.get("accent", "#5b8cff")
|
|
vars_list = ", ".join(f"{k}:{v}" for k, v in palette.items())
|
|
return f":root {{ {vars_list}; --acc:{accent}; }}"
|
|
|
|
|
|
def _fmt_time(first_seen):
|
|
if not first_seen:
|
|
return ""
|
|
try:
|
|
dt = datetime.datetime.strptime(first_seen, "%Y-%m-%dT%H:%M:%SZ")
|
|
return dt.strftime("%H:%M")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _card(it, big=False):
|
|
title = html.escape(it["title"] or "(untitled)")
|
|
url = html.escape(it["url"] or "#")
|
|
src = html.escape(it["source"])
|
|
t = _fmt_time(it.get("first_seen"))
|
|
summary_raw = _clean_summary(it.get("summary") or "")
|
|
summary = html.escape(summary_raw[:200])
|
|
cls = "card big" if big else "card"
|
|
summary_html = ('<p class="summary">{0}</p>'.format(summary)) if (summary and big) else ""
|
|
return f"""
|
|
<article class="{cls}" data-src="{src}">
|
|
<div class="meta"><span class="src">{src}</span>
|
|
<span class="time">{t}</span>
|
|
<span class="sig">sig {it.get('signal_score') or 0:.1f}</span>
|
|
<span class="score">\U0001f525 {it['clickability_decayed']:.2f}</span></div>
|
|
<h3><a href="{url}" target="_blank" rel="noopener">{title}</a></h3>
|
|
{summary_html}
|
|
</article>"""
|
|
|
|
|
|
def build_html(items):
|
|
"""Build the full HTML page from ranked items."""
|
|
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
ranked = sorted(items, key=lambda x: x["clickability_decayed"], reverse=True)
|
|
|
|
fresh = [it for it in ranked if it.get("fresh")]
|
|
top = fresh[:TOP_N]
|
|
stack = [it for it in ranked if it not in top]
|
|
|
|
by_day = OrderedDict()
|
|
for it in stack:
|
|
day = (it.get("first_seen") or "")[:10] or "unknown"
|
|
by_day.setdefault(day, []).append(it)
|
|
|
|
top_html = "".join(_card(it, big=True) for it in top)
|
|
|
|
stack_html = ""
|
|
for day, rows in by_day.items():
|
|
rows.sort(key=lambda x: x["clickability_decayed"], reverse=True)
|
|
cards = "".join(_card(it) for it in rows)
|
|
stack_html += f"""
|
|
<h3 class="day">\U0001f4c5 {html.escape(day)}</h3>
|
|
<div class="stack">{cards}</div>"""
|
|
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Athena AI News — Ranked by Clickability</title>
|
|
<style>
|
|
:root {{ --bg:#0b0e14; --card:#141925; --fg:#e6e9ef; --mut:#8b93a7; --acc:#5b8cff; }}
|
|
* {{ box-sizing:border-box; }}
|
|
body {{ margin:0; background:var(--bg); color:var(--fg);
|
|
font:15px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; }}
|
|
header {{ padding:28px 20px 14px; border-bottom:1px solid #1f2533; text-align:center; }}
|
|
header h1 {{ margin:0; font-size:28px; letter-spacing:.5px; }}
|
|
header .sub {{ color:var(--mut); font-size:13px; margin-top:6px; }}
|
|
main {{ max-width:1000px; margin:0 auto; padding:20px; }}
|
|
h2.sech {{ font-size:18px; margin:26px 0 12px; border-left:3px solid var(--acc); padding-left:10px; }}
|
|
.grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:14px; }}
|
|
.card {{ background:var(--card); border:1px solid #1f2533; border-radius:12px; padding:16px; }}
|
|
.card.big {{ grid-column:1/-1; }}
|
|
.meta {{ display:flex; gap:10px; align-items:center; font-size:12px; color:var(--mut); }}
|
|
.src {{ background:#1f2533; padding:2px 8px; border-radius:20px; text-transform:uppercase; }}
|
|
.score {{ color:#ff9d5b; font-weight:600; margin-left:auto; }}
|
|
.card h3 {{ font-size:16px; margin:10px 0 8px; line-height:1.35; }}
|
|
.card.big h3 {{ font-size:20px; }}
|
|
.card h3 a {{ color:var(--fg); text-decoration:none; }}
|
|
.card h3 a:hover {{ color:var(--acc); }}
|
|
.summary {{ color:var(--mut); font-size:13px; margin:0; }}
|
|
.day {{ font-size:15px; color:var(--mut); margin:28px 0 10px; border-bottom:1px solid #1f2533; padding-bottom:6px; }}
|
|
.stack {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:12px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Athena AI News</h1>
|
|
<div class="sub">Auto-ranked by Clickability Index · decays with age so the stack flows top → bottom · generated {now} · {len(items)} stories</div>
|
|
</header>
|
|
<main>
|
|
<h2 class="sech">\U0001f534 Top News</h2>
|
|
<div class="grid">{top_html}</div>
|
|
<h2 class="sech">\U0001f4f0 The Stack</h2>
|
|
{stack_html}
|
|
</main>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def render(dry_run=False, webroot=None):
|
|
"""Run the full render pipeline.
|
|
|
|
Returns (output_path, item_count).
|
|
"""
|
|
conn = sqlite3.connect(str(DB_PATH))
|
|
items = fetch_items(conn)
|
|
conn.close()
|
|
|
|
items = compute_index(items)
|
|
items = decay_index(items, HALF_LIFE_H)
|
|
page = build_html(items)
|
|
|
|
if dry_run:
|
|
out = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_preview.html")
|
|
with open(out, "w") as f:
|
|
f.write(page)
|
|
fresh = [it for it in items if it.get("fresh")]
|
|
top = sorted(fresh, key=lambda x: x["clickability_decayed"], reverse=True)[:TOP_N]
|
|
print(f"[dry-run] wrote {out} ({len(items)} items, {len(fresh)} fresh today)")
|
|
print(f"TOP {TOP_N} FRESH (today only) by decayed clickability:")
|
|
for i, it in enumerate(top, 1):
|
|
print(f" {i}. [{it['clickability_decayed']:.2f} | age {it['age_hours']:.0f}h] {it['source']:10} {it['title'][:55]}")
|
|
return out, len(items)
|
|
|
|
target = webroot or (WEBROOT if os.path.isdir(WEBROOT) else str(FALLBACK_WEBROOT))
|
|
os.makedirs(target, exist_ok=True)
|
|
|
|
with open(os.path.join(target, "index.html"), "w") as f:
|
|
f.write(page)
|
|
|
|
with open(os.path.join(target, "feed.json"), "w") as f:
|
|
json.dump([
|
|
{"title": i["title"], "url": i["url"], "source": i["source"],
|
|
"clickability_decayed": i["clickability_decayed"], "age_hours": i["age_hours"],
|
|
"first_seen": i.get("first_seen")}
|
|
for i in sorted(items, key=lambda x: x["clickability_decayed"], reverse=True)
|
|
], f, indent=2)
|
|
|
|
where = "WEBROOT" if target == WEBROOT else "fallback(~oracle/site)"
|
|
print(f"[render] wrote {target}/index.html ({len(items)} items) -> {where}")
|
|
return os.path.join(target, "index.html"), len(items)
|
|
|
|
|
|
# ── Variant rendering ──────────────────────────────────────────────────────
|
|
|
|
def _variant_card(it: dict, display: dict, big: bool = False) -> str:
|
|
"""Build an HTML card for a variant edition."""
|
|
title = html.escape(it.get("title") or "(untitled)")
|
|
url = html.escape(it.get("url") or "#")
|
|
src = html.escape(it.get("source", ""))
|
|
t = _fmt_time(it.get("first_seen"))
|
|
summary_raw = _clean_summary(it.get("summary") or "")
|
|
summary = html.escape(summary_raw[:200])
|
|
|
|
score = it.get("signal_score")
|
|
tier = it.get("source_tier")
|
|
verdict = it.get("verdict", "")
|
|
|
|
cls = "card big" if big else "card"
|
|
summary_html = ('<p class="summary">{}</p>'.format(summary)) if (summary and display.get("show_summary") and big) else ""
|
|
|
|
# Build meta badges
|
|
badges = f'<span class="src">{src}</span>'
|
|
if t:
|
|
badges += f'<span class="time">{t}</span>'
|
|
if display.get("show_score") and score is not None:
|
|
badges += f'<span class="sig">sig {float(score):.1f}</span>'
|
|
if display.get("show_tier") and tier is not None:
|
|
badges += f'<span class="tier">T{tier}</span>'
|
|
if display.get("show_verdict") and verdict:
|
|
badges += f'<span class="verdict verdict-{verdict.lower()}">{verdict}</span>'
|
|
|
|
return f"""
|
|
<article class="{cls}">
|
|
<div class="meta">{badges}</div>
|
|
<h3><a href="{url}" target="_blank" rel="noopener">{title}</a></h3>
|
|
{summary_html}
|
|
</article>"""
|
|
|
|
|
|
def render_variant_html(items: list[dict], variant: dict) -> str:
|
|
"""Build the full HTML page for a variant edition."""
|
|
display = variant.get("display", {})
|
|
name = variant.get("name", "Athena")
|
|
desc = variant.get("description", "")
|
|
logo = display.get("logo", "🏛️")
|
|
top_n = display.get("top_n", 8)
|
|
half_life = variant.get("ranking", {}).get("half_life_h", 18)
|
|
|
|
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
|
|
top = items[:top_n]
|
|
stack = items[top_n:]
|
|
|
|
# Group stack by day
|
|
by_day = OrderedDict()
|
|
for it in stack:
|
|
day = (it.get("first_seen") or "")[:10] or "unknown"
|
|
by_day.setdefault(day, []).append(it)
|
|
|
|
top_html = "\n".join(_variant_card(it, display, big=True) for it in top)
|
|
stack_html = ""
|
|
for day, rows in by_day.items():
|
|
cards = "".join(_variant_card(it, display) for it in rows)
|
|
stack_html += f"""
|
|
<h3 class="day">🗒️ {html.escape(day)}</h3>
|
|
<div class="stack">{cards}</div>"""
|
|
|
|
theme = _theme_css(display)
|
|
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{html.escape(name)}</title>
|
|
<style>
|
|
{theme}
|
|
* {{ box-sizing:border-box; }}
|
|
body {{ margin:0; background:var(--bg); color:var(--fg);
|
|
font:15px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; }}
|
|
header {{ padding:28px 20px 14px; border-bottom:1px solid var(--border); text-align:center; }}
|
|
header h1 {{ margin:0; font-size:28px; letter-spacing:.5px; }}
|
|
header .sub {{ color:var(--mut); font-size:13px; margin-top:6px; }}
|
|
main {{ max-width:1000px; margin:0 auto; padding:20px; }}
|
|
h2.sech {{ font-size:18px; margin:26px 0 12px; border-left:3px solid var(--acc); padding-left:10px; }}
|
|
.grid {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:14px; }}
|
|
.card {{ background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px; }}
|
|
.card.big {{ grid-column:1/-1; }}
|
|
.meta {{ display:flex; gap:10px; align-items:center; font-size:12px; color:var(--mut); flex-wrap:wrap; }}
|
|
.src {{ background:var(--border); padding:2px 8px; border-radius:20px; text-transform:uppercase; }}
|
|
.sig {{ color:var(--acc); font-weight:600; }}
|
|
.tier {{ color:var(--mut); }}
|
|
.verdict {{ padding:2px 6px; border-radius:4px; font-weight:600; text-transform:uppercase; font-size:10px; }}
|
|
.verdict-publish {{ background:#065f46; color:#a7f3d0; }}
|
|
.verdict-watch {{ background:#1e3a5f; color:#93c5fd; }}
|
|
.verdict-archive {{ background:#4a3b1f; color:#fcd34d; }}
|
|
.verdict-drop {{ background:#4a1f1f; color:#fca5a5; }}
|
|
.card h3 {{ font-size:16px; margin:10px 0 8px; line-height:1.35; }}
|
|
.card.big h3 {{ font-size:20px; }}
|
|
.card h3 a {{ color:var(--fg); text-decoration:none; }}
|
|
.card h3 a:hover {{ color:var(--acc); }}
|
|
.summary {{ color:var(--mut); font-size:13px; margin:0; }}
|
|
.day {{ font-size:15px; color:var(--mut); margin:28px 0 10px; border-bottom:1px solid var(--border); padding-bottom:6px; }}
|
|
.stack {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:12px; }}
|
|
footer {{ text-align:center; padding:20px; color:var(--mut); font-size:12px; border-top:1px solid var(--border); margin-top:40px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>{logo} {html.escape(name)}</h1>
|
|
<div class="sub">{html.escape(desc)} · generated {now} · {len(items)} stories</div>
|
|
</header>
|
|
<main>
|
|
<h2 class="sech">🔥 Top News</h2>
|
|
<div class="grid">{top_html}</div>
|
|
{('<h2 class="sech">🗄️ The Stack</h2>' + stack_html) if stack else ''}
|
|
</main>
|
|
<footer>
|
|
Athena AI Research Oracle · <a href="feed.json">feed.json</a>
|
|
</footer>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def render_variant_json(items: list[dict], variant: dict, path: str) -> None:
|
|
"""Write a JSON feed for a variant edition."""
|
|
display = variant.get("display", {})
|
|
feed = []
|
|
for i in items:
|
|
entry = {
|
|
"title": i.get("title", ""),
|
|
"url": i.get("url", ""),
|
|
"source": i.get("source", ""),
|
|
"signal_score": float(i.get("signal_score") or 0),
|
|
"first_seen": i.get("first_seen", ""),
|
|
}
|
|
if display.get("show_tier"):
|
|
entry["source_tier"] = i.get("source_tier")
|
|
if display.get("show_verdict"):
|
|
entry["verdict"] = i.get("verdict", "")
|
|
feed.append(entry)
|
|
|
|
with open(path, "w") as f:
|
|
json.dump(feed, f, indent=2)
|