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
+100
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import json
import sqlite3
import tomllib
from pathlib import Path
from urllib.parse import urlsplit
def load_sources(path: Path | str) -> list[dict]:
@@ -53,3 +55,101 @@ def upsert_sources(conn: sqlite3.Connection, source_defs: list[dict]) -> int:
conn.commit()
return count
# --- Supervised source candidates (staging before the real sources table) ----
def save_candidate(
conn: sqlite3.Connection,
feed_url: str,
preview: dict | None = None,
name: str | None = None,
homepage_url: str | None = None,
status: str = "quarantined",
notes: str | None = None,
) -> sqlite3.Row:
"""Stage a suggested feed (with an optional preview snapshot) for review.
Re-previewing an existing candidate refreshes its snapshot but never changes
a status a curator already set (e.g. a rejected feed stays rejected).
"""
conn.execute(
"""
INSERT INTO source_candidates (
feed_url, homepage_url, name, status, preview_json, notes, last_previewed_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(feed_url) DO UPDATE SET
preview_json = excluded.preview_json,
name = COALESCE(excluded.name, source_candidates.name),
homepage_url = COALESCE(excluded.homepage_url, source_candidates.homepage_url),
notes = COALESCE(excluded.notes, source_candidates.notes),
last_previewed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
""",
(feed_url, homepage_url, name, status, json.dumps(preview) if preview else None, notes),
)
conn.commit()
return conn.execute("SELECT * FROM source_candidates WHERE feed_url = ?", (feed_url,)).fetchone()
def list_candidates(conn: sqlite3.Connection, status: str | None = None) -> list[sqlite3.Row]:
if status:
return conn.execute(
"SELECT * FROM source_candidates WHERE status = ? ORDER BY updated_at DESC", (status,)
).fetchall()
return conn.execute("SELECT * FROM source_candidates ORDER BY updated_at DESC").fetchall()
def reject_candidate(conn: sqlite3.Connection, candidate_id: int) -> bool:
cur = conn.execute(
"UPDATE source_candidates SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(candidate_id,),
)
conn.commit()
return cur.rowcount > 0
def promote_candidate(
conn: sqlite3.Connection,
candidate_id: int,
active: bool = False,
default_category: str | None = None,
trust_score: int = 5,
pr_risk_score: int = 3,
poll_interval_minutes: int = 180,
) -> int:
"""Copy a reviewed candidate into the real sources table.
Inactive by default (active-on-approval): a promoted feed is wired up but
won't be polled until explicitly activated. Never called automatically.
"""
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (candidate_id,)).fetchone()
if cand is None:
raise ValueError(f"no candidate with id {candidate_id}")
name = cand["name"] or urlsplit(cand["feed_url"]).netloc or cand["feed_url"]
upsert_sources(
conn,
[
{
"name": name,
"feed_url": cand["feed_url"],
"homepage_url": cand["homepage_url"],
"default_category": default_category,
"trust_score": trust_score,
"pr_risk_score": pr_risk_score,
"active": active,
"poll_interval_minutes": poll_interval_minutes,
"notes": f"promoted from candidate {candidate_id}",
}
],
)
conn.execute(
"UPDATE source_candidates SET status = 'promoted', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(candidate_id,),
)
conn.commit()
row = conn.execute("SELECT id FROM sources WHERE feed_url = ?", (cand["feed_url"],)).fetchone()
return int(row["id"])