docs/data-directory.md surveyed the real scraped corpus that now
exists on disk: 74 real municipalities, 525 scrape attempts
(data/budget_files.csv/.json), 512 downloaded files under
data/{muni_id}_{name}/{year|general}/. Every downstream design doc in
this repo (docs/handshake-level2-level3.md, ADR-0004, the level-2
tests) was written assuming level-1 scraping did not exist yet —
muni_id 901-910 are explicitly synthetic placeholders throughout. Issue
#8 already tracks this gap ("No Level-1 manifest yet mapping muni_id ->
real budget file URLs").
This PRD turns "we now have real scraped data" into an executable task
list: bridge data/ into run.py's (level 2) expected input shape, run
the real corpus through level 2 + level 2.5, and load the real dimension
tables (muni, classification_code) into Supabase.
Scope boundary — read before touching anything downstream of level 2.5:
level 3 (file analysis) and the level-3-to-Postgres handshake
(budget/budget_line_item tables, line_items.json, issue #13) belong
to a different team. Nothing here builds level 3, resolves its open
design questions, or edits docs/handshake-level2-level3.md /
docs/handshake-level3-postgres.md. Where our work would otherwise force
a change to either handshake, we work around it on our side and document
the patch instead (see Task 1's "Known workaround" below) — never edit
those docs to make our life easier.
Tasks 1, 2, 5 are ordered (5 gates 3; 1 gates 3). Task 4 is independent — can run any time, in parallel with everything else.
Goal: produce the [{muni_id, budget_filename, source: {kind, value}}]
array run.py/ingest.py already expect (resolve_source() in
src/muni_budget_analysis/processing/ingest.py), sourced from the real
corpus instead of budget_examples/.
- New script:
scripts/build_level1_manifest.py. Walksdata/on disk directly — do not trustbudget_files.csv/.json'sdownloaded_pathcolumn, it's wrong (data/budgets/{muni}/...recorded vs. the realdata/{muni}/..., confirmed against the actual tree indocs/data-directory.md). Skip.DS_Store. - For every
data/{muni_id}_{muni_name}/{year}/{file}(year folders only —general/is Task 2, not this task), emit one record:{muni_id: int, budget_filename: "{muni_slug}_{year}{ext}", source: {kind: "local", value: "data/{muni_id}_{muni_name}/{year}/{file}"}}. - Cross-reference
budget_files.csvby(municipality_code, budget_year)formunicipality_name/municipality_type/website_url— useful provenance, carry it in the manifest as extra (non-contract) fields if convenient, but the four contract fields above are whatingest.pyactually reads. - Known workaround (document, don't hide): real filenames are
source-site timestamps (
1541058045.2762.pdf) with no year in them. Level 2.5'sextract_year()(scripts/run_scope_classify.py) parses the target fiscal year fromsource_filename— unchanged by this task. Instead, this adapter synthesizesbudget_filenameto encode the year ({muni_slug}_{year}{ext}), sosource_filenamedownstream still satisfies the existing contract. The filename level 3 eventually reads is therefore project-assigned, not the real source filename. Add a docstring at the synthesis site inbuild_level1_manifest.pystating exactly this, so a future cleaner fix (e.g. an explicitfiscal_yearfield instead of filename parsing) has a paper trail. Do not changeextract_year()or either handshake doc as part of this task. - Verify: no muni/year in the real corpus has more than one non-
.DS_Storefile (already confirmed true as of this PRD — re-check ifdata/changes), so no multi-file-per-slot handling is needed. - Excludes
general/files entirely (Task 2's problem, not this task's). - Deliverable:
scripts/build_level1_manifest.py+ a way to invoke it (CLI, writes e.g.data/level1_manifest.json— gitignored, matchesdocs/examples/level1-scraping/README.md's existing mention of that filename shape).
Goal: decide what to do with the 25 munis' general/ files (9 of
which have no year folder at all — zero other data for those munis).
- These files carry no year signal anywhere — not in filename, not in directory structure (unlike the year-folder case Task 1 handles).
- Options to evaluate (not decided by this PRD — that's this task's point): open each file and read a title/header for the year (manual or LLM-assisted), or leave them out of the manifest entirely until a real year-detection approach exists.
- Independent of Task 1 — does not block or get blocked by it.
- Deliverable: a decision + (if not "defer") an extension to
build_level1_manifest.pyor a follow-up script.
Goal: run the existing, unmodified pipeline against the real
manifest, producing real normalized.json/scoped.json per real
muni_id for handoff to the level-3 team.
- Inputs: Task 1's manifest. Gated on Task 5's two bug fixes landing first (both are pre-flight risks for a run this size).
run.py'srun_batch()— unchanged, perprd.md's Task 7 (already built, sequential/single-process, no worker pool).scripts/run_scope_classify.py— unchanged, still POC-shaped per ADR-0003, still capped at--max-tables-per-doc(default 3) as a cost/quota guard.- Scale/cost planning needed before running: 992MB across the corpus,
411 PDFs (some scanned, some 100+pp — per the GPU-tier precedent in
activity.md's Tier B/C runs againstbudget_examples/, this is not a laptop-scale run). Level 2.5 is one live LLM call per classified table; at ~65 munis (74 minus the 9general/-only, before Task 2 lands) this is real spend, not a rerun of the existing 10-document POC. - Output (
normalized.json/scoped.jsonper real muni_id) is handed off as-is — this task does not consume it further; that's level 3's job. - Deliverable: real
data/processed/{muni_id}/{filename_stem}/*for as much of the corpus as succeeds, plus a batch summary (success/partial/ failed counts) matching the style ofactivity.md's prior runs.
Goal: replace the synthetic muni/classification_code seed data in
Supabase (supabase/seed.sql, currently muni_id 901-903 and a partial
code slice) with the real thing. Pure dimension tables — no
budget/budget_line_item involvement, so this doesn't touch the
level-3/DB handshake at all.
classification_code: promote the full ~672-code taxonomy frompipeline/analysis/moi_budget_codes.json(already parsed byscripts/parse_codebook.py) into theclassification_codetable — per ADR-0004, "promote that into this table rather than re-sourcing."muni: real rows for the 74 munis found indata/(name/type frombudget_files.csv), plus — for full coverage of the expected muni x year matrix perCONTEXT.md's "Budget coverage" term — the complete ~200-250 municipality list fromsrc/muni_budget_analysis/scrapers/localities.py's output, not just the 74 with a found budget file.- Deliverable: a migration/seed update (or a one-off load script) plus
confirmation the existing schema
(
supabase/migrations/20260730221726_init_schema.sql) needs no changes — it already matches ADR-0004.
Goal: fix two real bugs surfaced by activity.md's prior GPU runs,
before they hit a 400+ file real batch.
TESSERACT_CMDhardcoded Windows path:pdf_pipeline.py'sdocling_pdf_ocrpath defaults toC:\Program Files\Tesseract-OCR\tesseract.exe, which crashes at model-init on any Linux/GCP host unlessTESSERACT_CMDis set via env var. Fix: cross-platform default (e.g. resolve viaPATH) or fail fast with a clear error instead of a hardcoded Windows default.run.pyoutput-dir collision: output directory isPath(budget_filename).stem, so a muni-year with both a.pdfand an.xlsxwould collide and overwrite. Not observed in the real corpus today, but Task 1's synthesized filenames make this a live risk to double-check. Fix: include the extension (orpipeline_used) in the output-dir key.- Deliverable: both fixes landed in
src/muni_budget_analysis/processing/, with regression coverage per whatever this repo's existing test conventions are (tests/test_run_e2e.pyand neighbors).
- Task 1: run
build_level1_manifest.pyagainst realdata/, confirm every emitted record'ssource.valuepath actually exists on disk and everybudget_filenameparses back to the correct year via the same regexextract_year()uses. - Task 3: after a real run, spot-check a handful of
manifest.jsonstatusfields and confirmnormalized.jsonexists for everystatus != failedrecord. - Task 4: query
select count(*) from classification_code(~672) andselect count(*) from muni(~200-250) against the dev Supabase project. - Task 5: add/extend unit tests exercising both bug scenarios (a
Linux-style env without the Windows tesseract path; a synthetic
manifest with both a
.pdfand.xlsxfor the same muni/year).