diff --git a/.env.example b/.env.example
index 786ac49..89b1187 100644
--- a/.env.example
+++ b/.env.example
@@ -47,6 +47,12 @@ GEMINI_MODEL=gemini-1.5-pro
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL=llama3.1
+# ─── Google Civic Information API ─────────────────────────────────────────────
+# Used for zip code → representative lookup in the Draft Letter panel.
+# Free tier: 25,000 req/day. Enable the API at:
+# https://console.cloud.google.com/apis/library/civicinfo.googleapis.com
+CIVIC_API_KEY=
+
# ─── News ─────────────────────────────────────────────────────────────────────
# Free key (100 req/day): https://newsapi.org/register
NEWSAPI_KEY=
diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index 7e7743f..3a5dadd 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -272,9 +272,10 @@ async def api_health(current_user: User = Depends(get_current_admin)):
asyncio.to_thread(_test_govinfo),
asyncio.to_thread(_test_newsapi),
asyncio.to_thread(_test_gnews),
+ asyncio.to_thread(_test_rep_lookup),
return_exceptions=True,
)
- keys = ["congress_gov", "govinfo", "newsapi", "google_news"]
+ keys = ["congress_gov", "govinfo", "newsapi", "google_news", "rep_lookup"]
return {
k: r if isinstance(r, dict) else {"status": "error", "detail": str(r)}
for k, r in zip(keys, results)
@@ -370,6 +371,54 @@ def _test_gnews() -> dict:
return {"status": "error", "detail": str(exc)}
+def _test_rep_lookup() -> dict:
+ import re as _re
+ import requests as req
+ def _call():
+ # Step 1: Nominatim ZIP → lat/lng
+ r1 = req.get(
+ "https://nominatim.openstreetmap.org/search",
+ params={"postalcode": "20001", "country": "US", "format": "json", "limit": "1"},
+ headers={"User-Agent": "PocketVeto/1.0"},
+ timeout=10,
+ )
+ r1.raise_for_status()
+ places = r1.json()
+ if not places:
+ return {"status": "error", "detail": "Nominatim: no result for test ZIP 20001"}
+ lat, lng = places[0]["lat"], places[0]["lon"]
+ half = 0.5
+ # Step 2: TIGERweb identify → congressional district
+ r2 = req.get(
+ "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Legislative/MapServer/identify",
+ params={
+ "f": "json",
+ "geometry": f"{lng},{lat}",
+ "geometryType": "esriGeometryPoint",
+ "sr": "4326",
+ "layers": "all",
+ "tolerance": "2",
+ "mapExtent": f"{float(lng)-half},{float(lat)-half},{float(lng)+half},{float(lat)+half}",
+ "imageDisplay": "100,100,96",
+ },
+ timeout=15,
+ )
+ r2.raise_for_status()
+ results = r2.json().get("results", [])
+ for item in results:
+ attrs = item.get("attributes", {})
+ cd_field = next((k for k in attrs if _re.match(r"CD\d+FP$", k)), None)
+ if cd_field:
+ district = str(int(str(attrs[cd_field]))) if str(attrs[cd_field]).strip("0") else "At-large"
+ return {"status": "ok", "detail": f"Nominatim + TIGERweb reachable — district {district} found for ZIP 20001"}
+ layers = [r.get("layerName") for r in results]
+ return {"status": "error", "detail": f"Reachable but no CD field found. Layers: {layers}"}
+ try:
+ return _timed(_call)
+ except Exception as exc:
+ return {"status": "error", "detail": str(exc)}
+
+
@router.get("/task-status/{task_id}")
async def get_task_status(task_id: str, current_user: User = Depends(get_current_admin)):
from app.workers.celery_app import celery_app
diff --git a/backend/app/api/bills.py b/backend/app/api/bills.py
index cf4109e..756a97f 100644
--- a/backend/app/api/bills.py
+++ b/backend/app/api/bills.py
@@ -35,7 +35,8 @@ class DraftLetterRequest(BaseModel):
tone: Literal["short", "polite", "firm"]
selected_points: list[str]
include_citations: bool = True
- zip_code: str | None = None # not stored, not logged
+ zip_code: str | None = None # not stored, not logged
+ rep_name: str | None = None # not stored, not logged
class DraftLetterResponse(BaseModel):
@@ -50,6 +51,7 @@ async def list_bills(
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"),
@@ -80,6 +82,12 @@ async def list_bills(
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())
@@ -224,6 +232,7 @@ async def generate_letter(bill_id: str, body: DraftLetterRequest, db: AsyncSessi
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,
)
diff --git a/backend/app/api/members.py b/backend/app/api/members.py
index ca75aa8..1b965a8 100644
--- a/backend/app/api/members.py
+++ b/backend/app/api/members.py
@@ -1,7 +1,23 @@
import logging
+import re
from datetime import datetime, timezone
from typing import Optional
+_FIPS_TO_STATE = {
+ "01": "AL", "02": "AK", "04": "AZ", "05": "AR", "06": "CA",
+ "08": "CO", "09": "CT", "10": "DE", "11": "DC", "12": "FL",
+ "13": "GA", "15": "HI", "16": "ID", "17": "IL", "18": "IN",
+ "19": "IA", "20": "KS", "21": "KY", "22": "LA", "23": "ME",
+ "24": "MD", "25": "MA", "26": "MI", "27": "MN", "28": "MS",
+ "29": "MO", "30": "MT", "31": "NE", "32": "NV", "33": "NH",
+ "34": "NJ", "35": "NM", "36": "NY", "37": "NC", "38": "ND",
+ "39": "OH", "40": "OK", "41": "OR", "42": "PA", "44": "RI",
+ "45": "SC", "46": "SD", "47": "TN", "48": "TX", "49": "UT",
+ "50": "VT", "51": "VA", "53": "WA", "54": "WV", "55": "WI",
+ "56": "WY", "60": "AS", "66": "GU", "69": "MP", "72": "PR", "78": "VI",
+}
+
+import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,6 +36,139 @@ logger = logging.getLogger(__name__)
router = APIRouter()
+@router.get("/by-zip/{zip_code}", response_model=list[MemberSchema])
+async def get_members_by_zip(zip_code: str, db: AsyncSession = Depends(get_db)):
+ """Return the House rep and senators for a ZIP code.
+ Step 1: Nominatim (OpenStreetMap) — ZIP → lat/lng.
+ Step 2: TIGERweb Legislative identify — lat/lng → congressional district.
+ """
+ if not re.fullmatch(r"\d{5}", zip_code):
+ raise HTTPException(status_code=400, detail="ZIP code must be 5 digits")
+
+ state_code: str | None = None
+ district_num: str | None = None
+
+ try:
+ async with httpx.AsyncClient(timeout=20.0) as client:
+ # Step 1: ZIP → lat/lng
+ r1 = await client.get(
+ "https://nominatim.openstreetmap.org/search",
+ params={"postalcode": zip_code, "country": "US", "format": "json", "limit": "1"},
+ headers={"User-Agent": "PocketVeto/1.0"},
+ )
+ places = r1.json() if r1.status_code == 200 else []
+ if not places:
+ logger.warning("Nominatim: no result for ZIP %s", zip_code)
+ return []
+
+ lat = places[0]["lat"]
+ lng = places[0]["lon"]
+
+ # Step 2: lat/lng → congressional district via TIGERweb identify (all layers)
+ half = 0.5
+ r2 = await client.get(
+ "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Legislative/MapServer/identify",
+ params={
+ "f": "json",
+ "geometry": f"{lng},{lat}",
+ "geometryType": "esriGeometryPoint",
+ "sr": "4326",
+ "layers": "all",
+ "tolerance": "2",
+ "mapExtent": f"{float(lng)-half},{float(lat)-half},{float(lng)+half},{float(lat)+half}",
+ "imageDisplay": "100,100,96",
+ },
+ )
+ if r2.status_code != 200:
+ logger.warning("TIGERweb returned %s for ZIP %s", r2.status_code, zip_code)
+ return []
+
+ identify_results = r2.json().get("results", [])
+ logger.info(
+ "TIGERweb ZIP %s layers: %s",
+ zip_code, [r.get("layerName") for r in identify_results],
+ )
+
+ for item in identify_results:
+ if "Congressional" not in (item.get("layerName") or ""):
+ continue
+ attrs = item.get("attributes", {})
+ # GEOID = 2-char state FIPS + 2-char district (e.g. "1218" = FL-18)
+ geoid = str(attrs.get("GEOID") or "").strip()
+ if len(geoid) == 4:
+ state_fips = geoid[:2]
+ district_fips = geoid[2:]
+ state_code = _FIPS_TO_STATE.get(state_fips)
+ district_num = str(int(district_fips)) if district_fips.strip("0") else None
+ if state_code:
+ break
+
+ # Fallback: explicit field names
+ cd_field = next((k for k in attrs if re.match(r"CD\d+FP$", k)), None)
+ state_field = next((k for k in attrs if "STATEFP" in k.upper()), None)
+ if cd_field and state_field:
+ state_fips = str(attrs[state_field]).zfill(2)
+ district_fips = str(attrs[cd_field])
+ state_code = _FIPS_TO_STATE.get(state_fips)
+ district_num = str(int(district_fips)) if district_fips.strip("0") else None
+ if state_code:
+ break
+
+ if not state_code:
+ logger.warning(
+ "ZIP %s: no CD found. Layers: %s",
+ zip_code, [r.get("layerName") for r in identify_results],
+ )
+
+ except Exception as exc:
+ logger.warning("ZIP lookup error for %s: %s", zip_code, exc)
+ return []
+
+ if not state_code:
+ return []
+
+ members: list[MemberSchema] = []
+ seen: set[str] = set()
+
+ if district_num:
+ result = await db.execute(
+ select(Member).where(
+ Member.state == state_code,
+ Member.district == district_num,
+ Member.chamber == "House of Representatives",
+ )
+ )
+ member = result.scalar_one_or_none()
+ if member:
+ seen.add(member.bioguide_id)
+ members.append(MemberSchema.model_validate(member))
+ else:
+ # At-large states (AK, DE, MT, ND, SD, VT, WY)
+ result = await db.execute(
+ select(Member).where(
+ Member.state == state_code,
+ Member.chamber == "House of Representatives",
+ ).limit(1)
+ )
+ member = result.scalar_one_or_none()
+ if member:
+ seen.add(member.bioguide_id)
+ members.append(MemberSchema.model_validate(member))
+
+ result = await db.execute(
+ select(Member).where(
+ Member.state == state_code,
+ Member.chamber == "Senate",
+ )
+ )
+ for member in result.scalars().all():
+ if member.bioguide_id not in seen:
+ seen.add(member.bioguide_id)
+ members.append(MemberSchema.model_validate(member))
+
+ return members
+
+
@router.get("", response_model=PaginatedResponse[MemberSchema])
async def list_members(
chamber: Optional[str] = Query(None),
diff --git a/backend/app/config.py b/backend/app/config.py
index 4894732..9996e14 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -39,6 +39,10 @@ class Settings(BaseSettings):
OLLAMA_BASE_URL: str = "http://host.docker.internal:11434"
OLLAMA_MODEL: str = "llama3.1"
+ # Google Civic Information API (zip → representative lookup)
+ # Free key: https://console.cloud.google.com/apis/library/civicinfo.googleapis.com
+ CIVIC_API_KEY: str = ""
+
# News
NEWSAPI_KEY: str = ""
diff --git a/backend/app/services/congress_api.py b/backend/app/services/congress_api.py
index 57b02f3..774444f 100644
--- a/backend/app/services/congress_api.py
+++ b/backend/app/services/congress_api.py
@@ -126,6 +126,34 @@ def parse_bill_from_api(data: dict, congress: int) -> dict:
}
+_STATE_NAME_TO_CODE: dict[str, str] = {
+ "Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR",
+ "California": "CA", "Colorado": "CO", "Connecticut": "CT", "Delaware": "DE",
+ "Florida": "FL", "Georgia": "GA", "Hawaii": "HI", "Idaho": "ID",
+ "Illinois": "IL", "Indiana": "IN", "Iowa": "IA", "Kansas": "KS",
+ "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME", "Maryland": "MD",
+ "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
+ "Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV",
+ "New Hampshire": "NH", "New Jersey": "NJ", "New Mexico": "NM", "New York": "NY",
+ "North Carolina": "NC", "North Dakota": "ND", "Ohio": "OH", "Oklahoma": "OK",
+ "Oregon": "OR", "Pennsylvania": "PA", "Rhode Island": "RI", "South Carolina": "SC",
+ "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX", "Utah": "UT",
+ "Vermont": "VT", "Virginia": "VA", "Washington": "WA", "West Virginia": "WV",
+ "Wisconsin": "WI", "Wyoming": "WY",
+ "American Samoa": "AS", "Guam": "GU", "Northern Mariana Islands": "MP",
+ "Puerto Rico": "PR", "Virgin Islands": "VI", "District of Columbia": "DC",
+}
+
+
+def _normalize_state(state: str | None) -> str | None:
+ if not state:
+ return None
+ s = state.strip()
+ if len(s) == 2:
+ return s.upper()
+ return _STATE_NAME_TO_CODE.get(s, s)
+
+
def parse_member_from_api(data: dict) -> dict:
"""Normalize raw API member list data into our model fields."""
terms = data.get("terms", {}).get("item", [])
@@ -136,9 +164,9 @@ def parse_member_from_api(data: dict) -> dict:
"first_name": data.get("firstName"),
"last_name": data.get("lastName"),
"party": data.get("partyName") or None,
- "state": data.get("state"),
+ "state": _normalize_state(data.get("state")),
"chamber": current_term.get("chamber"),
- "district": str(current_term.get("district")) if current_term.get("district") else None,
+ "district": str(data.get("district")) if data.get("district") else None,
"photo_url": data.get("depiction", {}).get("imageUrl"),
"official_url": data.get("officialWebsiteUrl"),
}
diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py
index 005d22a..ad68990 100644
--- a/backend/app/services/llm_service.py
+++ b/backend/app/services/llm_service.py
@@ -418,6 +418,7 @@ def generate_draft_letter(
selected_points: list[str],
include_citations: bool,
zip_code: str | None,
+ rep_name: str | None = None,
llm_provider: str | None = None,
llm_model: str | None = None,
) -> str:
@@ -436,12 +437,19 @@ def generate_draft_letter(
location_line = f"The constituent is writing from ZIP code {zip_code}." if zip_code else ""
+ if rep_name:
+ title = "Senator" if recipient == "senate" else "Representative"
+ salutation_instruction = f'- Open with "Dear {title} {rep_name},"'
+ else:
+ salutation_instruction = f'- Open with "Dear {chamber_word} Member,"'
+
prompt = f"""Write a short constituent letter to a {chamber_word} member of Congress.
RULES:
- {tone_instruction}
- 6 to 12 sentences total.
-- First sentence must be a clear, direct ask: "Please vote {vote_word} on {bill_label}."
+- {salutation_instruction}
+- Second sentence must be a clear, direct ask: "Please vote {vote_word} on {bill_label}."
- The body must reference ONLY the points listed below — do not invent any other claims or facts.
- {citation_instruction}
- Close with a brief sign-off and the placeholder "[Your Name]".
diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py
index 8f53570..55d5084 100644
--- a/backend/app/workers/celery_app.py
+++ b/backend/app/workers/celery_app.py
@@ -70,6 +70,10 @@ celery_app.conf.update(
"task": "app.workers.member_interest.calculate_all_member_trend_scores",
"schedule": crontab(hour=3, minute=0),
},
+ "sync-members": {
+ "task": "app.workers.congress_poller.sync_members",
+ "schedule": crontab(hour=1, minute=0), # 1 AM UTC daily — refreshes chamber/district/contact info
+ },
"fetch-actions-active-bills": {
"task": "app.workers.congress_poller.fetch_actions_for_active_bills",
"schedule": crontab(hour=4, minute=0), # 4 AM UTC, after trend + member scoring
diff --git a/backend/app/workers/notification_utils.py b/backend/app/workers/notification_utils.py
index d49b53b..ea1fe6d 100644
--- a/backend/app/workers/notification_utils.py
+++ b/backend/app/workers/notification_utils.py
@@ -35,7 +35,9 @@ def is_referral_action(action_text: str) -> bool:
return any(kw in t for kw in _REFERRAL_KEYWORDS)
-def _build_payload(bill, action_summary: str, milestone_tier: str = "progress") -> dict:
+def _build_payload(
+ bill, action_summary: str, milestone_tier: str = "progress", source: str = "bill_follow"
+) -> dict:
from app.config import settings
base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/")
return {
@@ -44,6 +46,7 @@ def _build_payload(bill, action_summary: str, milestone_tier: str = "progress")
"brief_summary": (action_summary or "")[:300],
"bill_url": f"{base_url}/bills/{bill.bill_id}",
"milestone_tier": milestone_tier,
+ "source": source,
}
@@ -69,7 +72,7 @@ def emit_bill_notification(
if not followers:
return 0
- payload = _build_payload(bill, action_summary, milestone_tier)
+ payload = _build_payload(bill, action_summary, milestone_tier, source="bill_follow")
count = 0
for follow in followers:
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
@@ -100,7 +103,7 @@ def emit_member_follow_notifications(
if not followers:
return 0
- payload = _build_payload(bill, action_summary, milestone_tier)
+ payload = _build_payload(bill, action_summary, milestone_tier, source="member_follow")
count = 0
for follow in followers:
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
@@ -140,7 +143,7 @@ def emit_topic_follow_notifications(
if not followers:
return 0
- payload = _build_payload(bill, action_summary, milestone_tier)
+ payload = _build_payload(bill, action_summary, milestone_tier, source="topic_follow")
count = 0
for follow in followers:
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
diff --git a/frontend/app/bills/page.tsx b/frontend/app/bills/page.tsx
index fb43295..9d85ebc 100644
--- a/frontend/app/bills/page.tsx
+++ b/frontend/app/bills/page.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
-import { Search } from "lucide-react";
+import { FileText, Search } from "lucide-react";
import { useBills } from "@/lib/hooks/useBills";
import { BillCard } from "@/components/shared/BillCard";
@@ -19,12 +19,22 @@ export default function BillsPage() {
const [q, setQ] = useState(searchParams.get("q") ?? "");
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
+ const [hasText, setHasText] = useState(false);
const [page, setPage] = useState(1);
+ // Sync URL params → state so tag/topic links work when already on this page
+ useEffect(() => {
+ setQ(searchParams.get("q") ?? "");
+ setChamber(searchParams.get("chamber") ?? "");
+ setTopic(searchParams.get("topic") ?? "");
+ setPage(1);
+ }, [searchParams]);
+
const params = {
...(q && { q }),
...(chamber && { chamber }),
...(topic && { topic }),
+ ...(hasText && { has_document: true }),
page,
per_page: 20,
sort: "latest_action_date",
@@ -67,6 +77,18 @@ export default function BillsPage() {
{TOPICS.slice(1).map((t) => )}
+
{/* Results */}
diff --git a/frontend/app/collections/page.tsx b/frontend/app/collections/page.tsx
index 7663192..60e7034 100644
--- a/frontend/app/collections/page.tsx
+++ b/frontend/app/collections/page.tsx
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react";
import { collectionsAPI } from "@/lib/api";
+import { HelpTip } from "@/components/shared/HelpTip";
import type { Collection } from "@/lib/types";
export default function CollectionsPage() {
@@ -49,9 +50,15 @@ export default function CollectionsPage() {
return (
-
-
-
My Collections
+
+
+
+
My Collections
+
+
+ A collection is a named group of bills you curate — like a playlist for legislation.
+ Share any collection via a link; no account required to view.
+