|
| 1 | +# Lamas |
| 2 | + |
| 3 | +Downloads, parses, and normalizes the Israeli Central Bureau of Statistics ("Lamas") yearly |
| 4 | +municipal statistics workbooks (1999-2024, one Excel file per year) into tidy |
| 5 | +`(year, name, header, value, filename)` rows, ready to load locally or into Postgres. |
| 6 | + |
| 7 | +There is no Jupyter notebook and no Airtable dependency - everything runs through the `lamas` CLI, |
| 8 | +backed by two local YAML config files that are the only state that needs to persist between runs: |
| 9 | + |
| 10 | +- `data/sheet_config.yaml` - per-year/per-sheet Excel layout overrides (header row counts, which |
| 11 | + sheets to skip, etc.) |
| 12 | +- `data/header_mapping.yaml` - canonical header name -> list of raw header text variants seen |
| 13 | + across 26 years of shifting CBS spreadsheet layouts |
| 14 | + |
| 15 | +If you're looking for the deep history of *why* the pipeline works the way it does (the original |
| 16 | +pre-modernization behavior, bugs found along the way, etc.), see `docs/CURRENT_BEHAVIOR.md`. |
| 17 | + |
| 18 | +## Setup |
| 19 | + |
| 20 | +```bash |
| 21 | +pip install -e ".[dev]" # from inside Lamas/; [dev] adds pytest + the one-time Airtable seed script |
| 22 | +``` |
| 23 | + |
| 24 | +This registers the `lamas` console script. A `.env` file (not committed) holds |
| 25 | +`DATAFLOWS_DB_ENGINE` (a Postgres connection string) if you intend to push output there, and |
| 26 | +`AIRTABLE_API_KEY`, only needed for `scripts/seed_mapping_from_airtable.py` (a one-time migration |
| 27 | +helper, not part of normal operation). |
| 28 | + |
| 29 | +## The `lamas` CLI |
| 30 | + |
| 31 | +Every step of the pipeline is its own subcommand, so you can run the whole thing (`full-run`) or |
| 32 | +drop into just the step you need while iterating on a new year. |
| 33 | + |
| 34 | +| Command | What it does | |
| 35 | +|---|---| |
| 36 | +| `lamas download [--year Y]` | Download one year, or every year configured in `downloader.py` (idempotent - skips files already on disk). | |
| 37 | +| `lamas diagnose --year Y [--sheet NAME]` | Dry-run parse of a workbook against the *current* `sheet_config.yaml`, without writing anything. Prints, per sheet: row/column counts, detected name-column index, a preview of the first extracted headers, or the exact parse error. This is the tool for iteratively tuning `sheet_config.yaml` for a new or misbehaving year. | |
| 38 | +| `lamas config show --year Y` | Print the effective sheet config (defaults + overrides) for that year. | |
| 39 | +| `lamas config set-sheet --year Y --sheet NAME [--header-rows N] [--extend-top N] [--extend-bottom N] [--skip/--no-skip]` | Create/update one sheet's config entry; rewrites `sheet_config.yaml` deterministically. | |
| 40 | +| `lamas preprocess [--year Y] [--checkpoint PATH]` | Parse the downloaded workbook(s) and write a parquet checkpoint (default `.cache/preprocessed.parquet`) - this is the expensive step (~2-3 min for the full 1999-2024 corpus). | |
| 41 | +| `lamas map-headers [--year Y] [--strict]` | Resolve every row's raw header against `header_mapping.yaml` and (re)write `reports/pending_headers.csv` for anything that didn't resolve cleanly. `--strict` exits non-zero if any row is fully `unresolved` (useful in CI). | |
| 42 | +| `lamas mapping add --canonical "..." --orig "..."` | Add a raw header as a variant of a canonical (existing or brand new). | |
| 43 | +| `lamas mapping confirm-fuzzy --orig "..."` | Accept the fuzzy-match suggestion already recorded for a pending row in `pending_headers.csv`. | |
| 44 | +| `lamas mapping reject-fuzzy --orig "..." [--canonical "..."]` | Override a fuzzy suggestion: map to a different canonical, or omit `--canonical` to make it a brand-new one. | |
| 45 | +| `lamas stats [--year Y]` | Regenerate `reports/header_stats.{csv,md}` - per-header row counts and year coverage, the local replacement for eyeballing the old Airtable "Stats" table. | |
| 46 | +| `lamas build [--strict] [--output local,postgres] [--output-dir PATH]` | Apply `specific_fixes`/`value_fixes` and write the final output. `--strict` refuses to build while any header is unresolved. Defaults to local-only output. | |
| 47 | +| `lamas full-run [--year Y] [--strict] [--output ...]` | `download` -> `preprocess` -> `map-headers` -> `stats` -> `build`, one shot. | |
| 48 | +| `lamas qa` | Runs the full pytest suite under `tests/` - the automated quality gate (see below). | |
| 49 | + |
| 50 | +Run `lamas <command> --help` for the full flag list on anything above. |
| 51 | + |
| 52 | +### Quality assurance |
| 53 | + |
| 54 | +`lamas qa` (or plain `pytest tests/`) runs the same checks a human used to do by eye in Airtable's |
| 55 | +"Stats" table, now automated: no unresolved headers, no near-duplicate or unit-mixed canonicals in |
| 56 | +`header_mapping.yaml`, no duplicate header collisions within a sheet, sane per-year/sheet row |
| 57 | +counts, and year-over-year header coverage (a header reported in 5 straight years must still be |
| 58 | +reported in the 6th, catching silently broken extractions - see `tests/test_quality_report.py` for |
| 59 | +the full list and the reasoning behind each check, including the small set of already-investigated |
| 60 | +exceptions that are allowed to stay). |
| 61 | + |
| 62 | +CI (`.github/workflows/lamas-tests.yml`) runs this same suite on every PR touching `Lamas/`, |
| 63 | +downloading and preprocessing the real data first (cached between runs) so the data-dependent |
| 64 | +checks actually execute rather than skip. |
| 65 | + |
| 66 | +## The `lamas-ingest` skill |
| 67 | + |
| 68 | +`.claude/skills/lamas-ingest/SKILL.md` is a Claude Code skill that walks through ingesting one |
| 69 | +year end-to-end, orchestrating the CLI above rather than reimplementing any parsing logic. Invoke |
| 70 | +it (or just ask Claude to "ingest year X") when a newly-published CBS workbook needs onboarding, or |
| 71 | +when an existing year has unresolved headers or config gaps. |
| 72 | + |
| 73 | +It encodes one hard rule worth knowing even if you're doing this by hand: **every unmapped header |
| 74 | +gets resolved one by one, by a tight heuristic or explicit semantic review - never in bulk, and |
| 75 | +never just because "no better candidate was found."** An earlier version of this process got this |
| 76 | +wrong once (bulk-accepting ~800 headers with no cross-checking), which fragmented what should have |
| 77 | +been single metrics into near-duplicate canonicals. The skill file explains what a real per-header |
| 78 | +review looks like, with worked examples of fuzzy-match suggestions that looked right but weren't. |
| 79 | + |
| 80 | +## Walkthrough: ingesting a new year's Excel file |
| 81 | + |
| 82 | +This is what the skill above automates, spelled out as a manual sequence of CLI calls: |
| 83 | + |
| 84 | +1. **Download it.** |
| 85 | + ```bash |
| 86 | + lamas download --year 2025 |
| 87 | + ``` |
| 88 | + Confirms the file lands in `downloads/`. If CBS has changed their URL/filename pattern (rare, |
| 89 | + but check `lamas/downloader.py`'s `P_LIBUD`/`P_LIBUD2` special cases if this fails), fix the |
| 90 | + pattern there first. |
| 91 | + |
| 92 | +2. **Get it parsing correctly.** |
| 93 | + ```bash |
| 94 | + lamas diagnose --year 2025 |
| 95 | + ``` |
| 96 | + Look at the header preview for every sheet. A new year usually parses fine using the previous |
| 97 | + year's layout, but watch for: sheets with suspiciously few headers, obviously truncated/mis-joined |
| 98 | + header text, or an outright parse error. For any sheet that looks wrong, adjust its config and |
| 99 | + re-check: |
| 100 | + ```bash |
| 101 | + lamas config set-sheet --year 2025 --sheet "נתוני תקציב" --header-rows 4 --extend-top 2 |
| 102 | + lamas diagnose --year 2025 --sheet "נתוני תקציב" |
| 103 | + ``` |
| 104 | + Repeat until every sheet's header preview looks like real column labels, or mark a sheet |
| 105 | + `--skip` if it's legitimately irrelevant (matches the historical pattern of skipped |
| 106 | + social-survey/labor-force sheets). |
| 107 | + |
| 108 | +3. **Build a checkpoint and find unmapped headers.** |
| 109 | + ```bash |
| 110 | + lamas preprocess |
| 111 | + lamas map-headers --year 2025 |
| 112 | + ``` |
| 113 | + Check the summary line (`N unresolved, M auto-resolved needing confirmation`) and open |
| 114 | + `reports/pending_headers.csv`. |
| 115 | + |
| 116 | +4. **Resolve every pending header - one at a time.** |
| 117 | + - For each `auto_resolved_needs_confirmation` row: look at the `suggested_canonical` and |
| 118 | + `suggested_score`, compare against the raw header text and a few `sample_values`, and either: |
| 119 | + ```bash |
| 120 | + lamas mapping confirm-fuzzy --orig "<orig_header>" # suggestion is correct |
| 121 | + lamas mapping reject-fuzzy --orig "<orig_header>" --canonical "<correct one>" # it's wrong |
| 122 | + lamas mapping reject-fuzzy --orig "<orig_header>" # it's actually a new metric |
| 123 | + ``` |
| 124 | + - For each `unresolved` row (no fuzzy candidate cleared the threshold): decide whether it's a |
| 125 | + variant of an existing canonical (search `data/header_mapping.yaml` for similar text) or a |
| 126 | + genuinely new metric, then: |
| 127 | + ```bash |
| 128 | + lamas mapping add --canonical "<existing or new canonical>" --orig "<orig_header>" |
| 129 | + ``` |
| 130 | + Never accept a fuzzy suggestion, and never create a new canonical, purely because "nothing |
| 131 | + better turned up" - a wrong merge or an unnecessary new canonical is exactly the kind of |
| 132 | + fragmentation this whole mapping system exists to prevent. |
| 133 | +
|
| 134 | +5. **Confirm the year is clean.** |
| 135 | + ```bash |
| 136 | + lamas map-headers --year 2025 --strict |
| 137 | + ``` |
| 138 | + Exits non-zero if anything is still unresolved - go back to step 4 if so. |
| 139 | +
|
| 140 | +6. **Regenerate stats and build the final output.** |
| 141 | + ```bash |
| 142 | + lamas stats |
| 143 | + lamas build --strict |
| 144 | + ``` |
| 145 | +
|
| 146 | +7. **Run the full QA suite.** |
| 147 | + ```bash |
| 148 | + lamas qa |
| 149 | + ``` |
| 150 | + This checks the whole historical dataset, not just the new year - a bad `sheet_config.yaml` |
| 151 | + tweak or a wrong mapping decision can regress older years too. |
| 152 | +
|
| 153 | +8. **Hand off to Postgres - only when asked.** |
| 154 | + ```bash |
| 155 | + lamas build --output local,postgres |
| 156 | + ``` |
| 157 | + This is a shared-system write. Treat it as a suggestion to make to whoever's driving, not |
| 158 | + something to run automatically once QA passes. |
0 commit comments