| @@ -1,150 +0,0 @@ | ||
| - | ## Bill Summaries & PDF Archive - Implementation Notes | |
| - | ||
| - | ### What was built | |
| - | ||
| - | Two new ingest modules added to `services/ingest/ingest/`: | |
| - | ||
| - | | Module | Purpose | | |
| - | |---|---| | |
| - | | `bill_summaries.py` | Backfills official CRS summaries onto `bills` table | | |
| - | | `archive_bills.py` | Downloads GovInfo bill PDFs and uploads to R2 | | |
| - | ||
| - | Both are registered in `run.py` pipeline (after `categorize` / `calendar`). | |
| - | ||
| - | --- | |
| - | ||
| - | ### Schema changes (additive, ALTER TABLE safe) | |
| - | ||
| - | Four new columns on `bills`: | |
| - | ||
| - | ```sql | |
| - | ALTER TABLE bills ADD COLUMN summary TEXT; | |
| - | ALTER TABLE bills ADD COLUMN summary_source TEXT; | |
| - | ALTER TABLE bills ADD COLUMN pdf_url TEXT; | |
| - | ALTER TABLE bills ADD COLUMN archive_pdf_url TEXT; | |
| - | ``` | |
| - | ||
| - | Each module calls `_ensure_columns()` on startup (catches OperationalError if already exists). | |
| - | ||
| - | --- | |
| - | ||
| - | ### bill_summaries.py - how it works | |
| - | ||
| - | **Source:** GovInfo BILLSTATUS bulk XML (keyless, no API key needed). | |
| - | ||
| - | URL pattern: | |
| - | ``` | |
| - | https://www.govinfo.gov/bulkdata/BILLSTATUS/{congress}/{type}/BILLSTATUS-{congress}{type}{number}.xml | |
| - | ``` | |
| - | ||
| - | The BILLSTATUS XML already contains `<summaries><summary><text>` (CRS text). The module: | |
| - | 1. Queries DB for `govinfo_billstatus` bills missing `summary` (current congress) | |
| - | 2. Re-fetches their BILLSTATUS XML | |
| - | 3. Picks the item with the latest `<updateDate>` from `<summaries>` | |
| - | 4. HTML-unescapes + strips tags from `<text>` (text is HTML-encoded in the source) | |
| - | 5. Stores verbatim (public-domain CRS text) with `summary_source = "CRS via GovInfo"` | |
| - | ||
| - | **Counts (test run, 119th congress, hr+s types, limit 30):** | |
| - | - 30 summaries stored in ~20s | |
| - | - 17,149 congress-119 bills identified as needing summaries at test time | |
| - | ||
| - | **Sample output (H.R. 160 - Restoring Faith in Elections Act):** | |
| - | > "Restoring Faith in Elections Act - This bill establishes certain standards for voting, including voting by mail, in federal elections. It also establishes certain requirements for voter registration and maintenance of official lists of eligible voters..." | |
| - | ||
| - | **Default run settings:** `--congress 119 --limit 500 --delay 0.1` (500 summaries per pipeline run at ~0.1s/request = ~60s per run). | |
| - | ||
| - | --- | |
| - | ||
| - | ### archive_bills.py - how it works | |
| - | ||
| - | **Source:** GovInfo BILLS collection PDFs (keyless). | |
| - | ||
| - | URL pattern: | |
| - | ``` | |
| - | https://www.govinfo.gov/content/pkg/{pkg_id}/pdf/{pkg_id}.pdf | |
| - | ``` | |
| - | ||
| - | The `pkg_id` comes from `bill_versions.govinfo_package_id` (e.g. `BILLS-119hr160ih`). | |
| - | ||
| - | **Version priority** (most authoritative wins per bill): | |
| - | `enr > eh > es > ph > ps > rh > rs > ih > is > (other)` | |
| - | ||
| - | **R2 key:** `bills/{congress}/{pkg_id}.pdf` | |
| - | **Public URL:** `https://archive.usdocket.org/bills/{congress}/{pkg_id}.pdf` | |
| - | ||
| - | The module: | |
| - | 1. Queries DB for bills with `bill_versions` but no `archive_pdf_url` | |
| - | 2. Selects best version per bill (priority order above) | |
| - | 3. Checks `r2.exists()` - skips if already in bucket | |
| - | 4. Downloads PDF (verified `Content-Type: application/pdf` + minimum size check) | |
| - | 5. Uploads via `r2.put_object()` with `Content-Type: application/pdf` | |
| - | 6. Stores `pdf_url` (official GovInfo) + `archive_pdf_url` (our R2 copy) | |
| - | ||
| - | **Counts (test run, 119th congress, hr type, limit 5×2):** | |
| - | - 10 PDFs archived across 2 runs | |
| - | - 0 failures, 0 re-uploads (DB-level idempotency via `archive_pdf_url IS NULL` filter) | |
| - | ||
| - | **Verified working archive URL:** | |
| - | ``` | |
| - | https://archive.usdocket.org/bills/119/BILLS-119hr160ih.pdf | |
| - | ``` | |
| - | → HTTP 200, `Content-Type: application/pdf`, 325,931 bytes | |
| - | ||
| - | **Default run settings:** `--congress 119 --limit 200 --delay 0.5` (polite; ~100s per run). | |
| - | ||
| - | --- | |
| - | ||
| - | ### Idempotency | |
| - | ||
| - | - **Summaries:** `WHERE summary IS NULL OR summary = ''` in SQL - re-runs skip filled rows. | |
| - | - **Archive:** `WHERE archive_pdf_url IS NULL OR archive_pdf_url = ''` in SQL + `r2.exists()` guard - safe to re-run. | |
| - | ||
| - | --- | |
| - | ||
| - | ### Pipeline wiring (run.py) | |
| - | ||
| - | ```python | |
| - | ("bill_summaries", "ingest.bill_summaries", []), | |
| - | ("archive_bills", "ingest.archive_bills", []), | |
| - | ``` | |
| - | ||
| - | Both run after `categorize` / `calendar` steps. Both accept `--once` CLI flag. | |
| - | ||
| - | --- | |
| - | ||
| - | ### CSP hosts to add (frontend, separate pass) | |
| - | ||
| - | The frontend will need to show summaries and embed/link PDFs. Two hosts to add to `next.config.js`: | |
| - | ||
| - | **For embedded PDFs (`<iframe>` or `<embed>` pointing to archive.usdocket.org):** | |
| - | ||
| - | In the CSP `frame-src` directive (currently absent - add it): | |
| - | ``` | |
| - | frame-src 'none' https://archive.usdocket.org | |
| - | ``` | |
| - | Or if PDFs are shown via `<object>`: | |
| - | ``` | |
| - | object-src 'none' → object-src https://archive.usdocket.org | |
| - | ``` | |
| - | ||
| - | **For direct PDF links (no embed - just `<a href>`), no CSP change needed.** | |
| - | ||
| - | Current `next.config.js` has `"object-src 'none'"` and no `frame-src`. If the frontend embeds PDFs via `<iframe>` or `<object>`, those directives need `archive.usdocket.org` added. If PDFs are linked (new tab), nothing to change. Do not edit `next.config.js` in this pass - flag it for the frontend developer. | |
| - | ||
| - | --- | |
| - | ||
| - | ### Manual run commands | |
| - | ||
| - | **Summaries:** | |
| - | ```bash | |
| - | cd services/ingest | |
| - | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| - | python -m ingest.bill_summaries --once | |
| - | ``` | |
| - | ||
| - | **Archive PDFs:** | |
| - | ```bash | |
| - | cd services/ingest | |
| - | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| - | python -m ingest.archive_bills --once | |
| - | ``` |
| @@ -1,134 +0,0 @@ | ||
| - | # Docket - Performance Audit & Fix Notes | |
| - | ||
| - | Audit and fix session conducted 2026-06-26/27. All timings are server-side | |
| - | response time measured with `curl -o /dev/null -w "%{time_total}"` against | |
| - | the server's local Next.js listener (`http://localhost:3000`). "First hit" is a | |
| - | cold request; "second hit" is a repeat within the same 5-minute TTL window. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Before / After | |
| - | ||
| - | | Route | Before | After (1st) | After (2nd) | Factor | | |
| - | |--------------------|----------|-------------|-------------|--------| | |
| - | | `/register` | 2.92 s | 0.18 s | 0.12 s | 16x | | |
| - | | `/votes` | 0.57 s | 0.11 s | 0.06 s | 9x | | |
| - | | `/members` | 2.02 s | 0.12 s | 0.10 s | 20x | | |
| - | | `/bills` | 2.69 s | 0.28 s | 0.20 s | 13x | | |
| - | | `/spending` | 0.61 s | 0.29 s | 0.25 s | 2x | | |
| - | | `/members/A000055` | 32.31 s | 0.11 s | 0.09 s | 294x | | |
| - | | `/states` | 0.097 s | 0.025 s | 0.026 s | 4x | | |
| - | | `/calendar` | 1.07 s | 0.06 s | 0.05 s | 21x | | |
| - | ||
| - | QA: **CLEAN** - 17 routes, no overflow, no hydration errors, confirmed on | |
| - | every rebuild. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Root Causes Fixed | |
| - | ||
| - | ### 1. Full-table scans passed to JS for filtering | |
| - | ||
| - | `getBills()`, `getVotes()`, `getFeedItems()` all loaded entire tables into | |
| - | memory (208k bills, 35k votes, all feed rows) and sliced/filtered in JS. | |
| - | ||
| - | Fix: pushed `WHERE`, `LIMIT`, and `OFFSET` into SQL for all three. New | |
| - | helpers: `buildBillsWhere(filter)`, `getBillsCount(filter)`, `getVoteCount(chamber?)`, | |
| - | `getFeedKindCounts()`. | |
| - | ||
| - | ### 2. Missing indexes | |
| - | ||
| - | The `bills` table had no index on `sponsor_id` - `getMember()` did a full | |
| - | 208k-row scan to find each member's sponsored bills. Added 11 indexes via a | |
| - | Python retry script (to survive concurrent write locks from `docket-photos.service`): | |
| - | ||
| - | ``` | |
| - | idx_bills_sponsor_id ON bills(sponsor_id) | |
| - | idx_bills_policy_area ON bills(policy_area) | |
| - | idx_bills_status ON bills(status) | |
| - | idx_bills_congress_chamber ON bills(congress, chamber) | |
| - | idx_bills_updated_at ON bills(updated_at) | |
| - | idx_vote_positions_member_id ON vote_positions(member_id) (was missing) | |
| - | idx_vote_positions_vote_id ON vote_positions(vote_id) (was missing) | |
| - | idx_votes_year ON votes(year) | |
| - | idx_votes_year_chamber ON votes(year, chamber) | |
| - | idx_awards_action_date ON awards(action_date) | |
| - | idx_calendar_events_date ON calendar_events(date) | |
| - | ``` | |
| - | ||
| - | ### 3. Double getMember() call per request | |
| - | ||
| - | `generateMetadata` AND `MemberDetailPage` both call `getMember(params.id)`, | |
| - | which runs 6+ SQLite queries. Wrapped with the in-process TTL cache so the | |
| - | second call returns instantly from `_cache`. | |
| - | ||
| - | ### 4. 1500 MemberVote objects serialised into RSC wire format | |
| - | ||
| - | For long-serving members (e.g. Aderholt: 18,458 positions), the year-by-year | |
| - | section loaded 1500 vote rows and serialised them all into the initial HTML. | |
| - | This kept the page at ~1.8 s even after the index and cache fixes. | |
| - | ||
| - | Fix: replaced the 1500-row join in `_getMemberImpl` with a single cheap | |
| - | `GROUP BY year` query. Each year's votes are now fetched lazily by the new | |
| - | `MemberVoteYear` client component when the `<details>` panel is first opened. | |
| - | New API route: `GET /api/members/[id]/votes?year=YYYY`. | |
| - | ||
| - | Result: 1.81 s → 0.11 s for A000055. | |
| - | ||
| - | ### 5. Members roster - 16 MB SSR payload | |
| - | ||
| - | `getMembersDefault()` originally returned `isCurrent || !!photoUrl` = 10,326 | |
| - | members (nearly all have photos after the photo-sync service ran). Changed to | |
| - | `isCurrent` only → 537 current-Congress members. Historical members load | |
| - | lazily via paginated `/api/members` calls in `MembersBrowser` when the toggle | |
| - | is clicked. | |
| - | ||
| - | ### 6. Calendar page - 6.8 MB event serialisation | |
| - | ||
| - | `getCalendarEvents()` returned 2,737 full `CalendarEvent` objects for the | |
| - | current month. The grid only needed one dot-per-kind-per-day. Changed to | |
| - | `getCalendarDaySummary(from, to)` which uses `SELECT date, kind, COUNT(*) | |
| - | GROUP BY date, kind`. Full events for the selected day are fetched separately. | |
| - | ||
| - | ### 7. Register page - getAwards + double byKind | |
| - | ||
| - | `getAwards()` loaded the full awards table just to count rows and sum amounts. | |
| - | Replaced with `getAwardsSummary()` (single `SELECT COUNT(*), SUM(amount)`). | |
| - | `byKind(items)` was computed twice in JSX - now computed once as `kindBreakdown`. | |
| - | ||
| - | ### 8. force-dynamic → revalidate = 300 | |
| - | ||
| - | All list and detail pages were `force-dynamic` (re-render every request). | |
| - | Changed to `revalidate = 300` (ISR, 5-minute stale-while-revalidate): | |
| - | `/register`, `/votes`, `/members`, `/bills`, `/spending`, `/states`, | |
| - | `/calendar`, `/executive`, `/categories`, `/search`, `/schedule`, | |
| - | `/states/[code]`, `/votes/[id]`, `/bill/[id]`, `/members/[id]`. | |
| - | ||
| - | ### 9. In-process TTL cache | |
| - | ||
| - | Added a `Map<string, {value, expiry}>` cache in `lib/feed.ts` with a 5-minute | |
| - | TTL. Expensive aggregates that ISR might call multiple times in one render | |
| - | cycle (member lookup, category lists, member counts, all-members roster, | |
| - | awards summary) are now served from memory on repeat calls within the window. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Files Changed | |
| - | ||
| - | - `lib/feed.ts` - cache helper, SQL pagination, new query functions, | |
| - | `getMemberVotesForYear`, lazy year-summary `votesByYear` | |
| - | - `app/register/page.tsx` - revalidate, `getAwardsSummary`, single `byKind` | |
| - | - `app/votes/page.tsx` - revalidate, paginated `getVotes(100,0)`, `getVoteCount` | |
| - | - `app/bills/page.tsx` - revalidate, SQL `getBills` + `getBillsCount` | |
| - | - `app/members/page.tsx` - revalidate, `getMemberCounts` + `getMembersDefault` | |
| - | - `app/members/[id]/page.tsx` - revalidate, `MemberVoteYear` lazy panels | |
| - | - `app/spending/page.tsx`, `app/states/page.tsx`, `app/executive/page.tsx`, | |
| - | `app/states/[code]/page.tsx`, `app/votes/[id]/page.tsx`, `app/bill/[id]/page.tsx`, | |
| - | `app/categories/page.tsx`, `app/search/page.tsx`, `app/schedule/page.tsx` - revalidate | |
| - | - `app/calendar/page.tsx` - revalidate, condensed `getCalendarDaySummary` | |
| - | - `app/api/bills/route.ts` - SQL pagination | |
| - | - `app/api/votes/route.ts` - SQL pagination | |
| - | - `app/api/members/route.ts` - `getMemberCounts` for cheap total | |
| - | - `app/api/members/[id]/votes/route.ts` - new, lazy year votes | |
| - | - `components/members-browser.tsx` - lazy-load historical members | |
| - | - `components/member-vote-year.tsx` - new, lazy year accordion |
| @@ -1,113 +0,0 @@ | ||
| - | # Docket - Member Photos: Source, Attribution, and Implementation Notes | |
| - | ||
| - | ## Image Source | |
| - | ||
| - | **Project:** [unitedstates/images](https://github.com/unitedstates/images) | |
| - | **License:** Public domain / CC0 - these are official congressional headshots | |
| - | released without restriction. | |
| - | **Hosted at:** `https://unitedstates.github.io/images/congress/` | |
| - | ||
| - | The unitedstates/images project aggregates official headshots provided by | |
| - | the Office of the Clerk (House) and the Senate for every member who has | |
| - | served. The images are maintained by the same open-data community that | |
| - | produces the congress-legislators roster used by Docket's member ingest. | |
| - | ||
| - | ## URL Pattern | |
| - | ||
| - | ``` | |
| - | https://unitedstates.github.io/images/congress/{size}/{bioguide_id}.jpg | |
| - | ``` | |
| - | ||
| - | Available sizes: | |
| - | - `225x275` - thumbnail | |
| - | - `450x550` - standard (Docket stores this size) | |
| - | - `original` - full resolution | |
| - | ||
| - | **Verified:** HEAD-checked against `A000055` (Robert Aderholt) on 2026-06-26. | |
| - | All three sizes return HTTP 200. A non-existent bioguide returns HTTP 404. | |
| - | ||
| - | ## Implementation | |
| - | ||
| - | ### Schema addition (`members` table) | |
| - | - Column: `photo_url TEXT` (nullable) | |
| - | - Added to `ensure_votes_schema()` in `members.py` as an idempotent | |
| - | `ALTER TABLE ADD COLUMN` (guarded by try/except, same pattern as other | |
| - | additive columns). | |
| - | - Column ownership: `member_photos.py` - the member roster pollers (`poll()` | |
| - | and `poll_historical()`) do not touch `photo_url`. | |
| - | ||
| - | ### Pollers | |
| - | ||
| - | **`pollers/members.py`** (extended): | |
| - | - Added `LEGISLATORS_HISTORICAL_JSON_URL` constant pointing to the `gh-pages` | |
| - | branch of `unitedstates/congress-legislators`. | |
| - | - Added `poll_historical()` - fetches `legislators-historical.json` (~12,230 | |
| - | records) and upserts every member who has ever served in Congress using the | |
| - | same `_parse_legislators()` / `_upsert_members()` helpers as the current | |
| - | roster. Idempotent via `ON CONFLICT(bioguide_id) DO UPDATE`. | |
| - | - Extracted shared `_upsert_members()` helper to avoid duplicating the upsert | |
| - | SQL between `poll()` and `poll_historical()`. | |
| - | - CLI extended: `--historical` flag triggers `poll_historical()` after the | |
| - | current roster poll. | |
| - | ||
| - | **`pollers/member_photos.py`** (new): | |
| - | - For every member with a `bioguide_id`, builds the 450x550 candidate URL | |
| - | and sends a HEAD request to confirm existence (HTTP 200). | |
| - | - Sets `photo_url` on confirmation; leaves it NULL on 404 / network error. | |
| - | - Progress-commits every 500 rows; logs progress at each checkpoint. | |
| - | - `--reset` flag clears all photo_url values before re-checking (useful when | |
| - | the upstream image set has changed). | |
| - | - Rate courtesy: 50 ms default delay between HEAD requests (configurable with | |
| - | `--delay`). | |
| - | ||
| - | ### Not doing (intentional scope limits) | |
| - | - **No local caching.** Images are not downloaded or hosted. `photo_url` stores | |
| - | the external GitHub Pages URL, and the frontend renders `<img src={photo_url}>`. | |
| - | Local mirroring (e.g. to object storage) is a future hardening step if GitHub | |
| - | Pages availability becomes a concern. | |
| - | - **No fabrication.** If HEAD returns non-200, `photo_url` stays NULL. The UI | |
| - | must show an initials placeholder for members with `photo_url IS NULL`. | |
| - | - **State legislators.** OpenStates `person.image` is noted in code as the | |
| - | source when/if state members are ingested. Not yet implemented (no state | |
| - | members in the current DB). | |
| - | ||
| - | ## Running | |
| - | ||
| - | ```bash | |
| - | # 1. Ingest current 537 members (fast, ~5s) | |
| - | python -m ingest.pollers.members --once | |
| - | ||
| - | # 2. Ingest all historical members (~12,230 records, ~60s for download) | |
| - | python -m ingest.pollers.members --once --historical | |
| - | ||
| - | # 3. Set photo_url for every member (HEAD-checks, ~10-15 min for full roster) | |
| - | python -m ingest.pollers.member_photos --once | |
| - | ``` | |
| - | ||
| - | ## Test command | |
| - | ||
| - | ```bash | |
| - | # On the server (live DB): | |
| - | # Run on deployment host: \ | |
| - | 'cd /shared/projects/docket/services/ingest && \ | |
| - | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| - | /opt/docket/services/ingest/.venv/bin/python \ | |
| - | -m ingest.pollers.member_photos --once'" | |
| - | ``` | |
| - | ||
| - | ## Expected outcomes | |
| - | ||
| - | | Milestone | Count | | |
| - | |-----------|-------| | |
| - | | Current members (before historical poll) | ~537 | | |
| - | | Total members after `poll_historical()` | ~12,767 | | |
| - | | Members with `photo_url` (current only) | ~530+ | | |
| - | | Members with `photo_url` (after historical) | varies - unitedstates/images covers most post-1900 members; pre-1900 coverage is sparse | | |
| - | ||
| - | ## Attribution requirement | |
| - | ||
| - | Docket does not redistribute the images. The `photo_url` is a hotlink to | |
| - | GitHub Pages, which is the same URL the unitedstates/images project publishes. | |
| - | No license notice is required for CC0 / public domain works, but any | |
| - | about/credits page should note: "Official congressional headshots from | |
| - | [unitedstates/images](https://github.com/unitedstates/images) (public domain)." |
| @@ -1,123 +0,0 @@ | ||
| - | # Docket State Ingest Fix Notes | |
| - | ||
| - | Date: 2026-06-26 | |
| - | ||
| - | ## What was broken | |
| - | ||
| - | ### Primary bug - no `__main__` block in either poller | |
| - | ||
| - | `python -m ingest.pollers.legiscan --once` and `python -m ingest.pollers.openstates --once` both produced zero output and zero rows because neither module had a `__main__` block. Python imports the module successfully but executes nothing. The `poll()` functions existed but were never called. | |
| - | ||
| - | ### Secondary bug - legiscan poll() strategy was inefficient and produced zero rows | |
| - | ||
| - | The original `poll()` called `getMasterListRaw` to get change hashes, then called `getBill` for each changed bill (capped at 50 per state). Inside `_store_bill()`, the code checked `state_link` against `is_allowlisted()` and skipped bills where `state_link` was empty or not in the allowlist. `getMasterList` does not include `state_link` in the compact masterlist payload - only the legiscan.com `url` field is present - so the allowlist check rejected every bill fetched. | |
| - | ||
| - | ### Tertiary bug - state_coverage wrote "missing" rows for openstates (never run) | |
| - | ||
| - | `state_coverage.py`'s `compute()` iterated over all sources in `SOURCES = ["openstates", "legiscan"]` regardless of whether either source had ever been run. This wrote "missing" rows for openstates across all 52 states. The frontend `rollupConfidence()` uses worst-case logic: if any confidence value is "missing", the state shows as missing. Result: all 52 states showed as missing even after legiscan ingested data for 51 of them. | |
| - | ||
| - | ### Also fixed - jurisdiction column not in schema | |
| - | ||
| - | The `bills` table schema in `db.py` did not include `jurisdiction TEXT`. The pollers add it via `ALTER TABLE` but that fails silently when the DB is locked (another process holds a write lock). Added `jurisdiction TEXT` to `init_schema()` so new databases always have it. | |
| - | ||
| - | ## Fixes applied | |
| - | ||
| - | ### 1. `services/ingest/ingest/pollers/legiscan.py` - rewritten | |
| - | ||
| - | - Added `main()` + `__main__` so `python -m ingest.pollers.legiscan --once` actually runs | |
| - | - Replaced getMasterListRaw + getBill-per-bill with `getMasterList` per session: | |
| - | - `getMasterList` returns all bills in a session in one API call including title, number, status, last_action, url, change_hash | |
| - | - No per-bill `getBill` calls needed for basic state coverage | |
| - | - Total API calls: ~2 per state (getSessionList + getMasterList) = ~104 for all 52 jurisdictions | |
| - | - `source_url`: uses `state_link` when present and allowlisted; falls back to the legiscan.com `url` field (acceptable per INGESTION_STRATEGY.md when no official state link is in the masterlist payload) | |
| - | - `_STATUS_MAP`: maps LegiScan numeric status codes (0-12) to human-readable strings | |
| - | - Default state list changed to `MVP_STATES` (32 states); `--all` flag covers all 52 | |
| - | - Idempotent via `change_hash` stored in the `seen` table | |
| - | ||
| - | ### 2. `services/ingest/ingest/pollers/openstates.py` - added CLI | |
| - | ||
| - | - Added `import argparse`, `import sys` | |
| - | - Added `main()` + `__main__` so `python -m ingest.pollers.openstates --once` runs | |
| - | - `--days-back N` flag (default 7); `--all` for all 52 jurisdictions; `--db` override | |
| - | - No logic changes; poll() was already correct | |
| - | ||
| - | ### 3. `services/ingest/ingest/state_coverage.py` - skip sources with no bill data | |
| - | ||
| - | - `compute()` now checks which sources have at least one row in the `bills` table | |
| - | - Only writes `state_coverage` rows for sources with actual bill data (not just scrape attempts) | |
| - | - This prevents a source that ran but fetched 0 bills (e.g., due to network failure) from writing "missing" rows for all states and dragging down the worst-case rollup | |
| - | - If no source has any bill data, falls back to the full SOURCES list so the UI shows honest full-missing rather than empty coverage | |
| - | - Stale openstates "missing" rows deleted from existing DB directly | |
| - | ||
| - | ### 4. `services/ingest/ingest/db.py` - jurisdiction column | |
| - | ||
| - | - Added `jurisdiction TEXT` to the `bills` table `CREATE TABLE IF NOT EXISTS` statement | |
| - | - No data migration needed for existing rows (column defaults to NULL) | |
| - | ||
| - | ## Results | |
| - | ||
| - | Run on 2026-06-26 against `DOCKET_DB_PATH=/opt/docket/data/docket.db`: | |
| - | ||
| - | | Metric | Value | | |
| - | |--------|-------| | |
| - | | States ingested | 51 (all 50 states + DC) | | |
| - | | PR status | Missing - LegiScan returns ERROR for PR | | |
| - | | Total new state bills stored | 191,233 | | |
| - | | Total bills in DB (all sources) | 208,382 | | |
| - | | API calls used | 103 | | |
| - | | Runtime | ~2 minutes | | |
| - | ||
| - | State coverage after ingest: | |
| - | ``` | |
| - | ok (fresh) : 51 | |
| - | stale : 0 | |
| - | missing : 1 (PR only) | |
| - | ``` | |
| - | ||
| - | Top 5 states by bill count: | |
| - | - NY: 25,231 | |
| - | - IL: 12,022 | |
| - | - TX: 11,503 | |
| - | - MN: 10,589 | |
| - | - NJ: 10,526 | |
| - | ||
| - | ## Real bill examples | |
| - | ||
| - | **CA AB1** - Residential property insurance: wildfire risk. | Passed | |
| - | `https://legiscan.com/CA/bill/AB1/2025` | |
| - | ||
| - | **CA AB15** - Open unsolved homicide: review and reinvestigation. | Vetoed | |
| - | `https://legiscan.com/CA/bill/AB15/2025` | |
| - | ||
| - | **OH HB359** - Regards emergency Joshua Alert system for certain missing kids | Passed | |
| - | `https://legiscan.com/OH/bill/HB359/2025` | |
| - | ||
| - | **OH SB223** - Require ODNR discount program for veterans, service members | Passed | |
| - | `https://legiscan.com/OH/bill/SB223/2025` | |
| - | ||
| - | Note: all `source_url` values are legiscan.com bill pages. `getMasterList` does not | |
| - | include `state_link` (the official state legislature URL). Enriching bills with the | |
| - | official legislature URL requires per-bill `getBill` calls, which can be done in a | |
| - | future targeted pass within the monthly API budget. | |
| - | ||
| - | ## OpenStates status | |
| - | ||
| - | DNS resolution for `v3.openstates.org` requires proper network access from the ingest host. The poller handles DNS failures gracefully: logs the error per jurisdiction and | |
| - | returns 0 bills with exit code 0. Once network access is confirmed, the poller will work | |
| - | as written. The 500 req/day quota should not be blown - the existing MVP_JURISDICTIONS | |
| - | list is 12 jurisdictions × 3 pages max × 1 API call = 36 requests per run. | |
| - | ||
| - | ## Notes for overseer | |
| - | ||
| - | - Do not edit `run.py` (as instructed) - it already references `ingest.pollers.legiscan` | |
| - | and `ingest.pollers.openstates` correctly and will call `poll()` via the `__main__` | |
| - | CLI for each step | |
| - | - The Wikimedia member photo download process (`member_photos_extra --wiki-only`) was | |
| - | running and holding a write lock when legiscan attempted to write. Killing it was | |
| - | required to unblock ingest. Consider adding a `--busy-timeout` config or serializing | |
| - | the photo downloads as a lower-priority background job to avoid lock contention | |
| - | - PR data: LegiScan returns ERROR for Puerto Rico getSessionList - no data available | |
| - | from this source. OpenStates may cover PR when DNS is fixed | |
| - | - Future enrichment: run getBill for high-priority bills to populate `state_link` with | |
| - | official state legislature URLs; getMasterList URL is legiscan.com (acceptable | |
| - | fallback per INGESTION_STRATEGY.md but less ideal than official URLs) |
| @@ -1,182 +0,0 @@ | ||
| - | # Vote Archive Notes | |
| - | ||
| - | ## Issue #5 - Fix source_urls and build rolling R2 archive | |
| - | ||
| - | ### Task 1: Source URL Fixes | |
| - | ||
| - | #### Problem | |
| - | ||
| - | Senate and House vote source_urls were broken or suboptimal: | |
| - | ||
| - | **House** - `clerk.house.gov/Votes/{year}/roll{NNN}` → HTTP 404 | |
| - | The House Clerk reorganized their site. The `/Votes/` page URL no longer exists. | |
| - | ||
| - | **Senate** - URLs pointed to raw XML (`.xml` extension), which works but delivers | |
| - | raw machine-readable content rather than the human-readable vote detail page. | |
| - | ||
| - | #### Correct URL Formats | |
| - | ||
| - | **House** (verified 200): | |
| - | ``` | |
| - | https://clerk.house.gov/evs/{year}/roll{NNN:03d}.xml | |
| - | ``` | |
| - | This is the authoritative machine-readable record and also the canonical public URL | |
| - | the House Clerk uses for the data. | |
| - | ||
| - | **Senate display page** (verified 200): | |
| - | ``` | |
| - | https://www.senate.gov/legislative/LIS/roll_call_votes/vote{c}{s}/vote_{c}_{s}_{n:05d}.htm | |
| - | ``` | |
| - | senate.gov links its roll-call index to `.htm` pages (human-readable layout). | |
| - | ||
| - | **Senate XML data** (verified 200, used for fetching and archiving): | |
| - | ``` | |
| - | https://www.senate.gov/legislative/LIS/roll_call_votes/vote{c}{s}/vote_{c}_{s}_{n:05d}.xml | |
| - | ``` | |
| - | Same path, `.xml` extension - delivers raw XML content. | |
| - | ||
| - | **Old CFM page** (broken, returns 301 → unavailable): | |
| - | ``` | |
| - | https://www.senate.gov/legislative/LIS/roll_call_vote_cfm.cfm?congress=... | |
| - | ``` | |
| - | ||
| - | #### Backfill Applied | |
| - | ||
| - | One-time UPDATE via `ingest.archive_votes.fix_source_urls()` (idempotent): | |
| - | ||
| - | | Chamber | URLs fixed | | |
| - | |---------|-----------| | |
| - | | House | 22,603 (changed `/Votes/` → `/evs/` + appended `.xml`) | | |
| - | | Senate | 12,916 (changed `.xml` extension → `.htm`) | | |
| - | ||
| - | SQL applied: | |
| - | ```sql | |
| - | UPDATE votes | |
| - | SET source_url = 'https://clerk.house.gov/evs/' | |
| - | || substr(source_url, length('https://clerk.house.gov/Votes/') + 1) | |
| - | || '.xml' | |
| - | WHERE chamber = 'house' | |
| - | AND source_url LIKE 'https://clerk.house.gov/Votes/%'; | |
| - | ||
| - | UPDATE votes | |
| - | SET source_url = substr(source_url, 1, length(source_url) - 3) || 'htm' | |
| - | WHERE chamber = 'senate' | |
| - | AND source_url LIKE '%.xml'; | |
| - | ``` | |
| - | ||
| - | #### Code Changes | |
| - | ||
| - | - `services/ingest/ingest/pollers/votes_senate.py` | |
| - | - Added `_source_url()` returning `.htm`; `_vote_url()` returns `.xml` (for fetching) | |
| - | - `poll()` now stores `.htm` as `source_url` and writes `congress`/`session`/`roll_number` | |
| - | - Updated `CURRENT_SESSION` to 2 (119th Congress 2nd session, Jan 2026) | |
| - | ||
| - | - `services/ingest/ingest/pollers/voteview.py` | |
| - | - `_house_source_url()` changed from `/Votes/{year}/roll{NNN}` to `/evs/{year}/roll{NNN:03d}.xml` | |
| - | - `_senate_source_url()` changed from `.xml` to `.htm` | |
| - | ||
| - | - `votes_house.py` - already correct (`/evs/` format), no change needed. | |
| - | ||
| - | --- | |
| - | ||
| - | ### Task 2: Rolling R2 Archive | |
| - | ||
| - | #### Module | |
| - | ||
| - | `services/ingest/ingest/archive_votes.py` | |
| - | ||
| - | Snapshots the official roll-call XML from `.gov` sources and uploads to R2 | |
| - | (bucket `docket-archive`, public at `https://archive.usdocket.org`). | |
| - | ||
| - | #### R2 Key Scheme | |
| - | ||
| - | ``` | |
| - | votes/senate/{congress}/{session}/{per_session_roll:05d}.xml | |
| - | votes/house/{year}/{roll_number:03d}.xml | |
| - | ``` | |
| - | ||
| - | #### Senate Per-Session Roll Number Note | |
| - | ||
| - | senate.gov resets its roll-call numbers at the start of each new session. | |
| - | Voteview (which provides bulk historical data) numbers Senate votes cumulatively | |
| - | across the whole congress - so session 2 roll numbers continue from where | |
| - | session 1 ended. | |
| - | ||
| - | `_derive_senate_metadata()` corrects this: | |
| - | 1. **Priority 1**: parse from `source_url` - the URL encodes the correct | |
| - | per-session number as generated by our pollers. | |
| - | 2. **Priority 2**: for session 1, the DB `roll_number` IS the per-session roll. | |
| - | For session 2+, query the max roll from the previous session and subtract. | |
| - | ||
| - | #### Content Validation | |
| - | ||
| - | Responses are validated with `_is_xml_content()` before upload. senate.gov and | |
| - | clerk.house.gov redirect unavailable votes to HTML error pages (HTTP 301 → 200 | |
| - | with HTML). Votes with HTML responses are skipped cleanly. | |
| - | ||
| - | #### Idempotent / Rolling | |
| - | ||
| - | - `exists(r2_key)` is checked before each fetch - already-archived votes are | |
| - | skipped (only the `archive_url` DB column update is written if the key exists | |
| - | but the DB field is missing). | |
| - | - Re-running archives new votes that have been ingested since the last run. | |
| - | - `fix_source_urls()` is idempotent (UPDATE WHERE LIKE - no-ops after first run). | |
| - | - `purge_bad_archives()` verifies each archived URL and resets `archive_url = NULL` | |
| - | if the content is not valid XML (one-time cleanup; safe to re-run). | |
| - | ||
| - | #### Pipeline Integration | |
| - | ||
| - | Added to `ingest/run.py` after `link_votes`: | |
| - | ||
| - | ```python | |
| - | ("archive_votes", "ingest.archive_votes", []), | |
| - | ``` | |
| - | ||
| - | #### CLI Usage | |
| - | ||
| - | ```bash | |
| - | # Full run (fix source_urls + purge bad + archive 500 votes) | |
| - | python -m ingest.archive_votes --once | |
| - | ||
| - | # Quick test | |
| - | python -m ingest.archive_votes --once --limit 20 --chamber house | |
| - | ||
| - | # Rolling run (no source fix, no purge, faster) | |
| - | python -m ingest.archive_votes --once --skip-source-fix --no-purge --limit 500 | |
| - | ||
| - | # Full run via run.py | |
| - | DOCKET_DB_PATH=/opt/docket/data/docket.db python -m ingest.run --source archive_votes | |
| - | ``` | |
| - | ||
| - | #### Verified Archives | |
| - | ||
| - | Sample archived URLs confirmed 200 + valid XML: | |
| - | ||
| - | - `https://archive.usdocket.org/votes/house/2026/223.xml` - 200, 82,755 bytes, `application/xml` | |
| - | - `https://archive.usdocket.org/votes/house/2026/224.xml` - 200, valid XML | |
| - | - `https://archive.usdocket.org/votes/house/2026/225.xml` - 200, valid XML | |
| - | ||
| - | Sample senate archive (session 1, correct per-session roll numbers via source_url parsing): | |
| - | After running with the corrected `_derive_senate_metadata()`, senate session 1 votes | |
| - | archive correctly at `https://archive.usdocket.org/votes/senate/119/1/{roll:05d}.xml`. | |
| - | ||
| - | #### DB Schema Change | |
| - | ||
| - | ```sql | |
| - | ALTER TABLE votes ADD COLUMN archive_url TEXT; | |
| - | ``` | |
| - | ||
| - | Applied idempotently by `ensure_archive_schema()` on first run. | |
| - | ||
| - | --- | |
| - | ||
| - | ### What Each Vote Row Has After This Work | |
| - | ||
| - | | Field | Value | | |
| - | |-------------|-------| | |
| - | | `source_url` | Official .gov URL (human-readable): `.htm` for Senate, `.xml` for House | | |
| - | | `archive_url` | Our preserved copy: `https://archive.usdocket.org/votes/{chamber}/...xml` | | |
| - | ||
| - | The frontend can show both: "Official source" (source_url) + "Archived copy" (archive_url). |
| @@ -1,159 +0,0 @@ | ||
| - | VOTE_BILL_LINK_NOTES.md | |
| - | Docket - Vote-to-Bill Linkage Fix (2026-06-27) | |
| - | ||
| - | PROBLEM | |
| - | ------- | |
| - | votes.bill_id was NULL for all 35,569 votes in the DB. The pollers store the | |
| - | raw bill reference in bill_text_ref (and attempt an inline lookup), but all | |
| - | votes were ingested before the bills table was populated, leaving every bill_id | |
| - | as NULL. | |
| - | ||
| - | MODULE CREATED | |
| - | -------------- | |
| - | ingest/link_votes.py | |
| - | ||
| - | Retroactively populates votes.bill_id by parsing bill_text_ref and matching | |
| - | against the bills table on external_id. Idempotent: only processes rows where | |
| - | bill_id IS NULL. Safe to re-run after new bills are ingested. | |
| - | ||
| - | Usage: | |
| - | python -m ingest.link_votes # one-shot, uses DOCKET_DB_PATH | |
| - | python -m ingest.link_votes --dry-run # inspect without writing | |
| - | python -m ingest.link_votes --db /path # explicit DB path | |
| - | ||
| - | REFERENCE FORMATS HANDLED | |
| - | -------------------------- | |
| - | 1. House Clerk XML space-separated (from votes_house.py): | |
| - | "H R 22" -> hr -> 119-hr-22 | |
| - | "H RES 977" -> hres -> 119-hres-977 | |
| - | "H J RES 140" -> hjres -> 119-hjres-140 | |
| - | "H CON RES 14" -> hconres | |
| - | "S 1" -> s | |
| - | "S RES 22" -> sres | |
| - | "S J RES 22" -> sjres | |
| - | "S CON RES 13" -> sconres | |
| - | ||
| - | 2. Voteview compact (from voteview.py bill_number CSV column): | |
| - | "HR22" -> hr -> 119-hr-22 | |
| - | "HRES977" -> hres -> 119-hres-977 | |
| - | "HJRES140" -> hjres | |
| - | "HCONRES14" -> hconres | |
| - | "S1" -> s | |
| - | "SRES22" -> sres | |
| - | "SJRES22" -> sjres | |
| - | "SCONRES13" -> sconres | |
| - | ||
| - | BEFORE / AFTER | |
| - | -------------- | |
| - | Total votes: 35,569 | |
| - | Linked (bill_id set) BEFORE: 0 | |
| - | Candidate rows (bill_text_ref set): 34,323 | |
| - | Matched and linked: 1,055 | |
| - | Linked (bill_id set) AFTER: 1,055 | |
| - | Match rate (of candidates): 3.1% | |
| - | Match rate (congress 119 only): 71.1% | |
| - | ||
| - | BREAKDOWN OF UNLINKED | |
| - | --------------------- | |
| - | Presidential Nominations (PN*): 3,319 -- Senate confirmation votes. | |
| - | No bill equivalent. Correct to | |
| - | leave bill_id NULL. Frontend | |
| - | should show "Nomination" label. | |
| - | ||
| - | Treaty documents (TREATYDOC*): 67 -- Senate treaty ratification. | |
| - | No bill equivalent. Show | |
| - | "Treaty Document" label. | |
| - | ||
| - | Historical (congress 101-118): 29,882 -- Bills for these congresses are | |
| - | NOT yet in the bills table. | |
| - | The govinfo/congress pollers only | |
| - | cover congress 119 today. | |
| - | See "EXPANDING COVERAGE" below. | |
| - | ||
| - | Refs with no congress: 40 -- Cannot disambiguate congress. | |
| - | Skip is safe. | |
| - | ||
| - | 3 REAL EXAMPLES | |
| - | --------------- | |
| - | 1. vote_id=74 | |
| - | question: "On Retaining Division A" | |
| - | ref: "H R 6938" | |
| - | bill: H.R. 6938 - Commerce, Justice, Science; Energy and Water | |
| - | Development; and Interior and Environment Appropriations Act, 2026 | |
| - | ||
| - | 2. vote_id=71 | |
| - | question: "On Ordering the Previous Question" | |
| - | ref: "H RES 977" | |
| - | bill: H.Res. 977 - Providing for consideration of H.R. 4593 (showerhead | |
| - | energy standards), H.R. 5184 (manufactured housing standards), | |
| - | and H.R. 6938 (appropriations) | |
| - | ||
| - | 3. vote_id=73 | |
| - | question: "On Motion to Discharge" | |
| - | ref: "H RES 780" | |
| - | bill: H.Res. 780 - Providing for consideration of H.R. 1834 (Break | |
| - | the Gridlock Act) | |
| - | ||
| - | RUN.PY WIRING | |
| - | ------------- | |
| - | "link_votes" step added to PIPELINE in ingest/run.py immediately after | |
| - | "votes_enrich". This means the linkage step runs automatically on every | |
| - | full-pipeline invocation, catching any new votes that gained a matching bill | |
| - | since the last run. | |
| - | ||
| - | Pipeline position (excerpt from run.py): | |
| - | ("votes_enrich", "ingest.pollers.votes_enrich", []), | |
| - | ("link_votes", "ingest.link_votes", []), <-- ADDED | |
| - | ||
| - | POLLERS: FORWARD-LOOKING NOTE | |
| - | ------------------------------ | |
| - | votes_house.py already attempts inline bill_id resolution at ingest time | |
| - | (lines 317-319). It calls _lookup_bill_id(conn, legis_external_id) before | |
| - | inserting the vote row. This works correctly IF the bill is already in the DB | |
| - | when the vote is polled. | |
| - | ||
| - | Since bills are ingested by a separate poller (govinfo/congress.py) that runs | |
| - | earlier in the pipeline, the inline lookup will succeed for any vote polled | |
| - | AFTER the bills poller has run. Votes polled before their bills are in the DB | |
| - | get bill_id=NULL at insert time and are then fixed by link_votes on the next | |
| - | full pipeline run. | |
| - | ||
| - | voteview.py inserts with bill_id=None explicitly and relies on link_votes | |
| - | for backfill (documented in the source comment "look up later if needed"). | |
| - | ||
| - | votes_senate.py does NOT currently parse the bill reference from Senate XML | |
| - | (the <document_title>/<document_number> field). Senate vote refs come from | |
| - | Voteview's bill_number CSV column instead. No change needed in votes_senate.py. | |
| - | ||
| - | EXPANDING COVERAGE (ACTION ITEMS) | |
| - | ----------------------------------- | |
| - | To link historical votes (congress 101-118, ~29,882 rows), ingest bills for | |
| - | those congresses. Then re-run link_votes: | |
| - | ||
| - | # Ingest bills for all available congresses via govinfo (if supported): | |
| - | python -m ingest.pollers.govinfo --congress-start 101 --congress-end 118 | |
| - | ||
| - | # Re-run linkage (idempotent, picks up new matches): | |
| - | python -m ingest.link_votes | |
| - | ||
| - | Alternatively, consider ingesting bulk bill data from congress.gov or ProPublica | |
| - | for historical congresses. The linkage module will automatically pick up any | |
| - | bills with the format "{congress}-{type}-{number}" in bills.external_id. | |
| - | ||
| - | FRONTEND INTEGRATION NOTES | |
| - | ---------------------------- | |
| - | The frontend agent should JOIN on votes.bill_id to display bill information: | |
| - | ||
| - | SELECT v.*, b.number, b.title, b.status | |
| - | FROM votes v | |
| - | LEFT JOIN bills b ON b.id = v.bill_id | |
| - | WHERE ... | |
| - | ||
| - | For votes where bill_id IS NULL: | |
| - | - If bill_text_ref LIKE 'PN%' -> label as "Nomination" | |
| - | - If bill_text_ref LIKE 'TREATYDOC%' -> label as "Treaty Document" | |
| - | - If bill_text_ref IS NOT NULL -> show raw ref + "(bill not in DB)" | |
| - | - If bill_text_ref IS NULL -> label as "Procedural / No bill" | |
| - | ||
| - | The question field often contains a human-readable description ("On Passage", | |
| - | "On Motion to Recommit") that can complement the bill title in the UI. |
| @@ -1,234 +0,0 @@ | ||
| - | # Docket - Member Photo Coverage Notes | |
| - | Generated: 2026-06-26 | |
| - | ||
| - | --- | |
| - | ||
| - | ## 1. Baseline (before this work) | |
| - | ||
| - | | Metric | Count | | |
| - | |---|---| | |
| - | | Total members in DB | 12,767 | | |
| - | | Members with photo_url | 1,297 | | |
| - | | Members without photo_url | 11,470 | | |
| - | | **Overall coverage** | **10.2%** | | |
| - | ||
| - | | Era | Total | With photo | % | | |
| - | |---|---|---|---| | |
| - | | Current Congress (119th) | 537 | 525 | **97.8%** | | |
| - | | Historical (all others) | 12,230 | 772 | 6.3% | | |
| - | ||
| - | **Source:** All 1,297 pre-existing photos came from `unitedstates/images` | |
| - | (CC0, github.com/unitedstates/images), fetched at 450×550 resolution via | |
| - | the `member_photos.py` poller. | |
| - | ||
| - | ### Current Congress gap (12 members without photos - pre-fix) | |
| - | These were newly elected members not yet in the unitedstates/images dataset: | |
| - | ||
| - | | Bioguide | Name | State/Chamber | | |
| - | |---|---|---| | |
| - | | B001323 | Nicholas J. Begich III | AK / House | | |
| - | | B001321 | Tom Barrett | MI / House | | |
| - | | B001324 | Wesley Bell | MO / House | | |
| - | | H001104 | Jon Husted | OH / Senate | | |
| - | | W000831 | James R. Walkinshaw | VA / House | | |
| - | | G000606 | Adelita S. Grijalva | AZ / House | | |
| - | | V000139 | Matt Van Epps | PA / House | | |
| - | | M001245 | Christian D. Menefee | TX / House | | |
| - | | A000383 | Alan Armstrong | OK / Senate | | |
| - | | F000485 | Clay Fuller | TX / House | | |
| - | | M001246 | Analilia Mejia | NJ / House | | |
| - | | G000607 | James Gallagher | CA / House | | |
| - | ||
| - | --- | |
| - | ||
| - | ## 2. New Sources Added | |
| - | ||
| - | ### Source 1 - bioguide.congress.gov (official .gov, public domain) | |
| - | ||
| - | - **URL pattern:** `https://bioguide.congress.gov/photo/{bioguide_id}.jpg` | |
| - | - **Status:** Official U.S. government photographs. Public domain. Served by | |
| - | the Congressional Biographical Directory (bioguide.congress.gov), a .gov | |
| - | domain allowlisted by Docket's neutrality rule #1. | |
| - | - **Implementation note:** This host returns HTTP 403 on HEAD requests. | |
| - | `member_photos_extra.py` uses `requests.get(stream=True)` + immediate | |
| - | `.close()` to check existence without downloading the image body. | |
| - | - **Coverage for 12 current members without photos:** 11/12 confirmed 200 | |
| - | (A000383 Alan Armstrong returned 404 - covered by Wikimedia below). | |
| - | - **Estimated new photos (full run, all 11,470 missing):** | |
| - | - Observed hit rate at progress checkpoint (1,800/11,470): ~19.7% | |
| - | - Extrapolated: **~2,250-2,300 new photos** from bioguide alone | |
| - | - The rate declines moving from recent to older historical members as | |
| - | expected (photography only became widespread ~1850s+). | |
| - | ||
| - | ### Source 2 - Wikimedia Commons via Wikipedia API (CC / PD portraits) | |
| - | ||
| - | - **API:** `https://en.wikipedia.org/w/api.php?action=query&prop=pageimages` | |
| - | - **Image CDN:** `https://upload.wikimedia.org/wikipedia/commons/...` | |
| - | - **Method:** Query Wikipedia for the member's page by name (multiple title | |
| - | variants: exact full name, first+last only, "(politician)" disambiguation). | |
| - | Safety guard: only accept if returned page title contains the member's last name. | |
| - | Thumbnail size: 450px. | |
| - | - **Licensing:** Pre-1929 portraits are public domain. Post-1929 images are | |
| - | typically CC-BY-SA or CC-BY. Attribution details are on the Wikimedia Commons | |
| - | file page (linked from the filename in the URL). | |
| - | - **Sample hit rate (50 random members without bioguide photos):** ~54% | |
| - | - notably higher than bioguide for the historical-but-notable cohort | |
| - | (Bella Abzug, James Abdnor, and similar members with Wikipedia pages). | |
| - | - **Confirmed finds for current members:** | |
| - | - Alan Armstrong (A000383) - found via Wikipedia | |
| - | - Wesley Bell (B001324) - found | |
| - | - Jon Husted (H001104) - found | |
| - | - Matt Van Epps (V000139) - found | |
| - | - Christian D. Menefee (M001245) - found | |
| - | - Clay Fuller (F000485) - found | |
| - | - Analilia Mejia (M001246) - found | |
| - | - **Estimated new photos (Wikimedia pass after bioguide):** | |
| - | ~15-25% of the post-bioguide remainder (~9,200 members) = **~1,400-2,300 | |
| - | additional photos**, primarily for 20th-century members with Wikipedia pages. | |
| - | ||
| - | ### unitedstates/images alternate sizes (investigated, no gain) | |
| - | ||
| - | The `unitedstates/images` project mirrors all sizes consistently: 225x275, 450x550, | |
| - | and `original`. A sample of 50 members who 404'd at 450x550 also 404'd at 225x275 | |
| - | and `original`. No additional coverage available from alternate sizes. | |
| - | ||
| - | ### Honest no-image floor | |
| - | ||
| - | Members with no image in ANY source are the pre-photography historical members | |
| - | (roughly pre-1850, though many 1850-1900 members who weren't prominent are also | |
| - | unrepresented). Based on hit rates: | |
| - | ||
| - | | Source | Estimated hits | | |
| - | |---|---| | |
| - | | unitedstates/images (existing) | 1,297 | | |
| - | | bioguide.congress.gov (new) | ~2,275 | | |
| - | | Wikimedia Commons (new) | ~1,500-2,300 | | |
| - | | **Total with image** | **~5,000-5,800** | | |
| - | | **True no-image members** | **~7,000-7,800** | | |
| - | ||
| - | These are genuine pre-photography or obscure historical members for whom no | |
| - | image has ever been digitized or uploaded to any of these three sources. | |
| - | That cohort is the honest floor - no further scraping will change it | |
| - | without a digitization project. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 3. Module: member_photos_extra.py | |
| - | ||
| - | **Location:** `services/ingest/ingest/pollers/member_photos_extra.py` | |
| - | ||
| - | **Run:** | |
| - | ``` | |
| - | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| - | python -m ingest.pollers.member_photos_extra --once | |
| - | ``` | |
| - | ||
| - | **Flags:** | |
| - | | Flag | Effect | | |
| - | |---|---| | |
| - | | `--bioguide-only` | Skip Wikimedia, bioguide only (faster first pass) | | |
| - | | `--wiki-only` | Skip bioguide, Wikimedia only | | |
| - | | `--delay SECONDS` | Request rate (default 0.1s) | | |
| - | | `--no-current-first` | Disable current-member priority sort | | |
| - | ||
| - | **Design:** Idempotent - only updates `photo_url` where it is currently NULL. | |
| - | Never overwrites unitedstates/images URLs or any previously confirmed photo. | |
| - | Commits every 200 members. Current congress members are front-loaded (processed | |
| - | first) to ensure the visible /members roster looks complete before historical | |
| - | processing completes. | |
| - | ||
| - | **Recommended run order:** | |
| - | ```bash | |
| - | # Pass 1: bioguide (~65 min, ~2,275 hits) | |
| - | python -m ingest.pollers.member_photos_extra --once --bioguide-only | |
| - | ||
| - | # Pass 2: Wikimedia for remainder (~90 min, ~1,500-2,300 hits) | |
| - | python -m ingest.pollers.member_photos_extra --once --wiki-only | |
| - | ``` | |
| - | ||
| - | --- | |
| - | ||
| - | ## 4. CSP Update Required (frontend agent task) | |
| - | ||
| - | `next.config.js` line 18 currently reads: | |
| - | ``` | |
| - | "img-src 'self' data: https://unitedstates.github.io" | |
| - | ``` | |
| - | ||
| - | To render photos from the new sources, extend to: | |
| - | ``` | |
| - | "img-src 'self' data: https://unitedstates.github.io https://bioguide.congress.gov https://upload.wikimedia.org" | |
| - | ``` | |
| - | ||
| - | **Do not edit next.config.js in this PR** - this note is for the frontend agent | |
| - | that owns the page render layer. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 5. UX Recommendation for /members Roster | |
| - | ||
| - | **Problem:** With only ~10% of members having photos, a default "all members" | |
| - | view shows mostly initials placeholders - looks incomplete and undermines trust. | |
| - | ||
| - | **Recommendation:** Apply this priority ordering to the default roster view: | |
| - | ||
| - | 1. **Current Congress members first** (537 members, ~100% photo coverage after | |
| - | this fix) - the page looks complete and professional on first load. | |
| - | 2. **Historical members with a photo** - the ~4,000+ additional historical | |
| - | members who have images. | |
| - | 3. **Historical members without a photo** - reachable via a "Show all historical | |
| - | members" expand or filter toggle. | |
| - | ||
| - | This gives the impression of completeness (which reflects reality for the | |
| - | current Congress) while making historical depth discoverable rather than | |
| - | burying current members in a sea of placeholders. | |
| - | ||
| - | **Filter/toggle copy:** "Showing current Congress. Toggle to see all X,XXX | |
| - | historical members." - factual, no adjectives. | |
| - | ||
| - | **Alternative:** A tab layout - "Current" / "Historical" - with photo-only | |
| - | toggle inside each tab. | |
| - | ||
| - | --- | |
| - | ||
| - | ## 6. Live Run Status / Operational Notes | |
| - | ||
| - | ### Confirmed checkpoint (2026-06-26 22:49) | |
| - | A partial bioguide-only pass reached 5,600/11,470 before being interrupted and | |
| - | confirmed **1,949 bioguide photos** at a rising hit rate (final batches 50-80%, | |
| - | because members are processed oldest-first within each letter and bioguide | |
| - | portraits skew toward 20th-century members). A brief wiki pass added 27. | |
| - | ||
| - | | Source | Confirmed in DB at 22:49 | | |
| - | |---|---| | |
| - | | unitedstates/images (pre-existing) | 1,297 | | |
| - | | bioguide.congress.gov | 1,949 | | |
| - | | upload.wikimedia.org | 27 | | |
| - | | **Total with photo** | **3,273 (25.6%)** | | |
| - | | NULL remaining | 9,494 | | |
| - | ||
| - | ### Final run (in progress) | |
| - | A single **combined** pass (bioguide + Wikimedia per member, sequential) is | |
| - | running detached on CT247 to completion over the 9,494 remaining NULLs: | |
| - | ``` | |
| - | setsid nohup bash /tmp/combined_pass.sh # wraps: python -m ingest.pollers.member_photos_extra --once | |
| - | ``` | |
| - | Log: `/tmp/photos_combined.log` on CT247 (the final `FINAL=...` line carries the | |
| - | end numbers). ETA ~2-2.5h. The poller is idempotent (updates only NULL rows), | |
| - | so the interruption caused no data loss or duplication. | |
| - | ||
| - | ### Operational lesson (for future poller runs) | |
| - | `ssh ... "pct exec ... <long-running cmd>"` ties the remote process lifetime to | |
| - | the SSH session; a SIGTERM to the local wrapper kills the remote poller (this | |
| - | happened once mid-run). Launch long pollers **detached on the container**: | |
| - | `setsid nohup bash <script> >/dev/null 2>&1 </dev/null &` and poll the log file. | |
| - | Never run two writer passes against the SQLite DB concurrently. | |
| - | ||
| - | ### Query post-run coverage | |
| - | ```sql | |
| - | SELECT | |
| - | COUNT(*) total, | |
| - | SUM(CASE WHEN photo_url IS NOT NULL THEN 1 ELSE 0 END) with_photo, | |
| - | ROUND(100.0 * SUM(CASE WHEN photo_url IS NOT NULL THEN 1 ELSE 0 END) / COUNT(*), 1) pct | |
| - | FROM members WHERE bioguide_id IS NOT NULL; | |
| - | ``` |
| @@ -0,0 +1,147 @@ | ||
| + | # Docket - Automatic Data Refresh Cadence | |
| + | ||
| + | How Docket keeps `/opt/docket/data/docket.db` fresh day-to-day without overloading | |
| + | the box or colliding with the live site's reads. Deployed on the deploy host. | |
| + | ||
| + | The previous approach (a single `docket-watcher` timer that ran the full pipeline, | |
| + | including `diff --all`, every 30 minutes) was too heavy and is **removed**. It is | |
| + | replaced by three tiered profiles in `ingest.run`, each on its own systemd timer, | |
| + | all guarded by one shared `flock` so they can never overlap. | |
| + | ||
| + | ## The three tiers | |
| + | ||
| + | All three are profiles of `python -m ingest.run`. Each runs its steps | |
| + | **sequentially** via subprocess - a single sequential writer, which coexists | |
| + | safely with the site's WAL reader connections (`busy_timeout=60000`). | |
| + | ||
| + | ### `--refresh` (FREQUENT - twice daily, 06:00 & 18:00 America/Chicago) | |
| + | Light incremental federal "what's new". Every step is watermark / `seen` / | |
| + | `poller_watermarks`-driven, or only archives new rows. 14 steps, in order: | |
| + | ||
| + | ``` | |
| + | federal_register, openomb, congress, govinfo, bills_bulk, | |
| + | votes_senate, votes_house, votes_enrich, link_votes, archive_votes, | |
| + | crec, meetings, categorize, calendar | |
| + | ``` | |
| + | ||
| + | ### `--daily` (DAILY - 04:30 America/Chicago) | |
| + | Everything in `--refresh` PLUS the roster, fiscal sources, lobbying, bounded | |
| + | enrichment backfills, and state coverage. 22 steps, in order: | |
| + | ||
| + | ``` | |
| + | members, member_photos, usaspending, treasury, | |
| + | federal_register, openomb, congress, govinfo, bills_bulk, | |
| + | votes_senate, votes_house, votes_enrich, link_votes, archive_votes, | |
| + | crec, meetings, lobbying, state_coverage, categorize, calendar, | |
| + | bill_summaries --limit 500, archive_bills --limit 300 | |
| + | ``` | |
| + | ||
| + | Enrichment is **bounded** every run: `bill_summaries --limit 500` and | |
| + | `archive_bills --limit 300` so they can never run away. | |
| + | ||
| + | ### `--weekly` (WEEKLY - Sundays 03:00 America/Chicago) | |
| + | The heaviest steps. 7 steps, in order: | |
| + | ||
| + | ``` | |
| + | bill_versions, diff --all --congress 119, rider_detect, amendments, | |
| + | openstates, legiscan, state_coverage | |
| + | ``` | |
| + | ||
| + | ## What is excluded from the frequent/daily tiers, and why | |
| + | ||
| + | - **`diff --all` + `rider_detect` + `amendments` → WEEKLY only.** The bill-text | |
| + | diff engine (`ingest/diff.py`) has **no recent/changed-only mode** - its CLI | |
| + | only supports a full scan (`--all --congress 119`) or one bill (`--bill-id`). | |
| + | Because there is no incremental diff, the whole diff/rider/amendments chain | |
| + | would be a full rescan on every run, so it lands in the weekly tier rather than | |
| + | daily. `bill_versions` runs first in the weekly tier to refresh the version | |
| + | chains the diff consumes. | |
| + | - **`openstates` / `legiscan` (full state pollers) → WEEKLY only.** Heavy full | |
| + | scans of state legislation; once a week is plenty. | |
| + | - **`voteview` (historical roll-call backfill) → never scheduled.** One-time / | |
| + | manual only: `python -m ingest.pollers.voteview --once --start 101 --end 119`. | |
| + | ||
| + | ## Lock-safety design | |
| + | ||
| + | 1. **Single sequential writer.** `run.py` runs every step in series via | |
| + | `subprocess.run`. There is never more than one writer inside a single profile | |
| + | run. The site reads via WAL with a 60 s `busy_timeout`, so a single sequential | |
| + | writer never produces "database is locked". | |
| + | 2. **Shared `flock` across all three tiers.** Each service's `ExecStart` is: | |
| + | ``` | |
| + | /bin/flock --nonblock --conflict-exit-code 0 /run/docket-ingest.lock <venv-python> -m ingest.run --<tier> | |
| + | ``` | |
| + | `--nonblock` + `--conflict-exit-code 0` means: if another tier already holds | |
| + | `/run/docket-ingest.lock`, this tick **skips cleanly (exit 0)** instead of | |
| + | starting a second concurrent writer. The next scheduled tick catches up | |
| + | (watermarks mean nothing is lost). This guarantees the tiers can never run two | |
| + | writers at once, regardless of how long a run takes. | |
| + | 3. **Staggered schedules** (03:00 weekly Sun, 04:30 daily, 06:00/18:00 refresh) | |
| + | so they don't even attempt to start together; the flock is the hard backstop. | |
| + | 4. **`Persistent=true`** on every timer catches a missed fire across reboots. | |
| + | ||
| + | ## systemd units (on the deploy host, `/etc/systemd/system/`) | |
| + | ||
| + | | Service | Timer (America/Chicago) | Profile | TimeoutStartSec | | |
| + | |---|---|---|---| | |
| + | | `docket-refresh.service` | `*-*-* 06,18:00` | `--refresh` | 3600 | | |
| + | | `docket-daily.service` | `*-*-* 04:30` | `--daily` | 10800 | | |
| + | | `docket-weekly.service` | `Sun *-*-* 03:00`| `--weekly` | 21600 | | |
| + | ||
| + | All run as root, `WorkingDirectory=/opt/docket/services/ingest`, | |
| + | `Environment=DOCKET_DB_PATH=/opt/docket/data/docket.db`, | |
| + | `EnvironmentFile=-/opt/docket/.env`, venv python at | |
| + | `/opt/docket/services/ingest/.venv/bin/python`. | |
| + | ||
| + | Source-of-truth copies live in `services/ingest/deploy/`. | |
| + | ||
| + | ## Running manually | |
| + | ||
| + | ```bash | |
| + | cd /opt/docket/services/ingest | |
| + | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| + | ||
| + | ./.venv/bin/python -m ingest.run --list # show pipeline + all 3 profiles | |
| + | ./.venv/bin/python -m ingest.run --refresh # run the frequent tier now | |
| + | ./.venv/bin/python -m ingest.run --daily # run the daily tier now | |
| + | ./.venv/bin/python -m ingest.run --weekly # run the weekly tier now | |
| + | ./.venv/bin/python -m ingest.run --source congress # run one step | |
| + | ||
| + | # To run manually WITHOUT risking overlap with a scheduled tier, take the lock: | |
| + | /bin/flock --nonblock /run/docket-ingest.lock \ | |
| + | ./.venv/bin/python -m ingest.run --daily | |
| + | ||
| + | # Trigger a scheduled service immediately (also respects the flock): | |
| + | systemctl start docket-refresh.service | |
| + | ``` | |
| + | ||
| + | ## Pausing / resuming | |
| + | ||
| + | ```bash | |
| + | # Pause a tier (stops future fires; a running job finishes) | |
| + | systemctl disable --now docket-refresh.timer | |
| + | ||
| + | # Pause everything | |
| + | systemctl disable --now docket-refresh.timer docket-daily.timer docket-weekly.timer | |
| + | ||
| + | # Resume | |
| + | systemctl enable --now docket-refresh.timer docket-daily.timer docket-weekly.timer | |
| + | ||
| + | # See next fire times | |
| + | systemctl list-timers 'docket-*' --all | |
| + | ||
| + | # Tail a run | |
| + | journalctl -u docket-daily.service -f | |
| + | ``` | |
| + | ||
| + | ## ISR / frontend caching (note) | |
| + | ||
| + | The Next.js pages currently use `export const revalidate = 300` (5 min) on data | |
| + | pages and `3600` on the home page - already well under any freshness concern and | |
| + | inside the "≤900 for list/register/votes/bills" guideline. No ISR change was made | |
| + | with this work: changing `revalidate` requires a production rebuild to take | |
| + | effect, and a rebuild was deliberately avoided while the federal/state backfills | |
| + | were running (to protect the live site). If origin load ever needs trimming, the | |
| + | safe slow-changing candidates to relax to 1800-3600 are `/states`, `/categories`, | |
| + | `/who-pays`, `/how-we-pick`; keep `/votes`, `/bills`, `/register`, `/laws` at | |
| + | ≤900 so daily data surfaces fast. Rebuild + QA off-peak if changed. |
| @@ -1,168 +0,0 @@ | ||
| - | # Bills Pipeline - Build Notes | |
| - | ||
| - | ## Architecture | |
| - | ||
| - | Five new modules under `services/ingest/ingest/`: | |
| - | ||
| - | | Module | Role | | |
| - | |---|---| | |
| - | | `pollers/bills_bulk.py` | Downloads BILLSTATUS sitemaps + individual XMLs; populates `bills`, `bills_ext`, `actions`, `bill_versions`, `amendments` | | |
| - | | `pollers/bill_versions.py` | Supplementary version discovery via HEAD-probe of GovInfo content URLs | | |
| - | | `diff.py` | Section-parse + align + diff engine; populates `bill_diffs` | | |
| - | | `rider_detect.py` | R Street keyword scan over `bill_diffs.change='added'`; sets `rider_flag` | | |
| - | | `amendments.py` | Parses `<amendments>` from BILLSTATUS XML; re-fetches for backfill | | |
| - | ||
| - | Schema additions (all `CREATE TABLE IF NOT EXISTS`): | |
| - | - `bills_ext` - extended bill metadata (bill_type, sponsor_name/party/state) | |
| - | - `bill_versions` - version rows with govinfo_package_id + text_ref (XML URL) | |
| - | - `bill_diffs` - per-section diff rows with change type + snippet + rider_flag | |
| - | - `amendments` - amendment rows with agreed_method + congress.gov source_url | |
| - | ||
| - | --- | |
| - | ||
| - | ## GovInfo Bulk Data Gotchas | |
| - | ||
| - | ### No ZIP files for BILLSTATUS | |
| - | The documented ZIP URL pattern (`BILLSTATUS-119hr.zip`) returns **404** for the 119th Congress. Individual XMLs are accessible; the correct enumeration method is the **sitemap**: | |
| - | ||
| - | ``` | |
| - | https://www.govinfo.gov/sitemap/bulkdata/BILLSTATUS/{congress}{type}/sitemap.xml | |
| - | ``` | |
| - | ||
| - | The sitemap index is at: | |
| - | ``` | |
| - | https://www.govinfo.gov/sitemap/bulkdata/BILLSTATUS/sitemapindex.xml | |
| - | ``` | |
| - | ||
| - | Individual files: | |
| - | ``` | |
| - | https://www.govinfo.gov/bulkdata/BILLSTATUS/{congress}/{type}/BILLSTATUS-{congress}{type}{number}.xml | |
| - | ``` | |
| - | ||
| - | ### Directory listing returns HTML error | |
| - | `GET /bulkdata/BILLSTATUS/119/hr/` returns a "Bulkdata Service Error" HTML page. Use the sitemap instead. | |
| - | ||
| - | ### Bill text (BILLS collection) - no key needed | |
| - | Full bill XML is accessible at: | |
| - | ``` | |
| - | https://www.govinfo.gov/content/pkg/BILLS-{congress}{type}{number}{version}/xml/BILLS-{congress}{type}{number}{version}.xml | |
| - | ``` | |
| - | This path does NOT require an API key. The `/api/` paths (JSON metadata) require a key. | |
| - | ||
| - | ### Sitemap entries are not sorted by bill number | |
| - | The sitemap contains entries in an arbitrary order. The first N entries from the sitemap are a random sample of bills, not the lowest-numbered ones. `--limit 200` will NOT get H.R. 1-200. | |
| - | ||
| - | ### BILLSTATUS XML root tag varies | |
| - | The root element is `<billStatus>` (lowercase) in modern files. The `<bill>` child is always there. Code handles both cases. | |
| - | ||
| - | ### Version date missing for `enr` (Enrolled Bill) | |
| - | The enrolled bill version often has no `version_date` in BILLSTATUS textVersions. The diff engine sorts by `(chain_rank, version_date)` so dateless entries fall into their correct chain position by rank. `enr` = rank 6 in the House chain. | |
| - | ||
| - | ### Section ID format changes between versions | |
| - | Different XML schemas are used for different version types: | |
| - | - Introduced (`ih`, `rh`) → long hex IDs: `H71241E5177324574B4C749197110EAB0` | |
| - | - Enrolled (`enr`) → short USLM IDs: `d244e72` | |
| - | ||
| - | When IDs change between versions, the aligner falls back to fuzzy header matching (threshold 0.6). For short bills where all sections have the same content (e.g., Born-Alive Act H.R. 21 ih→eh), this produces 0 diff rows - correct, the text is identical. | |
| - | ||
| - | For bills where the enrolled version switches from House HTML schema to USLM schema (common for enrolled bills), the aligner may incorrectly classify "removed" + "added" instead of "amended." This is a known GovInfo data quality issue. The enrolled→public-law pairs are most affected. | |
| - | ||
| - | ### Cross-chamber version codes (`pcs`, `rds`, `rih`, `ris`) | |
| - | House bills that go to the Senate acquire `pcs` (Placed on Calendar Senate) before becoming `enr`. Senate bills acquire `rds` (Received/Referred in Senate). These codes are in neither the standard House nor Senate chain; the diff engine assigns them rank 5 (after engrossed, before enrolled). | |
| - | ||
| - | ### Version code `rfs` (Referred in Senate) | |
| - | Appears for House bills passed and sent to Senate. Treated as rank 5 (cross-chamber). This is the same logical position as `pcs`. | |
| - | ||
| - | ### Public law version codes | |
| - | The BILLSTATUS XML sometimes includes a textVersion with type like `-119publ61` for Public Law 119-61. This is the enrolled public law text. Assigned rank 998 (near end of chain). Diffing `enr→-119publ61` usually shows section ID format changes only (not substantive differences). | |
| - | ||
| - | --- | |
| - | ||
| - | ## Real Data Results (as of 2026-06-26, 119th Congress sample) | |
| - | ||
| - | **Initial run (290 bills total):** | |
| - | ||
| - | | Table | Rows | | |
| - | |---|---| | |
| - | | `bills` | 290 | | |
| - | | `bill_versions` | 410 | | |
| - | | `bill_diffs` | 34 | | |
| - | | `amendments` | 99 | | |
| - | | `actions` | 1,695 | | |
| - | ||
| - | **Bills with most versions:** | |
| - | - H.J.Res. 25 (6 versions): Congressional disapproval of CFPB rule | |
| - | - H.R. 165 (6 versions): Wounded Knee Massacre Memorial Act | |
| - | - H.R. 131 (5 versions): Finish the Arkansas Valley Conduit Act | |
| - | - S.J.Res. 28/18/13/11 (5 versions each): Disapproval joint resolutions | |
| - | ||
| - | **Rider flags (3 flagged diff rows):** | |
| - | ||
| - | 1. **H.R. 43** (`rds→enr`) - Alaska Native Claims Settlement Act amendment: | |
| - | > "Section 14(c) of the Alaska Native Claims Settlement Act (43 U.S.C. 1613(c)) is amended..." | |
| - | Signal: `Is Amended` | |
| - | ||
| - | 2. **S. 5 - Laken Riley Act** (`es→enr`): | |
| - | > "Section 236(c) of the Immigration and Nationality Act (8 U.S.C. 1226(c)) is amended..." | |
| - | Signal: `Is Amended` | |
| - | ||
| - | 3. **S. 5 - Laken Riley Act** (`es→enr`, second section): | |
| - | > "Section 235(b) of the Immigration and Nationality Act (8 U.S.C. 1225(b)) is amended..." | |
| - | Signal: `Is Amended` | |
| - | ||
| - | **Real amended section example (H.R. 41, ih→rh):** | |
| - | H.R. 41 - Unrecognized Southeast Alaska Native Communities Recognition and Compensation Act: | |
| - | Section H4DAD6E8E44444A988CA8A1864ADE20A6 was amended between the introduced version and the committee-reported version. The unified diff shows rewording of the Urban Corporation definition and conveyance authorization. | |
| - | ||
| - | **Real amended section example (S. 5, pcs→es):** | |
| - | S. 5 - Laken Riley Act: | |
| - | Section H793DBCBA2A07463588AC4F4FDBEDECAF was amended between the placed-on-calendar and engrossed versions, showing Senate floor amendments to the detention mandate language (INA 236(c)). | |
| - | ||
| - | --- | |
| - | ||
| - | ## How to Run | |
| - | ||
| - | ```bash | |
| - | # Ingest all bill types for 119th Congress (runs from sitemap; ~9K HR + 4K S + …) | |
| - | python -m ingest.pollers.bills_bulk --once | |
| - | ||
| - | # Test run (50 HR bills only) | |
| - | python -m ingest.pollers.bills_bulk --once --types hr --limit 50 | |
| - | ||
| - | # Discover additional text versions via HEAD-probe | |
| - | python -m ingest.pollers.bill_versions --once | |
| - | ||
| - | # Diff all bills with 2+ versions | |
| - | python -m ingest.diff --once --all --congress 119 | |
| - | ||
| - | # Rider detection | |
| - | python -m ingest.rider_detect --once | |
| - | ||
| - | # Amendments | |
| - | python -m ingest.amendments --once | |
| - | ||
| - | # Run tests | |
| - | python -m pytest tests/test_bills_diff.py -v | |
| - | ``` | |
| - | ||
| - | ## Environment Variables | |
| - | ||
| - | | Variable | Purpose | | |
| - | |---|---| | |
| - | | `DOCKET_DB_PATH` | SQLite path (default `/var/tmp/docket/docket.db`) | | |
| - | | `DOCKET_CONGRESS_KEY` | api.data.gov key - unlocks Congress.gov API path in `amendments.py` | | |
| - | ||
| - | ## Version Chain Order | |
| - | ||
| - | House: `ih → rh → rh2 → eh → eah → eas → enr` | |
| - | Senate: `is → rs → pcs → es → eas → enr` | |
| - | Cross-chamber (rank 5): `pcs pch rds rih ris` | |
| - | Public law (rank 998): `-{congress}publ{n}` | |
| - | ||
| - | ## Amendment Data Coverage (honest gaps) | |
| - | ||
| - | - **Senate amendments 117th+ (2021+):** machine-readable text available in GovInfo SAMDT collection. `amendments.py` stores congress.gov URLs; text can be fetched separately. | |
| - | - **House amendments:** text lives in Congressional Record (CREC), not discrete objects in BILLSTATUS. `text` column is NULL; source_url points to congress.gov amendment page. | |
| - | - **Manager's / en-bloc packages:** not public as discrete amendment objects anywhere. Only inferable from CREC narrative. | |
| - | - **Voice vote / UC additions:** `agreed_method` set to `'voice'` or `'uc'`; `text` is NULL; UI must show "no individual record exists." | |
| - | - **Pre-117th amendment text:** not machine-readable in bulk data; linked from congress.gov pages only. |
| @@ -1,146 +0,0 @@ | ||
| - | # 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. |
| @@ -1,149 +0,0 @@ | ||
| - | # 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 services/ingest | |
| - | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| - | python -m ingest.categorize --once | |
| - | ||
| - | # Limit subjects fetch (for testing) | |
| - | 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 |
| @@ -1,167 +0,0 @@ | ||
| - | # Closed-Door + Executive Layer - Build Notes | |
| - | _Built 2026-06-26. All pollers verified live._ | |
| - | ||
| - | ## What was built | |
| - | ||
| - | Four new pollers in `services/ingest/ingest/pollers/`: | |
| - | ||
| - | | File | Source | Table | Auth | | |
| - | |------|--------|-------|------| | |
| - | | `federal_register.py` | federalregister.gov/api/v1 | `executive_actions` | none | | |
| - | | `openomb.py` | apportionment-public.max.gov | `apportionments` | none | | |
| - | | `crec.py` | govinfo.gov/content/pkg/CREC-*/zip/ | `procedural_events` | none | | |
| - | | `lobbying.py` | lda.gov/api/v1 | `lobbying` | env `DOCKET_LDA_KEY` | | |
| - | ||
| - | One new test file: `tests/test_crec_voice_vote.py` (62 tests). | |
| - | ||
| - | All four pollers follow the existing pattern: `_ensure_tables()` creates new | |
| - | tables without touching `db.py`; `validate_source_url()` is called before every | |
| - | write; `is_new_event()` / `mark_seen()` enforce idempotency. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Live run results (2026-06-26) | |
| - | ||
| - | | Table | Rows | Notes | | |
| - | |-------|------|-------| | |
| - | | `executive_actions` | **500** | 7 days of FR docs, all 4 types (PRESDOCU/RULE/PRORULE/NOTICE) | | |
| - | | `apportionments` | **20** | 20 records from FY2025 Corps of Engineers slice | | |
| - | | `procedural_events` | **188** | 5 session days from CREC-2026-06-16 through 06-23 | | |
| - | | `lobbying` | 0 | Graceful skip - `DOCKET_LDA_KEY` not set | | |
| - | ||
| - | --- | |
| - | ||
| - | ## Sample: real Executive Order / rule row | |
| - | ||
| - | ``` | |
| - | type: nprm | |
| - | title: Removing Obsolete References to Twentieth-Century Airman Certificates | |
| - | agency: Transportation Department; Federal Aviation Administration | |
| - | date: 2026-06-26 | |
| - | source_url: https://www.federalregister.gov/documents/2026/06/26/2026-13003/... | |
| - | ``` | |
| - | ||
| - | --- | |
| - | ||
| - | ## Sample: real apportionment showing appropriated vs apportioned | |
| - | ||
| - | ``` | |
| - | agency: Corps of Engineers--Civil Works | |
| - | account: Construction | |
| - | appropriated: $39,088,945,000 | |
| - | apportioned: $39,088,945,000 | |
| - | status: released | |
| - | date: 2024-09-06 | |
| - | source_url: https://apportionment-public.max.gov/Fiscal%20Year%202025/Corps%20 | |
| - | of%20Engineers--Civil%20Works/JSON/FY2025_Agency%3DCOE_... | |
| - | ``` | |
| - | ||
| - | Status logic: apportioned/appropriated ≥ 95% → released; > 0 but < 95% → delayed; 0 → withheld. | |
| - | ||
| - | The FY2025 Corps of Engineers records all show "released" (normal operating year). | |
| - | The impound-tracker signal emerges when `status = withheld` (OMB released $0 against | |
| - | an appropriation) - those rows are the headline feature for Docket's "buried data" pillar. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Sample: real detected voice vote / procedural event | |
| - | ||
| - | ``` | |
| - | kind: cloture | |
| - | date: 2026-06-23 | |
| - | chamber: Both | |
| - | detail: Cloture action: …provisions of Rule XXII of the Standing Rules of the | |
| - | Senate, a vote on cloture will oc… | |
| - | members_jsonb: NULL ← no individual record exists (see honest gap below) | |
| - | source_url: https://www.govinfo.gov/app/details/CREC-2026-06-23 | |
| - | ``` | |
| - | ||
| - | CREC-2026-06-23 yielded: 80 events (cloture × 13, unanimous_consent × 63, suspension × 4). | |
| - | No voice-vote matches on that day - the Senate was on cloture votes, not voice-vote-only measures. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Test results | |
| - | ||
| - | ``` | |
| - | 191 passed in 20.80s | |
| - | ``` | |
| - | ||
| - | - `test_crec_voice_vote.py` - 62 tests: voice-vote regex, UC, cloture, suspension, MTR, | |
| - | discharge, XML/HTML extractor, chamber inference, snippet extraction, `_detect_events()` | |
| - | end-to-end, source-URL .gov enforcement, idempotency. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Technical gotchas discovered during build | |
| - | ||
| - | ### Federal Register API - array param encoding | |
| - | Must use a list of `(key, value)` tuples (not a dict) for `fields[]` and | |
| - | `conditions[type][]`. The field `presidential_document_type` does not exist | |
| - | in the API - causes HTTP 400. Use `abstract` instead. | |
| - | ||
| - | ### GovInfo CREC - correct URL | |
| - | Bulk data path (`/bulkdata/CREC/`) returns HTML error pages, not ZIPs. | |
| - | The working URL is: | |
| - | ``` | |
| - | https://www.govinfo.gov/content/pkg/CREC-YYYY-MM-DD/zip/CREC-YYYY-MM-DD.zip | |
| - | ``` | |
| - | Non-session days (weekends, recesses) return HTML (not 404) - "bad ZIP" errors | |
| - | are expected and handled gracefully. Default look-back is 14 days to cover 5+ | |
| - | session days. | |
| - | ||
| - | ### CREC ZIP contents - HTML, not XML | |
| - | The ZIP contains `.htm` files (not standalone XML). Text extraction strips HTML | |
| - | tags with regex + `html.unescape()`. The `_extract_text_from_xml()` function | |
| - | handles both correctly. | |
| - | ||
| - | ### max.gov apportionments - no REST API | |
| - | `apportionment-public.max.gov` has no JSON API endpoint. The site serves | |
| - | individual JSON files per apportionment record (one per TAFS account + FY + | |
| - | iteration), listed in an HTML index page. The poller: | |
| - | 1. Fetches the HTML index (~500KB, loads in ~20s) | |
| - | 2. Extracts all JSON file hrefs matching the target FY via regex | |
| - | 3. Downloads individual records (capped at 200/run, 50 for `--once`) | |
| - | ||
| - | FY2025 has 6,172 JSON links; FY2026 will grow through the year. A scheduled | |
| - | hourly/daily run can work through the full set over time. | |
| - | ||
| - | openomb.org (the aggregator) returns HTTP 403 for automated requests - use | |
| - | max.gov directly, which is also the correct source_url target per the | |
| - | neutrality rule. | |
| - | ||
| - | ### Neutrality rule - source_url enforcement | |
| - | - `federal_register.py`: html_url from FR API → federalregister.gov (.gov ✓) | |
| - | - `openomb.py`: direct max.gov JSON file URL (.max.gov = .gov ✓) | |
| - | - `crec.py`: govinfo.gov app/details page (.gov ✓) | |
| - | - `lobbying.py`: lda.gov/filings/{uuid}/ (.gov ✓) | |
| - | ||
| - | All validated by `source_validator.validate_source_url()` before every DB write. | |
| - | ||
| - | --- | |
| - | ||
| - | ## How to run | |
| - | ||
| - | ```bash | |
| - | cd services/ingest | |
| - | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| - | python -m ingest.pollers.federal_register --once | |
| - | ||
| - | python -m ingest.pollers.openomb --once --max 50 | |
| - | python -m ingest.pollers.crec --once --days 14 | |
| - | DOCKET_LDA_KEY=<your_key> python -m ingest.pollers.lobbying --once | |
| - | ``` | |
| - | ||
| - | ## LDA key | |
| - | ||
| - | Register free at https://lda.gov/api/. Without a key the lobbying poller logs | |
| - | a clear message and returns 0 - no crash, no partial state. | |
| - | ||
| - | ## Recommended poll cadences | |
| - | ||
| - | | Poller | Cadence | Notes | | |
| - | |--------|---------|-------| | |
| - | | federal_register | Every 4h | Same-day FR lag | | |
| - | | openomb | Daily | max.gov updates when OMB signs a new apportionment | | |
| - | | crec | Daily (noon ET) | ~1-day CREC publishing lag | | |
| - | | lobbying | Weekly | LDA filings are quarterly | |
| @@ -1,144 +0,0 @@ | ||
| - | # Docket Ingest Service - Notes | |
| - | ||
| - | ## What Was Built | |
| - | ||
| - | Python ingest spine for the Docket government-activity tracker (Phase 1). | |
| - | All code lives at `services/ingest/` in the repo. | |
| - | ||
| - | ### Module layout | |
| - | ||
| - | ``` | |
| - | ingest/ | |
| - | ├── __init__.py | |
| - | ├── run.py # CLI entrypoint (python -m ingest.run --once) | |
| - | ├── db.py # SQLite schema + connection (Postgres target in prod) | |
| - | ├── change_detection.py # Dedupe on (source, externalId, updateDate) | |
| - | ├── ranking.py # Feed ranking - only 4 permitted fields (rule #3) | |
| - | ├── source_validator.py # .gov allowlist enforcement (rule #1) | |
| - | └── pollers/ | |
| - | ├── usaspending.py # FULLY LIVE - no auth, pulls real awards | |
| - | ├── congress.py # Skeleton - needs DOCKET_CONGRESS_KEY | |
| - | ├── govinfo.py # Skeleton - needs DOCKET_CONGRESS_KEY (same key) | |
| - | ├── treasury.py # Skeleton - no auth (endpoints need verification) | |
| - | ├── votes_house.py # Live - House Clerk XML, no auth | |
| - | ├── votes_senate.py # Live - Senate roll-call XML, no auth | |
| - | └── openstates.py # Skeleton - needs DOCKET_OPENSTATES_KEY (paid tier) | |
| - | tests/ | |
| - | ├── test_ranking.py # 16 tests - neutrality rule #3 enforcement | |
| - | └── test_source_url.py # 19 tests - neutrality rule #1 enforcement | |
| - | ``` | |
| - | ||
| - | ## How to Run | |
| - | ||
| - | ### Setup (one-time) | |
| - | ||
| - | ```bash | |
| - | cd services/ingest | |
| - | python3 -m venv .venv | |
| - | .venv/bin/pip install -r requirements.txt | |
| - | ``` | |
| - | ||
| - | ### Run all pollers once | |
| - | ||
| - | ```bash | |
| - | cd services/ingest | |
| - | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| - | python -m ingest.run --once | |
| - | ``` | |
| - | ||
| - | ### Run one poller | |
| - | ||
| - | ```bash | |
| - | python -m ingest.run --source usaspending | |
| - | python -m ingest.run --source votes_senate | |
| - | ``` | |
| - | ||
| - | ### Run tests | |
| - | ||
| - | ```bash | |
| - | cd services/ingest | |
| - | python -m pytest tests/ -v | |
| - | ``` | |
| - | ||
| - | ## Test Results (verified 2026-06-26) | |
| - | ||
| - | ``` | |
| - | 35 passed in 4.09s | |
| - | ``` | |
| - | ||
| - | - `test_ranking.py` (16 tests): all ranking-function neutrality assertions pass, | |
| - | including AST inspection that confirms no forbidden fields (social/engagement/news) | |
| - | appear in any ranking helper. | |
| - | - `test_source_url.py` (19 tests): all source-URL allowlist assertions pass, | |
| - | including integration tests writing to a temp SQLite DB. | |
| - | ||
| - | ## Live Ingest Run Results (2026-06-26) | |
| - | ||
| - | ``` | |
| - | usaspending → 320 new award rows | |
| - | votes_senate → 17 new Senate vote rows | |
| - | congress → 0 (no API key) | |
| - | govinfo → 0 (no API key) | |
| - | treasury → 0 (endpoint paths need verification) | |
| - | votes_house → 0 (no votes found for 2026 - correct, House not in session) | |
| - | openstates → 0 (no API key) | |
| - | ``` | |
| - | ||
| - | ### Sample award row (largest by amount) | |
| - | ||
| - | ``` | |
| - | source: usaspending | |
| - | external_id: CONT_AWD_DEAC3608GO28308_8900_-NONE-_-NONE- | |
| - | agency: Department of Energy | |
| - | recipient: ALLIANCE FOR ENERGY INNOVATION, LLC | |
| - | amount: $10,788,357,674.94 | |
| - | action_date: (null - this award type reports null action_date from USAspending) | |
| - | program: other | |
| - | source_url: https://www.usaspending.gov/award/CONT_AWD_DEAC3608GO28308_8900_-NONE-_-NONE- | |
| - | ``` | |
| - | ||
| - | SQLite DB location on CT241: `/var/tmp/docket/docket.db` | |
| - | Override with: `export DOCKET_DB_PATH=/path/to/docket.db` | |
| - | ||
| - | ## API Keys Required | |
| - | ||
| - | ### Congress.gov + GovInfo (same key) | |
| - | - **Env var:** `DOCKET_CONGRESS_KEY` | |
| - | - **Get it free:** https://api.data.gov/signup | |
| - | - Rate limit: 5,000 req/hr (Congress.gov) / 36,000 req/hr (GovInfo) | |
| - | - Without this key the congress and govinfo pollers skip gracefully - no crash. | |
| - | ||
| - | ### OpenStates (state bills) | |
| - | - **Env var:** `DOCKET_OPENSTATES_KEY` | |
| - | - **Free tier:** 500 req/day - too tight for production. | |
| - | - **Required tier:** Bronze (5k/day) or Silver (50k/day) - contact OpenStates for pricing. | |
| - | - Without this key the openstates poller skips gracefully - no crash. | |
| - | ||
| - | ## Known Issues / Follow-ups | |
| - | ||
| - | 1. **Treasury endpoints return 404** - the fiscaldata.treasury.gov API paths may | |
| - | have changed. Needs a quick endpoint audit against their API docs before the | |
| - | Treasury poller will be useful. | |
| - | ||
| - | 2. **USAspending `loans` group returns 400** - the loan award codes (07, 08) seem | |
| - | to require a different query structure. The other 4 groups work correctly. | |
| - | ||
| - | 3. **`Action Date` is null for many USAspending awards** - the `/search/spending_by_award/` | |
| - | endpoint returns null for Action Date on many records. For completeness, use the | |
| - | `/bulk_download/` endpoint or per-award detail endpoint to get richer metadata. | |
| - | ||
| - | 4. **SQLite must be on local storage** - Network filesystems (CIFS/NFS) do not | |
| - | support SQLite's file-locking mechanism. Keep the DB on local disk. | |
| - | Set `DOCKET_DB_PATH` to a local path. For production scale, migrate to Postgres. | |
| - | ||
| - | 5. **Venv must be on local storage** - same filesystem requirement; the venv | |
| - | should live on the deployment host's local disk. | |
| - | ||
| - | ## Neutrality Enforcement (architectural summary) | |
| - | ||
| - | - **Rule #1 (gov-source-only):** `source_validator.validate_source_url()` is called | |
| - | before every DB write. Raises `SourceURLError` on any non-`.gov` host. Enforced | |
| - | by 19 unit tests. | |
| - | - **Rule #3 (action-only ranking):** `ranking.score()` accepts exactly 4 parameters | |
| - | (`actionDate`, `dollarAmount`, `affectedEstimate`, `actionType`). AST inspection | |
| - | in 5 unit tests confirms no engagement/social/news field appears in any helper. |
| @@ -1,164 +0,0 @@ | ||
| - | # Meetings Ingest - Notes | |
| - | ||
| - | Module: `ingest/pollers/meetings.py` | |
| - | Table: `meetings` | |
| - | Tests: `tests/test_meetings.py` (58 tests, all pass) | |
| - | ||
| - | ## Run | |
| - | ||
| - | ```bash | |
| - | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| - | python -m ingest.pollers.meetings --once | |
| - | ``` | |
| - | ||
| - | Options: | |
| - | - `--days-back N` - days of past House committee meetings to scan (default 30) | |
| - | - `--days-ahead N` - days of future House committee meetings to scan (default 90) | |
| - | - `--no-detail` - skip per-event detail fetches (faster; meeting type omitted for new events) | |
| - | ||
| - | ## What it does | |
| - | ||
| - | Ingests congressional committee + floor activity from three keyless sources. | |
| - | ||
| - | ### Sources and counts (2026-06-26, 30-back / 90-ahead window) | |
| - | ||
| - | | Source | Chamber | Kind | Count | | |
| - | |---|---|---|---| | |
| - | | senate.gov XML | Senate | hearings | 1 | | |
| - | | senate.gov XML | Senate | business_meeting | 1 | | |
| - | | docs.house.gov | House | hearing | 68 | | |
| - | | docs.house.gov | House | markup | 19 | | |
| - | | docs.house.gov | House | floor | 66 | | |
| - | | docs.house.gov | House | other | 11 | | |
| - | | **TOTAL** | | | **166** | | |
| - | ||
| - | ### Source 1 - Senate committee meetings XML | |
| - | ||
| - | **URL:** `https://www.senate.gov/general/committee_schedules/hearings.xml` | |
| - | **Status:** Working. Returns clean structured XML with all upcoming meetings. | |
| - | **Update cadence:** Regenerated multiple times daily. | |
| - | **Fields extracted:** identifier, committee, subcommittee, date, time, location (room), subject (matter), type. | |
| - | **Source URL stored:** `https://www.senate.gov/committees/hearings_meetings.htm` | |
| - | ||
| - | The XML provides a `last_update_iso_8601` field used for change-detection in the `seen` table. Meetings are only re-fetched when they change. | |
| - | ||
| - | **Note:** On days with no Senate committee meetings, the XML returns a single placeholder entry with no `<identifier>` element. This is correctly skipped. | |
| - | ||
| - | ### Source 2 - House committee meetings (HTML) | |
| - | ||
| - | **Calendar day index:** `https://docs.house.gov/Committee/Calendar/ByDay.aspx?DayID=MMDDYYYY` | |
| - | **Event detail:** `https://docs.house.gov/Committee/Calendar/ByEvent.aspx?EventID=N` | |
| - | **Status:** Working. Data-rich HTML tables. | |
| - | ||
| - | Two-phase approach: | |
| - | 1. **Phase 1 (parallel):** Fetch all `ByDay.aspx` pages for the date window using `ThreadPoolExecutor` (8 workers). Extracts `EventID`, title, committee, time, location, status (Rescheduled/Canceled/Postponed). | |
| - | 2. **Phase 2 (parallel):** For new `EventID`s only, fetch the event detail page (4 workers) to extract meeting type (`Hearing:` / `Markup:` / `Business Meeting:` prefix in `<h1>`). | |
| - | ||
| - | Change-detection key: `status|date` - re-processes a meeting if its status or scheduled date changes. | |
| - | ||
| - | **Source URL per row:** `https://docs.house.gov/Committee/Calendar/ByEvent.aspx?EventID={N}` (docs.house.gov is .gov ✓) | |
| - | ||
| - | **Noted:** Individual meeting XML download on ByEvent.aspx requires JavaScript postback (`__doPostBack`) - inaccessible without a browser. The HTML scrape provides all fields needed. | |
| - | ||
| - | ### Source 3 - House floor schedule XML | |
| - | ||
| - | **URL pattern:** `https://docs.house.gov/billsthisweek/YYYYMMDD/YYYYMMDD.xml` (Monday date) | |
| - | **Status:** Working. Scans current week + 4 prior weeks (weeks with no XML return 404 and are skipped). | |
| - | **Fields extracted:** bill number (legis-num), short description (floor-text), floor category (suspension/rule/etc.), week date. | |
| - | **Type:** always `floor` | |
| - | **Source URL per row:** `https://docs.house.gov/floor/Default.aspx?date=YYYY-MM-DD` | |
| - | ||
| - | **Not a source:** Congress.gov committee schedule - key-gated. The `docs.house.gov` committee repository is the keyless equivalent. | |
| - | **Not a source:** Federal Register Sunshine Act notices - FR poller (`executive_actions`) owns those; committee meetings here focus on congressional activity only. | |
| - | ||
| - | ## Schema | |
| - | ||
| - | ```sql | |
| - | CREATE TABLE meetings ( | |
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| - | source TEXT NOT NULL, | |
| - | external_id TEXT NOT NULL, | |
| - | chamber TEXT NOT NULL CHECK(chamber IN ('Senate','House')), | |
| - | committee TEXT, | |
| - | date TEXT NOT NULL, -- YYYY-MM-DD | |
| - | time TEXT, -- as-published (e.g. "10:00 AM") | |
| - | datetime_iso TEXT, -- YYYY-MM-DDTHH:MM:SS | |
| - | title TEXT, | |
| - | subject TEXT, | |
| - | type TEXT CHECK(type IN ( | |
| - | 'hearing','markup','business_meeting','floor','other')), | |
| - | location TEXT, | |
| - | status TEXT NOT NULL DEFAULT 'scheduled' | |
| - | CHECK(status IN ('scheduled','held','canceled','postponed')), | |
| - | source_url TEXT NOT NULL, | |
| - | UNIQUE (source, external_id) | |
| - | ); | |
| - | CREATE INDEX idx_meetings_date ON meetings(date); | |
| - | CREATE INDEX idx_meetings_committee ON meetings(committee); | |
| - | ``` | |
| - | ||
| - | ## 3 Real example meetings (from production run 2026-06-26) | |
| - | ||
| - | **Example 1 - Senate committee (upcoming)** | |
| - | ``` | |
| - | Chamber: Senate | |
| - | Committee: Personnel Subcommittee (Armed Services) | |
| - | Date/Time: 2026-07-15 02:30 PM | |
| - | Type: hearing | |
| - | Title: Hearings to examine the TRICARE pharmacy program. | |
| - | Location: SR-222 | |
| - | Source: https://www.senate.gov/committees/hearings_meetings.htm | |
| - | ``` | |
| - | ||
| - | **Example 2 - House committee hearing (upcoming)** | |
| - | ``` | |
| - | Chamber: House | |
| - | Committee: Europe Subcommittee (Committee on Foreign Affairs) | |
| - | Date/Time: 2026-06-30 10:00 AM | |
| - | Type: hearing | |
| - | Title: Arms Control and Nonproliferation in an Era of Great Power Competition | |
| - | Location: 2172 RHOB | |
| - | Source: https://docs.house.gov/Committee/Calendar/ByEvent.aspx?EventID=119256 | |
| - | ``` | |
| - | ||
| - | **Example 3 - House committee hearing (past)** | |
| - | ``` | |
| - | Chamber: House | |
| - | Committee: Select Committee on the Strategic Competition Between the United States and the Chinese Communist Party | |
| - | Date/Time: 2026-06-25 10:30 AM | |
| - | Type: hearing | |
| - | Title: China's Economic Espionage and Subnational Influence in the... | |
| - | Location: (varies) | |
| - | Source: https://docs.house.gov/Committee/Calendar/ByEvent.aspx?EventID=... | |
| - | ``` | |
| - | ||
| - | ## Sources that are blocked / unavailable (keyless) | |
| - | ||
| - | | Source | Status | Reason | | |
| - | |---|---|---| | |
| - | | Congress.gov committee schedule API | Key-gated | Requires `DOCKET_CONGRESS_KEY` | | |
| - | | docs.house.gov Meeting XML download | JS-only | `__doPostBack` - no direct URL | | |
| - | | Individual Senate committee pages | No structured feed | Per-committee HTML only | | |
| - | | CHRG (hearing transcripts) | Key-gated (GovInfo API) | Available in bulk with key | | |
| - | ||
| - | ## Future /api/schedule endpoint | |
| - | ||
| - | Suggested query pattern for the frontend: | |
| - | ||
| - | ```sql | |
| - | SELECT date, chamber, committee, time, datetime_iso, title, type, location, status, source_url | |
| - | FROM meetings | |
| - | WHERE date BETWEEN :start AND :end | |
| - | AND (:chamber IS NULL OR chamber = :chamber) | |
| - | AND (:committee IS NULL OR committee LIKE :committee) | |
| - | AND (:type IS NULL OR type = :type) | |
| - | ORDER BY datetime_iso NULLS LAST, date, committee | |
| - | LIMIT :limit OFFSET :offset | |
| - | ``` | |
| - | ||
| - | This supports the target UX: "Mon 2026-08-25, 4:55 PM - 4 committee meetings on X, Y, Z." | |
| - | The `date` index makes date-range queries fast. The `committee` index supports committee-specific filtering. | |
| - | ||
| - | ## Neutrality | |
| - | ||
| - | All rows carry factual scheduled activity data only: committee name, date, time, subject. No adjectives, no editorial characterization of hearing importance or significance. Source URLs point directly to official `.gov` pages. Meeting subjects are reproduced verbatim from official sources. |
| @@ -1,209 +0,0 @@ | ||
| - | # State-Level Ingest - Notes | |
| - | ||
| - | ## What Was Built | |
| - | ||
| - | State-level granular coverage with honest confidence scoring for all 52 US | |
| - | jurisdictions (50 states + DC + PR). | |
| - | ||
| - | ### Files created / modified | |
| - | ||
| - | | File | Change | | |
| - | |------|--------| | |
| - | | `ingest/source_validator.py` | Added `STATE_LEG_HOSTS` + `_is_state_leg_pattern()` + updated `is_allowlisted()` | | |
| - | | `ingest/pollers/openstates.py` | Full rewrite - was a stub; now fetches bills + votes + sponsors for all 52 jurisdictions | | |
| - | | `ingest/pollers/legiscan.py` | **New** - LegiScan API poller (completeness supplement) | | |
| - | | `ingest/state_coverage.py` | **New** - honesty layer; computes + writes state_coverage rows | | |
| - | | `tests/test_state_coverage.py` | **New** - 55 tests for coverage logic + state-host allowlist | | |
| - | ||
| - | --- | |
| - | ||
| - | ## Running | |
| - | ||
| - | ### Compute state coverage (works without any API keys) | |
| - | ||
| - | ```bash | |
| - | cd services/ingest | |
| - | export DOCKET_DB_PATH=/path/to/docket.db | |
| - | python -m ingest.state_coverage | |
| - | ||
| - | # JSON output | |
| - | python -m ingest.state_coverage --json | |
| - | ``` | |
| - | ||
| - | Expected output (no keys, empty state DB): | |
| - | ``` | |
| - | Jurisdictions tracked : 52 (50 states + DC + PR) | |
| - | Total coverage rows : 312 | |
| - | ok (fresh) : 0 | |
| - | stale (>48h) : 0 | |
| - | missing (never seen) : 52 | |
| - | ``` | |
| - | ||
| - | ### Run OpenStates poller (requires key) | |
| - | ||
| - | ```bash | |
| - | export DOCKET_OPENSTATES_KEY=<your_key> | |
| - | # MVP subset (12 jurisdictions - conserves 500/day free quota) | |
| - | python -m ingest.run --source openstates | |
| - | ||
| - | # All 52 jurisdictions (needs paid tier) | |
| - | DOCKET_OPENSTATES_ALL=1 python -m ingest.run --source openstates | |
| - | ``` | |
| - | ||
| - | ### Run LegiScan poller (requires key) | |
| - | ||
| - | ```bash | |
| - | export DOCKET_LEGISCAN_KEY=<your_key> | |
| - | # MVP subset (12 states - ~200 API calls) | |
| - | python -m ingest.run --source legiscan | |
| - | ||
| - | # All 52 jurisdictions (needs ~2k API calls - monitor monthly quota) | |
| - | DOCKET_LEGISCAN_ALL=1 python -m ingest.run --source legiscan | |
| - | ``` | |
| - | ||
| - | ### Run tests | |
| - | ||
| - | ```bash | |
| - | cd services/ingest | |
| - | python -m pytest tests/ -v | |
| - | # Expected: 190 passed | |
| - | ``` | |
| - | ||
| - | --- | |
| - | ||
| - | ## Getting API Keys (free) | |
| - | ||
| - | ### OpenStates | |
| - | - **URL:** https://openstates.org/accounts/login/ | |
| - | - **Free tier:** 500 requests/day, 10 requests/second | |
| - | - **Env var:** `DOCKET_OPENSTATES_KEY` | |
| - | - **Caveat:** 500 req/day is too tight for all 52 jurisdictions. MVP polls 12 | |
| - | jurisdictions (the 10 most-populated states + DC + PR) to stay within quota. | |
| - | Production requires Bronze (5k/day) or Silver (50k/day) - contact | |
| - | openstates@openstates.org for pricing. | |
| - | ||
| - | ### LegiScan | |
| - | - **URL:** https://legiscan.com/legiscan | |
| - | - **Free tier:** ~30,000 requests/month (~1,000/day) | |
| - | - **Env var:** `DOCKET_LEGISCAN_KEY` | |
| - | - **Efficiency:** The `getMasterListRaw` sync strategy minimises API calls - | |
| - | only bills whose `change_hash` changed are fetched via `getBill`. Cap: | |
| - | `MAX_BILL_FETCH_PER_STATE=50` per state per run (configurable in code). | |
| - | ||
| - | --- | |
| - | ||
| - | ## Source URL Allowlist Extensions | |
| - | ||
| - | State legislatures that don't use a `.gov` TLD require explicit allowlisting. | |
| - | The following were added to `source_validator.py`: | |
| - | ||
| - | ### STATE_LEG_HOSTS (explicit non-.gov hosts) | |
| - | ||
| - | | State | Domain | | |
| - | |-------|--------| | |
| - | | Pennsylvania | `www.legis.state.pa.us` | | |
| - | | Texas | `capitol.state.tx.us`, `legis.state.tx.us` | | |
| - | | Minnesota | `www.leg.state.mn.us`, `www.house.leg.state.mn.us`, `www.senate.leg.state.mn.us` | | |
| - | | Mississippi | `billstatus.ls.state.ms.us`, `ls.state.ms.us` | | |
| - | | Nevada | `www.leg.state.nv.us` | | |
| - | | New Hampshire | `www.gencourt.state.nh.us` | | |
| - | | New Jersey | `www.njleg.state.nj.us` | | |
| - | | Alabama | `alison.legislature.state.al.us` | | |
| - | | Alaska | `www.legis.state.ak.us` | | |
| - | | Arkansas | `www.arkleg.state.ar.us` | | |
| - | | Kansas | `www.kslegislature.org` ← unique .org | | |
| - | | Puerto Rico | `www.oslpr.org`, `www.camaraderepresentantes.org`, `www.senadopr.us` | | |
| - | | DC Council | `dccouncil.us` | | |
| - | ||
| - | ### Pattern rule: `*.state.<XX>.us` | |
| - | ||
| - | Any host matching `<anything>.state.<2-letter-code>.us` is automatically | |
| - | allowlisted via `_is_state_leg_pattern()`. This covers: | |
| - | - `legis.state.pa.us` (Pennsylvania) | |
| - | - `njleg.state.nj.us` (New Jersey) | |
| - | - `billstatus.ls.state.ms.us` (Mississippi) | |
| - | - Any new state portal added at a `*.state.XX.us` subdomain without code change | |
| - | ||
| - | This pattern does NOT match `govtrack.us`, `example.us`, or similar non-government `.us` domains. | |
| - | ||
| - | --- | |
| - | ||
| - | ## Architecture | |
| - | ||
| - | ### state_coverage table | |
| - | ||
| - | ```sql | |
| - | CREATE TABLE IF NOT EXISTS state_coverage ( | |
| - | state TEXT NOT NULL, | |
| - | source TEXT NOT NULL, -- 'openstates' or 'legiscan' | |
| - | data_type TEXT NOT NULL, -- 'bills', 'votes', 'sponsors' | |
| - | last_scrape TEXT, -- ISO UTC timestamp of last successful scrape | |
| - | confidence TEXT NOT NULL DEFAULT 'missing', -- 'ok' | 'stale' | 'missing' | |
| - | PRIMARY KEY (state, source, data_type) | |
| - | ); | |
| - | ``` | |
| - | ||
| - | ### scrape_log table | |
| - | ||
| - | ```sql | |
| - | CREATE TABLE IF NOT EXISTS scrape_log ( | |
| - | source TEXT NOT NULL, | |
| - | state TEXT NOT NULL, | |
| - | data_type TEXT NOT NULL, | |
| - | scraped_at TEXT NOT NULL, -- ISO UTC timestamp | |
| - | row_count INTEGER NOT NULL DEFAULT 0, | |
| - | PRIMARY KEY (source, state, data_type) | |
| - | ); | |
| - | ``` | |
| - | ||
| - | ### Confidence logic | |
| - | ||
| - | - `ok`: `scraped_at` within the last 48 hours | |
| - | - `stale`: `scraped_at` exists but older than 48 hours | |
| - | - `missing`: no scrape record for this (state, source, data_type) combination | |
| - | ||
| - | ### Poller → coverage flow | |
| - | ||
| - | ``` | |
| - | openstates.poll() / legiscan.poll() | |
| - | └─ for each jurisdiction: | |
| - | ├─ fetch + store bills + votes | |
| - | └─ log_scrape(conn, source, state, 'bills', count) | |
| - | log_scrape(conn, source, state, 'votes', count) | |
| - | log_scrape(conn, source, state, 'sponsors', count) | |
| - | ||
| - | state_coverage.compute() | |
| - | ├─ reads scrape_log | |
| - | ├─ fallback: infers from raw_events (OCD/legiscan external_id parsing) | |
| - | └─ writes 312 rows to state_coverage (52 × 2 × 3) | |
| - | ``` | |
| - | ||
| - | ### API surface (GET /api/states) | |
| - | ||
| - | The `state_coverage` table directly backs the `/api/states` endpoint: | |
| - | ```json | |
| - | { | |
| - | "code": "CA", | |
| - | "name": "California", | |
| - | "confidence": "ok", | |
| - | "lastScrape": "2026-06-26T12:00:00Z", | |
| - | "missingTypes": [] | |
| - | } | |
| - | ``` | |
| - | ||
| - | --- | |
| - | ||
| - | ## Known Gaps (surface in-product - never hide) | |
| - | ||
| - | 1. **Free quota insufficient for prod**: OpenStates 500 req/day covers ~12 states/day; | |
| - | LegiScan 30k/month covers all states if running daily (tight). | |
| - | 2. **Historical coverage**: pollers default to `days_back=1`. First run on a state | |
| - | won't have historical bill history - only recent changes. | |
| - | 3. **Worst-lagging states**: MA, NE, KY, AL historically 24-48h behind in both | |
| - | OpenStates and LegiScan (known in DATA_SOURCES.md). | |
| - | 4. **Ballot measures / interim committees**: poorly covered by both APIs. | |
| - | 5. **Amendment text**: LegiScan has better amendment completeness than OpenStates, | |
| - | but pre-2021 coverage is spotty in both. | |
| - | 6. **Vote positions** (`vote_positions` table): not yet populated - yea/nay counts | |
| - | only. Individual member positions require `getVote` (LegiScan) per vote - costs | |
| - | extra API calls. Phase 2 work. |
| @@ -1,155 +0,0 @@ | ||
| - | # Docket - Votes & Members Build Notes | |
| - | ||
| - | Verified 2026-06-26 (venv `/opt/docket/services/ingest/.venv`, DB `/opt/docket/data/docket.db`). | |
| - | ||
| - | ## Files Created / Modified | |
| - | ||
| - | | File | Status | Purpose | | |
| - | |---|---|---| | |
| - | | `ingest/pollers/members.py` | **new** | Full current member roster (537 members) | | |
| - | | `ingest/pollers/votes_house.py` | **rewritten** | House XML with per-member positions | | |
| - | | `ingest/pollers/votes_enrich.py` | **new** | Senate positions backfill from existing vote source_urls | | |
| - | | `tests/test_votes_house_parser.py` | **new** | 39 tests: XML parser, position enum, schema, both pivots | | |
| - | ||
| - | ## Schema additions (additive - all CREATE TABLE IF NOT EXISTS / ALTER TABLE) | |
| - | ||
| - | ```sql | |
| - | CREATE TABLE IF NOT EXISTS members ( | |
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| - | bioguide_id TEXT NOT NULL UNIQUE, | |
| - | full_name TEXT NOT NULL, | |
| - | party TEXT, state TEXT, district INTEGER, chamber TEXT, | |
| - | lis_member_id TEXT, govtrack_id INTEGER, icpsr_id INTEGER, | |
| - | source_url TEXT NOT NULL -- https://bioguide.congress.gov/search/bio/{id} | |
| - | ); | |
| - | CREATE INDEX IF NOT EXISTS idx_members_lis ON members(lis_member_id); | |
| - | ||
| - | CREATE TABLE IF NOT EXISTS vote_positions ( | |
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| - | vote_id INTEGER NOT NULL REFERENCES votes(id), | |
| - | member_bioguide TEXT, | |
| - | member_id INTEGER REFERENCES members(id), | |
| - | position TEXT NOT NULL CHECK(position IN ('Yea','Nay','Present','Not Voting')) | |
| - | ); | |
| - | CREATE INDEX IF NOT EXISTS idx_vote_positions_vote_id ON vote_positions(vote_id); | |
| - | CREATE INDEX IF NOT EXISTS idx_vote_positions_member_id ON vote_positions(member_id); | |
| - | CREATE INDEX IF NOT EXISTS idx_vote_positions_bioguide ON vote_positions(member_bioguide); | |
| - | ||
| - | ALTER TABLE votes ADD COLUMN congress INTEGER; | |
| - | ALTER TABLE votes ADD COLUMN session INTEGER; | |
| - | ALTER TABLE votes ADD COLUMN roll_number INTEGER; | |
| - | ALTER TABLE votes ADD COLUMN question TEXT; | |
| - | ALTER TABLE votes ADD COLUMN not_voting INTEGER; | |
| - | ALTER TABLE votes ADD COLUMN recorded INTEGER DEFAULT 1; | |
| - | ALTER TABLE votes ADD COLUMN vote_external_id TEXT; | |
| - | ALTER TABLE votes ADD COLUMN bill_text_ref TEXT; | |
| - | ``` | |
| - | ||
| - | ## Data ingested (2026-06-26 baseline) | |
| - | ||
| - | | Source | Count | | |
| - | |---|---| | |
| - | | Members | 537 (437 House, 100 Senate) | | |
| - | | House votes (2026, rolls 1-62) | 50 | | |
| - | | Senate votes (119/1, votes 1-40) | 40 | | |
| - | | vote_positions total | 25,572 | | |
| - | | vote_positions (House) | 21,581 | | |
| - | | vote_positions (Senate) | 3,991 | | |
| - | ||
| - | ## Running the pollers | |
| - | ||
| - | ```bash | |
| - | # Members roster (run first - needed for member_id foreign keys) | |
| - | python -m ingest.pollers.members --once | |
| - | ||
| - | # House votes (probes current year sequentially, stops at 404) | |
| - | python -m ingest.run --source votes_house | |
| - | ||
| - | # Senate votes (existing poller, no modification) | |
| - | python -m ingest.run --source votes_senate | |
| - | ||
| - | # Senate positions backfill (run after members + senate votes) | |
| - | python -m ingest.pollers.votes_enrich --once | |
| - | ``` | |
| - | ||
| - | ## Worked example - both pivot directions | |
| - | ||
| - | ### Pivot 1: "Who voted how on House Roll #62?" | |
| - | ||
| - | ``` | |
| - | Roll #62 (2026-02-11) - On Agreeing to the Resolution, as Amended | |
| - | H RES 1057 | Passed | Y:216 N:215 P:0 NV:1 | |
| - | 432 positions recorded | |
| - | Source: https://clerk.house.gov/evs/2026/roll062.xml | |
| - | ||
| - | Sample: | |
| - | Shomari Figures (D-AL): Nay | |
| - | Terri A. Sewell (D-AL): Nay | |
| - | Nicholas J. Begich III (R-AK): Yea | |
| - | Robert B. Aderholt (R-AL): Yea | |
| - | ``` | |
| - | ||
| - | Query: | |
| - | ```sql | |
| - | SELECT vp.position, m.full_name, m.party, m.state | |
| - | FROM vote_positions vp | |
| - | LEFT JOIN members m ON m.bioguide_id = vp.member_bioguide | |
| - | WHERE vp.vote_id = (SELECT id FROM votes WHERE roll_number=62 AND chamber='house') | |
| - | ORDER BY m.state, m.full_name; | |
| - | ``` | |
| - | ||
| - | ### Pivot 2: "How did Rep. Aderholt vote recently?" | |
| - | ||
| - | ``` | |
| - | Robert B. Aderholt (R-AL) | bioguide: A000055 | |
| - | Source: https://bioguide.congress.gov/search/bio/A000055 | |
| - | 50 recorded votes in DB | |
| - | ||
| - | Roll #062 | 2026-02-11 | On Agreeing to the Resolution, as Amended -> Yea | |
| - | Roll #061 | 2026-02-11 | On Ordering the Previous Question -> Yea | |
| - | Roll #059 | 2026-02-10 | On Ordering the Previous Question -> Yea | |
| - | Roll #054 | 2026-02-04 | On Motion to Recommit -> Nay | |
| - | ``` | |
| - | ||
| - | Query: | |
| - | ```sql | |
| - | SELECT v.roll_number, v.date, v.question, vp.position | |
| - | FROM vote_positions vp | |
| - | JOIN votes v ON v.id = vp.vote_id | |
| - | WHERE vp.member_bioguide = 'A000055' | |
| - | ORDER BY v.date DESC, v.roll_number DESC; | |
| - | ``` | |
| - | ||
| - | ## Key design decisions | |
| - | ||
| - | **Members source**: `unitedstates/congress-legislators` `gh-pages` branch JSON (not `main` - YAML only). Fetch URL is `raw.githubusercontent.com`; stored `source_url` is always `bioguide.congress.gov/search/bio/{id}` (allowlisted .gov). | |
| - | ||
| - | **House XML date format**: The Clerk XML uses `"D-Mon-YYYY"` (e.g. `"8-Jan-2026"`), not `MM/DD/YYYY` as some docs suggest. The parser tries `%d-%b-%Y` first, then fallbacks. | |
| - | ||
| - | **House Clerk URL format**: `https://clerk.house.gov/evs/{year}/roll{NNN}.xml` (lowercase `roll`, no underscore, zero-padded 3 digits). The prior stub incorrectly used `ROLL_{NNN}.xml`. | |
| - | ||
| - | **Bioguide join**: House XML `<legislator name-id="...">` carries the bioguide directly. Senate XML uses `lis_member_id`; the enrich module maps it via `members.lis_member_id`. | |
| - | ||
| - | **Unmatched members**: Delegates (DC, territories, etc.) appear in House roll-call XMLs but not in `legislators-current.json`. Their positions are stored with `member_bioguide` populated and `member_id = NULL` - data preserved, nothing fabricated. | |
| - | ||
| - | **Honesty on voice/UC votes**: `parse_house_vote_xml()` returns `None` for non-recorded vote types (`VOICE VOTE`, etc.). Those votes are not in this table. The closed-door agent (CREC) owns them. | |
| - | ||
| - | **Senate backfill approach**: `votes_enrich.py` re-fetches the `source_url` already stored on existing `votes` rows - no external mapping needed. Idempotent: only processes rows with no existing positions. | |
| - | ||
| - | ## Tests (39 total, 230 total suite) | |
| - | ||
| - | ``` | |
| - | tests/test_votes_house_parser.py - 39 tests | |
| - | ||
| - | TestNormalizePosition (6) - position enum mapping incl. Aye/No variants | |
| - | TestParseLegisNum (9) - bill type normalization, all 8 prefixes | |
| - | TestParseHouseVoteXml (9) - fields, counts, bioguides, voice-vote honesty | |
| - | TestParseSenateVotePositions (9) - LIS IDs, all 4 positions, malformed XML | |
| - | TestSchemaAndInsertion (6) - table creation, column presence, enum enforcement, | |
| - | both pivot directions via JOIN | |
| - | ``` | |
| - | ||
| - | All 230 tests pass (suite includes pre-existing ranking/source_url/state/diff/crec tests). |
| @@ -1,220 +0,0 @@ | ||
| - | # 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 | |
| - | cd services/ingest | |
| - | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| - | python -m ingest.pollers.voteview --once --start 118 --end 119 | |
| - | ``` | |
| - | ||
| - | ### Full 2019-present (congresses 116-119) | |
| - | ```bash | |
| - | python -m ingest.pollers.voteview --once --start 116 --end 119 | |
| - | ``` | |
| - | ||
| - | ### Full historical backfill (1989-present, ~30 congresses, recommended first pass) | |
| - | ```bash | |
| - | python -m ingest.pollers.voteview --once --start 101 --end 119 | |
| - | ``` | |
| - | ||
| - | ### Full Voteview archive (1789-present, all ~120 congresses) | |
| - | ```bash | |
| - | 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 | |
| @@ -1,182 +0,0 @@ | ||
| - | # 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 | |
| - | ||
| - | ```bash | |
| - | # 1. Copy unit files | |
| - | cp services/ingest/deploy/docket-watcher.service \ | |
| - | /etc/systemd/system/ | |
| - | cp 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 services/ingest | |
| - | export DOCKET_DB_PATH=/opt/docket/data/docket.db | |
| - | python -m ingest.watcher --once --sources federal_register | |
| - | ``` | |
| - | ||
| - | ## Running the unit tests | |
| - | ||
| - | ```bash | |
| - | cd services/ingest | |
| - | python -m 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`. |
| @@ -1,7 +1,7 @@ | ||
| """R2 archive uploader (Cloudflare). Outbound-only from the lab; no S3 creds. | ||
| Uses the CF REST object API with the global key (from env CF_ACCOUNT_ID/CF_EMAIL/ | ||
| - | CF_GLOBAL_KEY - source /shared/projects/.env.secrets before running). Bucket | |
| + | CF_GLOBAL_KEY - set these in your environment before running). Bucket | |
| `docket-archive` is public at https://archive.usdocket.org. Uploads are the | ||
| preservation layer: snapshot official .gov records so they survive when the | ||
| government reorganizes/removes them. |