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:
Jack Levy
2026-02-28 22:48:58 -05:00
parent 6a1b387dd2
commit 8d6a55905c
7 changed files with 144 additions and 28 deletions

View File

@@ -1,14 +1,74 @@
"use client";
import { AlertTriangle, CheckCircle, Clock, Cpu, Tag } from "lucide-react";
import { BriefSchema } from "@/lib/types";
import { useState } from "react";
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink, Tag } from "lucide-react";
import { BriefSchema, CitedPoint } from "@/lib/types";
import { formatDate } from "@/lib/utils";
interface AIBriefCardProps {
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) {
const [activeKey, setActiveKey] = useState<string | null>(null);
if (!brief) {
return (
<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 && (
<div>
<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) => (
<li key={i} className="flex items-start gap-2 text-sm">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 text-green-500 shrink-0" />
<span>{point}</span>
</li>
<CitedItem
key={i}
point={point}
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>
</div>
@@ -59,12 +124,17 @@ export function AIBriefCard({ brief }: AIBriefCardProps) {
{brief.risks && brief.risks.length > 0 && (
<div>
<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) => (
<li key={i} className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 text-yellow-500 shrink-0" />
<span>{risk}</span>
</li>
<CitedItem
key={i}
point={risk}
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>
</div>