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, 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 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), 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}%"), ) ) # 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.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, 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}