18 KiB
CPT Manual Organizing Principles (P49 slice 2a) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bring the AMA CPT manuals (2019–2024 EPUB editions in Zotero) into the bibliography and DuckDB as the CPT's own organizing structure — section hierarchy, code entries with symbols and required elements, parenthetical instructions, and per-code "CPT Changes" years — and make that structure the primary edge in family derivation, so every CPT code gets a family named the way the manual names it.
Architecture: A pure EPUB parser (pfs/cpt_epub.py) turns one edition into dataclasses; a loader (pfs/cpt_load.py) writes four pfs.cpt_* tables through duckdb_batch with the bib item key of the edition as provenance; pfs/families.py gains a "same CPT subsection" edge and takes family key/name from the CPT heading; pfs/lineage.py gains cpt_changed events from the per-code reference line; pfs/extract.py gains a CPT source for elements (the bulleted required-elements list is literally the element list). A small Zotero→bib import command brings the edition items and files into the bibliography so tables can cite them.
Tech Stack: Python 3.13 stdlib zipfile + html.parser (no new dependency; lxml/bs4 only if already in uv.lock — check), DuckDB via duckdb_batch, SQLite bib.Store, Zotero SQLite read-only via conf.connect.zotero(), typer, pytest.
Spec: docs/superpowers/specs/2026-09-09-code-family-longitudinal-design.md §Decisions 1–3 (elements, lineage, derived families). This slice supplies the organizing principle the spec's family derivation lacked (Ruling: the CPT's own hierarchy outranks stem-token similarity).
Global Constraints
- Copyright. CPT content is AMA-licensed material the user owns. It is stored only in the local DuckDB and replica for the user's own analysis. It is never indexed into the chat's pgvector corpus in this slice (the CPT bib items carry a tag the corpus indexer skips), never printed into docs, the tracker, or commit messages, and the notebook shows at most one code entry at a time as an illustration. Tests use a synthetic fixture in the manual's markup style, not copied CPT text.
- Files are read in place from Zotero storage (
data/zotero/data/storage/<attKey>/<file>, owned by the Zotero container uid — read-only, never chown) or from the bib copyattach_filemakes. pfs/cpt_epub.pyandpfs/cpt_model.pyare pure (no DuckDB, no bib at import). Writes only insideduckdb_batch("aco"),publish_replica("aco")after.- Hand families remain a minimum (Ruling 17); CPT subsection edges are additive to the slice-1 edges.
- Never add a Co-Authored-By trailer. Commit after every task. Test output pristine.
File map
| File | Responsibility |
|---|---|
src/bib/zotero_import.py (new) + src/cli/bib.py (modify) |
stack bib import-zotero --collection "AMA Coding Publications" --with-files [--tag …]: Zotero items (+ attachments) → bib Source items tagged source:ama module:coding year:YYYY llm:skip |
src/llm/source.py (modify) |
iter_corpus_refs skips items tagged llm:skip |
src/pfs/cpt_model.py (new) |
dataclasses CptSection, CptCode, CptInstruction, CptReference, CptEdition |
src/pfs/cpt_epub.py (new) |
parse_epub(path) -> CptEdition (2021+ template; 2019 template via the same table classes) |
src/pfs/codetables.py (modify) |
DDL + writers/readers for pfs.cpt_section, pfs.cpt_code, pfs.cpt_instruction, pfs.cpt_reference |
src/pfs/cpt_load.py (new) + src/cli/pfs.py (modify) |
`stack pfs cpt-ingest [--edition 2024 … |
src/pfs/families.py (modify) |
CPT subsection edge; key/name from the CPT heading; add-on edges from "Use X in conjunction with Y" |
src/pfs/lineage.py (modify) |
cpt_changed events from pfs.cpt_reference |
src/pfs/extract.py (modify) |
extract_code also reads pfs.cpt_code elements (source="cpt") |
notebooks/code_families.py (modify) |
section 1 shows the CPT heading path + CPT elements; section 4 shows the CPT subsection |
tests: tests/bib/test_zotero_import.py, tests/pfs/test_cpt_epub.py (+ tests/pfs/fixtures/cpt_sample.xhtml), tests/pfs/test_cpt_load.py, tests/pfs/test_families.py (extend), tests/pfs/test_lineage.py (extend), tests/pfs/test_extract.py (extend), tests/cli/test_pfs_cli.py (extend) |
Task 1: Zotero → bib import of the CPT editions (with files) and corpus skip tag
Files:
- Create:
src/bib/zotero_import.py; Modify:src/cli/bib.py,src/llm/source.py(iter_corpus_refs) - Test:
tests/bib/test_zotero_import.py,tests/llm/test_source.py(extend)
Interfaces:
@dataclass(frozen=True) class ZoteroBook: key: str; title: str; url: str; year: str; publisher: str; attachments: tuple[Path, ...]
def list_collection(zcon, name: str, *, with_files: bool) -> list[ZoteroBook] # sqlite3 connection from conf.connect.zotero(); resolves storage:<file> → storage/<attKey>/<file>
def import_books(store, books, *, tags: Sequence[str], copy_files: bool = True, dry_run=False) -> dict # Source items upserted by url (fallback url = f"zotero://select/library/items/{key}"), tags + year:YYYY, attach_file per attachment; {"created","updated","attached"}
iter_corpus_refs (and any other corpus walker) skips items tagged llm:skip. CLI: stack bib import-zotero --collection "AMA Coding Publications" --with-files --tag source:ama --tag module:coding --tag llm:skip [--only "CPT"] [--dry-run] (--only = title substring filter).
- Failing tests:
list_collectionagainst a tiny sqlite built with the Zotero schema subset (items, itemData, itemDataValues, fields, collections, collectionItems, itemAttachments, itemTypes) — two books, one with astorage:attachment;import_booksinto a tmpStorecreates Source items with the tags and attaches the file (copy); rerun →updated/unchanged, no duplicate attachment;iter_corpus_refsskips an item taggedllm:skip. - Implement; run
uv run pytest tests/bib tests/llm/test_source.py -q; live:uv run stack bib import-zotero --collection "AMA Coding Publications" --with-files --only CPT --tag source:ama --tag module:coding --tag llm:skip→ report the bib keys of the six CPT editions (2018, 2019, 2021, 2022, 2023 Changes, 2024). - Commit:
feat(bib): import Zotero collection items with files into bib; llm:skip tag honoured by the corpus indexer (refs #688).
Task 2: CPT EPUB parser (pure)
Files:
- Create:
src/pfs/cpt_model.py,src/pfs/cpt_epub.py,tests/pfs/fixtures/cpt_sample.xhtml(synthetic, in the 2024 template's markup style: twodiv.h1, twodiv.h2, onetable.table1with a primary code row with threetable-slistelements, an add-on row with✚, threetable-para2parentheticals (use-with, do-not-report-with, plain), and atable-RT"CPT Changes" line) - Test:
tests/pfs/test_cpt_epub.py
Interfaces:
@dataclass(frozen=True) class CptSection: sec_id: str; level: int; title: str; path: tuple[str, ...]; code_lo: str; code_hi: str; guideline: str # path = ancestors' titles + own; lo/hi from the TOC "(99490-99437)" when present
@dataclass(frozen=True) class CptCode: code: str; sec_id: str; descriptor: str; stem: str; elements: tuple[str, ...]; tail: str; addon: bool; resequenced: bool; new: bool; revised: bool; telemedicine: bool; parent: str
# sub-rows ("each additional …") inherit stem/elements from the preceding primary row; parent = that primary code
@dataclass(frozen=True) class CptInstruction: code: str; kind: str; text: str; targets: tuple[str, ...] # kind ∈ {"use-with","not-with","not-with-time","see","other"}; targets = codes (ranges expanded ≤ 20)
@dataclass(frozen=True) class CptReference: code: str; kind: str; years: tuple[int, ...]; text: str # kind ∈ {"cpt-changes","cpt-assistant"}
@dataclass(frozen=True) class CptEdition: year: int; sections: tuple[CptSection, ...]; codes: tuple[CptCode, ...]; instructions: tuple[CptInstruction, ...]; references: tuple[CptReference, ...]
def parse_epub(path: Path, *, year: int | None = None) -> CptEdition
def parse_xhtml(text: str, *, year: int) -> tuple[list[CptSection], list[CptCode], list[CptInstruction], list[CptReference]] # one body file
Parsing rules (from the 2024/2022/2021 template): headings are <div class="h1|h2|h3|h4" id="sec_N">TITLE</div> (the * and range are in the TOC files *_Toc.xhtml, whose div.hN-toc entries carry TITLE* (LO-HI) — read them for lo/hi); guideline text = the div.noindent paragraphs between a heading and the next table or heading; code rows = tr with td.td-w1[id=code_NNNNN] (2019: no id — fall back to the <b>NNNNN</b> in div.table-para); symbols in span.ama-en glyphs: # resequenced, ✚ add-on, ● new, ▲ revised, ★ telemedicine (also accept the 2019 glyph set — verify on the 2019 file and document what differs); descriptor = table-para1 (stem) + table-slist* items (elements) + first table-para2 that does not start with ( (tail, e.g. the time clause); sub-rows use table-para1-sub; parentheticals = table-para2 starting with (; use-with = (Use X in conjunction with …), not-with = (Do not report … in the same … with …), not-with-time = (Do not report … for service time reported with …), see = (For …, see …) / (… use NNNNN); references = table-RT* lines (CPT Changes: An Insider's View 2015, 2021, 2022 → years; CPT Assistant … kept as text). HTML entities and <span class="ssp"/> bullets are stripped; whitespace normalised; the <i> and <b> tags removed from text.
- Failing tests on the synthetic fixture: two sections with correct levels/paths and lo/hi from a synthetic TOC snippet; primary code has 3 elements and the tail; the add-on row has
addon=True,parent=<primary>, inherited elements;use-withtargets(primary,);not-withtargets expanded from a range90951-90970(20 codes) plus listed codes;cpt-changesyears(2015, 2021, 2022); entities/whitespace cleaned. Plus an integration test@pytest.mark.skipif(not Path(EPUB_2024).exists())thatparse_epubon the real 2024 file yields ≥ 8,000 codes, the section titled "Chronic Care Management Services" contains 99490/99439/99491/99437, and 99439 is an add-on whoseuse-withtarget is 99490 — asserting only structure, never descriptor text. - Implement with
html.parser.HTMLParser(stdlib) building a flat event list, then a second pass grouping rows; keepparse_xhtmlindependently testable. - Commit:
feat(pfs): CPT EPUB parser — sections, code entries with symbols and elements, parenthetical instructions, CPT Changes references (refs #687).
Task 3: pfs.cpt_* tables and stack pfs cpt-ingest
Files:
- Modify:
src/pfs/codetables.py(four DDLs,write_cpt_edition(con, edition, item_key),read_cpt_codes(con, year),read_cpt_sections(con, year)), Create:src/pfs/cpt_load.py, Modify:src/cli/pfs.py - Test:
tests/pfs/test_cpt_load.py,tests/cli/test_pfs_cli.py(extend)
Tables (all with edition_year INTEGER, item_key VARCHAR first): pfs.cpt_section(sec_id, level, title, path, code_lo, code_hi, guideline), pfs.cpt_code(code, sec_id, descriptor, stem, elements, tail, addon, resequenced, new, revised, telemedicine, parent) (elements space-joined? No — elements is a VARCHAR[] list; DuckDB supports it), pfs.cpt_instruction(code, kind, text, targets) (targets VARCHAR[]), pfs.cpt_reference(code, kind, years INTEGER[], text). Delete-then-insert per edition_year.
cpt_load.ingest(store, con, *, years: Sequence[int] | None) -> dict: finds bib items tagged source:ama whose title matches CPT (Professional )?(\d{4}) with an EPUB attachment, parses each, writes, returns rows per table per year. CLI stack pfs cpt-ingest [--edition 2024]… [--all] [--dry-run]; --dry-run parses and prints counts only.
- Failing tests: DDL idempotent; write/read round trip for a two-code synthetic edition;
ingestwith a fake store/attachment path pointing at a tiny EPUB built in the test (zip the fixture xhtml + a minimal container) writes rows; CLI test with_batch/_read/_store/_publishmonkeypatched as in slice 1. - Implement; live:
stack pfs cpt-ingest --all(2019, 2021, 2022, 2024 EPUBs; 2023 Changes is a different book — skip it here, note for later) → report rows per table per year, and one sanity query per year: the section holding 99490 and the count of add-on codes. - Commit:
feat(pfs): pfs.cpt_section/cpt_code/cpt_instruction/cpt_reference + stack pfs cpt-ingest (refs #687).
Task 4: Families from the CPT hierarchy
Files: Modify src/pfs/families.py, src/cli/pfs.py (families command feeds CPT data), Test tests/pfs/test_families.py, tests/cli/test_pfs_cli.py
Ruling (binding): for CPT codes the family is the lowest CPT heading that groups ≥ 2 codes in the newest ingested edition (e.g. "Chronic Care Management Services" for 99490/99439/99491/99437; "Complex Chronic Care Management Services" for 99487/99489). Its key is a slug of the heading title (CHRONIC-CARE-MANAGEMENT-SERVICES), its name the heading title, and since = the earliest edition year the code appears in pfs.cpt_code. Slice-1 edges still apply on top (so G2058 still joins via its single-target replacement, and use-with instructions from pfs.cpt_instruction count as add-on edges). Hand families keep their keys and names: when a CPT heading's group intersects a hand family, the hand key/name wins for that component (Ruling 17), and the CPT heading title is stored as the family's note. Codes not in any CPT edition (HCPCS G-codes) keep the slice-1 derivation.
Interfaces:
def cpt_groups(cpt_codes: Sequence[CptCodeRow], cpt_sections: Sequence[CptSectionRow]) -> dict[str, tuple[str, str, tuple[str, ...]]] # sec_id -> (key, title, codes) for the lowest heading with ≥2 codes
def derive_families(elements, events, descriptions, *, cpt_codes=(), cpt_sections=(), cpt_instructions=()) -> list[FamilyRow] # new keyword inputs; existing calls unchanged
FamilyRow gains note: str = "" (append to the DDL and dataclass — last field, default, so slice-1 readers keep working; read_families reads it).
- Failing tests: a synthetic edition with "Care Management Services" (h1) → "Chronic Care Management Services" (h2, 99490 99439 99491 99437) and "Complex …" (h2, 99487 99489):
cpt_groupsreturns the two h2 groups, not the h1;derive_familieswith those inputs and the slice-1 CCM fixture yields the hand keyCCMfor the merged component withnote == "Chronic Care Management Services"(99487/99489 join via slice-1 stem+activity as before); a synthetic "Remote Physiologic Monitoring Treatment Management Services" heading with 99457/99458 and no elements yields keyREMOTE-PHYSIOLOGIC-MONITORING-TREATMENT-MANAGEMENT-SERVICESwithsince= edition year. - Implement; live:
stack pfs families --write→ report: total families, families with ≥ 2 codes (expect thousands now), the five hand families' membership and notes, and five sample CPT-named families. - Commit:
feat(pfs): families from the CPT manual hierarchy — lowest multi-code heading names the family; use-with edges (refs #687).
Task 5: CPT Changes years as lineage events and CPT elements in extraction
Files: Modify src/pfs/lineage.py (cpt_events(con, code) from pfs.cpt_reference kind cpt-changes → EventRow(kind="cpt_changed", year=Y, source="cpt", item_key=<edition bib key>, anchored=True); lineage() merges them and an RVU event is also anchored by a cpt_changed within ±1 year), src/pfs/extract.py (extract_code adds ElementRows from the newest pfs.cpt_code row: parse_descriptor(stem + tail) plus each bulleted element parsed and, when the regex finds nothing, a ReviewRow with source="cpt"; FR rows still win on duplicates), src/cli/pfs.py (lineage prints the new kind).
- Failing tests for both; implement; live:
stack pfs lineage --code 99490 --writeshowscpt_changed2015/2021/2022 anchored to the 2024 edition item;stack pfs elements --family CCM --no-llmreview queue does not grow. - Commit:
feat(pfs): CPT Changes years as lineage events; CPT required-elements lists as an extraction source (refs #685 #686).
Task 6: Notebook — the CPT manual's own organizing principles
Files: Modify notebooks/code_families.py (section 1 gains "Where the manual files this code": the CPT heading path for the picked code from pfs.cpt_section/pfs.cpt_code and its instructions; section 4 shows the family's CPT heading note and the sibling headings under the same parent; a new short callout in section 0 that CPT's hierarchy is the first organizing principle and the FR is the second), tests/notebooks/test_code_families_nb.py (banner count unchanged; cells still degrade).
- Implement; headless export clean; commit
feat(notebooks): code_families — CPT hierarchy, instructions and family headings (refs #687).
Self-review
Coverage. User request "bring in the CPT manuals from Zotero so we have a better sense of organizing principles" → T1 (into bib), T2–T3 (structure into tables), T4 (organizing principle drives families), T5 (lineage/elements benefit), T6 (explainer). Copyright handling is a global constraint with a concrete mechanism (llm:skip, no text in docs/tracker/tests).
Placeholders. T2's parsing rules are exact selectors observed on the 2024/2022/2021 files; T1/T3–T6 give signatures, rules and test intents (mid-tier implementers).
Types. FamilyRow.note is appended last with a default so slice-1 readers/writers keep working; EventRow.kind="cpt_changed" and source="cpt" reuse the existing dataclass; ElementRow.source="cpt" reuses the existing column.