mirror of
https://github.com/temetro/temetro.git
synced 2026-08-24 08:46:27 +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,
|
||||
|
||||
Reference in New Issue
Block a user