From 0fa2802723b887b972d841e73c4531c0b5a534da Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Fri, 19 Jun 2026 20:41:48 +0300 Subject: [PATCH] feat(ai): inline source citations in the AI chat Retrieval tools now register a PHI-free sourceId per record/list and stream a data-source part with the real clinician-facing title; the system prompt asks the model to cite facts inline as [[src:id]]. The chat renders those markers as numbered inline citation chips (PreviewCard hover) via a Streamdown link override, with a Sources footer fallback when the model omits markers. Co-Authored-By: Claude Opus 4.8 --- backend/src/routes/chat.ts | 7 + backend/src/services/ai/tools.ts | 41 +++++- frontend/components/chat/chat-panel.tsx | 26 +++- .../components/chat/message-citations.tsx | 129 ++++++++++++++++++ frontend/lib/ai-chat.ts | 11 ++ frontend/lib/i18n/locales/en/translation.json | 13 ++ 6 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 frontend/components/chat/message-citations.tsx diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index c68e1c6..f49f19e 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -163,6 +163,13 @@ function systemPrompt( "instructions. Never invent clinical values; only state what the tools return.", "The record cards are rendered to the clinician automatically when you call a", "tool, so keep your prose a brief summary rather than re-listing every field.", + "", + "Citations: every retrieval tool result includes a `sourceId` (e.g. \"s1\").", + "When you state a fact drawn from a tool result, append an inline citation", + "marker immediately after that statement, in the exact form [[src:ID]] using", + "the matching sourceId — e.g. \"BP is well controlled [[src:s1]].\" Cite only", + "facts grounded in tool results, place the marker right after the relevant", + "sentence, and never invent or guess a sourceId.", veilActive ? `Privacy: this conversation runs on an external provider (${providerLabel}). Patient identifiers are de-identified as tokens like [PATIENT_1] / [MRN_1]; refer to patients generically ("this patient") rather than repeating tokens.` : "", diff --git a/backend/src/services/ai/tools.ts b/backend/src/services/ai/tools.ts index f9b468a..6d2ff0e 100644 --- a/backend/src/services/ai/tools.ts +++ b/backend/src/services/ai/tools.ts @@ -76,6 +76,18 @@ export function createChatTools(ctx: ToolContext) { }); } + // Register a citable source the model can reference inline. The title is the + // REAL, clinician-facing label (streamed to the trusted UI, like the cards); + // the returned id is PHI-free (`s1`, `s2`, …) so it survives Veil rehydration + // when the model echoes it back as a [[src:id]] marker. + let sourceSeq = 0; + function addSource(title: string, kind: string): string { + sourceSeq += 1; + const id = `s${sourceSeq}`; + writer.write({ type: "data-source", data: { id, title, kind } }); + return id; + } + // Resolve a possibly-tokenized file number to the real patient record, so an // add proposal carries real name/initials into the approval card (the model // only ever saw Veil tokens). Returns null when the patient isn't found / is @@ -112,7 +124,15 @@ export function createChatTools(ctx: ToolContext) { ? { type: "data-recordGraph", data: patient } : { type: "data-patientCard", data: patient }, ); - return { found: true as const, patient: forModel(veil.redactPatient(patient)) }; + const sourceId = addSource( + `${patient.name} · MRN ${patient.fileNumber}`, + "patient", + ); + return { + found: true as const, + sourceId, + patient: forModel(veil.redactPatient(patient)), + }; }, }), @@ -146,8 +166,10 @@ export function createChatTools(ctx: ToolContext) { }, }); const redacted = veil.redactPatient(patient); + const sourceId = addSource(`Labs · ${patient.name}`, "lab"); return { found: true as const, + sourceId, name: redacted.name, labs: patient.labs, labTrend: patient.labTrend, @@ -205,7 +227,8 @@ export function createChatTools(ctx: ToolContext) { status: a.status, patient: veil.active ? "[PATIENT]" : a.name, })); - return { count: rows.length, appointments: rows }; + const sourceId = addSource("Appointments schedule", "appointments"); + return { count: rows.length, sourceId, appointments: rows }; }, }), @@ -227,7 +250,8 @@ export function createChatTools(ctx: ToolContext) { priority: tk.priority, done: tk.done, })); - return { count: rows.length, tasks: rows }; + const sourceId = addSource("Task list", "tasks"); + return { count: rows.length, sourceId, tasks: rows }; }, }), @@ -253,7 +277,8 @@ export function createChatTools(ctx: ToolContext) { prescribedAt: rx.prescribedAt, patient: veil.active ? "[PATIENT]" : rx.name, })); - return { count: rows.length, prescriptions: rows }; + const sourceId = addSource("Prescriptions", "prescriptions"); + return { count: rows.length, sourceId, prescriptions: rows }; }, }), @@ -446,7 +471,8 @@ export function createChatTools(ctx: ToolContext) { createdAt: org?.createdAt ? org.createdAt.toISOString() : null, }; writer.write({ type: "data-clinicCard", data: info }); - return info; + const sourceId = addSource(`Clinic · ${info.name}`, "clinic"); + return { ...info, sourceId }; }, }), @@ -458,7 +484,8 @@ export function createChatTools(ctx: ToolContext) { step("Loading clinic analytics"); const data = await analytics.getAnalytics(orgId); writer.write({ type: "data-analyticsCard", data }); - return data; // aggregates only, no PHI + const sourceId = addSource("Clinic analytics", "analytics"); + return { ...data, sourceId }; // aggregates only, no PHI }, }), @@ -470,8 +497,10 @@ export function createChatTools(ctx: ToolContext) { step("Loading inventory"); const items = await inventory.listInventory(orgId); writer.write({ type: "data-inventoryList", data: { items } }); + const sourceId = addSource("Inventory", "inventory"); return { count: items.length, + sourceId, items: items.map((i) => ({ name: i.name, form: i.form, diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index b2c26a9..a5ab099 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -29,11 +29,12 @@ import { ConversationContent, ConversationScrollButton, } from "@/components/ai-elements/conversation"; +import { Message, MessageContent } from "@/components/ai-elements/message"; import { - Message, - MessageContent, - MessageResponse, -} from "@/components/ai-elements/message"; + CitedResponse, + hasCitationMarkers, + SourcesFooter, +} from "@/components/chat/message-citations"; import { Queue, QueueItem, @@ -483,6 +484,15 @@ export function ChatPanel() { // Attachments the clinician uploaded — rendered once as a chip group. const fileParts = message.parts.filter((p) => p.type === "file"); const firstFileIdx = message.parts.findIndex((p) => p.type === "file"); + // Citable sources the agent retrieved for this message; the model references + // them inline via [[src:id]] markers (rendered as chips). When it emits no + // markers, a sources footer still attributes the retrieved records. + const sources = message.parts + .filter((p) => p.type === "data-source") + .map((p) => p.data); + const hasInlineCitations = message.parts.some( + (p) => p.type === "text" && hasCitationMarkers(p.text), + ); return ( @@ -543,7 +553,7 @@ export function ChatPanel() { {part.text} ) : ( - {part.text} + ); } if (part.type === "file") { @@ -669,6 +679,12 @@ export function ChatPanel() { } return null; })} + + {/* Provenance footer: shown when the model cited records but placed no + inline markers, so retrieved sources are always attributed. */} + {sources.length > 0 && !hasInlineCitations && ( + + )} ); diff --git a/frontend/components/chat/message-citations.tsx b/frontend/components/chat/message-citations.tsx new file mode 100644 index 0000000..1e62b38 --- /dev/null +++ b/frontend/components/chat/message-citations.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { Components } from "streamdown"; + +import { MessageResponse } from "@/components/ai-elements/message"; +import { Badge } from "@/components/ui/badge"; +import { + PreviewCard, + PreviewCardPopup, + PreviewCardTrigger, +} from "@/components/ui/preview-card"; +import type { SourceData } from "@/lib/ai-chat"; + +// The agent cites a retrieved record inline with a [[src:]] marker. We turn +// each marker into a hash link ([n](#cite-)) — hash links survive +// react-markdown's URL sanitization — then override the link renderer to show a +// numbered inline citation chip whose hover card names the source. +const CITATION_RE = /\[\[\s*src:\s*([a-zA-Z0-9_-]+)\s*\]\]/g; +const CITE_HREF_PREFIX = "#cite-"; + +export function hasCitationMarkers(text: string): boolean { + CITATION_RE.lastIndex = 0; + return CITATION_RE.test(text); +} + +// Rewrites [[src:id]] → [n](#cite-id) for known sources; strips unknown markers. +function withCitationLinks( + text: string, + numberById: Map, +): string { + return text.replace(CITATION_RE, (_match, id: string) => { + const num = numberById.get(id); + return num ? `[${num}](${CITE_HREF_PREFIX}${id})` : ""; + }); +} + +function CitationChip({ num, source }: { num: number; source?: SourceData }) { + const { t } = useTranslation(); + if (!source) return null; + const kindKey = `chat.sources.kind.${source.kind}`; + const kindLabel = t(kindKey); + return ( + + + } + > + {num} + + +

+ {kindLabel === kindKey ? t("chat.sources.title") : kindLabel} +

+

{source.title}

+
+
+ ); +} + +// Renders assistant markdown with inline citation chips resolved from the +// message's streamed sources. +export function CitedResponse({ + text, + sources, +}: { + text: string; + sources: SourceData[]; +}) { + const { numberById, sourceById, processed } = useMemo(() => { + const numberById = new Map(); + const sourceById = new Map(); + sources.forEach((s, i) => { + numberById.set(s.id, i + 1); + sourceById.set(s.id, s); + }); + return { + numberById, + sourceById, + processed: withCitationLinks(text, numberById), + }; + }, [text, sources]); + + const components = useMemo( + () => ({ + a({ href, children, ...props }) { + if (typeof href === "string" && href.startsWith(CITE_HREF_PREFIX)) { + const id = href.slice(CITE_HREF_PREFIX.length); + const num = numberById.get(id) ?? 0; + return ; + } + return ( + + {children} + + ); + }, + }), + [numberById, sourceById], + ); + + return {processed}; +} + +// Provenance footer shown beneath a message that has sources — primarily a +// fallback when the model didn't place inline markers, so retrieved records are +// always attributed. +export function SourcesFooter({ sources }: { sources: SourceData[] }) { + const { t } = useTranslation(); + if (sources.length === 0) return null; + return ( +
+ + {t("chat.sources.title")} + + {sources.map((s, i) => ( + + {i + 1} + {s.title} + + ))} +
+ ); +} diff --git a/frontend/lib/ai-chat.ts b/frontend/lib/ai-chat.ts index ae8d4a7..f3a471d 100644 --- a/frontend/lib/ai-chat.ts +++ b/frontend/lib/ai-chat.ts @@ -43,6 +43,16 @@ export type StepData = { status: "active" | "complete"; }; +// A citable source the agent retrieved (one per display-tool call). The model +// references it inline via a [[src:id]] marker; the client renders that marker +// as an inline citation chip and a sources footer. `title` is the real, +// clinician-facing label; `id` is PHI-free (s1, s2, …). +export type SourceData = { + id: string; + title: string; + kind: string; +}; + // Read-only list cards (display actions). export type AppointmentListData = { appointments: Appointment[] }; export type TaskListData = { tasks: Task[] }; @@ -85,6 +95,7 @@ export type TemetroDataParts = { importPreview: ImportPreviewData; veilNotice: VeilNoticeData; step: StepData; + source: SourceData; appointmentList: AppointmentListData; taskList: TaskListData; prescriptionList: PrescriptionListData; diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index a9f2d18..f2e0660 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -947,6 +947,19 @@ "thinking": "Thinking…", "steps": "Steps", "reasoning": "Reasoning", + "sources": { + "title": "Sources", + "kind": { + "patient": "Patient record", + "lab": "Lab results", + "appointments": "Schedule", + "tasks": "Tasks", + "prescriptions": "Prescriptions", + "clinic": "Clinic", + "analytics": "Analytics", + "inventory": "Inventory" + } + }, "graphCard": { "label": "Record graph" },