10 KiB
title, status, folder, tags, created, updated, version
| title | status | folder | tags | created | updated | version | ||||
|---|---|---|---|---|---|---|---|---|---|---|
| kycourt-colab — Kentucky Appellate Court Corpus Pipeline (project notes) | active | repo root (kycourt-colab) |
|
2026-08-12 | 2026-08-12 | 1.1.0 |
kycourt-colab — Kentucky Appellate Court Corpus Pipeline
Overview
A single generated Google Colab notebook (ky_court_pipeline.ipynb) that
restores, discovers, downloads, extracts, and verifies the full Kentucky
appellate court document corpus (~98.7k documents). It is built to survive
Colab's ephemeral runtimes: it auto-resumes after disconnects, is idempotent,
and mirrors all state to Google Drive so nothing is lost between sessions.
Remote: https://github.com/stateofshit/kycourt-colab (private)
Local worktree: /home/user/00-incoming/kycourt-colab
What problem it solves
A batch archival crawl of public court documents over a ~10-hour-per-session horizon, on infrastructure (Colab) that kills your process mid-run. Requirements that fall out of that:
- Every session must pick up exactly where the last one stopped.
- Re-running must be safe (idempotent) — no double-downloads, no data loss.
- State must live somewhere durable (Drive), because the runtime is throwaway.
- The crawl must be polite to a public API (paced, backoff, retries) and deduplicate aggressively (content-addressed storage).
Architecture
The notebook is not hand-written — its cells are inlined from real Python
modules in notebook_builder/cells/ (one module per cell, c00 → c90), and
regenerated with notebook_builder/build.py. This means the pipeline is
version-controlled, unit-testable Python that just happens to be shipped as a
Colab notebook.
Two shared abstractions carry every stage:
- SQLite DB (
ky_court.sqlite) — source of truth:documents,discovery_runs,extraction_log,kv. Opened in WAL mode with a 30sbusy_timeoutso a background reader can snapshot it while the writer commits (see concurrency below). Storeabstraction — Google Drive in Colab, local filesystem in tests; exposesexists / read_file / write_bytes / upload_file / list_dir. All stages talk to it, never to Drive directly.
Stage order: restore → discover → backfill → download → extract → sync/verify.
The six stages (what each actually does)
1. Restore — c40_restore.py
Rehydrate a fresh/empty workspace from the newest ky_court-WIP-resume-*.tar.gz.
Only acts when no DB exists; skips otherwise. If a stale workspace dir is
present it is moved aside to -pre-restore (renamed, never deleted) before
extracting — the "never destroys data" guarantee. Extracts to temp, then moves
into place.
2. Discover — c50_discover.py
Enumerate the corpus by searching the court API so we know what exists.
Builds search shards (YYYY-CA, YYYY-SC per year; ceiling-hit shards get
sub-sharded YYYY-CA-0..9). Pages results with X-CTrack-Paging-* headers up
to a 10k ceiling; a 400 or ceiling-cross raises to force sub-sharding rather
than silently missing docs. Normalizes rows into documents with
status='discovered', inserted idempotently via ON CONFLICT(document_id).
Sweeps repeatedly until a pass adds fewer than CONVERGENCE_DELTA docs
(convergence). Every sweep logged to discovery_runs.
3. Backfill — c60_backfill.py
Deduplicate existing backups into the content-addressed store. Hashes every
file in pdf_backup/ (SHA-256), writes unique content to pdf_by_hash/<sha>.pdf
(9,582 backup files → 3,326 unique PDFs) — "do we already have this?" becomes a
hash lookup. Normalizes DB local_pdf pointers. Guarded by kv backfill_done.
4. Download — c70_download.py
Fetch PDFs for all discovered/failed docs with attempts < MAX_ATTEMPTS,
in batches of 50. api_download does a HEAD (rejects non-200 and anything
MAX_PDF_BYTES), then streamsGET, hashing as it goes. Dedup wins: a hash already in the store/manifest uploads nothing. Retry/backoff on transient errors:sleep = min(60, 5·2^attempts)(10→20→40→60s, capped), up to 4 attempts. Permanent errors (400/401/403/404/413) fail immediately, no backoff. Writes totmp/<id>.part, promotes to store, paces itself, and pushes a heartbeat tostatus.jsonevery 100 attempts.
5. Extract — c80_extract.py
Turn downloaded PDFs into searchable text with pypdf. Batches of 500 with 8
parallel worker threads. A PDF with no text layer is a valid "empty" result,
not an error. Text written to text/<sha>.txt; extracted_chars recorded.
Heuristic flags likely-scanned (image-only) PDFs when chars/pages < 20.
6. Sync/Verify — c90_verify.py
Prove nothing is corrupted and push an authoritative snapshot + summaries to
Drive. Runs PRAGMA integrity_check; counts totals and the remaining
queues (download + extract eligibility queries mirror the stage code exactly,
so done is trustworthy). Exports document_inventory.csv +
failed_inventory.csv. checkpoint() makes a consistent DB snapshot via
src.backup(dst) (SQLite online backup API) and uploads it as the new
ky_court.sqlite. Updates status.json with done = (queues empty).
Prints PASS/FAIL.
Concurrency (the interesting part)
checkpoint() uses SQLite's online backup API (src.backup(dst)) from a
separate connection — not copytree, not a WAL checkpoint. It copies the
DB page-by-page and re-copies any page the writer modified during the pass,
looping until a clean pass completes. Combined with WAL mode (readers don't
block the writer) and busy_timeout=30000, this yields a
transactionally-consistent snapshot even while Download is committing every doc.
A naive file copy would produce a torn/corrupt snapshot and drop WAL-committed
data.
Key design decisions
- Idempotent + resume-safe everywhere: skip what's done, never auto-delete on Drive.
- Named permanent-failure set (
PERMANENT_STATUSES+_is_permanent()) distinguishes never-succeed (401/403/404/413) from transient (5xx/conn/429/408). - Content-addressed store (
pdf_by_hash/<sha>.pdf) makes dedup a lookup. - Config via one
CFGdict with env overrides (KY_BUDGET,KY_DRY,KY_PHASES,KY_RETRY_HARD,KY_LIVE).
Config knobs (top cell CFG)
| Key | Default | Meaning |
|---|---|---|
BUDGET_SECONDS |
10h | max wall-clock per session (KY_BUDGET) |
SHUTDOWN_SECONDS |
15 min | stop early so the session finishes cleanly |
PACING_SEARCH / PACING_DL |
(0.3,0.7)/(0.3,0.5) | random sleep between API calls |
MAX_ATTEMPTS |
4 | download retries before failed |
MAX_PDF_BYTES |
200 MiB | size cap; oversized (413) fails permanently, no retry/backoff |
MAX_EXTRACT_ATTEMPTS |
3 | extraction retries before skip |
RETRY_HARD |
0 (KY_RETRY_HARD) |
on resume, reset failed → discovered |
PHASES |
discover,backfill,download,extract | stages to run (KY_PHASES) |
DRY_RUN |
0 (KY_DRY) |
1 = fully offline, no network |
File layout
ky_court_pipeline.ipynb the deliverable (generated)
notebook_builder/
build.py regenerate the notebook from cells
cells/ c00_config … c90_verify + drive_store.py
tests/ test_pipeline_local.py + conftest.py (offline; live gated by KY_LIVE)
scripts/ smoke_local.py full offline pipeline over the real DB
RUNBOOK.md operational runbook (first run, resuming, failure modes)
docs/superpowers/ specs/ + plans/ design spec + implementation plan
PROJECT_NOTES.md this file
.gitignore ignores .venv, extracted/, *.sqlite, __pycache__,
.worktrees/, .superpowers/ (NOT the WIP tars)
Data is NOT in the repo.
extracted/,*.tar.gzresume/WIP archives, andsanitize-work/live only in Drive / locally. Gitignore does not cover the WIP.tar.gzfiles, so nevergit add -Afrom the repo root.
Running / testing
python3 -m venv .venv && .venv/bin/pip install -r <pinned deps>
.venv/bin/pytest tests -q # 20 offline tests (live ones skipped)
.venv/bin/python scripts/smoke_local.py # full pipeline over the real 98,724-row DB
.venv/bin/python notebook_builder/build.py # regenerate the notebook
Live API tests are gated behind KY_LIVE=1 and share a ≤10-request budget.
Suite result at v1.2: 20 passed, 3 skipped.
Failure modes (from RUNBOOK)
- Colab disconnect mid-run → re-run;
recover()heals stale rows, drops.partfiles. - Drive quota exceeded → checkpoint push fails; run continues, store lags.
- 5xx/connection storms → backoff
min(60, 5·2^attempts)up toMAX_ATTEMPTS. - Search shard hits 10k ceiling → planner sub-shards next run.
integrity_checknot "ok" → DB corrupt; restore from last resume tar.
Versioning / releases
Immutable milestone tags (never force-move a published tag):
pipeline-v1 33a6ec1 original pipeline milestone
v1.1 ae77344 +README
v1.2 ec248fe +413 oversize permanent-failure fix (MAX_PDF_BYTES knob)
v1.2 also has a GitHub release with changelog:
https://github.com/stateofshit/kycourt-colab/releases/tag/v1.2
Status
Project complete and shipped. Suite green offline. The pipeline is ready to run in Colab (restore from the resume tar → Run all).
Roadmap / Future enhancements
- Parallel extract workers — tune the 8-worker batch size based on observed Colab runtime limits.
- Drive quota monitoring — automated alert when
status.jsonreports approaching quota, with graceful degradation to skip upload. - CLI entry point —
kycourt-pipeline run --phase discoverto run individual stages outside Colab. - Query layer — lightweight read-only API over the SQLite DB for inspecting discovered/extracted docs without re-running the pipeline.
- Vault migration — if/when the repo is folded into the vault, adapt
folderfrontmatter and consolidate.tar.gzWIP archives under the vault's resume format. Remote backup, README, tags, and v1.2 release all in place on GitHub. Suite green offline. The pipeline is ready to run in Colab (restore from the resume tar → Run all).