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:
jay
2026-05-30 20:28:18 +00:00
parent aa4125ddec
commit 1e190c5e88
6 changed files with 243 additions and 4 deletions
+28
View File
@@ -24,6 +24,7 @@ from .sources import (
load_sources,
promote_candidate,
reject_candidate,
review_sources,
save_candidate,
upsert_sources,
)
@@ -95,6 +96,11 @@ def main() -> None:
reject_parser = subparsers.add_parser("reject-candidate", help="Mark a candidate as rejected")
reject_parser.add_argument("id", type=int)
review_parser = subparsers.add_parser(
"review-sources", help="Recompute advisory review flags (never deactivates anything)"
)
review_parser.add_argument("--stale-days", type=int, default=14)
runs_parser = subparsers.add_parser("list-runs", help="Show recent ingest runs")
runs_parser.add_argument("--limit", type=int, default=20)
@@ -114,6 +120,7 @@ def main() -> None:
cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step")
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
cycle_parser.add_argument("--force", action="store_true", help="Poll all active sources, ignoring intervals")
cycle_parser.add_argument("--base-url", help="OpenAI-compatible base URL for classify")
cycle_parser.add_argument("--model", help="Local model name for classify")
@@ -218,6 +225,15 @@ def main() -> None:
init_db(conn)
ok = reject_candidate(conn, args.id)
print(f"Rejected candidate #{args.id}." if ok else f"No candidate #{args.id}.")
elif args.command == "review-sources":
init_db(conn)
flagged = review_sources(conn, stale_days=args.stale_days)
if not flagged:
print("All active sources look healthy.")
else:
print(f"{len(flagged)} source(s) flagged for review (advisory — none deactivated):")
for f in flagged:
print(f" [{f['id']}] {f['name']}: {f['reason']}")
elif args.command == "list-runs":
list_runs(conn, limit=args.limit)
elif args.command == "rescore":
@@ -430,6 +446,13 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc:
print(f"brief: skipped ({exc})")
if not args.no_review:
try:
flagged = review_sources(conn)
print(f"review: {len(flagged)} source(s) flagged for review (advisory)")
except Exception as exc:
print(f"review: skipped ({exc})")
def serve(args: argparse.Namespace) -> None:
try:
@@ -530,6 +553,9 @@ def source_report(conn: sqlite3.Connection) -> None:
src.default_category,
src.trust_score,
src.pr_risk_score AS source_pr_risk,
src.review_flag,
src.review_reason,
src.consecutive_failures,
COUNT(a.id) AS articles,
SUM(CASE WHEN s.accepted = 1 THEN 1 ELSE 0 END) AS accepted,
ROUND(AVG(s.constructive_score), 1) AS avg_constructive,
@@ -558,6 +584,8 @@ def source_report(conn: sqlite3.Connection) -> None:
f"avg_ragebait={row['avg_ragebait']}"
)
print(f" newest={row['newest_article'] or 'none'}")
if row["review_flag"]:
print(f" ⚑ review: {row['review_reason']}")
def list_runs(conn: sqlite3.Connection, limit: int) -> None: