Files
stack/dev/scripts/import_lead_corpus.py
kert 2dc0e9c586 feat(bib): vendor the staged LEAD corpus documents into the library
dev/scripts/import_lead_corpus.py files every distinct document in
lead_corpus/ as a bib `source` item with its file attached, tagged
source:lead-corpus, under a "LEAD corpus" collection tree:

- lead_research: 73 methodology papers, CMS model specifications and
  research articles, with title / authors / year / URL from
  sources.csv (people become an `Authors:` line, organisations the
  institution) and the per-topic LEAD notes as the abstract.
- lead_enrichment: 68 data dictionaries, methodology references and
  eCQM specifications, with title / publisher / release / download URL
  and "how LEAD could use it" from the _catalogs job lists.

Identical files (one PDF filed under two topic folders, the same
dictionary in every release folder) fold to one item by sha256: 123
items. Skipped: 81 `dictionary__*` files that are byte-identical
copies of data.cms.gov's JavaScript app shell (no dictionary content),
and text extracts sitting next to their PDF. Re-running is a no-op
(items match on URL, or on title within the tag; attachments on name).

bib.sync, so the import lands properly in Zotero:
- `source:lead-corpus` items route to LEAD corpus / Research /
  <topic>, / Dataset documentation, or / Measure specifications.
- An item already in Zotero is matched by URL, else by key when the
  Zotero row with that key has the same title. Matching on URL alone
  re-created every URL-less item on every sync — the second corpus
  sync duplicated the 11 documents without a public URL. The title
  guard keeps an unrelated row that merely holds the key from being
  overwritten (test_key_collision).
2026-10-27 12:38:39 -04:00

414 lines
14 KiB
Python

"""Vendor the staged LEAD corpus documents into the bib library.
``lead_corpus/`` (staged 2026-10-23 for the LEAD repository) holds two
kinds of document:
- ``lead_research/<topic>/*`` — methodology papers, CMS model
specifications and research articles, catalogued in ``sources.csv``
(title, authors, year, URL, sha256, topic).
- ``lead_enrichment/<dataset>/**/{dictionary,reference}__*`` — data
dictionaries and methodology references for the staged datasets,
catalogued in ``_catalogs/*_jobs.json`` (title, publisher, release,
download URL, how LEAD could use it), plus the eCQM HTML specs.
Each distinct document (by sha256) becomes one bib ``source`` item with
its file attached, tagged ``source:lead-corpus`` and filed under the
"LEAD corpus" collection. The datasets themselves are not documents and
stay out of the library.
Skipped on purpose:
- ``dictionary__*`` files that are byte-identical copies of
data.cms.gov's JavaScript app shell ("You need to enable JavaScript
to run this app") — the fetch saved the page skeleton, not the
dictionary, so there is nothing to file.
- text extracts that sit next to a research PDF of the same name — they
are derived from the PDF, which the library indexes itself.
Re-running is safe: items match on URL (or, without a public URL, on
title within ``source:lead-corpus``) and attachments on file name.
Usage (from the main checkout, so ``db.bib`` is the real library)::
uv run python dev/scripts/import_lead_corpus.py [--root lead_corpus] [--dry-run]
Then push to Zotero::
docker exec api uv run --no-sync \\
stack bib sync-zotero --tag source:lead-corpus --hold-zotero
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import re
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
TAG = "source:lead-corpus"
COLLECTION = "LEAD corpus"
# data.cms.gov's SPA shell, saved 81 times in place of real dictionaries.
SPA_SHELL_MD5 = "71f2ae0beed162e2dbcb8d8d9445c890"
RESEARCH_TOPICS = {
"benchmark": "Benchmark",
"cms_primary": "CMS primary sources",
"ml_sota": "Machine learning methods",
"quality": "Quality",
"risk_adjustment": "Risk adjustment",
"settlement": "Settlement",
}
DOC_SUFFIXES = {".pdf", ".html", ".htm", ".txt", ".xlsx", ".xls", ".zip", ".docx"}
INSTITUTION = re.compile(
r"\b(CMS|CMMI|MedPAC|NORC|RTI|Yale|YNHHSC|CORE|Board|eCFR|Congress|ASPE|PTAC|"
r"Office|Center|University|International|Commission)\b"
)
@dataclass
class Doc:
path: Path
sha256: str
title: str
url: str = ""
institution: str = ""
authors: list[str] = field(default_factory=list)
date: str = ""
doc_type: str = ""
abstract: str = ""
tags: list[str] = field(default_factory=list)
collection: str = ""
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
def _md5(path: Path) -> str:
return hashlib.md5(path.read_bytes()).hexdigest() # noqa: S324 — identity, not security
def split_authors(raw: str) -> tuple[list[str], str]:
"""Split a sources.csv author cell into (people, institution).
People come back as ``"Last First"`` strings, the shape
``bib.sync`` parses from an ``Authors:`` line. A cell naming only an
organisation ("CMS Innovation Center") is an institution; a trailing
parenthetical on a list of people ("... (RTI International)") is the
institution behind them.
"""
raw = raw.strip()
inst = ""
m = re.search(r"\(([^)]*)\)\s*$", raw)
if m:
inst, raw = m.group(1).strip(), raw[: m.start()].strip()
if not raw:
return [], inst
if INSTITUTION.search(raw) and not inst:
return [], raw
people = []
for part in raw.split(","):
name = re.sub(r"\bet al\.?$", "", part).strip()
if name:
people.append(name)
return people, inst
def research_docs(root: Path) -> tuple[list[Doc], list[str]]:
base = root / "lead_research"
rows = list(csv.DictReader((base / "sources.csv").open(encoding="utf-8")))
# sources.csv lists a document once per topic folder it was filed in
# (same bytes, same title), so a file can stand for several rows.
by_sha: dict[str, list[dict]] = {}
by_name: dict[str, list[dict]] = {}
for r in rows:
if r["sha256"].strip():
by_sha.setdefault(r["sha256"].strip(), []).append(r)
by_name.setdefault(Path(r["local_path"]).name, []).append(r)
docs, notes = [], []
seen_rows: set[int] = set()
for path in sorted(base.rglob("*")):
if not path.is_file() or path.suffix.lower() not in DOC_SUFFIXES:
continue
if path.suffix.lower() == ".txt" and any(
path.with_suffix(s).exists() for s in (".pdf", ".PDF")
):
continue # text extract of a PDF we attach anyway
topic_dir = path.relative_to(base).parts[0]
sha = _sha256(path)
matched = by_sha.get(sha) or by_name.get(path.name) or []
if matched:
row = matched[0]
seen_rows.update(id(r) for r in matched)
people, inst = split_authors(row["authors"])
topics = list(
dict.fromkeys(
t.strip()
for r in matched
for t in r["topic"].split(";")
if t.strip()
)
)
short = [t for t in topics if re.fullmatch(r"[a-z0-9_]+", t)]
detail = [t for t in topics if t not in short]
url = row["url"].strip()
docs.append(
Doc(
path=path,
sha256=sha,
title=row["title"].strip(),
url=url if url.startswith("http") else "",
institution=inst,
authors=people,
date=row["year"].strip(),
doc_type="methodology / research",
abstract=("LEAD use: " + "; ".join(detail)) if detail else "",
tags=[
f"lead-topic:{t}" for t in dict.fromkeys([topic_dir, *short])
],
collection=RESEARCH_TOPICS.get(topic_dir, "Research"),
)
)
else:
notes.append(f"research file not in sources.csv: {path.relative_to(root)}")
docs.append(
Doc(
path=path,
sha256=sha,
title=path.stem.replace("_", " "),
doc_type="methodology / research",
tags=[f"lead-topic:{topic_dir}", "lead:uncatalogued"],
collection=RESEARCH_TOPICS.get(topic_dir, "Research"),
)
)
for r in rows:
if id(r) not in seen_rows:
notes.append(f"sources.csv row without a file: {r['title'][:80]}")
return docs, notes
def _jobs(root: Path) -> list[dict]:
jobs: list[dict] = []
for name in ("pdc_jobs.json", "cms_datajson_jobs.json", "other_jobs.json"):
p = root / "lead_enrichment" / "_catalogs" / name
if p.exists():
jobs.extend(json.loads(p.read_text(encoding="utf-8")))
return jobs
def enrichment_docs(root: Path) -> tuple[list[Doc], list[str]]:
base = root / "lead_enrichment"
# A job's relpath is either relative to the dataset folder
# ("2026_10_01/dictionary__X.pdf") or a bare/ID-prefixed file name
# ("dictionary__DOC_Data_Dictionary.pdf"); match on either.
jobs: dict[tuple[str, str], dict] = {}
for j in _jobs(root):
rel = j.get("relpath", "")
jobs.setdefault((j.get("source_id", ""), rel), j)
jobs.setdefault((j.get("source_id", ""), Path(rel).name), j)
docs, notes = [], []
shells = 0
candidates = [
p
for p in sorted(base.rglob("*"))
if p.is_file()
and (
p.name.startswith(("dictionary__", "reference__"))
or (p.parent.name == "ecqi_ecqm_specs" and p.suffix.lower() == ".html")
)
]
for path in candidates:
if path.stat().st_size < 10_000 and _md5(path) == SPA_SHELL_MD5:
shells += 1
continue
source_id = path.relative_to(base).parts[0]
kind, _, relpath = path.name.partition("__")
if not relpath: # eCQM spec, no kind prefix
kind, relpath = "specification", path.name
rel = path.relative_to(base / source_id).as_posix()
job = jobs.get((source_id, rel)) or jobs.get((source_id, path.name))
# No row for this file (the Provider Data Catalog dictionaries):
# borrow publisher and release from the dataset's other entries.
sibling = job or next(
(j for (sid, _), j in jobs.items() if sid == source_id), {}
)
if job:
dataset = job.get("title", source_id).split(" : ")[0].strip()
else:
dataset = (
source_id.removeprefix("cms_")
.removeprefix("pdc_")
.replace("_", " ")
.strip()
.capitalize()
)
if source_id.startswith("pdc_"):
dataset = f"Provider Data Catalog: {dataset.lower()}"
label = {
"dictionary": "data dictionary",
"reference": "methodology reference",
"specification": "eCQM specification",
}.get(kind, kind)
if kind == "specification":
m = re.match(r"(CMS\d+)-v([\d.]+?)\.0+\.000-(\w+)", path.stem)
title = (
f"{m.group(1)}v{m.group(2)} eCQM specification ({m.group(3)})"
if m
else path.stem
)
else:
title = f"{dataset} — {label}: {Path(relpath).stem.replace('_', ' ')}"
docs.append(
Doc(
path=path,
sha256=_sha256(path),
title=title,
url=(job or {}).get("url", ""),
institution=sibling.get("publisher", ""),
date=sibling.get("release", ""),
doc_type=label,
abstract=("LEAD use: " + job["use"]) if job and job.get("use") else "",
tags=[f"lead-dataset:{source_id}", f"doc:{kind}"],
collection=(
"Measure specifications"
if kind == "specification"
else "Dataset documentation"
),
)
)
if job is None:
notes.append(f"no catalog entry: {path.relative_to(root)}")
notes.append(
f"skipped {shells} data.cms.gov app-shell placeholders (no dictionary content)"
)
return docs, notes
def dedupe(docs: list[Doc]) -> tuple[list[Doc], int]:
"""One item per distinct file: the same dictionary often sits in every
release folder of a dataset. Keep the newest release's copy, but carry
every dataset tag."""
best: dict[str, Doc] = {}
for d in docs:
cur = best.get(d.sha256)
if cur is None:
best[d.sha256] = d
continue
keep, other = (d, cur) if d.date > cur.date else (cur, d)
keep.tags = list(dict.fromkeys([*keep.tags, *other.tags]))
best[d.sha256] = keep
return list(best.values()), len(docs) - len(best)
def _existing_by_title(store, title: str) -> str | None:
row = (
store._con()
.execute(
"""SELECT i.key FROM items i
JOIN item_tags it ON it.item_id = i.id
JOIN tags t ON t.id = it.tag_id
WHERE t.name = ? AND i.title = ? LIMIT 1""",
(TAG, title),
)
.fetchone()
)
return row["key"] if row else None
def import_docs(docs: list[Doc]) -> Counter:
from bib.item import Source
from bib.store import Store
store = Store()
cols = store.ensure_collections(
{
COLLECTION: {
"Research": {name: {} for name in RESEARCH_TOPICS.values()},
"Dataset documentation": {},
"Measure specifications": {},
}
}
)
stats: Counter = Counter()
for d in docs:
extra = f"Authors: {'; '.join(d.authors)}" if d.authors else ""
extra = "\n".join(filter(None, [extra, f"sha256: {d.sha256}"]))
item = Source(
title=d.title,
url=d.url,
institution=d.institution,
date_published=d.date,
abstract=d.abstract,
extra=extra,
doc_type=d.doc_type,
)
if not d.url and (key := _existing_by_title(store, d.title)):
item.key = key
store.add_tags(key, [TAG, *d.tags])
status = "unchanged"
else:
key, status = store.upsert_status(
item, tags=[TAG, *d.tags], collection=cols[d.collection]
)
stats[status] += 1
before = (
store._con()
.execute(
"SELECT count(*) FROM attachments a JOIN items i ON i.id = a.item_id "
"WHERE i.key = ?",
(key,),
)
.fetchone()[0]
)
store.attach_file(key, d.path)
after = (
store._con()
.execute(
"SELECT count(*) FROM attachments a JOIN items i ON i.id = a.item_id "
"WHERE i.key = ?",
(key,),
)
.fetchone()[0]
)
stats["attached" if after > before else "attachment-present"] += 1
return stats
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--root", type=Path, default=Path("lead_corpus"))
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
research, rnotes = research_docs(args.root)
enrich, enotes = enrichment_docs(args.root)
docs, dupes = dedupe(research + enrich)
for n in rnotes + enotes:
print("note:", n)
by_col = Counter(d.collection for d in docs)
print(
f"{len(docs)} distinct documents ({len(research)} research files, "
f"{len(enrich)} dataset docs, {dupes} duplicate copies folded)"
)
for col, n in sorted(by_col.items()):
print(f" {col}: {n}")
print(f" without a public URL: {sum(not d.url for d in docs)}")
if args.dry_run:
return 0
stats = import_docs(docs)
print("result:", dict(stats))
return 0
if __name__ == "__main__":
raise SystemExit(main())