Advisory source health: review flags, never auto-deactivate
- Add source health columns (last_success_at, last_error_at, last_error, consecutive_failures, review_flag, review_reason) via SCHEMA + migration. - poll_source maintains them: success resets the failure streak and records the success time; failure increments it and stores the latest error. - review_sources() flags active sources that are stale, repeatedly failing, low-acceptance, duplicate-heavy, or doom-skewed (high cortisol/ragebait) over a recent window. It is purely advisory: it sets review_flag/review_reason and never changes the active column (human stays in the loop), clearing the flag when a source recovers. - CLI review-sources; cycle runs it as a final step (--no-review to skip); source-report shows a review line for flagged feeds. - Tests: healthy/failing/stale/low-acceptance/recovery and never-deactivates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import sqlite3
|
||||
import tomllib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -153,3 +154,82 @@ def promote_candidate(
|
||||
row = conn.execute("SELECT id FROM sources WHERE feed_url = ?", (cand["feed_url"],)).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
# --- Advisory source health: flag for review, never auto-deactivate -----------
|
||||
|
||||
|
||||
def review_sources(
|
||||
conn: sqlite3.Connection,
|
||||
stale_days: int = 14,
|
||||
min_recent: int = 15,
|
||||
recent_window: int = 40,
|
||||
) -> list[dict]:
|
||||
"""Recompute advisory review flags for active sources.
|
||||
|
||||
Sets review_flag/review_reason but NEVER changes `active` — the human stays
|
||||
in the loop. Returns the list of newly-flagged sources.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
flagged = []
|
||||
sources = conn.execute(
|
||||
"SELECT id, name, consecutive_failures FROM sources WHERE active = 1"
|
||||
).fetchall()
|
||||
|
||||
for s in sources:
|
||||
reasons: list[str] = []
|
||||
if (s["consecutive_failures"] or 0) >= 3:
|
||||
reasons.append(f"failing ({s['consecutive_failures']} consecutive)")
|
||||
|
||||
recent = conn.execute(
|
||||
"""
|
||||
SELECT sc.accepted, sc.cortisol_score, sc.ragebait_score, a.duplicate_of,
|
||||
COALESCE(a.published_at, a.discovered_at) AS dt
|
||||
FROM articles a
|
||||
JOIN article_scores sc ON sc.article_id = a.id
|
||||
WHERE a.source_id = ?
|
||||
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(s["id"], recent_window),
|
||||
).fetchall()
|
||||
n = len(recent)
|
||||
|
||||
if n == 0:
|
||||
reasons.append("no articles yet")
|
||||
else:
|
||||
try:
|
||||
newest = datetime.fromisoformat(recent[0]["dt"])
|
||||
if newest.tzinfo is None:
|
||||
newest = newest.replace(tzinfo=timezone.utc)
|
||||
age = (now - newest).days
|
||||
if age > stale_days:
|
||||
reasons.append(f"stale (newest {age}d ago)")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if n >= min_recent:
|
||||
acc = sum(r["accepted"] or 0 for r in recent) / n
|
||||
if acc < 0.10:
|
||||
reasons.append(f"low acceptance ({acc * 100:.0f}%)")
|
||||
dup = sum(1 for r in recent if r["duplicate_of"] is not None) / n
|
||||
if dup > 0.5:
|
||||
reasons.append(f"duplicate-heavy ({dup * 100:.0f}%)")
|
||||
avg_cort = sum(r["cortisol_score"] or 0 for r in recent) / n
|
||||
if avg_cort > 5:
|
||||
reasons.append(f"high cortisol (avg {avg_cort:.1f})")
|
||||
avg_rage = sum(r["ragebait_score"] or 0 for r in recent) / n
|
||||
if avg_rage > 3:
|
||||
reasons.append(f"high ragebait (avg {avg_rage:.1f})")
|
||||
|
||||
flag = 1 if reasons else 0
|
||||
reason = "; ".join(reasons) if reasons else None
|
||||
conn.execute(
|
||||
"UPDATE sources SET review_flag = ?, review_reason = ? WHERE id = ?",
|
||||
(flag, reason, s["id"]),
|
||||
)
|
||||
if flag:
|
||||
flagged.append({"id": s["id"], "name": s["name"], "reason": reason})
|
||||
|
||||
conn.commit()
|
||||
return flagged
|
||||
|
||||
|
||||
Reference in New Issue
Block a user