Roll-call votes:
- Migration 0017: bill_votes + member_vote_positions tables
- Fetch vote XML directly from House Clerk / Senate LIS URLs
embedded in bill actions recordedVotes objects
- GET /api/bills/{id}/votes triggers background fetch on first view
- VotePanel on bill detail: yea/nay bar, result badge, followed
member positions with Sen./Rep. title, party badge, and state
Alert filter fix:
- _should_dispatch returns True when alert_filters is None so users
who haven't saved filters still receive all notifications
Authored-By: Jack Levy
278 lines
9.4 KiB
Python
278 lines
9.4 KiB
Python
from typing import Literal, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import desc, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.database import get_db
|
|
from app.models import Bill, BillAction, BillBrief, BillDocument, NewsArticle, TrendScore
|
|
from app.schemas.schemas import (
|
|
BillDetailSchema,
|
|
BillSchema,
|
|
BillActionSchema,
|
|
BillVoteSchema,
|
|
NewsArticleSchema,
|
|
PaginatedResponse,
|
|
TrendScoreSchema,
|
|
)
|
|
|
|
_BILL_TYPE_LABELS: dict[str, str] = {
|
|
"hr": "H.R.",
|
|
"s": "S.",
|
|
"hjres": "H.J.Res.",
|
|
"sjres": "S.J.Res.",
|
|
"hconres": "H.Con.Res.",
|
|
"sconres": "S.Con.Res.",
|
|
"hres": "H.Res.",
|
|
"sres": "S.Res.",
|
|
}
|
|
|
|
|
|
class DraftLetterRequest(BaseModel):
|
|
stance: Literal["yes", "no"]
|
|
recipient: Literal["house", "senate"]
|
|
tone: Literal["short", "polite", "firm"]
|
|
selected_points: list[str]
|
|
include_citations: bool = True
|
|
zip_code: str | None = None # not stored, not logged
|
|
rep_name: str | None = None # not stored, not logged
|
|
|
|
|
|
class DraftLetterResponse(BaseModel):
|
|
draft: str
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("", response_model=PaginatedResponse[BillSchema])
|
|
async def list_bills(
|
|
chamber: Optional[str] = Query(None),
|
|
topic: Optional[str] = Query(None),
|
|
sponsor_id: Optional[str] = Query(None),
|
|
q: Optional[str] = Query(None),
|
|
has_document: Optional[bool] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(20, ge=1, le=100),
|
|
sort: str = Query("latest_action_date"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
query = (
|
|
select(Bill)
|
|
.options(
|
|
selectinload(Bill.sponsor),
|
|
selectinload(Bill.briefs),
|
|
selectinload(Bill.trend_scores),
|
|
)
|
|
)
|
|
|
|
if chamber:
|
|
query = query.where(Bill.chamber == chamber)
|
|
if sponsor_id:
|
|
query = query.where(Bill.sponsor_id == sponsor_id)
|
|
if topic:
|
|
query = query.join(BillBrief, Bill.bill_id == BillBrief.bill_id).where(
|
|
BillBrief.topic_tags.contains([topic])
|
|
)
|
|
if q:
|
|
query = query.where(
|
|
or_(
|
|
Bill.bill_id.ilike(f"%{q}%"),
|
|
Bill.title.ilike(f"%{q}%"),
|
|
Bill.short_title.ilike(f"%{q}%"),
|
|
)
|
|
)
|
|
if has_document is True:
|
|
doc_subq = select(BillDocument.bill_id).where(BillDocument.bill_id == Bill.bill_id).exists()
|
|
query = query.where(doc_subq)
|
|
elif has_document is False:
|
|
doc_subq = select(BillDocument.bill_id).where(BillDocument.bill_id == Bill.bill_id).exists()
|
|
query = query.where(~doc_subq)
|
|
|
|
# Count total
|
|
count_query = select(func.count()).select_from(query.subquery())
|
|
total = await db.scalar(count_query) or 0
|
|
|
|
# Sort
|
|
sort_col = getattr(Bill, sort, Bill.latest_action_date)
|
|
query = query.order_by(desc(sort_col)).offset((page - 1) * per_page).limit(per_page)
|
|
|
|
result = await db.execute(query)
|
|
bills = result.scalars().unique().all()
|
|
|
|
# Single batch query: which of these bills have at least one document?
|
|
bill_ids = [b.bill_id for b in bills]
|
|
doc_result = await db.execute(
|
|
select(BillDocument.bill_id).where(BillDocument.bill_id.in_(bill_ids)).distinct()
|
|
)
|
|
bills_with_docs = {row[0] for row in doc_result}
|
|
|
|
# Attach latest brief, trend, and has_document to each bill
|
|
items = []
|
|
for bill in bills:
|
|
bill_dict = BillSchema.model_validate(bill)
|
|
if bill.briefs:
|
|
bill_dict.latest_brief = bill.briefs[0]
|
|
if bill.trend_scores:
|
|
bill_dict.latest_trend = bill.trend_scores[0]
|
|
bill_dict.has_document = bill.bill_id in bills_with_docs
|
|
items.append(bill_dict)
|
|
|
|
return PaginatedResponse(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
pages=max(1, (total + per_page - 1) // per_page),
|
|
)
|
|
|
|
|
|
@router.get("/{bill_id}", response_model=BillDetailSchema)
|
|
async def get_bill(bill_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(
|
|
select(Bill)
|
|
.options(
|
|
selectinload(Bill.sponsor),
|
|
selectinload(Bill.actions),
|
|
selectinload(Bill.briefs),
|
|
selectinload(Bill.news_articles),
|
|
selectinload(Bill.trend_scores),
|
|
)
|
|
.where(Bill.bill_id == bill_id)
|
|
)
|
|
bill = result.scalar_one_or_none()
|
|
if not bill:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="Bill not found")
|
|
|
|
detail = BillDetailSchema.model_validate(bill)
|
|
if bill.briefs:
|
|
detail.latest_brief = bill.briefs[0]
|
|
if bill.trend_scores:
|
|
detail.latest_trend = bill.trend_scores[0]
|
|
doc_exists = await db.scalar(
|
|
select(func.count()).select_from(BillDocument).where(BillDocument.bill_id == bill_id)
|
|
)
|
|
detail.has_document = bool(doc_exists)
|
|
|
|
# Trigger a background news refresh if no articles are stored but trend
|
|
# data shows there are gnews results out there waiting to be fetched.
|
|
latest_trend = bill.trend_scores[0] if bill.trend_scores else None
|
|
has_gnews = latest_trend and (latest_trend.gnews_count or 0) > 0
|
|
if not bill.news_articles and has_gnews:
|
|
try:
|
|
from app.workers.news_fetcher import fetch_news_for_bill
|
|
fetch_news_for_bill.delay(bill_id)
|
|
except Exception:
|
|
pass
|
|
|
|
return detail
|
|
|
|
|
|
@router.get("/{bill_id}/actions", response_model=list[BillActionSchema])
|
|
async def get_bill_actions(bill_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(
|
|
select(BillAction)
|
|
.where(BillAction.bill_id == bill_id)
|
|
.order_by(desc(BillAction.action_date))
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{bill_id}/news", response_model=list[NewsArticleSchema])
|
|
async def get_bill_news(bill_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(
|
|
select(NewsArticle)
|
|
.where(NewsArticle.bill_id == bill_id)
|
|
.order_by(desc(NewsArticle.published_at))
|
|
.limit(20)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{bill_id}/trend", response_model=list[TrendScoreSchema])
|
|
async def get_bill_trend(bill_id: str, days: int = Query(30, ge=7, le=365), db: AsyncSession = Depends(get_db)):
|
|
from datetime import date, timedelta
|
|
cutoff = date.today() - timedelta(days=days)
|
|
result = await db.execute(
|
|
select(TrendScore)
|
|
.where(TrendScore.bill_id == bill_id, TrendScore.score_date >= cutoff)
|
|
.order_by(TrendScore.score_date)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{bill_id}/votes", response_model=list[BillVoteSchema])
|
|
async def get_bill_votes_endpoint(bill_id: str, db: AsyncSession = Depends(get_db)):
|
|
from app.models.vote import BillVote
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
result = await db.execute(
|
|
select(BillVote)
|
|
.where(BillVote.bill_id == bill_id)
|
|
.options(selectinload(BillVote.positions))
|
|
.order_by(desc(BillVote.vote_date))
|
|
)
|
|
votes = result.scalars().unique().all()
|
|
|
|
# Trigger background fetch if no votes are stored yet
|
|
if not votes:
|
|
bill = await db.get(Bill, bill_id)
|
|
if bill:
|
|
try:
|
|
from app.workers.vote_fetcher import fetch_bill_votes
|
|
fetch_bill_votes.delay(bill_id)
|
|
except Exception:
|
|
pass
|
|
|
|
return votes
|
|
|
|
|
|
@router.post("/{bill_id}/draft-letter", response_model=DraftLetterResponse)
|
|
async def generate_letter(bill_id: str, body: DraftLetterRequest, db: AsyncSession = Depends(get_db)):
|
|
from app.models.setting import AppSetting
|
|
from app.services.llm_service import generate_draft_letter
|
|
|
|
bill = await db.get(Bill, bill_id)
|
|
if not bill:
|
|
raise HTTPException(status_code=404, detail="Bill not found")
|
|
|
|
if not body.selected_points:
|
|
raise HTTPException(status_code=422, detail="At least one point must be selected")
|
|
|
|
prov_row = await db.get(AppSetting, "llm_provider")
|
|
model_row = await db.get(AppSetting, "llm_model")
|
|
llm_provider_override = prov_row.value if prov_row else None
|
|
llm_model_override = model_row.value if model_row else None
|
|
|
|
type_label = _BILL_TYPE_LABELS.get((bill.bill_type or "").lower(), (bill.bill_type or "").upper())
|
|
bill_label = f"{type_label} {bill.bill_number}"
|
|
|
|
try:
|
|
draft = generate_draft_letter(
|
|
bill_label=bill_label,
|
|
bill_title=bill.short_title or bill.title or bill_label,
|
|
stance=body.stance,
|
|
recipient=body.recipient,
|
|
tone=body.tone,
|
|
selected_points=body.selected_points,
|
|
include_citations=body.include_citations,
|
|
zip_code=body.zip_code,
|
|
rep_name=body.rep_name,
|
|
llm_provider=llm_provider_override,
|
|
llm_model=llm_model_override,
|
|
)
|
|
except Exception as exc:
|
|
msg = str(exc)
|
|
if "insufficient_quota" in msg or "quota" in msg.lower():
|
|
detail = "LLM quota exceeded. Check your API key billing."
|
|
elif "rate_limit" in msg.lower() or "429" in msg:
|
|
detail = "LLM rate limit hit. Wait a moment and try again."
|
|
elif "auth" in msg.lower() or "401" in msg or "403" in msg:
|
|
detail = "LLM authentication failed. Check your API key."
|
|
else:
|
|
detail = f"LLM error: {msg[:200]}"
|
|
raise HTTPException(status_code=502, detail=detail)
|
|
return {"draft": draft}
|