Initial commit
This commit is contained in:
commit
5f2db08b43
100 files changed
+16985
No files matched your search
+101
@@ -0,0 +1,101 @@
|
||||
# Correlation — bills ↔ committees ↔ members
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Step 1: parse cached HTML into the flat table (parser lane — already done)
|
||||
python -m scraper parse --input cache/raw --db db/daily_file.sqlite
|
||||
|
||||
# Step 2: create the relational schema (idempotent)
|
||||
python -m scraper migrate --db db/daily_file.sqlite
|
||||
|
||||
# Step 3: derive bill<->committee<->member relations from the flat table
|
||||
python -m scraper correlate --db db/daily_file.sqlite
|
||||
|
||||
# Optional: backfill committee membership from a roster JSON file
|
||||
python -m scraper correlate --db db/daily_file.sqlite --roster rosters.json
|
||||
|
||||
# Or: one-shot — migrate then correlate in process order
|
||||
python -m scraper migrate --db db/daily_file.sqlite && \
|
||||
python -m scraper correlate --db db/daily_file.sqlite
|
||||
```
|
||||
|
||||
## What correlate derives
|
||||
|
||||
| Source column / field | Target table | Rule |
|
||||
|---------------------------------|----------------------|---------------------------------------------------|
|
||||
| `bill_id` (any row) | `bills` | One bill row per unique `(bill_id, session, chamber_origin)` |
|
||||
| `committee` on hearing / committee-bill rows | `committees` | One committee row per unique slug |
|
||||
| `author LIKE 'Committee on …'` | `committees` | Strips the "Committee on " prefix; same slug rules |
|
||||
| `bill_id AND committee` non-null | `bill_committees` | The "reviewed by" edge: bill was acted on by this committee |
|
||||
| `author` = "Name" or "Name et al." (not committee) | `members` | `kind = 'author-seed'`; real legislator name, never a guess |
|
||||
| roster JSON `members[]` | `committee_members` | `kind = 'roster'`; only when a roster is supplied |
|
||||
|
||||
## Never-fabricate rule
|
||||
|
||||
* `committee_members` is populated **only** from a supplied roster file
|
||||
(`--roster rosters.json`).
|
||||
* When a committee seen in the daily file has **no roster**, it is still
|
||||
created in `committees` but its membership is left **empty**.
|
||||
* A WARN is logged for each committee missing a roster.
|
||||
* No member is ever invented. `test_committee_without_roster_has_no_members`
|
||||
asserts `COUNT(*) = 0` for a committee with no roster.
|
||||
|
||||
The roster JSON format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"committee": "Emergency Management",
|
||||
"session": "2025-2026",
|
||||
"chamber": "senate",
|
||||
"members": [
|
||||
{"name": "Perez", "role": "Chair"},
|
||||
{"name": "Davis", "role": "Member"}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Roster keying
|
||||
|
||||
`load_rosters()` returns a dict keyed by
|
||||
`(slug, session, chamber)` — three fields, not just the slug — so
|
||||
a committee appearing in two different `(session, chamber)` contexts
|
||||
keeps separate membership sets instead of clobbering.
|
||||
|
||||
`_ingest_relations()` builds a `roster_slugs` set from that dict. A
|
||||
committee `(slug, session, chamber)` slot is reported "missing a roster"
|
||||
when **no roster block covers that slug at all**, regardless of
|
||||
`(session, chamber)`. This allows one roster file to backfill
|
||||
multiple slots for the same slug.
|
||||
|
||||
## Idempotency
|
||||
|
||||
| Operation | Idempotency mechanism |
|
||||
|--------------|------------------------------------------------|
|
||||
| `migrate()` | All DDL is `CREATE … IF NOT EXISTS`; |
|
||||
| | `schema_migrations` uses `INSERT OR IGNORE` |
|
||||
| `correlate()`| All `INSERT` is `INSERT OR IGNORE` on natural |
|
||||
| | keys; re-running produces zero new rows |
|
||||
| `--rebuild` | `DELETE FROM` all 5 derived tables, then re- |
|
||||
| | correlate — a clean deterministic rebuild |
|
||||
|
||||
## Roster-driven slot matching
|
||||
|
||||
The chamber axis defaults to `chamber_origin` (for bill rows, the
|
||||
origin house of the bill) and falls back to `house` (the publishing
|
||||
chamber) when `chamber_origin` is absent. This matters for hearing
|
||||
rows, which have `chamber_origin = NULL` but `house = 'assembly'` —
|
||||
without the fallback the hearing slot would get `chamber = 'unknown'`
|
||||
and never match a roster entry with `chamber = 'assembly'`.
|
||||
|
||||
## CLI registration
|
||||
|
||||
`scraper/cli.py` is a shared hotspot co-owned by the ingestion lane
|
||||
(`ingest`/`list`/`check`) and the parser lane (`parse`). This lane
|
||||
registers `migrate` and `correlate` **additively**, through
|
||||
`scraper/relate_cli.py::register()` called in a `try/except` block
|
||||
in `cli.py::build_parser()` — exactly the same guarded pattern that
|
||||
`parse_cli.py::register()` uses. The new subcommand names
|
||||
(`migrate`, `correlate`) do not collide with any sibling owner.
|
||||
Reference in new issue
Block a user