Sprint 3: Anti-bot retrieval layer + metrics module

Anti-bot changes (all 6 adapters):
- Browser-grade User-Agent rotation (Chrome/Firefox on Linux/Windows)
- Shared browser_headers() with Accept, Accept-Language, DNT
- Session-consistent UA fingerprint (picked once, not per-request)
- jitter_sleep() replaces fixed time.sleep() on all adapters
- Exponential backoff on 429/503 already on reddit, now consistent

New shared module:
- adapters/__init__.py: browser_user_agent(), browser_headers(), jitter_sleep()
- adapters/_http.py: HTTPClient class for future browser-mode adapters

Metrics module (from Sprint 2 carry):
- oracle/metrics.py: MetricsRun for log_adapter/log_verdicts/log_scores
- oracle/weekly.py: SYSTEM HEALTH section wired to adapter_health
- oracle/cli.py: metrics subparser with --adapters/--publish/--scores/--alerts

Before: bot signatures like 'ai-oracle/0.1', 'python:athena:v0.1'
After: 'Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0'
This commit is contained in:
Epictetus
2026-07-22 14:26:42 +00:00
parent 3ec955e143
commit 641d531d88
11 changed files with 694 additions and 21 deletions
+45
View File
@@ -1,10 +1,55 @@
"""Source adapters for AI Research Oracle.""" """Source adapters for AI Research Oracle."""
import random
import urllib.request import urllib.request
import urllib.error import urllib.error
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
# --- Browser-grade headers (anti-bot) ---
# Picked once per session so fingerprint stays consistent.
_SESSION_UA: str | None = None
_BROWSER_UAS = [
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0",
]
def browser_user_agent() -> str:
"""Return a realistic browser User-Agent (same for the session)."""
global _SESSION_UA
if _SESSION_UA is None:
_SESSION_UA = random.choice(_BROWSER_UAS)
return _SESSION_UA
def browser_headers(api_mode: bool = False) -> dict:
"""Build realistic browser headers for adapter requests.
Args:
api_mode: If True, use Accept: application/json (for JSON APIs).
"""
headers: dict = {
"User-Agent": browser_user_agent(),
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
}
if api_mode:
headers["Accept"] = "application/json, text/json, */*;q=0.8"
else:
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
return headers
def jitter_sleep(base: float = 1.0, range_frac: float = 0.3) -> None:
"""Sleep for base ± range_frac fraction to break mechanical patterns."""
actual = base + base * range_frac * (2 * random.random() - 1)
time.sleep(actual)
class SourceAdapter(ABC): class SourceAdapter(ABC):
"""Base class for all ingestion adapters.""" """Base class for all ingestion adapters."""
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Shared HTTP utilities for Athena adapters.
Purpose: Make adapter traffic look like real browser requests
instead of bot signatures. Centralized so all adapters benefit.
Features:
- Realistic User-Agent rotation (Chrome/Firefox/Safari on Linux/Windows/macOS)
- Standard browser headers (Accept, Accept-Language, DNT)
- Jittered sleep to break mechanical timing patterns
- Exponential backoff on 429/503 responses
"""
import random
import time
import urllib.request
import urllib.error
# Realistic User-Agent strings — rotated per session
USER_AGENTS = [
# Chrome on Linux
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
# Chrome on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
# Firefox on Linux
"Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0",
# Firefox on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0",
# Safari on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15",
]
# Standard browser headers that every real request includes
BROWSER_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
class HTTPClient:
"""Browser-like HTTP client for adapters.
Usage:
client = HTTPClient(name="arxiv")
data = client.get("https://example.com/api")
client.jitter_sleep(3) # polite spacing with randomness
"""
def __init__(self, name: str = "athena", api_mode: bool = False):
"""
Args:
name: Adapter name for User-Agent identification fallback.
api_mode: If True, use Accept: application/json headers
(for JSON APIs). If False, use browser-like HTML headers.
"""
self.name = name
self.api_mode = api_mode
# Pick a User-Agent once per session — avoids fingerprint rotation
# which is MORE suspicious than sticking to one identity.
self.user_agent = random.choice(USER_AGENTS)
def _headers(self) -> dict:
"""Build headers dict for a request."""
headers = {"User-Agent": self.user_agent}
if self.api_mode:
headers.update({
"Accept": "application/json, text/json, */*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
})
else:
headers.update(BROWSER_HEADERS)
return headers
def get(self, url: str, extra_headers: dict | None = None,
timeout: int = 30, max_retries: int = 2,
backoff_base: float = 2.0) -> bytes | None:
"""Make a GET request with browser-like headers and retry/backoff.
Args:
url: Request URL.
extra_headers: Additional headers to merge in.
timeout: Request timeout in seconds.
max_retries: Max retry attempts on 429/503.
backoff_base: Base seconds for exponential backoff (2^n * base).
Returns:
Response body bytes, or None on persistent failure.
"""
headers = self._headers()
if extra_headers:
headers.update(extra_headers)
req = urllib.request.Request(url, headers=headers)
for attempt in range(max_retries + 1):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except urllib.error.HTTPError as e:
if e.code in (429, 503):
wait = backoff_base ** (attempt + 1) + random.uniform(0, 1)
if attempt < max_retries:
print(f" HTTP {e.code}, retrying in {wait:.1f}s")
time.sleep(wait)
continue
print(f" HTTP {e.code} after {max_retries} retries, giving up")
return None
# Other HTTP errors — don't retry
print(f" HTTP {e.code} for {url[:80]}")
return None
except urllib.error.URLError as e:
if attempt < max_retries:
wait = backoff_base ** (attempt + 1)
print(f" URLError: {e.reason}, retrying in {wait:.1f}s")
time.sleep(wait)
continue
print(f" URLError after retries: {e.reason}")
return None
except Exception as e:
print(f" Request error: {e}")
return None
return None
def get_json(self, url: str, extra_headers: dict | None = None,
timeout: int = 30, max_retries: int = 2) -> dict | list | None:
"""Make a GET request and parse JSON response."""
import json
data = self.get(url, extra_headers=extra_headers, timeout=timeout,
max_retries=max_retries)
if data is None:
return None
try:
return json.loads(data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
print(f" JSON decode error: {e}")
return None
@staticmethod
def jitter_sleep(base_seconds: float, jitter_range: float = 0.3) -> None:
"""Sleep for base_seconds ± jitter_range fraction.
Breaks mechanical timing patterns. Default ±30% jitter.
Args:
base_seconds: Base sleep duration.
jitter_range: Fraction of base to randomize (0.3 = ±30%).
"""
jitter = base_seconds * jitter_range * (2 * random.random() - 1)
actual = base_seconds + jitter
time.sleep(actual)
+4 -3
View File
@@ -32,9 +32,10 @@ import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from html import unescape from html import unescape
from adapters import SourceAdapter from adapters import SourceAdapter, browser_headers, jitter_sleep
from adapters._store import true_first_seen, upsert_entries from adapters._store import true_first_seen, upsert_entries
# arXiv API # arXiv API
ARXIV_API = "http://export.arxiv.org/api/query" ARXIV_API = "http://export.arxiv.org/api/query"
@@ -152,7 +153,7 @@ class ArxivAdapter(SourceAdapter):
f"&max_results={max_results}" f"&max_results={max_results}"
) )
req = urllib.request.Request(url, headers={"User-Agent": "ai-oracle/0.1"}) req = urllib.request.Request(url, headers=browser_headers())
try: try:
with urllib.request.urlopen(req, timeout=30) as resp: with urllib.request.urlopen(req, timeout=30) as resp:
@@ -389,7 +390,7 @@ class ArxivAdapter(SourceAdapter):
q = f"cat:{cat}" q = f"cat:{cat}"
cat_papers = self._request(q, max_results=limit, sort_by="submittedDate") cat_papers = self._request(q, max_results=limit, sort_by="submittedDate")
all_papers.extend(cat_papers) all_papers.extend(cat_papers)
time.sleep(self.rate_limit) jitter_sleep(self.rate_limit)
# Deduplicate by arxiv_id # Deduplicate by arxiv_id
seen = set() seen = set()
+4 -4
View File
@@ -17,7 +17,7 @@ import urllib.error
import urllib.parse import urllib.parse
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from adapters import SourceAdapter from adapters import SourceAdapter, browser_user_agent, jitter_sleep
from adapters._store import true_first_seen, upsert_entries from adapters._store import true_first_seen, upsert_entries
@@ -37,7 +37,7 @@ class GitHubAdapter(SourceAdapter):
def _headers(self): def _headers(self):
headers = { headers = {
"Accept": "application/vnd.github.v3+json", "Accept": "application/vnd.github.v3+json",
"User-Agent": "ai-oracle/0.1", "User-Agent": browser_user_agent(),
} }
if self.token: if self.token:
headers["Authorization"] = f"token {self.token}" headers["Authorization"] = f"token {self.token}"
@@ -166,7 +166,7 @@ class GitHubAdapter(SourceAdapter):
]: ]:
batch = self._search_repos(q, sort="stars", per_page=30) batch = self._search_repos(q, sort="stars", per_page=30)
repos.extend(batch) repos.extend(batch)
time.sleep(1) # polite spacing jitter_sleep(1) # polite spacing
# Deduplicate by full_name # Deduplicate by full_name
seen = set() seen = set()
@@ -234,7 +234,7 @@ class GitHubAdapter(SourceAdapter):
readme_text = "" readme_text = ""
if owner and repo_name and idx < readme_budget: if owner and repo_name and idx < readme_budget:
readme_text = self._get_readme(f"https://api.github.com/repos/{owner}/{repo_name}/readme") readme_text = self._get_readme(f"https://api.github.com/repos/{owner}/{repo_name}/readme")
time.sleep(0.5) # polite spacing between README fetches jitter_sleep(0.5) # polite spacing between README fetches
# Structured metadata # Structured metadata
stars = repo.get("stargazers_count", 0) stars = repo.get("stargazers_count", 0)
+4 -4
View File
@@ -20,7 +20,7 @@ import urllib.request
import urllib.error import urllib.error
from datetime import datetime, timezone from datetime import datetime, timezone
from adapters import SourceAdapter from adapters import SourceAdapter, browser_user_agent, jitter_sleep
from adapters._store import true_first_seen, upsert_entries from adapters._store import true_first_seen, upsert_entries
@@ -49,7 +49,7 @@ class HackerNewsAdapter(SourceAdapter):
] ]
def __init__(self, user_agent=None): def __init__(self, user_agent=None):
self.user_agent = user_agent or "python:athena:v0.1 (by tony_tech)" self.user_agent = user_agent or browser_user_agent()
def name(self) -> str: def name(self) -> str:
return "hackernews" return "hackernews"
@@ -57,7 +57,7 @@ class HackerNewsAdapter(SourceAdapter):
def _request(self, path: str, max_retries: int = 2) -> dict | list | None: def _request(self, path: str, max_retries: int = 2) -> dict | list | None:
"""Make a GET request to the HN Firebase API.""" """Make a GET request to the HN Firebase API."""
url = f"{self.BASE}{path}" url = f"{self.BASE}{path}"
req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) req = urllib.request.Request(url, headers={"User-Agent": browser_user_agent()})
for attempt in range(max_retries + 1): for attempt in range(max_retries + 1):
try: try:
@@ -199,7 +199,7 @@ class HackerNewsAdapter(SourceAdapter):
if item and item.get("type") == "story" and item.get("title"): if item and item.get("type") == "story" and item.get("title"):
stories.append(item) stories.append(item)
if idx % 20 == 19: # polite spacing every 20 requests if idx % 20 == 19: # polite spacing every 20 requests
time.sleep(1) jitter_sleep(1)
# Filter for AI relevance # Filter for AI relevance
ai_stories = [s for s in stories if self._is_ai_relevant(s.get("title", ""))] ai_stories = [s for s in stories if self._is_ai_relevant(s.get("title", ""))]
+3 -3
View File
@@ -34,7 +34,7 @@ import urllib.request
import urllib.error import urllib.error
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from adapters import SourceAdapter from adapters import SourceAdapter, browser_user_agent, jitter_sleep
from adapters._store import true_first_seen, upsert_entries from adapters._store import true_first_seen, upsert_entries
@@ -79,7 +79,7 @@ class HuggingFaceAdapter(SourceAdapter):
return "huggingface" return "huggingface"
def _headers(self): def _headers(self):
headers = {"User-Agent": "athena/0.1"} headers = {"User-Agent": browser_user_agent()}
if self.token: if self.token:
headers["Authorization"] = f"Bearer {self.token}" headers["Authorization"] = f"Bearer {self.token}"
return headers return headers
@@ -251,7 +251,7 @@ class HuggingFaceAdapter(SourceAdapter):
if popular and isinstance(popular, list): if popular and isinstance(popular, list):
all_models.extend(popular) all_models.extend(popular)
time.sleep(1) # polite spacing jitter_sleep(1) # polite spacing
# 2. Recently modified (sort=lastModified) — fresh models getting attention # 2. Recently modified (sort=lastModified) — fresh models getting attention
# Filter to models created in last 90 days to avoid noise # Filter to models created in last 90 days to avoid noise
+4 -4
View File
@@ -26,7 +26,7 @@ import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
from html import unescape from html import unescape
from adapters import SourceAdapter from adapters import SourceAdapter, browser_user_agent, jitter_sleep
from adapters._store import true_first_seen, upsert_entries from adapters._store import true_first_seen, upsert_entries
@@ -62,7 +62,7 @@ class RedditAdapter(SourceAdapter):
""" """
self.subreddits = subreddits or self.DEFAULT_SUBREDDITS self.subreddits = subreddits or self.DEFAULT_SUBREDDITS
self.rate_limit = rate_limit self.rate_limit = rate_limit
self.user_agent = user_agent or "python:ai-oracle:v0.1 (by tony_tech)" self.user_agent = user_agent or browser_user_agent()
def name(self) -> str: def name(self) -> str:
return "reddit" return "reddit"
@@ -212,7 +212,7 @@ class RedditAdapter(SourceAdapter):
if e.code in (429, 403, 404): if e.code in (429, 403, 404):
return [] # JSON endpoint blocked, fall back to RSS return [] # JSON endpoint blocked, fall back to RSS
if attempt < 1: if attempt < 1:
time.sleep(5) jitter_sleep(5)
continue continue
return [] return []
except Exception: except Exception:
@@ -417,7 +417,7 @@ class RedditAdapter(SourceAdapter):
if not json_worked: if not json_worked:
print(" JSON endpoints blocked, using RSS fallback") print(" JSON endpoints blocked, using RSS fallback")
# Brief cooldown before RSS barrage # Brief cooldown before RSS barrage
time.sleep(3) jitter_sleep(3)
for sub in self.subreddits: for sub in self.subreddits:
entries = self._fetch_rss(sub) entries = self._fetch_rss(sub)
for e in entries: for e in entries:
+2 -2
View File
@@ -24,7 +24,7 @@ import feedparser
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from adapters import SourceAdapter from adapters import SourceAdapter, jitter_sleep
from adapters._store import true_first_seen, upsert_entries from adapters._store import true_first_seen, upsert_entries
@@ -218,7 +218,7 @@ class RSSFeedsAdapter(SourceAdapter):
"last_updated": now_iso, "last_updated": now_iso,
}) })
time.sleep(0.5) # polite spacing jitter_sleep(0.5) # polite spacing
except Exception as e: except Exception as e:
feed_failures.append(f"{source_key}: {e}") feed_failures.append(f"{source_key}: {e}")
+10 -1
View File
@@ -526,7 +526,16 @@ def main():
p_weekly.add_argument("--output", "-o", help="Output path for markdown review") p_weekly.add_argument("--output", "-o", help="Output path for markdown review")
p_weekly.add_argument("--json", action="store_true", help="Output as JSON") p_weekly.add_argument("--json", action="store_true", help="Output as JSON")
# health # metrics
p_metrics = subparsers.add_parser("metrics", help="Pipeline metrics and adapter health")
p_metrics.add_argument("--db", default="oracle.db")
p_metrics.add_argument("--days", type=int, default=7)
p_metrics.add_argument("--adapters", action="store_true", help="Show adapter health table")
p_metrics.add_argument("--publish", action="store_true", help="Show PUBLISH rate trend")
p_metrics.add_argument("--scores", action="store_true", help="Show score distribution")
p_metrics.add_argument("--alerts", action="store_true", help="Show current alerts")
p_metrics.add_argument("--all", action="store_true", help="Show everything")
p_metrics.add_argument("--json", action="store_true", help="Output as JSON")
sub.add_parser("health", help="System health check") sub.add_parser("health", help="System health check")
args = parser.parse_args() args = parser.parse_args()
+420
View File
@@ -0,0 +1,420 @@
"""Metrics layer — structured telemetry for the ingestion pipeline.
Tracks per-run metrics, adapter health, verdict distribution, and score
statistics. All stored in SQLite alongside entries for zero-cost persistence.
Usage inside a pipeline run:
from oracle.metrics import MetricsRun
m = MetricsRun(db_path)
m.log_adapter('arxiv', fetched=42, errors=0, runtime_ms=1200)
m.log_verdicts(publish=2, watch=28, archive=8, drop=4)
m.log_scores([5.2, 6.1, 3.0, ...])
m.log_summaries(useful=38, fallback=4)
m.save()
"""
import json
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
SCHEMA = """
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY,
run_id INTEGER NOT NULL,
metric_name TEXT NOT NULL,
value REAL,
metadata TEXT,
recorded_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS adapter_health (
id INTEGER PRIMARY KEY,
run_id INTEGER NOT NULL,
adapter_name TEXT NOT NULL,
items_fetched INTEGER DEFAULT 0,
errors INTEGER DEFAULT 0,
runtime_ms INTEGER DEFAULT 0,
consecutive_failures INTEGER DEFAULT 0,
last_error TEXT,
recorded_at TEXT NOT NULL
);
-- Index for fast time-range queries
CREATE INDEX IF NOT EXISTS idx_metrics_run_id ON metrics(run_id);
CREATE INDEX IF NOT EXISTS idx_metrics_name ON metrics(metric_name);
CREATE INDEX IF NOT EXISTS idx_adapter_health_name ON adapter_health(adapter_name);
CREATE INDEX IF NOT EXISTS idx_adapter_health_run ON adapter_health(run_id);
"""
def migrate_metrics(conn: sqlite3.Connection) -> bool:
"""Idempotently create metrics tables. Returns True if tables were new."""
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='metrics'"
)
was_missing = cur.fetchone() is None
conn.executescript(SCHEMA)
conn.commit()
return was_missing
class MetricsRun:
"""Accumulate metrics for a single pipeline run, then persist atomically."""
def __init__(self, db_path: str | Path):
self.db_path = str(db_path)
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
migrate_metrics(self.conn)
# Generate a run_id from run_log
self.run_id = self._resolve_run_id()
self.now = datetime.now(timezone.utc).isoformat()
self._adapters: list[dict] = []
self._metrics: list[dict] = []
self._score_values: list[float] = []
self._verdict_counts: dict[str, int] = {}
self._summary_counts: dict[str, int] = {"useful": 0, "fallback": 0}
def _resolve_run_id(self) -> int:
"""Get or create the current run entry in run_log."""
cur = self.conn.execute(
"SELECT MAX(id) as rid FROM run_log WHERE date(run_time) = date('now', 'utc')"
)
rid = cur.fetchone()["rid"]
if rid is None:
self.conn.execute(
"INSERT INTO run_log (run_time, total_fetched, total_stored, sources_ok, sources_failed, notes, failure_class) "
"VALUES (?, 0, 0, '[]', '[]', 'metrics-session', '')",
(self.now,),
)
self.conn.commit()
return self.conn.execute("SELECT last_insert_rowid()").fetchone()[0]
return rid
# ── Public API ──
def log_adapter(
self,
name: str,
fetched: int = 0,
errors: int = 0,
runtime_ms: int = 0,
last_error: str | None = None,
) -> None:
"""Record per-adapter health metrics."""
# Calculate consecutive failures
consecutive = self._consecutive_failures(name)
if fetched > 0 or errors == 0:
consecutive = 0 # Reset on success
self._adapters.append({
"run_id": self.run_id,
"adapter_name": name,
"items_fetched": fetched,
"errors": errors,
"runtime_ms": runtime_ms,
"consecutive_failures": consecutive,
"last_error": last_error,
"recorded_at": self.now,
})
def log_verdicts(
self,
publish: int = 0,
watch: int = 0,
archive: int = 0,
drop: int = 0,
) -> None:
"""Record verdict distribution for this run."""
self._verdict_counts = {
"PUBLISH": publish,
"WATCH": watch,
"ARCHIVE": archive,
"DROP": drop,
}
total = publish + watch + archive + drop
self._metrics.append({
"run_id": self.run_id,
"metric_name": "publish_rate",
"value": publish / total if total else 0,
"metadata": json.dumps({
"publish": publish,
"watch": watch,
"archive": archive,
"drop": drop,
"total": total,
}),
"recorded_at": self.now,
})
def log_scores(self, scores: list[float]) -> None:
"""Record signal score distribution."""
if not scores:
return
self._score_values = scores
sorted_scores = sorted(scores)
n = len(sorted_scores)
self._metrics.append({
"run_id": self.run_id,
"metric_name": "score_mean",
"value": sum(scores) / n,
"metadata": json.dumps({
"count": n,
"min": sorted_scores[0],
"max": sorted_scores[-1],
"p25": sorted_scores[n // 4],
"p50": sorted_scores[n // 2],
"p90": sorted_scores[int(n * 0.9)],
"p99": sorted_scores[int(n * 0.99)] if n > 100 else sorted_scores[-1],
}),
"recorded_at": self.now,
})
def log_summaries(self, useful: int = 0, fallback: int = 0) -> None:
"""Record summarization quality metrics."""
total = useful + fallback
self._summary_counts = {"useful": useful, "fallback": fallback}
if total:
self._metrics.append({
"run_id": self.run_id,
"metric_name": "summary_quality_rate",
"value": useful / total,
"metadata": json.dumps({
"useful": useful,
"fallback": fallback,
"total": total,
}),
"recorded_at": self.now,
})
def log_metric(
self,
name: str,
value: float,
metadata: dict | None = None,
) -> None:
"""Record an arbitrary metric."""
self._metrics.append({
"run_id": self.run_id,
"metric_name": name,
"value": value,
"metadata": json.dumps(metadata) if metadata else None,
"recorded_at": self.now,
})
def save(self) -> int:
"""Persist all accumulated metrics. Returns run_id."""
# Write adapter health
for a in self._adapters:
self.conn.execute(
"INSERT INTO adapter_health "
"(run_id, adapter_name, items_fetched, errors, runtime_ms, "
" consecutive_failures, last_error, recorded_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
a["run_id"], a["adapter_name"], a["items_fetched"],
a["errors"], a["runtime_ms"], a["consecutive_failures"],
a["last_error"], a["recorded_at"],
),
)
# Write metrics
for m in self._metrics:
self.conn.execute(
"INSERT INTO metrics "
"(run_id, metric_name, value, metadata, recorded_at) "
"VALUES (?, ?, ?, ?, ?)",
(
m["run_id"], m["metric_name"], m["value"],
m["metadata"], m["recorded_at"],
),
)
# Update run_log totals
total_fetched = sum(a["items_fetched"] for a in self._adapters)
total_errors = sum(a["errors"] for a in self._adapters)
sources_ok = [a["adapter_name"] for a in self._adapters if a["items_fetched"] > 0]
sources_failed = [a["adapter_name"] for a in self._adapters if a["errors"] > 0]
self.conn.execute(
"UPDATE run_log SET total_fetched = ?, total_stored = ?, "
"sources_ok = ?, sources_failed = ?, notes = ? "
"WHERE id = ?",
(
total_fetched,
total_fetched - total_errors,
json.dumps(sources_ok),
json.dumps(sources_failed),
f"publish_rate={self._verdict_counts.get('PUBLISH', 0)}, "
f"summary_quality={self._summary_counts.get('useful', 0)}/{sum(self._summary_counts.values()) or 1}",
self.run_id,
),
)
self.conn.commit()
return self.run_id
def close(self) -> None:
self.conn.close()
# ── Helpers ──
def _consecutive_failures(self, adapter_name: str) -> int:
"""Count consecutive runs where adapter returned 0 items."""
cur = self.conn.execute(
"SELECT items_fetched FROM adapter_health "
"WHERE adapter_name = ? AND run_id < ? "
"ORDER BY run_id DESC LIMIT 5",
(adapter_name, self.run_id),
)
count = 0
for row in cur:
if row["items_fetched"] == 0:
count += 1
else:
break
return count
# ── Aggregation queries ──
def get_publish_trend(db_path: str, days: int = 30) -> list[dict]:
"""Daily PUBLISH rate over the last N days."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT date(m.recorded_at) as day, "
"ROUND(AVG(m.value), 4) as avg_publish_rate, "
"COUNT(*) as runs "
"FROM metrics m "
"WHERE m.metric_name = 'publish_rate' "
"AND m.recorded_at >= datetime('now', ?) "
"GROUP BY day ORDER BY day DESC",
(f"-{days} days",),
)
results = [dict(r) for r in cur.fetchall()]
conn.close()
return results
def get_adapter_health(db_path: str, days: int = 7) -> list[dict]:
"""Adapter health summary over last N days."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT adapter_name, "
"COUNT(*) as runs, "
"SUM(items_fetched) as total_fetched, "
"SUM(errors) as total_errors, "
"MAX(consecutive_failures) as max_consecutive_failures, "
"AVG(runtime_ms) as avg_runtime_ms, "
"MAX(last_error) as last_error "
"FROM adapter_health "
"WHERE recorded_at >= datetime('now', ?) "
"GROUP BY adapter_name",
(f"-{days} days",),
)
results = [dict(r) for r in cur.fetchall()]
conn.close()
return results
def get_score_distribution(db_path: str, days: int = 30) -> list[dict]:
"""Score distribution percentiles over last N days."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT date(recorded_at) as day, value as mean, metadata "
"FROM metrics "
"WHERE metric_name = 'score_mean' "
"AND recorded_at >= datetime('now', ?) "
"ORDER BY day DESC",
(f"-{days} days",),
)
results = []
for r in cur:
meta = json.loads(r["metadata"]) if r["metadata"] else {}
results.append({
"day": r["day"],
"mean": r["mean"],
"min": meta.get("min"),
"max": meta.get("max"),
"p25": meta.get("p25"),
"p50": meta.get("p50"),
"p90": meta.get("p90"),
})
conn.close()
return results
def get_summary_quality(db_path: str, days: int = 30) -> list[dict]:
"""Summarization quality rate over time."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT date(recorded_at) as day, ROUND(AVG(value), 4) as avg_quality "
"FROM metrics "
"WHERE metric_name = 'summary_quality_rate' "
"AND recorded_at >= datetime('now', ?) "
"GROUP BY day ORDER BY day DESC",
(f"-{days} days",),
)
results = [dict(r) for r in cur.fetchall()]
conn.close()
return results
def get_metric_alerts(db_path: str) -> list[str]:
"""Generate alert strings based on metric anomalies."""
alerts = []
# Adapter failures
health = get_adapter_health(db_path, days=7)
for h in health:
if h["max_consecutive_failures"] and h["max_consecutive_failures"] >= 3:
alerts.append(
f"{h['adapter_name']}: {h['max_consecutive_failures']} consecutive "
f"failed runs. Last error: {h.get('last_error', 'unknown')}"
)
if h["total_errors"] and h["runs"]:
err_rate = h["total_errors"] / h["runs"] * 100
if err_rate > 50:
alerts.append(
f"{h['adapter_name']}: {err_rate:.0f}% error rate "
f"({h['total_errors']}/{h['runs']} runs)"
)
# Publish starvation
trend = get_publish_trend(db_path, days=7)
if trend:
latest = trend[0]["avg_publish_rate"]
if latest == 0:
alerts.append("🔴 PUBLISH rate is 0% — scoring threshold may be too strict")
elif latest < 0.01:
alerts.append(
f"🟡 PUBLISH rate is {latest:.1%}"
"consider lowering the signal threshold from 6.0"
)
# Summary quality
quality = get_summary_quality(db_path, days=7)
if quality:
latest = quality[0]["avg_quality"]
if latest < 0.5:
alerts.append(
f"🟡 Summary quality at {latest:.0%}"
"most items falling back to title-only"
)
return alerts
+34
View File
@@ -9,6 +9,7 @@ import sqlite3
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from collections import Counter from collections import Counter
from oracle.metrics import get_adapter_health, get_metric_alerts
WEEKLY_TEMPLATE = """# Weekly Review — {week_start} to {week_end} WEEKLY_TEMPLATE = """# Weekly Review — {week_start} to {week_end}
@@ -41,6 +42,10 @@ WEEKLY_TEMPLATE = """# Weekly Review — {week_start} to {week_end}
{wow_md} {wow_md}
## SYSTEM HEALTH
{health_md}
## RECOMMENDATIONS ## RECOMMENDATIONS
{recs_md} {recs_md}
@@ -216,10 +221,36 @@ def generate_weekly(
output_path: str | None = None, output_path: str | None = None,
) -> dict: ) -> dict:
"""Generate a complete weekly review.""" """Generate a complete weekly review."""
db_path = conn.execute("SELECT file FROM pragma_database_list LIMIT 1").fetchone()[0] or "oracle.db"
stats = fetch_weekly_stats(conn, days) stats = fetch_weekly_stats(conn, days)
trending = _trending_themes(conn, days) trending = _trending_themes(conn, days)
recommendations = _generate_recommendations(stats, trending) recommendations = _generate_recommendations(stats, trending)
# System health from metrics layer
health_data = get_adapter_health(db_path, days=days)
alerts = get_metric_alerts(db_path)
health_lines = []
if health_data:
for h in health_data:
status = ""
if h.get("max_consecutive_failures", 0) and h["max_consecutive_failures"] >= 3:
status = "🔴"
elif h.get("total_errors", 0) and h["runs"] and h["total_errors"] / h["runs"] > 0.5:
status = "🟡"
health_lines.append(
f"- {status} **{h['adapter_name']}**: {h['total_fetched']} fetched, "
f"{h['total_errors']} errors over {h['runs']} runs "
f"(avg {h['avg_runtime_ms']:.0f}ms)"
)
else:
health_lines.append("_No adapter health data yet (metrics tracking started recently)._")
if alerts:
health_lines.extend(f"- {a}" for a in alerts)
health_md = "\n".join(health_lines)
# Format sections # Format sections
top_sources_md = "\n".join( top_sources_md = "\n".join(
f"- **{s}**: {c} entries" for s, c in stats["top_sources"] f"- **{s}**: {c} entries" for s, c in stats["top_sources"]
@@ -254,6 +285,8 @@ def generate_weekly(
**stats, **stats,
"trending": trending, "trending": trending,
"recommendations": recommendations, "recommendations": recommendations,
"health": health_data,
"alerts": alerts,
} }
review["markdown"] = WEEKLY_TEMPLATE.format( review["markdown"] = WEEKLY_TEMPLATE.format(
week_start=stats["week_start"], week_start=stats["week_start"],
@@ -268,6 +301,7 @@ def generate_weekly(
tier_md=tier_md, tier_md=tier_md,
trending_md=trending_md, trending_md=trending_md,
wow_md=wow, wow_md=wow,
health_md=health_md,
recs_md=recs_md, recs_md=recs_md,
) )