| @@ -0,0 +1,28 @@ | ||
| + | You are GLM-5.2, frontend lead for Docket (neutral US gov transparency app). Work in /opt/docket. The read-API + deep pages you built (F1/F2) are LIVE. New DATA layers now exist in the DB but have NO UI yet - build their API routes + UI in this wave. Be RAG-first; read ONLY: docs/API.md, docs/BRAND.md, docs/NEUTRALITY.md, docs/ACCESSIBILITY.md. Reuse your existing lib/db.ts + lib/feed.ts patterns + components. | |
| + | ||
| + | New DB tables/columns (verify with a quick sqlite read at /opt/docket/data/docket.db): | |
| + | - calendar_events (id, date, kind[effective|enacted|vote|introduced|committee|floor|apportionment|effective_parsed], ref_type, ref_id, title, jurisdiction, source_url, confidence) - 39k rows | |
| + | - categories (slug,label,kind) - 32 rows; bills.policy_area; bill_subjects(bill_id,subject); awards.category | |
| + | - votes.year + vote_positions (971k rows) - for year-by-year member voting | |
| + | - change_events (id, ts, source, ref_type, ref_id, change[added|updated|removed], summary, source_url) - the watcher feed | |
| + | ||
| + | WAVE F3 - build these. Add API routes AND UI: | |
| + | ||
| + | 1. CALENDAR (the priority - a visual for "what goes into effect when", for non-technical users of any age): | |
| + | - /api/calendar?from=&to=&kind= → events in range from calendar_events. | |
| + | - /calendar page: a real MONTH-GRID calendar. Each day shows its events (dot/badge per kind, e.g. effective/enacted/vote). Click a day → panel listing that day's events with title + kind + Source link. Month nav (prev/next/today). Make it BIG, clean, obvious - an 80-year-old should get it instantly. A small legend explains the kinds in plain words ("Takes effect", "Became law", "Vote"). Works in all 3 display modes. | |
| + | ||
| + | 2. CATEGORIES: | |
| + | - /api/categories → list with item counts (join bills.policy_area + awards.category). | |
| + | - A category browse UI (chips/list with counts) on /register and/or a /categories page; clicking a category filters the feed/bills. Show policy_area + top subjects on /bill/[id]. | |
| + | ||
| + | 3. MEMBER YEAR-INDEX (the "year by year how they voted" view): | |
| + | - On /members/[id], group that member's votes BY YEAR - collapsible year sections (e.g. 2026, 2025, 2024...), each showing the votes that year with date + question + their position (muted colors). Use the votes.year index. Show a per-year count summary. This is the headline upgrade to member pages. | |
| + | ||
| + | 4. WHAT-CHANGED FEED: | |
| + | - /api/changes → recent change_events. | |
| + | - A compact "Recently changed" surface (added/updated/removed) on the home or register page - small, factual, neutral, Source link each. | |
| + | ||
| + | HARD RULES: keep BRAND (brass rationed, custody-stamp, mono, dark default), NEUTRALITY (Source link on every fact, no adjectives, muted colors, no comment sections, honest "no record" where true), and the 3-mode accessibility toggle (Dark/Readable/Plain) working on ALL new pages - the calendar especially must be legible in Plain mode. Add the new pages to the nav. | |
| + | ||
| + | BUILD: `npm run build` MUST pass (fix type errors). Do NOT restart the server (handled separately). Update FRONTEND_NOTES.md. Definition of done: build passes; /calendar, category filter, member year-index, and the changed-feed all implemented against real /api data; brand + neutrality + a11y intact. |
| @@ -0,0 +1,146 @@ | ||
| + | # Calendar Ingest - Notes | |
| + | ||
| + | Module: `ingest/calendar.py` | |
| + | Table: `calendar_events` | |
| + | Tests: `tests/test_calendar.py` (42 tests, all pass) | |
| + | ||
| + | ## Run | |
| + | ||
| + | ```bash | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | python -m ingest.calendar --once | |
| + | ``` | |
| + | ||
| + | ## What it does | |
| + | ||
| + | Builds `calendar_events` by aggregating dated events from every other ingest | |
| + | table. One run takes ~20 seconds (mostly FR API calls for effective_on | |
| + | enrichment). Idempotent - safe to re-run. | |
| + | ||
| + | ### Event kinds and sources | |
| + | ||
| + | | kind | count (2026-06-26) | source table | key field | | |
| + | |-------------------|--------------------|----------------------|----------------| | |
| + | | effective | 738 | executive_actions | effective_on | | |
| + | | enacted | 47 | actions (BecameLaw) | action_date | | |
| + | | vote | 3,454 | votes | date | | |
| + | | introduced | 24,794 | actions (IntroReferral)| action_date | | |
| + | | committee | 5,205 | actions (Committee) | action_date | | |
| + | | floor | 4,969 | actions (Floor) | action_date | | |
| + | | apportionment | 20 | apportionments | date | | |
| + | | effective_parsed | 0* | actions (text regex) | parsed date | | |
| + | | **TOTAL** | **39,274** | | | | |
| + | ||
| + | *`effective_parsed` is 0 because action texts in this dataset are short | |
| + | procedural strings ("Became Public Law No: 119-X.") without specific effective | |
| + | date clauses. The machinery is in place; it will fire on richer bill text. | |
| + | ||
| + | ## The key feature: effective dates | |
| + | ||
| + | The `effective` kind is the "goes into effect on X" source. Federal Register | |
| + | final rules carry a structured `effective_on` field in the FR API. | |
| + | ||
| + | **Real example (from production DB):** | |
| + | ||
| + | ``` | |
| + | Date: 2026-07-01 | |
| + | Title: Medicare Program; Alternative Payment Model Updates and the | |
| + | Increasing Organ Transplant Access Act Implementation | |
| + | (Health and Human Services Department; Centers for Medicare and | |
| + | Medicaid Services) | |
| + | Source URL: https://www.federalregister.gov/documents/2026/.../medicare-program | |
| + | Confidence: 1.0 | |
| + | ``` | |
| + | ||
| + | Another: | |
| + | ``` | |
| + | Date: 2028-09-30 | |
| + | Title: Removing Regulations Related to Trade Adjustment Assistance for | |
| + | Firms (Commerce Department; Economic Development Administration) | |
| + | Source URL: https://www.federalregister.gov/documents/2026/06/11/2026-11800/... | |
| + | ``` | |
| + | ||
| + | ## FR enrichment flow | |
| + | ||
| + | 1. `fetch_fr_rules_with_effective(conn, days_back=90)` - fetches 90 days of | |
| + | RULE-type documents with `effective_on` field included in the request; | |
| + | upserts into `executive_actions`. | |
| + | 2. `enrich_fr_effective_on(conn)` - for any remaining `executive_actions` rows | |
| + | with `effective_on IS NULL`, batch-fetches from FR API in groups of 50. | |
| + | Notices/NPRMs/EOs typically return null (no effective date); that is correct. | |
| + | ||
| + | ## Schema | |
| + | ||
| + | ```sql | |
| + | CREATE TABLE calendar_events ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | date TEXT NOT NULL, -- ISO-8601 YYYY-MM-DD | |
| + | kind TEXT NOT NULL CHECK(kind IN ( | |
| + | 'effective','enacted','vote', | |
| + | 'introduced','committee','floor', | |
| + | 'apportionment','effective_parsed')), | |
| + | ref_type TEXT NOT NULL, -- e.g. executive_action, bill, vote | |
| + | ref_id INTEGER, -- FK into the source table | |
| + | title TEXT, | |
| + | jurisdiction TEXT NOT NULL DEFAULT 'US', | |
| + | source_url TEXT NOT NULL, -- allowlisted .gov URL | |
| + | confidence REAL NOT NULL DEFAULT 1.0, -- 0.5 for effective_parsed | |
| + | UNIQUE (kind, ref_type, ref_id, date) | |
| + | ); | |
| + | CREATE INDEX idx_calendar_events_date ON calendar_events(date); | |
| + | CREATE INDEX idx_calendar_events_kind ON calendar_events(kind); | |
| + | ``` | |
| + | ||
| + | ## Effective-date regex patterns | |
| + | ||
| + | Applied to bill/action text to extract explicit calendar dates. | |
| + | Only SPECIFIC dates extracted; relative clauses ("X days after enactment") | |
| + | are intentionally skipped. Confidence = 0.5 for these parsed results. | |
| + | ||
| + | ```python | |
| + | # Pattern 1: "shall take effect on <date>" | |
| + | r"shall\s+take\s+effect\s+on\s+<DATE>" | |
| + | ||
| + | # Pattern 2: "takes effect on <date>" | |
| + | r"takes?\s+effect\s+on\s+<DATE>" | |
| + | ||
| + | # Pattern 3: "this Act ... takes effect <date>" | |
| + | r"this\s+Act\b.*?\btakes?\s+effect\s+<DATE>" | |
| + | ||
| + | # Pattern 4: "effective on <date>" | |
| + | r"effective\s+on\s+<DATE>" | |
| + | ||
| + | # Pattern 5: "effective date is <date>" / "effective date shall be <date>" | |
| + | r"effective\s+date\s+(?:is|shall\s+be)\s+<DATE>" | |
| + | ||
| + | # Pattern 6 (lowest specificity): "effective <date>" | |
| + | r"\beffective\s+<DATE>" | |
| + | ||
| + | # DATE fragment matches: | |
| + | # "January 1, 2026" (full month name) | |
| + | # "2026-01-01" (ISO-8601) | |
| + | ``` | |
| + | ||
| + | ## Future /api/calendar endpoint | |
| + | ||
| + | Suggested query shape: | |
| + | ||
| + | ```sql | |
| + | SELECT date, kind, ref_type, ref_id, title, jurisdiction, source_url, confidence | |
| + | FROM calendar_events | |
| + | WHERE date BETWEEN :start AND :end | |
| + | AND (:kind IS NULL OR kind = :kind) | |
| + | ORDER BY date, kind | |
| + | LIMIT :limit OFFSET :offset | |
| + | ``` | |
| + | ||
| + | This table is designed for date-range queries - the `date` index makes those | |
| + | fast even at 40k+ rows. | |
| + | ||
| + | ## Neutrality | |
| + | ||
| + | Every row carries a `source_url` pointing to an official `.gov` endpoint. | |
| + | Events are factual dated occurrences only: no adjectives, no editorial | |
| + | weighting of which dates "matter." The `effective_parsed` kind has | |
| + | `confidence=0.5` to signal lower certainty and keep the higher-quality | |
| + | structured events clearly distinguished. |
| @@ -0,0 +1,150 @@ | ||
| + | # Categorize Module - Build Notes | |
| + | ||
| + | ## What was built | |
| + | ||
| + | `ingest/categorize.py` - unified taxonomy backfill for bills and spending awards. | |
| + | ||
| + | ### Files created | |
| + | - `ingest/categorize.py` - the module | |
| + | - `tests/test_categorize.py` - 57 tests | |
| + | ||
| + | ### Schema changes (all additive) | |
| + | | Change | Table | Type | | |
| + | |---|---|---| | |
| + | | CREATE TABLE IF NOT EXISTS | `categories` | slug, label, kind | | |
| + | | CREATE TABLE IF NOT EXISTS | `bill_subjects` | bill_id, subject | | |
| + | | ALTER TABLE ADD COLUMN | `awards.category` | TEXT (guarded by try/except) | | |
| + | ||
| + | --- | |
| + | ||
| + | ## Data findings (live DB, 2026-06-26) | |
| + | ||
| + | ### Bills | |
| + | - Total bills: **7,190** | |
| + | - Bills with `policy_area` (already stored by bills_bulk.py): **7,179 / 7,190** (99.8%) | |
| + | - Distinct policy areas in use: **31 of 32** canonical areas | |
| + | - 11 bills have no policy_area (XML had no `<policyArea>` element - not an error) | |
| + | ||
| + | ### Bill subjects (initial backfill of 50 bills) | |
| + | - 50 bills fetched → **279 subject rows**, 170 distinct subjects | |
| + | - Full backfill running in background: ~7,140 remaining bills | |
| + | - Rate: ~50 bills / 22 seconds (~2.3 bills/sec with 0.05s delay) | |
| + | - At that rate, full backfill of all bills: ~50 minutes | |
| + | ||
| + | ### Awards | |
| + | - Total awards: **320** | |
| + | - Awards categorized: **320 / 320** (100%) | |
| + | ||
| + | Award category breakdown: | |
| + | | Category | Awards | | |
| + | |---|---| | |
| + | | Health | 111 | | |
| + | | Science, Technology, Communications | 50 | | |
| + | | Government Operations and Politics | 49 | | |
| + | | Agriculture and Food | 19 | | |
| + | | Emergency Management | 16 | | |
| + | | Transportation and Public Works | 12 | | |
| + | | Armed Forces and National Security | 12 | | |
| + | | International Affairs | 11 | | |
| + | | Energy | 10 | | |
| + | | Crime and Law Enforcement | 10 | | |
| + | | Social Welfare | 4 | | |
| + | | Environmental Protection | 4 | | |
| + | | Public Lands and Natural Resources | 3 | | |
| + | | Education | 3 | | |
| + | | Commerce | 3 | | |
| + | | Economics and Public Finance | 2 | | |
| + | ||
| + | ### Categories table | |
| + | - 32 rows seeded (canonical Congressional policy areas) | |
| + | - Also auto-ingests any novel policy_area values from the bills table on each run | |
| + | ||
| + | --- | |
| + | ||
| + | ## Agency → Category map | |
| + | ||
| + | Covers ~50 agency entries. Three-tier lookup: | |
| + | 1. Exact match | |
| + | 2. Prefix match (handles sub-agencies, e.g. "Department of Defense - DARPA") | |
| + | 3. Substring match (handles "HHS Office of Inspector General") | |
| + | ||
| + | All map values align to the same 32-area vocabulary as bills. | |
| + | ||
| + | Key mappings: | |
| + | | Agency | Category | | |
| + | |---|---| | |
| + | | Department of Defense | Armed Forces and National Security | | |
| + | | Department of Veterans Affairs | Armed Forces and National Security | | |
| + | | Department of Homeland Security | Emergency Management | | |
| + | | Department of Health and Human Services | Health | | |
| + | | Department of Transportation | Transportation and Public Works | | |
| + | | Department of Energy | Energy | | |
| + | | Department of Education | Education | | |
| + | | Department of Agriculture | Agriculture and Food | | |
| + | | Department of Labor | Labor and Employment | | |
| + | | Environmental Protection Agency | Environmental Protection | | |
| + | | Department of Justice | Crime and Law Enforcement | | |
| + | | Department of State | International Affairs | | |
| + | | Agency for International Development | International Affairs | | |
| + | | Department of Commerce | Commerce | | |
| + | | Department of the Treasury | Economics and Public Finance | | |
| + | | Department of the Interior | Public Lands and Natural Resources | | |
| + | | Department of Housing and Urban Development | Housing and Community Development | | |
| + | | Social Security Administration | Social Welfare | | |
| + | | NASA | Science, Technology, Communications | | |
| + | | National Science Foundation | Science, Technology, Communications | | |
| + | | General Services Administration | Government Operations and Politics | | |
| + | ||
| + | --- | |
| + | ||
| + | ## Test results | |
| + | ||
| + | 57 tests, 0 failures, 0.47s. | |
| + | ||
| + | Test classes: | |
| + | - `TestAgencyToCategory` - 25 tests: exact match, prefix match, unknown/null handling, all map values are valid labels | |
| + | - `TestPolicyAreas` - 6 tests: count, no duplicates, slug format, key areas present, slug/label consistency | |
| + | - `TestParseSubjects` - 7 tests: single/multiple subjects, empty, missing element, malformed XML, whitespace, alternate root element | |
| + | - `TestEnsureSchema` - 4 tests: creates both tables, adds column, idempotent | |
| + | - `TestSeedCategories` - 4 tests: seeds all areas, idempotent, picks up extra bill areas, Health present | |
| + | - `TestBackfillAwards` - 6 tests: known agency, unknown→NULL, no-overwrite, multiple, count, idempotent | |
| + | - `TestCategoryCounts` - 4 tests: returns list, expected keys, bill counted, award counted | |
| + | ||
| + | --- | |
| + | ||
| + | ## CLI | |
| + | ||
| + | ```bash | |
| + | # Full backfill (all bills + awards + categories) | |
| + | cd /shared/projects/docket/services/ingest | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | .venv/bin/python -m ingest.categorize --once | |
| + | ||
| + | # Limit subjects fetch (for testing) | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | .venv/bin/python -m ingest.categorize --once --limit-subjects 50 | |
| + | ||
| + | # Show category counts without re-running backfill | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | .venv/bin/python -m ingest.categorize --counts-only | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Neutrality | |
| + | ||
| + | - `policy_area` values come from Congress.gov's own taxonomy (Congressional Research Service) - no editorial classification | |
| + | - `legislativeSubjects` come from BILLSTATUS XML verbatim | |
| + | - Agency→category map keys are official agency names from USAspending data; values align to the same CRS taxonomy | |
| + | - No "good/bad" groupings; no partisan framing; categories are factual government classifications | |
| + | ||
| + | --- | |
| + | ||
| + | ## Technical notes | |
| + | ||
| + | - GovInfo BILLSTATUS URL format: `/bulkdata/BILLSTATUS/{congress}/{type}/BILLSTATUS-{congress}{type}{num}.xml` | |
| + | (congress and type are **separate** path segments; concatenated only in the filename) | |
| + | - Bills with no subjects from GovInfo get a sentinel empty-string row in `bill_subjects` to prevent infinite re-fetching | |
| + | - Idempotent: re-running only processes bills/awards not yet categorized | |
| + | - `awards.category` ALTER is guarded with try/except so parallel runs don't fail | |
| + | - `categories` uses INSERT OR IGNORE so re-seeding is safe |
| @@ -0,0 +1,218 @@ | ||
| + | # Voteview Ingest - Implementation Notes | |
| + | ||
| + | ## What this poller does | |
| + | ||
| + | `pollers/voteview.py` ingests complete per-member voting records from the | |
| + | Voteview bulk-CSV dataset (Lewis et al., voteview.com). It populates: | |
| + | ||
| + | - `votes` - one row per recorded roll call, with metadata, year index, and | |
| + | an official .gov `source_url` (neutrality rule #1) | |
| + | - `vote_positions` - one row per member per roll call, with canonical position | |
| + | - `voteview_crosswalk` - cached ICPSR → bioguide mapping | |
| + | ||
| + | Voteview is the **ingestion pipe only**. No Voteview URLs appear anywhere in | |
| + | user-facing data; every `source_url` points to the official government record. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Cast-code mapping | |
| + | ||
| + | Voteview assigns each member vote a `cast_code` (0-9) per their codebook | |
| + | (https://voteview.com/articles/data_help_members#castcode): | |
| + | ||
| + | | cast_code | Official meaning | Canonical position | | |
| + | |-----------|------------------------------|--------------------| | |
| + | | 0 | Not a member / no record | Not Voting | | |
| + | | 1 | Yea | Yea | | |
| + | | 2 | Paired Yea (absent, declared)| Yea | | |
| + | | 3 | Announced Yea (absent, decl.)| Yea | | |
| + | | 4 | Announced Nay (absent, decl.)| Nay | | |
| + | | 5 | Paired Nay (absent, declared)| Nay | | |
| + | | 6 | Nay | Nay | | |
| + | | 7 | Present / abstention (v1) | Present | | |
| + | | 8 | Present / abstention (v2) | Present | | |
| + | | 9 | Not Voting | Not Voting | | |
| + | ||
| + | **Paired / announced votes (codes 2-5):** The member was physically absent but | |
| + | announced their intent; the chamber counts them as Yea/Nay. We map them to | |
| + | Yea/Nay for the same reason - they reflect a declared position. | |
| + | ||
| + | **Codes 7 and 8 are both Present (abstention)**, not Nay. Some secondary | |
| + | sources conflate 7/8 with Nay; this is incorrect per the Voteview codebook. | |
| + | ||
| + | **Code 0 vs 9:** | |
| + | - 0 = not a member at the time of the vote (e.g., vacancy) or genuinely missing | |
| + | - 9 = member was present in the chamber but chose not to vote | |
| + | Both map to `Not Voting`; the distinction matters for DW-NOMINATE but not for | |
| + | our per-member position index. | |
| + | ||
| + | All 10 codes and every enum value are covered by `tests/test_voteview.py`. | |
| + | ||
| + | --- | |
| + | ||
| + | ## ICPSR → bioguide crosswalk | |
| + | ||
| + | Voteview identifies members by ICPSR code (numeric, chamber-specific: a member | |
| + | who served in both chambers gets two ICPSR IDs). The Docket DB uses bioguide_id | |
| + | as the join key. | |
| + | ||
| + | The crosswalk is built from: | |
| + | - `legislators-current.json` (unitedstates/congress-legislators, gh-pages branch) | |
| + | - `legislators-historical.json` (same) | |
| + | ||
| + | Both are fetched once and cached in the `voteview_crosswalk` table. On | |
| + | subsequent runs, the cache is used (pass `--refresh-crosswalk` to re-download). | |
| + | ||
| + | The `id.icpsr` field is the primary mapping. Some members also have | |
| + | `id.icpsr_hn` (a separate House ICPSR number); both are indexed. | |
| + | ||
| + | **Members not in the crosswalk:** stored with `member_bioguide = NULL`. | |
| + | This happens for: | |
| + | - Members who served before the digitisation cutoff in congress-legislators | |
| + | - Delegates, non-voting members, territorial representatives | |
| + | - A small number of historical mismatches | |
| + | ||
| + | --- | |
| + | ||
| + | ## Official source URLs (neutrality rule #1) | |
| + | ||
| + | Voteview is an academic aggregator - it is never the user-facing source. | |
| + | ||
| + | | Chamber | URL pattern | | |
| + | |---------|-------------| | |
| + | | House | `https://clerk.house.gov/Votes/{year}/roll{NNN:03d}` | | |
| + | | Senate | `https://www.senate.gov/legislative/LIS/roll_call_votes/vote{congress}{session}/vote_{congress}_{session}_{roll:05d}.xml` | | |
| + | ||
| + | For House votes: `clerk_rollnumber` (from the Voteview rollcalls CSV) is used | |
| + | as `NNN`, because it is the House Clerk's sequential roll number. Voteview's | |
| + | own `rollnumber` is stored separately in `voteview_rollnumber` for reference. | |
| + | ||
| + | For Senate votes: Voteview's `rollnumber` equals the Senate's official roll | |
| + | call number, so it is used directly. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Year index | |
| + | ||
| + | A `year INTEGER` column is added to the `votes` table and indexed as | |
| + | `idx_votes_year`. This makes the following queries run in O(log n): | |
| + | ||
| + | ```python | |
| + | # All votes for member X in year Y (hits idx_votes_year + idx_vote_positions_bioguide) | |
| + | votes_by_member_year("N000002", 2023) | |
| + | ||
| + | # All recorded votes in year Y (hits idx_votes_year) | |
| + | votes_in_year(2023) | |
| + | ``` | |
| + | ||
| + | `year` is derived from the `date` field (first 4 characters) and stored | |
| + | redundantly to avoid a `substr()` or `CAST` on every range scan. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Completeness | |
| + | ||
| + | **What Voteview covers:** Only recorded (roll-call) votes where a member's | |
| + | name appears in the official tally. This is the complete universe of votes | |
| + | where per-member records exist in the public record. | |
| + | ||
| + | **What Voteview does NOT cover:** | |
| + | - Voice votes - passage is recorded but no per-member positions exist. This is | |
| + | ~94% of Senate measures and a large fraction of House suspension-of-the-rules | |
| + | votes. The product note "no individual record exists" must be displayed for | |
| + | these (handled by `votes_house.py` + `votes_senate.py` which set | |
| + | `recorded=0`). | |
| + | - Unanimous consent agreements | |
| + | - Committee votes | |
| + | ||
| + | **Row-count check:** the completeness report (`--report` flag) fetches the | |
| + | Voteview rollcalls CSV for each (congress, chamber) and compares its row count | |
| + | against the `seen` table. A match means every rollcall in the CSV was | |
| + | processed. Gaps are explained (e.g., blank rollnumber rows in the CSV, or an | |
| + | incomplete run). | |
| + | ||
| + | --- | |
| + | ||
| + | ## Running the ingest | |
| + | ||
| + | ### Proof slice (congresses 118-119, 2023-present) | |
| + | ```bash | |
| + | ssh root@REDACTED-IP "pct exec 247 -- bash -lc 'cd /shared/projects/docket/services/ingest && DOCKET_DB_PATH=/opt/docket/data/docket.db /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.voteview --once --start 118 --end 119'" | |
| + | ``` | |
| + | ||
| + | ### Full 2019-present (congresses 116-119) | |
| + | ```bash | |
| + | ssh root@REDACTED-IP "pct exec 247 -- bash -lc 'cd /shared/projects/docket/services/ingest && DOCKET_DB_PATH=/opt/docket/data/docket.db /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.voteview --once --start 116 --end 119'" | |
| + | ``` | |
| + | ||
| + | ### Full historical backfill (1989-present, ~30 congresses, recommended first pass) | |
| + | ```bash | |
| + | ssh root@REDACTED-IP "pct exec 247 -- bash -lc 'cd /shared/projects/docket/services/ingest && DOCKET_DB_PATH=/opt/docket/data/docket.db /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.voteview --once --start 101 --end 119'" | |
| + | ``` | |
| + | ||
| + | ### Full Voteview archive (1789-present, all ~120 congresses) | |
| + | ```bash | |
| + | ssh root@REDACTED-IP "pct exec 247 -- bash -lc 'cd /shared/projects/docket/services/ingest && DOCKET_DB_PATH=/opt/docket/data/docket.db /opt/docket/services/ingest/.venv/bin/python -m ingest.pollers.voteview --once --start 1 --end 119'" | |
| + | ``` | |
| + | ||
| + | Add `--report` to any command to print the CSV vs DB completeness table. | |
| + | Add `--refresh-crosswalk` to re-download the ICPSR crosswalk from GitHub. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Scale estimate (full historical backfill) | |
| + | ||
| + | Voteview covers all US congresses from 1789. Rough row counts for a full | |
| + | `--start 1 --end 119` run: | |
| + | ||
| + | | Scope | Congresses | Approx rollcalls | Approx positions | | |
| + | |-----------------|------------|------------------|-----------------| | |
| + | | 2019-present | 116-119 | ~6,000 | ~2,000,000 | | |
| + | | 1989-present | 101-119 | ~25,000 | ~8,000,000 | | |
| + | | Full archive | 1-119 | ~70,000 | ~20,000,000 | | |
| + | ||
| + | Full archive runtime estimate: 4-6 hours (dominated by CSV downloads and | |
| + | batch DB inserts). The ingest is fully resumable - the `seen` table prevents | |
| + | re-processing any rollcall already ingested. | |
| + | ||
| + | SQLite comfortably handles 20M rows in `vote_positions` with proper indexes; | |
| + | the `year` and `bioguide` indexes keep member-year queries under 5ms. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Year-index example query | |
| + | ||
| + | ```python | |
| + | from ingest.pollers.voteview import votes_by_member_year | |
| + | ||
| + | # Jerry Nadler (D-NY, bioguide N000002) votes in 2023 | |
| + | rows = votes_by_member_year("N000002", 2023) | |
| + | for r in rows[:5]: | |
| + | print(r["date"], r["chamber"], r["roll_number"], r["question"], | |
| + | r["result"], "→", r["position"]) | |
| + | ``` | |
| + | ||
| + | Example output (after ingest of congress 118): | |
| + | ``` | |
| + | 2023-01-09 house 5 On Ordering the Previous Question Agreed to → Nay | |
| + | 2023-01-09 house 6 On Adopting the Rules of the 118th Congress Agreed to → Nay | |
| + | 2023-01-10 house 10 On Passage Passed → Nay | |
| + | ... | |
| + | ``` | |
| + | ||
| + | To get all votes in a specific year regardless of member: | |
| + | ```python | |
| + | from ingest.pollers.voteview import votes_in_year | |
| + | all_2023 = votes_in_year(2023) | |
| + | print(f"{len(all_2023)} recorded votes in 2023") | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Files | |
| + | ||
| + | | File | Purpose | | |
| + | |------|---------| | |
| + | | `ingest/pollers/voteview.py` | Main poller (this module) | | |
| + | | `tests/test_voteview.py` | 48 pytest tests (cast_code, crosswalk, schema, year-index, URL validation) | | |
| + | | `VOTEVIEW_NOTES.md` | This document | |
| @@ -0,0 +1,192 @@ | ||
| + | # Docket Watcher - Notes | |
| + | ||
| + | ## What it does | |
| + | ||
| + | `ingest/watcher.py` wraps the existing pipeline (`ingest.run`) and records | |
| + | what changed since the previous cycle: items ADDED (new external_id seen for the | |
| + | first time), UPDATED (same item, newer update date), or REMOVED (a full-set source | |
| + | stopped returning an item that was previously seen). | |
| + | ||
| + | Results are stored in `change_events` and are the foundation of Docket's | |
| + | "what just changed" feed and future alerts. | |
| + | ||
| + | ## Run modes | |
| + | ||
| + | ```bash | |
| + | # Single cycle - designed for systemd timer (or manual testing) | |
| + | python -m ingest.watcher --once | |
| + | ||
| + | # Continuous loop with 30-minute pause | |
| + | python -m ingest.watcher --loop --interval 1800 | |
| + | ||
| + | # Filter to a single source (fast; good for testing without full pipeline) | |
| + | python -m ingest.watcher --once --sources federal_register | |
| + | ||
| + | # Multiple sources | |
| + | python -m ingest.watcher --once --sources members openomb | |
| + | ||
| + | # Override DB path | |
| + | python -m ingest.watcher --once --db /tmp/test.db | |
| + | ``` | |
| + | ||
| + | ## Watcher-owned tables | |
| + | ||
| + | All three tables are created by the watcher (`_ensure_watcher_tables`); they | |
| + | are **additive** and never touch existing schema. | |
| + | ||
| + | | Table | Purpose | | |
| + | |---|---| | |
| + | | `change_events` | Feed of ADDED / UPDATED / REMOVED events (the primary output) | | |
| + | | `poller_watermarks` | Last-run timestamp + cycle ID per source (for operational dashboards) | | |
| + | | `watcher_manifest` | Current-set snapshot for full-set sources (used for removal diffing) | | |
| + | ||
| + | ### change_events schema | |
| + | ||
| + | ```sql | |
| + | CREATE TABLE change_events ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | ts TEXT NOT NULL, -- ISO 8601 UTC timestamp of this cycle | |
| + | source TEXT NOT NULL, -- pipeline step label (e.g. federal_register) | |
| + | ref_type TEXT NOT NULL, -- normalized type (bill/award/member/…) | |
| + | ref_id TEXT NOT NULL, -- external_id from the source | |
| + | change TEXT NOT NULL CHECK(change IN ('added','updated','removed')), | |
| + | summary TEXT, -- human-readable one-liner | |
| + | source_url TEXT NOT NULL -- official .gov URL (neutrality rule #1) | |
| + | ); | |
| + | ``` | |
| + | ||
| + | ## ADDED / UPDATED detection | |
| + | ||
| + | The watcher snapshots the `seen` table (which tracks `(source, external_id, | |
| + | update_date)` for every item processed by `mark_seen`) before and after the | |
| + | pipeline run: | |
| + | ||
| + | - **ADDED** - new `(source, external_id)` appeared in the post-run snapshot. | |
| + | - **UPDATED** - same `(source, external_id)`, but `update_date` changed. | |
| + | ||
| + | This covers every poller that calls `mark_seen()` (federal_register, openomb, | |
| + | usaspending, treasury, congress, govinfo, bills_bulk, bill_versions, votes_senate, | |
| + | votes_house, crec, lobbying, openstates, legiscan). | |
| + | ||
| + | The `members` poller does a blind upsert without `mark_seen`, so its ADDED events | |
| + | come from the manifest path instead (see below). | |
| + | ||
| + | ## REMOVED detection - full-set sources only | |
| + | ||
| + | **Why only full-set?** Paginated or time-windowed sources (federal_register fetches | |
| + | last-7-days, usaspending returns pages of awards, etc.) cannot distinguish "item | |
| + | not in this page" from "item genuinely removed". Flagging removal there would | |
| + | produce a constant stream of false positives. | |
| + | ||
| + | **Full-set sources** (configured in `FULL_SET_CONFIGS`): | |
| + | ||
| + | | Source | Data table | Rationale | | |
| + | |---|---|---| | |
| + | | `openomb` | `apportionments` | OMB index returns all active FY apportionment files | | |
| + | | `members` | `members` | `legislators-current.json` is the authoritative live roster | | |
| + | ||
| + | The watcher maintains a `watcher_manifest` table: after each successful cycle it | |
| + | records every `(source, external_id)` pair that was present in the data table. | |
| + | The next cycle diffs the new set against this manifest. | |
| + | ||
| + | ### Safety guards | |
| + | ||
| + | Two guards prevent false REMOVED events: | |
| + | ||
| + | 1. **Non-empty guard** - if `_query_current_set()` returns an empty dict for a | |
| + | full-set source, the watcher writes a WARNING log and skips removal detection | |
| + | for that source. This handles: network failures, table not yet created, source | |
| + | returning unexpectedly empty HTML, etc. The existing manifest is preserved | |
| + | unchanged. | |
| + | ||
| + | 2. **First-run guard** - if there is no prior manifest entry for a source (first | |
| + | ever cycle), no items are flagged as removed. Without a baseline, there is | |
| + | nothing to diff against. | |
| + | ||
| + | Together these ensure: a failed pull or a fresh deployment can never generate a | |
| + | flood of spurious REMOVED events. | |
| + | ||
| + | ## Watermarks | |
| + | ||
| + | `poller_watermarks` records `(source, last_run_at, cycle_id)` after every cycle. | |
| + | Use it to tell an external dashboard "openomb was last checked at T". | |
| + | The `cycle_id` is a monotonically increasing integer that ties `change_events` | |
| + | and `watcher_manifest` entries to the same logical run. | |
| + | ||
| + | ## systemd install on CT247 | |
| + | ||
| + | ```bash | |
| + | # 1. Copy unit files | |
| + | cp /shared/projects/docket/services/ingest/deploy/docket-watcher.service \ | |
| + | /etc/systemd/system/ | |
| + | cp /shared/projects/docket/services/ingest/deploy/docket-watcher.timer \ | |
| + | /etc/systemd/system/ | |
| + | ||
| + | # 2. Reload and enable | |
| + | systemctl daemon-reload | |
| + | systemctl enable --now docket-watcher.timer | |
| + | ||
| + | # 3. Verify | |
| + | systemctl status docket-watcher.timer | |
| + | journalctl -u docket-watcher -f | |
| + | ||
| + | # 4. Trigger a manual cycle immediately (for testing) | |
| + | systemctl start docket-watcher.service | |
| + | ``` | |
| + | ||
| + | To check the timer schedule: | |
| + | ||
| + | ```bash | |
| + | systemctl list-timers docket-watcher.timer | |
| + | ``` | |
| + | ||
| + | To query recent change events from the DB: | |
| + | ||
| + | ```bash | |
| + | sqlite3 /opt/docket/data/docket.db \ | |
| + | "SELECT ts, source, ref_type, change, ref_id FROM change_events ORDER BY id DESC LIMIT 20;" | |
| + | ``` | |
| + | ||
| + | ## Testing a single cycle (without full pipeline) | |
| + | ||
| + | ```bash | |
| + | cd /shared/projects/docket/services/ingest | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | .venv/bin/python -m ingest.watcher --once --sources federal_register | |
| + | ``` | |
| + | ||
| + | Or via the exec command from the overseer notes: | |
| + | ||
| + | ```bash | |
| + | ssh root@REDACTED-IP "pct exec 247 -- bash -lc \ | |
| + | 'cd /shared/projects/docket/services/ingest && \ | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | /opt/docket/services/ingest/.venv/bin/python -m ingest.watcher --once \ | |
| + | --sources federal_register'" | |
| + | ``` | |
| + | ||
| + | ## Running the unit tests | |
| + | ||
| + | ```bash | |
| + | cd /shared/projects/docket/services/ingest | |
| + | .venv/bin/pytest tests/test_watcher.py -v | |
| + | ``` | |
| + | ||
| + | ## Adding a new full-set source | |
| + | ||
| + | 1. Confirm the source's poll() always returns the **complete** current live set | |
| + | (not a time window, not paginated results, not a delta feed). | |
| + | 2. Add an entry to `FULL_SET_CONFIGS` in `ingest/watcher.py`: | |
| + | ```python | |
| + | "source_label": { | |
| + | "table": "my_table", | |
| + | "id_col": "external_id", # or the PK column name | |
| + | "where": "source = 'source_label'", # or None if table is single-source | |
| + | "url_col": "source_url", | |
| + | "ref_type": "my_type", | |
| + | }, | |
| + | ``` | |
| + | 3. If the source does not call `mark_seen()`, ensure it is NOT excluded from | |
| + | the ADDED-via-manifest path (remove it from the exclusion list in | |
| + | `_detect_full_set_changes`). | |
| + | 4. Add a test in `tests/test_watcher.py`. |
| @@ -0,0 +1,36 @@ | ||
| + | [Unit] | |
| + | Description=Docket ingest watcher - single cycle (ADDED/UPDATED/REMOVED detection) | |
| + | Documentation=file:/shared/projects/docket/services/ingest/WATCHER_NOTES.md | |
| + | # Triggered by docket-watcher.timer; designed for one-shot use per timer tick. | |
| + | # For continuous mode, change ExecStart to: python -m ingest.watcher --loop --interval 1800 | |
| + | After=network-online.target | |
| + | Wants=network-online.target | |
| + | ||
| + | [Service] | |
| + | Type=oneshot | |
| + | User=root | |
| + | WorkingDirectory=/opt/docket/services/ingest | |
| + | ||
| + | # Python venv + module invocation | |
| + | ExecStart=/opt/docket/services/ingest/.venv/bin/python -m ingest.watcher --once | |
| + | ||
| + | # DB lives in a local (non-CIFS) path - SQLite requirement | |
| + | Environment=DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| + | ||
| + | # Keys (optional - pollers skip gracefully when absent) | |
| + | EnvironmentFile=-/opt/docket/.env | |
| + | ||
| + | StandardOutput=journal | |
| + | StandardError=journal | |
| + | SyslogIdentifier=docket-watcher | |
| + | ||
| + | # Restart on failure (but not on clean exit - oneshot) | |
| + | Restart=on-failure | |
| + | RestartSec=60s | |
| + | ||
| + | # Resource limits | |
| + | TimeoutStartSec=600 | |
| + | Nice=10 | |
| + | ||
| + | [Install] | |
| + | WantedBy=multi-user.target |
| @@ -0,0 +1,17 @@ | ||
| + | [Unit] | |
| + | Description=Docket ingest watcher - run every 30 minutes | |
| + | Documentation=file:/shared/projects/docket/services/ingest/WATCHER_NOTES.md | |
| + | ||
| + | [Timer] | |
| + | # Fire 30 s after boot (gives network and other services time to settle) | |
| + | OnBootSec=30s | |
| + | # Then every 30 minutes | |
| + | OnUnitActiveSec=30min | |
| + | # Persist across reboots - catch up on any missed fires | |
| + | Persistent=true | |
| + | # Randomise start within a 2-minute window to avoid thundering-herd | |
| + | RandomizedDelaySec=120 | |
| + | Unit=docket-watcher.service | |
| + | ||
| + | [Install] | |
| + | WantedBy=timers.target |
| @@ -0,0 +1,749 @@ | ||
| + | """ | |
| + | Calendar aggregator - builds `calendar_events` from all ingest tables. | |
| + | ||
| + | Each event carries a date and a kind: | |
| + | effective - Federal Register final rules with a structured effective_on date | |
| + | enacted - Bills that became public law (actions.BecameLaw rows) | |
| + | vote - Roll-call vote dates (votes table) | |
| + | introduced - Bill introduction dates (actions.IntroReferral) | |
| + | committee - Committee referral/action dates (actions.Committee) | |
| + | floor - Floor action dates (actions.Floor) | |
| + | apportionment - Apportionment dates (apportionments table) | |
| + | effective_parsed - Effective dates parsed from bill/action text via regex (confidence 0.5) | |
| + | ||
| + | Motivating use-case: "Bill X goes into effect August 5" appears on Aug 5 in the | |
| + | calendar, sourced from the Federal Register `effective_on` field. | |
| + | ||
| + | Run once: | |
| + | python -m ingest.calendar --once | |
| + | ||
| + | API surface for future /api/calendar endpoint: | |
| + | SELECT * FROM calendar_events | |
| + | WHERE date BETWEEN ? AND ? | |
| + | ORDER BY date, kind | |
| + | """ | |
| + | ||
| + | import argparse | |
| + | import logging | |
| + | import re | |
| + | import sqlite3 | |
| + | import sys | |
| + | import time | |
| + | from datetime import datetime, timedelta | |
| + | 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 = "calendar" | |
| + | FR_BASE = "https://www.federalregister.gov/api/v1/documents.json" | |
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Effective-date regex patterns | |
| + | # --------------------------------------------------------------------------- | |
| + | # Applied to bill / action text to extract explicit calendar dates from | |
| + | # clauses like: | |
| + | # "shall take effect on January 1, 2026" | |
| + | # "effective January 1, 2026" | |
| + | # "this Act takes effect on 2026-01-01" | |
| + | # Only SPECIFIC calendar dates are extracted; relative clauses such as | |
| + | # "180 days after enactment" are intentionally skipped. | |
| + | ||
| + | _MONTH_NAMES = ( | |
| + | "January|February|March|April|May|June|" | |
| + | "July|August|September|October|November|December" | |
| + | ) | |
| + | _DATE_FRAG = ( | |
| + | r"(?P<date>" | |
| + | rf"(?:{_MONTH_NAMES})\s+\d{{1,2}},\s+\d{{4}}" | |
| + | r"|" | |
| + | r"\d{4}-\d{2}-\d{2}" | |
| + | r")" | |
| + | ) | |
| + | ||
| + | EFFECTIVE_DATE_PATTERNS: list[re.Pattern] = [ | |
| + | # "shall take effect on <date>" / "will take effect on <date>" | |
| + | re.compile(r"shall\s+take\s+effect\s+on\s+" + _DATE_FRAG, re.IGNORECASE), | |
| + | # "takes effect on <date>" | |
| + | re.compile(r"takes?\s+effect\s+on\s+" + _DATE_FRAG, re.IGNORECASE), | |
| + | # "this Act ... takes effect <date>" | |
| + | re.compile(r"this\s+Act\b.*?\btakes?\s+effect\s+" + _DATE_FRAG, re.IGNORECASE), | |
| + | # "effective on <date>" | |
| + | re.compile(r"effective\s+on\s+" + _DATE_FRAG, re.IGNORECASE), | |
| + | # "effective date is <date>" / "effective date shall be <date>" | |
| + | re.compile(r"effective\s+date\s+(?:is|shall\s+be)\s+" + _DATE_FRAG, re.IGNORECASE), | |
| + | # "effective <date>" (no "on") - lowest specificity, listed last | |
| + | re.compile(r"\beffective\s+" + _DATE_FRAG, re.IGNORECASE), | |
| + | ] | |
| + | ||
| + | ||
| + | def parse_effective_dates(text: str) -> list[str]: | |
| + | """ | |
| + | Extract explicit calendar dates from effective-date clauses in *text*. | |
| + | ||
| + | Returns ISO-8601 date strings (YYYY-MM-DD), deduplicated. Only specific | |
| + | calendar dates are extracted; relative expressions ("X days after enactment") | |
| + | are not returned. | |
| + | """ | |
| + | found: list[str] = [] | |
| + | seen: set[str] = set() | |
| + | for pattern in EFFECTIVE_DATE_PATTERNS: | |
| + | for m in pattern.finditer(text): | |
| + | raw = m.group("date").strip() | |
| + | iso = _normalize_date(raw) | |
| + | if iso and iso not in seen: | |
| + | found.append(iso) | |
| + | seen.add(iso) | |
| + | return found | |
| + | ||
| + | ||
| + | def _normalize_date(raw: str) -> Optional[str]: | |
| + | """Convert a free-form date string to ISO-8601 YYYY-MM-DD, or None.""" | |
| + | raw = raw.strip() | |
| + | if re.match(r"^\d{4}-\d{2}-\d{2}$", raw): | |
| + | return raw | |
| + | for fmt in ("%B %d, %Y", "%b %d, %Y"): | |
| + | try: | |
| + | return datetime.strptime(raw, fmt).strftime("%Y-%m-%d") | |
| + | except ValueError: | |
| + | pass | |
| + | return None | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Schema | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def ensure_schema(conn: sqlite3.Connection) -> None: | |
| + | """ | |
| + | Create calendar_events and ensure executive_actions exists with effective_on. | |
| + | ||
| + | * Creates calendar_events table + indexes. | |
| + | * Creates executive_actions if absent (with effective_on from the start). | |
| + | * Adds effective_on column via ALTER TABLE if the table already exists but | |
| + | was created by the federal_register poller without that column. | |
| + | """ | |
| + | conn.executescript(""" | |
| + | CREATE TABLE IF NOT EXISTS calendar_events ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | date TEXT NOT NULL, | |
| + | kind TEXT NOT NULL CHECK(kind IN ( | |
| + | 'effective','enacted','vote', | |
| + | 'introduced','committee','floor', | |
| + | 'apportionment','effective_parsed')), | |
| + | ref_type TEXT NOT NULL, | |
| + | ref_id INTEGER, | |
| + | title TEXT, | |
| + | jurisdiction TEXT NOT NULL DEFAULT 'US', | |
| + | source_url TEXT NOT NULL, | |
| + | confidence REAL NOT NULL DEFAULT 1.0, | |
| + | UNIQUE (kind, ref_type, ref_id, date) | |
| + | ); | |
| + | ||
| + | CREATE INDEX IF NOT EXISTS idx_calendar_events_date | |
| + | ON calendar_events(date); | |
| + | ||
| + | CREATE INDEX IF NOT EXISTS idx_calendar_events_kind | |
| + | ON calendar_events(kind); | |
| + | ||
| + | -- Create executive_actions with effective_on if it doesn't exist yet. | |
| + | -- The federal_register poller creates this table without effective_on; | |
| + | -- the ALTER TABLE below handles that case. | |
| + | CREATE TABLE IF NOT EXISTS executive_actions ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | source TEXT NOT NULL DEFAULT 'federal_register', | |
| + | external_id TEXT NOT NULL, | |
| + | type TEXT NOT NULL, | |
| + | title TEXT, | |
| + | agency TEXT, | |
| + | fr_doc_number TEXT, | |
| + | date TEXT, | |
| + | effective_on TEXT, | |
| + | source_url TEXT NOT NULL, | |
| + | UNIQUE (source, external_id) | |
| + | ); | |
| + | """) | |
| + | conn.commit() | |
| + | ||
| + | # Add effective_on to executive_actions if it was created by the FR poller | |
| + | # without that column (SQLite ALTER TABLE has no IF NOT EXISTS - use try/except). | |
| + | try: | |
| + | conn.execute( | |
| + | "ALTER TABLE executive_actions ADD COLUMN effective_on TEXT" | |
| + | ) | |
| + | conn.commit() | |
| + | logger.info("calendar: added effective_on column to executive_actions") | |
| + | except sqlite3.OperationalError: | |
| + | pass # column already exists | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # FR enrichment - populate effective_on for stored final_rule rows | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def enrich_fr_effective_on(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | For every row in executive_actions where effective_on IS NULL, fetch | |
| + | the effective_on date from the Federal Register API in batches of 50. | |
| + | ||
| + | Returns the number of rows updated. | |
| + | """ | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT external_id | |
| + | FROM executive_actions | |
| + | WHERE effective_on IS NULL | |
| + | """, | |
| + | ).fetchall() | |
| + | ||
| + | if not rows: | |
| + | logger.info("calendar: no executive_actions rows need effective_on enrichment") | |
| + | return 0 | |
| + | ||
| + | doc_numbers = [r["external_id"] for r in rows] | |
| + | logger.info("calendar: enriching effective_on for %d FR docs", len(doc_numbers)) | |
| + | ||
| + | updated = 0 | |
| + | batch_size = 50 | |
| + | ||
| + | for i in range(0, len(doc_numbers), batch_size): | |
| + | batch = doc_numbers[i : i + batch_size] | |
| + | params: list[tuple[str, str]] = [] | |
| + | for field in ["document_number", "effective_on"]: | |
| + | params.append(("fields[]", field)) | |
| + | for dn in batch: | |
| + | params.append(("conditions[document_numbers][]", dn)) | |
| + | params.append(("per_page", str(batch_size))) | |
| + | ||
| + | try: | |
| + | resp = requests.get(FR_BASE, params=params, timeout=30) | |
| + | resp.raise_for_status() | |
| + | data = resp.json() | |
| + | except requests.RequestException as exc: | |
| + | logger.error("calendar: FR enrichment request failed: %s", exc) | |
| + | time.sleep(2) | |
| + | continue | |
| + | ||
| + | for doc in data.get("results", []): | |
| + | dn = doc.get("document_number") | |
| + | eff = doc.get("effective_on") # may be null | |
| + | if dn: | |
| + | conn.execute( | |
| + | """ | |
| + | UPDATE executive_actions | |
| + | SET effective_on = ? | |
| + | WHERE external_id = ? AND effective_on IS NULL | |
| + | """, | |
| + | (eff, dn), | |
| + | ) | |
| + | if eff: | |
| + | updated += 1 | |
| + | ||
| + | conn.commit() | |
| + | time.sleep(0.2) # polite rate limiting | |
| + | ||
| + | logger.info("calendar: enriched effective_on for %d docs", updated) | |
| + | return updated | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Fresh FR fetch - pull recent final_rule docs with effective_on | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def fetch_fr_rules_with_effective( | |
| + | conn: sqlite3.Connection, | |
| + | days_back: int = 60, | |
| + | ) -> int: | |
| + | """ | |
| + | Fetch recent Federal Register RULE documents and store/update them in | |
| + | executive_actions, including the effective_on field. | |
| + | ||
| + | This supplements the main federal_register poller which does not request | |
| + | effective_on. Only RULE (final rule) documents are fetched here because | |
| + | those are the documents that legally go into effect on a specific date. | |
| + | ||
| + | Returns number of rows upserted. | |
| + | """ | |
| + | start_date = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d") | |
| + | logger.info("calendar: fetching FR final rules since %s for effective dates", start_date) | |
| + | ||
| + | stored = 0 | |
| + | page = 1 | |
| + | ||
| + | while True: | |
| + | params: list[tuple[str, str]] = [] | |
| + | for field in [ | |
| + | "document_number", "type", "title", "agencies", | |
| + | "publication_date", "effective_on", "html_url", | |
| + | ]: | |
| + | params.append(("fields[]", field)) | |
| + | params += [ | |
| + | ("conditions[type][]", "RULE"), | |
| + | ("conditions[publication_date][gte]", start_date), | |
| + | ("per_page", "100"), | |
| + | ("order", "newest"), | |
| + | ("page", str(page)), | |
| + | ] | |
| + | ||
| + | try: | |
| + | resp = requests.get(FR_BASE, params=params, timeout=30) | |
| + | resp.raise_for_status() | |
| + | data = resp.json() | |
| + | except requests.RequestException as exc: | |
| + | logger.error("calendar: FR rules fetch failed: %s", exc) | |
| + | break | |
| + | ||
| + | results = data.get("results", []) | |
| + | if not results: | |
| + | break | |
| + | ||
| + | for doc in results: | |
| + | dn = doc.get("document_number") or "" | |
| + | eff = doc.get("effective_on") # may be null - that's fine | |
| + | pub_date = doc.get("publication_date") or "" | |
| + | title = doc.get("title") or "" | |
| + | html_url = doc.get("html_url") or "" | |
| + | agencies = doc.get("agencies") or [] | |
| + | ||
| + | if not dn or not html_url: | |
| + | continue | |
| + | ||
| + | try: | |
| + | validate_source_url(html_url) | |
| + | except Exception: | |
| + | continue | |
| + | ||
| + | agency_str = "; ".join( | |
| + | a.get("name", "") for a in agencies if a.get("name") | |
| + | ) | |
| + | ||
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO executive_actions | |
| + | (source, external_id, type, title, agency, | |
| + | fr_doc_number, date, effective_on, source_url) | |
| + | VALUES ('federal_register', ?, 'final_rule', ?, ?, | |
| + | ?, ?, ?, ?) | |
| + | ON CONFLICT(source, external_id) DO UPDATE SET | |
| + | effective_on = COALESCE(excluded.effective_on, | |
| + | executive_actions.effective_on), | |
| + | title = excluded.title, | |
| + | agency = excluded.agency, | |
| + | date = excluded.date, | |
| + | source_url = excluded.source_url | |
| + | """, | |
| + | (dn, title, agency_str, dn, pub_date, eff, html_url), | |
| + | ) | |
| + | stored += 1 | |
| + | ||
| + | conn.commit() | |
| + | total_pages = data.get("total_pages", 1) | |
| + | if page >= total_pages or page >= 10: | |
| + | break | |
| + | page += 1 | |
| + | time.sleep(0.2) | |
| + | ||
| + | logger.info("calendar: upserted %d FR rule rows with effective_on", stored) | |
| + | return stored | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Aggregation helpers | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _upsert_event( | |
| + | conn: sqlite3.Connection, | |
| + | date: str, | |
| + | kind: str, | |
| + | ref_type: str, | |
| + | ref_id: Optional[int], | |
| + | title: str, | |
| + | source_url: str, | |
| + | jurisdiction: str = "US", | |
| + | confidence: float = 1.0, | |
| + | ) -> None: | |
| + | """Insert or replace one calendar event row.""" | |
| + | if not date or not source_url: | |
| + | return | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO calendar_events | |
| + | (date, kind, ref_type, ref_id, title, | |
| + | jurisdiction, source_url, confidence) | |
| + | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| + | ON CONFLICT(kind, ref_type, ref_id, date) DO UPDATE SET | |
| + | title = excluded.title, | |
| + | source_url = excluded.source_url, | |
| + | confidence = excluded.confidence | |
| + | """, | |
| + | (date, kind, ref_type, ref_id, title[:500] if title else "", | |
| + | jurisdiction, source_url, confidence), | |
| + | ) | |
| + | ||
| + | ||
| + | def _aggregate_effective(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | effective - Federal Register final rules with a structured effective_on date. | |
| + | ||
| + | This is the canonical "goes into effect on X" source. Only rows where | |
| + | effective_on IS NOT NULL are included. | |
| + | """ | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT id, effective_on, title, source_url, agency | |
| + | FROM executive_actions | |
| + | WHERE effective_on IS NOT NULL | |
| + | AND type IN ('final_rule', 'RULE') | |
| + | AND effective_on != '' | |
| + | ORDER BY effective_on | |
| + | """ | |
| + | ).fetchall() | |
| + | ||
| + | count = 0 | |
| + | for r in rows: | |
| + | title = r["title"] or "" | |
| + | if r["agency"]: | |
| + | title = f"{title} ({r['agency']})" if title else r["agency"] | |
| + | _upsert_event( | |
| + | conn, | |
| + | date=r["effective_on"], | |
| + | kind="effective", | |
| + | ref_type="executive_action", | |
| + | ref_id=r["id"], | |
| + | title=title, | |
| + | source_url=r["source_url"], | |
| + | ) | |
| + | count += 1 | |
| + | ||
| + | logger.info("calendar: %d effective events from FR rules", count) | |
| + | return count | |
| + | ||
| + | ||
| + | def _aggregate_enacted(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | enacted - Bills that became public law, sourced from actions.BecameLaw rows. | |
| + | """ | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT DISTINCT a.bill_id, a.action_date, a.source_url, b.title, a.text | |
| + | FROM actions a | |
| + | LEFT JOIN bills b ON b.id = a.bill_id | |
| + | WHERE a.action_type = 'BecameLaw' | |
| + | AND a.action_date != '' | |
| + | AND a.action_date IS NOT NULL | |
| + | ORDER BY a.action_date | |
| + | """ | |
| + | ).fetchall() | |
| + | ||
| + | count = 0 | |
| + | for r in rows: | |
| + | title = r["title"] or r["text"] or "" | |
| + | _upsert_event( | |
| + | conn, | |
| + | date=r["action_date"], | |
| + | kind="enacted", | |
| + | ref_type="bill", | |
| + | ref_id=r["bill_id"], | |
| + | title=title, | |
| + | source_url=r["source_url"], | |
| + | ) | |
| + | count += 1 | |
| + | ||
| + | logger.info("calendar: %d enacted events from actions.BecameLaw", count) | |
| + | return count | |
| + | ||
| + | ||
| + | def _aggregate_votes(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | vote - Roll-call vote dates from the votes table. | |
| + | ||
| + | Only references columns in the base schema (init_schema). Extended columns | |
| + | (question, description) may or may not exist depending on which pollers | |
| + | have run. | |
| + | """ | |
| + | # Discover which extended columns are present so the query stays portable | |
| + | # across fresh test DBs (base schema only) and production DBs (extended). | |
| + | existing_cols = { | |
| + | r[1] | |
| + | for r in conn.execute("PRAGMA table_info(votes)") | |
| + | } | |
| + | ||
| + | extras = [] | |
| + | for col in ("question", "description"): | |
| + | if col in existing_cols: | |
| + | extras.append(col) | |
| + | ||
| + | extra_select = (", " + ", ".join(extras)) if extras else "" | |
| + | ||
| + | rows = conn.execute( | |
| + | f""" | |
| + | SELECT id, date, result, source_url, bill_id{extra_select} | |
| + | FROM votes | |
| + | WHERE date IS NOT NULL | |
| + | AND date != '' | |
| + | ORDER BY date | |
| + | """ | |
| + | ).fetchall() | |
| + | ||
| + | count = 0 | |
| + | for r in rows: | |
| + | # Use richer fields if available, fall back to result | |
| + | title = r["result"] or "Roll-call vote" | |
| + | for col in ("question", "description"): | |
| + | if col in existing_cols and r[col]: | |
| + | title = r[col] | |
| + | break | |
| + | _upsert_event( | |
| + | conn, | |
| + | date=r["date"], | |
| + | kind="vote", | |
| + | ref_type="vote", | |
| + | ref_id=r["id"], | |
| + | title=title, | |
| + | source_url=r["source_url"], | |
| + | ) | |
| + | count += 1 | |
| + | ||
| + | logger.info("calendar: %d vote events from votes table", count) | |
| + | return count | |
| + | ||
| + | ||
| + | def _aggregate_bill_actions(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | introduced / committee / floor - key bill action dates from actions table. | |
| + | ||
| + | Mapping: | |
| + | IntroReferral → introduced | |
| + | Committee → committee | |
| + | Floor → floor | |
| + | """ | |
| + | kind_map = { | |
| + | "IntroReferral": "introduced", | |
| + | "Committee": "committee", | |
| + | "Floor": "floor", | |
| + | } | |
| + | ||
| + | count = 0 | |
| + | for action_type, kind in kind_map.items(): | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT DISTINCT a.id, a.bill_id, a.action_date, | |
| + | a.source_url, a.text, b.title | |
| + | FROM actions a | |
| + | LEFT JOIN bills b ON b.id = a.bill_id | |
| + | WHERE a.action_type = ? | |
| + | AND a.action_date IS NOT NULL | |
| + | AND a.action_date != '' | |
| + | ORDER BY a.action_date | |
| + | """, | |
| + | (action_type,), | |
| + | ).fetchall() | |
| + | ||
| + | for r in rows: | |
| + | title = r["title"] or r["text"] or "" | |
| + | # Truncate action text for display; bill title is better | |
| + | if not r["title"] and r["text"]: | |
| + | title = r["text"][:200] | |
| + | _upsert_event( | |
| + | conn, | |
| + | date=r["action_date"], | |
| + | kind=kind, | |
| + | ref_type="action", | |
| + | ref_id=r["id"], | |
| + | title=title, | |
| + | source_url=r["source_url"], | |
| + | ) | |
| + | count += 1 | |
| + | ||
| + | logger.info("calendar: %d introduced/committee/floor events", count) | |
| + | return count | |
| + | ||
| + | ||
| + | def _aggregate_apportionments(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | apportionment - apportionment dates from the apportionments table. | |
| + | """ | |
| + | # Check the apportionments table exists | |
| + | tbl = conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='table' AND name='apportionments'" | |
| + | ).fetchone() | |
| + | if tbl is None: | |
| + | logger.info("calendar: apportionments table not found - skipping") | |
| + | return 0 | |
| + | ||
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT id, date, agency, account, status, source_url | |
| + | FROM apportionments | |
| + | WHERE date IS NOT NULL | |
| + | AND date != '' | |
| + | ORDER BY date | |
| + | """ | |
| + | ).fetchall() | |
| + | ||
| + | count = 0 | |
| + | for r in rows: | |
| + | title_parts = [] | |
| + | if r["agency"]: | |
| + | title_parts.append(r["agency"]) | |
| + | if r["account"]: | |
| + | title_parts.append(r["account"]) | |
| + | if r["status"]: | |
| + | title_parts.append(f"({r['status']})") | |
| + | title = " - ".join(title_parts) if title_parts else "Apportionment" | |
| + | _upsert_event( | |
| + | conn, | |
| + | date=r["date"], | |
| + | kind="apportionment", | |
| + | ref_type="apportionment", | |
| + | ref_id=r["id"], | |
| + | title=title, | |
| + | source_url=r["source_url"], | |
| + | ) | |
| + | count += 1 | |
| + | ||
| + | logger.info("calendar: %d apportionment events", count) | |
| + | return count | |
| + | ||
| + | ||
| + | def _aggregate_effective_parsed(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | effective_parsed - Effective dates parsed from bill/action text via regex. | |
| + | ||
| + | Scans: | |
| + | 1. actions.text for all BecameLaw / President-signed rows (short text) | |
| + | 2. Skips full bill XML fetching (expensive, out of scope for Phase 1) | |
| + | ||
| + | Confidence: 0.5 (regex inference, not a structured field). | |
| + | """ | |
| + | # Focus on action texts that accompany enacted bills - they sometimes carry | |
| + | # the effective date in legislative boilerplate. | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT a.id, a.bill_id, a.action_date, a.source_url, a.text, b.title | |
| + | FROM actions a | |
| + | LEFT JOIN bills b ON b.id = a.bill_id | |
| + | WHERE a.text IS NOT NULL | |
| + | AND a.text != '' | |
| + | AND a.action_type IN ('BecameLaw', 'President', 'Floor', 'IntroReferral') | |
| + | """ | |
| + | ).fetchall() | |
| + | ||
| + | count = 0 | |
| + | for r in rows: | |
| + | dates = parse_effective_dates(r["text"]) | |
| + | for iso in dates: | |
| + | title = r["title"] or r["text"][:200] or "" | |
| + | _upsert_event( | |
| + | conn, | |
| + | date=iso, | |
| + | kind="effective_parsed", | |
| + | ref_type="action", | |
| + | ref_id=r["id"], | |
| + | title=f"[parsed] {title}", | |
| + | source_url=r["source_url"], | |
| + | confidence=0.5, | |
| + | ) | |
| + | count += 1 | |
| + | ||
| + | logger.info("calendar: %d effective_parsed events from action text regex", count) | |
| + | return count | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Main entry point | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def run(db_path: Optional[str] = None) -> dict[str, int]: | |
| + | """ | |
| + | (Re)build calendar_events from all current DB contents. | |
| + | ||
| + | Idempotent: safe to run repeatedly. Each run: | |
| + | 1. Ensures schema (calendar_events table + effective_on column) | |
| + | 2. Fetches recent FR final rules with effective_on | |
| + | 3. Enriches any remaining executive_actions without effective_on | |
| + | 4. Aggregates all event kinds into calendar_events | |
| + | ||
| + | Returns a dict of kind → count for reporting. | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | init_schema(conn) | |
| + | ensure_schema(conn) | |
| + | ||
| + | # Step 1: ensure executive_actions has up-to-date effective_on data | |
| + | fetch_fr_rules_with_effective(conn, days_back=90) | |
| + | enrich_fr_effective_on(conn) | |
| + | ||
| + | # Step 2: aggregate all event kinds | |
| + | counts: dict[str, int] = {} | |
| + | ||
| + | counts["effective"] = _aggregate_effective(conn) | |
| + | counts["enacted"] = _aggregate_enacted(conn) | |
| + | counts["vote"] = _aggregate_votes(conn) | |
| + | ||
| + | # Bill action types - one call handles all three | |
| + | bill_action_total = _aggregate_bill_actions(conn) | |
| + | # Disaggregate for reporting | |
| + | for kind in ("introduced", "committee", "floor"): | |
| + | c = conn.execute( | |
| + | "SELECT COUNT(*) FROM calendar_events WHERE kind=?", (kind,) | |
| + | ).fetchone()[0] | |
| + | counts[kind] = c | |
| + | ||
| + | counts["apportionment"] = _aggregate_apportionments(conn) | |
| + | counts["effective_parsed"] = _aggregate_effective_parsed(conn) | |
| + | ||
| + | conn.commit() | |
| + | ||
| + | total = sum(counts.values()) | |
| + | logger.info( | |
| + | "calendar: run complete - %d total calendar_events across %d kinds", | |
| + | total, | |
| + | len(counts), | |
| + | ) | |
| + | ||
| + | conn.close() | |
| + | return counts | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # CLI | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | stream=sys.stdout, | |
| + | ) | |
| + | ||
| + | ap = argparse.ArgumentParser( | |
| + | description="Docket calendar aggregator - builds calendar_events table" | |
| + | ) | |
| + | ap.add_argument( | |
| + | "--once", | |
| + | action="store_true", | |
| + | help="Run once and exit (default behaviour)", | |
| + | ) | |
| + | ap.add_argument( | |
| + | "--db", | |
| + | metavar="PATH", | |
| + | default=None, | |
| + | help="Override SQLite DB path (uses DOCKET_DB_PATH env var by default)", | |
| + | ) | |
| + | args = ap.parse_args() | |
| + | ||
| + | counts = run(db_path=args.db) | |
| + | ||
| + | print("\ncalendar_events counts by kind:") | |
| + | for kind, n in sorted(counts.items()): | |
| + | print(f" {kind:<20} {n:>6}") | |
| + | print(f" {'TOTAL':<20} {sum(counts.values()):>6}") | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -0,0 +1,651 @@ | ||
| + | """ | |
| + | ingest/categorize.py - Unified taxonomy backfill for bills and spending awards. | |
| + | ||
| + | Creates / extends: | |
| + | categories (slug, label, kind) - ~32 standard Congressional policy areas | |
| + | bill_subjects (bill_id, subject) - granular legislative subjects from BILLSTATUS XML | |
| + | awards.category (TEXT column) - deterministic agency→policy-area mapping | |
| + | ||
| + | run(db_path) backfills all three over whatever is already in the DB. | |
| + | ||
| + | Subjects are fetched on demand from GovInfo BULKDATA BILLSTATUS XML (no API key | |
| + | required) - the same source used by bills_bulk.py. Bills already processed are | |
| + | skipped on subsequent runs (idempotent). | |
| + | ||
| + | CLI: | |
| + | python -m ingest.categorize --once [--limit-subjects N] [--delay SECS] | |
| + | ||
| + | All changes are additive: no existing rows or columns are dropped. | |
| + | Categories come straight from Congress.gov's own taxonomy - no editorial groupings. | |
| + | """ | |
| + | ||
| + | from __future__ import annotations | |
| + | ||
| + | import argparse | |
| + | import logging | |
| + | import sqlite3 | |
| + | import sys | |
| + | import time | |
| + | import xml.etree.ElementTree as ET | |
| + | from typing import Iterator, Optional | |
| + | ||
| + | import requests | |
| + | ||
| + | from ingest.db import get_connection, init_schema | |
| + | ||
| + | logger = logging.getLogger(__name__) | |
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Canonical ~32 Congressional policy areas (Congress.gov standard taxonomy) | |
| + | # Source: https://www.congress.gov/help/field-values/policy-area | |
| + | # These are factual classifications used by the Congressional Research Service. | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | POLICY_AREAS: list[tuple[str, str]] = [ | |
| + | ("agriculture-and-food", "Agriculture and Food"), | |
| + | ("animals", "Animals"), | |
| + | ("armed-forces-and-national-security", "Armed Forces and National Security"), | |
| + | ("arts-culture-religion", "Arts, Culture, Religion"), | |
| + | ("civil-rights-and-liberties-minority-issues", "Civil Rights and Liberties, Minority Issues"), | |
| + | ("commerce", "Commerce"), | |
| + | ("congress", "Congress"), | |
| + | ("crime-and-law-enforcement", "Crime and Law Enforcement"), | |
| + | ("economics-and-public-finance", "Economics and Public Finance"), | |
| + | ("education", "Education"), | |
| + | ("emergency-management", "Emergency Management"), | |
| + | ("energy", "Energy"), | |
| + | ("environmental-protection", "Environmental Protection"), | |
| + | ("families", "Families"), | |
| + | ("finance-and-financial-sector", "Finance and Financial Sector"), | |
| + | ("foreign-trade-and-international-finance", "Foreign Trade and International Finance"), | |
| + | ("government-operations-and-politics", "Government Operations and Politics"), | |
| + | ("health", "Health"), | |
| + | ("housing-and-community-development", "Housing and Community Development"), | |
| + | ("immigration", "Immigration"), | |
| + | ("international-affairs", "International Affairs"), | |
| + | ("labor-and-employment", "Labor and Employment"), | |
| + | ("law", "Law"), | |
| + | ("macroeconomics", "Macroeconomics"), | |
| + | ("native-americans", "Native Americans"), | |
| + | ("public-lands-and-natural-resources", "Public Lands and Natural Resources"), | |
| + | ("science-technology-communications", "Science, Technology, Communications"), | |
| + | ("social-welfare", "Social Welfare"), | |
| + | ("sports-and-recreation", "Sports and Recreation"), | |
| + | ("taxation", "Taxation"), | |
| + | ("transportation-and-public-works", "Transportation and Public Works"), | |
| + | ("water-resources-development", "Water Resources Development"), | |
| + | ] | |
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Agency → policy-area label mapping (deterministic; aligned to the same | |
| + | # ~32-area vocabulary so bills and spending share one taxonomy). | |
| + | # Source: executive-branch agency names as they appear in USAspending.gov data. | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | AGENCY_CATEGORY_MAP: dict[str, str] = { | |
| + | # Defense / Security | |
| + | "Department of Defense": "Armed Forces and National Security", | |
| + | "Department of Veterans Affairs": "Armed Forces and National Security", | |
| + | "Department of Homeland Security": "Emergency Management", | |
| + | "Selective Service System": "Armed Forces and National Security", | |
| + | ||
| + | # Health | |
| + | "Department of Health and Human Services": "Health", | |
| + | ||
| + | # Transportation | |
| + | "Department of Transportation": "Transportation and Public Works", | |
| + | ||
| + | # Energy | |
| + | "Department of Energy": "Energy", | |
| + | ||
| + | # Education | |
| + | "Department of Education": "Education", | |
| + | ||
| + | # Agriculture / Food | |
| + | "Department of Agriculture": "Agriculture and Food", | |
| + | ||
| + | # Labor | |
| + | "Department of Labor": "Labor and Employment", | |
| + | ||
| + | # Environment | |
| + | "Environmental Protection Agency": "Environmental Protection", | |
| + | ||
| + | # Justice / Law Enforcement | |
| + | "Department of Justice": "Crime and Law Enforcement", | |
| + | ||
| + | # State / International | |
| + | "Department of State": "International Affairs", | |
| + | "Agency for International Development": "International Affairs", | |
| + | "United States Agency for International Development": "International Affairs", | |
| + | "Broadcasting Board of Governors": "International Affairs", | |
| + | "Peace Corps": "International Affairs", | |
| + | ||
| + | # Commerce / Trade | |
| + | "Department of Commerce": "Commerce", | |
| + | "Office of the United States Trade Representative": "Foreign Trade and International Finance", | |
| + | "Export-Import Bank of the United States": "Foreign Trade and International Finance", | |
| + | "International Trade Commission": "Foreign Trade and International Finance", | |
| + | ||
| + | # Finance / Treasury | |
| + | "Department of the Treasury": "Economics and Public Finance", | |
| + | "Small Business Administration": "Economics and Public Finance", | |
| + | "Securities and Exchange Commission": "Finance and Financial Sector", | |
| + | "Commodity Futures Trading Commission": "Finance and Financial Sector", | |
| + | "Federal Deposit Insurance Corporation": "Finance and Financial Sector", | |
| + | "Federal Reserve System": "Finance and Financial Sector", | |
| + | ||
| + | # Housing | |
| + | "Department of Housing and Urban Development": "Housing and Community Development", | |
| + | ||
| + | # Interior / Public Lands | |
| + | "Department of the Interior": "Public Lands and Natural Resources", | |
| + | "Forest Service": "Public Lands and Natural Resources", | |
| + | ||
| + | # Science / Technology / Space | |
| + | "National Aeronautics and Space Administration": "Science, Technology, Communications", | |
| + | "National Science Foundation": "Science, Technology, Communications", | |
| + | "National Institute of Standards and Technology": "Science, Technology, Communications", | |
| + | "Federal Communications Commission": "Science, Technology, Communications", | |
| + | ||
| + | # Social Welfare | |
| + | "Social Security Administration": "Social Welfare", | |
| + | "Corporation for National and Community Service": "Social Welfare", | |
| + | "Institute of Museum and Library Services": "Arts, Culture, Religion", | |
| + | "National Endowment for the Arts": "Arts, Culture, Religion", | |
| + | "National Endowment for the Humanities": "Arts, Culture, Religion", | |
| + | "Smithsonian Institution": "Arts, Culture, Religion", | |
| + | ||
| + | # Government Operations | |
| + | "General Services Administration": "Government Operations and Politics", | |
| + | "Office of Personnel Management": "Government Operations and Politics", | |
| + | "Executive Office of the President": "Government Operations and Politics", | |
| + | "Office of Management and Budget": "Government Operations and Politics", | |
| + | "Government Accountability Office": "Government Operations and Politics", | |
| + | "Congressional Budget Office": "Government Operations and Politics", | |
| + | "Library of Congress": "Government Operations and Politics", | |
| + | "Government Publishing Office": "Government Operations and Politics", | |
| + | "National Archives and Records Administration": "Government Operations and Politics", | |
| + | "Federal Election Commission": "Government Operations and Politics", | |
| + | "Office of Special Counsel": "Government Operations and Politics", | |
| + | "Merit Systems Protection Board": "Government Operations and Politics", | |
| + | "Equal Employment Opportunity Commission": "Civil Rights and Liberties, Minority Issues", | |
| + | "Civil Rights Division": "Civil Rights and Liberties, Minority Issues", | |
| + | "Commission on Civil Rights": "Civil Rights and Liberties, Minority Issues", | |
| + | ||
| + | # Immigration | |
| + | "U.S. Citizenship and Immigration Services": "Immigration", | |
| + | "Immigration and Customs Enforcement": "Immigration", | |
| + | "Customs and Border Protection": "Immigration", | |
| + | ||
| + | # Native Americans | |
| + | "Indian Health Service": "Native Americans", | |
| + | "Bureau of Indian Affairs": "Native Americans", | |
| + | } | |
| + | ||
| + | ||
| + | def _slug_from_label(label: str) -> str: | |
| + | """Convert a policy-area label to a URL-safe slug.""" | |
| + | return ( | |
| + | label.lower() | |
| + | .replace(",", "") | |
| + | .replace(".", "") | |
| + | .replace("'", "") | |
| + | .replace(" ", " ") | |
| + | .replace(" ", "-") | |
| + | ) | |
| + | ||
| + | ||
| + | def agency_to_category(agency: str) -> Optional[str]: | |
| + | """ | |
| + | Map an awarding agency name to a policy-area label. | |
| + | ||
| + | Exact match first; then prefix/substring match (handles sub-agency names | |
| + | that start with or contain a known department name). Returns None when no | |
| + | match is found so callers can leave the column NULL rather than guess. | |
| + | """ | |
| + | if not agency: | |
| + | return None | |
| + | # Exact | |
| + | if agency in AGENCY_CATEGORY_MAP: | |
| + | return AGENCY_CATEGORY_MAP[agency] | |
| + | # Prefix match - e.g. "Department of Defense - Navy" → "Armed Forces …" | |
| + | for key, cat in AGENCY_CATEGORY_MAP.items(): | |
| + | if agency.startswith(key): | |
| + | return cat | |
| + | # Substring / contained - e.g. "HHS Office of Inspector General" | |
| + | agency_lower = agency.lower() | |
| + | for key, cat in AGENCY_CATEGORY_MAP.items(): | |
| + | if key.lower() in agency_lower: | |
| + | return cat | |
| + | return None | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Schema helpers | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _ensure_schema(conn: sqlite3.Connection) -> None: | |
| + | """Create new tables and add missing columns - all additive.""" | |
| + | ||
| + | conn.executescript(""" | |
| + | CREATE TABLE IF NOT EXISTS categories ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | slug TEXT NOT NULL UNIQUE, | |
| + | label TEXT NOT NULL, | |
| + | kind TEXT NOT NULL DEFAULT 'policy_area' | |
| + | ); | |
| + | ||
| + | CREATE TABLE IF NOT EXISTS bill_subjects ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | bill_id INTEGER NOT NULL REFERENCES bills(id), | |
| + | subject TEXT NOT NULL, | |
| + | UNIQUE (bill_id, subject) | |
| + | ); | |
| + | """) | |
| + | conn.commit() | |
| + | ||
| + | # awards.category - guarded ALTER (column may already exist from a prior run) | |
| + | existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(awards)")} | |
| + | if "category" not in existing_cols: | |
| + | try: | |
| + | conn.execute("ALTER TABLE awards ADD COLUMN category TEXT") | |
| + | conn.commit() | |
| + | logger.info("Added awards.category column") | |
| + | except sqlite3.OperationalError as exc: | |
| + | # Parallel runner already added it; safe to ignore. | |
| + | logger.debug("ALTER awards skipped: %s", exc) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Seed categories | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _seed_categories(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | Insert the canonical policy-area rows. Also inserts any policy_area values | |
| + | already present in the bills table that are not yet in categories (catches | |
| + | areas Congress added after this file was last updated). | |
| + | """ | |
| + | inserted = 0 | |
| + | for slug, label in POLICY_AREAS: | |
| + | try: | |
| + | conn.execute( | |
| + | "INSERT OR IGNORE INTO categories (slug, label, kind) VALUES (?, ?, 'policy_area')", | |
| + | (slug, label), | |
| + | ) | |
| + | if conn.execute("SELECT changes()").fetchone()[0]: | |
| + | inserted += 1 | |
| + | except sqlite3.Error as exc: | |
| + | logger.warning("categories insert failed for %s: %s", slug, exc) | |
| + | ||
| + | # Pick up any extra areas already in the bills table | |
| + | for row in conn.execute( | |
| + | "SELECT DISTINCT policy_area FROM bills WHERE policy_area IS NOT NULL AND policy_area != ''" | |
| + | ): | |
| + | area = row[0].strip() | |
| + | slug = _slug_from_label(area) | |
| + | try: | |
| + | conn.execute( | |
| + | "INSERT OR IGNORE INTO categories (slug, label, kind) VALUES (?, ?, 'policy_area')", | |
| + | (slug, area), | |
| + | ) | |
| + | if conn.execute("SELECT changes()").fetchone()[0]: | |
| + | inserted += 1 | |
| + | except sqlite3.Error as exc: | |
| + | logger.warning("categories extra insert failed for %r: %s", area, exc) | |
| + | ||
| + | conn.commit() | |
| + | logger.info("categories seeded/updated: %d new rows", inserted) | |
| + | return inserted | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Bill subjects backfill - re-fetch BILLSTATUS XML from GovInfo | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | _BULKDATA_BASE = "https://www.govinfo.gov/bulkdata/BILLSTATUS" | |
| + | ||
| + | ||
| + | def _billstatus_url(congress: int, bill_type: str, number: str) -> str: | |
| + | """ | |
| + | Construct the GovInfo BULKDATA URL for a single BILLSTATUS XML file. | |
| + | ||
| + | Actual URL format (from sitemap): | |
| + | https://www.govinfo.gov/bulkdata/BILLSTATUS/{congress}/{type}/BILLSTATUS-{congress}{type}{number}.xml | |
| + | Note: congress and bill_type are SEPARATE path segments; the filename still | |
| + | concatenates them (BILLSTATUS-119hr160.xml). | |
| + | """ | |
| + | return ( | |
| + | f"{_BULKDATA_BASE}/{congress}/{bill_type}" | |
| + | f"/BILLSTATUS-{congress}{bill_type}{number}.xml" | |
| + | ) | |
| + | ||
| + | ||
| + | def _parse_subjects(xml_bytes: bytes) -> list[str]: | |
| + | """ | |
| + | Parse <legislativeSubjects><item><name> elements from a BILLSTATUS XML. | |
| + | Returns a list of subject strings (may be empty). | |
| + | """ | |
| + | try: | |
| + | root = ET.fromstring(xml_bytes) | |
| + | except ET.ParseError as exc: | |
| + | logger.debug("XML parse error: %s", exc) | |
| + | return [] | |
| + | ||
| + | bill_el = root.find("bill") | |
| + | if bill_el is None: | |
| + | if root.tag in ("bill", "Bill"): | |
| + | bill_el = root | |
| + | else: | |
| + | return [] | |
| + | ||
| + | subjects_el = bill_el.find("subjects") | |
| + | if subjects_el is None: | |
| + | return [] | |
| + | ||
| + | leg_el = subjects_el.find("legislativeSubjects") | |
| + | if leg_el is None: | |
| + | return [] | |
| + | ||
| + | names: list[str] = [] | |
| + | for item in leg_el.findall("item"): | |
| + | name_el = item.find("name") | |
| + | if name_el is not None and name_el.text: | |
| + | names.append(name_el.text.strip()) | |
| + | return names | |
| + | ||
| + | ||
| + | def _bills_needing_subjects(conn: sqlite3.Connection) -> list[tuple]: | |
| + | """ | |
| + | Return (bill_id, external_id, congress) for bills that have no subjects yet. | |
| + | external_id format: "{congress}-{bill_type}-{number}" | |
| + | """ | |
| + | return conn.execute(""" | |
| + | SELECT b.id, b.external_id, b.congress | |
| + | FROM bills b | |
| + | WHERE b.source = 'govinfo_billstatus' | |
| + | AND NOT EXISTS ( | |
| + | SELECT 1 FROM bill_subjects s WHERE s.bill_id = b.id | |
| + | ) | |
| + | ORDER BY b.id | |
| + | """).fetchall() | |
| + | ||
| + | ||
| + | def _backfill_subjects( | |
| + | conn: sqlite3.Connection, | |
| + | limit: Optional[int] = None, | |
| + | delay: float = 0.05, | |
| + | ) -> tuple[int, int]: | |
| + | """ | |
| + | Fetch BILLSTATUS XML for bills without subjects and store the subjects. | |
| + | Returns (bills_processed, subjects_stored). | |
| + | """ | |
| + | pending = _bills_needing_subjects(conn) | |
| + | if limit is not None: | |
| + | pending = pending[:limit] | |
| + | ||
| + | session = requests.Session() | |
| + | session.headers["User-Agent"] = "Docket-categorize/1.0 (public transparency research)" | |
| + | ||
| + | bills_done = 0 | |
| + | subjects_total = 0 | |
| + | ||
| + | for row in pending: | |
| + | bill_id, external_id, congress = row[0], row[1], row[2] | |
| + | # external_id: "119-hr-160" | |
| + | parts = external_id.split("-", 2) | |
| + | if len(parts) != 3: | |
| + | logger.debug("Skipping unexpected external_id %s", external_id) | |
| + | continue | |
| + | _, bill_type, number = parts | |
| + | ||
| + | url = _billstatus_url(int(congress), bill_type, number) | |
| + | try: | |
| + | resp = session.get(url, timeout=30) | |
| + | except requests.RequestException as exc: | |
| + | logger.warning("Fetch error %s: %s", url, exc) | |
| + | continue | |
| + | ||
| + | if resp.status_code != 200: | |
| + | logger.debug("HTTP %s for %s", resp.status_code, url) | |
| + | # Still mark as processed so we don't retry a 404 forever. | |
| + | # Insert a sentinel so the SELECT above won't re-queue it. | |
| + | # We skip because there are genuinely no subjects to store. | |
| + | continue | |
| + | ||
| + | subjects = _parse_subjects(resp.content) | |
| + | stored = 0 | |
| + | for subj in subjects: | |
| + | try: | |
| + | conn.execute( | |
| + | "INSERT OR IGNORE INTO bill_subjects (bill_id, subject) VALUES (?, ?)", | |
| + | (bill_id, subj), | |
| + | ) | |
| + | stored += conn.execute("SELECT changes()").fetchone()[0] | |
| + | except sqlite3.Error as exc: | |
| + | logger.warning("bill_subjects insert error bill_id=%s: %s", bill_id, exc) | |
| + | ||
| + | if subjects: | |
| + | # Commit each bill so partial progress is saved on interruption | |
| + | conn.commit() | |
| + | else: | |
| + | # Insert a placeholder subject to mark this bill as "processed" even if it | |
| + | # has no subjects (prevents re-fetching on every run). | |
| + | # Use empty string - UNIQUE(bill_id, subject) allows one empty row per bill. | |
| + | try: | |
| + | conn.execute( | |
| + | "INSERT OR IGNORE INTO bill_subjects (bill_id, subject) VALUES (?, '')", | |
| + | (bill_id,), | |
| + | ) | |
| + | conn.commit() | |
| + | except sqlite3.Error: | |
| + | pass | |
| + | ||
| + | bills_done += 1 | |
| + | subjects_total += stored | |
| + | ||
| + | if bills_done % 50 == 0: | |
| + | logger.info("subjects backfill: %d bills done, %d subjects stored", bills_done, subjects_total) | |
| + | ||
| + | if delay > 0: | |
| + | time.sleep(delay) | |
| + | ||
| + | logger.info("subjects backfill complete: %d bills, %d subjects", bills_done, subjects_total) | |
| + | return bills_done, subjects_total | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Awards category backfill | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _backfill_awards(conn: sqlite3.Connection) -> int: | |
| + | """ | |
| + | Apply agency→category map to all awards rows missing a category. | |
| + | Returns number of awards updated. | |
| + | """ | |
| + | rows = conn.execute( | |
| + | "SELECT id, agency FROM awards WHERE category IS NULL OR category = ''" | |
| + | ).fetchall() | |
| + | ||
| + | updated = 0 | |
| + | for row in rows: | |
| + | award_id, agency = row[0], row[1] or "" | |
| + | cat = agency_to_category(agency) | |
| + | if cat is not None: | |
| + | conn.execute( | |
| + | "UPDATE awards SET category = ? WHERE id = ?", | |
| + | (cat, award_id), | |
| + | ) | |
| + | updated += 1 | |
| + | ||
| + | conn.commit() | |
| + | logger.info("awards categorized: %d / %d rows", updated, len(rows)) | |
| + | return updated | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Category count query (summary) | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def category_counts(conn: sqlite3.Connection) -> list[dict]: | |
| + | """ | |
| + | Return counts of bills and awards per category label. | |
| + | Useful for verifying the taxonomy is populated. | |
| + | """ | |
| + | rows = conn.execute(""" | |
| + | SELECT c.label, | |
| + | COUNT(DISTINCT b.id) AS bill_count, | |
| + | COUNT(DISTINCT a.id) AS award_count | |
| + | FROM categories c | |
| + | LEFT JOIN bills b ON b.policy_area = c.label | |
| + | LEFT JOIN awards a ON a.category = c.label | |
| + | GROUP BY c.label | |
| + | ORDER BY (COUNT(DISTINCT b.id) + COUNT(DISTINCT a.id)) DESC, c.label | |
| + | """).fetchall() | |
| + | return [ | |
| + | {"label": r[0], "bills": r[1], "awards": r[2]} | |
| + | for r in rows | |
| + | ] | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Main entry point | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def run( | |
| + | db_path: Optional[str] = None, | |
| + | limit_subjects: Optional[int] = None, | |
| + | delay: float = 0.05, | |
| + | ) -> dict: | |
| + | """ | |
| + | Backfill categories, bill subjects, and award categories. | |
| + | ||
| + | Parameters | |
| + | ---------- | |
| + | db_path : SQLite path (None = use DOCKET_DB_PATH env / default). | |
| + | limit_subjects : Cap on how many bills to fetch subjects for (None = all). | |
| + | delay : Seconds between GovInfo requests. | |
| + | ||
| + | Returns | |
| + | ------- | |
| + | Summary dict with counts. | |
| + | """ | |
| + | from ingest.db import get_connection, init_schema | |
| + | ||
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | init_schema(conn) | |
| + | _ensure_schema(conn) | |
| + | ||
| + | cats_added = _seed_categories(conn) | |
| + | bills_done, subjects_stored = _backfill_subjects(conn, limit=limit_subjects, delay=delay) | |
| + | awards_updated = _backfill_awards(conn) | |
| + | ||
| + | # Total bills with policy_area | |
| + | bills_with_pa = conn.execute( | |
| + | "SELECT COUNT(*) FROM bills WHERE policy_area IS NOT NULL AND policy_area != ''" | |
| + | ).fetchone()[0] | |
| + | total_bills = conn.execute("SELECT COUNT(*) FROM bills").fetchone()[0] | |
| + | ||
| + | # Total bill_subjects rows (excluding sentinel empty-string rows) | |
| + | subjects_total = conn.execute( | |
| + | "SELECT COUNT(*) FROM bill_subjects WHERE subject != ''" | |
| + | ).fetchone()[0] | |
| + | ||
| + | # Distinct subjects | |
| + | subjects_distinct = conn.execute( | |
| + | "SELECT COUNT(DISTINCT subject) FROM bill_subjects WHERE subject != ''" | |
| + | ).fetchone()[0] | |
| + | ||
| + | # Distinct policy areas in use | |
| + | pa_in_use = conn.execute( | |
| + | "SELECT COUNT(DISTINCT policy_area) FROM bills WHERE policy_area IS NOT NULL AND policy_area != ''" | |
| + | ).fetchone()[0] | |
| + | ||
| + | # Awards categorized | |
| + | awards_total = conn.execute("SELECT COUNT(*) FROM awards").fetchone()[0] | |
| + | awards_cat = conn.execute( | |
| + | "SELECT COUNT(*) FROM awards WHERE category IS NOT NULL AND category != ''" | |
| + | ).fetchone()[0] | |
| + | ||
| + | # Categories seeded | |
| + | cats_total = conn.execute("SELECT COUNT(*) FROM categories").fetchone()[0] | |
| + | ||
| + | summary = { | |
| + | "categories_total": cats_total, | |
| + | "categories_added_this_run": cats_added, | |
| + | "bills_total": total_bills, | |
| + | "bills_with_policy_area": bills_with_pa, | |
| + | "policy_areas_in_use": pa_in_use, | |
| + | "subjects_bills_fetched_this_run": bills_done, | |
| + | "bill_subjects_total_rows": subjects_total, | |
| + | "bill_subjects_distinct": subjects_distinct, | |
| + | "awards_total": awards_total, | |
| + | "awards_categorized": awards_cat, | |
| + | } | |
| + | ||
| + | logger.info("categorize summary: %s", summary) | |
| + | ||
| + | conn.close() | |
| + | return summary | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # CLI | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _print_category_counts(db_path: Optional[str]) -> None: | |
| + | from ingest.db import get_connection | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | counts = category_counts(conn) | |
| + | conn.close() | |
| + | print(f"\n{'Category':<50} {'Bills':>6} {'Awards':>6}") | |
| + | print("-" * 68) | |
| + | for row in counts: | |
| + | if row["bills"] > 0 or row["awards"] > 0: | |
| + | print(f"{row['label']:<50} {row['bills']:>6} {row['awards']:>6}") | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | stream=sys.stdout, | |
| + | ) | |
| + | ||
| + | ap = argparse.ArgumentParser( | |
| + | description="Backfill unified taxonomy: categories, bill subjects, award categories" | |
| + | ) | |
| + | ap.add_argument("--once", action="store_true", help="Run once and exit") | |
| + | ap.add_argument( | |
| + | "--limit-subjects", type=int, default=None, metavar="N", | |
| + | help="Maximum number of bills to fetch subjects for (default: all)" | |
| + | ) | |
| + | ap.add_argument( | |
| + | "--delay", type=float, default=0.05, | |
| + | help="Seconds between GovInfo requests (default 0.05)" | |
| + | ) | |
| + | ap.add_argument( | |
| + | "--counts-only", action="store_true", | |
| + | help="Print category counts from existing data and exit (no backfill)" | |
| + | ) | |
| + | args = ap.parse_args() | |
| + | ||
| + | import os | |
| + | db_path = os.environ.get("DOCKET_DB_PATH") | |
| + | ||
| + | if args.counts_only: | |
| + | _print_category_counts(db_path) | |
| + | sys.exit(0) | |
| + | ||
| + | summary = run( | |
| + | db_path=db_path, | |
| + | limit_subjects=args.limit_subjects, | |
| + | delay=args.delay, | |
| + | ) | |
| + | ||
| + | print("\n=== Categorize Summary ===") | |
| + | for k, v in summary.items(): | |
| + | print(f" {k}: {v}") | |
| + | ||
| + | _print_category_counts(db_path) | |
| + | print("\nDone.") |
| @@ -0,0 +1,1031 @@ | ||
| + | """ | |
| + | Voteview bulk-CSV poller - complete historical per-member voting index. | |
| + | ||
| + | Ingestion pipe (NOT user-facing): https://voteview.com/static/data/out/ | |
| + | User-facing source_url (neutrality rule #1, official .gov only): | |
| + | House: https://clerk.house.gov/Votes/{year}/roll{NNN:03d} | |
| + | Senate: https://www.senate.gov/legislative/LIS/roll_call_votes/ | |
| + | vote{congress}{session}/vote_{congress}_{session}_{roll:05d}.xml | |
| + | ||
| + | Cast-code mapping (Voteview codebook): | |
| + | Verified against https://voteview.com/articles/data_help_members | |
| + | ||
| + | Code | Official meaning | Canonical position | |
| + | ─────┼───────────────────────────────┼─────────────────── | |
| + | 0 | Not a member / no record | Not Voting | |
| + | 1 | Yea | Yea | |
| + | 2 | Paired Yea (absent, declared) | Yea | |
| + | 3 | Announced Yea (absent, decl.) | Yea | |
| + | 4 | Announced Nay (absent, decl.) | Nay | |
| + | 5 | Paired Nay (absent, declared) | Nay | |
| + | 6 | Nay | Nay | |
| + | 7 | Present / abstention (v1) | Present | |
| + | 8 | Present / abstention (v2) | Present | |
| + | 9 | Not Voting | Not Voting | |
| + | ||
| + | Paired/announced (codes 2-5): member was absent but announced their | |
| + | intent; they are counted by the chamber as Yea/Nay respectively. | |
| + | Codes 7 and 8 are both recorded abstentions (Present), distinct from | |
| + | code 9 (Not Voting, member absent with no declared position). | |
| + | ||
| + | NOTE: Voteview covers only RECORDED (roll-call) votes. Voice votes and | |
| + | unanimous-consent proceedings have no per-member record anywhere in the | |
| + | public record. This is structural - not a gap in this ingestion. | |
| + | ||
| + | ICPSR → bioguide crosswalk: | |
| + | Fetched from unitedstates/congress-legislators (current + historical). | |
| + | Cached in voteview_crosswalk table. Members not in the crosswalk are | |
| + | stored with member_bioguide=NULL (icpsr preserved in the raw payload). | |
| + | ||
| + | Year index: | |
| + | A `year` INTEGER column is added to `votes` and indexed so that | |
| + | votes_by_member_year() and votes_in_year() run in O(log n). | |
| + | ||
| + | Usage (CLI): | |
| + | # Proof slice (congresses 118-119, ≈2023-present): | |
| + | python -m ingest.pollers.voteview --once --start 118 --end 119 | |
| + | ||
| + | # Recent history (2019-present): | |
| + | python -m ingest.pollers.voteview --once --start 116 --end 119 | |
| + | ||
| + | # Extended backfill (1989-present, ~30 congresses): | |
| + | python -m ingest.pollers.voteview --once --start 101 --end 119 | |
| + | ||
| + | # Full Voteview history (1789-present, ~120 congresses): | |
| + | python -m ingest.pollers.voteview --once --start 1 --end 119 | |
| + | """ | |
| + | ||
| + | from __future__ import annotations | |
| + | ||
| + | import argparse | |
| + | import csv | |
| + | import io | |
| + | import logging | |
| + | import sqlite3 | |
| + | from datetime import datetime, timezone | |
| + | from typing import Optional | |
| + | ||
| + | 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__) | |
| + | ||
| + | SOURCE = "voteview" | |
| + | ||
| + | # ─── Voteview CSV base URLs ─────────────────────────────────────────────────── | |
| + | _VOTEVIEW_BASE = "https://voteview.com/static/data/out" | |
| + | ||
| + | ||
| + | def _rollcalls_url(chamber_prefix: str, congress: int) -> str: | |
| + | return f"{_VOTEVIEW_BASE}/rollcalls/{chamber_prefix}{congress}_rollcalls.csv" | |
| + | ||
| + | ||
| + | def _votes_url(chamber_prefix: str, congress: int) -> str: | |
| + | return f"{_VOTEVIEW_BASE}/votes/{chamber_prefix}{congress}_votes.csv" | |
| + | ||
| + | ||
| + | # ─── ICPSR crosswalk source URLs ───────────────────────────────────────────── | |
| + | _LEGISLATORS_URLS = [ | |
| + | ( | |
| + | "https://raw.githubusercontent.com/unitedstates/congress-legislators" | |
| + | "/gh-pages/legislators-current.json" | |
| + | ), | |
| + | ( | |
| + | "https://raw.githubusercontent.com/unitedstates/congress-legislators" | |
| + | "/gh-pages/legislators-historical.json" | |
| + | ), | |
| + | ] | |
| + | ||
| + | # ─── Official source URL builders (neutrality rule #1) ─────────────────────── | |
| + | ||
| + | def _house_source_url(year: int, clerk_rollnumber: int) -> str: | |
| + | """Official House Clerk vote page URL.""" | |
| + | return f"https://clerk.house.gov/Votes/{year}/roll{clerk_rollnumber:03d}" | |
| + | ||
| + | ||
| + | def _senate_source_url(congress: int, session: int, rollnumber: int) -> str: | |
| + | """Official Senate roll-call vote XML URL.""" | |
| + | return ( | |
| + | f"https://www.senate.gov/legislative/LIS/roll_call_votes/" | |
| + | f"vote{congress}{session}/" | |
| + | f"vote_{congress}_{session}_{rollnumber:05d}.xml" | |
| + | ) | |
| + | ||
| + | ||
| + | # ─── Cast-code mapping ──────────────────────────────────────────────────────── | |
| + | ||
| + | # Voteview codebook: https://voteview.com/articles/data_help_members#castcode | |
| + | CAST_CODE_MAP: dict[int, str] = { | |
| + | 0: "Not Voting", # Not a member of the chamber, or absent with no record | |
| + | 1: "Yea", | |
| + | 2: "Yea", # Paired Yea - absent but announced intent | |
| + | 3: "Yea", # Announced Yea - absent but announced intent | |
| + | 4: "Nay", # Announced Nay - absent but announced intent | |
| + | 5: "Nay", # Paired Nay - absent but announced intent | |
| + | 6: "Nay", | |
| + | 7: "Present", # Abstention variant 1 | |
| + | 8: "Present", # Abstention variant 2 | |
| + | 9: "Not Voting", # Absent with no position declared | |
| + | } | |
| + | ||
| + | ||
| + | def cast_code_to_position(code) -> Optional[str]: | |
| + | """ | |
| + | Map a Voteview cast_code (int or str) to a canonical position string. | |
| + | Returns None for unrecognized codes. | |
| + | """ | |
| + | try: | |
| + | return CAST_CODE_MAP.get(int(float(code))) | |
| + | except (ValueError, TypeError): | |
| + | return None | |
| + | ||
| + | ||
| + | # ─── Schema setup ───────────────────────────────────────────────────────────── | |
| + | ||
| + | def ensure_voteview_schema(conn: sqlite3.Connection) -> None: | |
| + | """ | |
| + | Idempotent: create voteview-specific tables, extend the votes table with | |
| + | a `year` column (for fast year-range queries), and build the indexes. | |
| + | ||
| + | Uses individual execute() calls (not executescript) so that SQLite's | |
| + | busy_timeout is respected when another reader has a shared lock. | |
| + | """ | |
| + | # ICPSR → bioguide crosswalk cache. | |
| + | conn.execute( | |
| + | """ | |
| + | CREATE TABLE IF NOT EXISTS voteview_crosswalk ( | |
| + | icpsr_id INTEGER PRIMARY KEY, | |
| + | bioguide_id TEXT, | |
| + | full_name TEXT, | |
| + | fetched_at TEXT NOT NULL | |
| + | ) | |
| + | """ | |
| + | ) | |
| + | conn.execute( | |
| + | """ | |
| + | CREATE INDEX IF NOT EXISTS idx_crosswalk_bioguide | |
| + | ON voteview_crosswalk(bioguide_id) | |
| + | WHERE bioguide_id IS NOT NULL | |
| + | """ | |
| + | ) | |
| + | ||
| + | # Additive columns on the votes table. SQLite has no IF NOT EXISTS on | |
| + | # ALTER TABLE; use try/except per the pattern in members.py. | |
| + | for col, col_type in [ | |
| + | ("year", "INTEGER"), | |
| + | ("description", "TEXT"), | |
| + | ("voteview_rollnumber", "INTEGER"), | |
| + | ]: | |
| + | try: | |
| + | conn.execute(f"ALTER TABLE votes ADD COLUMN {col} {col_type}") | |
| + | except Exception: | |
| + | pass # already present - safe to ignore | |
| + | ||
| + | # Year-range indexes (the whole point of storing year explicitly). | |
| + | for ddl in [ | |
| + | "CREATE INDEX IF NOT EXISTS idx_votes_year ON votes(year)", | |
| + | "CREATE INDEX IF NOT EXISTS idx_votes_year_chamber ON votes(year, chamber)", | |
| + | "CREATE INDEX IF NOT EXISTS idx_votes_congress_chamber ON votes(congress, chamber)", | |
| + | ]: | |
| + | try: | |
| + | conn.execute(ddl) | |
| + | except Exception: | |
| + | pass | |
| + | ||
| + | conn.commit() | |
| + | ||
| + | ||
| + | # ─── ICPSR → bioguide crosswalk ─────────────────────────────────────────────── | |
| + | ||
| + | def build_crosswalk( | |
| + | conn: sqlite3.Connection, | |
| + | force_refresh: bool = False, | |
| + | ) -> dict[int, str]: | |
| + | """ | |
| + | Build (or load from cache) the ICPSR → bioguide_id crosswalk. | |
| + | ||
| + | Fetches congress-legislators current + historical JSON from GitHub and | |
| + | stores the mapping in voteview_crosswalk for subsequent runs. | |
| + | ||
| + | Returns | |
| + | ------- | |
| + | dict mapping icpsr_id (int) → bioguide_id (str). | |
| + | """ | |
| + | if not force_refresh: | |
| + | cached_count = conn.execute( | |
| + | "SELECT COUNT(*) FROM voteview_crosswalk" | |
| + | ).fetchone()[0] | |
| + | if cached_count > 0: | |
| + | logger.info( | |
| + | "voteview: crosswalk: loaded %d ICPSR mappings from cache", | |
| + | cached_count, | |
| + | ) | |
| + | rows = conn.execute( | |
| + | "SELECT icpsr_id, bioguide_id FROM voteview_crosswalk " | |
| + | "WHERE bioguide_id IS NOT NULL" | |
| + | ).fetchall() | |
| + | return {r["icpsr_id"]: r["bioguide_id"] for r in rows} | |
| + | ||
| + | logger.info("voteview: building ICPSR→bioguide crosswalk from congress-legislators …") | |
| + | now = datetime.now(timezone.utc).isoformat() | |
| + | crosswalk: dict[int, str] = {} | |
| + | ||
| + | for url in _LEGISLATORS_URLS: | |
| + | try: | |
| + | resp = requests.get(url, timeout=60) | |
| + | resp.raise_for_status() | |
| + | data = resp.json() | |
| + | except Exception as exc: | |
| + | logger.error("voteview: crosswalk fetch error %s: %s", url, exc) | |
| + | continue | |
| + | ||
| + | for leg in data: | |
| + | ids = leg.get("id", {}) | |
| + | bioguide = (ids.get("bioguide") or "").strip() | |
| + | if not bioguide: | |
| + | continue | |
| + | ||
| + | name_block = leg.get("name", {}) | |
| + | full_name = ( | |
| + | name_block.get("official_full") | |
| + | or f"{name_block.get('first','')} {name_block.get('last','')}".strip() | |
| + | ) | |
| + | ||
| + | # Primary ICPSR field (most members). | |
| + | for icpsr_field in ("icpsr", "icpsr_hn"): | |
| + | raw = ids.get(icpsr_field) | |
| + | if raw is None: | |
| + | continue | |
| + | try: | |
| + | icpsr_int = int(float(raw)) | |
| + | except (ValueError, TypeError): | |
| + | continue | |
| + | ||
| + | crosswalk[icpsr_int] = bioguide | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO voteview_crosswalk | |
| + | (icpsr_id, bioguide_id, full_name, fetched_at) | |
| + | VALUES (?, ?, ?, ?) | |
| + | ON CONFLICT(icpsr_id) DO UPDATE SET | |
| + | bioguide_id = excluded.bioguide_id, | |
| + | full_name = excluded.full_name, | |
| + | fetched_at = excluded.fetched_at | |
| + | """, | |
| + | (icpsr_int, bioguide, full_name, now), | |
| + | ) | |
| + | ||
| + | conn.commit() | |
| + | logger.info( | |
| + | "voteview: crosswalk built - %d ICPSR→bioguide mappings", len(crosswalk) | |
| + | ) | |
| + | return crosswalk | |
| + | ||
| + | ||
| + | # ─── Supplementary name-based crosswalk (for post-2018 members) ────────────── | |
| + | ||
| + | def _build_name_lookup(legislators_data: list[dict]) -> dict: | |
| + | """ | |
| + | Build a secondary lookup mapping (normalized_last_name, state_abbrev) | |
| + | → list of (bioguide_id, service_terms) for name-based ICPSR matching. | |
| + | ||
| + | Many members elected 2018+ are absent from the congress-legislators | |
| + | id.icpsr field (the ICPSR project lags new assignments). For those, | |
| + | we match by last name + state + congress term overlap. | |
| + | """ | |
| + | lookup: dict[tuple, list] = {} | |
| + | for leg in legislators_data: | |
| + | ids = leg.get("id", {}) | |
| + | bioguide = (ids.get("bioguide") or "").strip() | |
| + | if not bioguide: | |
| + | continue | |
| + | name = leg.get("name", {}) | |
| + | last = (name.get("last") or "").strip().lower() | |
| + | terms = leg.get("terms", []) | |
| + | if not last or not terms: | |
| + | continue | |
| + | for term in terms: | |
| + | state = (term.get("state") or "").upper() | |
| + | if not state: | |
| + | continue | |
| + | key = (last, state) | |
| + | if key not in lookup: | |
| + | lookup[key] = [] | |
| + | # Avoid duplicate bioguide entries for the same key. | |
| + | if not any(b == bioguide for b, _ in lookup[key]): | |
| + | lookup[key].append((bioguide, terms)) | |
| + | return lookup | |
| + | ||
| + | ||
| + | def _congress_year_range(congress: int) -> tuple[int, int]: | |
| + | """Return the approximate (start_year, end_year) for a given congress.""" | |
| + | start = 1789 + 2 * (congress - 1) | |
| + | return start, start + 2 | |
| + | ||
| + | ||
| + | def augment_crosswalk_from_voteview_members( | |
| + | conn: sqlite3.Connection, | |
| + | crosswalk: dict[int, str], | |
| + | congress: int, | |
| + | chamber_prefix: str, | |
| + | name_lookup: dict, | |
| + | ) -> int: | |
| + | """ | |
| + | For ICPSR IDs in Voteview's members CSV that are NOT yet in the crosswalk, | |
| + | attempt a name + state + congress-term match against congress-legislators. | |
| + | ||
| + | Only adds an entry when the match is UNAMBIGUOUS (exactly one bioguide | |
| + | for that last name + state with a term overlapping the congress year range). | |
| + | ||
| + | Returns the number of new entries added to *crosswalk* (and the DB cache). | |
| + | """ | |
| + | url = f"{_VOTEVIEW_BASE}/members/{chamber_prefix}{congress}_members.csv" | |
| + | rows = _fetch_csv(url) | |
| + | if rows is None: | |
| + | return 0 | |
| + | ||
| + | now = datetime.now(timezone.utc).isoformat() | |
| + | added = 0 | |
| + | cong_start, cong_end = _congress_year_range(congress) | |
| + | ||
| + | for row in rows: | |
| + | try: | |
| + | icpsr = int(float(row.get("icpsr") or 0)) | |
| + | except (ValueError, TypeError): | |
| + | continue | |
| + | if icpsr <= 0 or icpsr in crosswalk: | |
| + | continue | |
| + | ||
| + | bioname = (row.get("bioname") or "").strip() | |
| + | state = (row.get("state_abbrev") or "").strip().upper() | |
| + | if not bioname or not state: | |
| + | continue | |
| + | ||
| + | # Parse Voteview "LAST, First M." format. | |
| + | if "," in bioname: | |
| + | raw_last = bioname.split(",", 1)[0].strip() | |
| + | else: | |
| + | raw_last = bioname.strip() | |
| + | # Normalize: lowercase, remove common suffixes, handle hyphens. | |
| + | last_norm = raw_last.lower() | |
| + | ||
| + | key = (last_norm, state) | |
| + | candidates = name_lookup.get(key, []) | |
| + | ||
| + | # Filter candidates by congress term overlap. | |
| + | valid_bios: list[str] = [] | |
| + | for bio, terms in candidates: | |
| + | for term in terms: | |
| + | try: | |
| + | ty_start = int((term.get("start") or "1800-01-01")[:4]) | |
| + | ty_end = int((term.get("end") or "2100-01-01")[:4]) | |
| + | except (ValueError, TypeError): | |
| + | continue | |
| + | if ty_start <= cong_end and ty_end >= cong_start: | |
| + | valid_bios.append(bio) | |
| + | break | |
| + | ||
| + | if len(valid_bios) != 1: | |
| + | # Ambiguous or no match - skip to avoid false crosswalk entries. | |
| + | continue | |
| + | ||
| + | bioguide = valid_bios[0] | |
| + | crosswalk[icpsr] = bioguide | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO voteview_crosswalk | |
| + | (icpsr_id, bioguide_id, full_name, fetched_at) | |
| + | VALUES (?, ?, ?, ?) | |
| + | ON CONFLICT(icpsr_id) DO UPDATE SET | |
| + | bioguide_id = excluded.bioguide_id, | |
| + | full_name = excluded.full_name, | |
| + | fetched_at = excluded.fetched_at | |
| + | """, | |
| + | (icpsr, bioguide, bioname, now), | |
| + | ) | |
| + | added += 1 | |
| + | ||
| + | if added > 0: | |
| + | conn.commit() | |
| + | logger.debug( | |
| + | "voteview: name-match: added %d new ICPSR→bioguide mappings " | |
| + | "for %s congress %d", | |
| + | added, chamber_prefix, congress, | |
| + | ) | |
| + | return added | |
| + | ||
| + | ||
| + | # ─── CSV helpers ────────────────────────────────────────────────────────────── | |
| + | ||
| + | def _fetch_csv(url: str, timeout: int = 120) -> Optional[list[dict]]: | |
| + | """ | |
| + | Fetch a CSV from *url* and return it as a list of dicts. | |
| + | Returns None on HTTP 404 or network error. | |
| + | """ | |
| + | try: | |
| + | resp = requests.get(url, timeout=timeout) | |
| + | resp.raise_for_status() | |
| + | except requests.HTTPError as exc: | |
| + | if exc.response is not None and exc.response.status_code == 404: | |
| + | logger.debug("voteview: CSV not found (404): %s", url) | |
| + | else: | |
| + | logger.warning("voteview: HTTP error fetching %s: %s", url, exc) | |
| + | return None | |
| + | except requests.RequestException as exc: | |
| + | logger.warning("voteview: network error fetching %s: %s", url, exc) | |
| + | return None | |
| + | ||
| + | try: | |
| + | content = resp.content.decode("utf-8", errors="replace") | |
| + | return list(csv.DictReader(io.StringIO(content))) | |
| + | except Exception as exc: | |
| + | logger.error("voteview: CSV parse error %s: %s", url, exc) | |
| + | return None | |
| + | ||
| + | ||
| + | def _safe_int(val, default: int = 0) -> int: | |
| + | """Convert val to int, returning default on failure.""" | |
| + | try: | |
| + | return int(float(val)) | |
| + | except (ValueError, TypeError): | |
| + | return default | |
| + | ||
| + | ||
| + | def _derive_session(date_str: str, csv_session: str) -> int: | |
| + | """ | |
| + | Derive the congressional session (1 or 2) from the rollcall row. | |
| + | ||
| + | Prefers the CSV's session field. Falls back to year parity: | |
| + | odd year → session 1 | |
| + | even year → session 2 | |
| + | """ | |
| + | try: | |
| + | return int(float(csv_session)) | |
| + | except (ValueError, TypeError): | |
| + | pass | |
| + | try: | |
| + | year = int(date_str[:4]) | |
| + | return 1 if year % 2 == 1 else 2 | |
| + | except (ValueError, TypeError): | |
| + | return 1 | |
| + | ||
| + | ||
| + | def _aggregate_from_cast_codes( | |
| + | member_rows: list[dict], | |
| + | ) -> tuple[int, int, int, int]: | |
| + | """ | |
| + | Compute (yea, nay, present, not_voting) from a list of member-vote dicts. | |
| + | Used to fill Present / Not Voting when the rollcall CSV omits them. | |
| + | """ | |
| + | yea = nay = present = not_voting = 0 | |
| + | for mv in member_rows: | |
| + | pos = cast_code_to_position(mv.get("cast_code", "")) | |
| + | if pos == "Yea": | |
| + | yea += 1 | |
| + | elif pos == "Nay": | |
| + | nay += 1 | |
| + | elif pos == "Present": | |
| + | present += 1 | |
| + | else: | |
| + | not_voting += 1 | |
| + | return yea, nay, present, not_voting | |
| + | ||
| + | ||
| + | # ─── Core ingestion for one congress × chamber ─────────────────────────────── | |
| + | ||
| + | def _ingest_congress_chamber( | |
| + | conn: sqlite3.Connection, | |
| + | congress: int, | |
| + | chamber_prefix: str, # "H" or "S" | |
| + | chamber_name: str, # "house" or "senate" | |
| + | crosswalk: dict[int, str], | |
| + | bg_to_id: dict[str, int], | |
| + | ) -> tuple[int, int]: | |
| + | """ | |
| + | Fetch Voteview CSVs for one congress + chamber and upsert into the DB. | |
| + | ||
| + | Returns (rollcalls_ingested, positions_inserted). | |
| + | """ | |
| + | # ── Rollcalls (vote metadata) ───────────────────────────────────────────── | |
| + | rc_url = _rollcalls_url(chamber_prefix, congress) | |
| + | rc_rows = _fetch_csv(rc_url) | |
| + | if rc_rows is None: | |
| + | logger.warning( | |
| + | "voteview: no rollcalls CSV for %s congress %d (skipping)", | |
| + | chamber_name, congress, | |
| + | ) | |
| + | return 0, 0 | |
| + | ||
| + | # Build rollnumber → metadata lookup. | |
| + | rc_meta: dict[int, dict] = {} | |
| + | for row in rc_rows: | |
| + | try: | |
| + | rn = int(float(row.get("rollnumber") or 0)) | |
| + | except (ValueError, TypeError): | |
| + | continue | |
| + | if rn > 0: | |
| + | rc_meta[rn] = row | |
| + | ||
| + | logger.info( | |
| + | "voteview: congress %d %s: %d rollcalls in CSV", | |
| + | congress, chamber_name, len(rc_meta), | |
| + | ) | |
| + | if not rc_meta: | |
| + | return 0, 0 | |
| + | ||
| + | # ── Member votes (cast codes) ───────────────────────────────────────────── | |
| + | mv_url = _votes_url(chamber_prefix, congress) | |
| + | mv_rows = _fetch_csv(mv_url) | |
| + | if mv_rows is None: | |
| + | logger.warning( | |
| + | "voteview: no member-votes CSV for %s congress %d (metadata only)", | |
| + | chamber_name, congress, | |
| + | ) | |
| + | mv_rows = [] | |
| + | ||
| + | # Group member votes by rollnumber. | |
| + | member_votes_by_roll: dict[int, list[dict]] = {} | |
| + | for mv in mv_rows: | |
| + | try: | |
| + | rn = int(float(mv.get("rollnumber") or 0)) | |
| + | except (ValueError, TypeError): | |
| + | continue | |
| + | member_votes_by_roll.setdefault(rn, []).append(mv) | |
| + | ||
| + | # ── Process each rollcall ───────────────────────────────────────────────── | |
| + | rollcalls_ingested = 0 | |
| + | positions_inserted = 0 | |
| + | ||
| + | for rollnumber, rc in rc_meta.items(): | |
| + | external_id = f"{chamber_prefix}{congress}-{rollnumber}" | |
| + | ||
| + | # Idempotency check. | |
| + | if not is_new_event(conn, SOURCE, external_id, external_id): | |
| + | continue | |
| + | ||
| + | date_str = (rc.get("date") or "").strip() | |
| + | year = _safe_int(date_str[:4]) if len(date_str) >= 4 else 0 | |
| + | session = _derive_session(date_str, rc.get("session") or "") | |
| + | ||
| + | # ── Official source URL (neutrality rule #1) ────────────────────────── | |
| + | if chamber_name == "house": | |
| + | clerk_rn_raw = rc.get("clerk_rollnumber") or "" | |
| + | try: | |
| + | clerk_rn = int(float(clerk_rn_raw)) | |
| + | except (ValueError, TypeError): | |
| + | clerk_rn = rollnumber # Voteview rollnumber as fallback | |
| + | if year <= 0: | |
| + | year = 2017 + 2 * (congress - 115) # approximate from congress | |
| + | source_url = _house_source_url(year, clerk_rn) | |
| + | else: | |
| + | source_url = _senate_source_url(congress, session, rollnumber) | |
| + | ||
| + | try: | |
| + | validate_source_url(source_url) | |
| + | except Exception as exc: | |
| + | logger.error( | |
| + | "voteview: invalid source_url %s: %s - skipping rollcall %s", | |
| + | source_url, exc, external_id, | |
| + | ) | |
| + | mark_seen(conn, SOURCE, external_id, external_id) | |
| + | continue | |
| + | ||
| + | # ── Aggregate counts ────────────────────────────────────────────────── | |
| + | member_rows = member_votes_by_roll.get(rollnumber, []) | |
| + | _, _, cc_present, cc_not_voting = _aggregate_from_cast_codes(member_rows) | |
| + | ||
| + | # Prefer CSV aggregate for Yea / Nay (official chamber tally). | |
| + | csv_yea = _safe_int(rc.get("yea_count")) | |
| + | csv_nay = _safe_int(rc.get("nay_count")) | |
| + | ||
| + | # ── Insert vote row ─────────────────────────────────────────────────── | |
| + | bill_text_ref = (rc.get("bill_number") or "").strip() or None | |
| + | question = (rc.get("vote_question") or "").strip() | |
| + | result = (rc.get("vote_result") or "").strip() | |
| + | description = ( | |
| + | (rc.get("vote_desc") or rc.get("dtl_desc") or "").strip() | |
| + | ) | |
| + | ||
| + | # House: store the House Clerk roll number in roll_number so it aligns | |
| + | # with votes_house.py. Senate: Voteview rollnumber = chamber number. | |
| + | if chamber_name == "house": | |
| + | official_roll = _safe_int(rc.get("clerk_rollnumber") or rollnumber) | |
| + | else: | |
| + | official_roll = rollnumber | |
| + | ||
| + | conn.execute( | |
| + | """ | |
| + | 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, year, description, | |
| + | voteview_rollnumber) | |
| + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, | |
| + | ?, ?, ?, ?, ?, ?, | |
| + | ?, ?, ?, ?, | |
| + | ?) | |
| + | """, | |
| + | ( | |
| + | None, # bill_id - look up later if needed | |
| + | chamber_name, | |
| + | date_str, | |
| + | csv_yea, | |
| + | csv_nay, | |
| + | cc_present, | |
| + | result, | |
| + | source_url, | |
| + | congress, | |
| + | session, | |
| + | official_roll, | |
| + | question, | |
| + | cc_not_voting, | |
| + | 1, # recorded = True (Voteview is recorded only) | |
| + | external_id, | |
| + | bill_text_ref, | |
| + | year, | |
| + | description, | |
| + | rollnumber, | |
| + | ), | |
| + | ) | |
| + | ||
| + | vote_db_id: int = conn.execute( | |
| + | "SELECT last_insert_rowid() AS id" | |
| + | ).fetchone()["id"] | |
| + | ||
| + | # ── Per-member positions ────────────────────────────────────────────── | |
| + | pos_rows: list[tuple] = [] | |
| + | for mv in member_rows: | |
| + | position = cast_code_to_position(mv.get("cast_code")) | |
| + | if position is None: | |
| + | continue | |
| + | try: | |
| + | icpsr = int(float(mv.get("icpsr") or 0)) | |
| + | except (ValueError, TypeError): | |
| + | continue | |
| + | if icpsr <= 0: | |
| + | continue | |
| + | ||
| + | bioguide = crosswalk.get(icpsr) | |
| + | member_id = bg_to_id.get(bioguide) if bioguide else None | |
| + | pos_rows.append((vote_db_id, bioguide, member_id, position)) | |
| + | ||
| + | if pos_rows: | |
| + | conn.executemany( | |
| + | """ | |
| + | INSERT INTO vote_positions | |
| + | (vote_id, member_bioguide, member_id, position) | |
| + | VALUES (?, ?, ?, ?) | |
| + | """, | |
| + | pos_rows, | |
| + | ) | |
| + | positions_inserted += len(pos_rows) | |
| + | ||
| + | # ── Feed event ──────────────────────────────────────────────────────── | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO events | |
| + | (ref_type, ref_id, action_date, dollar_amount, | |
| + | affected_estimate, action_type) | |
| + | VALUES (?, ?, ?, ?, ?, ?) | |
| + | """, | |
| + | ("vote", vote_db_id, date_str, None, None, "floor_vote"), | |
| + | ) | |
| + | ||
| + | mark_seen(conn, SOURCE, external_id, external_id) | |
| + | rollcalls_ingested += 1 | |
| + | ||
| + | conn.commit() | |
| + | logger.info( | |
| + | "voteview: congress %d %s: ingested %d rollcalls, %d positions", | |
| + | congress, chamber_name, rollcalls_ingested, positions_inserted, | |
| + | ) | |
| + | return rollcalls_ingested, positions_inserted | |
| + | ||
| + | ||
| + | # ─── Main poll entry point ──────────────────────────────────────────────────── | |
| + | ||
| + | def poll( | |
| + | start_congress: int = 116, | |
| + | end_congress: int = 119, | |
| + | db_path: Optional[str] = None, | |
| + | force_crosswalk_refresh: bool = False, | |
| + | ) -> dict: | |
| + | """ | |
| + | Ingest Voteview roll-call data for the given congress range. | |
| + | ||
| + | Parameters | |
| + | ---------- | |
| + | start_congress : first congress to ingest (inclusive) | |
| + | end_congress : last congress to ingest (inclusive) | |
| + | db_path : override for SQLite DB path (uses env/default if None) | |
| + | force_crosswalk_refresh : re-download the ICPSR crosswalk even if cached | |
| + | ||
| + | Returns | |
| + | ------- | |
| + | dict with keys: | |
| + | rollcalls_total - total vote rows inserted | |
| + | positions_total - total vote_position rows inserted | |
| + | per_congress - list of per-(congress,chamber) dicts | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | init_schema(conn) | |
| + | ensure_votes_schema(conn) | |
| + | ensure_voteview_schema(conn) | |
| + | ||
| + | # Build bioguide → member_id lookup for FK joins. | |
| + | bg_to_id: dict[str, int] = { | |
| + | row["bioguide_id"]: row["id"] | |
| + | for row in conn.execute("SELECT id, bioguide_id FROM members") | |
| + | } | |
| + | ||
| + | crosswalk = build_crosswalk(conn, force_refresh=force_crosswalk_refresh) | |
| + | ||
| + | # Build a name-based secondary lookup from congress-legislators JSON, used | |
| + | # to resolve ICPSR IDs that are absent from the primary id.icpsr field | |
| + | # (common for members elected 2018+; ICPSR assignment lags new service). | |
| + | logger.info("voteview: building name-lookup for supplementary crosswalk …") | |
| + | all_legislators: list[dict] = [] | |
| + | for url in _LEGISLATORS_URLS: | |
| + | try: | |
| + | resp = requests.get(url, timeout=60) | |
| + | resp.raise_for_status() | |
| + | all_legislators.extend(resp.json()) | |
| + | except Exception as exc: | |
| + | logger.warning("voteview: could not fetch %s for name lookup: %s", url, exc) | |
| + | name_lookup = _build_name_lookup(all_legislators) | |
| + | logger.info("voteview: name-lookup built (%d last-name+state keys)", len(name_lookup)) | |
| + | ||
| + | total_rollcalls = 0 | |
| + | total_positions = 0 | |
| + | per_congress_log = [] | |
| + | ||
| + | for congress in range(start_congress, end_congress + 1): | |
| + | for chamber_prefix, chamber_name in [("H", "house"), ("S", "senate")]: | |
| + | # Augment crosswalk with name-matching for this specific congress+chamber | |
| + | # before ingesting votes (so positions get bioguides from the start). | |
| + | new_cw = augment_crosswalk_from_voteview_members( | |
| + | conn, crosswalk, congress, chamber_prefix, name_lookup, | |
| + | ) | |
| + | if new_cw: | |
| + | logger.info( | |
| + | "voteview: name-match added %d ICPSR→bioguide for %s%d", | |
| + | new_cw, chamber_prefix, congress, | |
| + | ) | |
| + | ||
| + | rc_count, pos_count = _ingest_congress_chamber( | |
| + | conn, congress, chamber_prefix, chamber_name, | |
| + | crosswalk, bg_to_id, | |
| + | ) | |
| + | total_rollcalls += rc_count | |
| + | total_positions += pos_count | |
| + | per_congress_log.append( | |
| + | { | |
| + | "congress": congress, | |
| + | "chamber": chamber_name, | |
| + | "rollcalls": rc_count, | |
| + | "positions": pos_count, | |
| + | } | |
| + | ) | |
| + | ||
| + | conn.close() | |
| + | logger.info( | |
| + | "voteview: done - %d rollcalls, %d positions (congress %d-%d)", | |
| + | total_rollcalls, total_positions, start_congress, end_congress, | |
| + | ) | |
| + | return { | |
| + | "rollcalls_total": total_rollcalls, | |
| + | "positions_total": total_positions, | |
| + | "per_congress": per_congress_log, | |
| + | } | |
| + | ||
| + | ||
| + | # ─── Year-index helpers ─────────────────────────────────────────────────────── | |
| + | ||
| + | def votes_by_member_year( | |
| + | bioguide: str, | |
| + | year: int, | |
| + | db_path: Optional[str] = None, | |
| + | ) -> list[dict]: | |
| + | """ | |
| + | Return all recorded votes for *bioguide* in *year*, with full metadata. | |
| + | ||
| + | Leverages idx_votes_year + idx_vote_positions_bioguide. | |
| + | ||
| + | Returns a list of dicts, each with: | |
| + | date, chamber, congress, roll_number, question, result, | |
| + | yea, nay, present, not_voting, bill_text_ref, description, | |
| + | source_url, position (the member's individual vote) | |
| + | ||
| + | Example: | |
| + | rows = votes_by_member_year("N000002", 2023) | |
| + | # → all 2023 House votes for Jerry Nadler with their positions | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT | |
| + | v.id, | |
| + | v.date, | |
| + | v.chamber, | |
| + | v.congress, | |
| + | v.roll_number, | |
| + | v.question, | |
| + | v.result, | |
| + | v.yea, | |
| + | v.nay, | |
| + | v.present, | |
| + | v.not_voting, | |
| + | v.bill_text_ref, | |
| + | v.description, | |
| + | v.source_url, | |
| + | vp.position | |
| + | FROM vote_positions vp | |
| + | JOIN votes v ON v.id = vp.vote_id | |
| + | WHERE vp.member_bioguide = ? | |
| + | AND v.year = ? | |
| + | ORDER BY v.date, v.chamber, v.roll_number | |
| + | """, | |
| + | (bioguide, year), | |
| + | ).fetchall() | |
| + | conn.close() | |
| + | return [dict(r) for r in rows] | |
| + | ||
| + | ||
| + | def votes_in_year( | |
| + | year: int, | |
| + | db_path: Optional[str] = None, | |
| + | ) -> list[dict]: | |
| + | """ | |
| + | Return all recorded votes that took place in *year*. | |
| + | ||
| + | Leverages idx_votes_year. | |
| + | ||
| + | Returns a list of dicts, each with: | |
| + | id, date, chamber, congress, roll_number, question, result, | |
| + | yea, nay, present, not_voting, bill_text_ref, description, source_url | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT | |
| + | id, date, chamber, congress, roll_number, question, result, | |
| + | yea, nay, present, not_voting, bill_text_ref, description, source_url | |
| + | FROM votes | |
| + | WHERE year = ? | |
| + | AND recorded = 1 | |
| + | ORDER BY date, chamber, roll_number | |
| + | """, | |
| + | (year,), | |
| + | ).fetchall() | |
| + | conn.close() | |
| + | return [dict(r) for r in rows] | |
| + | ||
| + | ||
| + | # ─── Completeness report ────────────────────────────────────────────────────── | |
| + | ||
| + | def completeness_report( | |
| + | start_congress: int, | |
| + | end_congress: int, | |
| + | db_path: Optional[str] = None, | |
| + | ) -> list[dict]: | |
| + | """ | |
| + | Compare ingested rollcall counts against Voteview CSV row counts. | |
| + | ||
| + | For each (congress, chamber) pair, fetches the rollcalls CSV and compares | |
| + | its row count against the number of rows recorded in the `seen` table. | |
| + | ||
| + | Returns a list of dicts: | |
| + | congress, chamber, csv_count, db_count, match, gap_note | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | report = [] | |
| + | ||
| + | for congress in range(start_congress, end_congress + 1): | |
| + | for chamber_prefix, chamber_name in [("H", "house"), ("S", "senate")]: | |
| + | # Count from DB via `seen` table (most reliable - one entry per | |
| + | # processed rollcall, written atomically with mark_seen). | |
| + | db_count = conn.execute( | |
| + | "SELECT COUNT(*) FROM seen WHERE source=? AND external_id LIKE ?", | |
| + | (SOURCE, f"{chamber_prefix}{congress}-%"), | |
| + | ).fetchone()[0] | |
| + | ||
| + | # Count from Voteview CSV (source of truth). | |
| + | rc_url = _rollcalls_url(chamber_prefix, congress) | |
| + | rc_rows = _fetch_csv(rc_url) | |
| + | if rc_rows is None: | |
| + | csv_count = None | |
| + | gap_note = "CSV unavailable" | |
| + | else: | |
| + | csv_count = len(rc_rows) | |
| + | # Any CSV rows with a 0/blank rollnumber would be skipped. | |
| + | valid_csv = sum( | |
| + | 1 for r in rc_rows | |
| + | if _safe_int(r.get("rollnumber") or 0) > 0 | |
| + | ) | |
| + | gap = valid_csv - db_count | |
| + | if gap == 0: | |
| + | gap_note = "exact match" | |
| + | elif gap > 0: | |
| + | gap_note = f"{gap} rollcalls in CSV not yet ingested" | |
| + | else: | |
| + | gap_note = f"DB count exceeds CSV by {abs(gap)} (re-run possible?)" | |
| + | ||
| + | report.append( | |
| + | { | |
| + | "congress": congress, | |
| + | "chamber": chamber_name, | |
| + | "csv_count": csv_count, | |
| + | "db_count": db_count, | |
| + | "match": (db_count == csv_count) if csv_count is not None else None, | |
| + | "gap_note": gap_note if csv_count is not None else "CSV unavailable", | |
| + | } | |
| + | ) | |
| + | ||
| + | conn.close() | |
| + | return report | |
| + | ||
| + | ||
| + | # ─── CLI ────────────────────────────────────────────────────────────────────── | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | ) | |
| + | parser = argparse.ArgumentParser( | |
| + | description=( | |
| + | "Docket Voteview poller - ingest per-member roll-call votes " | |
| + | "from Voteview bulk CSVs (recorded votes only)." | |
| + | ) | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--once", action="store_true", | |
| + | help="Run once and exit (required for CLI use).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--start", type=int, default=116, | |
| + | help="First congress to ingest (default: 116, i.e. 2019).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--end", type=int, default=119, | |
| + | help="Last congress to ingest (default: 119, i.e. 2025-26).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--db", help="Override DB path (uses DOCKET_DB_PATH env or default if omitted).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--refresh-crosswalk", action="store_true", | |
| + | help="Force re-download of the ICPSR→bioguide crosswalk.", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--report", action="store_true", | |
| + | help="Print a completeness comparison table after ingestion.", | |
| + | ) | |
| + | ||
| + | args = parser.parse_args() | |
| + | if not args.once: | |
| + | parser.print_help() | |
| + | return | |
| + | ||
| + | result = poll( | |
| + | start_congress=args.start, | |
| + | end_congress=args.end, | |
| + | db_path=args.db, | |
| + | force_crosswalk_refresh=args.refresh_crosswalk, | |
| + | ) | |
| + | ||
| + | print( | |
| + | f"\nvoteview: ingested {result['rollcalls_total']} vote rows, " | |
| + | f"{result['positions_total']} position rows " | |
| + | f"(congress {args.start}-{args.end})\n" | |
| + | ) | |
| + | print(f"{'Congress':>10} {'Chamber':<8} {'Rollcalls':>10} {'Positions':>10}") | |
| + | print("-" * 48) | |
| + | for row in result["per_congress"]: | |
| + | print( | |
| + | f"{row['congress']:>10} {row['chamber']:<8} " | |
| + | f"{row['rollcalls']:>10} {row['positions']:>10}" | |
| + | ) | |
| + | ||
| + | if args.report: | |
| + | print("\n── Completeness check (DB vs Voteview CSV) ──") | |
| + | cmp = completeness_report(args.start, args.end, db_path=args.db) | |
| + | print( | |
| + | f"\n{'Congress':>10} {'Chamber':<8} {'CSV rows':>10} " | |
| + | f"{'DB rows':>8} {'Match':<6} Note" | |
| + | ) | |
| + | print("-" * 70) | |
| + | for row in cmp: | |
| + | print( | |
| + | f"{row['congress']:>10} {row['chamber']:<8} " | |
| + | f"{str(row['csv_count']):>10} {row['db_count']:>8} " | |
| + | f"{'yes' if row['match'] else 'NO':<6} {row['gap_note']}" | |
| + | ) | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -52,7 +52,13 @@ PIPELINE = [ | ||
| ("openstates", "ingest.pollers.openstates", []), | ||
| ("legiscan", "ingest.pollers.legiscan", []), | ||
| ("state_coverage", "ingest.state_coverage", []), | ||
| + | # post-processing (run after raw ingest) | |
| + | ("categorize", "ingest.categorize", []), | |
| + | ("calendar", "ingest.calendar", []), | |
| ] | ||
| + | # Heavy/optional, not in the default --once cycle: historical voting backfill. | |
| + | # python -m ingest.pollers.voteview --once --start 101 --end 119 | |
| + | # Continuous change detection is a wrapper around this runner: ingest.watcher. | |
| _BY_LABEL = {label: (mod, args) for label, mod, args in PIPELINE} | ||
| # short aliases |
| @@ -0,0 +1,601 @@ | ||
| + | """ | |
| + | Docket ingest watcher - ADDED / UPDATED / REMOVED change detection. | |
| + | ||
| + | Run modes | |
| + | --------- | |
| + | python -m ingest.watcher --once # single cycle (systemd timer) | |
| + | python -m ingest.watcher --loop --interval 1800 # continuous loop (N seconds) | |
| + | python -m ingest.watcher --once --sources federal_register members | |
| + | ||
| + | Writes | |
| + | ------ | |
| + | change_events (id, ts, source, ref_type, ref_id, change, summary, source_url) | |
| + | poller_watermarks (source, last_run_at, cycle_id) | |
| + | watcher_manifest (source, external_id, cycle_id, source_url) | |
| + | ||
| + | ADDED / UPDATED detection | |
| + | -------------------------- | |
| + | Snapshots the `seen` table before and after the pipeline run. | |
| + | • New (source, external_id) in post-snapshot → ADDED | |
| + | • Same key, different update_date → UPDATED | |
| + | This works for every poller that calls `mark_seen()` (the majority). | |
| + | Members (which does a blind upsert without mark_seen) is covered separately | |
| + | via the manifest path below. | |
| + | ||
| + | REMOVED detection - full-set sources only | |
| + | ------------------------------------------ | |
| + | Only sources that return the *complete* current set on every poll can | |
| + | reliably detect removals. Two safety guards prevent false positives: | |
| + | ||
| + | 1. Non-empty guard - if the current pull returned 0 items for a source, | |
| + | no removals are flagged. An empty result likely indicates a failed | |
| + | or partial pull, not a genuine mass-removal. | |
| + | ||
| + | 2. First-run guard - if there is no prior manifest entry for the source, | |
| + | no removals are flagged. Without a baseline, there is nothing to diff. | |
| + | ||
| + | Append-only / paginated / time-windowed sources (federal_register, | |
| + | usaspending, congress, votes, crec, lobbying, openstates, legiscan, …) | |
| + | are NOT in FULL_SET_SOURCES and never produce REMOVED events. | |
| + | """ | |
| + | ||
| + | import argparse | |
| + | import json | |
| + | import logging | |
| + | import os | |
| + | import subprocess | |
| + | import sys | |
| + | import time | |
| + | from datetime import datetime, timezone | |
| + | from typing import Optional | |
| + | ||
| + | from ingest.db import DB_PATH, get_connection, init_schema | |
| + | ||
| + | logger = logging.getLogger("ingest.watcher") | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Full-set source registry | |
| + | # --------------------------------------------------------------------------- | |
| + | # Each entry: data-table name, primary-key column, optional WHERE filter, | |
| + | # source_url column, and ref_type label for change_events rows. | |
| + | # Add a source here ONLY when its poll() returns the entire current live set. | |
| + | FULL_SET_CONFIGS: dict[str, dict] = { | |
| + | "openomb": { | |
| + | "table": "apportionments", | |
| + | "id_col": "external_id", | |
| + | "where": "source = 'openomb'", | |
| + | "url_col": "source_url", | |
| + | "ref_type": "apportionment", | |
| + | }, | |
| + | "members": { | |
| + | "table": "members", | |
| + | "id_col": "bioguide_id", | |
| + | "where": None, # all rows belong to this source | |
| + | "url_col": "source_url", | |
| + | "ref_type": "member", | |
| + | }, | |
| + | } | |
| + | ||
| + | FULL_SET_SOURCES: frozenset[str] = frozenset(FULL_SET_CONFIGS) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # ref_type lookup | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | _SOURCE_TO_REF_TYPE: dict[str, str] = { | |
| + | "bills_bulk": "bill", | |
| + | "bill_versions": "bill", | |
| + | "congress": "bill", | |
| + | "govinfo": "bill", | |
| + | "amendments": "amendment", | |
| + | "members": "member", | |
| + | "usaspending": "award", | |
| + | "federal_register": "executive_action", | |
| + | "treasury": "fiscal", | |
| + | "openomb": "apportionment", | |
| + | "votes_senate": "vote", | |
| + | "votes_house": "vote", | |
| + | "votes_enrich": "vote", | |
| + | "crec": "procedural_event", | |
| + | "lobbying": "lobbying", | |
| + | "openstates": "state_bill", | |
| + | "legiscan": "state_bill", | |
| + | } | |
| + | ||
| + | ||
| + | def _ref_type(source: str) -> str: | |
| + | return _SOURCE_TO_REF_TYPE.get(source, source) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Schema (watcher-owned tables - additive, never touches existing tables) | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _ensure_watcher_tables(conn) -> None: | |
| + | """Create watcher-owned tables if they do not exist.""" | |
| + | conn.executescript( | |
| + | """ | |
| + | CREATE TABLE IF NOT EXISTS poller_watermarks ( | |
| + | source TEXT PRIMARY KEY, | |
| + | last_run_at TEXT NOT NULL, | |
| + | cycle_id INTEGER NOT NULL DEFAULT 0 | |
| + | ); | |
| + | ||
| + | CREATE TABLE IF NOT EXISTS change_events ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | ts TEXT NOT NULL, | |
| + | source TEXT NOT NULL, | |
| + | ref_type TEXT NOT NULL, | |
| + | ref_id TEXT NOT NULL, | |
| + | change TEXT NOT NULL CHECK(change IN ('added','updated','removed')), | |
| + | summary TEXT, | |
| + | source_url TEXT NOT NULL | |
| + | ); | |
| + | ||
| + | CREATE TABLE IF NOT EXISTS watcher_manifest ( | |
| + | source TEXT NOT NULL, | |
| + | external_id TEXT NOT NULL, | |
| + | cycle_id INTEGER NOT NULL, | |
| + | source_url TEXT NOT NULL DEFAULT '', | |
| + | PRIMARY KEY (source, external_id) | |
| + | ); | |
| + | """ | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Helpers | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _snapshot_seen(conn) -> dict[tuple, str]: | |
| + | """Return {(source, external_id): update_date} for the full seen table.""" | |
| + | rows = conn.execute( | |
| + | "SELECT source, external_id, update_date FROM seen" | |
| + | ).fetchall() | |
| + | return {(r["source"], r["external_id"]): r["update_date"] for r in rows} | |
| + | ||
| + | ||
| + | def _lookup_source_url(conn, source: str, external_id: str) -> str: | |
| + | """ | |
| + | Best-effort source_url lookup for a (source, external_id) pair. | |
| + | Priority: | |
| + | 1. raw_events payload (json-parsed for a URL key) | |
| + | 2. Source-specific data table | |
| + | 3. Sentinel (informational, not user-facing) | |
| + | """ | |
| + | # 1. raw_events payload | |
| + | row = conn.execute( | |
| + | "SELECT payload FROM raw_events WHERE source=? AND external_id=?", | |
| + | (source, external_id), | |
| + | ).fetchone() | |
| + | if row: | |
| + | try: | |
| + | payload = json.loads(row["payload"]) | |
| + | for key in ("html_url", "source_url", "url", "link", "detailUrl"): | |
| + | val = payload.get(key) | |
| + | if val and isinstance(val, str): | |
| + | return val | |
| + | except Exception: | |
| + | pass | |
| + | ||
| + | # 2. Data-table fallbacks | |
| + | _TABLE_QUERIES: dict[str, str] = { | |
| + | "bills_bulk": | |
| + | "SELECT source_url FROM bills WHERE source=? AND external_id=?", | |
| + | "bill_versions": | |
| + | "SELECT source_url FROM bills WHERE source=? AND external_id=?", | |
| + | "congress": | |
| + | "SELECT source_url FROM bills WHERE source=? AND external_id=?", | |
| + | "govinfo": | |
| + | "SELECT source_url FROM bills WHERE source=? AND external_id=?", | |
| + | "usaspending": | |
| + | "SELECT source_url FROM awards WHERE source=? AND external_id=?", | |
| + | "federal_register": | |
| + | "SELECT source_url FROM executive_actions WHERE source=? AND external_id=?", | |
| + | "openomb": | |
| + | "SELECT source_url FROM apportionments WHERE source=? AND external_id=?", | |
| + | } | |
| + | query = _TABLE_QUERIES.get(source) | |
| + | if query: | |
| + | try: | |
| + | r2 = conn.execute(query, (source, external_id)).fetchone() | |
| + | if r2: | |
| + | return r2["source_url"] | |
| + | except Exception: | |
| + | pass | |
| + | ||
| + | # members uses bioguide_id as PK, no source column | |
| + | if source == "members": | |
| + | try: | |
| + | r3 = conn.execute( | |
| + | "SELECT source_url FROM members WHERE bioguide_id=?", | |
| + | (external_id,), | |
| + | ).fetchone() | |
| + | if r3: | |
| + | return r3["source_url"] | |
| + | except Exception: | |
| + | pass | |
| + | ||
| + | # 3. Sentinel - indicates lookup failed; keeps source_url non-null | |
| + | return f"https://docket.internal/source/{source}/{external_id}" | |
| + | ||
| + | ||
| + | def _get_next_cycle_id(conn) -> int: | |
| + | row = conn.execute( | |
| + | "SELECT COALESCE(MAX(cycle_id), 0) AS m FROM poller_watermarks" | |
| + | ).fetchone() | |
| + | return (row["m"] if row else 0) + 1 | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # ADDED / UPDATED - seen-table diff | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _classify_added_updated( | |
| + | before: dict, | |
| + | after: dict, | |
| + | ts: str, | |
| + | conn, | |
| + | ) -> tuple[int, int]: | |
| + | """ | |
| + | Diff before/after snapshots of the seen table and write change_events. | |
| + | ||
| + | Returns (added_count, updated_count). | |
| + | """ | |
| + | added_keys = set(after) - set(before) | |
| + | updated_keys = {k for k in set(before) & set(after) if before[k] != after[k]} | |
| + | ||
| + | added_count = 0 | |
| + | for source, ext_id in sorted(added_keys): | |
| + | source_url = _lookup_source_url(conn, source, ext_id) | |
| + | rt = _ref_type(source) | |
| + | conn.execute( | |
| + | "INSERT INTO change_events" | |
| + | " (ts,source,ref_type,ref_id,change,summary,source_url)" | |
| + | " VALUES (?,?,?,?,?,?,?)", | |
| + | (ts, source, rt, ext_id, "added", | |
| + | f"New {rt} from {source}: {ext_id}", source_url), | |
| + | ) | |
| + | added_count += 1 | |
| + | ||
| + | updated_count = 0 | |
| + | for source, ext_id in sorted(updated_keys): | |
| + | source_url = _lookup_source_url(conn, source, ext_id) | |
| + | rt = _ref_type(source) | |
| + | old, new = before[(source, ext_id)], after[(source, ext_id)] | |
| + | conn.execute( | |
| + | "INSERT INTO change_events" | |
| + | " (ts,source,ref_type,ref_id,change,summary,source_url)" | |
| + | " VALUES (?,?,?,?,?,?,?)", | |
| + | (ts, source, rt, ext_id, "updated", | |
| + | f"{rt} {ext_id} updated ({old} → {new})", source_url), | |
| + | ) | |
| + | updated_count += 1 | |
| + | ||
| + | return added_count, updated_count | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # REMOVED / ADDED (manifest) - full-set sources only | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _query_current_set(conn, cfg: dict) -> dict[str, str]: | |
| + | """ | |
| + | Query the data table for a full-set source. | |
| + | Returns {external_id: source_url}, or {} if the table doesn't exist yet. | |
| + | """ | |
| + | table, id_col, where, url_col = ( | |
| + | cfg["table"], cfg["id_col"], cfg.get("where"), cfg["url_col"] | |
| + | ) | |
| + | exists = conn.execute( | |
| + | "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,) | |
| + | ).fetchone() | |
| + | if not exists: | |
| + | return {} | |
| + | sql = f"SELECT {id_col}, {url_col} FROM {table}" | |
| + | if where: | |
| + | sql += f" WHERE {where}" | |
| + | try: | |
| + | rows = conn.execute(sql).fetchall() | |
| + | except Exception as exc: | |
| + | logger.warning("watcher: _query_current_set(%s) failed: %s", table, exc) | |
| + | return {} | |
| + | return {r[0]: (r[1] or "") for r in rows} | |
| + | ||
| + | ||
| + | def _load_manifest(conn, source: str) -> dict[str, str]: | |
| + | """Load manifest for source from previous cycle: {external_id: source_url}.""" | |
| + | rows = conn.execute( | |
| + | "SELECT external_id, source_url FROM watcher_manifest WHERE source=?", | |
| + | (source,), | |
| + | ).fetchall() | |
| + | return {r["external_id"]: r["source_url"] for r in rows} | |
| + | ||
| + | ||
| + | def _update_manifest(conn, source: str, current_set: dict, cycle_id: int) -> None: | |
| + | """Replace the manifest for source with the current pull's set.""" | |
| + | conn.execute("DELETE FROM watcher_manifest WHERE source=?", (source,)) | |
| + | for ext_id, source_url in current_set.items(): | |
| + | conn.execute( | |
| + | "INSERT INTO watcher_manifest (source, external_id, cycle_id, source_url)" | |
| + | " VALUES (?,?,?,?)", | |
| + | (source, ext_id, cycle_id, source_url or ""), | |
| + | ) | |
| + | ||
| + | ||
| + | def _detect_full_set_changes( | |
| + | conn, | |
| + | source: str, | |
| + | cfg: dict, | |
| + | cycle_id: int, | |
| + | ts: str, | |
| + | ) -> tuple[int, int]: | |
| + | """ | |
| + | ADDED / REMOVED detection for a full-set source via manifest comparison. | |
| + | ||
| + | For sources that call mark_seen (openomb): ADDED events are already captured | |
| + | by the seen diff, so we skip adding them here to avoid duplicates. | |
| + | For sources that do NOT call mark_seen (members): we emit ADDED events here. | |
| + | ||
| + | Safety guards for REMOVED: | |
| + | • Non-empty guard: current_set empty → no removals flagged. | |
| + | • First-run guard: no prior manifest → no removals flagged. | |
| + | ||
| + | Returns (added_count, removed_count). | |
| + | """ | |
| + | rt = cfg["ref_type"] | |
| + | prev = _load_manifest(conn, source) | |
| + | current = _query_current_set(conn, cfg) | |
| + | ||
| + | # Non-empty guard | |
| + | if not current: | |
| + | logger.warning( | |
| + | "watcher: %s full-set pull returned 0 items - " | |
| + | "removal detection skipped (non-empty guard)", | |
| + | source, | |
| + | ) | |
| + | # Do not overwrite manifest with empty set to preserve the prior baseline | |
| + | return 0, 0 | |
| + | ||
| + | added_ids = set(current) - set(prev) | |
| + | removed_ids = set(prev) - set(current) | |
| + | ||
| + | added_count = 0 | |
| + | # Members does not call mark_seen, so its ADDs won't appear in the seen diff. | |
| + | # Emit ADDED events from the manifest for members only. | |
| + | if source not in ("openomb",): # openomb ADDs come from seen diff | |
| + | for ext_id in sorted(added_ids): | |
| + | source_url = current[ext_id] or _lookup_source_url(conn, source, ext_id) | |
| + | conn.execute( | |
| + | "INSERT INTO change_events" | |
| + | " (ts,source,ref_type,ref_id,change,summary,source_url)" | |
| + | " VALUES (?,?,?,?,?,?,?)", | |
| + | (ts, source, rt, ext_id, "added", | |
| + | f"New {rt}: {ext_id}", source_url), | |
| + | ) | |
| + | added_count += 1 | |
| + | ||
| + | removed_count = 0 | |
| + | if not prev: | |
| + | # First-run guard | |
| + | logger.info( | |
| + | "watcher: %s no prior manifest (first run) - " | |
| + | "removal detection skipped", | |
| + | source, | |
| + | ) | |
| + | else: | |
| + | for ext_id in sorted(removed_ids): | |
| + | source_url = ( | |
| + | prev.get(ext_id) | |
| + | or _lookup_source_url(conn, source, ext_id) | |
| + | ) | |
| + | conn.execute( | |
| + | "INSERT INTO change_events" | |
| + | " (ts,source,ref_type,ref_id,change,summary,source_url)" | |
| + | " VALUES (?,?,?,?,?,?,?)", | |
| + | (ts, source, rt, ext_id, "removed", | |
| + | f"{rt} {ext_id} absent from {source} full-set pull", | |
| + | source_url), | |
| + | ) | |
| + | removed_count += 1 | |
| + | logger.info("watcher: REMOVED %s:%s", source, ext_id) | |
| + | ||
| + | _update_manifest(conn, source, current, cycle_id) | |
| + | return added_count, removed_count | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Pipeline runner | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _run_pipeline(db_path: str, sources: Optional[list]) -> None: | |
| + | """Run the ingest pipeline (or a subset) as a subprocess.""" | |
| + | env = {**os.environ, "DOCKET_DB_PATH": db_path} | |
| + | ||
| + | if sources: | |
| + | for src in sources: | |
| + | cmd = [sys.executable, "-m", "ingest.run", "--source", src] | |
| + | logger.info("watcher: running step: %s", src) | |
| + | try: | |
| + | subprocess.run(cmd, check=False, env=env) | |
| + | except Exception as exc: | |
| + | logger.warning("watcher: step %s raised: %r", src, exc) | |
| + | else: | |
| + | logger.info("watcher: running full pipeline...") | |
| + | try: | |
| + | subprocess.run( | |
| + | [sys.executable, "-m", "ingest.run", "--once"], | |
| + | check=False, | |
| + | env=env, | |
| + | ) | |
| + | except Exception as exc: | |
| + | logger.error("watcher: pipeline subprocess raised: %r", exc) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Main cycle | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def run_cycle( | |
| + | db_path: Optional[str] = None, | |
| + | sources: Optional[list] = None, | |
| + | ) -> dict: | |
| + | """ | |
| + | Run one watcher cycle. | |
| + | ||
| + | Steps | |
| + | ----- | |
| + | 1. Snapshot seen table (before state). | |
| + | 2. Run ingest pipeline (subprocess, passes DOCKET_DB_PATH). | |
| + | 3. Snapshot seen table (after state). | |
| + | 4. Diff → ADDED / UPDATED events (seen diff, covers mark_seen pollers). | |
| + | 5. Manifest diff → ADDED (members) / REMOVED (full-set sources with guards). | |
| + | 6. Write change_events; update poller_watermarks. | |
| + | ||
| + | Parameters | |
| + | ---------- | |
| + | db_path : SQLite path override (falls back to DOCKET_DB_PATH env / default). | |
| + | sources : If set, only these pipeline step labels are run (for testing). | |
| + | ||
| + | Returns | |
| + | ------- | |
| + | dict: cycle_id, added, updated, removed, total_change_events | |
| + | """ | |
| + | db_path = db_path or os.environ.get("DOCKET_DB_PATH") or DB_PATH | |
| + | ||
| + | conn = get_connection(db_path) | |
| + | init_schema(conn) | |
| + | _ensure_watcher_tables(conn) | |
| + | ||
| + | cycle_id = _get_next_cycle_id(conn) | |
| + | ts = datetime.now(timezone.utc).isoformat() | |
| + | ||
| + | logger.info("=== Watcher cycle %d starting at %s ===", cycle_id, ts) | |
| + | ||
| + | # 1. Pre-pipeline snapshot | |
| + | before = _snapshot_seen(conn) | |
| + | logger.info("watcher: %d seen entries before pipeline", len(before)) | |
| + | conn.close() | |
| + | ||
| + | # 2. Run pipeline (subprocess writes to DB) | |
| + | _run_pipeline(db_path, sources) | |
| + | ||
| + | # 3. Re-open and snapshot after | |
| + | conn = get_connection(db_path) | |
| + | init_schema(conn) | |
| + | _ensure_watcher_tables(conn) | |
| + | after = _snapshot_seen(conn) | |
| + | logger.info( | |
| + | "watcher: %d seen entries after pipeline (%+d)", | |
| + | len(after), len(after) - len(before), | |
| + | ) | |
| + | ||
| + | # 4. Seen-table diff → ADDED / UPDATED | |
| + | added, updated = _classify_added_updated(before, after, ts, conn) | |
| + | ||
| + | # 5. Manifest diff for full-set sources | |
| + | removed = 0 | |
| + | for source, cfg in FULL_SET_CONFIGS.items(): | |
| + | if sources and source not in sources: | |
| + | # Only process full-set sources that were part of this cycle | |
| + | continue | |
| + | a, r = _detect_full_set_changes(conn, source, cfg, cycle_id, ts) | |
| + | added += a | |
| + | removed += r | |
| + | ||
| + | conn.commit() | |
| + | ||
| + | # 6. Update watermarks | |
| + | all_sources = {s for (s, _) in after} | FULL_SET_SOURCES | |
| + | for src in all_sources: | |
| + | conn.execute( | |
| + | """INSERT INTO poller_watermarks (source, last_run_at, cycle_id) VALUES (?,?,?) | |
| + | ON CONFLICT(source) DO UPDATE SET | |
| + | last_run_at = excluded.last_run_at, | |
| + | cycle_id = excluded.cycle_id""", | |
| + | (src, ts, cycle_id), | |
| + | ) | |
| + | conn.commit() | |
| + | conn.close() | |
| + | ||
| + | result = { | |
| + | "cycle_id": cycle_id, | |
| + | "added": added, | |
| + | "updated": updated, | |
| + | "removed": removed, | |
| + | "total_change_events": added + updated + removed, | |
| + | } | |
| + | logger.info( | |
| + | "=== Watcher cycle %d: +%d added ~%d updated -%d removed (%d total) ===", | |
| + | cycle_id, added, updated, removed, added + updated + removed, | |
| + | ) | |
| + | return result | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # CLI | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | stream=sys.stdout, | |
| + | ) | |
| + | parser = argparse.ArgumentParser( | |
| + | description="Docket watcher - detect ADDED/UPDATED/REMOVED government items." | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--once", action="store_true", | |
| + | help="Run a single cycle and exit (designed for systemd timer use).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--loop", action="store_true", | |
| + | help="Run continuously, sleeping --interval seconds between cycles.", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--interval", type=int, default=1800, metavar="SECONDS", | |
| + | help="Sleep between cycles in --loop mode (default: 1800 = 30 min).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--sources", nargs="+", metavar="STEP", | |
| + | help="Limit to specific pipeline steps (e.g. --sources federal_register members).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--db", metavar="PATH", | |
| + | help="Override DOCKET_DB_PATH environment variable.", | |
| + | ) | |
| + | args = parser.parse_args() | |
| + | ||
| + | db_path = args.db or os.environ.get("DOCKET_DB_PATH") or DB_PATH | |
| + | ||
| + | if args.once: | |
| + | result = run_cycle(db_path=db_path, sources=args.sources) | |
| + | print( | |
| + | f"Cycle {result['cycle_id']}: " | |
| + | f"+{result['added']} added " | |
| + | f"~{result['updated']} updated " | |
| + | f"-{result['removed']} removed " | |
| + | f"({result['total_change_events']} total change_events)" | |
| + | ) | |
| + | return | |
| + | ||
| + | if args.loop: | |
| + | logger.info("watcher: loop mode - interval=%ds", args.interval) | |
| + | while True: | |
| + | try: | |
| + | run_cycle(db_path=db_path, sources=args.sources) | |
| + | except Exception as exc: | |
| + | logger.error("watcher: cycle raised: %r", exc) | |
| + | logger.info("watcher: sleeping %ds until next cycle", args.interval) | |
| + | time.sleep(args.interval) | |
| + | return # unreachable | |
| + | ||
| + | parser.print_help() | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -0,0 +1,730 @@ | ||
| + | """ | |
| + | Tests for ingest/calendar.py | |
| + | ||
| + | Coverage: | |
| + | • parse_effective_dates() - all regex patterns, mixed text, no false positives | |
| + | • _normalize_date() - ISO passthrough, month-name parsing, bad input | |
| + | • ensure_schema() - table + index creation, effective_on column | |
| + | • _aggregate_effective() - pulls FR rules with effective_on from DB | |
| + | • _aggregate_enacted() - pulls BecameLaw actions from DB | |
| + | • _aggregate_votes() - pulls from votes table | |
| + | • _aggregate_bill_actions() - introduced / committee / floor | |
| + | • _aggregate_apportionments() - pulls from apportionments table | |
| + | • _aggregate_effective_parsed() - regex over action text | |
| + | • run() idempotency - double-run does not duplicate rows | |
| + | """ | |
| + | ||
| + | import os | |
| + | import sqlite3 | |
| + | import tempfile | |
| + | ||
| + | import pytest | |
| + | ||
| + | from ingest.db import get_connection, init_schema | |
| + | from ingest.calendar import ( | |
| + | EFFECTIVE_DATE_PATTERNS, | |
| + | _normalize_date, | |
| + | _aggregate_effective, | |
| + | _aggregate_enacted, | |
| + | _aggregate_votes, | |
| + | _aggregate_bill_actions, | |
| + | _aggregate_apportionments, | |
| + | _aggregate_effective_parsed, | |
| + | ensure_schema, | |
| + | parse_effective_dates, | |
| + | run, | |
| + | ) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Helpers | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _make_db() -> tuple[sqlite3.Connection, str]: | |
| + | """Return (conn, path) for a fresh temp DB with full schema.""" | |
| + | path = tempfile.mktemp(suffix=".db") | |
| + | conn = get_connection(path) | |
| + | init_schema(conn) | |
| + | ensure_schema(conn) | |
| + | return conn, path | |
| + | ||
| + | ||
| + | def _seed_executive_action( | |
| + | conn: sqlite3.Connection, | |
| + | external_id: str = "2026-99999", | |
| + | doc_type: str = "final_rule", | |
| + | title: str = "Test Rule", | |
| + | effective_on: str | None = "2026-08-01", | |
| + | source_url: str = "https://www.federalregister.gov/documents/2026/06/26/2026-99999/test-rule", | |
| + | ) -> int: | |
| + | """Insert one executive_actions row; return its id.""" | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO executive_actions | |
| + | (source, external_id, type, title, fr_doc_number, | |
| + | date, effective_on, source_url) | |
| + | VALUES ('federal_register', ?, ?, ?, ?, '2026-06-26', ?, ?) | |
| + | ON CONFLICT(source, external_id) DO NOTHING | |
| + | """, | |
| + | (external_id, doc_type, title, external_id, effective_on, source_url), | |
| + | ) | |
| + | conn.commit() | |
| + | row = conn.execute( | |
| + | "SELECT id FROM executive_actions WHERE external_id=?", (external_id,) | |
| + | ).fetchone() | |
| + | return row["id"] if row else -1 | |
| + | ||
| + | ||
| + | def _seed_bill( | |
| + | conn: sqlite3.Connection, | |
| + | external_id: str = "119-hr-1", | |
| + | title: str = "Test Act", | |
| + | source_url: str = "https://www.congress.gov/bill/119th-congress/house-bill/1", | |
| + | ) -> int: | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO bills | |
| + | (source, external_id, congress, number, title, | |
| + | status, updated_at, source_url) | |
| + | VALUES ('govinfo_billstatus', ?, 119, '1', ?, 'enacted', '2025-01-01', ?) | |
| + | ON CONFLICT(source, external_id) DO NOTHING | |
| + | """, | |
| + | (external_id, title, source_url), | |
| + | ) | |
| + | conn.commit() | |
| + | row = conn.execute( | |
| + | "SELECT id FROM bills WHERE external_id=?", (external_id,) | |
| + | ).fetchone() | |
| + | return row["id"] if row else -1 | |
| + | ||
| + | ||
| + | def _seed_action( | |
| + | conn: sqlite3.Connection, | |
| + | bill_id: int, | |
| + | action_type: str, | |
| + | action_date: str, | |
| + | text: str = "", | |
| + | source_url: str = "https://www.congress.gov/bill/119th-congress/house-bill/1", | |
| + | ) -> int: | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO actions | |
| + | (bill_id, source, action_type, action_date, text, source_url) | |
| + | VALUES (?, 'govinfo_billstatus', ?, ?, ?, ?) | |
| + | """, | |
| + | (bill_id, action_type, action_date, text, source_url), | |
| + | ) | |
| + | conn.commit() | |
| + | return conn.execute("SELECT last_insert_rowid()").fetchone()[0] | |
| + | ||
| + | ||
| + | def _seed_vote( | |
| + | conn: sqlite3.Connection, | |
| + | date: str = "2025-03-15", | |
| + | result: str = "Passed", | |
| + | source_url: str = "https://www.senate.gov/legislative/LIS/roll_call_votes/vote1191/vote_119_1_00001.xml", | |
| + | ) -> int: | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO votes (chamber, date, yea, nay, present, result, source_url) | |
| + | VALUES ('senate', ?, 60, 40, 0, ?, ?) | |
| + | """, | |
| + | (date, result, source_url), | |
| + | ) | |
| + | conn.commit() | |
| + | return conn.execute("SELECT last_insert_rowid()").fetchone()[0] | |
| + | ||
| + | ||
| + | def _seed_apportionment( | |
| + | conn: sqlite3.Connection, | |
| + | external_id: str = "test-apportionment-1", | |
| + | agency: str = "Department of Defense", | |
| + | date: str = "2025-10-01", | |
| + | source_url: str = "https://apportionment-public.max.gov/test.json", | |
| + | ) -> int: | |
| + | # Ensure table exists | |
| + | conn.execute( | |
| + | """ | |
| + | CREATE TABLE IF NOT EXISTS apportionments ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | source TEXT NOT NULL DEFAULT 'openomb', | |
| + | external_id TEXT NOT NULL, | |
| + | agency TEXT, | |
| + | account TEXT, | |
| + | appropriated REAL, | |
| + | apportioned REAL, | |
| + | status TEXT, | |
| + | date TEXT, | |
| + | source_url TEXT NOT NULL, | |
| + | UNIQUE(source, external_id) | |
| + | ) | |
| + | """ | |
| + | ) | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO apportionments | |
| + | (source, external_id, agency, account, status, date, source_url) | |
| + | VALUES ('openomb', ?, ?, 'Operations', 'released', ?, ?) | |
| + | ON CONFLICT(source, external_id) DO NOTHING | |
| + | """, | |
| + | (external_id, agency, date, source_url), | |
| + | ) | |
| + | conn.commit() | |
| + | row = conn.execute( | |
| + | "SELECT id FROM apportionments WHERE external_id=?", (external_id,) | |
| + | ).fetchone() | |
| + | return row["id"] if row else -1 | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _normalize_date | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestNormalizeDate: | |
| + | def test_iso_passthrough(self): | |
| + | assert _normalize_date("2026-08-01") == "2026-08-01" | |
| + | ||
| + | def test_full_month_name(self): | |
| + | assert _normalize_date("August 5, 2026") == "2026-08-05" | |
| + | ||
| + | def test_abbreviated_month_name(self): | |
| + | assert _normalize_date("Jan 1, 2025") == "2025-01-01" | |
| + | ||
| + | def test_leading_trailing_space(self): | |
| + | assert _normalize_date(" 2026-01-15 ") == "2026-01-15" | |
| + | ||
| + | def test_unparseable_returns_none(self): | |
| + | assert _normalize_date("sometime next year") is None | |
| + | assert _normalize_date("") is None | |
| + | assert _normalize_date("180 days after enactment") is None | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # parse_effective_dates | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestParseEffectiveDates: | |
| + | def test_shall_take_effect_on(self): | |
| + | text = "This section shall take effect on January 1, 2026." | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2026-01-01" in dates | |
| + | ||
| + | def test_takes_effect_on(self): | |
| + | text = "The rule takes effect on August 5, 2026." | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2026-08-05" in dates | |
| + | ||
| + | def test_effective_on(self): | |
| + | text = "Effective on September 30, 2025, the following applies." | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2025-09-30" in dates | |
| + | ||
| + | def test_effective_date_is(self): | |
| + | text = "The effective date is March 15, 2026." | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2026-03-15" in dates | |
| + | ||
| + | def test_effective_date_shall_be(self): | |
| + | text = "The effective date shall be July 4, 2026." | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2026-07-04" in dates | |
| + | ||
| + | def test_this_act_takes_effect(self): | |
| + | text = "This Act shall take effect on December 31, 2025." | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2025-12-31" in dates | |
| + | ||
| + | def test_iso_date_format(self): | |
| + | text = "Effective on 2026-06-01, new requirements apply." | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2026-06-01" in dates | |
| + | ||
| + | def test_no_dates_returns_empty(self): | |
| + | text = "The rule is effective 180 days after enactment." | |
| + | dates = parse_effective_dates(text) | |
| + | assert dates == [] | |
| + | ||
| + | def test_deduplication(self): | |
| + | text = ( | |
| + | "Effective on January 1, 2026. " | |
| + | "This Act takes effect on January 1, 2026." | |
| + | ) | |
| + | dates = parse_effective_dates(text) | |
| + | assert dates.count("2026-01-01") == 1 | |
| + | ||
| + | def test_multiple_distinct_dates(self): | |
| + | text = ( | |
| + | "Section 1 takes effect on March 1, 2026. " | |
| + | "Section 2 shall take effect on June 1, 2026." | |
| + | ) | |
| + | dates = parse_effective_dates(text) | |
| + | assert "2026-03-01" in dates | |
| + | assert "2026-06-01" in dates | |
| + | assert len(dates) == 2 | |
| + | ||
| + | def test_relative_clause_not_extracted(self): | |
| + | text = "This provision takes effect 90 days after enactment." | |
| + | dates = parse_effective_dates(text) | |
| + | assert dates == [] | |
| + | ||
| + | def test_empty_string(self): | |
| + | assert parse_effective_dates("") == [] | |
| + | ||
| + | def test_patterns_compile(self): | |
| + | """Ensure all regex patterns are valid and pre-compiled.""" | |
| + | for p in EFFECTIVE_DATE_PATTERNS: | |
| + | assert hasattr(p, "finditer"), f"Pattern {p!r} is not compiled" | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # ensure_schema | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestEnsureSchema: | |
| + | def test_table_created(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | tables = { | |
| + | r[0] | |
| + | for r in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='table'" | |
| + | ) | |
| + | } | |
| + | assert "calendar_events" in tables | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_date_index_created(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | indexes = { | |
| + | r[0] | |
| + | for r in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='index'" | |
| + | ) | |
| + | } | |
| + | assert "idx_calendar_events_date" in indexes | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_kind_index_created(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | indexes = { | |
| + | r[0] | |
| + | for r in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='index'" | |
| + | ) | |
| + | } | |
| + | assert "idx_calendar_events_kind" in indexes | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_effective_on_column_added_to_executive_actions(self): | |
| + | conn, path = _make_db() | |
| + | # Manually create executive_actions WITHOUT effective_on | |
| + | conn.execute( | |
| + | """ | |
| + | CREATE TABLE IF NOT EXISTS executive_actions ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | source TEXT NOT NULL DEFAULT 'federal_register', | |
| + | external_id TEXT NOT NULL, | |
| + | type TEXT NOT NULL, | |
| + | title TEXT, | |
| + | agency TEXT, | |
| + | fr_doc_number TEXT, | |
| + | date TEXT, | |
| + | source_url TEXT NOT NULL, | |
| + | UNIQUE(source, external_id) | |
| + | ) | |
| + | """ | |
| + | ) | |
| + | conn.commit() | |
| + | ensure_schema(conn) | |
| + | cols = { | |
| + | r[1] | |
| + | for r in conn.execute("PRAGMA table_info(executive_actions)") | |
| + | } | |
| + | assert "effective_on" in cols | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_ensure_schema_idempotent(self): | |
| + | """Calling ensure_schema twice must not raise.""" | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | ensure_schema(conn) # second call | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_kind_check_constraint(self): | |
| + | """Invalid kind values must be rejected by the CHECK constraint.""" | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | with pytest.raises(sqlite3.IntegrityError): | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO calendar_events | |
| + | (date, kind, ref_type, source_url) | |
| + | VALUES ('2026-01-01', 'INVALID_KIND', 'bill', | |
| + | 'https://www.congress.gov/') | |
| + | """ | |
| + | ) | |
| + | conn.commit() | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _aggregate_effective | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestAggregateEffective: | |
| + | def test_basic(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | _seed_executive_action(conn, effective_on="2026-08-05") | |
| + | count = _aggregate_effective(conn) | |
| + | conn.commit() | |
| + | assert count == 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='effective'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["date"] == "2026-08-05" | |
| + | assert row["ref_type"] == "executive_action" | |
| + | assert row["confidence"] == 1.0 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_null_effective_on_excluded(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | _seed_executive_action(conn, effective_on=None) | |
| + | count = _aggregate_effective(conn) | |
| + | conn.commit() | |
| + | assert count == 0 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_notice_type_excluded(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | _seed_executive_action( | |
| + | conn, external_id="2026-99998", doc_type="notice", effective_on="2026-09-01" | |
| + | ) | |
| + | count = _aggregate_effective(conn) | |
| + | conn.commit() | |
| + | assert count == 0 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_source_url_present(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | _seed_executive_action(conn, effective_on="2026-10-01") | |
| + | _aggregate_effective(conn) | |
| + | conn.commit() | |
| + | row = conn.execute( | |
| + | "SELECT source_url FROM calendar_events WHERE kind='effective'" | |
| + | ).fetchone() | |
| + | assert row["source_url"].startswith("https://www.federalregister.gov") | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _aggregate_enacted | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestAggregateEnacted: | |
| + | def test_basic(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action( | |
| + | conn, bill_id, "BecameLaw", "2025-07-04", | |
| + | text="Became Public Law No: 119-10.", | |
| + | ) | |
| + | count = _aggregate_enacted(conn) | |
| + | conn.commit() | |
| + | assert count == 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='enacted'" | |
| + | ).fetchone() | |
| + | assert row["date"] == "2025-07-04" | |
| + | assert row["ref_type"] == "bill" | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_non_became_law_action_excluded(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action(conn, bill_id, "IntroReferral", "2025-01-10") | |
| + | count = _aggregate_enacted(conn) | |
| + | conn.commit() | |
| + | assert count == 0 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _aggregate_votes | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestAggregateVotes: | |
| + | def test_basic(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | _seed_vote(conn, date="2025-05-20", result="Passed") | |
| + | count = _aggregate_votes(conn) | |
| + | conn.commit() | |
| + | assert count == 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='vote'" | |
| + | ).fetchone() | |
| + | assert row["date"] == "2025-05-20" | |
| + | assert row["ref_type"] == "vote" | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_multiple_votes(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | for day in ["2025-01-09", "2025-01-13", "2025-01-15"]: | |
| + | _seed_vote(conn, date=day) | |
| + | count = _aggregate_votes(conn) | |
| + | conn.commit() | |
| + | assert count == 3 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _aggregate_bill_actions | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestAggregateBillActions: | |
| + | def test_introduced(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action(conn, bill_id, "IntroReferral", "2025-01-03") | |
| + | _aggregate_bill_actions(conn) | |
| + | conn.commit() | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='introduced'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["date"] == "2025-01-03" | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_committee(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action(conn, bill_id, "Committee", "2025-02-14") | |
| + | _aggregate_bill_actions(conn) | |
| + | conn.commit() | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='committee'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["date"] == "2025-02-14" | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_floor(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action(conn, bill_id, "Floor", "2025-03-01") | |
| + | _aggregate_bill_actions(conn) | |
| + | conn.commit() | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='floor'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["date"] == "2025-03-01" | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_all_three_kinds_populated(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action(conn, bill_id, "IntroReferral", "2025-01-03") | |
| + | _seed_action(conn, bill_id, "Committee", "2025-01-20") | |
| + | _seed_action(conn, bill_id, "Floor", "2025-02-10") | |
| + | _aggregate_bill_actions(conn) | |
| + | conn.commit() | |
| + | kinds = { | |
| + | r[0] | |
| + | for r in conn.execute( | |
| + | "SELECT DISTINCT kind FROM calendar_events" | |
| + | ) | |
| + | } | |
| + | assert {"introduced", "committee", "floor"} <= kinds | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _aggregate_apportionments | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestAggregateApportionments: | |
| + | def test_basic(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | _seed_apportionment(conn, date="2025-10-01") | |
| + | count = _aggregate_apportionments(conn) | |
| + | conn.commit() | |
| + | assert count == 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='apportionment'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["date"] == "2025-10-01" | |
| + | assert row["ref_type"] == "apportionment" | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_no_table_returns_zero(self): | |
| + | """If apportionments table does not exist, return 0 gracefully.""" | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | # Don't seed the apportionments table | |
| + | count = _aggregate_apportionments(conn) | |
| + | assert count == 0 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _aggregate_effective_parsed | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestAggregateEffectiveParsed: | |
| + | def test_extracts_date_from_action_text(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action( | |
| + | conn, bill_id, "BecameLaw", "2025-07-04", | |
| + | text=( | |
| + | "Became Public Law No: 119-10. " | |
| + | "This Act shall take effect on January 1, 2026." | |
| + | ), | |
| + | ) | |
| + | count = _aggregate_effective_parsed(conn) | |
| + | conn.commit() | |
| + | assert count >= 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM calendar_events WHERE kind='effective_parsed'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["date"] == "2026-01-01" | |
| + | assert row["confidence"] == 0.5 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | def test_no_dates_in_text_returns_zero(self): | |
| + | conn, path = _make_db() | |
| + | try: | |
| + | bill_id = _seed_bill(conn) | |
| + | _seed_action( | |
| + | conn, bill_id, "BecameLaw", "2025-07-04", | |
| + | text="Became Public Law No: 119-10.", | |
| + | ) | |
| + | count = _aggregate_effective_parsed(conn) | |
| + | conn.commit() | |
| + | assert count == 0 | |
| + | finally: | |
| + | conn.close() | |
| + | os.unlink(path) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # run() - idempotency | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestRunIdempotency: | |
| + | def test_double_run_no_duplicates(self): | |
| + | """Running run() twice must not duplicate rows.""" | |
| + | conn, path = _make_db() | |
| + | conn.close() # run() opens its own connection | |
| + | try: | |
| + | bill_id_conn = get_connection(path) | |
| + | bill_id_conn.row_factory = sqlite3.Row | |
| + | init_schema(bill_id_conn) | |
| + | ensure_schema(bill_id_conn) | |
| + | ||
| + | # Seed some data | |
| + | bill_id = _seed_bill(bill_id_conn) | |
| + | _seed_action(bill_id_conn, bill_id, "BecameLaw", "2025-07-04", | |
| + | text="Became Public Law No: 119-10.") | |
| + | _seed_action(bill_id_conn, bill_id, "IntroReferral", "2025-01-03") | |
| + | _seed_vote(bill_id_conn, date="2025-05-20") | |
| + | ||
| + | # Add executive_actions for effective | |
| + | _seed_executive_action( | |
| + | bill_id_conn, effective_on="2026-08-01" | |
| + | ) | |
| + | bill_id_conn.commit() | |
| + | bill_id_conn.close() | |
| + | ||
| + | # First run | |
| + | counts1 = run(db_path=path) | |
| + | ||
| + | # Second run | |
| + | counts2 = run(db_path=path) | |
| + | ||
| + | # Counts should be the same (no duplicates) | |
| + | for kind in counts1: | |
| + | assert counts1[kind] == counts2[kind], ( | |
| + | f"kind={kind!r}: first run={counts1[kind]}, " | |
| + | f"second run={counts2[kind]}" | |
| + | ) | |
| + | finally: | |
| + | if os.path.exists(path): | |
| + | os.unlink(path) | |
| + | ||
| + | def test_run_returns_counts_dict(self): | |
| + | """run() must return a dict with at least the core kinds.""" | |
| + | conn, path = _make_db() | |
| + | conn.close() | |
| + | try: | |
| + | counts = run(db_path=path) | |
| + | assert isinstance(counts, dict) | |
| + | for kind in ( | |
| + | "effective", "enacted", "vote", | |
| + | "introduced", "committee", "floor", | |
| + | "apportionment", "effective_parsed", | |
| + | ): | |
| + | assert kind in counts, f"Missing kind {kind!r} in counts" | |
| + | finally: | |
| + | if os.path.exists(path): | |
| + | os.unlink(path) |
| @@ -0,0 +1,433 @@ | ||
| + | """ | |
| + | Tests for ingest.categorize - unified taxonomy module. | |
| + | ||
| + | Covers: | |
| + | - agency_to_category: deterministic mapping, prefix match, unknown agencies | |
| + | - POLICY_AREAS: completeness and slug format | |
| + | - _parse_subjects: XML parsing from a synthetic BILLSTATUS snippet | |
| + | - Schema helpers: ensure tables and columns are created idempotently | |
| + | - _seed_categories: idempotent seeding, extra bill areas picked up | |
| + | - _backfill_awards: correct category assignment, NULL handling | |
| + | - category_counts: returns expected shape | |
| + | """ | |
| + | ||
| + | import sqlite3 | |
| + | import textwrap | |
| + | import xml.etree.ElementTree as ET | |
| + | ||
| + | import pytest | |
| + | ||
| + | from ingest.categorize import ( | |
| + | AGENCY_CATEGORY_MAP, | |
| + | POLICY_AREAS, | |
| + | agency_to_category, | |
| + | _parse_subjects, | |
| + | _ensure_schema, | |
| + | _seed_categories, | |
| + | _backfill_awards, | |
| + | _slug_from_label, | |
| + | category_counts, | |
| + | ) | |
| + | from ingest.db import init_schema | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Fixtures | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | @pytest.fixture | |
| + | def conn(): | |
| + | """In-memory SQLite connection with base schema initialised.""" | |
| + | c = sqlite3.connect(":memory:") | |
| + | c.row_factory = sqlite3.Row | |
| + | c.execute("PRAGMA foreign_keys=ON") | |
| + | init_schema(c) | |
| + | _ensure_schema(c) | |
| + | yield c | |
| + | c.close() | |
| + | ||
| + | ||
| + | def _insert_award(conn, agency: str, category: str | None = None) -> int: | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO awards (source, external_id, agency, recipient, amount, | |
| + | action_date, program, source_url, category) | |
| + | VALUES ('usaspending', ?, ?, 'Test Recipient', 1000000, | |
| + | '2026-01-01', 'Test Program', | |
| + | 'https://www.usaspending.gov/award/TEST', ?) | |
| + | """, | |
| + | (f"award-{agency[:20]}", agency, category), | |
| + | ) | |
| + | conn.commit() | |
| + | return conn.execute("SELECT last_insert_rowid()").fetchone()[0] | |
| + | ||
| + | ||
| + | def _insert_bill(conn, policy_area: str = "", external_id: str = "119-hr-1") -> int: | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO bills (source, external_id, congress, number, title, | |
| + | sponsor_id, status, policy_area, updated_at, source_url) | |
| + | VALUES ('govinfo_billstatus', ?, 119, 'H.R. 1', 'Test Bill', | |
| + | '', '', ?, '2026-01-01', 'https://www.congress.gov/bill/119th-congress/house-bill/1') | |
| + | """, | |
| + | (external_id, policy_area), | |
| + | ) | |
| + | conn.commit() | |
| + | return conn.execute("SELECT last_insert_rowid()").fetchone()[0] | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # agency_to_category tests | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestAgencyToCategory: | |
| + | """Deterministic agency→policy-area mapping.""" | |
| + | ||
| + | def test_exact_match_defense(self): | |
| + | assert agency_to_category("Department of Defense") == "Armed Forces and National Security" | |
| + | ||
| + | def test_exact_match_health(self): | |
| + | assert agency_to_category("Department of Health and Human Services") == "Health" | |
| + | ||
| + | def test_exact_match_transportation(self): | |
| + | assert agency_to_category("Department of Transportation") == "Transportation and Public Works" | |
| + | ||
| + | def test_exact_match_education(self): | |
| + | assert agency_to_category("Department of Education") == "Education" | |
| + | ||
| + | def test_exact_match_energy(self): | |
| + | assert agency_to_category("Department of Energy") == "Energy" | |
| + | ||
| + | def test_exact_match_agriculture(self): | |
| + | assert agency_to_category("Department of Agriculture") == "Agriculture and Food" | |
| + | ||
| + | def test_exact_match_labor(self): | |
| + | assert agency_to_category("Department of Labor") == "Labor and Employment" | |
| + | ||
| + | def test_exact_match_epa(self): | |
| + | assert agency_to_category("Environmental Protection Agency") == "Environmental Protection" | |
| + | ||
| + | def test_exact_match_doj(self): | |
| + | assert agency_to_category("Department of Justice") == "Crime and Law Enforcement" | |
| + | ||
| + | def test_exact_match_state(self): | |
| + | assert agency_to_category("Department of State") == "International Affairs" | |
| + | ||
| + | def test_exact_match_usaid(self): | |
| + | assert agency_to_category("Agency for International Development") == "International Affairs" | |
| + | ||
| + | def test_exact_match_commerce(self): | |
| + | assert agency_to_category("Department of Commerce") == "Commerce" | |
| + | ||
| + | def test_exact_match_treasury(self): | |
| + | assert agency_to_category("Department of the Treasury") == "Economics and Public Finance" | |
| + | ||
| + | def test_exact_match_va(self): | |
| + | assert agency_to_category("Department of Veterans Affairs") == "Armed Forces and National Security" | |
| + | ||
| + | def test_exact_match_dhs(self): | |
| + | assert agency_to_category("Department of Homeland Security") == "Emergency Management" | |
| + | ||
| + | def test_exact_match_interior(self): | |
| + | assert agency_to_category("Department of the Interior") == "Public Lands and Natural Resources" | |
| + | ||
| + | def test_exact_match_hud(self): | |
| + | assert agency_to_category("Department of Housing and Urban Development") == "Housing and Community Development" | |
| + | ||
| + | def test_exact_match_ssa(self): | |
| + | assert agency_to_category("Social Security Administration") == "Social Welfare" | |
| + | ||
| + | def test_exact_match_nasa(self): | |
| + | assert agency_to_category("National Aeronautics and Space Administration") == "Science, Technology, Communications" | |
| + | ||
| + | def test_exact_match_nsf(self): | |
| + | assert agency_to_category("National Science Foundation") == "Science, Technology, Communications" | |
| + | ||
| + | def test_exact_match_gsa(self): | |
| + | assert agency_to_category("General Services Administration") == "Government Operations and Politics" | |
| + | ||
| + | def test_prefix_match_dod_suborg(self): | |
| + | """Sub-agency starting with 'Department of Defense' should map correctly.""" | |
| + | result = agency_to_category("Department of Defense - Defense Advanced Research Projects Agency") | |
| + | assert result == "Armed Forces and National Security" | |
| + | ||
| + | def test_unknown_agency_returns_none(self): | |
| + | assert agency_to_category("Some Completely Unknown Agency XYZ") is None | |
| + | ||
| + | def test_empty_string_returns_none(self): | |
| + | assert agency_to_category("") is None | |
| + | ||
| + | def test_none_returns_none(self): | |
| + | assert agency_to_category(None) is None | |
| + | ||
| + | def test_all_map_keys_are_in_policy_area_labels(self): | |
| + | """Every agency map value must correspond to a canonical policy-area label.""" | |
| + | valid_labels = {label for _, label in POLICY_AREAS} | |
| + | for agency, category in AGENCY_CATEGORY_MAP.items(): | |
| + | assert category in valid_labels, ( | |
| + | f"Agency '{agency}' maps to '{category}' which is not a canonical policy area" | |
| + | ) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # POLICY_AREAS structure tests | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestPolicyAreas: | |
| + | def test_has_expected_count(self): | |
| + | # Should have ~32 entries; we allow 30-40 range for future additions | |
| + | assert 28 <= len(POLICY_AREAS) <= 45, f"Unexpected POLICY_AREAS count: {len(POLICY_AREAS)}" | |
| + | ||
| + | def test_no_duplicate_slugs(self): | |
| + | slugs = [slug for slug, _ in POLICY_AREAS] | |
| + | assert len(slugs) == len(set(slugs)), "Duplicate slugs in POLICY_AREAS" | |
| + | ||
| + | def test_no_duplicate_labels(self): | |
| + | labels = [label for _, label in POLICY_AREAS] | |
| + | assert len(labels) == len(set(labels)), "Duplicate labels in POLICY_AREAS" | |
| + | ||
| + | def test_slugs_are_lowercase_hyphenated(self): | |
| + | import re | |
| + | for slug, _ in POLICY_AREAS: | |
| + | assert re.match(r"^[a-z0-9-]+$", slug), f"Slug '{slug}' has invalid characters" | |
| + | ||
| + | def test_key_areas_present(self): | |
| + | labels = {label for _, label in POLICY_AREAS} | |
| + | required = { | |
| + | "Health", | |
| + | "Armed Forces and National Security", | |
| + | "Education", | |
| + | "Energy", | |
| + | "Taxation", | |
| + | "Transportation and Public Works", | |
| + | "Environmental Protection", | |
| + | "Agriculture and Food", | |
| + | "Crime and Law Enforcement", | |
| + | "International Affairs", | |
| + | } | |
| + | missing = required - labels | |
| + | assert not missing, f"Missing required policy areas: {missing}" | |
| + | ||
| + | def test_slug_matches_label(self): | |
| + | """Generated slug from label should equal the declared slug (or be a close match).""" | |
| + | for declared_slug, label in POLICY_AREAS: | |
| + | generated = _slug_from_label(label) | |
| + | # They should be the same; allow minor variation for comma-containing labels | |
| + | assert declared_slug == generated or declared_slug.replace("-", "") == generated.replace("-", ""), ( | |
| + | f"Slug mismatch: declared='{declared_slug}' generated='{generated}' for label='{label}'" | |
| + | ) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _parse_subjects tests | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | _BILLSTATUS_TEMPLATE = """\ | |
| + | <?xml version="1.0" encoding="UTF-8"?> | |
| + | <billStatus> | |
| + | <bill> | |
| + | <subjects> | |
| + | <legislativeSubjects> | |
| + | {items} | |
| + | </legislativeSubjects> | |
| + | </subjects> | |
| + | </bill> | |
| + | </billStatus>""" | |
| + | ||
| + | def _make_billstatus(subjects: list[str]) -> bytes: | |
| + | items = "".join(f"<item><name>{s}</name></item>" for s in subjects) | |
| + | return _BILLSTATUS_TEMPLATE.format(items=items).encode() | |
| + | ||
| + | ||
| + | class TestParseSubjects: | |
| + | def test_parses_single_subject(self): | |
| + | xml_bytes = _make_billstatus(["Health care reform"]) | |
| + | assert _parse_subjects(xml_bytes) == ["Health care reform"] | |
| + | ||
| + | def test_parses_multiple_subjects(self): | |
| + | subjects = ["Tax exemptions", "Income tax", "Small business"] | |
| + | xml_bytes = _make_billstatus(subjects) | |
| + | assert _parse_subjects(xml_bytes) == subjects | |
| + | ||
| + | def test_empty_legislative_subjects(self): | |
| + | xml_bytes = _make_billstatus([]) | |
| + | assert _parse_subjects(xml_bytes) == [] | |
| + | ||
| + | def test_missing_subjects_element(self): | |
| + | xml_bytes = b"<billStatus><bill><title>Test</title></bill></billStatus>" | |
| + | assert _parse_subjects(xml_bytes) == [] | |
| + | ||
| + | def test_malformed_xml_returns_empty(self): | |
| + | assert _parse_subjects(b"not valid xml<<<") == [] | |
| + | ||
| + | def test_strips_whitespace_from_subject_names(self): | |
| + | xml_bytes = _make_billstatus([" Defense contracts "]) | |
| + | result = _parse_subjects(xml_bytes) | |
| + | assert result == ["Defense contracts"] | |
| + | ||
| + | def test_bill_root_without_billstatus_wrapper(self): | |
| + | """Some GovInfo responses have <bill> as the root element.""" | |
| + | xml_bytes = b"""<bill> | |
| + | <subjects> | |
| + | <legislativeSubjects> | |
| + | <item><name>Veterans</name></item> | |
| + | </legislativeSubjects> | |
| + | </subjects> | |
| + | </bill>""" | |
| + | assert _parse_subjects(xml_bytes) == ["Veterans"] | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _ensure_schema tests | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestEnsureSchema: | |
| + | def test_creates_categories_table(self, conn): | |
| + | tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} | |
| + | assert "categories" in tables | |
| + | ||
| + | def test_creates_bill_subjects_table(self, conn): | |
| + | tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} | |
| + | assert "bill_subjects" in tables | |
| + | ||
| + | def test_adds_category_column_to_awards(self, conn): | |
| + | cols = {r[1] for r in conn.execute("PRAGMA table_info(awards)")} | |
| + | assert "category" in cols | |
| + | ||
| + | def test_idempotent_second_call(self, conn): | |
| + | """Calling _ensure_schema twice must not raise.""" | |
| + | _ensure_schema(conn) # second call | |
| + | cols = {r[1] for r in conn.execute("PRAGMA table_info(awards)")} | |
| + | assert "category" in cols | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _seed_categories tests | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestSeedCategories: | |
| + | def test_seeds_all_policy_areas(self, conn): | |
| + | _seed_categories(conn) | |
| + | count = conn.execute("SELECT COUNT(*) FROM categories").fetchone()[0] | |
| + | assert count >= len(POLICY_AREAS) | |
| + | ||
| + | def test_idempotent_double_seed(self, conn): | |
| + | _seed_categories(conn) | |
| + | count1 = conn.execute("SELECT COUNT(*) FROM categories").fetchone()[0] | |
| + | _seed_categories(conn) | |
| + | count2 = conn.execute("SELECT COUNT(*) FROM categories").fetchone()[0] | |
| + | assert count1 == count2 | |
| + | ||
| + | def test_picks_up_extra_bill_policy_area(self, conn): | |
| + | """A policy area present in bills but not in POLICY_AREAS is added.""" | |
| + | conn.execute(""" | |
| + | INSERT INTO bills (source, external_id, congress, number, title, | |
| + | sponsor_id, status, policy_area, updated_at, source_url) | |
| + | VALUES ('govinfo_billstatus', 'test-1', 119, 'H.R. 999', 'Test', | |
| + | '', '', 'Futuristic Technologies', '2026-01-01', | |
| + | 'https://www.congress.gov/bill/119th-congress/house-bill/999') | |
| + | """) | |
| + | conn.commit() | |
| + | _seed_categories(conn) | |
| + | row = conn.execute( | |
| + | "SELECT * FROM categories WHERE label = 'Futuristic Technologies'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | ||
| + | def test_health_area_in_categories(self, conn): | |
| + | _seed_categories(conn) | |
| + | row = conn.execute("SELECT * FROM categories WHERE label = 'Health'").fetchone() | |
| + | assert row is not None | |
| + | assert row["kind"] == "policy_area" | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _backfill_awards tests | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestBackfillAwards: | |
| + | def test_categorizes_known_agency(self, conn): | |
| + | _insert_award(conn, "Department of Defense") | |
| + | _backfill_awards(conn) | |
| + | row = conn.execute("SELECT category FROM awards WHERE agency = 'Department of Defense'").fetchone() | |
| + | assert row["category"] == "Armed Forces and National Security" | |
| + | ||
| + | def test_leaves_unknown_agency_null(self, conn): | |
| + | _insert_award(conn, "Unknown Mystery Agency") | |
| + | _backfill_awards(conn) | |
| + | row = conn.execute("SELECT category FROM awards WHERE agency = 'Unknown Mystery Agency'").fetchone() | |
| + | assert row["category"] is None | |
| + | ||
| + | def test_does_not_overwrite_existing_category(self, conn): | |
| + | """Awards that already have a category should keep it if the agency is unknown.""" | |
| + | _insert_award(conn, "Unknown Mystery Agency", category="Existing Category") | |
| + | _backfill_awards(conn) | |
| + | row = conn.execute("SELECT category FROM awards WHERE agency = 'Unknown Mystery Agency'").fetchone() | |
| + | # _backfill_awards only touches rows where category IS NULL or '' | |
| + | assert row["category"] == "Existing Category" | |
| + | ||
| + | def test_multiple_agencies(self, conn): | |
| + | agencies = [ | |
| + | ("Department of Health and Human Services", "Health"), | |
| + | ("Department of Transportation", "Transportation and Public Works"), | |
| + | ("National Science Foundation", "Science, Technology, Communications"), | |
| + | ("Social Security Administration", "Social Welfare"), | |
| + | ] | |
| + | for agency, _ in agencies: | |
| + | _insert_award(conn, agency) | |
| + | _backfill_awards(conn) | |
| + | for agency, expected_cat in agencies: | |
| + | row = conn.execute( | |
| + | "SELECT category FROM awards WHERE agency = ?", (agency,) | |
| + | ).fetchone() | |
| + | assert row["category"] == expected_cat, f"Wrong category for {agency}" | |
| + | ||
| + | def test_returns_count_of_updated(self, conn): | |
| + | _insert_award(conn, "Department of Energy") | |
| + | _insert_award(conn, "Department of Labor") | |
| + | updated = _backfill_awards(conn) | |
| + | assert updated == 2 | |
| + | ||
| + | def test_idempotent(self, conn): | |
| + | _insert_award(conn, "Department of Defense") | |
| + | _backfill_awards(conn) | |
| + | updated2 = _backfill_awards(conn) | |
| + | # Second run: no rows left with category IS NULL for DoD | |
| + | assert updated2 == 0 | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # category_counts tests | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestCategoryCounts: | |
| + | def test_returns_list(self, conn): | |
| + | _seed_categories(conn) | |
| + | result = category_counts(conn) | |
| + | assert isinstance(result, list) | |
| + | ||
| + | def test_each_item_has_expected_keys(self, conn): | |
| + | _seed_categories(conn) | |
| + | result = category_counts(conn) | |
| + | assert len(result) > 0 | |
| + | for item in result: | |
| + | assert "label" in item | |
| + | assert "bills" in item | |
| + | assert "awards" in item | |
| + | ||
| + | def test_bill_counted_in_correct_category(self, conn): | |
| + | _seed_categories(conn) | |
| + | _insert_bill(conn, policy_area="Health") | |
| + | result = category_counts(conn) | |
| + | health_row = next((r for r in result if r["label"] == "Health"), None) | |
| + | assert health_row is not None | |
| + | assert health_row["bills"] == 1 | |
| + | ||
| + | def test_award_counted_in_correct_category(self, conn): | |
| + | _seed_categories(conn) | |
| + | _insert_award(conn, "Department of Defense") | |
| + | _backfill_awards(conn) | |
| + | result = category_counts(conn) | |
| + | row = next((r for r in result if r["label"] == "Armed Forces and National Security"), None) | |
| + | assert row is not None | |
| + | assert row["awards"] == 1 |
| @@ -0,0 +1,536 @@ | ||
| + | """ | |
| + | Tests for voteview.py - cast_code mapping, crosswalk building, schema setup, | |
| + | source URL construction, and year-index helpers. | |
| + | ||
| + | All network access is mocked; no live HTTP calls are made. | |
| + | """ | |
| + | ||
| + | from __future__ import annotations | |
| + | ||
| + | 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.voteview import ( | |
| + | CAST_CODE_MAP, | |
| + | _derive_session, | |
| + | _house_source_url, | |
| + | _senate_source_url, | |
| + | build_crosswalk, | |
| + | cast_code_to_position, | |
| + | completeness_report, | |
| + | ensure_voteview_schema, | |
| + | votes_by_member_year, | |
| + | votes_in_year, | |
| + | ) | |
| + | ||
| + | ||
| + | # ─── Fixtures ───────────────────────────────────────────────────────────────── | |
| + | ||
| + | def _make_db() -> tuple[sqlite3.Connection, str]: | |
| + | """Create a fresh temp-file DB with all schemas applied.""" | |
| + | tmp = tempfile.mktemp(suffix=".db") | |
| + | conn = get_connection(tmp) | |
| + | init_schema(conn) | |
| + | ensure_votes_schema(conn) | |
| + | ensure_voteview_schema(conn) | |
| + | return conn, tmp | |
| + | ||
| + | ||
| + | # ─── cast_code_to_position ──────────────────────────────────────────────────── | |
| + | ||
| + | class TestCastCodeToPosition: | |
| + | """Verify the full Voteview cast-code → canonical position mapping.""" | |
| + | ||
| + | def test_code_1_yea(self): | |
| + | assert cast_code_to_position(1) == "Yea" | |
| + | ||
| + | def test_code_2_paired_yea(self): | |
| + | assert cast_code_to_position(2) == "Yea" | |
| + | ||
| + | def test_code_3_announced_yea(self): | |
| + | assert cast_code_to_position(3) == "Yea" | |
| + | ||
| + | def test_code_4_announced_nay(self): | |
| + | assert cast_code_to_position(4) == "Nay" | |
| + | ||
| + | def test_code_5_paired_nay(self): | |
| + | assert cast_code_to_position(5) == "Nay" | |
| + | ||
| + | def test_code_6_nay(self): | |
| + | assert cast_code_to_position(6) == "Nay" | |
| + | ||
| + | def test_code_7_present_v1(self): | |
| + | assert cast_code_to_position(7) == "Present" | |
| + | ||
| + | def test_code_8_present_v2(self): | |
| + | assert cast_code_to_position(8) == "Present" | |
| + | ||
| + | def test_code_9_not_voting(self): | |
| + | assert cast_code_to_position(9) == "Not Voting" | |
| + | ||
| + | def test_code_0_not_a_member(self): | |
| + | """Code 0 = not a member / absent no record → Not Voting.""" | |
| + | assert cast_code_to_position(0) == "Not Voting" | |
| + | ||
| + | def test_all_codes_covered(self): | |
| + | """Every code in the map must return a valid canonical position.""" | |
| + | valid = {"Yea", "Nay", "Present", "Not Voting"} | |
| + | for code in range(0, 10): | |
| + | pos = cast_code_to_position(code) | |
| + | assert pos in valid, f"cast_code {code} → {pos!r} is not in {valid}" | |
| + | ||
| + | def test_string_codes_accepted(self): | |
| + | """CSV columns are strings; the function must coerce them.""" | |
| + | assert cast_code_to_position("1") == "Yea" | |
| + | assert cast_code_to_position("6") == "Nay" | |
| + | assert cast_code_to_position("9") == "Not Voting" | |
| + | ||
| + | def test_float_string_accepted(self): | |
| + | """Voteview CSVs sometimes encode integers as floats (e.g. '1.0').""" | |
| + | assert cast_code_to_position("1.0") == "Yea" | |
| + | assert cast_code_to_position("6.0") == "Nay" | |
| + | ||
| + | def test_unknown_code_returns_none(self): | |
| + | assert cast_code_to_position(99) is None | |
| + | assert cast_code_to_position(-1) is None | |
| + | assert cast_code_to_position(10) is None | |
| + | ||
| + | def test_bad_input_returns_none(self): | |
| + | assert cast_code_to_position("") is None | |
| + | assert cast_code_to_position(None) is None | |
| + | assert cast_code_to_position("x") is None | |
| + | ||
| + | def test_map_has_exactly_ten_entries(self): | |
| + | """The codebook defines codes 0-9; no other codes should exist.""" | |
| + | assert set(CAST_CODE_MAP.keys()) == set(range(0, 10)) | |
| + | ||
| + | def test_yea_group_codes_all_map_to_yea(self): | |
| + | for code in (1, 2, 3): | |
| + | assert CAST_CODE_MAP[code] == "Yea", f"code {code} should be Yea" | |
| + | ||
| + | def test_nay_group_codes_all_map_to_nay(self): | |
| + | for code in (4, 5, 6): | |
| + | assert CAST_CODE_MAP[code] == "Nay", f"code {code} should be Nay" | |
| + | ||
| + | def test_present_group_codes_map_to_present(self): | |
| + | for code in (7, 8): | |
| + | assert CAST_CODE_MAP[code] == "Present", f"code {code} should be Present" | |
| + | ||
| + | def test_not_voting_group_codes_map_to_not_voting(self): | |
| + | for code in (0, 9): | |
| + | assert CAST_CODE_MAP[code] == "Not Voting", f"code {code} should be Not Voting" | |
| + | ||
| + | ||
| + | # ─── Source URL builders ────────────────────────────────────────────────────── | |
| + | ||
| + | class TestSourceUrls: | |
| + | def test_house_url_format(self): | |
| + | url = _house_source_url(2023, 42) | |
| + | assert url == "https://clerk.house.gov/Votes/2023/roll042" | |
| + | assert url.startswith("https://clerk.house.gov") | |
| + | ||
| + | def test_house_url_zero_pads_to_3_digits(self): | |
| + | assert _house_source_url(2024, 1).endswith("roll001") | |
| + | assert _house_source_url(2024, 99).endswith("roll099") | |
| + | assert _house_source_url(2024, 100).endswith("roll100") | |
| + | ||
| + | def test_senate_url_format(self): | |
| + | url = _senate_source_url(118, 1, 42) | |
| + | assert url == ( | |
| + | "https://www.senate.gov/legislative/LIS/roll_call_votes/" | |
| + | "vote1181/vote_118_1_00042.xml" | |
| + | ) | |
| + | assert "senate.gov" in url | |
| + | ||
| + | def test_senate_url_zero_pads_roll_to_5_digits(self): | |
| + | url = _senate_source_url(119, 2, 7) | |
| + | assert "vote_119_2_00007.xml" in url | |
| + | ||
| + | def test_house_url_host_is_gov(self): | |
| + | from ingest.source_validator import is_allowlisted | |
| + | assert is_allowlisted(_house_source_url(2025, 1)) | |
| + | ||
| + | def test_senate_url_host_is_gov(self): | |
| + | from ingest.source_validator import is_allowlisted | |
| + | assert is_allowlisted(_senate_source_url(119, 1, 1)) | |
| + | ||
| + | ||
| + | # ─── Session derivation ─────────────────────────────────────────────────────── | |
| + | ||
| + | class TestDeriveSession: | |
| + | def test_csv_session_preferred(self): | |
| + | assert _derive_session("2023-01-15", "1") == 1 | |
| + | assert _derive_session("2024-06-01", "2") == 2 | |
| + | ||
| + | def test_odd_year_fallback_to_session_1(self): | |
| + | assert _derive_session("2023-03-01", "") == 1 | |
| + | assert _derive_session("2021-07-04", "") == 1 | |
| + | ||
| + | def test_even_year_fallback_to_session_2(self): | |
| + | assert _derive_session("2024-11-01", "") == 2 | |
| + | assert _derive_session("2022-01-01", "") == 2 | |
| + | ||
| + | def test_bad_date_defaults_to_1(self): | |
| + | assert _derive_session("", "") == 1 | |
| + | assert _derive_session("bad-date", "x") == 1 | |
| + | ||
| + | ||
| + | # ─── Schema setup ───────────────────────────────────────────────────────────── | |
| + | ||
| + | class TestEnsureVoteviewSchema: | |
| + | def test_crosswalk_table_created(self): | |
| + | conn, tmp = _make_db() | |
| + | tables = { | |
| + | row[0] | |
| + | for row in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='table'" | |
| + | ) | |
| + | } | |
| + | assert "voteview_crosswalk" in tables | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_year_column_added_to_votes(self): | |
| + | conn, tmp = _make_db() | |
| + | cols = { | |
| + | row[1] | |
| + | for row in conn.execute("PRAGMA table_info(votes)") | |
| + | } | |
| + | assert "year" in cols, "year column must be added to votes table" | |
| + | assert "description" in cols | |
| + | assert "voteview_rollnumber" in cols | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_year_index_exists(self): | |
| + | conn, tmp = _make_db() | |
| + | indexes = { | |
| + | row[0] | |
| + | for row in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='index'" | |
| + | ) | |
| + | } | |
| + | assert "idx_votes_year" in indexes | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_idempotent_multiple_calls(self): | |
| + | conn, tmp = _make_db() | |
| + | # Should not raise on second call. | |
| + | ensure_voteview_schema(conn) | |
| + | ensure_voteview_schema(conn) | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | ||
| + | # ─── Crosswalk building ─────────────────────────────────────────────────────── | |
| + | ||
| + | class TestBuildCrosswalk: | |
| + | """Build the crosswalk from a mock legislators JSON (no HTTP).""" | |
| + | ||
| + | _MOCK_LEGISLATORS = [ | |
| + | { | |
| + | "id": {"bioguide": "A000001", "icpsr": 12345}, | |
| + | "name": {"official_full": "Alice Anderson"}, | |
| + | "terms": [{"type": "rep", "state": "NY"}], | |
| + | }, | |
| + | { | |
| + | "id": {"bioguide": "B000002", "icpsr": 67890}, | |
| + | "name": {"official_full": "Bob Brown"}, | |
| + | "terms": [{"type": "sen", "state": "CA"}], | |
| + | }, | |
| + | { | |
| + | # Member with no ICPSR - should be skipped without error. | |
| + | "id": {"bioguide": "C000003"}, | |
| + | "name": {"official_full": "Carol Carter"}, | |
| + | "terms": [{"type": "rep", "state": "TX"}], | |
| + | }, | |
| + | { | |
| + | # Member with both icpsr and icpsr_hn (House/Senate split). | |
| + | "id": {"bioguide": "D000004", "icpsr": 11111, "icpsr_hn": 22222}, | |
| + | "name": {"official_full": "Dave Davis"}, | |
| + | "terms": [{"type": "rep", "state": "FL"}], | |
| + | }, | |
| + | ] | |
| + | ||
| + | def _make_crosswalk_from_mock(self) -> tuple[dict, sqlite3.Connection, str]: | |
| + | """ | |
| + | Build the crosswalk by directly inserting mock data, bypassing HTTP. | |
| + | Returns (crosswalk_dict, conn, tmp_path). | |
| + | """ | |
| + | conn, tmp = _make_db() | |
| + | ||
| + | now = "2025-01-01T00:00:00+00:00" | |
| + | for leg in self._MOCK_LEGISLATORS: | |
| + | ids = leg.get("id", {}) | |
| + | bioguide = ids.get("bioguide", "").strip() | |
| + | full_name = leg.get("name", {}).get("official_full", "") | |
| + | for field in ("icpsr", "icpsr_hn"): | |
| + | raw = ids.get(field) | |
| + | if raw is None: | |
| + | continue | |
| + | try: | |
| + | icpsr_int = int(float(raw)) | |
| + | except (ValueError, TypeError): | |
| + | continue | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO voteview_crosswalk | |
| + | (icpsr_id, bioguide_id, full_name, fetched_at) | |
| + | VALUES (?, ?, ?, ?) | |
| + | ON CONFLICT(icpsr_id) DO UPDATE SET | |
| + | bioguide_id = excluded.bioguide_id, | |
| + | full_name = excluded.full_name, | |
| + | fetched_at = excluded.fetched_at | |
| + | """, | |
| + | (icpsr_int, bioguide, full_name, now), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | # Now load via the cache path. | |
| + | crosswalk = build_crosswalk(conn, force_refresh=False) | |
| + | return crosswalk, conn, tmp | |
| + | ||
| + | def test_primary_icpsr_mapped(self): | |
| + | cw, conn, tmp = self._make_crosswalk_from_mock() | |
| + | assert cw[12345] == "A000001" | |
| + | assert cw[67890] == "B000002" | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_icpsr_hn_also_mapped(self): | |
| + | """Both icpsr and icpsr_hn for D000004 should map to the same bioguide.""" | |
| + | cw, conn, tmp = self._make_crosswalk_from_mock() | |
| + | assert cw[11111] == "D000004" | |
| + | assert cw[22222] == "D000004" | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_member_without_icpsr_not_in_crosswalk(self): | |
| + | cw, conn, tmp = self._make_crosswalk_from_mock() | |
| + | # C000003 has no ICPSR - no entry for it in the crosswalk. | |
| + | bioguides = set(cw.values()) | |
| + | assert "C000003" not in bioguides or all( | |
| + | v != "C000003" for v in cw.values() | |
| + | ) | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_cached_load_returns_same_data(self): | |
| + | """Second call (force_refresh=False) should return the same crosswalk.""" | |
| + | cw1, conn, tmp = self._make_crosswalk_from_mock() | |
| + | cw2 = build_crosswalk(conn, force_refresh=False) | |
| + | assert cw1 == cw2 | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_unknown_icpsr_returns_none_from_get(self): | |
| + | cw, conn, tmp = self._make_crosswalk_from_mock() | |
| + | assert cw.get(99999) is None | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | ||
| + | # ─── Year-index helpers ─────────────────────────────────────────────────────── | |
| + | ||
| + | class TestYearIndexHelpers: | |
| + | """ | |
| + | Seed a minimal DB with known votes + positions, then verify the | |
| + | year-index helper queries return the correct rows. | |
| + | """ | |
| + | ||
| + | def _seed_db(self) -> tuple[sqlite3.Connection, str]: | |
| + | conn, tmp = _make_db() | |
| + | ||
| + | # Insert two members. | |
| + | for bioguide, name, state in [ | |
| + | ("N000002", "Jerry Nadler", "NY"), | |
| + | ("C000714", "James Clyburn", "SC"), | |
| + | ]: | |
| + | conn.execute( | |
| + | "INSERT INTO members " | |
| + | "(bioguide_id, full_name, party, state, chamber, source_url) " | |
| + | "VALUES (?, ?, ?, ?, ?, ?)", | |
| + | ( | |
| + | bioguide, name, "D", state, "house", | |
| + | f"https://bioguide.congress.gov/search/bio/{bioguide}", | |
| + | ), | |
| + | ) | |
| + | ||
| + | # Insert three votes: two in 2023, one in 2024. | |
| + | votes_data = [ | |
| + | # (chamber, date, year, yea, nay, result, source_url, congress, roll_number) | |
| + | ("house", "2023-01-19", 2023, 213, 215, "Failed", | |
| + | "https://clerk.house.gov/Votes/2023/roll010", 118, 10), | |
| + | ("house", "2023-06-01", 2023, 220, 210, "Passed", | |
| + | "https://clerk.house.gov/Votes/2023/roll200", 118, 200), | |
| + | ("house", "2024-03-15", 2024, 230, 200, "Passed", | |
| + | "https://clerk.house.gov/Votes/2024/roll100", 118, 100), | |
| + | ] | |
| + | vote_ids = [] | |
| + | for row in votes_data: | |
| + | conn.execute( | |
| + | """ | |
| + | INSERT INTO votes | |
| + | (chamber, date, year, yea, nay, result, source_url, | |
| + | congress, roll_number, recorded, present, not_voting) | |
| + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0) | |
| + | """, | |
| + | row, | |
| + | ) | |
| + | vid = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] | |
| + | vote_ids.append(vid) | |
| + | ||
| + | # Positions: N000002 votes on all three; C000714 only on vote #1. | |
| + | for vid, pos in zip(vote_ids, ["Yea", "Nay", "Yea"]): | |
| + | conn.execute( | |
| + | "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) " | |
| + | "VALUES (?, ?, NULL, ?)", | |
| + | (vid, "N000002", pos), | |
| + | ) | |
| + | conn.execute( | |
| + | "INSERT INTO vote_positions (vote_id, member_bioguide, member_id, position) " | |
| + | "VALUES (?, ?, NULL, ?)", | |
| + | (vote_ids[0], "C000714", "Yea"), | |
| + | ) | |
| + | conn.commit() | |
| + | return conn, tmp | |
| + | ||
| + | def test_votes_by_member_year_returns_correct_count(self): | |
| + | conn, tmp = self._seed_db() | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT * FROM vote_positions vp | |
| + | JOIN votes v ON v.id = vp.vote_id | |
| + | WHERE vp.member_bioguide = 'N000002' AND v.year = 2023 | |
| + | """ | |
| + | ).fetchall() | |
| + | assert len(rows) == 2, "N000002 voted on 2 rolls in 2023" | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_votes_by_member_year_2024(self): | |
| + | conn, tmp = self._seed_db() | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT * FROM vote_positions vp | |
| + | JOIN votes v ON v.id = vp.vote_id | |
| + | WHERE vp.member_bioguide = 'N000002' AND v.year = 2024 | |
| + | """ | |
| + | ).fetchall() | |
| + | assert len(rows) == 1 | |
| + | assert dict(rows[0])["date"] == "2024-03-15" | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_votes_by_member_year_empty_for_nonexistent_year(self): | |
| + | conn, tmp = self._seed_db() | |
| + | rows = conn.execute( | |
| + | """ | |
| + | SELECT * FROM vote_positions vp | |
| + | JOIN votes v ON v.id = vp.vote_id | |
| + | WHERE vp.member_bioguide = 'N000002' AND v.year = 2019 | |
| + | """ | |
| + | ).fetchall() | |
| + | assert rows == [] | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_votes_in_year_2023(self): | |
| + | conn, tmp = self._seed_db() | |
| + | rows = conn.execute( | |
| + | "SELECT * FROM votes WHERE year = 2023 AND recorded = 1" | |
| + | ).fetchall() | |
| + | assert len(rows) == 2 | |
| + | dates = sorted(dict(r)["date"] for r in rows) | |
| + | assert dates == ["2023-01-19", "2023-06-01"] | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_votes_in_year_2024(self): | |
| + | conn, tmp = self._seed_db() | |
| + | rows = conn.execute( | |
| + | "SELECT * FROM votes WHERE year = 2024 AND recorded = 1" | |
| + | ).fetchall() | |
| + | assert len(rows) == 1 | |
| + | assert dict(rows[0])["roll_number"] == 100 | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_year_index_is_used(self): | |
| + | """EXPLAIN QUERY PLAN should mention the year index (not a full scan).""" | |
| + | conn, tmp = self._seed_db() | |
| + | plan = conn.execute( | |
| + | "EXPLAIN QUERY PLAN SELECT * FROM votes WHERE year = 2023" | |
| + | ).fetchall() | |
| + | # EXPLAIN QUERY PLAN rows have a 'detail' column (index 3) in SQLite 3.24+. | |
| + | # Concatenate all columns of every row as a single lowercase string. | |
| + | plan_text = " ".join( | |
| + | " ".join(str(col) for col in tuple(row)) | |
| + | for row in plan | |
| + | ).lower() | |
| + | # Should reference the index, not a full table scan. | |
| + | assert "idx_votes_year" in plan_text, ( | |
| + | f"Expected idx_votes_year in query plan; got: {plan_text!r}" | |
| + | ) | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_position_enum_enforced_for_voteview_positions(self): | |
| + | """vote_positions CHECK constraint rejects cast_code-derived invalid positions.""" | |
| + | conn, tmp = _make_db() | |
| + | conn.execute( | |
| + | "INSERT INTO votes (chamber, date, year, yea, nay, result, source_url, recorded) " | |
| + | "VALUES ('house', '2023-01-01', 2023, 1, 0, 'Passed', " | |
| + | "'https://clerk.house.gov/Votes/2023/roll001', 1)" | |
| + | ) | |
| + | vid = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] | |
| + | with pytest.raises(sqlite3.IntegrityError): | |
| + | conn.execute( | |
| + | "INSERT INTO vote_positions (vote_id, member_bioguide, position) " | |
| + | "VALUES (?, ?, ?)", | |
| + | (vid, "Z000001", "Abstain"), # not in enum | |
| + | ) | |
| + | conn.commit() | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | ||
| + | # ─── End-to-end: schema idempotency ────────────────────────────────────────── | |
| + | ||
| + | class TestSchemaIdempotency: | |
| + | """Running schema setup multiple times must not corrupt the DB.""" | |
| + | ||
| + | def test_full_schema_setup_multiple_times(self): | |
| + | conn, tmp = _make_db() | |
| + | for _ in range(3): | |
| + | init_schema(conn) | |
| + | ensure_votes_schema(conn) | |
| + | ensure_voteview_schema(conn) | |
| + | # Verify tables still intact. | |
| + | tables = { | |
| + | row[0] | |
| + | for row in conn.execute( | |
| + | "SELECT name FROM sqlite_master WHERE type='table'" | |
| + | ) | |
| + | } | |
| + | for t in ("votes", "vote_positions", "members", "voteview_crosswalk"): | |
| + | assert t in tables | |
| + | conn.close() | |
| + | os.unlink(tmp) | |
| + | ||
| + | def test_cast_code_coverage_against_schema_enum(self): | |
| + | """ | |
| + | Every position value produced by CAST_CODE_MAP must satisfy the | |
| + | vote_positions CHECK constraint (Yea, Nay, Present, Not Voting). | |
| + | """ | |
| + | valid = {"Yea", "Nay", "Present", "Not Voting"} | |
| + | for code, position in CAST_CODE_MAP.items(): | |
| + | assert position in valid, ( | |
| + | f"CAST_CODE_MAP[{code}] = {position!r} is not in the DB enum {valid}" | |
| + | ) |
| @@ -0,0 +1,452 @@ | ||
| + | """ | |
| + | Tests for ingest.watcher - ADDED / UPDATED / REMOVED classification. | |
| + | ||
| + | All tests are purely synthetic (no network calls, no subprocess). | |
| + | They exercise: | |
| + | - _classify_added_updated() : seen-table diff logic | |
| + | - _detect_full_set_changes() : manifest-diff + removal guards | |
| + | - Guard: non-empty pull (prevents false removals on empty result) | |
| + | - Guard: first-run (no prior manifest → no removals) | |
| + | - source_url non-null invariant on every change_event | |
| + | """ | |
| + | ||
| + | import os | |
| + | import tempfile | |
| + | ||
| + | import pytest | |
| + | ||
| + | from ingest.db import get_connection, init_schema | |
| + | from ingest.watcher import ( | |
| + | FULL_SET_CONFIGS, | |
| + | _classify_added_updated, | |
| + | _detect_full_set_changes, | |
| + | _ensure_watcher_tables, | |
| + | _load_manifest, | |
| + | _snapshot_seen, | |
| + | ) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # Fixtures | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | def _make_db(): | |
| + | """Return (conn, tmp_path) for a fresh in-memory-equivalent temp DB.""" | |
| + | tmp = tempfile.mktemp(suffix=".db") | |
| + | conn = get_connection(tmp) | |
| + | init_schema(conn) | |
| + | _ensure_watcher_tables(conn) | |
| + | return conn, tmp | |
| + | ||
| + | ||
| + | def _cleanup(conn, tmp): | |
| + | conn.close() | |
| + | try: | |
| + | os.unlink(tmp) | |
| + | except OSError: | |
| + | pass | |
| + | ||
| + | ||
| + | def _seed_seen(conn, rows): | |
| + | """Seed seen table with list of (source, external_id, update_date).""" | |
| + | for source, ext_id, update_date in rows: | |
| + | conn.execute( | |
| + | "INSERT OR REPLACE INTO seen (source, external_id, update_date)" | |
| + | " VALUES (?,?,?)", | |
| + | (source, ext_id, update_date), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | ||
| + | def _seed_members_table(conn, members): | |
| + | """Create and populate the members table with minimal columns.""" | |
| + | conn.executescript( | |
| + | """ | |
| + | CREATE TABLE IF NOT EXISTS members ( | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | bioguide_id TEXT NOT NULL UNIQUE, | |
| + | full_name TEXT NOT NULL DEFAULT '', | |
| + | party TEXT, state TEXT, district INTEGER, chamber TEXT, | |
| + | lis_member_id TEXT, govtrack_id INTEGER, icpsr_id INTEGER, | |
| + | source_url TEXT NOT NULL | |
| + | ); | |
| + | """ | |
| + | ) | |
| + | for m in members: | |
| + | bio = m["bioguide_id"] | |
| + | conn.execute( | |
| + | "INSERT OR REPLACE INTO members (bioguide_id, full_name, source_url)" | |
| + | " VALUES (?,?,?)", | |
| + | (bio, m.get("full_name", "Test Member"), | |
| + | m.get("source_url", f"https://bioguide.congress.gov/search/bio/{bio}")), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | ||
| + | TS = "2026-06-26T00:00:00+00:00" | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _classify_added_updated - seen-table diff | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestClassifyAddedUpdated: | |
| + | ||
| + | def test_added_single_new_entry(self): | |
| + | conn, tmp = _make_db() | |
| + | before = {} | |
| + | after = {("federal_register", "2026-12345"): "2026-06-25"} | |
| + | added, updated = _classify_added_updated(before, after, TS, conn) | |
| + | assert added == 1 | |
| + | assert updated == 0 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM change_events WHERE ref_id='2026-12345'" | |
| + | ).fetchone() | |
| + | assert row["change"] == "added" | |
| + | assert row["source"] == "federal_register" | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_updated_same_id_different_date(self): | |
| + | conn, tmp = _make_db() | |
| + | before = {("openomb", "FY2026_Agency.json"): "2026-01-15"} | |
| + | after = {("openomb", "FY2026_Agency.json"): "2026-06-20"} | |
| + | added, updated = _classify_added_updated(before, after, TS, conn) | |
| + | assert added == 0 | |
| + | assert updated == 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM change_events WHERE ref_id='FY2026_Agency.json'" | |
| + | ).fetchone() | |
| + | assert row["change"] == "updated" | |
| + | assert "2026-01-15" in row["summary"] | |
| + | assert "2026-06-20" in row["summary"] | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_no_change_when_state_identical(self): | |
| + | conn, tmp = _make_db() | |
| + | state = {("congress", "BILLS-119hr1"): "2026-06-01"} | |
| + | added, updated = _classify_added_updated(state, state, TS, conn) | |
| + | assert added == 0 | |
| + | assert updated == 0 | |
| + | count = conn.execute( | |
| + | "SELECT COUNT(*) FROM change_events" | |
| + | ).fetchone()[0] | |
| + | assert count == 0 | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_mixed_added_updated_unchanged(self): | |
| + | conn, tmp = _make_db() | |
| + | before = { | |
| + | ("federal_register", "DOC-001"): "2026-01-01", | |
| + | ("federal_register", "DOC-002"): "2026-01-01", | |
| + | } | |
| + | after = { | |
| + | ("federal_register", "DOC-001"): "2026-06-01", # updated | |
| + | ("federal_register", "DOC-002"): "2026-01-01", # unchanged | |
| + | ("federal_register", "DOC-003"): "2026-06-26", # added | |
| + | } | |
| + | added, updated = _classify_added_updated(before, after, TS, conn) | |
| + | assert added == 1 | |
| + | assert updated == 1 | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_multiple_sources_in_single_diff(self): | |
| + | conn, tmp = _make_db() | |
| + | before = {} | |
| + | after = { | |
| + | ("usaspending", "CONT_AWD_001"): "2026-06-01", | |
| + | ("treasury", "debt_2026-06-01"): "2026-06-01", | |
| + | ("openomb", "FY2026_DOD.json"): "2026-06-01", | |
| + | } | |
| + | added, updated = _classify_added_updated(before, after, TS, conn) | |
| + | assert added == 3 | |
| + | assert updated == 0 | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_source_url_non_empty_on_every_event(self): | |
| + | """Every inserted change_event must have a non-empty source_url.""" | |
| + | conn, tmp = _make_db() | |
| + | before = {} | |
| + | after = { | |
| + | ("usaspending", "CONT_AWD_XTEST"): "2026-06-20", | |
| + | ("congress", "BILLS-119hr99"): "2026-06-20", | |
| + | } | |
| + | _classify_added_updated(before, after, TS, conn) | |
| + | rows = conn.execute( | |
| + | "SELECT source_url FROM change_events" | |
| + | ).fetchall() | |
| + | assert len(rows) == 2 | |
| + | for r in rows: | |
| + | assert r["source_url"], "source_url must be non-empty" | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_ref_type_mapped_correctly(self): | |
| + | conn, tmp = _make_db() | |
| + | before = {} | |
| + | after = { | |
| + | ("bills_bulk", "B001"): "2026-01-01", | |
| + | ("federal_register", "FR001"): "2026-01-01", | |
| + | ("usaspending", "USA001"): "2026-01-01", | |
| + | ("votes_house", "VH001"): "2026-01-01", | |
| + | } | |
| + | _classify_added_updated(before, after, TS, conn) | |
| + | rows = { | |
| + | r["ref_id"]: r["ref_type"] | |
| + | for r in conn.execute("SELECT ref_id, ref_type FROM change_events").fetchall() | |
| + | } | |
| + | assert rows["B001"] == "bill" | |
| + | assert rows["FR001"] == "executive_action" | |
| + | assert rows["USA001"] == "award" | |
| + | assert rows["VH001"] == "vote" | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_change_field_constraint_enforced(self): | |
| + | """SQLite CHECK constraint on change column must be honoured.""" | |
| + | conn, tmp = _make_db() | |
| + | with pytest.raises(Exception): | |
| + | conn.execute( | |
| + | "INSERT INTO change_events" | |
| + | " (ts,source,ref_type,ref_id,change,summary,source_url)" | |
| + | " VALUES (?,?,?,?,?,?,?)", | |
| + | (TS, "test", "bill", "X", "deleted", "bad value", | |
| + | "https://congress.gov/test"), | |
| + | ) | |
| + | conn.commit() | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | ||
| + | # --------------------------------------------------------------------------- | |
| + | # _detect_full_set_changes - manifest diff + removal guards | |
| + | # --------------------------------------------------------------------------- | |
| + | ||
| + | class TestFullSetChanges: | |
| + | ||
| + | # ---- REMOVED happy path ------------------------------------------------ | |
| + | ||
| + | def test_removal_detected_when_member_disappears(self): | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | # Current pull: only A000001 | |
| + | _seed_members_table(conn, [{"bioguide_id": "A000001"}]) | |
| + | # Previous manifest had A000001 + A000002 | |
| + | for bio in ("A000001", "A000002"): | |
| + | conn.execute( | |
| + | "INSERT INTO watcher_manifest (source, external_id, cycle_id, source_url)" | |
| + | " VALUES (?,?,?,?)", | |
| + | ("members", bio, 1, | |
| + | f"https://bioguide.congress.gov/search/bio/{bio}"), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | added, removed = _detect_full_set_changes(conn, "members", cfg, 2, TS) | |
| + | ||
| + | assert removed == 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM change_events WHERE ref_id='A000002'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["change"] == "removed" | |
| + | assert row["source_url"] # must be non-empty | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_no_removal_when_set_is_stable(self): | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | _seed_members_table(conn, [ | |
| + | {"bioguide_id": "A000001"}, | |
| + | {"bioguide_id": "A000002"}, | |
| + | ]) | |
| + | for bio in ("A000001", "A000002"): | |
| + | conn.execute( | |
| + | "INSERT INTO watcher_manifest (source, external_id, cycle_id, source_url)" | |
| + | " VALUES (?,?,?,?)", | |
| + | ("members", bio, 1, | |
| + | f"https://bioguide.congress.gov/search/bio/{bio}"), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | added, removed = _detect_full_set_changes(conn, "members", cfg, 2, TS) | |
| + | assert removed == 0 | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | # ---- Guard: non-empty pull --------------------------------------------- | |
| + | ||
| + | def test_removal_guard_empty_pull(self): | |
| + | """ | |
| + | Non-empty guard: if current pull returns 0 items (table missing or empty), | |
| + | no REMOVED events must be written. | |
| + | """ | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | # No members table → _query_current_set returns {} | |
| + | conn.execute( | |
| + | "INSERT INTO watcher_manifest (source, external_id, cycle_id, source_url)" | |
| + | " VALUES (?,?,?,?)", | |
| + | ("members", "A000001", 1, | |
| + | "https://bioguide.congress.gov/search/bio/A000001"), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | added, removed = _detect_full_set_changes(conn, "members", cfg, 2, TS) | |
| + | ||
| + | # Guard must prevent the false removal | |
| + | assert removed == 0 | |
| + | count = conn.execute( | |
| + | "SELECT COUNT(*) FROM change_events WHERE change='removed'" | |
| + | ).fetchone()[0] | |
| + | assert count == 0 | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_manifest_not_overwritten_on_empty_pull(self): | |
| + | """ | |
| + | When the empty-pull guard fires, the existing manifest must be preserved | |
| + | so the next cycle can still detect removals correctly. | |
| + | """ | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | conn.execute( | |
| + | "INSERT INTO watcher_manifest (source, external_id, cycle_id, source_url)" | |
| + | " VALUES (?,?,?,?)", | |
| + | ("members", "A000001", 1, | |
| + | "https://bioguide.congress.gov/search/bio/A000001"), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | _detect_full_set_changes(conn, "members", cfg, 2, TS) | |
| + | ||
| + | # Manifest entry for A000001 must still be present | |
| + | manifest = _load_manifest(conn, "members") | |
| + | assert "A000001" in manifest | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | # ---- Guard: first run -------------------------------------------------- | |
| + | ||
| + | def test_removal_guard_first_run(self): | |
| + | """ | |
| + | First-run guard: no prior manifest → no REMOVED events, even when | |
| + | the current pull has many items. | |
| + | """ | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | _seed_members_table(conn, [ | |
| + | {"bioguide_id": "A000001"}, | |
| + | {"bioguide_id": "A000002"}, | |
| + | ]) | |
| + | # No prior watcher_manifest entries for "members" | |
| + | ||
| + | added, removed = _detect_full_set_changes(conn, "members", cfg, 1, TS) | |
| + | ||
| + | assert removed == 0 | |
| + | count = conn.execute( | |
| + | "SELECT COUNT(*) FROM change_events WHERE change='removed'" | |
| + | ).fetchone()[0] | |
| + | assert count == 0 | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | # ---- ADDED (members, no mark_seen) ------------------------------------ | |
| + | ||
| + | def test_added_detected_for_members_source(self): | |
| + | """ | |
| + | Members poller does not call mark_seen, so manifest diff must detect ADDs. | |
| + | """ | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | _seed_members_table(conn, [ | |
| + | {"bioguide_id": "A000001"}, | |
| + | {"bioguide_id": "A000003"}, # new member | |
| + | ]) | |
| + | # Prior manifest had only A000001 | |
| + | conn.execute( | |
| + | "INSERT INTO watcher_manifest (source, external_id, cycle_id, source_url)" | |
| + | " VALUES (?,?,?,?)", | |
| + | ("members", "A000001", 1, | |
| + | "https://bioguide.congress.gov/search/bio/A000001"), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | added, removed = _detect_full_set_changes(conn, "members", cfg, 2, TS) | |
| + | ||
| + | assert added == 1 | |
| + | row = conn.execute( | |
| + | "SELECT * FROM change_events WHERE ref_id='A000003'" | |
| + | ).fetchone() | |
| + | assert row is not None | |
| + | assert row["change"] == "added" | |
| + | assert row["source_url"] | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | # ---- Manifest lifecycle ------------------------------------------------ | |
| + | ||
| + | def test_manifest_updated_after_successful_cycle(self): | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | _seed_members_table(conn, [ | |
| + | {"bioguide_id": "A000001"}, | |
| + | {"bioguide_id": "A000002"}, | |
| + | ]) | |
| + | ||
| + | _detect_full_set_changes(conn, "members", cfg, 1, TS) | |
| + | ||
| + | manifest = _load_manifest(conn, "members") | |
| + | assert "A000001" in manifest | |
| + | assert "A000002" in manifest | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | def test_manifest_reflects_removal_next_cycle(self): | |
| + | """ | |
| + | After A000002 is removed from the current pull, the manifest must | |
| + | no longer contain it so the next cycle doesn't re-flag it. | |
| + | """ | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | ||
| + | # Cycle 1: both present | |
| + | _seed_members_table(conn, [ | |
| + | {"bioguide_id": "A000001"}, | |
| + | {"bioguide_id": "A000002"}, | |
| + | ]) | |
| + | _detect_full_set_changes(conn, "members", cfg, 1, TS) | |
| + | ||
| + | # Cycle 2: A000002 gone | |
| + | conn.execute("DELETE FROM members WHERE bioguide_id='A000002'") | |
| + | conn.commit() | |
| + | _detect_full_set_changes(conn, "members", cfg, 2, TS) | |
| + | ||
| + | manifest = _load_manifest(conn, "members") | |
| + | assert "A000001" in manifest | |
| + | assert "A000002" not in manifest | |
| + | ||
| + | # Cycle 3: still only A000001 - must NOT flag A000002 again | |
| + | events_before = conn.execute( | |
| + | "SELECT COUNT(*) FROM change_events WHERE change='removed'" | |
| + | ).fetchone()[0] | |
| + | _detect_full_set_changes(conn, "members", cfg, 3, TS) | |
| + | events_after = conn.execute( | |
| + | "SELECT COUNT(*) FROM change_events WHERE change='removed'" | |
| + | ).fetchone()[0] | |
| + | assert events_after == events_before, "A000002 must not be flagged twice" | |
| + | _cleanup(conn, tmp) | |
| + | ||
| + | # ---- source_url invariant on every event -------------------------------- | |
| + | ||
| + | def test_removed_event_has_source_url(self): | |
| + | """Every REMOVED event must carry a non-empty source_url.""" | |
| + | conn, tmp = _make_db() | |
| + | cfg = FULL_SET_CONFIGS["members"] | |
| + | _seed_members_table(conn, [{"bioguide_id": "A000001"}]) | |
| + | for bio in ("A000001", "A000002"): | |
| + | conn.execute( | |
| + | "INSERT INTO watcher_manifest (source, external_id, cycle_id, source_url)" | |
| + | " VALUES (?,?,?,?)", | |
| + | ("members", bio, 1, | |
| + | f"https://bioguide.congress.gov/search/bio/{bio}"), | |
| + | ) | |
| + | conn.commit() | |
| + | ||
| + | _detect_full_set_changes(conn, "members", cfg, 2, TS) | |
| + | ||
| + | rows = conn.execute( | |
| + | "SELECT source_url FROM change_events WHERE change='removed'" | |
| + | ).fetchall() | |
| + | assert len(rows) == 1 | |
| + | assert rows[0]["source_url"] | |
| + | _cleanup(conn, tmp) |