Skip to content

Commit e92eae1

Browse files
authored
Modernize Lamas pipeline: retire Airtable and Jupyter notebook (#1)
Squash-merging after validating the full pipeline end-to-end: pushed the freshly built dataset to the production lamas_muni table and confirmed row counts, year coverage, and key-column integrity all check out.
1 parent 4c25d06 commit e92eae1

47 files changed

Lines changed: 7160 additions & 4473 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/lamas-ingest/SKILL.md

Lines changed: 295 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/lamas-tests.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: Lamas tests
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'Lamas/**'
7+
- '.github/workflows/lamas-tests.yml'
8+
push:
9+
branches:
10+
- master
11+
paths:
12+
- 'Lamas/**'
13+
- '.github/workflows/lamas-tests.yml'
14+
15+
jobs:
16+
test:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.12'
24+
25+
- name: Install lamas package
26+
run: pip install -e "Lamas/[dev]"
27+
28+
# CBS's historical workbooks (1999-2024) are effectively immutable once published, so this
29+
# cache almost always hits and the download step below becomes a no-op (download_excel
30+
# skips any file that already exists). Bump the key suffix if a genuine re-download is ever
31+
# needed (e.g. downloader.py's year range changes).
32+
- name: Cache downloaded workbooks
33+
uses: actions/cache@v4
34+
with:
35+
path: Lamas/downloads
36+
key: lamas-downloads-v1
37+
38+
# Best-effort: a transient network hiccup hitting CBS's site shouldn't hard-fail the whole
39+
# job if we already have a viable checkpoint from cache (see the "Ensure test data is
40+
# available" step below, which is the one that actually enforces data availability).
41+
- name: Download all configured years
42+
run: lamas download
43+
continue-on-error: true
44+
45+
# Invalidated whenever code that affects extraction changes (sheet config, preprocessing
46+
# logic, or the downloaded files themselves), so a real bug fix always gets re-checked
47+
# against fresh data rather than serving a stale, possibly-buggy checkpoint from cache.
48+
- name: Cache preprocessed checkpoint
49+
uses: actions/cache@v4
50+
with:
51+
path: Lamas/.cache
52+
key: lamas-checkpoint-${{ hashFiles('Lamas/downloads/**', 'Lamas/lamas/preprocess.py', 'Lamas/lamas/sheet_config.py', 'Lamas/data/sheet_config.yaml') }}
53+
54+
# Ensures the data-dependent tests in test_quality_report.py actually RUN instead of
55+
# silently skipping: either a fresh/cached checkpoint already exists, or we build one from
56+
# whatever was downloaded (even a partial set, if the download step above hit a snag) - only
57+
# fail outright if there's truly nothing to build a checkpoint from at all.
58+
- name: Ensure test data is available (checkpoint or fresh download)
59+
run: |
60+
if [ -f Lamas/.cache/preprocessed.parquet ]; then
61+
echo "Using cached checkpoint"
62+
elif [ -n "$(ls -A Lamas/downloads 2>/dev/null)" ]; then
63+
echo "No cached checkpoint - preprocessing downloaded workbooks"
64+
lamas preprocess
65+
else
66+
echo "::error::No downloaded workbooks and no cached checkpoint - cannot run data-dependent tests"
67+
exit 1
68+
fi
69+
70+
- name: Regenerate pending-headers report
71+
run: lamas map-headers
72+
73+
- name: Run pytest
74+
# -rA surfaces captured stdout for EVERY outcome, including PASSED - several tests in
75+
# test_quality_report.py (near-duplicate canonicals, naming-convention violations) are
76+
# deliberately non-blocking reports that print findings without failing; pytest hides
77+
# captured stdout for passing tests by default, which would otherwise silently swallow
78+
# them here.
79+
run: pytest Lamas/tests/ -v -rA
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Push to Postgres
2+
3+
# Manual trigger ONLY - this truncates and overwrites the production lamas_muni table. It must
4+
# never run automatically on push/PR; every other workflow in this repo pushes nothing.
5+
on:
6+
workflow_dispatch:
7+
inputs:
8+
confirm:
9+
description: 'Type PUSH to confirm this will TRUNCATE and overwrite the production lamas_muni table'
10+
required: true
11+
12+
jobs:
13+
push:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Confirm intent
17+
run: |
18+
if [ "${{ github.event.inputs.confirm }}" != "PUSH" ]; then
19+
echo "::error::Confirmation input must be exactly 'PUSH' - aborting."
20+
exit 1
21+
fi
22+
23+
- uses: actions/checkout@v4
24+
25+
- uses: actions/setup-python@v5
26+
with:
27+
python-version: '3.12'
28+
29+
- name: Ensure psql is available
30+
run: command -v psql || (sudo apt-get update && sudo apt-get install -y postgresql-client)
31+
32+
- name: Install lamas package
33+
run: pip install -e "Lamas/[dev]"
34+
35+
# Same cache keys as lamas-tests.yml (repo-scoped, so this reuses whatever it already
36+
# populated) - see that workflow for the rationale.
37+
- name: Cache downloaded workbooks
38+
uses: actions/cache@v4
39+
with:
40+
path: Lamas/downloads
41+
key: lamas-downloads-v1
42+
43+
- name: Download all configured years
44+
run: lamas download
45+
continue-on-error: true
46+
47+
- name: Cache preprocessed checkpoint
48+
uses: actions/cache@v4
49+
with:
50+
path: Lamas/.cache
51+
key: lamas-checkpoint-${{ hashFiles('Lamas/downloads/**', 'Lamas/lamas/preprocess.py', 'Lamas/lamas/sheet_config.py', 'Lamas/data/sheet_config.yaml') }}
52+
53+
- name: Ensure data is available (checkpoint or fresh download)
54+
run: |
55+
if [ -f Lamas/.cache/preprocessed.parquet ]; then
56+
echo "Using cached checkpoint"
57+
elif [ -n "$(ls -A Lamas/downloads 2>/dev/null)" ]; then
58+
lamas preprocess
59+
else
60+
echo "::error::No downloaded workbooks and no cached checkpoint - cannot build"
61+
exit 1
62+
fi
63+
64+
- name: Regenerate pending-headers report
65+
run: lamas map-headers
66+
67+
- name: Run the full QA suite before pushing
68+
run: pytest Lamas/tests/ -v
69+
70+
- name: Build (local output, strict - refuses if any header is unresolved)
71+
run: lamas build --strict --output local
72+
73+
- name: Push to Postgres
74+
env:
75+
DATAFLOWS_DB_ENGINE: ${{ secrets.DATAFLOWS_DB_ENGINE }}
76+
run: lamas push-postgres

Lamas/.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
11
.env
22
downloads/
33
__pycache__
4+
*.egg-info/
5+
.pytest_cache/
6+
7+
# Generated intermediate/output artifacts - not source, regenerate via the lamas CLI.
8+
.cache/
9+
reports/*.csv
10+
reports/*.md
11+
db_bkp/
12+
data.pickle

Lamas/README.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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; `postgres` pushes the resulting CSV via `push-postgres` below. |
47+
| `lamas push-postgres [--csv PATH] [--table lamas_muni] [--truncate/--no-truncate]` | Push an already-built CSV (default `db_bkp/res_1.csv`) into Postgres via `psql \copy`. Reads the connection string from the `DATAFLOWS_DB_ENGINE` env var. Truncates the table first by default (matching a full-replace, not an incremental append) - a destructive, shared-system write, never run automatically. |
48+
| `lamas full-run [--year Y] [--strict] [--output ...]` | `download` -> `preprocess` -> `map-headers` -> `stats` -> `build`, one shot. |
49+
| `lamas qa` | Runs the full pytest suite under `tests/` - the automated quality gate (see below). |
50+
51+
Run `lamas <command> --help` for the full flag list on anything above.
52+
53+
### Pushing to Postgres
54+
55+
`DATAFLOWS_DB_ENGINE` is not auto-loaded from `.env` (nothing in the package calls
56+
`load_dotenv()`) - export it into your shell first:
57+
```bash
58+
set -a; source Lamas/.env; set +a
59+
```
60+
Then either let `build` push it as part of the pipeline (`lamas build --strict --output local,postgres`),
61+
or push a CSV you already have on hand directly:
62+
```bash
63+
lamas push-postgres --csv db_bkp/res_1.csv
64+
```
65+
A manually-triggered GitHub Action (`.github/workflows/push-postgres.yml`) runs this same flow
66+
using a repo secret - see that workflow file for details. It is never triggered automatically.
67+
68+
### Quality assurance
69+
70+
`lamas qa` (or plain `pytest tests/`) runs the same checks a human used to do by eye in Airtable's
71+
"Stats" table, now automated: no unresolved headers, no near-duplicate or unit-mixed canonicals in
72+
`header_mapping.yaml`, no duplicate header collisions within a sheet, sane per-year/sheet row
73+
counts, and year-over-year header coverage (a header reported in 5 straight years must still be
74+
reported in the 6th, catching silently broken extractions - see `tests/test_quality_report.py` for
75+
the full list and the reasoning behind each check, including the small set of already-investigated
76+
exceptions that are allowed to stay).
77+
78+
CI (`.github/workflows/lamas-tests.yml`) runs this same suite on every PR touching `Lamas/`,
79+
downloading and preprocessing the real data first (cached between runs) so the data-dependent
80+
checks actually execute rather than skip.
81+
82+
## The `lamas-ingest` skill
83+
84+
`.claude/skills/lamas-ingest/SKILL.md` is a Claude Code skill that walks through ingesting one
85+
year end-to-end, orchestrating the CLI above rather than reimplementing any parsing logic. Invoke
86+
it (or just ask Claude to "ingest year X") when a newly-published CBS workbook needs onboarding, or
87+
when an existing year has unresolved headers or config gaps.
88+
89+
It encodes one hard rule worth knowing even if you're doing this by hand: **every unmapped header
90+
gets resolved one by one, by a tight heuristic or explicit semantic review - never in bulk, and
91+
never just because "no better candidate was found."** An earlier version of this process got this
92+
wrong once (bulk-accepting ~800 headers with no cross-checking), which fragmented what should have
93+
been single metrics into near-duplicate canonicals. The skill file explains what a real per-header
94+
review looks like, with worked examples of fuzzy-match suggestions that looked right but weren't.
95+
96+
## Walkthrough: ingesting a new year's Excel file
97+
98+
This is what the skill above automates, spelled out as a manual sequence of CLI calls:
99+
100+
1. **Download it.**
101+
```bash
102+
lamas download --year 2025
103+
```
104+
Confirms the file lands in `downloads/`. If CBS has changed their URL/filename pattern (rare,
105+
but check `lamas/downloader.py`'s `P_LIBUD`/`P_LIBUD2` special cases if this fails), fix the
106+
pattern there first.
107+
108+
2. **Get it parsing correctly.**
109+
```bash
110+
lamas diagnose --year 2025
111+
```
112+
Look at the header preview for every sheet. A new year usually parses fine using the previous
113+
year's layout, but watch for: sheets with suspiciously few headers, obviously truncated/mis-joined
114+
header text, or an outright parse error. For any sheet that looks wrong, adjust its config and
115+
re-check:
116+
```bash
117+
lamas config set-sheet --year 2025 --sheet "נתוני תקציב" --header-rows 4 --extend-top 2
118+
lamas diagnose --year 2025 --sheet "נתוני תקציב"
119+
```
120+
Repeat until every sheet's header preview looks like real column labels, or mark a sheet
121+
`--skip` if it's legitimately irrelevant (matches the historical pattern of skipped
122+
social-survey/labor-force sheets).
123+
124+
3. **Build a checkpoint and find unmapped headers.**
125+
```bash
126+
lamas preprocess
127+
lamas map-headers --year 2025
128+
```
129+
Check the summary line (`N unresolved, M auto-resolved needing confirmation`) and open
130+
`reports/pending_headers.csv`.
131+
132+
4. **Resolve every pending header - one at a time.**
133+
- For each `auto_resolved_needs_confirmation` row: look at the `suggested_canonical` and
134+
`suggested_score`, compare against the raw header text and a few `sample_values`, and either:
135+
```bash
136+
lamas mapping confirm-fuzzy --orig "<orig_header>" # suggestion is correct
137+
lamas mapping reject-fuzzy --orig "<orig_header>" --canonical "<correct one>" # it's wrong
138+
lamas mapping reject-fuzzy --orig "<orig_header>" # it's actually a new metric
139+
```
140+
- For each `unresolved` row (no fuzzy candidate cleared the threshold): decide whether it's a
141+
variant of an existing canonical (search `data/header_mapping.yaml` for similar text) or a
142+
genuinely new metric, then:
143+
```bash
144+
lamas mapping add --canonical "<existing or new canonical>" --orig "<orig_header>"
145+
```
146+
Never accept a fuzzy suggestion, and never create a new canonical, purely because "nothing
147+
better turned up" - a wrong merge or an unnecessary new canonical is exactly the kind of
148+
fragmentation this whole mapping system exists to prevent.
149+
150+
5. **Confirm the year is clean.**
151+
```bash
152+
lamas map-headers --year 2025 --strict
153+
```
154+
Exits non-zero if anything is still unresolved - go back to step 4 if so.
155+
156+
6. **Regenerate stats and build the final output.**
157+
```bash
158+
lamas stats
159+
lamas build --strict
160+
```
161+
162+
7. **Run the full QA suite.**
163+
```bash
164+
lamas qa
165+
```
166+
This checks the whole historical dataset, not just the new year - a bad `sheet_config.yaml`
167+
tweak or a wrong mapping decision can regress older years too.
168+
169+
8. **Hand off to Postgres - only when asked.**
170+
```bash
171+
lamas build --output local,postgres
172+
```
173+
This is a shared-system write. Treat it as a suggestion to make to whoever's driving, not
174+
something to run automatically once QA passes.

Lamas/config.py

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)