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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user