# Batch architecture — full-catalog build-out (design, Fable 5, 2026-07-07) Design is final; Sonnet implements the TODOs in `pipeline/batch_run.py` without redesigning. Contracts below are what the skeleton, `progress.py`, `extract_local.py` and `qa_sample.py` already assume. ## Staging | Stage | Scope | LLM | Where | |---|---|---|---| | 1 packages | all 3 039 ESCO occupations | none | local + throttled Gitea push | | 2 evidence | all occupations, ~60 ads each | Ollama (extraction only) | local + RTX-3090 box | | 3 depth tier 1 | top-200 by evidence volume | Claude (per templates/) | Claude session | | 3 depth tier 3 | rest, night batch | Claude, resumable | Claude sessions | Order within stage 2 follows `docs/batch-plan.md` (HR/IT -> finance -> management), then the remaining catalog by ISCO group. ## Budgets & throttles - **JSearch: hard cap 33 000 requests lifetime**, enforced in `progress.spend_request()` BEFORE each HTTP call — not advisory. ~6 requests/occupation x 3 039 = ~18 k expected, cap leaves headroom. - Rate: <= 2 req/s (sleep 0.5 s), cache first — a cache hit costs nothing. - Thin market (< 20 usable ads): ESCO-synonym fallback -> Adzuna (free) -> mark `evidence: failed / thin market`, move on. No endless retries. - Gitea: >= 1 s between repo operations, check-before-create (resumable). - Ollama: sequential, one ad per call (~6.5 s measured on gemma3:27b); ~180 k ads ≈ 14 days GPU time — run as detached script on the webserver (survives Claude sessions), NOT inside the Claude loop. ## Data contracts - `data/raw/jobs//jsearch___p.json` — raw cache (existing recruiter files stay where they are; new fetches use subdirs) - `data/raw/jobs/_ads.json` — deduped `[{job_id, title, employer, country, description}]` (same shape as recruiter_ads.json) - `data/evidence/.jsonl` — one validated extraction per line (schema: `pipeline/prompts/extract_schema.json` + `job_id`) - `data/progress.json` — see `pipeline/progress.py` docstring; the ONLY place run state lives. Deleting it = full re-scan, but no re-spend (caches + file existence still short-circuit). ## MSSQL change (prerequisite for stage 2) ```sql ALTER TABLE evidence_job ADD occupation_slug NVARCHAR(200) NULL; UPDATE evidence_job SET occupation_slug = 'recruitment-consultant' WHERE occupation_slug IS NULL; -- backfill: all 350 existing rows are recruiter CREATE INDEX ix_evidence_job_occ ON evidence_job(occupation_slug); ``` `p3b_store_evidence.py` becomes incremental: MERGE on `(occupation_slug, job_id)` instead of drop-and-rebuild; `evidence_entity` keyed by job_id as today. `p3c_aggregate.py` parameterized by slug. ## QA cadence After each occupation's extraction: `qa_sample.py --rate 0.02` (min 1 record). Claude reviews the generated `docs/qa/-sample.md` in the next session pass; two consecutive harness failures (exit 1) = stop the batch, diagnose (systemic prompt/model drift, not noise). Depth files are checked against `pipeline/templates/QUALITY_BAR.md` (mechanical part greppable). ## Failure policy Any per-occupation failure is recorded (`error` field) and skipped — the batch never blocks on a single occupation. `--slugs` re-runs individual failures after a fix. BudgetExhausted stops the run with exit 2.