mirror of
https://github.com/temetro/temetro.git
synced 2026-08-21 07:26:45 +00:00
b29fdff1cb
Record a clinician↔patient visit (or paste a transcript) on the patient
sheet; the backend transcribes it (OpenAI Whisper / Gemini), de-identifies
the transcript + context through Veil, and drafts a structured SOAP note
the clinician reviews and edits before saving — the same write-approval
gate as the chat agent.
Backend: POST /api/scribe/{transcribe,draft,save} (routes/scribe.ts,
services/ai/transcribe.ts), veil.redactText() free-text redactor,
appendEncounter service, audio MIME types on attachments. Gated by
patient:write + the clinic AI policy (reception/disabled-AI excluded).
Frontend: ScribeDialog + lib/scribe.ts, "Record visit" on the patient
detail, gated by clinical access + AI availability. New `scribe` locale
namespace across all five languages. Bumps to v0.4.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
import type { Patient } from "../../types/patient.js";
|
|
import type { VeilLevel } from "../../types/ai.js";
|
|
|
|
// Veil — temetro's PHI de-identification safeguard. When the chat runs against
|
|
// an external cloud model, Veil sits between the patient data and the model:
|
|
//
|
|
// • tool RESULTS are redacted — direct identifiers (name, MRN, provider) are
|
|
// swapped for stable tokens like [PATIENT_1] / [MRN_1] before the model
|
|
// sees them. Clinical values (labs, vitals, problems, meds) pass through —
|
|
// they're what the model needs to reason.
|
|
// • tool ARGUMENTS are resolved — when the model calls a tool with a token
|
|
// (e.g. getPatientLabs("[MRN_1]")) Veil maps it back to the real file
|
|
// number server-side, so the external model never needs the real MRN.
|
|
// • the final OUTPUT is rehydrated — tokens are swapped back to real values
|
|
// before the answer reaches the clinician.
|
|
//
|
|
// Local Ollama mode never leaves the clinic, so Veil is created inactive there
|
|
// (level "off") and every method is a pass-through.
|
|
|
|
type TokenClass = "PATIENT" | "MRN" | "PROVIDER";
|
|
|
|
export type Veil = {
|
|
active: boolean;
|
|
level: VeilLevel;
|
|
/** De-identify a patient record for sending to an external model. */
|
|
redactPatient: (patient: Patient) => Patient;
|
|
/** Map a possibly-tokenized file number from a tool call back to the real one. */
|
|
resolveFileNumber: (input: string) => string;
|
|
/**
|
|
* De-identify free text (e.g. a visit transcript) by swapping any KNOWN
|
|
* identifiers — the patient name, MRN and provider names already seen via
|
|
* redactPatient — for their tokens. Seed the token maps by calling
|
|
* redactPatient(patient) first. Note: this only catches identifiers we know
|
|
* about; free-text PHI spoken aloud (addresses, relatives' names) is not
|
|
* covered — see the ambient-scribe consent notice.
|
|
*/
|
|
redactText: (text: string) => string;
|
|
/** Swap any tokens in model output back to real identifiers. */
|
|
rehydrate: (text: string) => string;
|
|
/** Token classes actually emitted — for the audit log. */
|
|
usedClasses: () => TokenClass[];
|
|
};
|
|
|
|
export function createVeil(level: VeilLevel, active: boolean): Veil {
|
|
// Real value → token, and token → real value, plus a reverse map keyed by
|
|
// token for fast file-number resolution.
|
|
const toToken = new Map<string, string>();
|
|
const fromToken = new Map<string, string>();
|
|
const mrnByToken = new Map<string, string>();
|
|
const counters: Record<TokenClass, number> = {
|
|
PATIENT: 0,
|
|
MRN: 0,
|
|
PROVIDER: 0,
|
|
};
|
|
|
|
function tokenFor(cls: TokenClass, value: string): string {
|
|
const key = `${cls}:${value}`;
|
|
const existing = toToken.get(key);
|
|
if (existing) return existing;
|
|
counters[cls] += 1;
|
|
const token = `[${cls}_${counters[cls]}]`;
|
|
toToken.set(key, token);
|
|
fromToken.set(token, value);
|
|
if (cls === "MRN") mrnByToken.set(token, value);
|
|
return token;
|
|
}
|
|
|
|
const isActive = active && level !== "off";
|
|
|
|
function redactPatient(patient: Patient): Patient {
|
|
if (!isActive) return patient;
|
|
const provider = patient.pcp
|
|
? tokenFor("PROVIDER", patient.pcp)
|
|
: patient.pcp;
|
|
return {
|
|
...patient,
|
|
name: tokenFor("PATIENT", patient.name),
|
|
fileNumber: tokenFor("MRN", patient.fileNumber),
|
|
initials: "··",
|
|
pcp: provider,
|
|
encounters: patient.encounters.map((e) => ({
|
|
...e,
|
|
provider: e.provider ? tokenFor("PROVIDER", e.provider) : e.provider,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function resolveFileNumber(input: string): string {
|
|
if (!isActive) return input;
|
|
return mrnByToken.get(input.trim()) ?? input;
|
|
}
|
|
|
|
function redactText(text: string): string {
|
|
if (!isActive || fromToken.size === 0) return text;
|
|
let out = text;
|
|
// Longest real values first so a provider name containing the patient name
|
|
// (or similar overlap) is replaced whole before its substrings.
|
|
const byLength = [...fromToken.entries()]
|
|
.filter(([, real]) => real.trim().length > 0)
|
|
.sort((a, b) => b[1].length - a[1].length);
|
|
for (const [token, real] of byLength) {
|
|
const escaped = real.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
out = out.replace(new RegExp(escaped, "gi"), token);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function rehydrate(text: string): string {
|
|
if (!isActive || fromToken.size === 0) return text;
|
|
let out = text;
|
|
for (const [token, real] of fromToken) {
|
|
out = out.split(token).join(real);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function usedClasses(): TokenClass[] {
|
|
return (Object.keys(counters) as TokenClass[]).filter(
|
|
(c) => counters[c] > 0,
|
|
);
|
|
}
|
|
|
|
return {
|
|
active: isActive,
|
|
level,
|
|
redactPatient,
|
|
resolveFileNumber,
|
|
redactText,
|
|
rehydrate,
|
|
usedClasses,
|
|
};
|
|
}
|