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:
@@ -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>
|
||||
|
||||
@@ -10,15 +10,23 @@ export interface Member {
|
||||
photo_url?: string;
|
||||
}
|
||||
|
||||
export interface CitedPoint {
|
||||
text: string;
|
||||
citation: string;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
export interface BriefSchema {
|
||||
id: number;
|
||||
brief_type?: string;
|
||||
summary?: string;
|
||||
key_points?: string[];
|
||||
risks?: string[];
|
||||
key_points?: (string | CitedPoint)[];
|
||||
risks?: (string | CitedPoint)[];
|
||||
deadlines?: { date: string | null; description: string }[];
|
||||
topic_tags?: string[];
|
||||
llm_provider?: string;
|
||||
llm_model?: string;
|
||||
govinfo_url?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user