Supervised source candidates: stage, list, promote, reject

- New source_candidates staging table (status suggested/quarantined/rejected/
  promoted, preview_json snapshot) so untrusted/suggested feeds stay out of the
  real ingestion path until reviewed.
- sources.py: save_candidate (re-preview never revives a curator's rejection),
  list_candidates, reject_candidate, promote_candidate (copies into sources,
  inactive by default — active on approval; never automatic).
- CLI: suggest-source / list-candidates / promote-candidate / reject-candidate.
- API: read-only GET /api/candidates (writes stay CLI-only — no unauthenticated
  public write surface yet).
- Fix deprecated ElementTree truth-value test in _parse_rss.
- Tests: candidate lifecycle (save/list/promote/reject, status preservation,
  name derivation) — 51 total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 19:52:40 +00:00
parent 95195daff8
commit aa4125ddec
7 changed files with 282 additions and 7 deletions
+65 -1
View File
@@ -19,7 +19,14 @@ from .feeds import (
)
from .llm import LocalModelClient, classify_articles
from .scoring import score_article
from .sources import load_sources, upsert_sources
from .sources import (
list_candidates,
load_sources,
promote_candidate,
reject_candidate,
save_candidate,
upsert_sources,
)
ROOT = Path(__file__).resolve().parents[1]
@@ -66,6 +73,28 @@ def main() -> None:
preview_parser.add_argument("--base-url", help="OpenAI-compatible base URL (with --classify)")
preview_parser.add_argument("--model", help="Local model name (with --classify)")
suggest_parser = subparsers.add_parser("suggest-source", help="Preview a feed and stage it as a candidate")
suggest_parser.add_argument("url", help="Feed URL to suggest")
suggest_parser.add_argument("--name", help="Display name for the source")
suggest_parser.add_argument("--homepage", help="Homepage URL")
suggest_parser.add_argument("--sample", type=int, default=25)
suggest_parser.add_argument("--classify", action="store_true", help="Classify the sample with the local model")
suggest_parser.add_argument("--base-url")
suggest_parser.add_argument("--model")
cand_parser = subparsers.add_parser("list-candidates", help="List staged source candidates")
cand_parser.add_argument("--status", help="Filter by status: suggested|quarantined|rejected|promoted")
promote_parser = subparsers.add_parser("promote-candidate", help="Copy a candidate into the real sources")
promote_parser.add_argument("id", type=int)
promote_parser.add_argument("--active", action="store_true", help="Activate immediately (default: inactive)")
promote_parser.add_argument("--category", help="default_category for the new source")
promote_parser.add_argument("--trust", type=int, default=5)
promote_parser.add_argument("--pr-risk", type=int, default=3)
reject_parser = subparsers.add_parser("reject-candidate", help="Mark a candidate as rejected")
reject_parser.add_argument("id", type=int)
runs_parser = subparsers.add_parser("list-runs", help="Show recent ingest runs")
runs_parser.add_argument("--limit", type=int, default=20)
@@ -154,6 +183,41 @@ def main() -> None:
client = llm_client_from_args(args) if args.classify else None
preview = preview_feed(args.url, sample=args.sample, client=client)
print_preview(preview)
elif args.command == "suggest-source":
init_db(conn)
client = llm_client_from_args(args) if args.classify else None
preview = preview_feed(args.url, sample=args.sample, client=client)
print_preview(preview)
cand = save_candidate(conn, args.url, preview=preview, name=args.name, homepage_url=args.homepage)
print(f"Saved as candidate #{cand['id']} (status {cand['status']}). Review with list-candidates.")
elif args.command == "list-candidates":
init_db(conn)
rows = list_candidates(conn, status=args.status)
if not rows:
print("No candidates.")
for r in rows:
line = f"[{r['id']}] {r['status']} | {r['name'] or '(unnamed)'} | {r['feed_url']}"
if r["preview_json"]:
import json as _json
p = _json.loads(r["preview_json"])
line += f" (accept {round(p.get('acceptance_rate', 0) * 100)}%, sampled {p.get('sampled', 0)})"
print(line)
elif args.command == "promote-candidate":
init_db(conn)
try:
source_id = promote_candidate(
conn, args.id, active=args.active, default_category=args.category,
trust_score=args.trust, pr_risk_score=args.pr_risk,
)
except ValueError as exc:
raise SystemExit(str(exc))
state = "active" if args.active else "inactive"
print(f"Promoted candidate #{args.id} -> source #{source_id} ({state}).")
elif args.command == "reject-candidate":
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 == "list-runs":
list_runs(conn, limit=args.limit)
elif args.command == "rescore":