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,