Admin: source Articles inspector (verify metrics against real evidence)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.
- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
read-only). queries.source_articles + source_articles_summary — per article:
title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
(domain rule), has_image, duplicate. Summary = counts + source-level paywall
rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
· … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
image/Duplicates), compact rows with title→link + badges + reason, Load more.
So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -454,6 +454,75 @@ def _attention(content: dict, sources: list[dict], feedback_unread: int, now: da
|
||||
return items
|
||||
|
||||
|
||||
# --- Source article inspector: the real articles behind the source metrics -----
|
||||
|
||||
_SRC_ART_FILTERS = {
|
||||
"accepted": "AND s.accepted = 1",
|
||||
"rejected": "AND s.accepted = 0",
|
||||
"no_image": "AND (a.image_url IS NULL OR a.image_url = '')",
|
||||
"duplicates": "AND a.duplicate_of IS NOT NULL",
|
||||
}
|
||||
|
||||
|
||||
def source_articles(conn: sqlite3.Connection, source_id: int, filter: str = "all",
|
||||
limit: int = 25, offset: int = 0) -> list[dict]:
|
||||
"""The actual ingested articles for a source, newest first — so admins can
|
||||
verify the metric (paywall/image/acceptance) against real evidence."""
|
||||
where = _SRC_ART_FILTERS.get(filter, "")
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT a.id, a.title, a.canonical_url, a.published_at, a.discovered_at,
|
||||
a.image_url, a.duplicate_of,
|
||||
s.accepted, s.reason_code, s.reason_text, s.topic, s.flavor
|
||||
FROM articles a
|
||||
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE a.source_id = ? {where}
|
||||
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(source_id, limit, offset),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"url": r["canonical_url"],
|
||||
"published_at": r["published_at"] or r["discovered_at"],
|
||||
"accepted": r["accepted"],
|
||||
"reason": r["reason_text"] or r["reason_code"], # the "why" behind accept/reject
|
||||
"topic": r["topic"],
|
||||
"flavor": r["flavor"],
|
||||
"paywalled": is_paywalled(r["canonical_url"]), # domain rule — same for the source
|
||||
"has_image": bool(r["image_url"]),
|
||||
"duplicate": r["duplicate_of"] is not None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def source_articles_summary(conn: sqlite3.Connection, source_id: int) -> dict:
|
||||
"""Counts behind the table metrics + the source-level paywall rule, so the
|
||||
panel header reads e.g. '120 · 96 accepted · 24 rejected · 3 no image · paywall: ON'."""
|
||||
agg = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) total,
|
||||
COALESCE(SUM(s.accepted = 1), 0) accepted,
|
||||
COALESCE(SUM(s.accepted = 0), 0) rejected,
|
||||
COALESCE(SUM(a.image_url IS NULL OR a.image_url = ''), 0) no_image,
|
||||
COALESCE(SUM(a.duplicate_of IS NOT NULL), 0) duplicates
|
||||
FROM articles a LEFT JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE a.source_id = ?
|
||||
""",
|
||||
(source_id,),
|
||||
).fetchone()
|
||||
one = conn.execute("SELECT canonical_url FROM articles WHERE source_id = ? LIMIT 1", (source_id,)).fetchone()
|
||||
return {
|
||||
"total": agg["total"], "accepted": agg["accepted"], "rejected": agg["rejected"],
|
||||
"no_image": agg["no_image"], "duplicates": agg["duplicates"],
|
||||
"paywalled": is_paywalled(one["canonical_url"]) if one else False,
|
||||
}
|
||||
|
||||
|
||||
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
||||
since = f"-{days} days"
|
||||
|
||||
Reference in New Issue
Block a user