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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-19 20:41:48 +03:00
parent 2f36875d37
commit 0fa2802723
6 changed files with 216 additions and 11 deletions
+7
View File
@@ -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.`
: "",
+35 -6
View File
@@ -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,
+21 -5
View File
@@ -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 (
<Message from={message.role} key={message.id}>
<MessageContent className="w-full">
@@ -543,7 +553,7 @@ export function ChatPanel() {
{part.text}
</span>
) : (
<MessageResponse key={key}>{part.text}</MessageResponse>
<CitedResponse key={key} sources={sources} 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 && (
<SourcesFooter sources={sources} />
)}
</MessageContent>
</Message>
);
@@ -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:<id>]] marker. We turn
// each marker into a hash link ([n](#cite-<id>)) — 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, number>,
): 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 (
<PreviewCard>
<PreviewCardTrigger
render={
<Badge
className="mx-0.5 cursor-default rounded-full px-1.5 align-super text-[10px] leading-none no-underline"
variant="secondary"
/>
}
>
{num}
</PreviewCardTrigger>
<PreviewCardPopup className="w-64 p-3">
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{kindLabel === kindKey ? t("chat.sources.title") : kindLabel}
</p>
<p className="mt-0.5 text-foreground text-sm">{source.title}</p>
</PreviewCardPopup>
</PreviewCard>
);
}
// 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<string, number>();
const sourceById = new Map<string, SourceData>();
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<Components>(
() => ({
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 <CitationChip num={num} source={sourceById.get(id)} />;
}
return (
<a href={href} rel="noreferrer" target="_blank" {...props}>
{children}
</a>
);
},
}),
[numberById, sourceById],
);
return <MessageResponse components={components}>{processed}</MessageResponse>;
}
// 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 (
<div className="mt-1 flex flex-wrap items-center gap-1.5 border-border/60 border-t pt-2">
<span className="text-muted-foreground text-xs">
{t("chat.sources.title")}
</span>
{sources.map((s, i) => (
<Badge className="gap-1 rounded-full font-normal" key={s.id} variant="outline">
<span className="text-muted-foreground">{i + 1}</span>
<span className="max-w-48 truncate">{s.title}</span>
</Badge>
))}
</div>
);
}
+11
View File
@@ -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;
@@ -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"
},