feat: ambient AI visit scribe (record/paste → reviewed SOAP note)

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>
This commit is contained in:
Khalid Abdi
2026-07-03 18:33:30 +03:00
parent d237504af9
commit b29fdff1cb
19 changed files with 1207 additions and 4 deletions
@@ -1,4 +1,48 @@
{
"scribe": {
"recordVisit": "تسجيل الزيارة",
"title": "كاتب الزيارة",
"subtitle": "سجّل أو الصق زيارة لـ {{name}}، ثم راجع الملاحظة المُصاغة قبل الحفظ.",
"tabs": {
"record": "تسجيل",
"paste": "لصق النص"
},
"record": {
"start": "بدء التسجيل",
"stop": "إيقاف",
"ready": "التسجيل جاهز ({{duration}})",
"discard": "تجاهل"
},
"paste": {
"placeholder": "الصق نص الزيارة هنا…"
},
"visitType": {
"label": "نوع الزيارة (اختياري)",
"placeholder": "مثال: متابعة"
},
"consent": "يُخزَّن الصوت في ملف المريض. عند استخدام مزوّد ذكاء اصطناعي خارجي، يغادر التسجيل العيادة ليُفرَّغ نصيًّا — لا يستطيع Veil إخفاء الكلام، لذا تُخفى هوية الملاحظة المُصاغة فقط.",
"generate": "صياغة الملاحظة",
"processing": "جارٍ العمل…",
"cancel": "إلغاء",
"review": {
"type": "نوع الزيارة",
"date": "التاريخ",
"summary": "الملاحظة",
"provider": "الطبيب: {{provider}}",
"veil": "أُخفيت الهوية عبر Veil قبل {{provider}}.",
"back": "رجوع",
"save": "الحفظ في السجل"
},
"saved": {
"title": "تم حفظ ملاحظة الزيارة"
},
"errors": {
"mic": "تعذّر الوصول إلى الميكروفون. تحقّق من أذونات المتصفح أو الصق نصًّا بدلاً من ذلك.",
"noRecording": "سجّل الزيارة أولاً، أو انتقل إلى تبويب اللصق.",
"empty": "النص فارغ.",
"generic": "حدث خطأ ما. يُرجى المحاولة مرة أخرى."
}
},
"common": {
"appName": "temetro",
"email": "البريد الإلكتروني",
@@ -1,4 +1,48 @@
{
"scribe": {
"recordVisit": "Besuch aufnehmen",
"title": "Besuchs-Schreiber",
"subtitle": "Nehmen Sie einen Besuch für {{name}} auf oder fügen Sie ihn ein und prüfen Sie die entworfene Notiz vor dem Speichern.",
"tabs": {
"record": "Aufnehmen",
"paste": "Transkript einfügen"
},
"record": {
"start": "Aufnahme starten",
"stop": "Stopp",
"ready": "Aufnahme bereit ({{duration}})",
"discard": "Verwerfen"
},
"paste": {
"placeholder": "Besuchstranskript hier einfügen…"
},
"visitType": {
"label": "Besuchsart (optional)",
"placeholder": "z. B. Nachsorge"
},
"consent": "Die Audioaufnahme wird in der Patientenakte gespeichert. Bei Nutzung eines externen KI-Anbieters verlässt die Aufnahme zur Transkription die Klinik — Veil kann Sprache nicht schwärzen, daher wird nur die entworfene Notiz anonymisiert.",
"generate": "Notiz entwerfen",
"processing": "In Arbeit…",
"cancel": "Abbrechen",
"review": {
"type": "Besuchsart",
"date": "Datum",
"summary": "Notiz",
"provider": "Behandler: {{provider}}",
"veil": "Über Veil anonymisiert vor {{provider}}.",
"back": "Zurück",
"save": "In Akte speichern"
},
"saved": {
"title": "Besuchsnotiz gespeichert"
},
"errors": {
"mic": "Kein Zugriff auf das Mikrofon. Prüfen Sie die Browser-Berechtigungen oder fügen Sie ein Transkript ein.",
"noRecording": "Nehmen Sie zuerst den Besuch auf oder wechseln Sie zum Einfügen-Tab.",
"empty": "Das Transkript ist leer.",
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
}
},
"common": {
"appName": "temetro",
"email": "E-Mail",
@@ -1,4 +1,48 @@
{
"scribe": {
"recordVisit": "Record visit",
"title": "Visit scribe",
"subtitle": "Record or paste a visit for {{name}}, then review the drafted note before saving.",
"tabs": {
"record": "Record",
"paste": "Paste transcript"
},
"record": {
"start": "Start recording",
"stop": "Stop",
"ready": "Recording ready ({{duration}})",
"discard": "Discard"
},
"paste": {
"placeholder": "Paste the visit transcript here…"
},
"visitType": {
"label": "Visit type (optional)",
"placeholder": "e.g. Follow-up"
},
"consent": "The audio is stored on the patient's chart. When you use an external AI provider, the recording leaves the clinic to be transcribed — Veil cannot redact speech, so only the drafted note is de-identified.",
"generate": "Draft note",
"processing": "Working…",
"cancel": "Cancel",
"review": {
"type": "Visit type",
"date": "Date",
"summary": "Note",
"provider": "Provider: {{provider}}",
"veil": "De-identified through Veil before {{provider}}.",
"back": "Back",
"save": "Save to record"
},
"saved": {
"title": "Visit note saved"
},
"errors": {
"mic": "Couldn't access the microphone. Check browser permissions or paste a transcript instead.",
"noRecording": "Record the visit first, or switch to the paste tab.",
"empty": "The transcript is empty.",
"generic": "Something went wrong. Please try again."
}
},
"common": {
"appName": "temetro",
"email": "Email",
@@ -1,4 +1,48 @@
{
"scribe": {
"recordVisit": "Enregistrer la visite",
"title": "Scribe de visite",
"subtitle": "Enregistrez ou collez une visite pour {{name}}, puis vérifiez la note rédigée avant de l'enregistrer.",
"tabs": {
"record": "Enregistrer",
"paste": "Coller la transcription"
},
"record": {
"start": "Démarrer l'enregistrement",
"stop": "Arrêter",
"ready": "Enregistrement prêt ({{duration}})",
"discard": "Supprimer"
},
"paste": {
"placeholder": "Collez la transcription de la visite ici…"
},
"visitType": {
"label": "Type de visite (facultatif)",
"placeholder": "ex. Suivi"
},
"consent": "L'audio est enregistré dans le dossier du patient. Si vous utilisez un fournisseur d'IA externe, l'enregistrement quitte la clinique pour être transcrit — Veil ne peut pas expurger la parole, seule la note rédigée est dépersonnalisée.",
"generate": "Rédiger la note",
"processing": "En cours…",
"cancel": "Annuler",
"review": {
"type": "Type de visite",
"date": "Date",
"summary": "Note",
"provider": "Praticien : {{provider}}",
"veil": "Dépersonnalisé via Veil avant {{provider}}.",
"back": "Retour",
"save": "Enregistrer dans le dossier"
},
"saved": {
"title": "Note de visite enregistrée"
},
"errors": {
"mic": "Impossible d'accéder au microphone. Vérifiez les autorisations du navigateur ou collez une transcription.",
"noRecording": "Enregistrez d'abord la visite, ou passez à l'onglet coller.",
"empty": "La transcription est vide.",
"generic": "Une erreur s'est produite. Veuillez réessayer."
}
},
"common": {
"appName": "temetro",
"email": "E-mail",
@@ -1,4 +1,48 @@
{
"scribe": {
"recordVisit": "Duub booqasho",
"title": "Qoraaga booqashada",
"subtitle": "Duub ama ku dhaji booqasho {{name}}, ka dibna dib u eeg qoraalka la sameeyay ka hor inta aadan keydin.",
"tabs": {
"record": "Duub",
"paste": "Ku dhaji qoraalka"
},
"record": {
"start": "Bilow duubista",
"stop": "Jooji",
"ready": "Duubista diyaar ({{duration}})",
"discard": "Tirtir"
},
"paste": {
"placeholder": "Halkan ku dhaji qoraalka booqashada…"
},
"visitType": {
"label": "Nooca booqashada (ikhtiyaari)",
"placeholder": "tusaale: Dib-u-eegis"
},
"consent": "Codka waxaa lagu keydiyaa faylka bukaanka. Marka aad isticmaasho bixiye AI dibadeed, duubistu waxay ka baxdaa rugta si loo qoro — Veil ma tirtiri karo hadalka, sidaas darteed kaliya qoraalka la sameeyay ayaa la qariyaa.",
"generate": "Samee qoraalka",
"processing": "Waa la shaqaynayaa…",
"cancel": "Jooji",
"review": {
"type": "Nooca booqashada",
"date": "Taariikhda",
"summary": "Qoraalka",
"provider": "Bixiye: {{provider}}",
"veil": "Waxaa lagu qariyay Veil ka hor {{provider}}.",
"back": "Dib u noqo",
"save": "Ku keydi diiwaanka"
},
"saved": {
"title": "Qoraalka booqashada waa la keydiyay"
},
"errors": {
"mic": "Lama gaari karin makarafoonka. Hubi oggolaanshaha browserka ama ku dhaji qoraal.",
"noRecording": "Marka hore duub booqashada, ama u wareeg tabka ku-dhajinta.",
"empty": "Qoraalku waa madhan yahay.",
"generic": "Wax baa qaldamay. Fadlan isku day mar kale."
}
},
"common": {
"appName": "temetro",
"email": "Iimayl",
+51
View File
@@ -0,0 +1,51 @@
// Client for the ambient AI visit-scribe API. The dialog records (or accepts a
// pasted transcript of) a clinician↔patient visit, transcribes it, drafts a
// structured encounter note, and — after the clinician reviews it — appends the
// note to the patient record (the same write-approval gate as the chat agent).
import { apiFetch } from "@/lib/api-client";
import type { Encounter, Patient } from "@/lib/patients";
export type ScribeVeil = {
active: boolean;
level: string;
classes: string[];
provider: string;
};
// Turn a stored audio attachment into a raw transcript (server streams it to the
// user's OpenAI/Gemini speech provider). Audio does NOT pass through Veil.
export function transcribeRecording(
attachmentId: string,
): Promise<{ transcript: string }> {
return apiFetch<{ transcript: string }>("/api/scribe/transcribe", {
method: "POST",
body: JSON.stringify({ attachmentId }),
});
}
// Draft an encounter note from a transcript. The transcript + patient context
// are de-identified through Veil before any external call; the draft is never
// saved automatically.
export function draftNote(input: {
fileNumber: string;
transcript: string;
visitType?: string;
date?: string;
}): Promise<{ draft: Encounter; veil: ScribeVeil }> {
return apiFetch<{ draft: Encounter; veil: ScribeVeil }>("/api/scribe/draft", {
method: "POST",
body: JSON.stringify(input),
});
}
// The approval step: append the reviewed note to the patient record.
export function saveNote(
fileNumber: string,
encounter: Encounter,
): Promise<Patient> {
return apiFetch<Patient>("/api/scribe/save", {
method: "POST",
body: JSON.stringify({ fileNumber, encounter }),
});
}