""" Congress.gov API client. Rate limit: 5,000 requests/hour (enforced server-side by Congress.gov). We track usage in Redis to stay well under the limit. """ import time from datetime import datetime from typing import Optional import requests from tenacity import retry, stop_after_attempt, wait_exponential from app.config import settings BASE_URL = "https://api.congress.gov/v3" _BILL_TYPE_SLUG = { "hr": "house-bill", "s": "senate-bill", "hjres": "house-joint-resolution", "sjres": "senate-joint-resolution", "hres": "house-resolution", "sres": "senate-resolution", "hconres": "house-concurrent-resolution", "sconres": "senate-concurrent-resolution", } def _congress_ordinal(n: int) -> str: if 11 <= n % 100 <= 13: return f"{n}th" suffixes = {1: "st", 2: "nd", 3: "rd"} return f"{n}{suffixes.get(n % 10, 'th')}" def build_bill_public_url(congress: int, bill_type: str, bill_number: int) -> str: """Return the public congress.gov page URL for a bill (not the API endpoint).""" slug = _BILL_TYPE_SLUG.get(bill_type.lower(), bill_type.lower()) return f"https://www.congress.gov/bill/{_congress_ordinal(congress)}-congress/{slug}/{bill_number}" def _get_current_congress() -> int: """Calculate the current Congress number. 119th started Jan 3, 2025.""" year = datetime.utcnow().year # Congress changes on odd years (Jan 3) if datetime.utcnow().month == 1 and datetime.utcnow().day < 3: year -= 1 return 118 + ((year - 2023) // 2 + (1 if year % 2 == 1 else 0)) @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) def _get(endpoint: str, params: dict) -> dict: params["api_key"] = settings.DATA_GOV_API_KEY params["format"] = "json" response = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=30) response.raise_for_status() return response.json() def get_current_congress() -> int: return _get_current_congress() def build_bill_id(congress: int, bill_type: str, bill_number: int) -> str: return f"{congress}-{bill_type.lower()}-{bill_number}" def get_bills( congress: int, offset: int = 0, limit: int = 250, from_date_time: Optional[str] = None, ) -> dict: params: dict = {"offset": offset, "limit": limit, "sort": "updateDate+desc"} if from_date_time: params["fromDateTime"] = from_date_time return _get(f"/bill/{congress}", params) def get_bill_detail(congress: int, bill_type: str, bill_number: int) -> dict: return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}", {}) def get_bill_actions(congress: int, bill_type: str, bill_number: int, offset: int = 0) -> dict: return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}/actions", {"offset": offset, "limit": 250}) def get_bill_text_versions(congress: int, bill_type: str, bill_number: int) -> dict: return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}/text", {}) def get_members(offset: int = 0, limit: int = 250, current_member: bool = True) -> dict: params: dict = {"offset": offset, "limit": limit} if current_member: params["currentMember"] = "true" return _get("/member", params) def get_member_detail(bioguide_id: str) -> dict: return _get(f"/member/{bioguide_id}", {}) def get_committees(offset: int = 0, limit: int = 250) -> dict: return _get("/committee", {"offset": offset, "limit": limit}) def parse_bill_from_api(data: dict, congress: int) -> dict: """Normalize raw API bill data into our model fields.""" bill_type = data.get("type", "").lower() bill_number = data.get("number", 0) latest_action = data.get("latestAction") or {} return { "bill_id": build_bill_id(congress, bill_type, bill_number), "congress_number": congress, "bill_type": bill_type, "bill_number": bill_number, "title": data.get("title"), "short_title": data.get("shortTitle"), "introduced_date": data.get("introducedDate"), "latest_action_date": latest_action.get("actionDate"), "latest_action_text": latest_action.get("text"), "status": latest_action.get("text", "")[:100] if latest_action.get("text") else None, "chamber": "House" if bill_type.startswith("h") else "Senate", "congress_url": build_bill_public_url(congress, bill_type, bill_number), } def parse_member_from_api(data: dict) -> dict: """Normalize raw API member list data into our model fields.""" terms = data.get("terms", {}).get("item", []) current_term = terms[-1] if terms else {} return { "bioguide_id": data.get("bioguideId"), "name": data.get("name", ""), "first_name": data.get("firstName"), "last_name": data.get("lastName"), "party": data.get("partyName") or None, "state": data.get("state"), "chamber": current_term.get("chamber"), "district": str(current_term.get("district")) if current_term.get("district") else None, "photo_url": data.get("depiction", {}).get("imageUrl"), "official_url": data.get("officialWebsiteUrl"), } def parse_member_detail_from_api(data: dict) -> dict: """Normalize Congress.gov member detail response into enrichment fields.""" member = data.get("member", data) addr = member.get("addressInformation") or {} terms_raw = member.get("terms", []) if isinstance(terms_raw, dict): terms_raw = terms_raw.get("item", []) leadership_raw = member.get("leadership") or [] if isinstance(leadership_raw, dict): leadership_raw = leadership_raw.get("item", []) first = member.get("firstName", "") last = member.get("lastName", "") bioguide_id = member.get("bioguideId", "") slug = f"{first}-{last}".lower().replace(" ", "-").replace("'", "") return { "birth_year": str(member["birthYear"]) if member.get("birthYear") else None, "address": addr.get("officeAddress"), "phone": addr.get("phoneNumber"), "official_url": member.get("officialWebsiteUrl"), "photo_url": (member.get("depiction") or {}).get("imageUrl"), "congress_url": f"https://www.congress.gov/member/{slug}/{bioguide_id}" if bioguide_id else None, "terms_json": [ { "congress": t.get("congress"), "chamber": t.get("chamber"), "partyName": t.get("partyName"), "stateCode": t.get("stateCode"), "stateName": t.get("stateName"), "startYear": t.get("startYear"), "endYear": t.get("endYear"), "district": t.get("district"), } for t in terms_raw ], "leadership_json": [ { "type": l.get("type"), "congress": l.get("congress"), "current": l.get("current"), } for l in leadership_raw ], "sponsored_count": (member.get("sponsoredLegislation") or {}).get("count"), "cosponsored_count": (member.get("cosponsoredLegislation") or {}).get("count"), }