UI/UX: - Bill detail page tab UI (Analysis / Timeline / Votes / Notes) - Topic tag pills on bill detail and listing pages — filtered to known topics, clickable, properly labelled via shared lib/topics.ts - Notes panel always-open in Notes tab; sign-in prompt for guests - Collapsible sidebar with icon-only mode and localStorage persistence - Bills page defaults to has-text filter enabled - Follow mode dropdown transparency fix - Favicon (Landmark icon, blue background) Security: - Fernet encryption for ntfy passwords at rest (app/core/crypto.py) - Separate ENCRYPTION_SECRET_KEY env var; falls back to JWT derivation - ntfy_password no longer returned in GET response — replaced with ntfy_password_set: bool; NotificationSettingsUpdate type for writes - JWT_SECRET_KEY fail-fast on startup if using default placeholder - get_optional_user catches (JWTError, ValueError) only, not Exception Bug fixes & code quality: - Dashboard N+1 topic query replaced with single OR query - notification_utils.py topic follower N+1 replaced with batch query - Note query in bill detail page gated on token (enabled: !!token) - search.py max_length=500 guard against oversized queries - CollectionCreate.validate_name wired up with @field_validator - LLM_RATE_LIMIT_RPM default raised from 10 to 50 Authored by: Jack Levy
175 lines
6.0 KiB
TypeScript
175 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink } 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>
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="flex items-start gap-2">
|
|
<span className="flex-1">{cited ? point.text : point}</span>
|
|
{cited && point.label === "inference" && (
|
|
<span
|
|
title="This point is an analytical interpretation, not a literal statement from the bill text"
|
|
className="shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-border text-muted-foreground font-sans leading-none mt-0.5"
|
|
>
|
|
Inferred
|
|
</span>
|
|
)}
|
|
</div>
|
|
{cited && (
|
|
<button
|
|
onClick={() => setActiveKey(isOpen ? null : openKey)}
|
|
title={isOpen ? "Hide source" : "View source"}
|
|
className={`text-left text-xs px-1.5 py-0.5 rounded font-mono leading-snug transition-colors ${
|
|
isOpen
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
}`}
|
|
>
|
|
§ {point.citation}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</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">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
|
<h2 className="font-semibold">AI Analysis</h2>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground italic">
|
|
Analysis not yet generated. It will appear once the bill text has been processed.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-card border border-border rounded-lg p-6 space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Cpu className="w-4 h-4 text-primary" />
|
|
<h2 className="font-semibold">AI Analysis</h2>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{brief.llm_provider}/{brief.llm_model} · {formatDate(brief.created_at)}
|
|
</span>
|
|
</div>
|
|
|
|
{brief.summary && (
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Summary</h3>
|
|
<p className="text-sm leading-relaxed whitespace-pre-line">{brief.summary}</p>
|
|
</div>
|
|
)}
|
|
|
|
{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-2">
|
|
{brief.key_points.map((point, i) => (
|
|
<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>
|
|
)}
|
|
|
|
{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-2">
|
|
{brief.risks.map((risk, i) => (
|
|
<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>
|
|
)}
|
|
|
|
{brief.deadlines && brief.deadlines.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Deadlines</h3>
|
|
<ul className="space-y-1.5">
|
|
{brief.deadlines.map((d, i) => (
|
|
<li key={i} className="flex items-start gap-2 text-sm">
|
|
<Clock className="w-3.5 h-3.5 mt-0.5 text-blue-500 shrink-0" />
|
|
<span>
|
|
{d.date ? <strong>{formatDate(d.date)}: </strong> : ""}
|
|
{d.description}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|