Initial commit

This commit is contained in:
kfj001 committed 2026-09-06 10:18:26 -07:00
commit 5f2db08b43
100 files changed
+16985

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
# QA Sign-Off Checklist — California Legislature Daily File Scraper
**Task:** `t_8e5a3821` · **Lane:** Software Quality Assurance
**Status:** _DRAFT — awaiting sign-off before first production cron run_
**Scope:** ingestion (`scraper.ingest`) + parsing (`scraper.parsers`) pipeline,
cache, HTTP/retry layer, and the `python -m scraper` CLI.
This checklist is the gate the acceptance criteria require ("the QA checklist is
reviewed and signed off before the first production cron run"). Every item is
either (a) verified by the automated suite in `tests/`, or (b) a known failure
mode with a mitigation. **Do not start the production cron until every "Pre-cron"
box is ticked and a reviewer has signed the footer.**
---
## 0. How to use this checklist
```
# Full deterministic suite (no network) + coverage gate on the parser core:
python3 -m pytest
# Emit the coverage report the acceptance criteria require (>=85% on parser core):
python3 -m pytest --cov
# Opt in to the one live smoke test (skipped by default so CI never depends on
# the external servers being up):
CALI_SCRAPER_SMOKE=1 python3 -m pytest tests/test_live_smoke.py -m smoke
```
Green criteria (from the task):
- [ ] `pytest` passes green on the fixture suite (all of `tests/`, no skips).
- [ ] Coverage report meets the **>= 85 %** threshold on `scraper.parsers`
(+ `scraper.bill_id`); `--cov` exits 0.
- [ ] QA checklist reviewed and signed off (this document) before cron.
---
## 1. Automated gate (must be green)
| # | Check | Verified by | Passes? |
|---|-------|-------------|:---:|
| 1.1 | Parser codec round-trips AB/SB/ACR/SCR/HR/SJR/ABR + rejects junk | `tests/test_bill_id.py` (21) | ☐ |
| 1.2 | Schema/JSONL: field set, content-hash id, null-stripping, round-trip | `tests/test_schema.py` (17) | ☐ |
| 1.3 | Parsers: 3 dates, both chambers, floor/committee/hearing discrimination | `tests/test_parsers.py` (35) | ☐ |
| 1.4 | Malformed rows skip + log warning, batch survives; UTF-16/encoding edge | `tests/test_parsers.py` | ☐ |
| 1.5 | Ingest→parse end-to-end → valid JSONL schema; request-log recorded | `tests/test_integration.py` | ☐ |
| 1.6 | Failure modes: expected-404, antibot-403, transient-retry×3 | `tests/test_integration.py` | ☐ |
| 1.7 | Cache hit serves from disk with **zero** network; corrupt cache = miss | `tests/test_integration.py` | ☐ |
| 1.8 | Live smoke (opt-in); **skipped by default** | `tests/test_live_smoke.py` | ☐ (skip) |
| 1.9 | Coverage ≥ 85 % on `scraper.parsers` / `scraper.bill_id` | `pytest --cov` | ☐ |
**Known, deliberately-pinned behaviour (not bugs — see §4):**
- `normalise_date("2026-08-31")` returns `None` (the `-NNN` display-suffix
stripper runs before ISO); the site only ever emits `MM/DD/YY`, so this is
safe in practice. A fix must surface as a changed assertion, not a silent
regression.
---
## 2. Failure modes, detection & mitigation
Each row maps a known failure mode to how the system detects it, how the
automated suite proves the handling, and the operator mitigation. These are the
"site redesign, rate-limiting, encoding shifts" the task calls out.
### 2.1 Site redesign / structural HTML change · _highest risk_
| Aspect | Detail |
|---|---|
| **Symptom** | A CSS class or `data-sort` attribute the parser relies on (e.g. `tr.floor-bill-details`, `td.floor_bill-measure a`) is renamed/removed. |
| **Detection (automatic)** | `tests/test_parsers.py::TestArchivedSample` parses the archived `samples/assembly_dailyfile_latest.html` and asserts **≥150 bill rows / ≥4 hearing rows**. A structural break drops the count → **red gate**. Also: `test_happy_path_ingest_then_parse_then_jsonl` asserts `AB 28` and `SB 795` survive the full pipeline. |
| **Runtime symptom** | Parse yields 0 (or a few) records where the prior run yielded ~150. |
| **Mitigation** | Roll back to last good `cache/raw/<date>` (never deleted). Re-scrape selectors from the live page, update `DAILY_FILE_PARSE_SPEC.md §3` and the parser selectors, re-record a fresh sample in `samples/`, re-run the gate. |
| **Watch** | A sudden count drop is the #1 leading indicator — pair the cron with a "record-count anomaly" alert (count vs. 7-day median). |
### 2.2 Rate-limiting / 429
| Aspect | Detail |
|---|---|
| **Symptom** | `httpx` 429 (or 5xx) — the source throttles on volume. |
| **Detection (automatic)** | `tests/test_integration.py::test_transient_retries_three_times` proves the retry loop makes 3 attempts with exponential back-off `[1.0, 2.0]`s and then raises `TransientError`. |
| **Runtime handling** | `config.yaml → http.retry_status` drives this; 429 should be added to that list if the source uses 429 (currently `[500,502,503,504]`). Backoff is capped at `backoff_cap_seconds`. |
| **Mitigation** | Lower cron frequency; add 429 to `retry_status` with a longer cap; if sustained, route via the `blocked-page-recovery` skill. Alert is fired per attempt via the request log. |
| **Watch** | A rising share of `5xx_retrying`/`transient_gave_up` outcomes in `cache/request_log.jsonl`. |
### 2.3 Antibot / 403 (Senate PDF path) · _documented, expected_
| Aspect | Detail |
|---|---|
| **Symptom** | `senate.ca.gov/dailyfile/publications/download/...` returns a 67-byte 403 antibot challenge on programmatic fetch. |
| **Detection (automatic)** | `test_antibot_403_is_flagged` proves a 403 → `status="antibot"`, `all_ok=False`, alert fired, **no retry-storm**. |
| **Runtime handling** | 403 is classified `antibot_status=[403]`; it is NOT retried (a retry storm just wastes the throttle) — it alerts and stops. Mitigation is the `blocked-page-recovery` skill (headless browser + session pre-warm). |
| **Mitigation / workaround** | Until antibot is solved, **Senate data is unavailable**; Assembly-only output is valid. Optionally enrich Senate floor/committee items from the leginfo `billResultsClient`/`billHistoryClient` alt (spec §5b). |
| **Watch** | `antibot` appears in request-log outcomes only on the Senate source. |
### 2.4 "No publication today" / expected 404 · _expected, not a failure_
| Aspect | Detail |
|---|---|
| **Symptom** | Senate PDF path 404s on non-legislative days (weekends, recess). |
| **Detection (automatic)** | `test_expected_404_is_not_an_error` proves 404 on a `missing_is_expected_404` source → `status="not_found"` with `"expected"` in the note, and `all_ok` is **not** polluted by it. |
| **Runtime handling** | `config.yaml → senate_daily_file.missing_is_expected_404: true` makes a 404 a clean exit-0 outcome; cron mail fires **only on real failures** (antibot/transient/unexpected-404). |
| **Watch** | A 404 on the *Assembly* source (which has no `missing_is_expected_404`) is a warning-level alert, not a clean no-file. |
### 2.5 Encoding shifts / bad charset
| Aspect | Detail |
|---|---|
| **Symptom** | A page changes its declared charset (UTF-8 → Windows-1252, adds a BOM, etc.) and produces mojibake or crashes a naive decode. |
| **Detection (automatic)** | `tests/test_parsers.py::test_utf16_edge_case_does_not_crash` writes a UTF-16/BOM file and asserts the parser **survives** (logs an encoding warning, returns `[]`) rather than aborting the batch. |
| **Runtime handling** | `scraper.parsers._read_text` sniffs the charset from `<meta>`/BOM and falls back to `utf-8/replace`, logging a warning on replacement chars. |
| **Mitigation** | On persistent mojibake the field values are corrupted but the pipeline does not crash; inspect the warning in logs and re-fetch. |
| **Watch** | `replacement chars detected` warnings in the request/app log. |
### 2.6 Corrupt / truncated cache
| Aspect | Detail |
|---|---|
| **Symptom** | A crash mid-download leaves a truncated raw file, or a meta sha256 mismatches the bytes. |
| **Detection (automatic)** | `tests/test_integration.py::test_cache_corruption_is_treated_as_a_miss` proves a sha mismatch is treated as a **cache miss**, so a good fetch re-runs instead of serving poison. |
| **Runtime handling** | `RawCache.put` is atomic (tmp + `os.replace`); `get` verifies sha256 before serving. |
| **Watch** | Repeated misses on a date that was previously cached suggests disk corruption — inspect `cache/raw`. |
### 2.7 Malformed rows / bad data within an otherwise-valid page
| Aspect | Detail |
|---|---|
| **Symptom** | A floor row has no bill link, a non-numeric item cell, or a hearing row with neither committee nor date. |
| **Detection (automatic)** | `test_malformed_rows_skipped_not_aborted` proves bad rows are skipped **per-row** with a `WARNING` and the batch (good + recoverable rows) still completes. |
| **Runtime handling** | `scraper.parsers` skips+warns, never aborts; bad rows are logged for triage. |
| **Watch** | A spike in per-row WARNING lines indicates an upstream data-quality regression. |
### 2.8 Dependency / import availability
| Aspect | Detail |
|---|---|
| **Symptom** | `httpx`/`PyYAML`/`beautifulsoup4`/`lxml` missing in a fresh cron env. |
| **Detection** | Import guards in `scraper/config.py` and `scraper/http_client.py` raise a `SystemExit` with a clear "install with `pip install …`" message (not a bare `ModuleNotFoundError`). |
| **Mitigation** | `requirements.txt` pins the working versions (Python 3.9). The cron wrapper should `pip install -r requirements.txt` (or run in a venv with them pre-installed) before each run. |
| **Watch** | A run that exits 2 with the "… is required" message means the cron env is missing a dep. |
### 2.9 P2 finding: retry-storm on non-retryable 4xx · _defect, regression-guarded_
| Aspect | Detail |
|---|---|
| **Symptom** | `scraper/http_client.py` has a catch-all `return "transient"` for any non-404/non-antibot HTTP status. A permanent 4xx such as 400 is retry-stormed 3× with back-off `[1.0, 2.0]s` — identical to a transient 5xx, even though the code's own comment says it "should not retry-storm." |
| **Detection (automatic)** | `tests/test_integration.py::TestRetryStormRegressionGuard::test_retry_storm_on_4xx_is_currently_3_attempts` asserts the *current buggy* behaviour (3 attempts, `sleeps == [1.0, 2.0]`), keeping the suite green. The companion `test_non_retryable_4xx_must_give_up_on_attempt_1` is a **strict `xfail`** that will flip to a failure when the engine is fixed, signalling "finding resolved, remove/convert this guard." |
| **Runtime impact** | A 400 from the source causes 3 full fetches + 3 request-log entries where 1 would suffice. Not a data-corruption risk; a waste of the request throttle and a noise amplifier in the request log. |
| **Fix** | In `scraper/http_client.py`, narrow the catch-all so that only transient-network faults and the explicit `retry_status` list are classified `transient`; a 4xx that is not 404/antibot/`retry_status` should be classified a non-retryable failure (a single-attempt terminal state), or `retry_status` should be expanded to exclude it. |
| **Watch** | This xfail is a trip-wire; a strict-xfail going green is a signal to convert it to a plain test and re-baseline the counts. |
---
## 3. Rollback & recovery procedure
The pipeline is designed so that **a bad run never destroys a good one** — the
cache is append-only on the network side and atomic on the disk side.
### 3.1 Pre-cron pre-flight (run every time, before first cron)
- [ ] `.git` working copy clean or on the expected branch/commit.
- [ ] `python3 -m pytest` is green (all of `tests/`, no non-live skips failing).
- [ ] `python3 -m pytest --cov` reports **≥ 85 %** on `scraper.parsers` and
exits 0 (the `--cov` addopts enforce this; a sub-floor run is a failure).
- [ ] `python3 -m scraper list` shows both chamber sources with the URLs from
`config.yaml`.
- [ ] `python3 -m scraper check --date <recent-legislative-day>` shows the
cache populated (or a first run is expected to be a miss).
- [ ] `cache/request_log.jsonl` writable; alert sink configured (`config.yaml`).
- [ ] This checklist is signed (footer) by a human reviewer.
### 3.2 Rollback on a bad run
1. **Do not delete the cache.** `cache/raw/<source>/<date>.<ext>` is the source
of truth and is append-only; a failed new fetch does not overwrite a prior
good cache hit.
2. To serve "last known good": re-ingest with the default `force=False` — the
cache serves the last good bytes; only a `--force` re-fetch reaches the
network.
3. Re-parse the cached bytes with `python -m scraper parse --input cache/raw/…`
— the parse is a pure function of the bytes, so re-parsing is safe.
4. If the parser selectors broke (red gate, §2.1): roll back the parser commit,
re-parse the *previous* cached bytes with the old code, and hold the new
sample + selector changes until the gate is green again.
5. If a corrupt cache is suspected (§2.6): delete only the affected
`cache/raw/<source>/<date>.*` + its `.meta.json` sidecar; the next non-
forced run re-fetches. (A sha-mismatch already forces a re-fetch
automatically, so this is belt-and-braces.)
6. Record the incident in `cache/request_log.jsonl` review and, if it changed
upstream, refresh the archived sample in `samples/` and the parse spec.
### 3.3 Recovery from each failure mode
| Mode | Recovery |
|---|---|
| 2.1 redesign | Roll back parser commit; re-derive selectors from live page; new sample; re-gate. |
| 2.2 rate-limit | Back off cron frequency; add 429 to `retry_status`; or `blocked-page-recovery` skill. |
| 2.3 antibot (Senate) | Use `blocked-page-recovery` skill (headless + session pre-warm); meanwhile run Assembly-only. |
| 2.4 expected 404 | No action — this is normal; confirm it's `not_found` + "expected". |
| 2.5 encoding | Re-fetch; confirm charset in the warning; update `_read_text` sniff list if a new charset appears. |
| 2.6 corrupt cache | Delete affected cache entry (+meta); re-fetch. |
| 2.7 malformed rows | Triage the logged `WARNING`s; an upstream data regression, not a pipeline bug. |
| 2.8 missing dep | `pip install -r requirements.txt` in the cron env. |
| 2.9 retry-storm(4xx) | Engineer narrows the transient catch-all in `http_client.py`; re-baseline the xfail guard (§2.9). |
---
## 4. Test suite map (what lives where)
| File | Covers | Count |
|---|---|---|
| `tests/test_bill_id.py` | `scraper.bill_id` codec: parse/normalise/origin/display/supplement + negatives | 21 |
| `tests/test_schema.py` | `scraper.schema` BillAction: fields, content-hash id, JSONL round-trip | 17 |
| `tests/test_parsers.py` | `scraper.parsers`: sample + 3 dates + both chambers + robustness/edge | 35 |
| `tests/test_integration.py` | ingest→parse end-to-end JSONL schema + 404/antibot/transient/cache + P2 retry-storm guard | 12 |
| `tests/test_live_smoke.py` | **opt-in** live-URL smoke (skipped unless `CALI_SCRAPER_SMOKE=1`) | 2 |
Deterministic suite (no network) total: **84 passed** (+ 1 strict `xfail`, a
regression trip-wire on the §2.9 retry-storm finding). Live smoke: 2, skipped by
default so CI is independent of external availability. Full run is `84 passed,
2 skipped, 1 xfailed`; coverage on the parser core (`scraper.parsers` +
`scraper.bill_id`) is **92.0 %**, which clears the ≥ 85 % `fail_under` gate.
---
## 5. Sign-off
**Gate:** no production cron run starts until 1.1–1.9 are green and the §3.1
pre-flight list is ticked.
| Role | Name | Date | Signature |
|---|---|---|---|
| QA (author) | software_quality_assurance | ______ | ____________ |
| Engineer (parser lane) | __________ | ______ | ____________ |
| Manager / release | __________ | ______ | ____________ |
_Reviewer note: this checklist is a DRAFT pending human sign-off. The automated
half (1.1–1.9) is enforced by `pytest`/`pytest --cov` and is not a substitute
for a human ticking §3.1._