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
+7 -4
View File
@@ -22,6 +22,7 @@ python3 -m goodnews suggest-source https://example.com/feed/ --name "Example" --
python3 -m goodnews list-candidates python3 -m goodnews list-candidates
python3 -m goodnews promote-candidate 1 # copies into sources (inactive by default) python3 -m goodnews promote-candidate 1 # copies into sources (inactive by default)
python3 -m goodnews reject-candidate 1 python3 -m goodnews reject-candidate 1
python3 -m goodnews review-sources # advisory health flags (never deactivates)
python3 -m goodnews build-brief --date 2026-05-27 --replace python3 -m goodnews build-brief --date 2026-05-27 --replace
python3 -m goodnews show-brief python3 -m goodnews show-brief
python3 -m goodnews list-recent --limit 10 python3 -m goodnews list-recent --limit 10
@@ -161,7 +162,7 @@ often as you like — it only polls sources that are *due* (per each source's
rebuilds the current day's brief: rebuilds the current day's brief:
```bash ```bash
python3 -m goodnews cycle # poll due -> classify new -> dedup -> rebuild today's brief python3 -m goodnews cycle # poll due -> classify -> dedup -> brief -> review flags
python3 -m goodnews cycle --force # poll every active source regardless of interval python3 -m goodnews cycle --force # poll every active source regardless of interval
python3 -m goodnews cycle --no-classify # skip the LLM step (e.g. model box offline) python3 -m goodnews cycle --no-classify # skip the LLM step (e.g. model box offline)
``` ```
@@ -195,9 +196,11 @@ Still ahead:
previews a feed and stages it in the `source_candidates` table (status previews a feed and stages it in the `source_candidates` table (status
suggested/quarantined/rejected/promoted); `promote-candidate` copies it into suggested/quarantined/rejected/promoted); `promote-candidate` copies it into
`sources` (inactive by default — active on approval); promotion is never `sources` (inactive by default — active on approval); promotion is never
automatic. Still ahead: advisory auto-degrade of stale/rejecting feeds (flag automatic. Advisory health is done too: `review-sources` (also run at the end
for review, never auto-block), and an authenticated POST surface so the website of `cycle`) flags stale, failing, low-acceptance, duplicate-heavy, or
can accept public suggestions once accounts exist. doom-skewed feeds for human review — it never deactivates anything. Still
ahead: an authenticated POST surface so the website can accept public
suggestions once accounts exist.
2. **Learned "Less like this" weighting** — replace the interim flavor-pause with 2. **Learned "Less like this" weighting** — replace the interim flavor-pause with
real preference down-ranking. real preference down-ranking.
3. **Corpus rebalancing** — add calm/feelgood sources (currently science-heavy). 3. **Corpus rebalancing** — add calm/feelgood sources (currently science-heavy).
+28
View File
@@ -24,6 +24,7 @@ from .sources import (
load_sources, load_sources,
promote_candidate, promote_candidate,
reject_candidate, reject_candidate,
review_sources,
save_candidate, save_candidate,
upsert_sources, upsert_sources,
) )
@@ -95,6 +96,11 @@ def main() -> None:
reject_parser = subparsers.add_parser("reject-candidate", help="Mark a candidate as rejected") reject_parser = subparsers.add_parser("reject-candidate", help="Mark a candidate as rejected")
reject_parser.add_argument("id", type=int) 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 = subparsers.add_parser("list-runs", help="Show recent ingest runs")
runs_parser.add_argument("--limit", type=int, default=20) 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-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-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-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("--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("--base-url", help="OpenAI-compatible base URL for classify")
cycle_parser.add_argument("--model", help="Local model name for classify") cycle_parser.add_argument("--model", help="Local model name for classify")
@@ -218,6 +225,15 @@ def main() -> None:
init_db(conn) init_db(conn)
ok = reject_candidate(conn, args.id) ok = reject_candidate(conn, args.id)
print(f"Rejected candidate #{args.id}." if ok else f"No candidate #{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": elif args.command == "list-runs":
list_runs(conn, limit=args.limit) list_runs(conn, limit=args.limit)
elif args.command == "rescore": elif args.command == "rescore":
@@ -430,6 +446,13 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc: except Exception as exc:
print(f"brief: skipped ({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: def serve(args: argparse.Namespace) -> None:
try: try:
@@ -530,6 +553,9 @@ def source_report(conn: sqlite3.Connection) -> None:
src.default_category, src.default_category,
src.trust_score, src.trust_score,
src.pr_risk_score AS source_pr_risk, src.pr_risk_score AS source_pr_risk,
src.review_flag,
src.review_reason,
src.consecutive_failures,
COUNT(a.id) AS articles, COUNT(a.id) AS articles,
SUM(CASE WHEN s.accepted = 1 THEN 1 ELSE 0 END) AS accepted, SUM(CASE WHEN s.accepted = 1 THEN 1 ELSE 0 END) AS accepted,
ROUND(AVG(s.constructive_score), 1) AS avg_constructive, 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']}" f"avg_ragebait={row['avg_ragebait']}"
) )
print(f" newest={row['newest_article'] or 'none'}") 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: def list_runs(conn: sqlite3.Connection, limit: int) -> None:
+19
View File
@@ -19,6 +19,12 @@ CREATE TABLE IF NOT EXISTS sources (
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
poll_interval_minutes INTEGER NOT NULL DEFAULT 60, poll_interval_minutes INTEGER NOT NULL DEFAULT 60,
notes TEXT, notes TEXT,
last_success_at TEXT,
last_error_at TEXT,
last_error TEXT,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
review_flag INTEGER NOT NULL DEFAULT 0,
review_reason TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -148,3 +154,16 @@ def _migrate(conn: sqlite3.Connection) -> None:
) )
# Created here (not in SCHEMA) so it runs after the column exists on upgrades. # Created here (not in SCHEMA) so it runs after the column exists on upgrades.
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)") conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
source_cols = {row["name"] for row in conn.execute("PRAGMA table_info(sources)")}
health_columns = {
"last_success_at": "TEXT",
"last_error_at": "TEXT",
"last_error": "TEXT",
"consecutive_failures": "INTEGER NOT NULL DEFAULT 0",
"review_flag": "INTEGER NOT NULL DEFAULT 0",
"review_reason": "TEXT",
}
for column, decl in health_columns.items():
if column not in source_cols:
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
+20
View File
@@ -108,6 +108,15 @@ def poll_source(conn: sqlite3.Connection, source: sqlite3.Row) -> dict:
""", """,
(seen, inserted, duplicate, run_id), (seen, inserted, duplicate, run_id),
) )
# A clean poll resets the failure streak and records the success time.
conn.execute(
"""
UPDATE sources
SET last_success_at = CURRENT_TIMESTAMP, consecutive_failures = 0
WHERE id = ?
""",
(source["id"],),
)
conn.commit() conn.commit()
return {"status": "ok", "seen": seen, "inserted": inserted, "duplicate": duplicate} return {"status": "ok", "seen": seen, "inserted": inserted, "duplicate": duplicate}
except Exception as exc: except Exception as exc:
@@ -124,6 +133,17 @@ def poll_source(conn: sqlite3.Connection, source: sqlite3.Row) -> dict:
""", """,
(seen, inserted, duplicate, str(exc), run_id), (seen, inserted, duplicate, str(exc), run_id),
) )
# Track the failure streak and latest error for advisory review flags.
conn.execute(
"""
UPDATE sources
SET consecutive_failures = consecutive_failures + 1,
last_error_at = CURRENT_TIMESTAMP,
last_error = ?
WHERE id = ?
""",
(str(exc), source["id"]),
)
conn.commit() conn.commit()
return { return {
"status": "failed", "status": "failed",
+80
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import sqlite3 import sqlite3
import tomllib import tomllib
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit 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() row = conn.execute("SELECT id FROM sources WHERE feed_url = ?", (cand["feed_url"],)).fetchone()
return int(row["id"]) 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
+89
View File
@@ -0,0 +1,89 @@
from datetime import datetime, timedelta, timezone
import pytest
from goodnews.db import connect, init_db
from goodnews.sources import review_sources
@pytest.fixture
def conn():
c = connect(":memory:")
init_db(c)
yield c
c.close()
def _source(conn, sid, name, failures=0):
conn.execute(
"INSERT INTO sources (id, name, feed_url, trust_score, consecutive_failures) VALUES (?,?,?,5,?)",
(sid, name, f"http://s{sid}/feed", failures),
)
def _article(conn, sid, aid, when, accepted=1, cortisol=1, ragebait=0, dup=None):
conn.execute(
"INSERT INTO articles (id, source_id, canonical_url, title, published_at, url_hash, duplicate_of) "
"VALUES (?,?,?,?,?,?,?)",
(aid, sid, f"http://s{sid}/{aid}", f"t{aid}", when, f"h{aid}", dup),
)
conn.execute(
"INSERT INTO article_scores (article_id, cortisol_score, ragebait_score, accepted) VALUES (?,?,?,?)",
(aid, cortisol, ragebait, accepted),
)
def test_healthy_source_not_flagged(conn):
_source(conn, 1, "Healthy")
now = datetime.now(timezone.utc)
for i in range(20):
_article(conn, 1, i, now.isoformat(), accepted=1, cortisol=1, ragebait=0)
conn.commit()
assert review_sources(conn) == []
assert conn.execute("SELECT review_flag FROM sources WHERE id=1").fetchone()["review_flag"] == 0
def test_repeated_failures_flagged(conn):
_source(conn, 1, "Flaky", failures=4)
conn.commit()
flagged = review_sources(conn)
assert len(flagged) == 1 and "failing" in flagged[0]["reason"]
def test_stale_source_flagged(conn):
_source(conn, 1, "Stale")
old = (datetime.now(timezone.utc) - timedelta(days=40)).isoformat()
_article(conn, 1, 1, old)
conn.commit()
flagged = review_sources(conn)
assert "stale" in flagged[0]["reason"]
def test_low_acceptance_and_cortisol_flagged(conn):
_source(conn, 1, "Doomy")
now = datetime.now(timezone.utc).isoformat()
for i in range(20):
_article(conn, 1, i, now, accepted=0, cortisol=8, ragebait=0)
conn.commit()
reason = review_sources(conn)[0]["reason"]
assert "low acceptance" in reason and "high cortisol" in reason
def test_review_never_deactivates(conn):
_source(conn, 1, "Flaky", failures=9)
conn.commit()
review_sources(conn)
assert conn.execute("SELECT active FROM sources WHERE id=1").fetchone()["active"] == 1
def test_recovered_source_flag_cleared(conn):
_source(conn, 1, "Recovered", failures=5)
conn.commit()
review_sources(conn) # flagged
conn.execute("UPDATE sources SET consecutive_failures=0 WHERE id=1")
now = datetime.now(timezone.utc).isoformat()
for i in range(20):
_article(conn, 1, i, now, accepted=1, cortisol=1)
conn.commit()
review_sources(conn) # should clear
assert conn.execute("SELECT review_flag FROM sources WHERE id=1").fetchone()["review_flag"] == 0