mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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.`
|
||||
: "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user