fix(members): link sponsors to bills and fix member search

- Poller now fetches bill detail on insert to get sponsor (list endpoint
  has no sponsor data)
- Add backfill_sponsor_ids task + admin endpoint + UI button to fix the
  1,616 existing bills with NULL sponsor_id
- Member name search now matches both "Last, First" and "First Last"
  using split_part() on the stored name column; same fix applied to
  global search
- Load Bill.sponsor relationship eagerly in get_member_bills to prevent
  MissingGreenlet error during Pydantic serialization
- Remove .trim() on search onChange so spaces can be typed

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-02-28 23:29:58 -05:00
parent 795385dcba
commit 13e1577968
8 changed files with 73 additions and 10 deletions

View File

@@ -110,6 +110,13 @@ async def get_stats(
# ── Celery Tasks ────────────────────────────────────────────────────────────── # ── Celery Tasks ──────────────────────────────────────────────────────────────
@router.post("/backfill-sponsors")
async def backfill_sponsors(current_user: User = Depends(get_current_admin)):
from app.workers.congress_poller import backfill_sponsor_ids
task = backfill_sponsor_ids.delay()
return {"task_id": task.id, "status": "queued"}
@router.post("/trigger-poll") @router.post("/trigger-poll")
async def trigger_poll(current_user: User = Depends(get_current_admin)): async def trigger_poll(current_user: User = Depends(get_current_admin)):
from app.workers.congress_poller import poll_congress_bills from app.workers.congress_poller import poll_congress_bills

View File

@@ -1,7 +1,7 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, func, select from sqlalchemy import desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -30,7 +30,15 @@ async def list_members(
if state: if state:
query = query.where(Member.state == state) query = query.where(Member.state == state)
if q: if q:
query = query.where(Member.name.ilike(f"%{q}%")) # name is stored as "Last, First" — also match "First Last" order
first_last = func.concat(
func.split_part(Member.name, ", ", 2), " ",
func.split_part(Member.name, ", ", 1),
)
query = query.where(or_(
Member.name.ilike(f"%{q}%"),
first_last.ilike(f"%{q}%"),
))
total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0 total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0
query = query.order_by(Member.last_name, Member.first_name).offset((page - 1) * per_page).limit(per_page) query = query.order_by(Member.last_name, Member.first_name).offset((page - 1) * per_page).limit(per_page)
@@ -62,7 +70,7 @@ async def get_member_bills(
per_page: int = Query(20, ge=1, le=100), per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
query = select(Bill).options(selectinload(Bill.briefs)).where(Bill.sponsor_id == bioguide_id) query = select(Bill).options(selectinload(Bill.briefs), selectinload(Bill.sponsor)).where(Bill.sponsor_id == bioguide_id)
total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0 total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0
query = query.order_by(desc(Bill.introduced_date)).offset((page - 1) * per_page).limit(per_page) query = query.order_by(desc(Bill.introduced_date)).offset((page - 1) * per_page).limit(per_page)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, text from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
@@ -38,10 +38,17 @@ async def search(
seen.add(b.bill_id) seen.add(b.bill_id)
bills.append(b) bills.append(b)
# Fuzzy member search # Fuzzy member search — matches "Last, First" and "First Last"
first_last = func.concat(
func.split_part(Member.name, ", ", 2), " ",
func.split_part(Member.name, ", ", 1),
)
member_results = await db.execute( member_results = await db.execute(
select(Member) select(Member)
.where(Member.name.ilike(f"%{q}%")) .where(or_(
Member.name.ilike(f"%{q}%"),
first_last.ilike(f"%{q}%"),
))
.order_by(Member.last_name) .order_by(Member.last_name)
.limit(10) .limit(10)
) )

View File

@@ -71,8 +71,14 @@ def poll_congress_bills(self):
existing = db.get(Bill, bill_id) existing = db.get(Bill, bill_id)
if existing is None: if existing is None:
# Upsert sponsor member if referenced # Bill list endpoint has no sponsor data — fetch detail to get it
sponsor_id = _sync_sponsor(db, bill_data) try:
detail = congress_api.get_bill_detail(
current_congress, parsed["bill_type"], parsed["bill_number"]
)
sponsor_id = _sync_sponsor(db, detail.get("bill", {}))
except Exception:
sponsor_id = None
parsed["sponsor_id"] = sponsor_id parsed["sponsor_id"] = sponsor_id
parsed["last_checked_at"] = datetime.now(timezone.utc) parsed["last_checked_at"] = datetime.now(timezone.utc)
db.add(Bill(**parsed)) db.add(Bill(**parsed))
@@ -165,6 +171,33 @@ def _sync_sponsor(db, bill_data: dict) -> str | None:
return bioguide_id return bioguide_id
@celery_app.task(bind=True, name="app.workers.congress_poller.backfill_sponsor_ids")
def backfill_sponsor_ids(self):
"""Backfill sponsor_id for all bills where it is NULL by fetching bill detail from Congress.gov."""
import time
db = get_sync_db()
try:
bills = db.query(Bill).filter(Bill.sponsor_id.is_(None)).all()
total = len(bills)
updated = 0
logger.info(f"Backfilling sponsors for {total} bills")
for bill in bills:
try:
detail = congress_api.get_bill_detail(bill.congress_number, bill.bill_type, bill.bill_number)
sponsor_id = _sync_sponsor(db, detail.get("bill", {}))
if sponsor_id:
bill.sponsor_id = sponsor_id
db.commit()
updated += 1
except Exception as e:
logger.warning(f"Could not backfill sponsor for {bill.bill_id}: {e}")
time.sleep(0.1) # ~10 req/sec, well under Congress.gov 5000/hr limit
logger.info(f"Sponsor backfill complete: {updated}/{total} updated")
return {"total": total, "updated": updated}
finally:
db.close()
def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool: def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
"""Update bill fields if anything has changed. Returns True if updated.""" """Update bill fields if anything has changed. Returns True if updated."""
changed = False changed = False

View File

@@ -45,7 +45,7 @@ export default function BillsPage() {
type="text" type="text"
placeholder="Search bills..." placeholder="Search bills..."
value={q} value={q}
onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }} onChange={(e) => { setQ(e.target.value); setPage(1); }}
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/> />
</div> </div>

View File

@@ -32,7 +32,7 @@ export default function MembersPage() {
type="text" type="text"
placeholder="Search by name..." placeholder="Search by name..."
value={q} value={q}
onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }} onChange={(e) => { setQ(e.target.value); setPage(1); }}
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none" className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
/> />
</div> </div>

View File

@@ -346,6 +346,12 @@ export default function SettingsPage() {
> >
<RefreshCw className="w-3.5 h-3.5" /> Calculate Trends <RefreshCw className="w-3.5 h-3.5" /> Calculate Trends
</button> </button>
<button
onClick={() => trigger("sponsors", adminAPI.backfillSponsors)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Backfill Sponsors
</button>
</div> </div>
{Object.entries(taskIds).map(([name, id]) => ( {Object.entries(taskIds).map(([name, id]) => (
<p key={name} className="text-xs text-muted-foreground">{name}: task {id} queued</p> <p key={name} className="text-xs text-muted-foreground">{name}: task {id} queued</p>

View File

@@ -151,6 +151,8 @@ export const adminAPI = {
apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data), apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data),
triggerTrendScores: () => triggerTrendScores: () =>
apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data), apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data),
backfillSponsors: () =>
apiClient.post("/api/admin/backfill-sponsors").then((r) => r.data),
getTaskStatus: (taskId: string) => getTaskStatus: (taskId: string) =>
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data), apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
}; };