mirror of
https://github.com/temetro/temetro.git
synced 2026-08-08 01:33:12 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user