feat(citations): add per-claim citations to AI briefs
LLM prompts updated to output {text, citation, quote} objects for every
key_point and risk. govinfo_url stored on BillBrief (migration 0006) so
the frontend can link directly to the source document without an extra
query. AIBriefCard renders § citation chips that expand inline to show
the verbatim quote and a View source → GovInfo link. Old plain-string
briefs continue to render unchanged.
Authored-By: Jack Levy
This commit is contained in:
21
backend/alembic/versions/0006_add_brief_govinfo_url.py
Normal file
21
backend/alembic/versions/0006_add_brief_govinfo_url.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""add govinfo_url to bill_briefs
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-02-28
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0006"
|
||||||
|
down_revision = "0005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("bill_briefs", sa.Column("govinfo_url", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("bill_briefs", "govinfo_url")
|
||||||
@@ -14,12 +14,13 @@ class BillBrief(Base):
|
|||||||
document_id = Column(Integer, ForeignKey("bill_documents.id", ondelete="SET NULL"), nullable=True)
|
document_id = Column(Integer, ForeignKey("bill_documents.id", ondelete="SET NULL"), nullable=True)
|
||||||
brief_type = Column(String(20), nullable=False, server_default="full") # full | amendment
|
brief_type = Column(String(20), nullable=False, server_default="full") # full | amendment
|
||||||
summary = Column(Text)
|
summary = Column(Text)
|
||||||
key_points = Column(JSONB) # list[str]
|
key_points = Column(JSONB) # list[{text, citation, quote}]
|
||||||
risks = Column(JSONB) # list[str]
|
risks = Column(JSONB) # list[{text, citation, quote}]
|
||||||
deadlines = Column(JSONB) # list[{date: str, description: str}]
|
deadlines = Column(JSONB) # list[{date: str, description: str}]
|
||||||
topic_tags = Column(JSONB) # list[str]
|
topic_tags = Column(JSONB) # list[str]
|
||||||
llm_provider = Column(String(50))
|
llm_provider = Column(String(50))
|
||||||
llm_model = Column(String(100))
|
llm_model = Column(String(100))
|
||||||
|
govinfo_url = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
bill = relationship("Bill", back_populates="briefs")
|
bill = relationship("Bill", back_populates="briefs")
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ class BriefSchema(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
brief_type: str = "full"
|
brief_type: str = "full"
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
key_points: Optional[list[str]] = None
|
key_points: Optional[list[Any]] = None
|
||||||
risks: Optional[list[str]] = None
|
risks: Optional[list[Any]] = None
|
||||||
deadlines: Optional[list[dict[str, Any]]] = None
|
deadlines: Optional[list[dict[str, Any]]] = None
|
||||||
topic_tags: Optional[list[str]] = None
|
topic_tags: Optional[list[str]] = None
|
||||||
llm_provider: Optional[str] = None
|
llm_provider: Optional[str] = None
|
||||||
llm_model: Optional[str] = None
|
llm_model: Optional[str] = None
|
||||||
|
govinfo_url: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -21,17 +21,24 @@ without political bias.
|
|||||||
Always respond with valid JSON matching exactly this schema:
|
Always respond with valid JSON matching exactly this schema:
|
||||||
{
|
{
|
||||||
"summary": "2-4 paragraph plain-language summary of what this bill does",
|
"summary": "2-4 paragraph plain-language summary of what this bill does",
|
||||||
"key_points": ["specific concrete fact 1", "specific concrete fact 2"],
|
"key_points": [
|
||||||
"risks": ["legitimate concern or challenge 1", "legitimate concern 2"],
|
{"text": "specific concrete fact", "citation": "Section X(y)", "quote": "verbatim excerpt from bill ≤80 words"}
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
{"text": "legitimate concern or challenge", "citation": "Section X(y)", "quote": "verbatim excerpt from bill ≤80 words"}
|
||||||
|
],
|
||||||
"deadlines": [{"date": "YYYY-MM-DD or null", "description": "what happens on this date"}],
|
"deadlines": [{"date": "YYYY-MM-DD or null", "description": "what happens on this date"}],
|
||||||
"topic_tags": ["healthcare", "taxation"]
|
"topic_tags": ["healthcare", "taxation"]
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- summary: Explain WHAT the bill does, not whether it is good or bad. Be factual and complete.
|
- summary: Explain WHAT the bill does, not whether it is good or bad. Be factual and complete.
|
||||||
- key_points: 5-10 specific, concrete things the bill changes, authorizes, or appropriates.
|
- key_points: 5-10 specific, concrete things the bill changes, authorizes, or appropriates. \
|
||||||
|
Each item MUST include "text" (your claim), "citation" (the section number, e.g. "Section 301(a)(2)"), \
|
||||||
|
and "quote" (a verbatim excerpt of ≤80 words from that section that supports your claim).
|
||||||
- risks: Legitimate concerns from any perspective — costs, implementation challenges, \
|
- risks: Legitimate concerns from any perspective — costs, implementation challenges, \
|
||||||
constitutional questions, unintended consequences. Include at least 2 even for benign bills.
|
constitutional questions, unintended consequences. Include at least 2 even for benign bills. \
|
||||||
|
Each item MUST include "text", "citation", and "quote" just like key_points.
|
||||||
- deadlines: Only include if explicitly stated in the text. Use null for date if a deadline \
|
- deadlines: Only include if explicitly stated in the text. Use null for date if a deadline \
|
||||||
is mentioned without a specific date. Empty list if none.
|
is mentioned without a specific date. Empty list if none.
|
||||||
- topic_tags: 3-8 lowercase tags. Prefer these standard tags: healthcare, taxation, defense, \
|
- topic_tags: 3-8 lowercase tags. Prefer these standard tags: healthcare, taxation, defense, \
|
||||||
@@ -49,8 +56,8 @@ TOKENS_PER_CHAR = 0.25 # rough approximation: 4 chars ≈ 1 token
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ReverseBrief:
|
class ReverseBrief:
|
||||||
summary: str
|
summary: str
|
||||||
key_points: list[str]
|
key_points: list[dict]
|
||||||
risks: list[str]
|
risks: list[dict]
|
||||||
deadlines: list[dict]
|
deadlines: list[dict]
|
||||||
topic_tags: list[str]
|
topic_tags: list[str]
|
||||||
llm_provider: str
|
llm_provider: str
|
||||||
@@ -82,16 +89,23 @@ and you must summarize what changed between the previous and new version.
|
|||||||
Always respond with valid JSON matching exactly this schema:
|
Always respond with valid JSON matching exactly this schema:
|
||||||
{
|
{
|
||||||
"summary": "2-3 paragraph plain-language description of what changed in this version",
|
"summary": "2-3 paragraph plain-language description of what changed in this version",
|
||||||
"key_points": ["specific change 1", "specific change 2"],
|
"key_points": [
|
||||||
"risks": ["new concern introduced by this change 1", "concern 2"],
|
{"text": "specific change", "citation": "Section X(y)", "quote": "verbatim excerpt from new version ≤80 words"}
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
{"text": "new concern introduced by this change", "citation": "Section X(y)", "quote": "verbatim excerpt from new version ≤80 words"}
|
||||||
|
],
|
||||||
"deadlines": [{"date": "YYYY-MM-DD or null", "description": "new deadline added"}],
|
"deadlines": [{"date": "YYYY-MM-DD or null", "description": "new deadline added"}],
|
||||||
"topic_tags": ["healthcare", "taxation"]
|
"topic_tags": ["healthcare", "taxation"]
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- summary: Focus ONLY on what is different from the previous version. Be specific.
|
- summary: Focus ONLY on what is different from the previous version. Be specific.
|
||||||
- key_points: List concrete additions, removals, or modifications in this version.
|
- key_points: List concrete additions, removals, or modifications in this version. \
|
||||||
- risks: Only include risks that are new or changed relative to the previous version.
|
Each item MUST include "text" (your claim), "citation" (the section number, e.g. "Section 301(a)(2)"), \
|
||||||
|
and "quote" (a verbatim excerpt of ≤80 words from the NEW version that supports your claim).
|
||||||
|
- risks: Only include risks that are new or changed relative to the previous version. \
|
||||||
|
Each item MUST include "text", "citation", and "quote" just like key_points.
|
||||||
- deadlines: Only new or changed deadlines. Empty list if none.
|
- deadlines: Only new or changed deadlines. Empty list if none.
|
||||||
- topic_tags: Same standard tags as before — include any new topics this version adds.
|
- topic_tags: Same standard tags as before — include any new topics this version adds.
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ def process_document_with_llm(self, document_id: int):
|
|||||||
topic_tags=brief.topic_tags,
|
topic_tags=brief.topic_tags,
|
||||||
llm_provider=brief.llm_provider,
|
llm_provider=brief.llm_provider,
|
||||||
llm_model=brief.llm_model,
|
llm_model=brief.llm_model,
|
||||||
|
govinfo_url=doc.govinfo_url,
|
||||||
)
|
)
|
||||||
db.add(db_brief)
|
db.add(db_brief)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -1,14 +1,74 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, CheckCircle, Clock, Cpu, Tag } from "lucide-react";
|
import { useState } from "react";
|
||||||
import { BriefSchema } from "@/lib/types";
|
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink, Tag } from "lucide-react";
|
||||||
|
import { BriefSchema, CitedPoint } from "@/lib/types";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
interface AIBriefCardProps {
|
interface AIBriefCardProps {
|
||||||
brief?: BriefSchema | null;
|
brief?: BriefSchema | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCited(p: string | CitedPoint): p is CitedPoint {
|
||||||
|
return typeof p === "object" && p !== null && "text" in p;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CitedItemProps {
|
||||||
|
point: string | CitedPoint;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
govinfo_url?: string;
|
||||||
|
openKey: string;
|
||||||
|
activeKey: string | null;
|
||||||
|
setActiveKey: (key: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey }: CitedItemProps) {
|
||||||
|
const cited = isCited(point);
|
||||||
|
const isOpen = activeKey === openKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||||
|
<span className="flex-1">{cited ? point.text : point}</span>
|
||||||
|
{cited && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveKey(isOpen ? null : openKey)}
|
||||||
|
title={isOpen ? "Hide source" : "View source"}
|
||||||
|
className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-mono transition-colors ${
|
||||||
|
isOpen
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
§ {point.citation}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cited && isOpen && (
|
||||||
|
<div className="mt-2 ml-5 rounded-md border border-border bg-muted/40 p-3 space-y-2">
|
||||||
|
<blockquote className="text-xs text-muted-foreground italic leading-relaxed border-l-2 border-primary pl-3">
|
||||||
|
"{point.quote}"
|
||||||
|
</blockquote>
|
||||||
|
{govinfo_url && (
|
||||||
|
<a
|
||||||
|
href={govinfo_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View source <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AIBriefCard({ brief }: AIBriefCardProps) {
|
export function AIBriefCard({ brief }: AIBriefCardProps) {
|
||||||
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!brief) {
|
if (!brief) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-6">
|
<div className="bg-card border border-border rounded-lg p-6">
|
||||||
@@ -45,12 +105,17 @@ export function AIBriefCard({ brief }: AIBriefCardProps) {
|
|||||||
{brief.key_points && brief.key_points.length > 0 && (
|
{brief.key_points && brief.key_points.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Key Points</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Key Points</h3>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-2">
|
||||||
{brief.key_points.map((point, i) => (
|
{brief.key_points.map((point, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-sm">
|
<CitedItem
|
||||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 text-green-500 shrink-0" />
|
key={i}
|
||||||
<span>{point}</span>
|
point={point}
|
||||||
</li>
|
icon={<CheckCircle className="w-3.5 h-3.5 text-green-500" />}
|
||||||
|
govinfo_url={brief.govinfo_url}
|
||||||
|
openKey={`kp-${i}`}
|
||||||
|
activeKey={activeKey}
|
||||||
|
setActiveKey={setActiveKey}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,12 +124,17 @@ export function AIBriefCard({ brief }: AIBriefCardProps) {
|
|||||||
{brief.risks && brief.risks.length > 0 && (
|
{brief.risks && brief.risks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Risks & Concerns</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Risks & Concerns</h3>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-2">
|
||||||
{brief.risks.map((risk, i) => (
|
{brief.risks.map((risk, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-sm">
|
<CitedItem
|
||||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 text-yellow-500 shrink-0" />
|
key={i}
|
||||||
<span>{risk}</span>
|
point={risk}
|
||||||
</li>
|
icon={<AlertTriangle className="w-3.5 h-3.5 text-yellow-500" />}
|
||||||
|
govinfo_url={brief.govinfo_url}
|
||||||
|
openKey={`risk-${i}`}
|
||||||
|
activeKey={activeKey}
|
||||||
|
setActiveKey={setActiveKey}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,15 +10,23 @@ export interface Member {
|
|||||||
photo_url?: string;
|
photo_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CitedPoint {
|
||||||
|
text: string;
|
||||||
|
citation: string;
|
||||||
|
quote: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BriefSchema {
|
export interface BriefSchema {
|
||||||
id: number;
|
id: number;
|
||||||
|
brief_type?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
key_points?: string[];
|
key_points?: (string | CitedPoint)[];
|
||||||
risks?: string[];
|
risks?: (string | CitedPoint)[];
|
||||||
deadlines?: { date: string | null; description: string }[];
|
deadlines?: { date: string | null; description: string }[];
|
||||||
topic_tags?: string[];
|
topic_tags?: string[];
|
||||||
llm_provider?: string;
|
llm_provider?: string;
|
||||||
llm_model?: string;
|
llm_model?: string;
|
||||||
|
govinfo_url?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user