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