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
@@ -7,6 +7,7 @@ import { AiBadge } from "@/components/ai-badge";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { RecordGraph } from "@/components/graph/record-graph";
import { PatientDetail } from "@/components/patients/patient-detail";
import { ScribeDialog } from "@/components/patients/scribe-dialog";
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
@@ -28,6 +29,7 @@ import { type Appointment, listAppointments } from "@/lib/appointments";
import { type Invoice, listInvoices } from "@/lib/invoices";
import { deletePatient, getPatient, type Patient } from "@/lib/patients";
import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
import { useAiAccess } from "@/lib/ai-policy";
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
@@ -76,9 +78,14 @@ export function PatientDetailSheet({
// Deleting a chart is destructive — only offer it once we know the role is
// a full clinician (patient:delete), never optimistically.
const canDelete = role != null && hasClinicalAccess(role);
// The ambient scribe writes a clinical note, so it needs full clinical write
// access AND the clinic's AI must be enabled for this member.
const { allowed: aiAllowed } = useAiAccess();
const canScribe = role != null && hasClinicalAccess(role) && aiAllowed;
const [patient, setPatient] = useState<Patient | null>(null);
const [status, setStatus] = useState<Status>("loading");
const [editOpen, setEditOpen] = useState(false);
const [scribeOpen, setScribeOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
// Graph popped out of the sheet into its own dialog (the sheet closes first).
@@ -171,6 +178,7 @@ export function PatientDetailSheet({
setEditKey((k) => k + 1);
setEditOpen(true);
}}
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
onOpenGraph={() => {
onOpenChange(false);
setGraphOpen(true);
@@ -197,6 +205,15 @@ export function PatientDetailSheet({
/>
)}
{patient && (
<ScribeDialog
onOpenChange={setScribeOpen}
onSaved={(updated) => setPatient(updated)}
open={scribeOpen}
patient={patient}
/>
)}
{patient && (
<TransferPatientDialog
onOpenChange={setTransferOpen}
@@ -1,6 +1,13 @@
"use client";
import { ArrowLeftRight, FileDown, Network, Pencil, Trash2 } from "lucide-react";
import {
ArrowLeftRight,
FileDown,
Mic,
Network,
Pencil,
Trash2,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -173,6 +180,7 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
export function PatientDetail({
patient,
onEdit,
onScribe,
onTransfer,
onDelete,
onOpenGraph,
@@ -182,6 +190,8 @@ export function PatientDetail({
}: {
patient: Patient;
onEdit?: () => void;
// Opens the ambient AI visit scribe (record/transcribe → draft note).
onScribe?: () => void;
onTransfer?: () => void;
onDelete?: () => void;
// Pops the record graph out into its own dialog (closing this sheet).
@@ -287,6 +297,12 @@ export function PatientDetail({
{t("patients.transfer.action")}
</Button>
)}
{onScribe && (
<Button onClick={onScribe} size="sm" type="button" variant="outline">
<Mic className="size-4" />
{t("scribe.recordVisit")}
</Button>
)}
{onEdit && (
<Button onClick={onEdit} size="sm" type="button" variant="outline">
<Pencil className="size-4" />
@@ -0,0 +1,436 @@
"use client";
import { Mic, Square, Sparkles, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import {
Tabs,
TabsList,
TabsPanel,
TabsTab,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { uploadAttachment } from "@/lib/attachments";
import type { Encounter, Patient } from "@/lib/patients";
import { draftNote, saveNote, transcribeRecording } from "@/lib/scribe";
import { notify } from "@/lib/toast";
import { ApiError } from "@/lib/api-client";
type Phase = "input" | "processing" | "review";
type InputTab = "record" | "paste";
type RecState = "idle" | "recording" | "recorded";
// Pick a supported audio mime for MediaRecorder (Opus in WebM/OGG is tiny for
// speech; Safari falls back to mp4). Returns "" to let the browser choose.
function pickAudioMime(): string {
if (typeof MediaRecorder === "undefined") return "";
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/mp4",
];
return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
}
function fmtElapsed(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
// The ambient AI scribe: record or paste a visit conversation, draft a SOAP
// encounter note, review it, and append it to the patient record.
export function ScribeDialog({
patient,
open,
onOpenChange,
onSaved,
}: {
patient: Patient;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: (updated: Patient) => void;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("input");
const [tab, setTab] = useState<InputTab>("record");
const [recState, setRecState] = useState<RecState>("idle");
const [elapsed, setElapsed] = useState(0);
const [transcript, setTranscript] = useState("");
const [visitType, setVisitType] = useState("");
const [draft, setDraft] = useState<Encounter | null>(null);
const [veilNote, setVeilNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const mediaRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const blobRef = useRef<Blob | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Tear down any live recording + object state when the dialog closes.
const stopTracks = () => {
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
mediaRef.current = null;
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
// Stop tracks on unmount.
useEffect(() => () => stopTracks(), []);
// Reset all state for the next open. Called on every close (the parent only
// ever closes this dialog through our onOpenChange), so no reset-in-effect.
const reset = () => {
setPhase("input");
setTab("record");
setRecState("idle");
setElapsed(0);
setTranscript("");
setVisitType("");
setDraft(null);
setVeilNote(null);
setError(null);
chunksRef.current = [];
blobRef.current = null;
};
const handleOpenChange = (next: boolean) => {
if (!next) {
stopTracks();
reset();
}
onOpenChange(next);
};
const startRecording = async () => {
setError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = pickAudioMime();
const recorder = new MediaRecorder(
stream,
mime ? { mimeType: mime } : undefined,
);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
blobRef.current = new Blob(chunksRef.current, {
type: recorder.mimeType || "audio/webm",
});
setRecState("recorded");
};
recorder.start();
mediaRef.current = recorder;
setRecState("recording");
setElapsed(0);
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
} catch {
setError(t("scribe.errors.mic"));
}
};
const stopRecording = () => {
mediaRef.current?.stop();
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
const discardRecording = () => {
blobRef.current = null;
chunksRef.current = [];
setRecState("idle");
setElapsed(0);
};
const filenameFor = (blob: Blob): string => {
const ext = blob.type.includes("mp4")
? "m4a"
: blob.type.includes("ogg")
? "ogg"
: "webm";
return `visit-${patient.fileNumber}-${Date.now()}.${ext}`;
};
const generate = async () => {
setError(null);
setPhase("processing");
try {
let text = transcript.trim();
if (tab === "record") {
const blob = blobRef.current;
if (!blob) {
setError(t("scribe.errors.noRecording"));
setPhase("input");
return;
}
// Store the recording as a patient attachment (auditable), then
// transcribe it server-side.
const file = new File([blob], filenameFor(blob), { type: blob.type });
const attachment = await uploadAttachment({
file,
fileNumber: patient.fileNumber,
labKey: "scribe",
});
const res = await transcribeRecording(attachment.id);
text = res.transcript.trim();
setTranscript(text);
}
if (!text) {
setError(t("scribe.errors.empty"));
setPhase("input");
return;
}
const { draft: note, veil } = await draftNote({
fileNumber: patient.fileNumber,
transcript: text,
visitType: visitType.trim() || undefined,
});
setDraft(note);
setVeilNote(
veil.active ? t("scribe.review.veil", { provider: veil.provider }) : null,
);
setPhase("review");
} catch (err) {
setError(
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
);
setPhase("input");
}
};
const save = async () => {
if (!draft) return;
setPhase("processing");
try {
const updated = await saveNote(patient.fileNumber, draft);
notify.success(t("scribe.saved.title"), patient.name);
onSaved(updated);
handleOpenChange(false);
} catch (err) {
setError(
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
);
setPhase("review");
}
};
const busy = phase === "processing";
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
{t("scribe.title")}
</DialogTitle>
<DialogDescription>
{t("scribe.subtitle", { name: patient.name })}
</DialogDescription>
</DialogHeader>
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
{phase === "review" && draft ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-type">{t("scribe.review.type")}</Label>
<Input
id="scribe-type"
onChange={(e) =>
setDraft({ ...draft, type: e.target.value })
}
value={draft.type}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-date">{t("scribe.review.date")}</Label>
<Input
id="scribe-date"
onChange={(e) =>
setDraft({ ...draft, date: e.target.value })
}
type="date"
value={draft.date}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-summary">
{t("scribe.review.summary")}
</Label>
<Textarea
className="min-h-56"
id="scribe-summary"
onChange={(e) =>
setDraft({ ...draft, summary: e.target.value })
}
value={draft.summary}
/>
</div>
<p className="text-muted-foreground text-xs">
{t("scribe.review.provider", { provider: draft.provider })}
</p>
{veilNote && (
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
{veilNote}
</p>
)}
</div>
) : (
<Tabs
onValueChange={(v) => setTab(v as InputTab)}
value={tab}
>
<TabsList className="w-full">
<TabsTab value="record">
<Mic className="size-4" />
{t("scribe.tabs.record")}
</TabsTab>
<TabsTab value="paste">{t("scribe.tabs.paste")}</TabsTab>
</TabsList>
<TabsPanel className="pt-3" value="record">
<div className="flex flex-col items-center gap-4 py-4">
{recState === "recording" ? (
<>
<div className="flex items-center gap-2 text-destructive">
<span className="size-2.5 animate-pulse rounded-full bg-destructive" />
<span className="font-mono text-lg tabular-nums">
{fmtElapsed(elapsed)}
</span>
</div>
<Button
onClick={stopRecording}
type="button"
variant="destructive"
>
<Square className="size-4" />
{t("scribe.record.stop")}
</Button>
</>
) : recState === "recorded" ? (
<>
<p className="text-foreground text-sm">
{t("scribe.record.ready", {
duration: fmtElapsed(elapsed),
})}
</p>
<Button
onClick={discardRecording}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
{t("scribe.record.discard")}
</Button>
</>
) : (
<Button onClick={startRecording} type="button">
<Mic className="size-4" />
{t("scribe.record.start")}
</Button>
)}
</div>
</TabsPanel>
<TabsPanel className="pt-3" value="paste">
<Textarea
className="min-h-40"
onChange={(e) => setTranscript(e.target.value)}
placeholder={t("scribe.paste.placeholder")}
value={transcript}
/>
</TabsPanel>
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-visit-type">
{t("scribe.visitType.label")}
</Label>
<Input
id="scribe-visit-type"
onChange={(e) => setVisitType(e.target.value)}
placeholder={t("scribe.visitType.placeholder")}
value={visitType}
/>
</div>
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
{t("scribe.consent")}
</p>
</div>
</Tabs>
)}
{error && (
<p className="mt-3 text-destructive text-sm" role="alert">
{error}
</p>
)}
</DialogPanel>
<DialogFooter>
{phase === "review" ? (
<>
<Button
disabled={busy}
onClick={() => setPhase("input")}
type="button"
variant="outline"
>
{t("scribe.review.back")}
</Button>
<Button disabled={busy} onClick={save} type="button">
{busy && <Spinner className="size-4" />}
{t("scribe.review.save")}
</Button>
</>
) : (
<>
<Button
disabled={busy}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
{t("scribe.cancel")}
</Button>
<Button
disabled={
busy ||
(tab === "record"
? recState !== "recorded"
: transcript.trim().length === 0)
}
onClick={generate}
type="button"
>
{busy && <Spinner className="size-4" />}
{busy ? t("scribe.processing") : t("scribe.generate")}
</Button>
</>
)}
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -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 }),
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "next dev",