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
141 lines
4.1 KiB
Python
141 lines
4.1 KiB
Python
"""Recency guard — Athena "how old is this news?" gate.
|
|
|
|
Age is the dominant gate. TODAY's items are always eligible.
|
|
Older items are eligible ONLY if never posted before.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
from datetime import datetime, timezone
|
|
|
|
from oracle.config import SEEN_JSON
|
|
|
|
ORACLE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _parse(ts):
|
|
if not ts:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _norm_url(u):
|
|
if not u:
|
|
return ""
|
|
return u.split("?")[0].split("#")[0].rstrip("/").lower()
|
|
|
|
|
|
def _norm_title(t):
|
|
if not t:
|
|
return ""
|
|
t = t.lower()
|
|
t = re.sub(r"[^a-z0-9 ]", " ", t)
|
|
return re.sub(r"\s+", " ", t).strip()[:60]
|
|
|
|
|
|
def load_posted(md_dir: str = ORACLE_DIR, seen_json: str = str(SEEN_JSON)):
|
|
"""Return (md_urls:set, md_titles:set, seen_urls:set)."""
|
|
md_urls, md_titles, seen_urls = set(), set(), set()
|
|
|
|
for fn in sorted(os.listdir(md_dir)):
|
|
if re.match(r"athena_top.*\.md$", fn):
|
|
try:
|
|
txt = open(os.path.join(md_dir, fn), encoding="utf-8", errors="replace").read()
|
|
except OSError:
|
|
continue
|
|
for m in re.findall(r"\]\((https?://[^)\s]+)\)", txt):
|
|
nu = _norm_url(m)
|
|
if nu:
|
|
md_urls.add(nu)
|
|
for t in re.findall(r"^\|\s*\d+\s*\|\s*(.+?)\s*\|", txt, re.M):
|
|
nt = _norm_title(t)
|
|
if nt:
|
|
md_titles.add(nt)
|
|
|
|
if os.path.exists(seen_json):
|
|
try:
|
|
with open(seen_json, encoding="utf-8") as f:
|
|
for u in json.load(f):
|
|
nu = _norm_url(u)
|
|
if nu:
|
|
seen_urls.add(nu)
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
return md_urls, md_titles, seen_urls
|
|
|
|
|
|
def is_today(first_seen, now=None):
|
|
now = now or datetime.now(timezone.utc)
|
|
d = _parse(first_seen)
|
|
return bool(d) and d.strftime("%Y-%m-%d") == now.strftime("%Y-%m-%d")
|
|
|
|
|
|
def age_days(first_seen, now=None):
|
|
now = now or datetime.now(timezone.utc)
|
|
d = _parse(first_seen)
|
|
if not d:
|
|
return 9999.0
|
|
return max((now - d).total_seconds() / 86400.0, 0.0)
|
|
|
|
|
|
def day_bucket(first_seen, now=None):
|
|
days = age_days(first_seen, now)
|
|
if days < 1:
|
|
return "today"
|
|
if days < 2:
|
|
return "yesterday"
|
|
if days <= 6:
|
|
return "this-week"
|
|
return "older"
|
|
|
|
|
|
def already_posted(url, title, first_seen, now=None,
|
|
md_urls=None, md_titles=None, seen_urls=None):
|
|
"""Age-aware dedup. Today's items are NEVER flagged."""
|
|
if md_urls is None or md_titles is None or seen_urls is None:
|
|
md_urls, md_titles, seen_urls = load_posted()
|
|
if _norm_url(url) in md_urls:
|
|
return True
|
|
nt = _norm_title(title)
|
|
if nt and nt in md_titles:
|
|
return True
|
|
if is_today(first_seen, now):
|
|
return False
|
|
if _norm_url(url) in seen_urls:
|
|
return True
|
|
return False
|
|
|
|
|
|
def recency_weight(first_seen, now=None, half_life_days=2.0):
|
|
"""1.0 for today, decays ~halving every 2 days."""
|
|
return 0.5 ** (age_days(first_seen, now) / half_life_days)
|
|
|
|
|
|
def blend_score(item, now=None):
|
|
"""clickability_decayed * recency_weight."""
|
|
base = item.get("clickability_decayed", 0) or 0
|
|
return base * recency_weight(item.get("first_seen"), now)
|
|
|
|
|
|
def filter_fresh(items, now=None, recent_window_days=4.0):
|
|
"""Split into (today_items, older_new_items, dropped_items)."""
|
|
now = now or datetime.now(timezone.utc)
|
|
md_urls, md_titles, seen_urls = load_posted()
|
|
today_items, older_new, dropped = [], [], []
|
|
for it in items:
|
|
fs = it.get("first_seen")
|
|
if is_today(fs, now):
|
|
today_items.append(it)
|
|
continue
|
|
posted = already_posted(it.get("url"), it.get("title"), fs, now,
|
|
md_urls, md_titles, seen_urls)
|
|
if posted and age_days(fs, now) > recent_window_days:
|
|
dropped.append(it)
|
|
else:
|
|
older_new.append(it)
|
|
return today_items, older_new, dropped
|