← back to Paul Conrad Archive
src/conrad/provenance.py
86 lines
"""Acquisition provenance — HOW each cartoon_sources row was obtained (Cody must-fix, TK-12199).
`acquisition_method` is independent of `provenance` (the free-text trail): it is a closed vocabulary that
lets exports, the API and the viewer flag records whose acquisition path is weaker than a direct fetch.
direct_api fetched live by this project from a documented JSON/XML API (e.g. loc.gov ?fo=json)
direct_html fetched live by this project from a public HTML/XML document (e.g. the Huntington EAD)
seed_direct seed corpus (TK-12179) whose raw cache was fetched directly from the repository
seed_via_reader_bypass seed corpus fetched through a third-party reader (r.jina.ai) past an AWS WAF / bot
challenge — every seed_wichita and seed_syracuse row. Pending direct verification.
secondary_citation not from a holding repository at all: a secondary source (obituary, article, book)
"""
from __future__ import annotations
METHODS = ("direct_api", "direct_html", "seed_direct", "seed_via_reader_bypass", "secondary_citation")
DIRECT = {"direct_api", "direct_html"}
# seed sources whose raw cache was fetched via r.jina.ai past a WAF challenge (TK-12179 research/raw/{wsu,syr.md})
READER_BYPASS_SOURCES = {"seed_wichita", "seed_syracuse"}
SECONDARY_SOURCES = {"seed_catalog", "secondary_web"}
# live crawlers that talk to a documented JSON/XML API
API_SOURCES = {"loc", "dpla", "smithsonian", "internet_archive"}
BADGE_TEXT = "acquired via third-party reader past a bot challenge — pending direct verification"
FLAG_BYPASS = "acquired_via_reader_bypass"
FLAG_BYPASS_UNVERIFIED = "pending_direct_verification"
FLAG_SECONDARY_ONLY = "secondary_citation_only"
def derive(source_id: str, provenance: str | None = None) -> str:
"""Deterministic method for a cartoon_sources row from its source_id (+ provenance text as tie-breaker)."""
sid = (source_id or "").strip()
prov = (provenance or "").lower()
if sid in READER_BYPASS_SOURCES or "r.jina.ai" in prov:
return "seed_via_reader_bypass" # never label a reader-proxied fetch as direct, whatever the source
if sid in SECONDARY_SOURCES:
return "secondary_citation"
if sid.startswith("seed"):
return "seed_direct"
if sid in API_SOURCES or "fo=json" in prov or "/api" in prov:
return "direct_api"
return "direct_html"
def flags(methods) -> list[str]:
"""Item-level provenance flags from the set of acquisition methods of every source row behind a cartoon."""
ms = {m for m in methods if m}
out = []
if "seed_via_reader_bypass" in ms:
out.append(FLAG_BYPASS)
if not ms & DIRECT:
out.append(FLAG_BYPASS_UNVERIFIED)
if ms and ms <= {"secondary_citation"}:
out.append(FLAG_SECONDARY_ONLY)
return out
def backfill(conn, overwrite: bool = False) -> int:
"""Fill acquisition_method on every row (only NULL rows unless overwrite). Idempotent. Returns rows written."""
where = "" if overwrite else "WHERE acquisition_method IS NULL"
rows = conn.execute(f"SELECT id, source_id, provenance FROM cartoon_sources {where}").fetchall()
conn.executemany("UPDATE cartoon_sources SET acquisition_method=? WHERE id=?",
[(derive(r[1], r[2]), r[0]) for r in rows])
conn.commit()
return len(rows)
def methods_by_canonical(conn, ids=None) -> dict[int, list[str]]:
"""{canonical cartoon id: sorted distinct methods across it and everything merged into it}."""
from collections import defaultdict
sql = """SELECT COALESCE(k.merged_into,k.id), x.acquisition_method FROM cartoon_sources x
JOIN cartoons k ON k.id=x.cartoon_id"""
args: list = []
if ids is not None:
ids = list(ids)
if not ids:
return {}
ph = ",".join("?" * len(ids))
sql += f" WHERE COALESCE(k.merged_into,k.id) IN ({ph})"
args = ids
out: dict[int, set] = defaultdict(set)
for canon, m in conn.execute(sql, args):
if m:
out[canon].add(m)
return {k: sorted(v) for k, v in out.items()}