feat(email_gen): draft constituent letter generator + bill text indicators

- Add DraftLetterPanel: collapsible UI below BriefPanel for bills with a
  brief; lets users select up to 3 cited points, pick stance/tone, and
  generate a plain-text letter via the configured LLM provider
- Stance pre-fills from follow mode (pocket_boost → YES, pocket_veto → NO)
  and clears when the user unfollows; recipient derived from bill chamber
- Add POST /api/bills/{bill_id}/draft-letter endpoint with proper LLM
  provider/model resolution from AppSetting (respects Settings page choice)
- Add generate_text() to LLMProvider ABC and all four providers
- Expose has_document on BillSchema (list endpoint) via a single batch
  query; BillCard shows Brief / Pending / No text indicator per bill

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 16:37:52 -05:00
parent 7106c9a63c
commit dc5e756749
8 changed files with 556 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
from typing import Optional
from typing import Literal, Optional
from fastapi import APIRouter, Depends, Query
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
@@ -16,6 +17,30 @@ from app.schemas.schemas import (
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()
@@ -67,7 +92,14 @@ async def list_bills(
result = await db.execute(query)
bills = result.scalars().unique().all()
# Attach latest brief and trend to each bill
# 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)
@@ -75,6 +107,7 @@ async def list_bills(
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(
@@ -159,3 +192,50 @@ async def get_bill_trend(bill_id: str, days: int = Query(30, ge=7, le=365), db:
.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}

View File

@@ -200,6 +200,7 @@ class BillSchema(BaseModel):
latest_brief: Optional[BriefSchema] = None
latest_trend: Optional[TrendScoreSchema] = None
updated_at: Optional[datetime] = None
has_document: bool = False
model_config = {"from_attributes": True}

View File

@@ -183,6 +183,10 @@ class LLMProvider(ABC):
def generate_amendment_brief(self, new_text: str, previous_text: str, bill_metadata: dict) -> ReverseBrief:
pass
@abstractmethod
def generate_text(self, prompt: str) -> str:
pass
class OpenAIProvider(LLMProvider):
def __init__(self, model: str | None = None):
@@ -218,6 +222,14 @@ class OpenAIProvider(LLMProvider):
raw = response.choices[0].message.content
return parse_brief_json(raw, "openai", self.model)
def generate_text(self, prompt: str) -> str:
response = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
)
return response.choices[0].message.content or ""
class AnthropicProvider(LLMProvider):
def __init__(self, model: str | None = None):
@@ -247,6 +259,14 @@ class AnthropicProvider(LLMProvider):
raw = response.content[0].text
return parse_brief_json(raw, "anthropic", self.model)
def generate_text(self, prompt: str) -> str:
response = self.client.messages.create(
model=self.model,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
class GeminiProvider(LLMProvider):
def __init__(self, model: str | None = None):
@@ -272,6 +292,14 @@ class GeminiProvider(LLMProvider):
response = self._make_model(AMENDMENT_SYSTEM_PROMPT).generate_content(prompt)
return parse_brief_json(response.text, "gemini", self.model_name)
def generate_text(self, prompt: str) -> str:
model = self._genai.GenerativeModel(
model_name=self.model_name,
generation_config={"temperature": 0.3},
)
response = model.generate_content(prompt)
return response.text
class OllamaProvider(LLMProvider):
def __init__(self, model: str | None = None):
@@ -326,6 +354,16 @@ class OllamaProvider(LLMProvider):
)
return parse_brief_json(raw2, "ollama", self.model)
def generate_text(self, prompt: str) -> str:
import requests as req
response = req.post(
f"{self.base_url}/api/generate",
json={"model": self.model, "prompt": prompt, "stream": False},
timeout=120,
)
response.raise_for_status()
return response.json().get("response", "")
def get_llm_provider(provider: str | None = None, model: str | None = None) -> LLMProvider:
"""Factory — returns the configured LLM provider.
@@ -344,3 +382,72 @@ def get_llm_provider(provider: str | None = None, model: str | None = None) -> L
elif provider == "ollama":
return OllamaProvider(model=model)
raise ValueError(f"Unknown LLM_PROVIDER: '{provider}'. Must be one of: openai, anthropic, gemini, ollama")
_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.",
}
_TONE_INSTRUCTIONS: dict[str, str] = {
"short": "Keep the letter brief — 6 to 8 sentences total.",
"polite": "Use a respectful, formal, and courteous tone throughout the letter.",
"firm": "Use a direct, firm tone that makes clear the constituent's strong conviction.",
}
def generate_draft_letter(
bill_label: str,
bill_title: str,
stance: str,
recipient: str,
tone: str,
selected_points: list[str],
include_citations: bool,
zip_code: str | None,
llm_provider: str | None = None,
llm_model: str | None = None,
) -> str:
"""Generate a plain-text constituent letter draft using the configured LLM provider."""
vote_word = "YES" if stance == "yes" else "NO"
chamber_word = "House" if recipient == "house" else "Senate"
tone_instruction = _TONE_INSTRUCTIONS.get(tone, _TONE_INSTRUCTIONS["polite"])
points_block = "\n".join(f"- {p}" for p in selected_points)
citation_instruction = (
"You may reference the citation label for each point (e.g. 'as noted in Section 3') if it adds clarity."
if include_citations
else "Do not include any citation references."
)
location_line = f"The constituent is writing from ZIP code {zip_code}." if zip_code else ""
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}."
- 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]".
- Plain text only. No markdown, no bullet points, no headers, no partisan framing.
- Do not mention any political party.
BILL: {bill_label}{bill_title}
STANCE: Vote {vote_word}
{location_line}
SELECTED POINTS TO REFERENCE:
{points_block}
Write the letter now:"""
return get_llm_provider(provider=llm_provider, model=llm_model).generate_text(prompt)

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { ArrowLeft, ExternalLink, FileX, User } from "lucide-react";
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
@@ -104,7 +105,10 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
<div className="md:col-span-2 space-y-6">
{bill.briefs.length > 0 ? (
<>
<BriefPanel briefs={bill.briefs} />
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
</>
) : bill.has_document ? (
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2">
<p className="text-sm font-medium text-muted-foreground">Analysis pending</p>

View File

@@ -0,0 +1,333 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { ChevronDown, ChevronRight, Copy, Check, Loader2, PenLine } from "lucide-react";
import type { BriefSchema, CitedPoint } from "@/lib/types";
import { billsAPI } from "@/lib/api";
import { useIsFollowing } from "@/lib/hooks/useFollows";
interface DraftLetterPanelProps {
billId: string;
brief: BriefSchema;
chamber?: string;
}
type Stance = "yes" | "no" | null;
type Tone = "short" | "polite" | "firm";
function pointText(p: string | CitedPoint): string {
return typeof p === "string" ? p : p.text;
}
function pointKey(p: string | CitedPoint, i: number): string {
return `${i}-${typeof p === "string" ? p.slice(0, 40) : p.text.slice(0, 40)}`;
}
function chamberToRecipient(chamber?: string): "house" | "senate" {
return chamber?.toLowerCase() === "senate" ? "senate" : "house";
}
export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelProps) {
const [open, setOpen] = useState(false);
const existing = useIsFollowing("bill", billId);
const [stance, setStance] = useState<Stance>(null);
const prevModeRef = useRef<string | undefined>(undefined);
// Keep stance in sync with follow mode changes (including unfollow → null)
useEffect(() => {
const newMode = existing?.follow_mode;
if (newMode === prevModeRef.current) return;
prevModeRef.current = newMode;
if (newMode === "pocket_boost") setStance("yes");
else if (newMode === "pocket_veto") setStance("no");
else setStance(null);
}, [existing?.follow_mode]);
const recipient = chamberToRecipient(chamber);
const [tone, setTone] = useState<Tone>("polite");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [includeCitations, setIncludeCitations] = useState(true);
const [zipCode, setZipCode] = useState("");
const [loading, setLoading] = useState(false);
const [draft, setDraft] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const keyPoints = brief.key_points ?? [];
const risks = brief.risks ?? [];
const allPoints = [
...keyPoints.map((p, i) => ({ group: "key" as const, index: i, text: pointText(p), raw: p })),
...risks.map((p, i) => ({ group: "risk" as const, index: keyPoints.length + i, text: pointText(p), raw: p })),
];
function togglePoint(globalIndex: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(globalIndex)) {
next.delete(globalIndex);
} else if (next.size < 3) {
next.add(globalIndex);
}
return next;
});
}
async function handleGenerate() {
if (selected.size === 0 || stance === null) return;
const selectedPoints = allPoints
.filter((p) => selected.has(p.index))
.map((p) => {
if (includeCitations && typeof p.raw !== "string" && p.raw.citation) {
return `${p.text} (${p.raw.citation})`;
}
return p.text;
});
setLoading(true);
setError(null);
setDraft(null);
try {
const result = await billsAPI.generateDraft(billId, {
stance,
recipient,
tone,
selected_points: selectedPoints,
include_citations: includeCitations,
zip_code: zipCode.trim() || undefined,
});
setDraft(result.draft);
} catch (err: unknown) {
const detail =
err &&
typeof err === "object" &&
"response" in err &&
err.response &&
typeof err.response === "object" &&
"data" in err.response &&
err.response.data &&
typeof err.response.data === "object" &&
"detail" in err.response.data
? String((err.response.data as { detail: string }).detail)
: "Failed to generate letter. Please try again.";
setError(detail);
} finally {
setLoading(false);
}
}
async function handleCopy() {
if (!draft) return;
await navigator.clipboard.writeText(draft);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors text-left"
>
{open ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
<PenLine className="w-3.5 h-3.5 shrink-0" />
Draft a letter to your {recipient === "senate" ? "senator" : "representative"}
</button>
{open && (
<div className="border-t border-border px-4 py-4 space-y-4">
{/* Stance + Tone */}
<div className="flex flex-wrap gap-4">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Stance</p>
<div className="flex rounded-md overflow-hidden border border-border text-xs">
{(["yes", "no"] as ("yes" | "no")[]).map((s) => (
<button
key={s}
onClick={() => setStance(s)}
className={`px-3 py-1.5 font-medium transition-colors ${
stance === s
? s === "yes"
? "bg-green-600 text-white"
: "bg-red-600 text-white"
: "bg-background text-muted-foreground hover:bg-accent/50"
}`}
>
{s === "yes" ? "Support (Vote YES)" : "Oppose (Vote NO)"}
</button>
))}
</div>
{stance === null && (
<p className="text-[10px] text-amber-500 mt-1">Select a position to continue</p>
)}
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Tone</p>
<select
value={tone}
onChange={(e) => setTone(e.target.value as Tone)}
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground"
>
<option value="short">Short</option>
<option value="polite">Polite</option>
<option value="firm">Firm</option>
</select>
</div>
</div>
{/* Point selector */}
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
Select up to 3 points to include
{selected.size > 0 && (
<span className="ml-1 text-muted-foreground">({selected.size}/3)</span>
)}
</p>
<div className="border border-border rounded-md divide-y divide-border">
{keyPoints.length > 0 && (
<>
<p className="px-3 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40">
Key Points
</p>
{keyPoints.map((p, i) => {
const globalIndex = i;
const isChecked = selected.has(globalIndex);
const isDisabled = !isChecked && selected.size >= 3;
return (
<label
key={pointKey(p, i)}
className={`flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-accent/40"
}`}
>
<input
type="checkbox"
checked={isChecked}
disabled={isDisabled}
onChange={() => togglePoint(globalIndex)}
className="mt-0.5 shrink-0"
/>
<span className="text-xs text-foreground leading-snug">{pointText(p)}</span>
</label>
);
})}
</>
)}
{risks.length > 0 && (
<>
<p className="px-3 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40">
Concerns
</p>
{risks.map((p, i) => {
const globalIndex = keyPoints.length + i;
const isChecked = selected.has(globalIndex);
const isDisabled = !isChecked && selected.size >= 3;
return (
<label
key={pointKey(p, keyPoints.length + i)}
className={`flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-accent/40"
}`}
>
<input
type="checkbox"
checked={isChecked}
disabled={isDisabled}
onChange={() => togglePoint(globalIndex)}
className="mt-0.5 shrink-0"
/>
<span className="text-xs text-foreground leading-snug">{pointText(p)}</span>
</label>
);
})}
</>
)}
</div>
</div>
{/* Options row */}
<div className="flex flex-wrap items-center gap-4">
<div className="space-y-0.5">
<input
type="text"
value={zipCode}
onChange={(e) => setZipCode(e.target.value)}
placeholder="ZIP code"
maxLength={10}
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground w-28 placeholder:text-muted-foreground"
/>
<p className="text-[10px] text-muted-foreground">optional · not stored</p>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-muted-foreground">
<input
type="checkbox"
checked={includeCitations}
onChange={(e) => setIncludeCitations(e.target.checked)}
className="shrink-0"
/>
Include citations
</label>
</div>
{/* Generate button */}
<button
onClick={handleGenerate}
disabled={loading || selected.size === 0 || stance === null}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-xs font-medium rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{loading ? "Generating…" : "Generate letter"}
</button>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Draft output */}
{draft && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground italic">Edit before sending</p>
<button
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-500">Copied!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
Copy
</>
)}
</button>
</div>
<textarea
readOnly
value={draft}
rows={10}
className="w-full text-xs bg-muted/30 border border-border rounded-md px-3 py-2 text-foreground resize-y font-sans leading-relaxed"
/>
</div>
)}
{/* Footer */}
<p className="text-[10px] text-muted-foreground border-t border-border pt-3">
Based only on the bill&apos;s cited text · We don&apos;t store your location
</p>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { TrendingUp, Calendar, User } from "lucide-react";
import { TrendingUp, Calendar, User, FileText, FileClock, FileX } from "lucide-react";
import { Bill } from "@/lib/types";
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
import { FollowButton } from "./FollowButton";
@@ -69,6 +69,22 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
{Math.round(score)}
</div>
)}
{bill.latest_brief ? (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400" title="Analysis available">
<FileText className="w-3 h-3" />
<span>Brief</span>
</div>
) : bill.has_document ? (
<div className="flex items-center gap-1 text-xs text-amber-500" title="Text retrieved, analysis pending">
<FileClock className="w-3 h-3" />
<span>Pending</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground/50" title="No bill text published">
<FileX className="w-3 h-3" />
<span>No text</span>
</div>
)}
</div>
</div>

View File

@@ -75,6 +75,15 @@ export const billsAPI = {
apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data),
getTrend: (id: string, days?: number) =>
apiClient.get<TrendScore[]>(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data),
generateDraft: (id: string, body: {
stance: string;
recipient: string;
tone: string;
selected_points: string[];
include_citations: boolean;
zip_code?: string;
}) =>
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
};
// Members

View File

@@ -116,6 +116,7 @@ export interface Bill {
latest_brief?: BriefSchema;
latest_trend?: TrendScore;
updated_at?: string;
has_document?: boolean;
}
export interface BillDetail extends Bill {