| @@ -0,0 +1,23 @@ | ||
| + | You are GLM-5.2, frontend lead for Docket (neutral US gov transparency app). Work in /opt/docket (local build dir). The read-API is LIVE and returns real data - all /api routes work (curl http://localhost:3000/api/feed?limit=2 to see). Be RAG-first; read ONLY: docs/API.md, docs/BRAND.md, docs/NEUTRALITY.md, docs/ACCESSIBILITY.md. Look at your existing app/ pages + components + lib/. | |
| + | ||
| + | WAVE F2 - build the DEEP, GRANULAR surfaces (the heart of the product). The DB now has: 290 bills, 410 bill_versions, 34 bill_diffs (with rider flags), 99 amendments, 90 votes, 25,572 vote_positions, 537 members, 500 executive_actions, 20 apportionments, 188 procedural_events, 320 awards. Make all of it explorable. Build/enhance these pages, each consuming the real /api routes: | |
| + | ||
| + | 1. VOTES - /votes (browse recorded roll-call votes) and /votes/[id] (THE ROSTER: show EVERY member's position - Yea/Nay/Present/Not Voting - with name + party + state + district. Use muted party/vote colors per BRAND, NEVER vivid red/blue. Default sort by vote then alphabetical, not grouped by party. For voice/UC votes where recorded=false, show "No individual record exists - passed by voice vote" instead of a fake roster.) Source link on every vote. | |
| + | ||
| + | 2. MEMBERS - /members (browseable roster, search/filter by state/chamber/party) and /members/[id] (a representative's FULL record: their recent votes with how they voted, bills sponsored, link each to the bill/vote). This is "what did MY rep do." | |
| + | ||
| + | 3. BILL DETAIL - enhance /bill/[id] with: (a) the "WHAT CHANGED / WHAT GOT SLIPPED IN" view consuming /api/bills/[id]/diff - show added/removed/amended sections with the snippet, and flag rider-flagged sections prominently (this is our signature feature); (b) the amendments list; (c) the bill's votes (link to the /votes/[id] roster); (d) full status timeline; (e) sponsor + cosponsors (link to /members/[id]). Honesty notes where data is voice-vote/conference (no individual record). | |
| + | ||
| + | 4. SPENDING - enhance /spending with the OMB APPORTIONMENT IMPOUND TRACKER from /api/spending/apportionments: per agency/account show appropriated vs apportioned vs status (released/delayed/withheld). Plus the awards list (already wired) and fiscal stats. | |
| + | ||
| + | 5. EXECUTIVE - /executive: Federal Register actions (Executive Orders, proposed/final rules, notices) from /api/executive, filterable by type. | |
| + | ||
| + | 6. STATES - enhance /states with the per-state confidence map (ok/stale/missing badges) from /api/states, and /states/[code] detail. | |
| + | ||
| + | 7. SEARCH - a global search UI consuming /api/search (the "look up my state / rep / topic" entry point). Make it prominent and simple (per ACCESSIBILITY.md: labeled, big target, drop-downs not bare box). | |
| + | ||
| + | HARD RULES: keep the BRAND (brass rationed, custody-stamp, mono data, dark default) and the 3-mode accessibility toggle working on ALL new pages (Dark/Readable/Plain). Enforce NEUTRALITY: a Source link on every fact, no adjectives, no comment sections, muted vote/party colors, honest "no record exists" where true. All new pages must work in all 3 display modes. | |
| + | ||
| + | BUILD: run `npm run build` and ensure it PASSES (fix any type errors). Do NOT try to restart the server (the tooling for that is handled separately - fuser is absent on this box). Just make sure the build is clean. | |
| + | ||
| + | Update FRONTEND_NOTES.md with the new pages. Definition of done: npm run build passes; all 7 surfaces implemented consuming real /api data; brand + neutrality + 3-mode accessibility intact. |
| @@ -0,0 +1,168 @@ | ||
| + | # Bills Pipeline - Build Notes | |
| + | ||
| + | ## Architecture | |
| + | ||
| + | Five new modules under `services/ingest/ingest/`: | |
| + | ||
| + | | Module | Role | | |
| + | |---|---| | |
| + | | `pollers/bills_bulk.py` | Downloads BILLSTATUS sitemaps + individual XMLs; populates `bills`, `bills_ext`, `actions`, `bill_versions`, `amendments` | | |
| + | | `pollers/bill_versions.py` | Supplementary version discovery via HEAD-probe of GovInfo content URLs | | |
| + | | `diff.py` | Section-parse + align + diff engine; populates `bill_diffs` | | |
| + | | `rider_detect.py` | R Street keyword scan over `bill_diffs.change='added'`; sets `rider_flag` | | |
| + | | `amendments.py` | Parses `<amendments>` from BILLSTATUS XML; re-fetches for backfill | | |
| + | ||
| + | Schema additions (all `CREATE TABLE IF NOT EXISTS`): | |
| + | - `bills_ext` - extended bill metadata (bill_type, sponsor_name/party/state) | |
| + | - `bill_versions` - version rows with govinfo_package_id + text_ref (XML URL) | |
| + | - `bill_diffs` - per-section diff rows with change type + snippet + rider_flag | |
| + | - `amendments` - amendment rows with agreed_method + congress.gov source_url | |
| + | ||
| + | --- | |
| + | ||
| + | ## GovInfo Bulk Data Gotchas | |
| + | ||
| + | ### No ZIP files for BILLSTATUS | |
| + | The documented ZIP URL pattern (`BILLSTATUS-119hr.zip`) returns **404** for the 119th Congress. Individual XMLs are accessible; the correct enumeration method is the **sitemap**: | |
| + | ||
| + | ``` | |
| + | https://www.govinfo.gov/sitemap/bulkdata/BILLSTATUS/{congress}{type}/sitemap.xml | |
| + | ``` | |
| + | ||
| + | The sitemap index is at: | |
| + | ``` | |
| + | https://www.govinfo.gov/sitemap/bulkdata/BILLSTATUS/sitemapindex.xml | |
| + | ``` | |
| + | ||
| + | Individual files: | |
| + | ``` | |
| + | https://www.govinfo.gov/bulkdata/BILLSTATUS/{congress}/{type}/BILLSTATUS-{congress}{type}{number}.xml | |
| + | ``` | |
| + | ||
| + | ### Directory listing returns HTML error | |
| + | `GET /bulkdata/BILLSTATUS/119/hr/` returns a "Bulkdata Service Error" HTML page. Use the sitemap instead. | |
| + | ||
| + | ### Bill text (BILLS collection) - no key needed | |
| + | Full bill XML is accessible at: | |
| + | ``` | |
| + | https://www.govinfo.gov/content/pkg/BILLS-{congress}{type}{number}{version}/xml/BILLS-{congress}{type}{number}{version}.xml | |
| + | ``` | |
| + | This path does NOT require an API key. The `/api/` paths (JSON metadata) require a key. | |
| + | ||
| + | ### Sitemap entries are not sorted by bill number | |
| + | The sitemap contains entries in an arbitrary order. The first N entries from the sitemap are a random sample of bills, not the lowest-numbered ones. `--limit 200` will NOT get H.R. 1-200. | |
| + | ||
| + | ### BILLSTATUS XML root tag varies | |
| + | The root element is `<billStatus>` (lowercase) in modern files. The `<bill>` child is always there. Code handles both cases. | |
| + | ||
| + | ### Version date missing for `enr` (Enrolled Bill) | |
| + | The enrolled bill version often has no `version_date` in BILLSTATUS textVersions. The diff engine sorts by `(chain_rank, version_date)` so dateless entries fall into their correct chain position by rank. `enr` = rank 6 in the House chain. | |
| + | ||
| + | ### Section ID format changes between versions | |
| + | Different XML schemas are used for different version types: | |
| + | - Introduced (`ih`, `rh`) → long hex IDs: `H71241E5177324574B4C749197110EAB0` | |
| + | - Enrolled (`enr`) → short USLM IDs: `d244e72` | |
| + | ||
| + | When IDs change between versions, the aligner falls back to fuzzy header matching (threshold 0.6). For short bills where all sections have the same content (e.g., Born-Alive Act H.R. 21 ih→eh), this produces 0 diff rows - correct, the text is identical. | |
| + | ||
| + | For bills where the enrolled version switches from House HTML schema to USLM schema (common for enrolled bills), the aligner may incorrectly classify "removed" + "added" instead of "amended." This is a known GovInfo data quality issue. The enrolled→public-law pairs are most affected. | |
| + | ||
| + | ### Cross-chamber version codes (`pcs`, `rds`, `rih`, `ris`) | |
| + | House bills that go to the Senate acquire `pcs` (Placed on Calendar Senate) before becoming `enr`. Senate bills acquire `rds` (Received/Referred in Senate). These codes are in neither the standard House nor Senate chain; the diff engine assigns them rank 5 (after engrossed, before enrolled). | |
| + | ||
| + | ### Version code `rfs` (Referred in Senate) | |
| + | Appears for House bills passed and sent to Senate. Treated as rank 5 (cross-chamber). This is the same logical position as `pcs`. | |
| + | ||
| + | ### Public law version codes | |
| + | The BILLSTATUS XML sometimes includes a textVersion with type like `-119publ61` for Public Law 119-61. This is the enrolled public law text. Assigned rank 998 (near end of chain). Diffing `enr→-119publ61` usually shows section ID format changes only (not substantive differences). | |
| + | ||
| + | --- | |
| + | ||
| + | ## Real Data Results (as of 2026-06-26, 119th Congress sample) | |
| + | ||
| + | **Initial run (290 bills total):** | |
| + | ||
| + | | Table | Rows | | |
| + | |---|---| | |
| + | | `bills` | 290 | | |
| + | | `bill_versions` | 410 | | |
| + | | `bill_diffs` | 34 | | |
| + | | `amendments` | 99 | | |
| + | | `actions` | 1,695 | | |
| + | ||
| + | **Bills with most versions:** | |
| + | - H.J.Res. 25 (6 versions): Congressional disapproval of CFPB rule | |
| + | - H.R. 165 (6 versions): Wounded Knee Massacre Memorial Act | |
| + | - H.R. 131 (5 versions): Finish the Arkansas Valley Conduit Act | |
| + | - S.J.Res. 28/18/13/11 (5 versions each): Disapproval joint resolutions | |
| + | ||
| + | **Rider flags (3 flagged diff rows):** | |
| + | ||
| + | 1. **H.R. 43** (`rds→enr`) - Alaska Native Claims Settlement Act amendment: | |
| + | > "Section 14(c) of the Alaska Native Claims Settlement Act (43 U.S.C. 1613(c)) is amended..." | |
| + | Signal: `Is Amended` | |
| + | ||
| + | 2. **S. 5 - Laken Riley Act** (`es→enr`): | |
| + | > "Section 236(c) of the Immigration and Nationality Act (8 U.S.C. 1226(c)) is amended..." | |
| + | Signal: `Is Amended` | |
| + | ||
| + | 3. **S. 5 - Laken Riley Act** (`es→enr`, second section): | |
| + | > "Section 235(b) of the Immigration and Nationality Act (8 U.S.C. 1225(b)) is amended..." | |
| + | Signal: `Is Amended` | |
| + | ||
| + | **Real amended section example (H.R. 41, ih→rh):** | |
| + | H.R. 41 - Unrecognized Southeast Alaska Native Communities Recognition and Compensation Act: | |
| + | Section H4DAD6E8E44444A988CA8A1864ADE20A6 was amended between the introduced version and the committee-reported version. The unified diff shows rewording of the Urban Corporation definition and conveyance authorization. | |
| + | ||
| + | **Real amended section example (S. 5, pcs→es):** | |
| + | S. 5 - Laken Riley Act: | |
| + | Section H793DBCBA2A07463588AC4F4FDBEDECAF was amended between the placed-on-calendar and engrossed versions, showing Senate floor amendments to the detention mandate language (INA 236(c)). | |
| + | ||
| + | --- | |
| + | ||
| + | ## How to Run | |
| + | ||
| + | ```bash | |
| + | # Ingest all bill types for 119th Congress (runs from sitemap; ~9K HR + 4K S + …) | |
| + | python -m ingest.pollers.bills_bulk --once | |
| + | ||
| + | # Test run (50 HR bills only) | |
| + | python -m ingest.pollers.bills_bulk --once --types hr --limit 50 | |
| + | ||
| + | # Discover additional text versions via HEAD-probe | |
| + | python -m ingest.pollers.bill_versions --once | |
| + | ||
| + | # Diff all bills with 2+ versions | |
| + | python -m ingest.diff --once --all --congress 119 | |
| + | ||
| + | # Rider detection | |
| + | python -m ingest.rider_detect --once | |
| + | ||
| + | # Amendments | |
| + | python -m ingest.amendments --once | |
| + | ||
| + | # Run tests | |
| + | python -m pytest tests/test_bills_diff.py -v | |
| + | ``` | |
| + | ||
| + | ## Environment Variables | |
| + | ||
| + | | Variable | Purpose | | |
| + | |---|---| | |
| + | | `DOCKET_DB_PATH` | SQLite path (default `/var/tmp/docket/docket.db`) | | |
| + | | `DOCKET_CONGRESS_KEY` | api.data.gov key - unlocks Congress.gov API path in `amendments.py` | | |
| + | ||
| + | ## Version Chain Order | |
| + | ||
| + | House: `ih → rh → rh2 → eh → eah → eas → enr` | |
| + | Senate: `is → rs → pcs → es → eas → enr` | |
| + | Cross-chamber (rank 5): `pcs pch rds rih ris` | |
| + | Public law (rank 998): `-{congress}publ{n}` | |
| + | ||
| + | ## Amendment Data Coverage (honest gaps) | |
| + | ||
| + | - **Senate amendments 117th+ (2021+):** machine-readable text available in GovInfo SAMDT collection. `amendments.py` stores congress.gov URLs; text can be fetched separately. | |
| + | - **House amendments:** text lives in Congressional Record (CREC), not discrete objects in BILLSTATUS. `text` column is NULL; source_url points to congress.gov amendment page. | |
| + | - **Manager's / en-bloc packages:** not public as discrete amendment objects anywhere. Only inferable from CREC narrative. | |
| + | - **Voice vote / UC additions:** `agreed_method` set to `'voice'` or `'uc'`; `text` is NULL; UI must show "no individual record exists." | |
| + | - **Pre-117th amendment text:** not machine-readable in bulk data; linked from congress.gov pages only. |
| @@ -0,0 +1,173 @@ | ||
| + | # Closed-Door + Executive Layer - Build Notes | |
| + | _Built 2026-06-26. All pollers verified live on CT247._ | |
| + | ||
| + | ## What was built | |
| + | ||
| + | Four new pollers in `services/ingest/ingest/pollers/`: | |
| + | ||
| + | | File | Source | Table | Auth | | |
| + | |------|--------|-------|------| | |
| + | | `federal_register.py` | federalregister.gov/api/v1 | `executive_actions` | none | | |
| + | | `openomb.py` | apportionment-public.max.gov | `apportionments` | none | | |
| + | | `crec.py` | govinfo.gov/content/pkg/CREC-*/zip/ | `procedural_events` | none | | |
| + | | `lobbying.py` | lda.gov/api/v1 | `lobbying` | env `DOCKET_LDA_KEY` | | |
| + | ||
| + | One new test file: `tests/test_crec_voice_vote.py` (62 tests). | |
| + | ||
| + | All four pollers follow the existing pattern: `_ensure_tables()` creates new | |
| + | tables without touching `db.py`; `validate_source_url()` is called before every | |
| + | write; `is_new_event()` / `mark_seen()` enforce idempotency. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Live run results (CT247 - 2026-06-26) | |
| + | ||
| + | | Table | Rows | Notes | | |
| + | |-------|------|-------| | |
| + | | `executive_actions` | **500** | 7 days of FR docs, all 4 types (PRESDOCU/RULE/PRORULE/NOTICE) | | |
| + | | `apportionments` | **20** | 20 records from FY2025 Corps of Engineers slice | | |
| + | | `procedural_events` | **188** | 5 session days from CREC-2026-06-16 through 06-23 | | |
| + | | `lobbying` | 0 | Graceful skip - `DOCKET_LDA_KEY` not set | | |
| + | ||
| + | --- | |
| + | ||
| + | ## Sample: real Executive Order / rule row | |
| + | ||
| + | ``` | |
| + | type: nprm | |
| + | title: Removing Obsolete References to Twentieth-Century Airman Certificates | |
| + | agency: Transportation Department; Federal Aviation Administration | |
| + | date: 2026-06-26 | |
| + | source_url: https://www.federalregister.gov/documents/2026/06/26/2026-13003/... | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Sample: real apportionment showing appropriated vs apportioned | |
| + | ||
| + | ``` | |
| + | agency: Corps of Engineers--Civil Works | |
| + | account: Construction | |
| + | appropriated: $39,088,945,000 | |
| + | apportioned: $39,088,945,000 | |
| + | status: released | |
| + | date: 2024-09-06 | |
| + | source_url: https://apportionment-public.max.gov/Fiscal%20Year%202025/Corps%20 | |
| + | of%20Engineers--Civil%20Works/JSON/FY2025_Agency%3DCOE_... | |
| + | ``` | |
| + | ||
| + | Status logic: apportioned/appropriated ≥ 95% → released; > 0 but < 95% → delayed; 0 → withheld. | |
| + | ||
| + | The FY2025 Corps of Engineers records all show "released" (normal operating year). | |
| + | The impound-tracker signal emerges when `status = withheld` (OMB released $0 against | |
| + | an appropriation) - those rows are the headline feature for Docket's "buried data" pillar. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Sample: real detected voice vote / procedural event | |
| + | ||
| + | ``` | |
| + | kind: cloture | |
| + | date: 2026-06-23 | |
| + | chamber: Both | |
| + | detail: Cloture action: …provisions of Rule XXII of the Standing Rules of the | |
| + | Senate, a vote on cloture will oc… | |
| + | members_jsonb: NULL ← no individual record exists (see honest gap below) | |
| + | source_url: https://www.govinfo.gov/app/details/CREC-2026-06-23 | |
| + | ``` | |
| + | ||
| + | CREC-2026-06-23 yielded: 80 events (cloture × 13, unanimous_consent × 63, suspension × 4). | |
| + | No voice-vote matches on that day - the Senate was on cloture votes, not voice-vote-only measures. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Test results | |
| + | ||
| + | ``` | |
| + | 191 passed in 20.80s | |
| + | ``` | |
| + | ||
| + | - `test_crec_voice_vote.py` - 62 tests: voice-vote regex, UC, cloture, suspension, MTR, | |
| + | discharge, XML/HTML extractor, chamber inference, snippet extraction, `_detect_events()` | |
| + | end-to-end, source-URL .gov enforcement, idempotency. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Technical gotchas discovered during build | |
| + | ||
| + | ### Federal Register API - array param encoding | |
| + | Must use a list of `(key, value)` tuples (not a dict) for `fields[]` and | |
| + | `conditions[type][]`. The field `presidential_document_type` does not exist | |
| + | in the API - causes HTTP 400. Use `abstract` instead. | |
| + | ||
| + | ### GovInfo CREC - correct URL | |
| + | Bulk data path (`/bulkdata/CREC/`) returns HTML error pages, not ZIPs. | |
| + | The working URL is: | |
| + | ``` | |
| + | https://www.govinfo.gov/content/pkg/CREC-YYYY-MM-DD/zip/CREC-YYYY-MM-DD.zip | |
| + | ``` | |
| + | Non-session days (weekends, recesses) return HTML (not 404) - "bad ZIP" errors | |
| + | are expected and handled gracefully. Default look-back is 14 days to cover 5+ | |
| + | session days. | |
| + | ||
| + | ### CREC ZIP contents - HTML, not XML | |
| + | The ZIP contains `.htm` files (not standalone XML). Text extraction strips HTML | |
| + | tags with regex + `html.unescape()`. The `_extract_text_from_xml()` function | |
| + | handles both correctly. | |
| + | ||
| + | ### max.gov apportionments - no REST API | |
| + | `apportionment-public.max.gov` has no JSON API endpoint. The site serves | |
| + | individual JSON files per apportionment record (one per TAFS account + FY + | |
| + | iteration), listed in an HTML index page. The poller: | |
| + | 1. Fetches the HTML index (~500KB, loads in ~20s) | |
| + | 2. Extracts all JSON file hrefs matching the target FY via regex | |
| + | 3. Downloads individual records (capped at 200/run, 50 for `--once`) | |
| + | ||
| + | FY2025 has 6,172 JSON links; FY2026 will grow through the year. A scheduled | |
| + | hourly/daily run can work through the full set over time. | |
| + | ||
| + | openomb.org (the aggregator) returns HTTP 403 for automated requests - use | |
| + | max.gov directly, which is also the correct source_url target per the | |
| + | neutrality rule. | |
| + | ||
| + | ### Neutrality rule - source_url enforcement | |
| + | - `federal_register.py`: html_url from FR API → federalregister.gov (.gov ✓) | |
| + | - `openomb.py`: direct max.gov JSON file URL (.max.gov = .gov ✓) | |
| + | - `crec.py`: govinfo.gov app/details page (.gov ✓) | |
| + | - `lobbying.py`: lda.gov/filings/{uuid}/ (.gov ✓) | |
| + | ||
| + | All validated by `source_validator.validate_source_url()` before every DB write. | |
| + | ||
| + | --- | |
| + | ||
| + | ## How to run | |
| + | ||
| + | ```bash | |
| + | # On CT247 (venv at /opt/docket/services/ingest/.venv) | |
| + | cd /shared/projects/docket/services/ingest | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.federal_register --once | |
| + | ||
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.openomb --once --max 50 | |
| + | ||
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.crec --once --days 14 | |
| + | ||
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db DOCKET_LDA_KEY=<your_key> \ | |
| + | /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.lobbying --once | |
| + | ``` | |
| + | ||
| + | ## LDA key | |
| + | ||
| + | Register free at https://lda.gov/api/. Without a key the lobbying poller logs | |
| + | a clear message and returns 0 - no crash, no partial state. | |
| + | ||
| + | ## Recommended poll cadences | |
| + | ||
| + | | Poller | Cadence | Notes | | |
| + | |--------|---------|-------| | |
| + | | federal_register | Every 4h | Same-day FR lag | | |
| + | | openomb | Daily | max.gov updates when OMB signs a new apportionment | | |
| + | | crec | Daily (noon ET) | ~1-day CREC publishing lag | | |
| + | | lobbying | Weekly | LDA filings are quarterly | |
| @@ -0,0 +1,155 @@ | ||
| + | # Docket - Votes & Members Build Notes | |
| + | ||
| + | Verified 2026-06-26 on CT247 (venv `/opt/docket/services/ingest/.venv`, DB `/opt/docket/data/docket.db`). | |
| + | ||
| + | ## Files Created / Modified | |
| + | ||
| + | | File | Status | Purpose | | |
| + | |---|---|---| | |
| + | | `ingest/pollers/members.py` | **new** | Full current member roster (537 members) | | |
| + | | `ingest/pollers/votes_house.py` | **rewritten** | House XML with per-member positions | | |
| + | | `ingest/pollers/votes_enrich.py` | **new** | Senate positions backfill from existing vote source_urls | | |
| + | | `tests/test_votes_house_parser.py` | **new** | 39 tests: XML parser, position enum, schema, both pivots | | |
| + | ||
| + | ## Schema additions (additive - all CREATE TABLE IF NOT EXISTS / ALTER TABLE) | |
| + | ||
| + | ```sql | |
| + | -- members table (new) | |
| + | CREATE TABLE IF NOT EXISTS members ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | bioguide_id TEXT NOT NULL UNIQUE, | |
| + | full_name TEXT NOT NULL, | |
| + | party TEXT, state TEXT, district INTEGER, chamber TEXT, | |
| + | lis_member_id TEXT, govtrack_id INTEGER, icpsr_id INTEGER, | |
| + | source_url TEXT NOT NULL -- https://bioguide.congress.gov/search/bio/{id} | |
| + | ); | |
| + | CREATE INDEX IF NOT EXISTS idx_members_lis ON members(lis_member_id); | |
| + | ||
| + | -- vote_positions table (new) | |
| + | CREATE TABLE IF NOT EXISTS vote_positions ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | vote_id INTEGER NOT NULL REFERENCES votes(id), | |
| + | member_bioguide TEXT, | |
| + | member_id INTEGER REFERENCES members(id), | |
| + | position TEXT NOT NULL CHECK(position IN ('Yea','Nay','Present','Not Voting')) | |
| + | ); | |
| + | CREATE INDEX IF NOT EXISTS idx_vote_positions_vote_id ON vote_positions(vote_id); | |
| + | CREATE INDEX IF NOT EXISTS idx_vote_positions_member_id ON vote_positions(member_id); | |
| + | CREATE INDEX IF NOT EXISTS idx_vote_positions_bioguide ON vote_positions(member_bioguide); | |
| + | ||
| + | -- Extended columns on votes (all nullable, backward-compatible) | |
| + | ALTER TABLE votes ADD COLUMN congress INTEGER; | |
| + | ALTER TABLE votes ADD COLUMN session INTEGER; | |
| + | ALTER TABLE votes ADD COLUMN roll_number INTEGER; | |
| + | ALTER TABLE votes ADD COLUMN question TEXT; | |
| + | ALTER TABLE votes ADD COLUMN not_voting INTEGER; | |
| + | ALTER TABLE votes ADD COLUMN recorded INTEGER DEFAULT 1; | |
| + | ALTER TABLE votes ADD COLUMN vote_external_id TEXT; | |
| + | ALTER TABLE votes ADD COLUMN bill_text_ref TEXT; | |
| + | ``` | |
| + | ||
| + | ## Data ingested (2026-06-26 baseline) | |
| + | ||
| + | | Source | Count | | |
| + | |---|---| | |
| + | | Members | 537 (437 House, 100 Senate) | | |
| + | | House votes (2026, rolls 1-62) | 50 | | |
| + | | Senate votes (119/1, votes 1-40) | 40 | | |
| + | | vote_positions total | 25,572 | | |
| + | | vote_positions (House) | 21,581 | | |
| + | | vote_positions (Senate) | 3,991 | | |
| + | ||
| + | ## Running the pollers | |
| + | ||
| + | ```bash | |
| + | # Members roster (run first - needed for member_id foreign keys) | |
| + | python -m ingest.pollers.members --once | |
| + | ||
| + | # House votes (probes current year sequentially, stops at 404) | |
| + | python -m ingest.run --source votes_house | |
| + | ||
| + | # Senate votes (existing poller, no modification) | |
| + | python -m ingest.run --source votes_senate | |
| + | ||
| + | # Senate positions backfill (run after members + senate votes) | |
| + | python -m ingest.pollers.votes_enrich --once | |
| + | ``` | |
| + | ||
| + | ## Worked example - both pivot directions | |
| + | ||
| + | ### Pivot 1: "Who voted how on House Roll #62?" | |
| + | ||
| + | ``` | |
| + | Roll #62 (2026-02-11) - On Agreeing to the Resolution, as Amended | |
| + | H RES 1057 | Passed | Y:216 N:215 P:0 NV:1 | |
| + | 432 positions recorded | |
| + | Source: https://clerk.house.gov/evs/2026/roll062.xml | |
| + | ||
| + | Sample: | |
| + | Shomari Figures (D-AL): Nay | |
| + | Terri A. Sewell (D-AL): Nay | |
| + | Nicholas J. Begich III (R-AK): Yea | |
| + | Robert B. Aderholt (R-AL): Yea | |
| + | ``` | |
| + | ||
| + | Query: | |
| + | ```sql | |
| + | SELECT vp.position, m.full_name, m.party, m.state | |
| + | FROM vote_positions vp | |
| + | LEFT JOIN members m ON m.bioguide_id = vp.member_bioguide | |
| + | WHERE vp.vote_id = (SELECT id FROM votes WHERE roll_number=62 AND chamber='house') | |
| + | ORDER BY m.state, m.full_name; | |
| + | ``` | |
| + | ||
| + | ### Pivot 2: "How did Rep. Aderholt vote recently?" | |
| + | ||
| + | ``` | |
| + | Robert B. Aderholt (R-AL) | bioguide: A000055 | |
| + | Source: https://bioguide.congress.gov/search/bio/A000055 | |
| + | 50 recorded votes in DB | |
| + | ||
| + | Roll #062 | 2026-02-11 | On Agreeing to the Resolution, as Amended -> Yea | |
| + | Roll #061 | 2026-02-11 | On Ordering the Previous Question -> Yea | |
| + | Roll #059 | 2026-02-10 | On Ordering the Previous Question -> Yea | |
| + | Roll #054 | 2026-02-04 | On Motion to Recommit -> Nay | |
| + | ``` | |
| + | ||
| + | Query: | |
| + | ```sql | |
| + | SELECT v.roll_number, v.date, v.question, vp.position | |
| + | FROM vote_positions vp | |
| + | JOIN votes v ON v.id = vp.vote_id | |
| + | WHERE vp.member_bioguide = 'A000055' | |
| + | ORDER BY v.date DESC, v.roll_number DESC; | |
| + | ``` | |
| + | ||
| + | ## Key design decisions | |
| + | ||
| + | **Members source**: `unitedstates/congress-legislators` `gh-pages` branch JSON (not `main` - YAML only). Fetch URL is `raw.githubusercontent.com`; stored `source_url` is always `bioguide.congress.gov/search/bio/{id}` (allowlisted .gov). | |
| + | ||
| + | **House XML date format**: The Clerk XML uses `"D-Mon-YYYY"` (e.g. `"8-Jan-2026"`), not `MM/DD/YYYY` as some docs suggest. The parser tries `%d-%b-%Y` first, then fallbacks. | |
| + | ||
| + | **House Clerk URL format**: `https://clerk.house.gov/evs/{year}/roll{NNN}.xml` (lowercase `roll`, no underscore, zero-padded 3 digits). The prior stub incorrectly used `ROLL_{NNN}.xml`. | |
| + | ||
| + | **Bioguide join**: House XML `<legislator name-id="...">` carries the bioguide directly. Senate XML uses `lis_member_id`; the enrich module maps it via `members.lis_member_id`. | |
| + | ||
| + | **Unmatched members**: Delegates (DC, territories, etc.) appear in House roll-call XMLs but not in `legislators-current.json`. Their positions are stored with `member_bioguide` populated and `member_id = NULL` - data preserved, nothing fabricated. | |
| + | ||
| + | **Honesty on voice/UC votes**: `parse_house_vote_xml()` returns `None` for non-recorded vote types (`VOICE VOTE`, etc.). Those votes are not in this table. The closed-door agent (CREC) owns them. | |
| + | ||
| + | **Senate backfill approach**: `votes_enrich.py` re-fetches the `source_url` already stored on existing `votes` rows - no external mapping needed. Idempotent: only processes rows with no existing positions. | |
| + | ||
| + | ## Tests (39 total, 230 total suite) | |
| + | ||
| + | ``` | |
| + | tests/test_votes_house_parser.py - 39 tests | |
| + | ||
| + | TestNormalizePosition (6) - position enum mapping incl. Aye/No variants | |
| + | TestParseLegisNum (9) - bill type normalization, all 8 prefixes | |
| + | TestParseHouseVoteXml (9) - fields, counts, bioguides, voice-vote honesty | |
| + | TestParseSenateVotePositions (9) - LIS IDs, all 4 positions, malformed XML | |
| + | TestSchemaAndInsertion (6) - table creation, column presence, enum enforcement, | |
| + | both pivot directions via JOIN | |
| + | ``` | |
| + | ||
| + | All 230 tests pass (suite includes pre-existing ranking/source_url/state/diff/crec tests). |
| @@ -1,29 +0,0 @@ | ||
| - | import sqlite3, os | |
| - | ||
| - | db = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db") | |
| - | conn = sqlite3.connect(db) | |
| - | conn.row_factory = sqlite3.Row | |
| - | ||
| - | print("=== BILLS ===") | |
| - | r = conn.execute("SELECT COUNT(*) n FROM bills WHERE source='govinfo_billstatus'").fetchone() | |
| - | print(" Total bills:", r["n"]) | |
| - | ||
| - | print("\n=== SAMPLE BILLS ===") | |
| - | for row in conn.execute("SELECT b.number, b.title, be.sponsor_name, be.sponsor_party, be.sponsor_state FROM bills b LEFT JOIN bills_ext be ON be.bill_id=b.id WHERE b.source='govinfo_billstatus' LIMIT 5").fetchall(): | |
| - | print(" ", row["number"], "|", row["title"][:50], "|", row["sponsor_name"], f'({row["sponsor_party"]}-{row["sponsor_state"]})') | |
| - | ||
| - | print("\n=== BILL_VERSIONS ===") | |
| - | r = conn.execute("SELECT COUNT(*) n FROM bill_versions").fetchone() | |
| - | print(" Total versions:", r["n"]) | |
| - | ||
| - | print("\n=== VERSION CODES ===") | |
| - | for row in conn.execute("SELECT version_code, COUNT(*) n FROM bill_versions GROUP BY version_code ORDER BY n DESC").fetchall(): | |
| - | print(" ", row["version_code"], ":", row["n"]) | |
| - | ||
| - | print("\n=== BILLS WITH 2+ VERSIONS ===") | |
| - | for row in conn.execute("SELECT b.number, b.title, COUNT(bv.id) nv FROM bills b JOIN bill_versions bv ON bv.bill_id=b.id WHERE b.source='govinfo_billstatus' GROUP BY b.id HAVING nv >= 2 ORDER BY nv DESC LIMIT 10").fetchall(): | |
| - | print(" ", row["number"], "(", row["nv"], "versions):", row["title"][:60]) | |
| - | ||
| - | print("\n=== ACTIONS ===") | |
| - | r = conn.execute("SELECT COUNT(*) n FROM actions WHERE source='govinfo_billstatus'").fetchone() | |
| - | print(" Total actions:", r["n"]) |
| @@ -1,38 +0,0 @@ | ||
| - | import sqlite3, os, sys, requests | |
| - | ||
| - | db = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db") | |
| - | sys.path.insert(0, "/shared/projects/docket/services/ingest") | |
| - | ||
| - | from ingest.diff import fetch_version_xml, parse_sections | |
| - | ||
| - | conn = sqlite3.connect(db) | |
| - | conn.row_factory = sqlite3.Row | |
| - | ||
| - | # Find H.R. 21 (Born-Alive, 3 versions) | |
| - | for num in ["H.R. 21", "H.R. 27", "H.R. 26"]: | |
| - | row = conn.execute("SELECT id, title FROM bills WHERE number=? AND source='govinfo_billstatus'", (num,)).fetchone() | |
| - | if row: | |
| - | bill_id = row["id"] | |
| - | title = row["title"] | |
| - | vers = conn.execute("SELECT version_code, text_ref FROM bill_versions WHERE bill_id=? ORDER BY version_date", (bill_id,)).fetchall() | |
| - | if len(vers) >= 2: | |
| - | print(f"Bill: {num} id={bill_id} '{title[:50]}'") | |
| - | for v in vers: | |
| - | print(f" {v['version_code']}: {v['text_ref']}") | |
| - | # Check section count in first two versions | |
| - | v0, v1 = vers[0], vers[1] | |
| - | xml0 = fetch_version_xml(v0["text_ref"]) | |
| - | xml1 = fetch_version_xml(v1["text_ref"]) | |
| - | if xml0: | |
| - | s0 = parse_sections(xml0) | |
| - | print(f" Sections in {v0['version_code']}: {len(s0)}") | |
| - | for s in s0[:3]: | |
| - | print(f" sec_id={s.sec_id!r} enum={s.enum!r} header={s.header!r}") | |
| - | if xml1: | |
| - | s1 = parse_sections(xml1) | |
| - | print(f" Sections in {v1['version_code']}: {len(s1)}") | |
| - | for s in s1[:3]: | |
| - | print(f" sec_id={s.sec_id!r} enum={s.enum!r} header={s.header!r}") | |
| - | break | |
| - | ||
| - | conn.close() |
| @@ -1,53 +0,0 @@ | ||
| - | import sqlite3, os, sys | |
| - | ||
| - | db = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db") | |
| - | sys.path.insert(0, "/shared/projects/docket/services/ingest") | |
| - | ||
| - | from ingest.db import get_connection, init_schema | |
| - | from ingest.pollers.bills_bulk import ensure_schema | |
| - | from ingest.diff import diff_bill | |
| - | ||
| - | conn = get_connection(db) | |
| - | init_schema(conn) | |
| - | ensure_schema(conn) | |
| - | ||
| - | # Find bill_id for HR 165 | |
| - | row = conn.execute("SELECT id, title FROM bills WHERE number='H.R. 165' AND source='govinfo_billstatus'").fetchone() | |
| - | if row is None: | |
| - | print("HR 165 not found, trying HR 21") | |
| - | row = conn.execute("SELECT id, title FROM bills WHERE number='H.R. 21' AND source='govinfo_billstatus'").fetchone() | |
| - | ||
| - | if row is None: | |
| - | print("No multi-version bill found") | |
| - | # Find any bill with 2+ versions | |
| - | row2 = conn.execute("""SELECT b.id, b.number, b.title, COUNT(bv.id) nv | |
| - | FROM bills b JOIN bill_versions bv ON bv.bill_id=b.id | |
| - | WHERE b.source='govinfo_billstatus' | |
| - | GROUP BY b.id HAVING nv >= 2 ORDER BY nv DESC LIMIT 1""").fetchone() | |
| - | if row2: | |
| - | bill_id = row2["id"] | |
| - | print(f"Using bill {row2['number']}: {row2['title'][:60]} ({row2['nv']} versions)") | |
| - | else: | |
| - | print("No multi-version bills found") | |
| - | sys.exit(1) | |
| - | else: | |
| - | bill_id = row["id"] | |
| - | print(f"Diffing bill_id={bill_id}: {row['title'][:60]}") | |
| - | ||
| - | # Show versions | |
| - | versions = conn.execute("SELECT version_code, version_date, text_ref FROM bill_versions WHERE bill_id=? ORDER BY version_date", (bill_id,)).fetchall() | |
| - | print("\nVersions:") | |
| - | for v in versions: | |
| - | print(f" {v['version_code']}: {v['version_date']}") | |
| - | ||
| - | print("\nRunning diff_bill...") | |
| - | n = diff_bill(bill_id, conn) | |
| - | print(f"\nDiff rows written: {n}") | |
| - | ||
| - | print("\n=== BILL_DIFFS ===") | |
| - | for row in conn.execute("SELECT from_version, to_version, section_id, change, rider_flag, snippet FROM bill_diffs WHERE bill_id=? LIMIT 20", (bill_id,)).fetchall(): | |
| - | print(f" {row['from_version']}→{row['to_version']} | {row['change']:8s} | rider={row['rider_flag']} | section={row['section_id']}") | |
| - | if row['snippet']: | |
| - | print(f" snippet: {row['snippet'][:120]}") | |
| - | ||
| - | conn.close() |
| @@ -1,38 +0,0 @@ | ||
| - | import sqlite3, os, sys | |
| - | ||
| - | db = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db") | |
| - | sys.path.insert(0, "/shared/projects/docket/services/ingest") | |
| - | ||
| - | conn = sqlite3.connect(db) | |
| - | conn.row_factory = sqlite3.Row | |
| - | ||
| - | print("=== BILLS WITH 3+ VERSIONS (all types) ===") | |
| - | for row in conn.execute(""" | |
| - | SELECT b.id, b.number, b.title, be.bill_type, COUNT(bv.id) nv | |
| - | FROM bills b | |
| - | JOIN bill_versions bv ON bv.bill_id=b.id | |
| - | LEFT JOIN bills_ext be ON be.bill_id=b.id | |
| - | WHERE b.source='govinfo_billstatus' | |
| - | GROUP BY b.id HAVING nv >= 3 ORDER BY nv DESC LIMIT 15 | |
| - | """).fetchall(): | |
| - | vcs = [r["version_code"] for r in conn.execute("SELECT version_code FROM bill_versions WHERE bill_id=? ORDER BY version_date", (row["id"],)).fetchall()] | |
| - | print(f" {row['number']:15s} ({row['nv']} vers) {row['bill_type']:7s}: {row['title'][:55]}") | |
| - | print(f" versions: {' → '.join(vcs)}") | |
| - | ||
| - | print("\n=== APPROPRIATIONS / HJRES BILLS ===") | |
| - | for row in conn.execute(""" | |
| - | SELECT b.id, b.number, b.title, be.bill_type, COUNT(bv.id) nv | |
| - | FROM bills b | |
| - | JOIN bill_versions bv ON bv.bill_id=b.id | |
| - | LEFT JOIN bills_ext be ON be.bill_id=b.id | |
| - | WHERE b.source='govinfo_billstatus' AND ( | |
| - | be.bill_type='HJRES' OR be.bill_type='SJRES' | |
| - | OR LOWER(b.title) LIKE '%appropriat%' | |
| - | ) | |
| - | GROUP BY b.id ORDER BY nv DESC LIMIT 10 | |
| - | """).fetchall(): | |
| - | vcs = [r["version_code"] for r in conn.execute("SELECT version_code FROM bill_versions WHERE bill_id=? ORDER BY version_date", (row["id"],)).fetchall()] | |
| - | print(f" {row['number']:15s} ({row['nv']} vers) {row['bill_type']:7s}: {row['title'][:55]}") | |
| - | print(f" versions: {' → '.join(vcs)}") | |
| - | ||
| - | conn.close() |
| @@ -86,47 +86,44 @@ def _classify_agreed_method(description: str) -> Optional[str]: | ||
| def _ingest_from_billstatus(conn: sqlite3.Connection, congress: int) -> int: | ||
| """ | ||
| - | Parse amendment references from bills already in the DB. | |
| - | BILLSTATUS XML sometimes contains <amendments> sections. | |
| - | Reads raw_events for the govinfo_billstatus source. | |
| + | Parse amendment references from action rows already stored by bills_bulk. | |
| + | ||
| + | bills_bulk.py stores one row per action in the `actions` table. Any action | |
| + | whose text references "H.Amdt." or "S.Amdt." plus a number is treated as an | |
| + | amendment reference and stored in the `amendments` table with whatever | |
| + | metadata can be inferred from the action text. | |
| + | ||
| + | Honest gaps: | |
| + | - House amendment TEXT is rarely machine-readable here; text=NULL unless | |
| + | the action row itself quotes it. | |
| + | - agreed_method inferred from action text keywords; may be NULL. | |
| + | - 117th Congress (2021)+ has machine-readable Senate amendment text in | |
| + | GovInfo; older congresses have Congress-Record-only text. | |
| """ | ||
| - | rows = conn.execute( | |
| - | """ | |
| - | SELECT re.external_id, re.payload | |
| - | FROM raw_events re | |
| - | JOIN bills b ON b.source = re.source AND b.external_id = re.external_id | |
| - | WHERE re.source = ? AND b.congress = ? | |
| - | """, | |
| + | import re as _re | |
| + | ||
| + | # All bills for this congress | |
| + | bills = conn.execute( | |
| + | "SELECT id, source_url FROM bills WHERE source=? AND congress=?", | |
| (BILL_SOURCE, congress), | ||
| ).fetchall() | ||
| stored = 0 | ||
| - | for row in rows: | |
| - | try: | |
| - | payload = json.loads(row["payload"]) | |
| - | except (json.JSONDecodeError, TypeError): | |
| - | continue | |
| + | for bill in bills: | |
| + | bill_id = bill["id"] | |
| + | source_url = bill["source_url"] | |
| - | # The payload stored by bills_bulk is the raw XML bytes or a dict summary. | |
| - | # bills_bulk doesn't store raw_events - but we can look up the bill_id | |
| - | # and check its actions for amendment references. | |
| - | bill_ext_id = row["external_id"] # e.g. "119-hr-1" | |
| - | bill_row = conn.execute( | |
| - | "SELECT id FROM bills WHERE source=? AND external_id=?", | |
| - | (BILL_SOURCE, bill_ext_id), | |
| - | ).fetchone() | |
| - | if bill_row is None: | |
| - | continue | |
| - | bill_id = bill_row["id"] | |
| - | ||
| - | # Look for amendment action rows (action_type contains 'Amendment') | |
| + | # Look for amendment action rows | |
| amdt_actions = conn.execute( | ||
| """ | ||
| SELECT action_date, text, action_type, chamber | ||
| FROM actions | ||
| - | WHERE bill_id = ? AND ( | |
| + | WHERE bill_id=? AND ( | |
| LOWER(action_type) LIKE '%amendment%' | ||
| - | OR LOWER(text) LIKE '%amendment%' | |
| + | OR text LIKE '%Amdt.%' | |
| + | OR text LIKE '%amendment%agreed%' | |
| + | OR text LIKE '%amendment%rejected%' | |
| + | OR text LIKE '%amendment%tabled%' | |
| ) | ||
| ORDER BY action_date | ||
| """, | ||
| @@ -134,19 +131,33 @@ def _ingest_from_billstatus(conn: sqlite3.Connection, congress: int) -> int: | ||
| ).fetchall() | ||
| for a in amdt_actions: | ||
| - | atext = a["text"] or "" | |
| - | # Try to extract amendment number from text | |
| - | import re | |
| - | m = re.search(r"(?:H\.|S\.)\s*Amdt\.?\s*(\d+)", atext, re.IGNORECASE) | |
| - | if m: | |
| - | amdt_num = m.group(1) | |
| - | amdt_type = "HAMDT" if a["chamber"] == "House" else "SAMDT" | |
| - | source_url = _congress_amdt_url(congress, amdt_type, amdt_num) | |
| + | atext = a["text"] or "" | |
| + | chamber = a["chamber"] or "" | |
| + | ||
| + | # Extract "H.Amdt. NNN" or "S.Amdt. NNN" | |
| + | for m in _re.finditer( | |
| + | r"([HS])\.?\s*Amdt\.?\s*(\d+)", atext, _re.IGNORECASE | |
| + | ): | |
| + | prefix = m.group(1).upper() | |
| + | amdt_num = m.group(2) | |
| + | amdt_type = "HAMDT" if prefix == "H" else "SAMDT" | |
| + | ||
| + | amdt_url = _congress_amdt_url(congress, amdt_type, amdt_num) | |
| try: | ||
| - | validate_source_url(source_url) | |
| + | validate_source_url(amdt_url) | |
| except Exception: | ||
| continue | ||
| + | ||
| agreed = _classify_agreed_method(atext) | ||
| + | ||
| + | # Infer status from action text | |
| + | status = "proposed" | |
| + | low = atext.lower() | |
| + | if "agreed to" in low or "passed" in low: | |
| + | status = "agreed" | |
| + | elif "rejected" in low or "tabled" in low or "failed" in low: | |
| + | status = "rejected" | |
| + | ||
| conn.execute( | ||
| """ | ||
| INSERT INTO amendments | ||
| @@ -155,14 +166,15 @@ def _ingest_from_billstatus(conn: sqlite3.Connection, congress: int) -> int: | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||
| ON CONFLICT(congress, type, number) DO UPDATE SET | ||
| bill_id = excluded.bill_id, | ||
| - | status = excluded.status, | |
| - | agreed_method = COALESCE(excluded.agreed_method, agreed_method), | |
| + | status = COALESCE(excluded.status, amendments.status), | |
| + | agreed_method = COALESCE(excluded.agreed_method, amendments.agreed_method), | |
| source_url = excluded.source_url | ||
| """, | ||
| ( | ||
| congress, amdt_type, amdt_num, bill_id, | ||
| - | "enacted" if "enacted" in atext.lower() else "proposed", | |
| - | agreed, None, source_url, | |
| + | status, agreed, | |
| + | None, # text=NULL - not machine-readable at this source | |
| + | amdt_url, | |
| ), | ||
| ) | ||
| stored += 1 | ||
| @@ -273,6 +285,72 @@ def _ingest_from_congress_api( | ||
| # Main poll | ||
| # --------------------------------------------------------------------------- | ||
| + | def _ingest_by_refetching_billstatus(conn: sqlite3.Connection, congress: int) -> int: | |
| + | """ | |
| + | For each bill already in the DB that has no amendments yet, | |
| + | re-fetch its BILLSTATUS XML from GovInfo and parse the <amendments> element. | |
| + | ||
| + | This is needed because bills_bulk.py only parses amendments for NEW bills. | |
| + | Existing bills (ingested before this code was added) need a one-time backfill. | |
| + | """ | |
| + | from ingest.pollers.bills_bulk import ( | |
| + | _store_amendments_from_bill_el, | |
| + | _iter_bill_elements, | |
| + | BILL_TYPE_SLUG, | |
| + | ) | |
| + | ||
| + | BULK_BASE = "https://www.govinfo.gov/bulkdata/BILLSTATUS" | |
| + | session = requests.Session() | |
| + | session.headers["User-Agent"] = "Docket-ingest/1.0 (transparency research)" | |
| + | ||
| + | # Bills in this congress that have no amendments row yet | |
| + | bills = conn.execute( | |
| + | """ | |
| + | SELECT b.id, b.number, be.bill_type, b.source_url | |
| + | FROM bills b | |
| + | LEFT JOIN bills_ext be ON be.bill_id=b.id | |
| + | WHERE b.source='govinfo_billstatus' AND b.congress=? | |
| + | """, | |
| + | (congress,), | |
| + | ).fetchall() | |
| + | ||
| + | total = 0 | |
| + | for bill in bills: | |
| + | bill_id = bill["id"] | |
| + | raw_num = bill["number"] or "" # "H.R. 5" → need the numeric part | |
| + | bill_type = (bill["bill_type"] or "").lower() | |
| + | ||
| + | # Extract numeric part | |
| + | num = "".join(c for c in raw_num if c.isdigit()) | |
| + | if not num or not bill_type: | |
| + | continue | |
| + | ||
| + | # Already has amendments? Skip | |
| + | existing = conn.execute( | |
| + | "SELECT COUNT(*) n FROM amendments WHERE bill_id=?", (bill_id,) | |
| + | ).fetchone()["n"] | |
| + | if existing > 0: | |
| + | continue | |
| + | ||
| + | url = f"{BULK_BASE}/{congress}/{bill_type}/BILLSTATUS-{congress}{bill_type}{num}.xml" | |
| + | try: | |
| + | r = session.get(url, timeout=30) | |
| + | if r.status_code != 200: | |
| + | continue | |
| + | xml_bytes = r.content | |
| + | except requests.RequestException: | |
| + | continue | |
| + | ||
| + | for bill_el in _iter_bill_elements(xml_bytes): | |
| + | n = _store_amendments_from_bill_el( | |
| + | conn, bill_el, bill_id, congress, bill["source_url"] or "" | |
| + | ) | |
| + | total += n | |
| + | ||
| + | conn.commit() | |
| + | return total | |
| + | ||
| + | ||
| def poll(congress: int = 119, db_path: Optional[str] = None) -> int: | ||
| """ | ||
| Populate the amendments table. | ||
| @@ -284,12 +362,17 @@ def poll(congress: int = 119, db_path: Optional[str] = None) -> int: | ||
| total = 0 | ||
| - | # Path 1: parse from already-stored bill action rows | |
| + | # Path 1: Re-fetch BILLSTATUS XMLs to parse <amendments> elements | |
| + | n = _ingest_by_refetching_billstatus(conn, congress) | |
| + | logger.info("amendments: %d rows from BILLSTATUS XML re-fetch", n) | |
| + | total += n | |
| + | ||
| + | # Path 2: parse from action-text references (supplementary) | |
| n = _ingest_from_billstatus(conn, congress) | ||
| - | logger.info("amendments: %d rows from BILLSTATUS action parsing", n) | |
| + | logger.info("amendments: %d rows from action-text parsing", n) | |
| total += n | ||
| - | # Path 2: Congress.gov API (if key present) | |
| + | # Path 3: Congress.gov API (if key present) | |
| key = os.environ.get("DOCKET_CONGRESS_KEY", "") | ||
| if key: | ||
| n = _ingest_from_congress_api(conn, congress, key) |
| @@ -456,9 +456,104 @@ def _store_bill( | ||
| (bill_id, vcode, pkg_id, label, vdate, text_ref, text_ref), | ||
| ) | ||
| + | # Amendments from the <amendments> element | |
| + | _store_amendments_from_bill_el(conn, bill_el, bill_id, congress, source_url) | |
| + | ||
| return bill_id | ||
| + | def _store_amendments_from_bill_el( | |
| + | conn: sqlite3.Connection, | |
| + | bill_el: ET.Element, | |
| + | bill_id: int, | |
| + | congress: int, | |
| + | default_source_url: str, | |
| + | ) -> int: | |
| + | """ | |
| + | Parse the <amendments> section of a BILLSTATUS <bill> element and | |
| + | upsert into the amendments table. | |
| + | ||
| + | Agreed method is inferred from the latestAction text: | |
| + | Yea-Nay / Roll Call → 'recorded' | |
| + | Voice Vote → 'voice' | |
| + | Unanimous Consent → 'uc' | |
| + | NULL otherwise. | |
| + | ||
| + | Note: House amendment TEXT is almost never in BILLSTATUS XML; text stays NULL. | |
| + | Senate amendment text is in GovInfo SAMDT collection (117th+). | |
| + | """ | |
| + | import re as _re | |
| + | ||
| + | amd_el = bill_el.find("amendments") | |
| + | if amd_el is None: | |
| + | return 0 | |
| + | ||
| + | stored = 0 | |
| + | for amd in amd_el.findall("amendment"): | |
| + | amd_num = _txt(amd, "number") | |
| + | amd_type = _txt(amd, "type") # HAMDT or SAMDT | |
| + | amd_cong = _txt(amd, "congress") or str(congress) | |
| + | ||
| + | if not amd_num or not amd_type: | |
| + | continue | |
| + | ||
| + | # Sponsor | |
| + | sp_el = amd.find("sponsors") | |
| + | sp_bio = "" | |
| + | if sp_el is not None: | |
| + | first = sp_el.find("item") | |
| + | if first is not None: | |
| + | sp_bio = _txt(first, "bioguideId") | |
| + | ||
| + | # Status / agreed_method from latestAction | |
| + | la_el = amd.find("latestAction") | |
| + | status = "" | |
| + | agreed = None | |
| + | if la_el is not None: | |
| + | la_text = _txt(la_el, "text") | |
| + | status = la_text[:200] | |
| + | low = la_text.lower() | |
| + | if any(k in low for k in ("yea-nay", "recorded vote", "roll call")): | |
| + | agreed = "recorded" | |
| + | elif "voice vote" in low: | |
| + | agreed = "voice" | |
| + | elif "unanimous consent" in low or "u.c." in low: | |
| + | agreed = "uc" | |
| + | ||
| + | # source_url: congress.gov amendment page | |
| + | slug_map = {"HAMDT": "house-amendment", "SAMDT": "senate-amendment"} | |
| + | slug = slug_map.get(amd_type.upper(), amd_type.lower()) | |
| + | amd_url = ( | |
| + | f"https://www.congress.gov/amendment/{amd_cong}/{slug}/{amd_num}" | |
| + | ) | |
| + | try: | |
| + | validate_source_url(amd_url) | |
| + | except Exception: | |
| + | continue | |
| + | ||
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO amendments | |
| + | (congress, type, number, bill_id, sponsor_id, | |
| + | status, agreed_method, text, source_url) | |
| + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| + | ON CONFLICT(congress, type, number) DO UPDATE SET | |
| + | bill_id = COALESCE(excluded.bill_id, amendments.bill_id), | |
| + | sponsor_id = excluded.sponsor_id, | |
| + | status = COALESCE(excluded.status, amendments.status), | |
| + | agreed_method = COALESCE(excluded.agreed_method, amendments.agreed_method), | |
| + | source_url = excluded.source_url | |
| + | """, | |
| + | ( | |
| + | int(amd_cong), amd_type.upper(), amd_num, bill_id, | |
| + | sp_bio, status, agreed, None, amd_url, | |
| + | ), | |
| + | ) | |
| + | stored += 1 | |
| + | ||
| + | return stored | |
| + | ||
| + | ||
| # --------------------------------------------------------------------------- | ||
| # Main poll | ||
| # --------------------------------------------------------------------------- |
| @@ -0,0 +1,260 @@ | ||
| + | """ | |
| + | Member roster poller - unitedstates/congress-legislators. | |
| + | ||
| + | Fetches `legislators-current.json` from the keyless GitHub dataset and | |
| + | populates the `members` table with full metadata. source_url for every | |
| + | member row is the official Bioguide page at bioguide.congress.gov (a .gov | |
| + | domain, allowlisted by rule #1). | |
| + | ||
| + | Also owns the shared schema additions used by votes_house.py and | |
| + | votes_enrich.py: | |
| + | • members table | |
| + | • vote_positions table (+ indexes) | |
| + | • Extended columns on the votes table (congress, session, roll_number, …) | |
| + | ||
| + | Run once (CLI): | |
| + | python -m ingest.pollers.members --once | |
| + | ||
| + | Imported by: | |
| + | votes_house.poll() - calls ensure_votes_schema() before inserting | |
| + | votes_enrich.poll() - same | |
| + | """ | |
| + | ||
| + | import argparse | |
| + | import logging | |
| + | from typing import Optional | |
| + | ||
| + | import requests | |
| + | ||
| + | from ingest.db import get_connection, init_schema | |
| + | from ingest.source_validator import validate_source_url | |
| + | ||
| + | logger = logging.getLogger(__name__) | |
| + | ||
| + | SOURCE = "members" | |
| + | ||
| + | LEGISLATORS_JSON_URL = ( | |
| + | "https://raw.githubusercontent.com/unitedstates/congress-legislators" | |
| + | "/gh-pages/legislators-current.json" | |
| + | ) | |
| + | ||
| + | # Official bioguide page - ends in .gov, always allowlisted. | |
| + | BIOGUIDE_BASE = "https://bioguide.congress.gov/search/bio" | |
| + | ||
| + | ||
| + | # ─── Shared schema setup ────────────────────────────────────────────────────── | |
| + | ||
| + | def ensure_votes_schema(conn) -> None: | |
| + | """ | |
| + | Idempotent: create members + vote_positions tables and extend the votes | |
| + | table with richer columns. Safe to call from any poller before writing. | |
| + | """ | |
| + | conn.executescript( | |
| + | """ | |
| + | CREATE TABLE IF NOT EXISTS members ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | bioguide_id TEXT NOT NULL UNIQUE, | |
| + | full_name TEXT NOT NULL, | |
| + | party TEXT, | |
| + | state TEXT, | |
| + | district INTEGER, | |
| + | chamber TEXT, | |
| + | lis_member_id TEXT, | |
| + | govtrack_id INTEGER, | |
| + | icpsr_id INTEGER, | |
| + | source_url TEXT NOT NULL | |
| + | ); | |
| + | ||
| + | CREATE INDEX IF NOT EXISTS idx_members_lis | |
| + | ON members(lis_member_id); | |
| + | ||
| + | CREATE TABLE IF NOT EXISTS vote_positions ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | vote_id INTEGER NOT NULL REFERENCES votes(id), | |
| + | member_bioguide TEXT, | |
| + | member_id INTEGER REFERENCES members(id), | |
| + | position TEXT NOT NULL | |
| + | CHECK(position IN ('Yea','Nay','Present','Not Voting')) | |
| + | ); | |
| + | ||
| + | CREATE INDEX IF NOT EXISTS idx_vote_positions_vote_id | |
| + | ON vote_positions(vote_id); | |
| + | CREATE INDEX IF NOT EXISTS idx_vote_positions_member_id | |
| + | ON vote_positions(member_id); | |
| + | CREATE INDEX IF NOT EXISTS idx_vote_positions_bioguide | |
| + | ON vote_positions(member_bioguide); | |
| + | """ | |
| + | ) | |
| + | ||
| + | # Additive columns on the pre-existing votes table. | |
| + | # SQLite has no IF NOT EXISTS for ALTER TABLE; use try/except. | |
| + | extra = [ | |
| + | ("congress", "INTEGER"), | |
| + | ("session", "INTEGER"), | |
| + | ("roll_number", "INTEGER"), | |
| + | ("question", "TEXT"), | |
| + | ("not_voting", "INTEGER"), | |
| + | ("recorded", "INTEGER DEFAULT 1"), | |
| + | ("vote_external_id", "TEXT"), | |
| + | ("bill_text_ref", "TEXT"), | |
| + | ] | |
| + | for col, col_type in extra: | |
| + | try: | |
| + | conn.execute(f"ALTER TABLE votes ADD COLUMN {col} {col_type}") | |
| + | except Exception: | |
| + | pass # column already present - ignore | |
| + | ||
| + | conn.commit() | |
| + | ||
| + | ||
| + | # ─── Data parsing ───────────────────────────────────────────────────────────── | |
| + | ||
| + | def _parse_legislators(data: list) -> list[dict]: | |
| + | """ | |
| + | Convert the raw legislators JSON list into cleaned member dicts. | |
| + | Uses the most recent term to determine current chamber / party / district. | |
| + | """ | |
| + | members = [] | |
| + | for leg in data: | |
| + | ids = leg.get("id", {}) | |
| + | name = leg.get("name", {}) | |
| + | terms = leg.get("terms", []) | |
| + | if not terms: | |
| + | continue | |
| + | ||
| + | bioguide = ids.get("bioguide", "").strip() | |
| + | if not bioguide: | |
| + | continue | |
| + | ||
| + | last_term = terms[-1] | |
| + | term_type = last_term.get("type", "") | |
| + | chamber = "senate" if term_type == "sen" else "house" | |
| + | ||
| + | full_name = ( | |
| + | name.get("official_full") | |
| + | or f"{name.get('first', '')} {name.get('last', '')}".strip() | |
| + | ) | |
| + | party = last_term.get("party", "") | |
| + | state = last_term.get("state", "") | |
| + | district = last_term.get("district") # None for senators | |
| + | ||
| + | lis_id = ids.get("lis") | |
| + | govtrack = ids.get("govtrack") | |
| + | icpsr = ids.get("icpsr") | |
| + | ||
| + | members.append( | |
| + | { | |
| + | "bioguide_id": bioguide, | |
| + | "full_name": full_name, | |
| + | "party": party, | |
| + | "state": state, | |
| + | "district": district, | |
| + | "chamber": chamber, | |
| + | "lis_member_id": lis_id, | |
| + | "govtrack_id": govtrack, | |
| + | "icpsr_id": icpsr, | |
| + | "source_url": f"{BIOGUIDE_BASE}/{bioguide}", | |
| + | } | |
| + | ) | |
| + | return members | |
| + | ||
| + | ||
| + | # ─── Poll entry point ───────────────────────────────────────────────────────── | |
| + | ||
| + | def poll(db_path: Optional[str] = None) -> int: | |
| + | """ | |
| + | Fetch the full current member roster and upsert into `members`. | |
| + | ||
| + | Returns | |
| + | ------- | |
| + | Number of members upserted (created or updated). | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | init_schema(conn) | |
| + | ensure_votes_schema(conn) | |
| + | ||
| + | logger.info("members: fetching legislators-current.json …") | |
| + | try: | |
| + | resp = requests.get(LEGISLATORS_JSON_URL, timeout=30) | |
| + | resp.raise_for_status() | |
| + | except requests.RequestException as exc: | |
| + | logger.error("members: fetch error: %s", exc) | |
| + | conn.close() | |
| + | return 0 | |
| + | ||
| + | try: | |
| + | data = resp.json() | |
| + | except ValueError as exc: | |
| + | logger.error("members: JSON parse error: %s", exc) | |
| + | conn.close() | |
| + | return 0 | |
| + | ||
| + | if not isinstance(data, list): | |
| + | logger.error("members: unexpected JSON shape (expected list, got %s)", type(data)) | |
| + | conn.close() | |
| + | return 0 | |
| + | ||
| + | members = _parse_legislators(data) | |
| + | logger.info("members: parsed %d legislator(s) from roster", len(members)) | |
| + | ||
| + | upserted = 0 | |
| + | for m in members: | |
| + | validate_source_url(m["source_url"]) # gate - must not raise for bioguide.congress.gov | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO members | |
| + | (bioguide_id, full_name, party, state, district, chamber, | |
| + | lis_member_id, govtrack_id, icpsr_id, source_url) | |
| + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| + | ON CONFLICT(bioguide_id) DO UPDATE SET | |
| + | full_name = excluded.full_name, | |
| + | party = excluded.party, | |
| + | state = excluded.state, | |
| + | district = excluded.district, | |
| + | chamber = excluded.chamber, | |
| + | lis_member_id = excluded.lis_member_id, | |
| + | govtrack_id = excluded.govtrack_id, | |
| + | icpsr_id = excluded.icpsr_id, | |
| + | source_url = excluded.source_url | |
| + | """, | |
| + | ( | |
| + | m["bioguide_id"], | |
| + | m["full_name"], | |
| + | m["party"], | |
| + | m["state"], | |
| + | m["district"], | |
| + | m["chamber"], | |
| + | m["lis_member_id"], | |
| + | m["govtrack_id"], | |
| + | m["icpsr_id"], | |
| + | m["source_url"], | |
| + | ), | |
| + | ) | |
| + | upserted += 1 | |
| + | ||
| + | conn.commit() | |
| + | conn.close() | |
| + | logger.info("members: upserted %d member(s)", upserted) | |
| + | return upserted | |
| + | ||
| + | ||
| + | # ─── CLI ────────────────────────────────────────────────────────────────────── | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | ) | |
| + | parser = argparse.ArgumentParser(description="Docket member roster poller") | |
| + | parser.add_argument("--once", action="store_true", help="Run once and exit.") | |
| + | parser.add_argument("--db", help="Override DB path.") | |
| + | args = parser.parse_args() | |
| + | if not args.once: | |
| + | parser.print_help() | |
| + | return | |
| + | count = poll(db_path=args.db) | |
| + | print(f"members: {count} upserted") | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -1,16 +1,27 @@ | ||
| """ | ||
| OMB Apportionment poller - the impound tracker (flagship "buried data" feature). | ||
| - | Data source: openomb.org JSON feed (no auth), which mirrors the official | |
| - | OMB apportionment data published at apportionment-public.max.gov. | |
| + | Data source: apportionment-public.max.gov (official OMB portal - .gov ✓) | |
| - | NEUTRALITY RULE: source_url stored in every row MUST point to the underlying | |
| - | official max.gov record, NOT to openomb.org. Aggregators are pipes, not sources. | |
| - | (DATA_SOURCES.md third-party-normalizer rule.) | |
| + | max.gov serves individual JSON files per apportionment record (one per TAFS | |
| + | account + iteration). There is no bulk REST API; this poller: | |
| + | 1. Fetches the HTML index page to extract JSON file URLs. | |
| + | 2. Downloads individual JSON apportionment files (capped per poll). | |
| + | 3. Parses ScheduleData line items to derive appropriated/apportioned amounts. | |
| + | 4. Writes normalized rows to the `apportionments` table. | |
| - | The apportionment-public.max.gov site is also queryable directly via its own | |
| - | API; we attempt that first and fall back to openomb.org if max.gov doesn't | |
| - | respond cleanly. Either way, source_url → max.gov. | |
| + | source_url for every row → the direct max.gov JSON file URL (never a third-party). | |
| + | ||
| + | Status derivation (OMB quarterly apportionment logic): | |
| + | - appropriated = "Total budgetary resources avail" line | |
| + | - apportioned = sum of Category A/B lines | |
| + | - released → apportioned ≥ 95% of appropriated (or all quarters filled) | |
| + | - delayed → apportioned > 0 but < 95% of appropriated (partial release) | |
| + | - withheld → apportioned == 0 and appropriated > 0 (funds not released) | |
| + | ||
| + | Honest gap: this reflects the most recent approved apportionment, not any | |
| + | informal hold or pending review. "Withheld" here means "not yet apportioned | |
| + | by OMB" which includes genuine impoundments and normal timing gaps. | |
| Writes to: apportionments table (created if absent - does not touch db.py core) | ||
| """ | ||
| @@ -18,10 +29,11 @@ Writes to: apportionments table (created if absent - does not touch db.py core | ||
| import argparse | ||
| import json | ||
| import logging | ||
| + | import re | |
| import sys | ||
| - | from datetime import datetime, timedelta | |
| + | from datetime import datetime | |
| from typing import Optional | ||
| - | from urllib.parse import urlencode | |
| + | from urllib.parse import urljoin, unquote | |
| import requests | ||
| @@ -32,23 +44,23 @@ from ingest.source_validator import validate_source_url | ||
| logger = logging.getLogger(__name__) | ||
| SOURCE = "openomb" | ||
| - | ||
| - | # Official max.gov apportionment portal | |
| MAX_GOV_BASE = "https://apportionment-public.max.gov" | ||
| - | MAX_GOV_API = "https://apportionment-public.max.gov/api/v1" | |
| - | # OpenOMB mirror (used as data source only - source_url never points here) | |
| - | OPENOMB_API = "https://openomb.org/api/apportionments" | |
| + | # Limit records fetched per poll to avoid hammering the server | |
| + | _MAX_RECORDS_PER_POLL = 200 | |
| - | # Status normalization | |
| - | _STATUS_MAP = { | |
| - | "released": "released", | |
| - | "approved": "released", | |
| - | "normal": "released", | |
| - | "delayed": "delayed", | |
| - | "withheld": "withheld", | |
| - | "not apportioned": "withheld", | |
| - | } | |
| + | # Line-description patterns for extracting dollar amounts | |
| + | _APPROPRIATED_PATTERNS = [ | |
| + | re.compile(r"total budgetary resources avail", re.IGNORECASE), | |
| + | re.compile(r"total budgetary resources available", re.IGNORECASE), | |
| + | ] | |
| + | _APPORTIONED_PATTERNS = [ | |
| + | re.compile(r"^Category [AB]", re.IGNORECASE), | |
| + | re.compile(r"^Category [AB] --", re.IGNORECASE), | |
| + | ] | |
| + | _WITHHELD_PATTERNS = [ | |
| + | re.compile(r"withheld|impound|not apportioned|unapportioned", re.IGNORECASE), | |
| + | ] | |
| def _ensure_tables(conn) -> None: | ||
| @@ -73,142 +85,125 @@ def _ensure_tables(conn) -> None: | ||
| conn.commit() | ||
| - | def _max_gov_url(agency_code: str = "", account_code: str = "", year: str = "") -> str: | |
| - | """ | |
| - | Construct the canonical max.gov URL for an apportionment record. | |
| - | ||
| - | The public portal search page accepts query params; a full-text search | |
| - | URL is the most reliable deep-link when account identifiers are available. | |
| - | """ | |
| - | base = f"{MAX_GOV_BASE}/apportionments" | |
| - | if year: | |
| - | base = f"{base}?fiscal_year={year}" | |
| - | if agency_code: | |
| - | base += f"&agency_code={agency_code}" | |
| - | return base | |
| - | ||
| - | ||
| - | def _fetch_from_max_gov(fy: int) -> list: | |
| - | """ | |
| - | Try fetching apportionment records directly from apportionment-public.max.gov. | |
| - | ||
| - | Returns list of normalized dicts or [] on failure. | |
| - | """ | |
| - | # max.gov exposes a JSON API for apportionment data | |
| - | url = f"{MAX_GOV_API}/apportionments/" | |
| - | params = { | |
| - | "fiscal_year": fy, | |
| - | "format": "json", | |
| - | "limit": 500, | |
| - | } | |
| + | def _fetch_index_html() -> str: | |
| + | """Fetch the max.gov apportionment HTML index page.""" | |
| try: | ||
| - | resp = requests.get(url, params=params, timeout=30) | |
| + | resp = requests.get(MAX_GOV_BASE + "/", timeout=30) | |
| resp.raise_for_status() | ||
| - | data = resp.json() | |
| - | records = data if isinstance(data, list) else data.get("results", data.get("data", [])) | |
| - | if records: | |
| - | logger.info("openomb: got %d record(s) from max.gov for FY%d", len(records), fy) | |
| - | return records | |
| - | except Exception as exc: | |
| - | logger.debug("openomb: max.gov direct API did not respond cleanly: %s", exc) | |
| - | return [] | |
| + | return resp.text | |
| + | except requests.RequestException as exc: | |
| + | logger.error("openomb: failed to fetch max.gov index: %s", exc) | |
| + | return "" | |
| - | def _fetch_from_openomb(fy: int) -> list: | |
| + | def _extract_json_links(html: str, fiscal_year: int) -> list: | |
| """ | ||
| - | Fetch apportionment data from openomb.org JSON feed. | |
| + | Extract JSON apportionment file paths from the max.gov HTML index. | |
| - | Returns list of raw record dicts or [] on failure. | |
| + | Returns a list of (agency, relative_path) tuples for the given FY. | |
| """ | ||
| - | params = {"fiscal_year": fy, "format": "json"} | |
| - | try: | |
| - | resp = requests.get(OPENOMB_API, params=params, timeout=30) | |
| - | resp.raise_for_status() | |
| - | data = resp.json() | |
| - | if isinstance(data, list): | |
| - | logger.info("openomb: got %d record(s) from openomb.org for FY%d", len(data), fy) | |
| - | return data | |
| - | results = data.get("results", data.get("apportionments", [])) | |
| - | logger.info("openomb: got %d record(s) from openomb.org for FY%d", len(results), fy) | |
| - | return results | |
| - | except Exception as exc: | |
| - | logger.warning("openomb: openomb.org fetch failed: %s", exc) | |
| - | return [] | |
| - | ||
| - | ||
| - | def _normalize_status(raw: str) -> str: | |
| - | """Normalize a raw status string to released | delayed | withheld.""" | |
| - | if not raw: | |
| + | fy_str = str(fiscal_year) | |
| + | # Match hrefs pointing to JSON apportionment files for the target FY | |
| + | pattern = re.compile( | |
| + | r'href="(/Fiscal%20Year%20' + fy_str + r'/[^"]+\.json)"', | |
| + | re.IGNORECASE, | |
| + | ) | |
| + | matches = pattern.findall(html) | |
| + | results = [] | |
| + | for path in matches: | |
| + | # Decode URL-encoded path to extract agency name | |
| + | decoded = unquote(path) | |
| + | # Path structure: /Fiscal Year 2026/Agency Name/JSON/filename.json | |
| + | parts = decoded.strip("/").split("/") | |
| + | agency = parts[1] if len(parts) >= 2 else "Unknown" | |
| + | results.append((agency, path)) | |
| + | logger.info("openomb: found %d JSON links for FY%d", len(results), fiscal_year) | |
| + | return results | |
| + | ||
| + | ||
| + | def _derive_status(appropriated: float, apportioned: float) -> str: | |
| + | """Classify an apportionment record as released / delayed / withheld.""" | |
| + | if appropriated <= 0: | |
| + | return "released" # nothing to apportion | |
| + | ratio = apportioned / appropriated | |
| + | if ratio >= 0.95: | |
| return "released" | ||
| - | lower = raw.strip().lower() | |
| - | for key, val in _STATUS_MAP.items(): | |
| - | if key in lower: | |
| - | return val | |
| - | return "released" | |
| + | if apportioned > 0: | |
| + | return "delayed" | |
| + | return "withheld" | |
| - | def _extract_fields(rec: dict) -> Optional[dict]: | |
| + | def _parse_apportionment_json(data: dict) -> Optional[dict]: | |
| """ | ||
| - | Extract normalized fields from a raw apportionment record dict. | |
| + | Extract key financial fields from a max.gov apportionment JSON record. | |
| - | Handles various field-name conventions from max.gov and openomb.org. | |
| - | Returns None if the record is unusable. | |
| + | Returns a normalized dict or None if the record is unusable. | |
| """ | ||
| - | # Field name aliases - max.gov and openomb may differ | |
| - | agency = (rec.get("agency_name") or rec.get("agency") or | |
| - | rec.get("Agency") or "") | |
| - | account = (rec.get("account_name") or rec.get("account") or | |
| - | rec.get("Account") or rec.get("account_title") or "") | |
| - | appropriated = (rec.get("total_appropriations") or rec.get("appropriated") or | |
| - | rec.get("amount_appropriated") or rec.get("Appropriated") or 0) | |
| - | apportioned = (rec.get("total_apportioned") or rec.get("apportioned") or | |
| - | rec.get("amount_apportioned") or rec.get("Apportioned") or 0) | |
| - | status_raw = (rec.get("status") or rec.get("apportionment_status") or | |
| - | rec.get("Status") or "released") | |
| - | date = (rec.get("date") or rec.get("period_of_availability") or | |
| - | rec.get("apportionment_date") or rec.get("fiscal_year") or "") | |
| - | # For external_id, use a combination of agency+account+date | |
| - | agency_code = (rec.get("agency_code") or rec.get("agency_identifier") or "") | |
| - | account_code = (rec.get("account_code") or rec.get("account_number") or "") | |
| - | year_str = str(rec.get("fiscal_year") or "") | |
| + | schedule = data.get("ScheduleData") or [] | |
| + | if not schedule: | |
| + | return None | |
| + | ||
| + | first = schedule[0] | |
| + | agency = data.get("Folder") or first.get("BudgetAgencyTitle") or "" | |
| + | account = first.get("AccountTitle") or "" | |
| if not agency and not account: | ||
| return None | ||
| - | ext_id = f"{year_str}_{agency_code or agency[:20]}_{account_code or account[:20]}".replace(" ", "_") | |
| + | appropriated = 0.0 | |
| + | apportioned = 0.0 | |
| - | try: | |
| - | appropriated_f = float(appropriated or 0) | |
| - | apportioned_f = float(apportioned or 0) | |
| - | except (ValueError, TypeError): | |
| - | appropriated_f = 0.0 | |
| - | apportioned_f = 0.0 | |
| + | for line in schedule: | |
| + | desc = (line.get("LineDescription") or "").strip() | |
| + | amount = float(line.get("ApprovedAmount") or 0) | |
| - | source_url = _max_gov_url(agency_code, account_code, year_str) | |
| + | if any(p.search(desc) for p in _APPROPRIATED_PATTERNS): | |
| + | appropriated = amount | |
| + | if any(p.match(desc) for p in _APPORTIONED_PATTERNS): | |
| + | apportioned += max(0.0, amount) | |
| + | ||
| + | status = _derive_status(appropriated, apportioned) | |
| + | ||
| + | # Date from approval timestamp: "2025-09-16-17.07.55.231605" → "2025-09-16" | |
| + | ts = data.get("ApprovalTimestamp") or "" | |
| + | date = ts[:10].replace("-", "-") if len(ts) >= 10 else datetime.utcnow().strftime("%Y-%m-%d") | |
| return { | ||
| - | "external_id": ext_id, | |
| - | "agency": str(agency), | |
| - | "account": str(account), | |
| - | "appropriated": appropriated_f, | |
| - | "apportioned": apportioned_f, | |
| - | "status": _normalize_status(str(status_raw)), | |
| - | "date": str(date)[:10] if date else "", | |
| - | "source_url": source_url, | |
| - | "update_date": str(date)[:10] if date else datetime.utcnow().strftime("%Y-%m-%d"), | |
| + | "agency": agency, | |
| + | "account": account, | |
| + | "appropriated": appropriated, | |
| + | "apportioned": apportioned, | |
| + | "status": status, | |
| + | "date": date, | |
| + | "fiscal_year": data.get("FiscalYear", ""), | |
| + | "filename": data.get("FileName", ""), | |
| } | ||
| - | def poll(fy: Optional[int] = None, db_path: Optional[str] = None) -> int: | |
| + | def _fetch_json_record(relative_path: str) -> Optional[dict]: | |
| + | """Download and parse one apportionment JSON file from max.gov.""" | |
| + | url = MAX_GOV_BASE + relative_path | |
| + | try: | |
| + | resp = requests.get(url, timeout=30) | |
| + | resp.raise_for_status() | |
| + | return resp.json() | |
| + | except Exception as exc: | |
| + | logger.debug("openomb: could not fetch %s: %s", relative_path, exc) | |
| + | return None | |
| + | ||
| + | ||
| + | def poll(fy: Optional[int] = None, db_path: Optional[str] = None, | |
| + | max_records: int = _MAX_RECORDS_PER_POLL) -> int: | |
| """ | ||
| - | Pull OMB apportionment records and write to the apportionments table. | |
| + | Pull OMB apportionment records from apportionment-public.max.gov. | |
| - | Always links source_url to apportionment-public.max.gov, never to openomb.org. | |
| + | Every stored row has source_url → a specific max.gov JSON file (all .gov ✓). | |
| Parameters | ||
| ---------- | ||
| - | fy : Fiscal year to fetch (default: current FY). | |
| - | db_path : Override default SQLite path. | |
| + | fy : Fiscal year (default: current FY). | |
| + | db_path : Override default SQLite path. | |
| + | max_records : Cap on records fetched per run (default 200). | |
| Returns | ||
| ------- | ||
| @@ -216,51 +211,69 @@ def poll(fy: Optional[int] = None, db_path: Optional[str] = None) -> int: | ||
| """ | ||
| if fy is None: | ||
| now = datetime.utcnow() | ||
| - | # US fiscal year starts Oct 1 | |
| fy = now.year if now.month >= 10 else now.year - 1 | ||
| - | logger.info("openomb: fetching apportionments for FY%d", fy) | |
| + | logger.info("openomb: fetching apportionments for FY%d (max %d records)", fy, max_records) | |
| conn = get_connection(db_path) if db_path else get_connection() | ||
| init_schema(conn) | ||
| _ensure_tables(conn) | ||
| - | # Try max.gov first; fall back to openomb.org | |
| - | records = _fetch_from_max_gov(fy) | |
| - | if not records: | |
| - | records = _fetch_from_openomb(fy) | |
| - | ||
| - | if not records: | |
| - | logger.warning("openomb: no records retrieved for FY%d from any source", fy) | |
| - | # Attempt current FY - 1 as a fallback if nothing for current FY | |
| - | if fy == datetime.utcnow().year: | |
| - | records = _fetch_from_openomb(fy - 1) | |
| - | if records: | |
| - | fy = fy - 1 | |
| - | logger.info("openomb: fell back to FY%d - %d record(s)", fy, len(records)) | |
| - | ||
| - | if not records: | |
| + | # Step 1: get the file index | |
| + | html = _fetch_index_html() | |
| + | if not html: | |
| conn.close() | ||
| return 0 | ||
| + | links = _extract_json_links(html, fy) | |
| + | if not links: | |
| + | logger.warning("openomb: no JSON links found for FY%d", fy) | |
| + | # Try current FY and previous FY | |
| + | if fy > 2022: | |
| + | links = _extract_json_links(html, fy - 1) | |
| + | ||
| + | if not links: | |
| + | logger.warning("openomb: no apportionment links found at all") | |
| + | conn.close() | |
| + | return 0 | |
| + | ||
| + | # Step 2: download and store records (cap at max_records) | |
| stored = 0 | ||
| - | for rec in records: | |
| - | fields = _extract_fields(rec) | |
| - | if fields is None: | |
| + | processed = 0 | |
| + | ||
| + | for agency, rel_path in links: | |
| + | if processed >= max_records: | |
| + | logger.info("openomb: reached max_records cap (%d), stopping", max_records) | |
| + | break | |
| + | ||
| + | source_url = MAX_GOV_BASE + rel_path | |
| + | # external_id = URL path (URL-decoded filename portion) | |
| + | filename = unquote(rel_path.split("/")[-1]) | |
| + | external_id = filename | |
| + | ||
| + | # update_date: use the date embedded in the filename (e.g., 2026-06-17) | |
| + | date_m = re.search(r"(\d{4}-\d{2}-\d{2})", filename) | |
| + | update_date = date_m.group(1) if date_m else datetime.utcnow().strftime("%Y-%m-%d") | |
| + | ||
| + | if not is_new_event(conn, SOURCE, external_id, update_date): | |
| + | processed += 1 | |
| continue | ||
| try: | ||
| - | validate_source_url(fields["source_url"]) | |
| + | validate_source_url(source_url) | |
| except Exception as exc: | ||
| - | logger.error( | |
| - | "openomb: source_url %r not allowlisted (%s) - skipping row", | |
| - | fields["source_url"], exc, | |
| - | ) | |
| + | logger.error("openomb: source_url %r not allowlisted: %s", source_url, exc) | |
| + | processed += 1 | |
| continue | ||
| - | update_date = fields["update_date"] | |
| - | if not is_new_event(conn, SOURCE, fields["external_id"], update_date): | |
| - | logger.debug("openomb: already seen %s", fields["external_id"]) | |
| + | raw = _fetch_json_record(rel_path) | |
| + | if raw is None: | |
| + | processed += 1 | |
| + | continue | |
| + | ||
| + | fields = _parse_apportionment_json(raw) | |
| + | if fields is None: | |
| + | processed += 1 | |
| continue | ||
| conn.execute( | ||
| @@ -280,14 +293,14 @@ def poll(fy: Optional[int] = None, db_path: Optional[str] = None) -> int: | ||
| """, | ||
| ( | ||
| SOURCE, | ||
| - | fields["external_id"], | |
| + | external_id, | |
| fields["agency"], | ||
| fields["account"], | ||
| fields["appropriated"], | ||
| fields["apportioned"], | ||
| fields["status"], | ||
| fields["date"], | ||
| - | fields["source_url"], | |
| + | source_url, | |
| ), | ||
| ) | ||
| @@ -299,11 +312,16 @@ def poll(fy: Optional[int] = None, db_path: Optional[str] = None) -> int: | ||
| payload = excluded.payload, | ||
| fetched_at = excluded.fetched_at | ||
| """, | ||
| - | (SOURCE, fields["external_id"], json.dumps(rec), datetime.utcnow().isoformat()), | |
| + | (SOURCE, external_id, json.dumps(raw), datetime.utcnow().isoformat()), | |
| ) | ||
| - | mark_seen(conn, SOURCE, fields["external_id"], update_date) | |
| + | mark_seen(conn, SOURCE, external_id, update_date) | |
| stored += 1 | ||
| + | processed += 1 | |
| + | ||
| + | if stored % 10 == 0: | |
| + | conn.commit() | |
| + | logger.info("openomb: %d new records stored so far", stored) | |
| conn.commit() | ||
| conn.close() | ||
| @@ -320,8 +338,10 @@ if __name__ == "__main__": | ||
| parser = argparse.ArgumentParser(description="OMB Apportionment poller") | ||
| parser.add_argument("--once", action="store_true", help="Run once and exit") | ||
| parser.add_argument("--fy", type=int, default=None, help="Fiscal year (default: current)") | ||
| + | parser.add_argument("--max", type=int, default=50, | |
| + | help="Max records to fetch (default 50 for --once; 200 in scheduled mode)") | |
| args = parser.parse_args() | ||
| - | count = poll(fy=args.fy) | |
| + | count = poll(fy=args.fy, max_records=args.max) | |
| print(f"Stored {count} new apportionments row(s).") | ||
| sys.exit(0) |
| @@ -0,0 +1,257 @@ | ||
| + | """ | |
| + | Votes enrichment - per-member positions for Senate roll-call votes. | |
| + | ||
| + | Senate completeness layer. The votes_senate.py poller captures aggregate | |
| + | counts (yea/nay/present) but NOT per-member positions. This module: | |
| + | ||
| + | 1. Scans the `votes` table for Senate rows that have NO entries in | |
| + | vote_positions. | |
| + | 2. Re-fetches each vote's source_url (already stored on the row - the | |
| + | original senate.gov XML URL). | |
| + | 3. Parses the per-member <member><lis_member_id>/<vote_cast> fields. | |
| + | 4. Maps lis_member_id → bioguide_id via the `members` table (populated | |
| + | by pollers/members.py). Where no mapping exists the row still lands | |
| + | with member_bioguide=None and member_id=None so the record is | |
| + | preserved without fabricated data. | |
| + | ||
| + | Run: | |
| + | python -m ingest.pollers.votes_enrich --once | |
| + | ||
| + | Idempotent: re-running never double-inserts because it filters for votes | |
| + | with zero existing positions first. | |
| + | """ | |
| + | ||
| + | import argparse | |
| + | import logging | |
| + | import re | |
| + | import xml.etree.ElementTree as ET | |
| + | from typing import Optional | |
| + | ||
| + | import requests | |
| + | ||
| + | from ingest.db import get_connection, init_schema | |
| + | from ingest.pollers.members import ensure_votes_schema | |
| + | from ingest.source_validator import validate_source_url | |
| + | ||
| + | logger = logging.getLogger(__name__) | |
| + | ||
| + | SOURCE = "votes_enrich" | |
| + | ||
| + | # Canonical position map for Senate <vote_cast> values | |
| + | _SENATE_POS_MAP: dict[str, str] = { | |
| + | "yea": "Yea", | |
| + | "aye": "Yea", | |
| + | "nay": "Nay", | |
| + | "no": "Nay", | |
| + | "present": "Present", | |
| + | "abstain": "Present", # Rare; treated as Present in the 4-value enum | |
| + | "not voting": "Not Voting", | |
| + | "not present":"Not Voting", | |
| + | } | |
| + | ||
| + | ||
| + | # ─── Senate XML parser ──────────────────────────────────────────────────────── | |
| + | ||
| + | def parse_senate_vote_positions(xml_text: str) -> list[dict]: | |
| + | """ | |
| + | Parse a Senate roll-call XML and return a list of per-member position dicts: | |
| + | [{lis_member_id, member_full, party, state, position}, …] | |
| + | ||
| + | Returns [] on parse failure. | |
| + | """ | |
| + | try: | |
| + | root = ET.fromstring(xml_text) | |
| + | except ET.ParseError as exc: | |
| + | logger.error("votes_enrich: XML parse error: %s", exc) | |
| + | return [] | |
| + | ||
| + | members_el = root.find("members") | |
| + | if members_el is None: | |
| + | logger.debug("votes_enrich: no <members> element - voice/UC vote?") | |
| + | return [] | |
| + | ||
| + | positions = [] | |
| + | for member_el in members_el.findall("member"): | |
| + | lis_id = (member_el.findtext("lis_member_id") or "").strip() | |
| + | full_name = (member_el.findtext("member_full") or "").strip() | |
| + | party = (member_el.findtext("party") or "").strip() | |
| + | state = (member_el.findtext("state") or "").strip() | |
| + | vote_cast = (member_el.findtext("vote_cast") or "").strip() | |
| + | ||
| + | position = _SENATE_POS_MAP.get(vote_cast.lower()) | |
| + | if position is None: | |
| + | logger.debug( | |
| + | "votes_enrich: unknown vote_cast %r for %s - skipping", | |
| + | vote_cast, lis_id, | |
| + | ) | |
| + | continue | |
| + | ||
| + | positions.append( | |
| + | { | |
| + | "lis_member_id": lis_id, | |
| + | "member_full": full_name, | |
| + | "party": party, | |
| + | "state": state, | |
| + | "position": position, | |
| + | } | |
| + | ) | |
| + | ||
| + | return positions | |
| + | ||
| + | ||
| + | # ─── Enrichment logic ───────────────────────────────────────────────────────── | |
| + | ||
| + | def _build_lis_to_bioguide(conn) -> dict[str, str]: | |
| + | """Build a lis_member_id → bioguide_id map from the members table.""" | |
| + | mapping: dict[str, str] = {} | |
| + | for row in conn.execute( | |
| + | "SELECT bioguide_id, lis_member_id FROM members WHERE lis_member_id IS NOT NULL" | |
| + | ): | |
| + | mapping[row["lis_member_id"]] = row["bioguide_id"] | |
| + | return mapping | |
| + | ||
| + | ||
| + | def _build_bioguide_to_id(conn) -> dict[str, int]: | |
| + | """Build a bioguide_id → members.id map.""" | |
| + | mapping: dict[str, int] = {} | |
| + | for row in conn.execute("SELECT id, bioguide_id FROM members"): | |
| + | mapping[row["bioguide_id"]] = row["id"] | |
| + | return mapping | |
| + | ||
| + | ||
| + | def enrich_senate_positions( | |
| + | conn, | |
| + | lis_to_bg: dict[str, str], | |
| + | bg_to_id: dict[str, int], | |
| + | max_votes: int = 200, | |
| + | ) -> int: | |
| + | """ | |
| + | Backfill vote_positions for Senate votes that have no positions yet. | |
| + | ||
| + | Returns the number of vote rows enriched (positions inserted). | |
| + | """ | |
| + | # Find Senate votes without any position rows | |
| + | pending = conn.execute( | |
| + | """ | |
| + | SELECT v.id, v.source_url | |
| + | FROM votes v | |
| + | WHERE v.chamber = 'senate' | |
| + | AND v.source_url IS NOT NULL | |
| + | AND v.source_url LIKE '%senate.gov%' | |
| + | AND NOT EXISTS ( | |
| + | SELECT 1 FROM vote_positions vp WHERE vp.vote_id = v.id | |
| + | ) | |
| + | ORDER BY v.id | |
| + | LIMIT ? | |
| + | """, | |
| + | (max_votes,), | |
| + | ).fetchall() | |
| + | ||
| + | if not pending: | |
| + | logger.info("votes_enrich: no Senate votes need position enrichment") | |
| + | return 0 | |
| + | ||
| + | logger.info("votes_enrich: %d Senate vote(s) pending enrichment", len(pending)) | |
| + | enriched = 0 | |
| + | ||
| + | for row in pending: | |
| + | vote_id = row["id"] | |
| + | source_url = row["source_url"] | |
| + | ||
| + | # The source_url IS the senate.gov XML - already allowlisted. | |
| + | try: | |
| + | validate_source_url(source_url) | |
| + | except Exception as exc: | |
| + | logger.warning("votes_enrich: invalid source_url %s: %s - skip", source_url, exc) | |
| + | continue | |
| + | ||
| + | try: | |
| + | resp = requests.get(source_url, timeout=20) | |
| + | except requests.RequestException as exc: | |
| + | logger.error("votes_enrich: fetch error %s: %s", source_url, exc) | |
| + | continue | |
| + | ||
| + | if not resp.ok: | |
| + | logger.warning("votes_enrich: HTTP %d for %s", resp.status_code, source_url) | |
| + | continue | |
| + | ||
| + | positions = parse_senate_vote_positions(resp.text) | |
| + | if not positions: | |
| + | logger.debug("votes_enrich: no positions parsed for vote_id=%d", vote_id) | |
| + | continue | |
| + | ||
| + | pos_rows = [] | |
| + | for p in positions: | |
| + | lis_id = p["lis_member_id"] | |
| + | bioguide = lis_to_bg.get(lis_id) | |
| + | member_id = bg_to_id.get(bioguide) if bioguide else None | |
| + | pos_rows.append((vote_id, bioguide, member_id, p["position"])) | |
| + | ||
| + | conn.executemany( | |
| + | """ | |
| + | INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) | |
| + | VALUES (?, ?, ?, ?) | |
| + | """, | |
| + | pos_rows, | |
| + | ) | |
| + | ||
| + | logger.info( | |
| + | "votes_enrich: vote_id=%d - %d position(s) inserted (url=%s)", | |
| + | vote_id, len(pos_rows), source_url, | |
| + | ) | |
| + | enriched += 1 | |
| + | ||
| + | conn.commit() | |
| + | return enriched | |
| + | ||
| + | ||
| + | # ─── Poll entry point ───────────────────────────────────────────────────────── | |
| + | ||
| + | def poll(db_path: Optional[str] = None, max_votes: int = 200) -> int: | |
| + | """ | |
| + | Enrich Senate vote_positions for all un-enriched Senate votes. | |
| + | ||
| + | Returns | |
| + | ------- | |
| + | Number of Senate votes that received position data. | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | init_schema(conn) | |
| + | ensure_votes_schema(conn) | |
| + | ||
| + | lis_to_bg = _build_lis_to_bioguide(conn) | |
| + | bg_to_id = _build_bioguide_to_id(conn) | |
| + | ||
| + | logger.info( | |
| + | "votes_enrich: member lookup tables ready (%d LIS→bioguide, %d bioguide→id)", | |
| + | len(lis_to_bg), len(bg_to_id), | |
| + | ) | |
| + | ||
| + | enriched = enrich_senate_positions(conn, lis_to_bg, bg_to_id, max_votes=max_votes) | |
| + | ||
| + | conn.close() | |
| + | logger.info("votes_enrich: enriched %d Senate vote(s)", enriched) | |
| + | return enriched | |
| + | ||
| + | ||
| + | # ─── CLI ────────────────────────────────────────────────────────────────────── | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | ) | |
| + | parser = argparse.ArgumentParser(description="Docket votes enrichment (Senate positions)") | |
| + | parser.add_argument("--once", action="store_true", help="Run once and exit.") | |
| + | parser.add_argument("--db", help="Override DB path.") | |
| + | parser.add_argument("--max-votes", type=int, default=200, help="Max votes to enrich per run.") | |
| + | args = parser.parse_args() | |
| + | if not args.once: | |
| + | parser.print_help() | |
| + | return | |
| + | count = poll(db_path=args.db, max_votes=args.max_votes) | |
| + | print(f"votes_enrich: {count} Senate vote(s) enriched") | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -1,14 +1,29 @@ | ||
| """ | ||
| - | House Clerk XML roll-call vote poller. | |
| + | House Clerk XML roll-call vote poller - full per-member positions. | |
| - | Source: https://clerk.house.gov/evs/{year}/ROLL_{vote_num:03d}.xml | |
| + | Source: https://clerk.house.gov/evs/{year}/ROLL_{NNN}.xml | |
| No auth required. Posted within hours of the vote. | ||
| - | Strategy: discover the latest vote number by checking sequentially from the | |
| - | last known; stop when a 404 is returned. Stores votes and emits feed events. | |
| + | Strategy: | |
| + | • Probe roll numbers sequentially from last known; stop on 404. | |
| + | • For each recorded vote, capture full metadata AND per-member positions | |
| + | into vote_positions (member_bioguide, position). | |
| + | • Aggregate totals are cross-checked against per-member count. | |
| + | • Honesty: we only process YEA-AND-NAY / RECORDED votes. Voice-vote or | |
| + | unanimous-consent proceedings do not appear in this XML stream. | |
| + | ||
| + | Schema extensions (CREATE TABLE IF NOT EXISTS / ALTER TABLE IF NEEDED): | |
| + | Handled by ingest.pollers.members.ensure_votes_schema(). | |
| + | Called at the start of poll(). | |
| + | ||
| + | Bill linkage: | |
| + | legis-num field (e.g. "H R 1234") is parsed into type + number. | |
| + | We attempt a bills-table lookup by congress + type + number; on miss, | |
| + | bill_id is NULL and bill_text_ref stores the raw legis-num for later join. | |
| """ | ||
| import logging | ||
| + | import re | |
| import sqlite3 | ||
| import xml.etree.ElementTree as ET | ||
| from datetime import datetime | ||
| @@ -18,6 +33,7 @@ import requests | ||
| from ingest.change_detection import is_new_event, mark_seen | ||
| from ingest.db import get_connection, init_schema | ||
| + | from ingest.pollers.members import ensure_votes_schema | |
| from ingest.source_validator import validate_source_url | ||
| logger = logging.getLogger(__name__) | ||
| @@ -25,61 +41,215 @@ logger = logging.getLogger(__name__) | ||
| SOURCE = "votes_house" | ||
| BASE_URL = "https://clerk.house.gov/evs" | ||
| + | # Valid positions in House roll-call XML | |
| + | _VALID_POSITIONS = frozenset({"Yea", "Nay", "Present", "Not Voting"}) | |
| + | ||
| + | # Map any variant spelling to our canonical enum | |
| + | _POSITION_MAP: dict[str, str] = { | |
| + | "yea": "Yea", | |
| + | "aye": "Yea", | |
| + | "nay": "Nay", | |
| + | "no": "Nay", | |
| + | "present": "Present", | |
| + | "not voting": "Not Voting", | |
| + | "not-voting": "Not Voting", | |
| + | } | |
| + | ||
| + | # legis-num → bill type mapping (longest prefix first) | |
| + | _LEGIS_TYPE_MAP = [ | |
| + | ("H J RES", "hjres"), | |
| + | ("H CON RES","hconres"), | |
| + | ("H RES", "hres"), | |
| + | ("H R", "hr"), | |
| + | ("S J RES", "sjres"), | |
| + | ("S CON RES","sconres"), | |
| + | ("S RES", "sres"), | |
| + | ("S", "s"), | |
| + | ] | |
| + | ||
| + | ||
| + | # ─── Helpers ────────────────────────────────────────────────────────────────── | |
| def _roll_url(year: int, roll_num: int) -> str: | ||
| - | return f"{BASE_URL}/{year}/ROLL_{roll_num:03d}.xml" | |
| + | return f"{BASE_URL}/{year}/roll{roll_num:03d}.xml" | |
| - | def _parse_vote_xml(xml_text: str, source_url: str) -> Optional[dict]: | |
| - | """Parse House roll-call XML; return a dict or None on parse error.""" | |
| + | def _normalize_position(raw: str) -> Optional[str]: | |
| + | """Map a raw <vote> string to a canonical position, or None if unknown.""" | |
| + | key = raw.strip().lower() | |
| + | return _POSITION_MAP.get(key) | |
| + | ||
| + | ||
| + | def _parse_legis_num(legis_num: str, congress: int) -> tuple[Optional[str], Optional[str], Optional[str]]: | |
| + | """ | |
| + | Parse 'H R 1234' into (bill_type, number, external_id). | |
| + | ||
| + | external_id matches the format used by pollers/congress.py: | |
| + | '{congress}-{bill_type}-{number}' e.g. '119-hr-1234' | |
| + | ||
| + | Returns (None, None, None) if not recognizable. | |
| + | """ | |
| + | s = legis_num.strip().upper() | |
| + | for prefix, bill_type in _LEGIS_TYPE_MAP: | |
| + | if s.startswith(prefix): | |
| + | remainder = s[len(prefix):].strip() | |
| + | # Remainder must be a bare number | |
| + | if re.fullmatch(r"\d+", remainder): | |
| + | number = remainder | |
| + | ext_id = f"{congress}-{bill_type}-{number}" | |
| + | return bill_type, number, ext_id | |
| + | return None, None, None | |
| + | ||
| + | ||
| + | def _lookup_bill_id(conn: sqlite3.Connection, external_id: str) -> Optional[int]: | |
| + | """Return the bills.id for a congress-poller external_id, or None.""" | |
| + | row = conn.execute( | |
| + | "SELECT id FROM bills WHERE source='congress' AND external_id=?", | |
| + | (external_id,), | |
| + | ).fetchone() | |
| + | return row["id"] if row else None | |
| + | ||
| + | ||
| + | # ─── XML parser ─────────────────────────────────────────────────────────────── | |
| + | ||
| + | def parse_house_vote_xml(xml_text: str, source_url: str) -> Optional[dict]: | |
| + | """ | |
| + | Parse House Clerk roll-call XML. | |
| + | ||
| + | Returns a dict with keys: | |
| + | action_date, congress, session, roll_number, question, bill_text_ref, | |
| + | legis_external_id, result, yea, nay, present, not_voting, | |
| + | source_url, positions: list[{bioguide, position}] | |
| + | ||
| + | Returns None on parse failure or if the vote type is not a recorded vote. | |
| + | """ | |
| try: | ||
| root = ET.fromstring(xml_text) | ||
| except ET.ParseError as exc: | ||
| logger.error("votes_house: XML parse error: %s", exc) | ||
| return None | ||
| - | vote_metadata = root.find("vote-metadata") | |
| - | if vote_metadata is None: | |
| + | meta = root.find("vote-metadata") | |
| + | if meta is None: | |
| + | logger.warning("votes_house: missing <vote-metadata>") | |
| return None | ||
| def text(tag: str) -> str: | ||
| - | el = vote_metadata.find(tag) | |
| - | return el.text.strip() if el is not None and el.text else "" | |
| + | el = meta.find(tag) | |
| + | return (el.text or "").strip() if el is not None else "" | |
| - | action_date = text("action-date") # MM/DD/YYYY | |
| - | # Normalise to ISO | |
| - | try: | |
| - | action_date_iso = datetime.strptime(action_date, "%m/%d/%Y").strftime("%Y-%m-%d") | |
| - | except ValueError: | |
| - | action_date_iso = action_date | |
| + | # Skip voice / procedural records that sneak into the stream | |
| + | vote_type = text("vote-type").upper() | |
| + | if vote_type not in {"YEA-AND-NAY", "RECORDED VOTE", "TWO-THIRDS YEA-AND-NAY"}: | |
| + | logger.debug("votes_house: skipping non-recorded vote type %r", vote_type) | |
| + | return None | |
| - | yea_count = nay_count = present_count = 0 | |
| - | totals = vote_metadata.find("vote-totals") | |
| - | if totals is not None: | |
| - | for totals_by_party in totals.findall("totals-by-vote"): | |
| - | yea_count = int(totals_by_party.findtext("yea-total", "0") or 0) | |
| - | nay_count = int(totals_by_party.findtext("nay-total", "0") or 0) | |
| - | present_count = int(totals_by_party.findtext("present-total", "0") or 0) | |
| + | action_date_raw = text("action-date") | |
| + | # House Clerk XML uses "D-Mon-YYYY" (e.g. "8-Jan-2026") or occasionally | |
| + | # "MM/DD/YYYY". Try both; fall back to raw string so the row is preserved. | |
| + | action_date = action_date_raw | |
| + | for fmt in ("%d-%b-%Y", "%m/%d/%Y", "%B %d, %Y", "%Y-%m-%d"): | |
| + | try: | |
| + | action_date = datetime.strptime(action_date_raw, fmt).strftime("%Y-%m-%d") | |
| + | break | |
| + | except ValueError: | |
| + | pass | |
| - | result_el = vote_metadata.find("vote-result") | |
| - | result = result_el.text.strip() if result_el is not None and result_el.text else "" | |
| + | try: | |
| + | congress = int(text("congress")) | |
| + | except ValueError: | |
| + | congress = 0 | |
| + | try: | |
| + | session = int(text("session")) | |
| + | except ValueError: | |
| + | session = 0 | |
| + | try: | |
| + | roll_number = int(text("rollcall-num")) | |
| + | except ValueError: | |
| + | roll_number = 0 | |
| + | ||
| + | question = text("vote-question") | |
| + | result = text("vote-result") | |
| + | legis_num_raw = text("legis-num") | |
| + | ||
| + | # Bill linkage | |
| + | _, _, legis_external_id = _parse_legis_num(legis_num_raw, congress) | |
| + | ||
| + | # Aggregate totals (totals-by-vote is the grand total, not per-party) | |
| + | yea = nay = present = not_voting = 0 | |
| + | totals_el = meta.find("vote-totals") | |
| + | if totals_el is not None: | |
| + | tbv = totals_el.find("totals-by-vote") | |
| + | if tbv is not None: | |
| + | yea = int(tbv.findtext("yea-total", "0") or 0) | |
| + | nay = int(tbv.findtext("nay-total", "0") or 0) | |
| + | present = int(tbv.findtext("present-total", "0") or 0) | |
| + | not_voting = int(tbv.findtext("not-voting-total", "0") or 0) | |
| + | ||
| + | # Per-member positions | |
| + | positions: list[dict] = [] | |
| + | vote_data = root.find("vote-data") | |
| + | if vote_data is not None: | |
| + | for rv in vote_data.findall("recorded-vote"): | |
| + | leg_el = rv.find("legislator") | |
| + | vote_el = rv.find("vote") | |
| + | if leg_el is None or vote_el is None: | |
| + | continue | |
| + | ||
| + | bioguide = (leg_el.get("name-id") or "").strip() | |
| + | raw_pos = (vote_el.text or "").strip() | |
| + | position = _normalize_position(raw_pos) | |
| + | if not bioguide or position is None: | |
| + | logger.debug("votes_house: skipping legislator %r pos %r", bioguide, raw_pos) | |
| + | continue | |
| + | ||
| + | positions.append({"bioguide": bioguide, "position": position}) | |
| + | ||
| + | # Sanity check: aggregate totals should equal sum of positions | |
| + | if positions: | |
| + | counted = {p: 0 for p in _VALID_POSITIONS} | |
| + | for p in positions: | |
| + | counted[p["position"]] += 1 | |
| + | total_from_positions = sum(counted.values()) | |
| + | total_from_meta = yea + nay + present + not_voting | |
| + | if total_from_meta and abs(total_from_positions - total_from_meta) > 5: | |
| + | logger.warning( | |
| + | "votes_house: position count mismatch (parsed %d vs meta total %d) for %s", | |
| + | total_from_positions, total_from_meta, source_url, | |
| + | ) | |
| return { | ||
| - | "action_date": action_date_iso, | |
| - | "yea": yea_count, | |
| - | "nay": nay_count, | |
| - | "present": present_count, | |
| - | "result": result, | |
| - | "source_url": source_url, | |
| + | "action_date": action_date, | |
| + | "congress": congress, | |
| + | "session": session, | |
| + | "roll_number": roll_number, | |
| + | "question": question, | |
| + | "bill_text_ref": legis_num_raw, | |
| + | "legis_external_id": legis_external_id, | |
| + | "result": result, | |
| + | "yea": yea, | |
| + | "nay": nay, | |
| + | "present": present, | |
| + | "not_voting": not_voting, | |
| + | "source_url": source_url, | |
| + | "positions": positions, | |
| } | ||
| - | def poll(year: Optional[int] = None, max_new: int = 20, db_path: Optional[str] = None) -> int: | |
| + | # ─── Poll entry point ───────────────────────────────────────────────────────── | |
| + | ||
| + | def poll( | |
| + | year: Optional[int] = None, | |
| + | max_new: int = 50, | |
| + | db_path: Optional[str] = None, | |
| + | ) -> int: | |
| """ | ||
| Fetch new House roll-call votes for the given year (default: current year). | ||
| - | Probes vote numbers sequentially from the last seen + 1 until a 404. | |
| - | Limits to max_new new votes per call to avoid runaway HTTP traffic. | |
| + | For each new vote, inserts: | |
| + | • A row in `votes` with extended metadata (congress, session, roll_number, …) | |
| + | • Per-member rows in `vote_positions` | |
| + | • An events feed row | |
| Returns | ||
| ------- | ||
| @@ -90,19 +260,24 @@ def poll(year: Optional[int] = None, max_new: int = 20, db_path: Optional[str] = | ||
| conn = get_connection(db_path) if db_path else get_connection() | ||
| init_schema(conn) | ||
| + | ensure_votes_schema(conn) | |
| - | # Find the highest vote number we've already seen for this year. | |
| + | # Build a quick bioguide → member_id lookup from the members table. | |
| + | # If the members table is empty (members poller not run yet), positions | |
| + | # will still be stored with member_bioguide; member_id will be NULL. | |
| + | bg_to_id: dict[str, int] = {} | |
| + | for row in conn.execute("SELECT id, bioguide_id FROM members"): | |
| + | bg_to_id[row["bioguide_id"]] = row["id"] | |
| + | ||
| + | # Find the highest roll number already seen for this year. | |
| last_row = conn.execute( | ||
| - | "SELECT external_id FROM seen WHERE source=? AND external_id LIKE ? ORDER BY external_id DESC LIMIT 1", | |
| + | "SELECT external_id FROM seen WHERE source=? AND external_id LIKE ? " | |
| + | "ORDER BY external_id DESC LIMIT 1", | |
| (SOURCE, f"{year}-%"), | ||
| ).fetchone() | ||
| + | last_num = int(last_row["external_id"].split("-")[1]) if last_row else 0 | |
| - | if last_row: | |
| - | last_num = int(last_row["external_id"].split("-")[1]) | |
| - | else: | |
| - | last_num = 0 | |
| - | ||
| - | stored = 0 | |
| + | stored = 0 | |
| probe_num = last_num + 1 | ||
| while stored < max_new: | ||
| @@ -113,13 +288,13 @@ def poll(year: Optional[int] = None, max_new: int = 20, db_path: Optional[str] = | ||
| url = _roll_url(year, probe_num) | ||
| try: | ||
| - | resp = requests.get(url, timeout=15) | |
| + | resp = requests.get(url, timeout=20) | |
| except requests.RequestException as exc: | ||
| logger.error("votes_house: request error %s: %s", url, exc) | ||
| break | ||
| if resp.status_code == 404: | ||
| - | logger.info("votes_house: no vote %s (probe stopped)", external_id) | |
| + | logger.info("votes_house: no vote %s (probe stopped at 404)", external_id) | |
| break | ||
| if not resp.ok: | ||
| logger.warning("votes_house: HTTP %d for %s", resp.status_code, url) | ||
| @@ -131,22 +306,76 @@ def poll(year: Optional[int] = None, max_new: int = 20, db_path: Optional[str] = | ||
| logger.error("votes_house: invalid source_url %s: %s", url, exc) | ||
| break | ||
| - | vote = _parse_vote_xml(resp.text, url) | |
| + | vote = parse_house_vote_xml(resp.text, url) | |
| if vote is None: | ||
| + | # parse_house_vote_xml logs the reason; skip and probe next | |
| + | mark_seen(conn, SOURCE, external_id, external_id) | |
| probe_num += 1 | ||
| continue | ||
| + | # ── Attempt bill linkage ────────────────────────────────────────── | |
| + | bill_id: Optional[int] = None | |
| + | if vote["legis_external_id"]: | |
| + | bill_id = _lookup_bill_id(conn, vote["legis_external_id"]) | |
| + | ||
| + | # ── Insert vote row ─────────────────────────────────────────────── | |
| conn.execute( | ||
| """ | ||
| - | INSERT INTO votes (chamber, date, yea, nay, present, result, source_url) | |
| - | VALUES (?, ?, ?, ?, ?, ?, ?) | |
| + | INSERT INTO votes | |
| + | (bill_id, chamber, date, yea, nay, present, result, source_url, | |
| + | congress, session, roll_number, question, not_voting, recorded, | |
| + | vote_external_id, bill_text_ref) | |
| + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, | ||
| - | ("house", vote["action_date"], vote["yea"], vote["nay"], | |
| - | vote["present"], vote["result"], vote["source_url"]), | |
| + | ( | |
| + | bill_id, | |
| + | "house", | |
| + | vote["action_date"], | |
| + | vote["yea"], | |
| + | vote["nay"], | |
| + | vote["present"], | |
| + | vote["result"], | |
| + | vote["source_url"], | |
| + | vote["congress"], | |
| + | vote["session"], | |
| + | vote["roll_number"], | |
| + | vote["question"], | |
| + | vote["not_voting"], | |
| + | 1, # recorded = True | |
| + | external_id, | |
| + | vote["bill_text_ref"], | |
| + | ), | |
| ) | ||
| vote_row = conn.execute("SELECT last_insert_rowid() AS id").fetchone() | ||
| - | if vote_row: | |
| + | vote_db_id = vote_row["id"] if vote_row else None | |
| + | ||
| + | # ── Insert per-member positions ─────────────────────────────────── | |
| + | if vote_db_id and vote["positions"]: | |
| + | pos_rows = [ | |
| + | ( | |
| + | vote_db_id, | |
| + | p["bioguide"], | |
| + | bg_to_id.get(p["bioguide"]), # member_id or None | |
| + | p["position"], | |
| + | ) | |
| + | for p in vote["positions"] | |
| + | ] | |
| + | conn.executemany( | |
| + | """ | |
| + | INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) | |
| + | VALUES (?, ?, ?, ?) | |
| + | """, | |
| + | pos_rows, | |
| + | ) | |
| + | logger.debug( | |
| + | "votes_house: %s - %d positions stored (Y:%d N:%d P:%d NV:%d)", | |
| + | external_id, len(pos_rows), | |
| + | vote["yea"], vote["nay"], vote["present"], vote["not_voting"], | |
| + | ) | |
| + | ||
| + | # ── Feed event ──────────────────────────────────────────────────── | |
| + | if vote_db_id: | |
| conn.execute( | ||
| """ | ||
| INSERT INTO events | ||
| @@ -154,14 +383,17 @@ def poll(year: Optional[int] = None, max_new: int = 20, db_path: Optional[str] = | ||
| affected_estimate, action_type) | ||
| VALUES (?, ?, ?, ?, ?, ?) | ||
| """, | ||
| - | ("vote", vote_row["id"], vote["action_date"], None, None, "floor_vote"), | |
| + | ("vote", vote_db_id, vote["action_date"], None, None, "floor_vote"), | |
| ) | ||
| mark_seen(conn, SOURCE, external_id, external_id) | ||
| - | stored += 1 | |
| + | stored += 1 | |
| probe_num += 1 | ||
| conn.commit() | ||
| conn.close() | ||
| - | logger.info("votes_house: stored %d new vote(s) (year %d)", stored, year) | |
| + | logger.info( | |
| + | "votes_house: stored %d new vote(s) for year %d (last roll probed: %d)", | |
| + | stored, year, probe_num - 1, | |
| + | ) | |
| return stored |
| @@ -1,24 +1,23 @@ | ||
| """ | ||
| - | Docket ingest CLI. | |
| + | Docket ingest CLI - full-spectrum orchestrator. | |
| Usage | ||
| ----- | ||
| - | python -m ingest.run --once # run all pollers once and exit | |
| - | python -m ingest.run --source usa # run only the USAspending poller | |
| - | ||
| - | Pollers | |
| - | ------- | |
| - | usaspending - real federal awards, no auth | |
| - | congress - bills (requires DOCKET_CONGRESS_KEY; skips if unset) | |
| - | govinfo - bill text / public laws (same key as congress) | |
| - | treasury - debt + cash flow, no auth | |
| - | votes_house - House roll-call XML, no auth | |
| - | votes_senate - Senate roll-call XML, no auth | |
| - | openstates - state bills (requires DOCKET_OPENSTATES_KEY; skips if unset) | |
| + | python -m ingest.run --once # run the whole pipeline once, in order | |
| + | python -m ingest.run --source members # run one module's CLI | |
| + | python -m ingest.run --list # list pipeline steps | |
| + | ||
| + | Each step is a module with its own tested `__main__` CLI (or a poll() function). | |
| + | The orchestrator runs them in dependency order, best-effort: a failing step is | |
| + | logged and the pipeline continues. Counts are emitted by each module's own logs. | |
| + | Keys (DOCKET_CONGRESS_KEY / DOCKET_OPENSTATES_KEY / DOCKET_LEGISCAN_KEY / | |
| + | DOCKET_LDA_KEY) are optional - modules that need one skip gracefully when absent. | |
| + | DB path comes from env DOCKET_DB_PATH. | |
| """ | ||
| import argparse | ||
| import logging | ||
| + | import subprocess | |
| import sys | ||
| logging.basicConfig( | ||
| @@ -28,80 +27,85 @@ logging.basicConfig( | ||
| ) | ||
| logger = logging.getLogger("ingest.run") | ||
| - | ||
| - | def _run_all() -> dict: | |
| - | """Run every poller once. Returns {source: count} of new rows stored.""" | |
| - | from ingest.pollers import congress, govinfo, openstates, treasury | |
| - | from ingest.pollers import usaspending, votes_house, votes_senate | |
| - | ||
| - | results = {} | |
| - | ||
| - | logger.info("=== Docket ingest run starting ===") | |
| - | ||
| - | results["usaspending"] = usaspending.poll() | |
| - | results["congress"] = congress.poll() | |
| - | results["govinfo"] = govinfo.poll() | |
| - | results["treasury"] = treasury.poll() | |
| - | results["votes_house"] = votes_house.poll() | |
| - | results["votes_senate"]= votes_senate.poll() | |
| - | results["openstates"] = openstates.poll() | |
| - | ||
| - | logger.info("=== Ingest run complete ===") | |
| - | for source, count in results.items(): | |
| - | logger.info(" %-15s → %d new row(s)", source, count) | |
| - | ||
| - | return results | |
| - | ||
| - | ||
| - | def _run_one(source: str) -> int: | |
| - | """Run a single named poller.""" | |
| - | from ingest.pollers import congress, govinfo, openstates, treasury | |
| - | from ingest.pollers import usaspending, votes_house, votes_senate | |
| - | ||
| - | pollers = { | |
| - | "usaspending": usaspending.poll, | |
| - | "congress": congress.poll, | |
| - | "govinfo": govinfo.poll, | |
| - | "treasury": treasury.poll, | |
| - | "votes_house": votes_house.poll, | |
| - | "votes_senate": votes_senate.poll, | |
| - | "openstates": openstates.poll, | |
| - | # short aliases | |
| - | "usa": usaspending.poll, | |
| - | "house": votes_house.poll, | |
| - | "senate": votes_senate.poll, | |
| - | } | |
| - | fn = pollers.get(source.lower()) | |
| - | if fn is None: | |
| - | logger.error("Unknown source: %r. Valid: %s", source, ", ".join(pollers)) | |
| - | sys.exit(1) | |
| - | return fn() | |
| + | # Dependency-ordered pipeline. Each entry: (label, module, [extra args]). | |
| + | # Roster first (vote/bill linkage), then independent federal sources, | |
| + | # then the bills pipeline (bulk -> versions -> diff -> riders -> amendments), | |
| + | # then votes (need members), procedural, lobbying, and finally state + coverage. | |
| + | PIPELINE = [ | |
| + | ("members", "ingest.pollers.members", []), | |
| + | ("usaspending", "ingest.pollers.usaspending", []), | |
| + | ("treasury", "ingest.pollers.treasury", []), | |
| + | ("federal_register","ingest.pollers.federal_register",[]), | |
| + | ("openomb", "ingest.pollers.openomb", []), | |
| + | ("congress", "ingest.pollers.congress", []), | |
| + | ("govinfo", "ingest.pollers.govinfo", []), | |
| + | ("bills_bulk", "ingest.pollers.bills_bulk", []), | |
| + | ("bill_versions", "ingest.pollers.bill_versions", []), | |
| + | ("diff", "ingest.diff", ["--all", "--congress", "119"]), | |
| + | ("rider_detect", "ingest.rider_detect", []), | |
| + | ("amendments", "ingest.amendments", []), | |
| + | ("votes_senate", "ingest.pollers.votes_senate", []), | |
| + | ("votes_house", "ingest.pollers.votes_house", []), | |
| + | ("votes_enrich", "ingest.pollers.votes_enrich", []), | |
| + | ("crec", "ingest.pollers.crec", []), | |
| + | ("lobbying", "ingest.pollers.lobbying", []), | |
| + | ("openstates", "ingest.pollers.openstates", []), | |
| + | ("legiscan", "ingest.pollers.legiscan", []), | |
| + | ("state_coverage", "ingest.state_coverage", []), | |
| + | ] | |
| + | ||
| + | _BY_LABEL = {label: (mod, args) for label, mod, args in PIPELINE} | |
| + | # short aliases | |
| + | _ALIASES = {"usa": "usaspending", "house": "votes_house", "senate": "votes_senate", | |
| + | "fr": "federal_register", "omb": "openomb", "bills": "bills_bulk", | |
| + | "coverage": "state_coverage"} | |
| + | ||
| + | ||
| + | def _run_step(label: str, module: str, extra: list) -> bool: | |
| + | """Run one module via its CLI. Returns True on exit 0.""" | |
| + | cmd = [sys.executable, "-m", module, "--once", *extra] | |
| + | logger.info("--- step: %-16s (%s) ---", label, module) | |
| + | try: | |
| + | rc = subprocess.run(cmd, check=False).returncode | |
| + | if rc != 0: | |
| + | logger.warning("step %s exited %d (continuing)", label, rc) | |
| + | return rc == 0 | |
| + | except Exception as exc: # never let one step kill the pipeline | |
| + | logger.warning("step %s raised %r (continuing)", label, exc) | |
| + | return False | |
| + | ||
| + | ||
| + | def _run_all() -> None: | |
| + | logger.info("=== Docket full-spectrum ingest starting (%d steps) ===", len(PIPELINE)) | |
| + | ok = 0 | |
| + | for label, module, extra in PIPELINE: | |
| + | if _run_step(label, module, extra): | |
| + | ok += 1 | |
| + | logger.info("=== Ingest complete: %d/%d steps clean ===", ok, len(PIPELINE)) | |
| def main(): | ||
| - | parser = argparse.ArgumentParser(description="Docket ingest runner") | |
| - | parser.add_argument( | |
| - | "--once", | |
| - | action="store_true", | |
| - | help="Run all pollers once and exit.", | |
| - | ) | |
| - | parser.add_argument( | |
| - | "--source", | |
| - | metavar="SOURCE", | |
| - | help="Run only this poller (usaspending, congress, govinfo, treasury, " | |
| - | "votes_house, votes_senate, openstates).", | |
| - | ) | |
| + | parser = argparse.ArgumentParser(description="Docket full-spectrum ingest runner") | |
| + | parser.add_argument("--once", action="store_true", help="Run the whole pipeline once.") | |
| + | parser.add_argument("--source", metavar="STEP", help="Run a single step by label.") | |
| + | parser.add_argument("--list", action="store_true", help="List pipeline steps.") | |
| args = parser.parse_args() | ||
| - | if not args.once and not args.source: | |
| - | parser.print_help() | |
| - | sys.exit(0) | |
| - | ||
| + | if args.list: | |
| + | for label, mod, extra in PIPELINE: | |
| + | print(f" {label:16} {mod} {' '.join(extra)}") | |
| + | return | |
| if args.source: | ||
| - | count = _run_one(args.source) | |
| - | logger.info("Done: %d new row(s) from %s", count, args.source) | |
| - | else: | |
| + | label = _ALIASES.get(args.source.lower(), args.source.lower()) | |
| + | if label not in _BY_LABEL: | |
| + | logger.error("Unknown step %r. Use --list.", args.source) | |
| + | sys.exit(1) | |
| + | mod, extra = _BY_LABEL[label] | |
| + | sys.exit(0 if _run_step(label, mod, extra) else 1) | |
| + | if args.once: | |
| _run_all() | ||
| + | return | |
| + | parser.print_help() | |
| if __name__ == "__main__": |
| @@ -1,36 +0,0 @@ | ||
| - | import zipfile, re, html as html_module | |
| - | zf = zipfile.ZipFile("/tmp/crec_2026.zip") | |
| - | names = zf.namelist() | |
| - | htm_names = [n for n in names if n.endswith('.htm')] | |
| - | print(f"HTM files: {len(htm_names)}, sample: {htm_names[:4]}") | |
| - | ||
| - | patterns = { | |
| - | "voice_vote": r'ayes? (?:have|has) it|noes? (?:have|has) it|yeas? (?:have|has) it|nays? (?:have|has) it', | |
| - | "unanimous_consent": r'without objection|unanimous consent|so ordered', | |
| - | "cloture": r'cloture|invoke cloture', | |
| - | "suspension": r'suspend the rules|suspension of the rules', | |
| - | "mtr": r'motion to recommit', | |
| - | } | |
| - | ||
| - | counts = {k: 0 for k in patterns} | |
| - | for fname in htm_names: | |
| - | raw = zf.read(fname).decode("utf-8", errors="replace") | |
| - | text = re.sub(r'<[^>]+>', ' ', raw) | |
| - | text = html_module.unescape(text) | |
| - | for kind, pat in patterns.items(): | |
| - | for m in re.finditer(pat, text, re.IGNORECASE): | |
| - | counts[kind] += 1 | |
| - | ||
| - | print("Pattern counts in CREC-2026-06-16:", counts) | |
| - | ||
| - | # Show one sample match | |
| - | for fname in htm_names: | |
| - | raw = zf.read(fname).decode("utf-8", errors="replace") | |
| - | text = re.sub(r'<[^>]+>', ' ', raw) | |
| - | text = html_module.unescape(text) | |
| - | m = re.search(r'ayes? (?:have|has) it|noes? (?:have|has) it', text, re.IGNORECASE) | |
| - | if m: | |
| - | ctx = text[max(0,m.start()-80):m.end()+100].replace('\n',' ') | |
| - | print(f"\nVoice vote sample from {fname}:") | |
| - | print(" ", ctx.strip()[:250]) | |
| - | break |
| @@ -1,44 +0,0 @@ | ||
| - | import sqlite3, os, sys | |
| - | ||
| - | db = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db") | |
| - | sys.path.insert(0, "/shared/projects/docket/services/ingest") | |
| - | ||
| - | from ingest.db import get_connection, init_schema | |
| - | from ingest.pollers.bills_bulk import ensure_schema | |
| - | from ingest.diff import diff_bill | |
| - | from ingest.rider_detect import scan_bill_diffs | |
| - | ||
| - | conn = get_connection(db) | |
| - | init_schema(conn) | |
| - | ensure_schema(conn) | |
| - | ||
| - | # H.R. 41 has ih → rh → eh (committee markup) | |
| - | row = conn.execute("SELECT id, title FROM bills WHERE number='H.R. 41' AND source='govinfo_billstatus'").fetchone() | |
| - | if not row: | |
| - | # Try H.J.Res. 25 which has ih→rh→eh (6 total) | |
| - | row = conn.execute("SELECT id, title FROM bills WHERE number='H.J.Res. 25' AND source='govinfo_billstatus'").fetchone() | |
| - | ||
| - | bill_id = row["id"] | |
| - | print(f"Diffing: {row['title'][:70]}") | |
| - | ||
| - | conn.execute("DELETE FROM bill_diffs WHERE bill_id=?", (bill_id,)) | |
| - | conn.commit() | |
| - | ||
| - | versions = conn.execute("SELECT version_code, version_date FROM bill_versions WHERE bill_id=? ORDER BY version_date", (bill_id,)).fetchall() | |
| - | print("Versions:", [(v["version_code"], v["version_date"]) for v in versions]) | |
| - | ||
| - | n = diff_bill(bill_id, conn) | |
| - | conn.commit() | |
| - | print(f"\nTotal diff rows: {n}") | |
| - | ||
| - | print("\n=== BILL_DIFFS ===") | |
| - | for d in conn.execute("SELECT from_version, to_version, section_id, change, rider_flag, snippet FROM bill_diffs WHERE bill_id=? ORDER BY from_version, change", (bill_id,)).fetchall(): | |
| - | print(f" {d['from_version']:5s}→{d['to_version']:5s} | {d['change']:8s} | rider={d['rider_flag']} | {d['section_id']}") | |
| - | if d['snippet'] and d['change'] == 'amended': | |
| - | print(f" DIFF SNIPPET: {d['snippet'][:300]}") | |
| - | ||
| - | flagged = scan_bill_diffs(bill_id, conn) | |
| - | conn.commit() | |
| - | print(f"\nRider flags set: {flagged}") | |
| - | ||
| - | conn.close() |
| @@ -1,59 +0,0 @@ | ||
| - | import sqlite3, os, sys | |
| - | ||
| - | db = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db") | |
| - | sys.path.insert(0, "/shared/projects/docket/services/ingest") | |
| - | ||
| - | from ingest.db import get_connection, init_schema | |
| - | from ingest.pollers.bills_bulk import ensure_schema | |
| - | from ingest.diff import diff_bill | |
| - | from ingest.rider_detect import scan_bill_diffs, scan_all_bills | |
| - | ||
| - | conn = get_connection(db) | |
| - | init_schema(conn) | |
| - | ensure_schema(conn) | |
| - | ||
| - | # Clear all existing diffs and re-run everything | |
| - | conn.execute("DELETE FROM bill_diffs") | |
| - | conn.commit() | |
| - | ||
| - | # Find all bills with 2+ versions | |
| - | multi_ver = conn.execute(""" | |
| - | SELECT b.id, b.number, b.title, COUNT(bv.id) nv | |
| - | FROM bills b JOIN bill_versions bv ON bv.bill_id=b.id | |
| - | WHERE b.source='govinfo_billstatus' | |
| - | GROUP BY b.id HAVING nv >= 2 ORDER BY nv DESC | |
| - | """).fetchall() | |
| - | ||
| - | print(f"Bills with 2+ versions: {len(multi_ver)}") | |
| - | ||
| - | total_diff_rows = 0 | |
| - | for row in multi_ver: | |
| - | n = diff_bill(row["id"], conn) | |
| - | conn.commit() | |
| - | total_diff_rows += n | |
| - | if n > 0: | |
| - | print(f" {row['number']}: {n} diff rows") | |
| - | ||
| - | print(f"\nTotal bill_diffs rows: {total_diff_rows}") | |
| - | ||
| - | # Run rider detection | |
| - | print("\nRunning rider detection...") | |
| - | flagged = scan_all_bills(conn, congress=119) | |
| - | print(f"Rider flags: {flagged}") | |
| - | ||
| - | # Show flagged rows | |
| - | print("\n=== RIDER FLAGGED DIFFS ===") | |
| - | for d in conn.execute(""" | |
| - | SELECT b.number, bd.from_version, bd.to_version, bd.section_id, bd.snippet | |
| - | FROM bill_diffs bd JOIN bills b ON b.id=bd.bill_id | |
| - | WHERE bd.rider_flag=1 LIMIT 10 | |
| - | """).fetchall(): | |
| - | print(f" {d['number']} {d['from_version']}→{d['to_version']} | {d['snippet'][:150]}") | |
| - | ||
| - | # Summary | |
| - | print("\n=== FINAL COUNTS ===") | |
| - | for table in ["bills", "bill_versions", "bill_diffs", "amendments", "actions"]: | |
| - | r = conn.execute(f"SELECT COUNT(*) n FROM {table}").fetchone() | |
| - | print(f" {table}: {r['n']}") | |
| - | ||
| - | conn.close() |
| @@ -1,40 +0,0 @@ | ||
| - | import sqlite3, os, sys | |
| - | ||
| - | db = os.environ.get("DOCKET_DB_PATH", "/opt/docket/data/docket.db") | |
| - | sys.path.insert(0, "/shared/projects/docket/services/ingest") | |
| - | ||
| - | from ingest.db import get_connection, init_schema | |
| - | from ingest.pollers.bills_bulk import ensure_schema | |
| - | from ingest.diff import diff_bill | |
| - | from ingest.rider_detect import scan_bill_diffs | |
| - | ||
| - | conn = get_connection(db) | |
| - | init_schema(conn) | |
| - | ensure_schema(conn) | |
| - | ||
| - | # Get H.R. 21 bill_id | |
| - | row = conn.execute("SELECT id, title FROM bills WHERE number='H.R. 21' AND source='govinfo_billstatus'").fetchone() | |
| - | bill_id = row["id"] | |
| - | print(f"Diffing bill_id={bill_id}: {row['title']}") | |
| - | ||
| - | # Clear any existing diffs first | |
| - | conn.execute("DELETE FROM bill_diffs WHERE bill_id=?", (bill_id,)) | |
| - | conn.commit() | |
| - | ||
| - | n = diff_bill(bill_id, conn) | |
| - | print(f"Diff rows: {n}") | |
| - | ||
| - | # Show diffs | |
| - | print("\n=== BILL_DIFFS (H.R. 21) ===") | |
| - | for d in conn.execute("SELECT from_version, to_version, section_id, change, rider_flag, snippet FROM bill_diffs WHERE bill_id=? ORDER BY from_version, section_id", (bill_id,)).fetchall(): | |
| - | print(f" {d['from_version']:4s}→{d['to_version']:4s} | {d['change']:8s} | section={d['section_id']}") | |
| - | if d['snippet']: | |
| - | print(f" SNIPPET: {d['snippet'][:200]}") | |
| - | ||
| - | # Rider scan | |
| - | print("\n=== RIDER DETECTION ===") | |
| - | flagged = scan_bill_diffs(bill_id, conn) | |
| - | conn.commit() | |
| - | print(f" Flagged {flagged} diff rows as rider candidates") | |
| - | ||
| - | conn.close() |
| @@ -0,0 +1,539 @@ | ||
| + | """ | |
| + | Tests for votes_house XML parser and votes_enrich Senate parser. | |
| + | ||
| + | Coverage: | |
| + | • parse_house_vote_xml() - position enum, bill linkage, aggregate counts | |
| + | • _normalize_position() - canonical mapping including edge cases | |
| + | • _parse_legis_num() - bill-type normalization | |
| + | • parse_senate_vote_positions() - lis_member_id extraction, position mapping | |
| + | • Integration: schema setup + position insertion via temp DB | |
| + | """ | |
| + | ||
| + | import os | |
| + | import sqlite3 | |
| + | import tempfile | |
| + | ||
| + | import pytest | |
| + | ||
| + | from ingest.db import get_connection, init_schema | |
| + | from ingest.pollers.members import ensure_votes_schema | |
| + | from ingest.pollers.votes_house import ( | |
| + | _normalize_position, | |
| + | _parse_legis_num, | |
| + | parse_house_vote_xml, | |
| + | ) | |
| + | from ingest.pollers.votes_enrich import parse_senate_vote_positions | |
| + | ||
| + | ||
| + | # ─── Fixtures ───────────────────────────────────────────────────────────────── | |
| + | ||
| + | SAMPLE_HOUSE_XML = """\ | |
| + | <?xml version="1.0" encoding="UTF-8"?> | |
| + | <rollcall-vote> | |
| + | <vote-metadata> | |
| + | <majority>Republican</majority> | |
| + | <congress>119</congress> | |
| + | <session>1</session> | |
| + | <chamber>U.S. House of Representatives</chamber> | |
| + | <rollcall-num>42</rollcall-num> | |
| + | <legis-num>H R 1000</legis-num> | |
| + | <vote-question>On Passage</vote-question> | |
| + | <vote-type>YEA-AND-NAY</vote-type> | |
| + | <vote-result>Passed</vote-result> | |
| + | <action-date>15-Mar-2025</action-date> | |
| + | <action-time time-etz="2:30 PM">2:30 PM</action-time> | |
| + | <vote-desc>Some bill</vote-desc> | |
| + | <vote-totals> | |
| + | <totals-by-party> | |
| + | <party>Republican</party> | |
| + | <yea-total>210</yea-total> | |
| + | <nay-total>5</nay-total> | |
| + | <present-total>0</present-total> | |
| + | <not-voting-total>2</not-voting-total> | |
| + | </totals-by-party> | |
| + | <totals-by-party> | |
| + | <party>Democratic</party> | |
| + | <yea-total>3</yea-total> | |
| + | <nay-total>195</nay-total> | |
| + | <present-total>1</present-total> | |
| + | <not-voting-total>1</not-voting-total> | |
| + | </totals-by-party> | |
| + | <totals-by-vote> | |
| + | <yea-total>213</yea-total> | |
| + | <nay-total>200</nay-total> | |
| + | <present-total>1</present-total> | |
| + | <not-voting-total>3</not-voting-total> | |
| + | </totals-by-vote> | |
| + | </vote-totals> | |
| + | </vote-metadata> | |
| + | <vote-data> | |
| + | <recorded-vote> | |
| + | <legislator name-id="A000055" sort-field="Aderholt" party="R" state="AL" role="Member">Aderholt</legislator> | |
| + | <vote>Yea</vote> | |
| + | </recorded-vote> | |
| + | <recorded-vote> | |
| + | <legislator name-id="B000574" sort-field="Blumenauer" party="D" state="OR" role="Member">Blumenauer</legislator> | |
| + | <vote>Nay</vote> | |
| + | </recorded-vote> | |
| + | <recorded-vote> | |
| + | <legislator name-id="C000537" sort-field="Clyburn" party="D" state="SC" role="Member">Clyburn</legislator> | |
| + | <vote>Present</vote> | |
| + | </recorded-vote> | |
| + | <recorded-vote> | |
| + | <legislator name-id="D000617" sort-field="DelBene" party="D" state="WA" role="Member">DelBene</legislator> | |
| + | <vote>Not Voting</vote> | |
| + | </recorded-vote> | |
| + | </vote-data> | |
| + | </rollcall-vote> | |
| + | """ | |
| + | ||
| + | VOICE_VOTE_XML = """\ | |
| + | <?xml version="1.0" encoding="UTF-8"?> | |
| + | <rollcall-vote> | |
| + | <vote-metadata> | |
| + | <congress>119</congress> | |
| + | <rollcall-num>10</rollcall-num> | |
| + | <vote-type>VOICE VOTE</vote-type> | |
| + | <vote-result>Passed</vote-result> | |
| + | <action-date>10-Jan-2025</action-date> | |
| + | <vote-totals> | |
| + | <totals-by-vote> | |
| + | <yea-total>0</yea-total> | |
| + | <nay-total>0</nay-total> | |
| + | <present-total>0</present-total> | |
| + | <not-voting-total>0</not-voting-total> | |
| + | </totals-by-vote> | |
| + | </vote-totals> | |
| + | </vote-metadata> | |
| + | </rollcall-vote> | |
| + | """ | |
| + | ||
| + | SAMPLE_SENATE_XML = """\ | |
| + | <?xml version="1.0" encoding="UTF-8"?> | |
| + | <roll_call_vote> | |
| + | <congress>119</congress> | |
| + | <session>1</session> | |
| + | <congress_year>2025</congress_year> | |
| + | <vote_number>100</vote_number> | |
| + | <vote_date>March 15, 2025, 02:00 PM</vote_date> | |
| + | <vote_question_text>On Passage of the Bill</vote_question_text> | |
| + | <vote_result_text>Passed</vote_result_text> | |
| + | <count> | |
| + | <yeas>51</yeas> | |
| + | <nays>49</nays> | |
| + | <abstains>0</abstains> | |
| + | <present>0</present> | |
| + | </count> | |
| + | <members> | |
| + | <member> | |
| + | <lis_member_id>S000148</lis_member_id> | |
| + | <member_full>Schumer, Charles E.</member_full> | |
| + | <last_name>Schumer</last_name> | |
| + | <first_name>Charles</first_name> | |
| + | <party>D</party> | |
| + | <state>NY</state> | |
| + | <vote_cast>Yea</vote_cast> | |
| + | </member> | |
| + | <member> | |
| + | <lis_member_id>S001217</lis_member_id> | |
| + | <member_full>Scott, Rick</member_full> | |
| + | <last_name>Scott</last_name> | |
| + | <first_name>Rick</first_name> | |
| + | <party>R</party> | |
| + | <state>FL</state> | |
| + | <vote_cast>Nay</vote_cast> | |
| + | </member> | |
| + | <member> | |
| + | <lis_member_id>S001181</lis_member_id> | |
| + | <member_full>Shaheen, Jeanne</member_full> | |
| + | <last_name>Shaheen</last_name> | |
| + | <first_name>Jeanne</first_name> | |
| + | <party>D</party> | |
| + | <state>NH</state> | |
| + | <vote_cast>Not Voting</vote_cast> | |
| + | </member> | |
| + | <member> | |
| + | <lis_member_id>S001184</lis_member_id> | |
| + | <member_full>Scott, Tim</member_full> | |
| + | <last_name>Scott</last_name> | |
| + | <first_name>Tim</first_name> | |
| + | <party>R</party> | |
| + | <state>SC</state> | |
| + | <vote_cast>Present</vote_cast> | |
| + | </member> | |
| + | </members> | |
| + | </roll_call_vote> | |
| + | """ | |
| + | ||
| + | ||
| + | # ─── _normalize_position ────────────────────────────────────────────────────── | |
| + | ||
| + | class TestNormalizePosition: | |
| + | def test_yea_variants(self): | |
| + | assert _normalize_position("Yea") == "Yea" | |
| + | assert _normalize_position("YEA") == "Yea" | |
| + | assert _normalize_position("yea") == "Yea" | |
| + | assert _normalize_position("Aye") == "Yea" | |
| + | assert _normalize_position("aye") == "Yea" | |
| + | ||
| + | def test_nay_variants(self): | |
| + | assert _normalize_position("Nay") == "Nay" | |
| + | assert _normalize_position("NAY") == "Nay" | |
| + | assert _normalize_position("No") == "Nay" | |
| + | assert _normalize_position("no") == "Nay" | |
| + | ||
| + | def test_present(self): | |
| + | assert _normalize_position("Present") == "Present" | |
| + | assert _normalize_position("present") == "Present" | |
| + | assert _normalize_position("PRESENT") == "Present" | |
| + | ||
| + | def test_not_voting(self): | |
| + | assert _normalize_position("Not Voting") == "Not Voting" | |
| + | assert _normalize_position("not voting") == "Not Voting" | |
| + | assert _normalize_position("not-voting") == "Not Voting" | |
| + | ||
| + | def test_unknown_returns_none(self): | |
| + | assert _normalize_position("Abstain") is None | |
| + | assert _normalize_position("") is None | |
| + | assert _normalize_position("Pass") is None | |
| + | ||
| + | def test_positions_are_in_valid_set(self): | |
| + | valid = {"Yea", "Nay", "Present", "Not Voting"} | |
| + | for raw in ["Yea", "Nay", "Present", "Not Voting", "Aye", "No"]: | |
| + | result = _normalize_position(raw) | |
| + | assert result in valid, f"Got unexpected position {result!r} for {raw!r}" | |
| + | ||
| + | ||
| + | # ─── _parse_legis_num ───────────────────────────────────────────────────────── | |
| + | ||
| + | class TestParseLegisNum: | |
| + | def test_house_bill(self): | |
| + | bt, num, ext = _parse_legis_num("H R 1234", 119) | |
| + | assert bt == "hr" | |
| + | assert num == "1234" | |
| + | assert ext == "119-hr-1234" | |
| + | ||
| + | def test_house_resolution(self): | |
| + | bt, num, ext = _parse_legis_num("H RES 50", 119) | |
| + | assert bt == "hres" | |
| + | assert num == "50" | |
| + | assert ext == "119-hres-50" | |
| + | ||
| + | def test_house_joint_resolution(self): | |
| + | bt, num, ext = _parse_legis_num("H J RES 88", 119) | |
| + | assert bt == "hjres" | |
| + | assert num == "88" | |
| + | assert ext == "119-hjres-88" | |
| + | ||
| + | def test_house_concurrent_resolution(self): | |
| + | bt, num, ext = _parse_legis_num("H CON RES 3", 119) | |
| + | assert bt == "hconres" | |
| + | assert num == "3" | |
| + | assert ext == "119-hconres-3" | |
| + | ||
| + | def test_senate_bill(self): | |
| + | bt, num, ext = _parse_legis_num("S 500", 119) | |
| + | assert bt == "s" | |
| + | assert num == "500" | |
| + | assert ext == "119-s-500" | |
| + | ||
| + | def test_senate_resolution(self): | |
| + | bt, num, ext = _parse_legis_num("S RES 22", 119) | |
| + | assert bt == "sres" | |
| + | assert num == "22" | |
| + | assert ext == "119-sres-22" | |
| + | ||
| + | def test_unparseable_returns_none_triple(self): | |
| + | bt, num, ext = _parse_legis_num("QUORUM CALL", 119) | |
| + | assert bt is None and num is None and ext is None | |
| + | ||
| + | def test_empty_returns_none_triple(self): | |
| + | bt, num, ext = _parse_legis_num("", 119) | |
| + | assert bt is None and num is None and ext is None | |
| + | ||
| + | def test_case_insensitive_prefix(self): | |
| + | bt, num, ext = _parse_legis_num("h r 999", 119) | |
| + | # Prefix matching normalises to upper first | |
| + | assert bt == "hr" | |
| + | assert num == "999" | |
| + | ||
| + | ||
| + | # ─── parse_house_vote_xml ───────────────────────────────────────────────────── | |
| + | ||
| + | class TestParseHouseVoteXml: | |
| + | SOURCE_URL = "https://clerk.house.gov/evs/2025/roll042.xml" | |
| + | ||
| + | def test_basic_fields(self): | |
| + | result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL) | |
| + | assert result is not None | |
| + | assert result["congress"] == 119 | |
| + | assert result["session"] == 1 | |
| + | assert result["roll_number"] == 42 | |
| + | assert result["action_date"] == "2025-03-15" | |
| + | assert result["question"] == "On Passage" | |
| + | assert result["result"] == "Passed" | |
| + | assert result["source_url"] == self.SOURCE_URL | |
| + | ||
| + | def test_aggregate_counts(self): | |
| + | result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL) | |
| + | assert result is not None | |
| + | assert result["yea"] == 213 | |
| + | assert result["nay"] == 200 | |
| + | assert result["present"] == 1 | |
| + | assert result["not_voting"] == 3 | |
| + | ||
| + | def test_bill_linkage_parsed(self): | |
| + | result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL) | |
| + | assert result is not None | |
| + | assert result["bill_text_ref"] == "H R 1000" | |
| + | assert result["legis_external_id"] == "119-hr-1000" | |
| + | ||
| + | def test_positions_extracted(self): | |
| + | result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL) | |
| + | assert result is not None | |
| + | positions = result["positions"] | |
| + | assert len(positions) == 4 | |
| + | ||
| + | def test_position_bioguides(self): | |
| + | result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL) | |
| + | assert result is not None | |
| + | pos_by_bio = {p["bioguide"]: p["position"] for p in result["positions"]} | |
| + | assert pos_by_bio["A000055"] == "Yea" | |
| + | assert pos_by_bio["B000574"] == "Nay" | |
| + | assert pos_by_bio["C000537"] == "Present" | |
| + | assert pos_by_bio["D000617"] == "Not Voting" | |
| + | ||
| + | def test_all_positions_in_valid_enum(self): | |
| + | valid = {"Yea", "Nay", "Present", "Not Voting"} | |
| + | result = parse_house_vote_xml(SAMPLE_HOUSE_XML, self.SOURCE_URL) | |
| + | assert result is not None | |
| + | for p in result["positions"]: | |
| + | assert p["position"] in valid, f"Invalid position: {p['position']!r}" | |
| + | ||
| + | def test_voice_vote_returns_none(self): | |
| + | """Voice votes must be skipped - no individual record exists.""" | |
| + | result = parse_house_vote_xml(VOICE_VOTE_XML, self.SOURCE_URL) | |
| + | assert result is None, "Voice vote should not produce position data" | |
| + | ||
| + | def test_malformed_xml_returns_none(self): | |
| + | result = parse_house_vote_xml("<broken>", self.SOURCE_URL) | |
| + | assert result is None | |
| + | ||
| + | def test_empty_xml_returns_none(self): | |
| + | result = parse_house_vote_xml("", self.SOURCE_URL) | |
| + | assert result is None | |
| + | ||
| + | ||
| + | # ─── parse_senate_vote_positions ───────────────────────────────────────────── | |
| + | ||
| + | class TestParseSenateVotePositions: | |
| + | def test_member_count(self): | |
| + | positions = parse_senate_vote_positions(SAMPLE_SENATE_XML) | |
| + | assert len(positions) == 4 | |
| + | ||
| + | def test_all_positions_valid(self): | |
| + | valid = {"Yea", "Nay", "Present", "Not Voting"} | |
| + | for p in parse_senate_vote_positions(SAMPLE_SENATE_XML): | |
| + | assert p["position"] in valid | |
| + | ||
| + | def test_yea_member(self): | |
| + | positions = parse_senate_vote_positions(SAMPLE_SENATE_XML) | |
| + | schumer = next(p for p in positions if p["lis_member_id"] == "S000148") | |
| + | assert schumer["position"] == "Yea" | |
| + | assert schumer["state"] == "NY" | |
| + | ||
| + | def test_nay_member(self): | |
| + | positions = parse_senate_vote_positions(SAMPLE_SENATE_XML) | |
| + | scott = next(p for p in positions if p["lis_member_id"] == "S001217") | |
| + | assert scott["position"] == "Nay" | |
| + | ||
| + | def test_not_voting_member(self): | |
| + | positions = parse_senate_vote_positions(SAMPLE_SENATE_XML) | |
| + | shaheen = next(p for p in positions if p["lis_member_id"] == "S001181") | |
| + | assert shaheen["position"] == "Not Voting" | |
| + | ||
| + | def test_present_member(self): | |
| + | positions = parse_senate_vote_positions(SAMPLE_SENATE_XML) | |
| + | tim_scott = next(p for p in positions if p["lis_member_id"] == "S001184") | |
| + | assert tim_scott["position"] == "Present" | |
| + | ||
| + | def test_malformed_xml_returns_empty(self): | |
| + | assert parse_senate_vote_positions("<broken>") == [] | |
| + | ||
| + | def test_empty_xml_returns_empty(self): | |
| + | assert parse_senate_vote_positions("") == [] | |
| + | ||
| + | def test_no_members_element_returns_empty(self): | |
| + | xml = "<roll_call_vote><congress>119</congress></roll_call_vote>" | |
| + | assert parse_senate_vote_positions(xml) == [] | |
| + | ||
| + | ||
| + | # ─── Integration: schema + position insertion ───────────────────────────────── | |
| + | ||
| + | class TestSchemaAndInsertion: | |
| + | """Verify that the schema setup and actual DB writes work end-to-end.""" | |
| + | ||
| + | def _make_db(self): | |
| + | tmp = tempfile.mktemp(suffix=".db") | |
| + | conn = get_connection(tmp) | |
| + | init_schema(conn) | |
| + | ensure_votes_schema(conn) | |
| + | return conn, tmp | |
| + | ||
| + | def test_members_table_created(self): | |
| + | conn, tmp = self._make_db() | |
| + | tables = { | |
| + | row[0] | |
| + | for row in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='table'" | |
| + | ) | |
| + | } | |
| + | assert "members" in tables | |
| + | assert "vote_positions" in tables | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_votes_extended_columns_present(self): | |
| + | conn, tmp = self._make_db() | |
| + | info = { | |
| + | row[1] | |
| + | for row in conn.execute("PRAGMA table_info(votes)") | |
| + | } | |
| + | for col in ["congress", "session", "roll_number", "question", | |
| + | "not_voting", "recorded", "vote_external_id", "bill_text_ref"]: | |
| + | assert col in info, f"Expected column {col!r} in votes" | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_vote_positions_indexes_exist(self): | |
| + | conn, tmp = self._make_db() | |
| + | indexes = { | |
| + | row[0] | |
| + | for row in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='index'" | |
| + | ) | |
| + | } | |
| + | assert "idx_vote_positions_vote_id" in indexes | |
| + | assert "idx_vote_positions_member_id" in indexes | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_insert_position_row(self): | |
| + | conn, tmp = self._make_db() | |
| + | # Insert a stub vote | |
| + | conn.execute( | |
| + | "INSERT INTO votes (chamber, date, yea, nay, present, result, source_url) " | |
| + | "VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| + | ("house", "2025-03-15", 213, 200, 1, "Passed", | |
| + | "https://clerk.house.gov/evs/2025/ROLL_042.xml"), | |
| + | ) | |
| + | vote_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] | |
| + | ||
| + | # Insert a position | |
| + | conn.execute( | |
| + | "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) " | |
| + | "VALUES (?, ?, ?, ?)", | |
| + | (vote_id, "A000055", None, "Yea"), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | row = conn.execute( | |
| + | "SELECT * FROM vote_positions WHERE vote_id=?", (vote_id,) | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["member_bioguide"] == "A000055" | |
| + | assert row["position"] == "Yea" | |
| + | ||
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_position_enum_enforced(self): | |
| + | """SQLite CHECK constraint must reject invalid position values.""" | |
| + | conn, tmp = self._make_db() | |
| + | conn.execute( | |
| + | "INSERT INTO votes (chamber, date, yea, nay, present, result, source_url) " | |
| + | "VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| + | ("house", "2025-03-15", 1, 0, 0, "Passed", | |
| + | "https://clerk.house.gov/evs/2025/roll001.xml"), | |
| + | ) | |
| + | vote_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] | |
| + | conn.execute("PRAGMA foreign_keys=ON") | |
| + | ||
| + | with pytest.raises(sqlite3.IntegrityError): | |
| + | conn.execute( | |
| + | "INSERT INTO vote_positions (vote_id, member_bioguide, position) " | |
| + | "VALUES (?, ?, ?)", | |
| + | (vote_id, "A000055", "Abstain"), # not in enum | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_both_pivots_queryable(self): | |
| + | """ | |
| + | Confirm that both query directions are supported: | |
| + | - all positions for a vote (vote_id → vote_positions → members) | |
| + | - all votes for a member (bioguide → vote_positions → votes) | |
| + | """ | |
| + | conn, tmp = self._make_db() | |
| + | ||
| + | # Seed member | |
| + | conn.execute( | |
| + | "INSERT INTO members (bioguide_id, full_name, party, state, chamber, source_url) " | |
| + | "VALUES (?, ?, ?, ?, ?, ?)", | |
| + | ("A000055", "Robert Aderholt", "Republican", "AL", "house", | |
| + | "https://bioguide.congress.gov/search/bio/A000055"), | |
| + | ) | |
| + | member_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] | |
| + | ||
| + | # Seed two votes | |
| + | for roll in [42, 43]: | |
| + | conn.execute( | |
| + | "INSERT INTO votes (chamber, date, yea, nay, present, result, source_url, roll_number) " | |
| + | "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| + | ("house", "2025-03-15", 213, 200, 1, "Passed", | |
| + | f"https://clerk.house.gov/evs/2025/ROLL_{roll:03d}.xml", roll), | |
| + | ) | |
| + | vid = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] | |
| + | conn.execute( | |
| + | "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) " | |
| + | "VALUES (?, ?, ?, ?)", | |
| + | (vid, "A000055", member_id, "Yea"), | |
| + | ) | |
| + | ||
| + | conn.commit() | |
| + | ||
| + | # Pivot 1: all positions for a given vote | |
| + | vote_id_42 = conn.execute( | |
| + | "SELECT id FROM votes WHERE roll_number=42" | |
| + | ).fetchone()["id"] | |
| + | pos_rows = conn.execute( | |
| + | """ | |
| + | SELECT vp.position, m.full_name, m.state | |
| + | FROM vote_positions vp | |
| + | JOIN members m ON m.id = vp.member_id | |
| + | WHERE vp.vote_id = ? | |
| + | """, | |
| + | (vote_id_42,), | |
| + | ).fetchall() | |
| + | assert len(pos_rows) == 1 | |
| + | assert pos_rows[0]["full_name"] == "Robert Aderholt" | |
| + | assert pos_rows[0]["position"] == "Yea" | |
| + | ||
| + | # Pivot 2: all votes for a given member | |
| + | member_votes = conn.execute( | |
| + | """ | |
| + | SELECT v.roll_number, vp.position | |
| + | FROM vote_positions vp | |
| + | JOIN votes v ON v.id = vp.vote_id | |
| + | WHERE vp.member_bioguide = 'A000055' | |
| + | ORDER BY v.roll_number | |
| + | """, | |
| + | ).fetchall() | |
| + | assert len(member_votes) == 2 | |
| + | assert member_votes[0]["roll_number"] == 42 | |
| + | assert member_votes[1]["roll_number"] == 43 | |
| + | ||
| + | conn.close() | |
| + | os.unlink(tmp) |