641d531d88
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'
421 lines
14 KiB
Python
421 lines
14 KiB
Python
"""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
|