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-cpt-canonical-schema-design.md (the books' structure and the derived schema) and docs/superpowers/specs/2026-09-09-code-family-longitudinal-design.md §Decisions 1–3. This slice supplies the organizing principle the family derivation lacked (Ruling: the CPT's own hierarchy outranks stem-token similarity).
Global Constraints
- The CPT items are corpus documents like any other. They are imported into bib with their files and indexed into the chat corpus; the
llm:skiptag is a generic capability and is NOT applied to them (Ruling C4). Tests use a synthetic fixture in the manual's markup style. - 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 |
src/llm/source.py (modify) |
iter_corpus_refs skips items tagged llm:skip (generic capability; not used for AMA items) |
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 [--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→ report the bib keys of the six CPT editions (2018, 2019, 2021, 2022, 2023 Changes, 2024). - After import:
stack llm index --collection corpuspicks the CPT items up on its next run (no special handling). - 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), plus the appendix-derived tables from the schema spec docs/superpowers/specs/2026-09-09-cpt-canonical-schema-design.md §3: pfs.cpt_change(code, kind, old_text, new_text, section) from Appendix B, pfs.cpt_crosswalk(current_code, former_code, year_deleted, citations) from Appendix M, pfs.cpt_list(appendix, code) from Appendices D/E/F/G/K/N/P/T, pfs.cpt_modifier(modifier, title, text) from Appendix A. 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.