mirror of
https://github.com/temetro/temetro.git
synced 2026-08-20 15:12:18 +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,22 @@ for how releases are cut and published.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] — 2026-07-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Ambient AI visit scribe** — a **Record visit** action on the patient sheet turns a
|
||||||
|
clinician↔patient conversation into a draft **SOAP** encounter note. Record with the
|
||||||
|
microphone (`MediaRecorder`, stored as an auditable patient attachment) or paste a
|
||||||
|
transcript; the backend transcribes via the user's **OpenAI (Whisper)** or **Gemini**
|
||||||
|
key, de-identifies the transcript + patient context through **Veil**, and the model
|
||||||
|
drafts a structured note that the clinician **reviews and edits before saving** — the
|
||||||
|
same write-approval gate as the chat agent. New `POST /api/scribe/{transcribe,draft,save}`
|
||||||
|
(`backend/src/routes/scribe.ts`, `services/ai/transcribe.ts`), a `veil.redactText()`
|
||||||
|
free-text redactor, and an `appendEncounter` service that adds one note without touching
|
||||||
|
the rest of the record. Gated by `patient:write` + the clinic AI policy (reception and
|
||||||
|
disabled-AI accounts don't see it). New `scribe` locale namespace across all five
|
||||||
|
languages. Drafting also works with local Ollama from a pasted transcript.
|
||||||
|
|
||||||
## [0.3.0] — 2026-07-02
|
## [0.3.0] — 2026-07-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temetro-backend",
|
"name": "temetro-backend",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { patientsRouter } from "./routes/patients.js";
|
|||||||
import { patientsWalletRouter } from "./routes/patients-wallet.js";
|
import { patientsWalletRouter } from "./routes/patients-wallet.js";
|
||||||
import { portalRouter } from "./routes/portal.js";
|
import { portalRouter } from "./routes/portal.js";
|
||||||
import { prescriptionsRouter } from "./routes/prescriptions.js";
|
import { prescriptionsRouter } from "./routes/prescriptions.js";
|
||||||
|
import { scribeRouter } from "./routes/scribe.js";
|
||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { signingRouter } from "./routes/signing.js";
|
import { signingRouter } from "./routes/signing.js";
|
||||||
import { staffRouter } from "./routes/staff.js";
|
import { staffRouter } from "./routes/staff.js";
|
||||||
@@ -104,6 +105,7 @@ app.use("/api/notifications", notificationsRouter);
|
|||||||
app.use("/api/settings", settingsRouter);
|
app.use("/api/settings", settingsRouter);
|
||||||
app.use("/api/ai", aiRouter);
|
app.use("/api/ai", aiRouter);
|
||||||
app.use("/api/chat", chatRouter);
|
app.use("/api/chat", chatRouter);
|
||||||
|
app.use("/api/scribe", scribeRouter);
|
||||||
app.use("/api/integrations", integrationsRouter);
|
app.use("/api/integrations", integrationsRouter);
|
||||||
app.use("/api/portal", portalRouter);
|
app.use("/api/portal", portalRouter);
|
||||||
app.use("/api/auth-helpers", authHelpersRouter);
|
app.use("/api/auth-helpers", authHelpersRouter);
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ const ALLOWED_MIME = new Set([
|
|||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
"application/vnd.ms-excel",
|
"application/vnd.ms-excel",
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
// Ambient visit-scribe recordings (stored as a patient attachment so they're
|
||||||
|
// auditable). Voice Opus/AAC stays well under the 15 MB cap for a long visit.
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/x-m4a",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Disk storage under UPLOAD_DIR/<orgId>/, keyed by a random id so original
|
// Disk storage under UPLOAD_DIR/<orgId>/, keyed by a random id so original
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { generateText } from "ai";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { HttpError } from "../lib/http-error.js";
|
||||||
|
import { isReceptionOnly, providerScope } from "../lib/role-scope.js";
|
||||||
|
import {
|
||||||
|
requireAuth,
|
||||||
|
requireOrg,
|
||||||
|
requirePermission,
|
||||||
|
} from "../middleware/auth.js";
|
||||||
|
import { recordActivity } from "../services/activity.js";
|
||||||
|
import { getAiSettings } from "../services/ai/config.js";
|
||||||
|
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
|
||||||
|
import { resolveModel } from "../services/ai/provider.js";
|
||||||
|
import { transcribeAudio } from "../services/ai/transcribe.js";
|
||||||
|
import { createVeil } from "../services/ai/veil.js";
|
||||||
|
import { absolutePath, getAttachmentRow } from "../services/attachments.js";
|
||||||
|
import { appendEncounter, getPatient } from "../services/patients.js";
|
||||||
|
import type { Encounter } from "../types/patient.js";
|
||||||
|
|
||||||
|
export const scribeRouter = Router();
|
||||||
|
|
||||||
|
// The ambient scribe drafts and saves a clinical encounter note, so it needs
|
||||||
|
// full clinical write access — never reception (demographics only).
|
||||||
|
scribeRouter.use(
|
||||||
|
requireAuth,
|
||||||
|
requireOrg,
|
||||||
|
requirePermission({ patient: ["write"] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Guard shared by every scribe route: the clinic AI kill-switch must allow this
|
||||||
|
// member, and reception (demographics-only) can never draft clinical notes.
|
||||||
|
async function ensureScribeAllowed(req: {
|
||||||
|
organizationId?: string;
|
||||||
|
memberRole?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (isReceptionOnly(req.memberRole)) {
|
||||||
|
throw new HttpError(403, "The visit scribe is not available for your role.");
|
||||||
|
}
|
||||||
|
const policy = await getPolicy(req.organizationId!);
|
||||||
|
if (!aiAllowedFor(policy, req.memberRole)) {
|
||||||
|
throw new HttpError(403, "The AI assistant is disabled for your account.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcribeSchema = z.object({
|
||||||
|
attachmentId: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/scribe/transcribe — turn a stored audio attachment into a raw
|
||||||
|
// transcript via the user's speech provider (OpenAI/Gemini). The audio does NOT
|
||||||
|
// pass through Veil or the chat loop.
|
||||||
|
scribeRouter.post("/transcribe", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await ensureScribeAllowed(req);
|
||||||
|
const { attachmentId } = transcribeSchema.parse(req.body);
|
||||||
|
const row = await getAttachmentRow(req.organizationId!, attachmentId);
|
||||||
|
if (!row) throw new HttpError(404, "Recording not found.");
|
||||||
|
if (!row.mimeType.startsWith("audio/")) {
|
||||||
|
throw new HttpError(400, "That attachment is not an audio recording.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getAiSettings(req.user!.id);
|
||||||
|
const buffer = await readFile(absolutePath(row.storagePath));
|
||||||
|
const { transcript, provider } = await transcribeAudio(settings, {
|
||||||
|
buffer,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
filename: row.filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
void recordActivity({
|
||||||
|
orgId: req.organizationId!,
|
||||||
|
actor: { id: req.user!.id, name: req.user!.name },
|
||||||
|
action: `Transcribed a visit recording (${provider})`,
|
||||||
|
entityType: "patient",
|
||||||
|
patientFileNumber: row.fileNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ transcript });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftSchema = z.object({
|
||||||
|
fileNumber: z.string().trim().min(1),
|
||||||
|
transcript: z.string().trim().min(1).max(100_000),
|
||||||
|
visitType: z.string().trim().max(120).optional(),
|
||||||
|
date: z.string().trim().max(40).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strip ```json fences and parse; returns null if the text isn't JSON.
|
||||||
|
function parseDraftJson(text: string): { type?: string; summary?: string } | null {
|
||||||
|
const cleaned = text
|
||||||
|
.replace(/^\s*```(?:json)?/i, "")
|
||||||
|
.replace(/```\s*$/i, "")
|
||||||
|
.trim();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cleaned);
|
||||||
|
return typeof parsed === "object" && parsed ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAFT_SYSTEM = [
|
||||||
|
"You are a clinical scribe. From a visit transcript, write a concise, structured",
|
||||||
|
"encounter note in SOAP format (Subjective, Objective, Assessment, Plan).",
|
||||||
|
"Rules:",
|
||||||
|
"- Use ONLY what the transcript supports. Never invent vitals, doses, or findings.",
|
||||||
|
"- If a SOAP section has nothing to report, write \"Not discussed.\" under it.",
|
||||||
|
"- Identifiers may appear as tokens like [PATIENT_1] or [PROVIDER_1]; keep them",
|
||||||
|
" verbatim — do not guess real names.",
|
||||||
|
"Respond with a JSON object ONLY (no prose, no code fences):",
|
||||||
|
'{ "type": "<short visit type, e.g. Follow-up / New patient / Telehealth>",',
|
||||||
|
' "summary": "<the SOAP note as markdown with **Subjective** / **Objective** /',
|
||||||
|
' **Assessment** / **Plan** headings>" }',
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// POST /api/scribe/draft — draft an encounter note from a transcript. The
|
||||||
|
// transcript + patient context are Veil-redacted before any external call and
|
||||||
|
// the output is rehydrated. Nothing is written — the clinician reviews and
|
||||||
|
// approves via POST /save (the same write-approval gate as the chat agent).
|
||||||
|
scribeRouter.post("/draft", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await ensureScribeAllowed(req);
|
||||||
|
const { fileNumber, transcript, visitType, date } = draftSchema.parse(
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const patient = await getPatient(
|
||||||
|
req.organizationId!,
|
||||||
|
fileNumber,
|
||||||
|
false,
|
||||||
|
providerScope(req.memberRole, req.user!.id),
|
||||||
|
);
|
||||||
|
if (!patient) throw new HttpError(404, "Patient not found.");
|
||||||
|
|
||||||
|
const settings = await getAiSettings(req.user!.id);
|
||||||
|
const resolved = resolveModel(settings, settings.defaultModel);
|
||||||
|
const veil = createVeil(settings.veilLevel, resolved.isExternal);
|
||||||
|
|
||||||
|
// Seed Veil's token maps with this patient's identifiers, then redact the
|
||||||
|
// free-text transcript against them before it leaves the clinic.
|
||||||
|
const redactedPatient = veil.redactPatient(patient);
|
||||||
|
const redactedTranscript = veil.redactText(transcript);
|
||||||
|
|
||||||
|
const context = [
|
||||||
|
`Patient: ${redactedPatient.name} (MRN ${redactedPatient.fileNumber}), ${patient.age}y ${patient.sex}.`,
|
||||||
|
patient.problems.length
|
||||||
|
? `Known problems: ${patient.problems.map((p) => p.label).join(", ")}.`
|
||||||
|
: "",
|
||||||
|
patient.medications.length
|
||||||
|
? `Current medications: ${patient.medications
|
||||||
|
.map((m) => `${m.name} ${m.dose}`)
|
||||||
|
.join(", ")}.`
|
||||||
|
: "",
|
||||||
|
visitType ? `Visit type hint: ${visitType}.` : "",
|
||||||
|
"",
|
||||||
|
"Transcript:",
|
||||||
|
redactedTranscript,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model: resolved.model,
|
||||||
|
system: DRAFT_SYSTEM,
|
||||||
|
prompt: context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = parseDraftJson(result.text);
|
||||||
|
const summary = veil.rehydrate(
|
||||||
|
(parsed?.summary ?? result.text ?? "").trim(),
|
||||||
|
);
|
||||||
|
const type = (parsed?.type ?? visitType ?? "Visit").trim() || "Visit";
|
||||||
|
|
||||||
|
const draft: Encounter = {
|
||||||
|
date: date || new Date().toISOString().slice(0, 10),
|
||||||
|
type,
|
||||||
|
// The responsible clinician is the signed-in user, not the model's guess.
|
||||||
|
provider: req.user!.name ?? "",
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
draft,
|
||||||
|
veil: {
|
||||||
|
active: veil.active,
|
||||||
|
level: veil.level,
|
||||||
|
classes: veil.usedClasses(),
|
||||||
|
provider: resolved.providerLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveSchema = z.object({
|
||||||
|
fileNumber: z.string().trim().min(1),
|
||||||
|
encounter: z.object({
|
||||||
|
date: z.string().trim().min(1),
|
||||||
|
type: z.string().trim().min(1),
|
||||||
|
provider: z.string().trim().default(""),
|
||||||
|
summary: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/scribe/save — the approval step: append the reviewed encounter note
|
||||||
|
// to the patient record. Re-validates server-side and audits like any add.
|
||||||
|
scribeRouter.post("/save", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await ensureScribeAllowed(req);
|
||||||
|
const { fileNumber, encounter } = saveSchema.parse(req.body);
|
||||||
|
const updated = await appendEncounter(
|
||||||
|
req.organizationId!,
|
||||||
|
fileNumber,
|
||||||
|
encounter,
|
||||||
|
);
|
||||||
|
if (!updated) throw new HttpError(404, "Patient not found.");
|
||||||
|
|
||||||
|
void recordActivity({
|
||||||
|
orgId: req.organizationId!,
|
||||||
|
actor: { id: req.user!.id, name: req.user!.name },
|
||||||
|
action: `Added a scribe visit note for ${updated.name}`,
|
||||||
|
entityType: "patient",
|
||||||
|
entityId: updated.fileNumber,
|
||||||
|
patientName: updated.name,
|
||||||
|
patientFileNumber: updated.fileNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { HttpError } from "../../lib/http-error.js";
|
||||||
|
import type { userAiSettings } from "../../db/schema/ai.js";
|
||||||
|
import { getApiKey } from "./config.js";
|
||||||
|
|
||||||
|
type AiSettingsRow = typeof userAiSettings.$inferSelect;
|
||||||
|
|
||||||
|
export type AudioInput = {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which transcription backend a user's AI settings can reach. Anthropic has no
|
||||||
|
// speech-to-text API, so an Anthropic-only user must paste a transcript instead.
|
||||||
|
export type TranscribeProvider = "openai" | "gemini";
|
||||||
|
|
||||||
|
export function transcribeProviderFor(
|
||||||
|
settings: AiSettingsRow,
|
||||||
|
): TranscribeProvider | null {
|
||||||
|
if (getApiKey(settings, "openai")) return "openai";
|
||||||
|
if (getApiKey(settings, "gemini")) return "gemini";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENAI_MODEL = "whisper-1";
|
||||||
|
const GEMINI_MODEL = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
const TRANSCRIBE_PROMPT =
|
||||||
|
"Transcribe this clinical visit recording verbatim. Return only the spoken words as plain text, with no commentary, headings, or timestamps.";
|
||||||
|
|
||||||
|
async function transcribeWithOpenAI(
|
||||||
|
apiKey: string,
|
||||||
|
audio: AudioInput,
|
||||||
|
): Promise<string> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(
|
||||||
|
"file",
|
||||||
|
new Blob([new Uint8Array(audio.buffer)], { type: audio.mimeType }),
|
||||||
|
audio.filename,
|
||||||
|
);
|
||||||
|
form.append("model", OPENAI_MODEL);
|
||||||
|
form.append("response_format", "json");
|
||||||
|
|
||||||
|
const res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => "");
|
||||||
|
throw new HttpError(
|
||||||
|
502,
|
||||||
|
`Transcription failed (OpenAI ${res.status}). ${detail.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as { text?: string };
|
||||||
|
return (json.text ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribeWithGemini(
|
||||||
|
apiKey: string,
|
||||||
|
audio: AudioInput,
|
||||||
|
): Promise<string> {
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${encodeURIComponent(
|
||||||
|
apiKey,
|
||||||
|
)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
inline_data: {
|
||||||
|
mime_type: audio.mimeType,
|
||||||
|
data: audio.buffer.toString("base64"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ text: TRANSCRIBE_PROMPT },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => "");
|
||||||
|
throw new HttpError(
|
||||||
|
502,
|
||||||
|
`Transcription failed (Gemini ${res.status}). ${detail.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
candidates?: { content?: { parts?: { text?: string }[] } }[];
|
||||||
|
};
|
||||||
|
const text =
|
||||||
|
json.candidates?.[0]?.content?.parts
|
||||||
|
?.map((p) => p.text ?? "")
|
||||||
|
.join("")
|
||||||
|
.trim() ?? "";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an audio recording to the user's transcription provider and return the
|
||||||
|
// raw transcript. The audio never passes through the chat loop or Veil — Veil
|
||||||
|
// cannot redact speech, so the caller must warn the clinician that audio leaves
|
||||||
|
// the clinic when an external provider is used (only the DRAFTING step is
|
||||||
|
// Veil-protected). Throws a 400 when no speech-capable provider is configured.
|
||||||
|
export async function transcribeAudio(
|
||||||
|
settings: AiSettingsRow,
|
||||||
|
audio: AudioInput,
|
||||||
|
): Promise<{ transcript: string; provider: TranscribeProvider }> {
|
||||||
|
const provider = transcribeProviderFor(settings);
|
||||||
|
if (!provider) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
"Transcription needs an OpenAI or Gemini API key. Add one in Settings → AI, or paste the visit transcript instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const apiKey = getApiKey(settings, provider)!;
|
||||||
|
const transcript =
|
||||||
|
provider === "openai"
|
||||||
|
? await transcribeWithOpenAI(apiKey, audio)
|
||||||
|
: await transcribeWithGemini(apiKey, audio);
|
||||||
|
if (!transcript) {
|
||||||
|
throw new HttpError(
|
||||||
|
502,
|
||||||
|
"The transcription came back empty — try again or paste the transcript.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { transcript, provider };
|
||||||
|
}
|
||||||
@@ -26,6 +26,15 @@ export type Veil = {
|
|||||||
redactPatient: (patient: Patient) => Patient;
|
redactPatient: (patient: Patient) => Patient;
|
||||||
/** Map a possibly-tokenized file number from a tool call back to the real one. */
|
/** Map a possibly-tokenized file number from a tool call back to the real one. */
|
||||||
resolveFileNumber: (input: string) => string;
|
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. */
|
/** Swap any tokens in model output back to real identifiers. */
|
||||||
rehydrate: (text: string) => string;
|
rehydrate: (text: string) => string;
|
||||||
/** Token classes actually emitted — for the audit log. */
|
/** Token classes actually emitted — for the audit log. */
|
||||||
@@ -81,6 +90,21 @@ export function createVeil(level: VeilLevel, active: boolean): Veil {
|
|||||||
return mrnByToken.get(input.trim()) ?? 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 {
|
function rehydrate(text: string): string {
|
||||||
if (!isActive || fromToken.size === 0) return text;
|
if (!isActive || fromToken.size === 0) return text;
|
||||||
let out = text;
|
let out = text;
|
||||||
@@ -101,6 +125,7 @@ export function createVeil(level: VeilLevel, active: boolean): Veil {
|
|||||||
level,
|
level,
|
||||||
redactPatient,
|
redactPatient,
|
||||||
resolveFileNumber,
|
resolveFileNumber,
|
||||||
|
redactText,
|
||||||
rehydrate,
|
rehydrate,
|
||||||
usedClasses,
|
usedClasses,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -570,6 +570,46 @@ export async function appendLabs(
|
|||||||
return getPatient(orgId, fileNumber);
|
return getPatient(orgId, fileNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append a single encounter (visit note) without touching the rest of the
|
||||||
|
// record — used by the ambient AI scribe, which drafts one note at a time and
|
||||||
|
// must not go through updatePatient's wholesale child replacement. Position
|
||||||
|
// continues after the current max so the new note sorts last.
|
||||||
|
export async function appendEncounter(
|
||||||
|
orgId: string,
|
||||||
|
fileNumber: string,
|
||||||
|
entry: Encounter,
|
||||||
|
): Promise<Patient | null> {
|
||||||
|
const inserted = await db.transaction(async (tx) => {
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: patients.id })
|
||||||
|
.from(patients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(patients.organizationId, orgId),
|
||||||
|
eq(patients.fileNumber, fileNumber),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!existing) return false;
|
||||||
|
|
||||||
|
const [pos] = await tx
|
||||||
|
.select({ max: sql<number>`coalesce(max(${encounters.position}), -1)` })
|
||||||
|
.from(encounters)
|
||||||
|
.where(eq(encounters.patientId, existing.id));
|
||||||
|
await tx.insert(encounters).values({
|
||||||
|
patientId: existing.id,
|
||||||
|
position: (pos?.max ?? -1) + 1,
|
||||||
|
...entry,
|
||||||
|
});
|
||||||
|
await tx
|
||||||
|
.update(patients)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(patients.id, existing.id));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!inserted) return null;
|
||||||
|
return getPatient(orgId, fileNumber);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove a single lab result from a patient, identified by its
|
// Remove a single lab result from a patient, identified by its
|
||||||
// name/value/takenAt (the frontend has no row id). Scoped to the org via the
|
// name/value/takenAt (the frontend has no row id). Scoped to the org via the
|
||||||
// owning patient. Returns the reloaded patient, or null when the chart is gone.
|
// owning patient. Returns the reloaded patient, or null when the chart is gone.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AiBadge } from "@/components/ai-badge";
|
|||||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||||
import { RecordGraph } from "@/components/graph/record-graph";
|
import { RecordGraph } from "@/components/graph/record-graph";
|
||||||
import { PatientDetail } from "@/components/patients/patient-detail";
|
import { PatientDetail } from "@/components/patients/patient-detail";
|
||||||
|
import { ScribeDialog } from "@/components/patients/scribe-dialog";
|
||||||
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
|
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,7 @@ import { type Appointment, listAppointments } from "@/lib/appointments";
|
|||||||
import { type Invoice, listInvoices } from "@/lib/invoices";
|
import { type Invoice, listInvoices } from "@/lib/invoices";
|
||||||
import { deletePatient, getPatient, type Patient } from "@/lib/patients";
|
import { deletePatient, getPatient, type Patient } from "@/lib/patients";
|
||||||
import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
|
import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
|
||||||
|
import { useAiAccess } from "@/lib/ai-policy";
|
||||||
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
|
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
|
||||||
import { notify } from "@/lib/toast";
|
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
|
// Deleting a chart is destructive — only offer it once we know the role is
|
||||||
// a full clinician (patient:delete), never optimistically.
|
// a full clinician (patient:delete), never optimistically.
|
||||||
const canDelete = role != null && hasClinicalAccess(role);
|
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 [patient, setPatient] = useState<Patient | null>(null);
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [scribeOpen, setScribeOpen] = useState(false);
|
||||||
const [transferOpen, setTransferOpen] = useState(false);
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
// Graph popped out of the sheet into its own dialog (the sheet closes first).
|
// 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);
|
setEditKey((k) => k + 1);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
|
||||||
onOpenGraph={() => {
|
onOpenGraph={() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setGraphOpen(true);
|
setGraphOpen(true);
|
||||||
@@ -197,6 +205,15 @@ export function PatientDetailSheet({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{patient && (
|
||||||
|
<ScribeDialog
|
||||||
|
onOpenChange={setScribeOpen}
|
||||||
|
onSaved={(updated) => setPatient(updated)}
|
||||||
|
open={scribeOpen}
|
||||||
|
patient={patient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{patient && (
|
{patient && (
|
||||||
<TransferPatientDialog
|
<TransferPatientDialog
|
||||||
onOpenChange={setTransferOpen}
|
onOpenChange={setTransferOpen}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"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 { type ReactNode, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -173,6 +180,7 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
|
|||||||
export function PatientDetail({
|
export function PatientDetail({
|
||||||
patient,
|
patient,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onScribe,
|
||||||
onTransfer,
|
onTransfer,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpenGraph,
|
onOpenGraph,
|
||||||
@@ -182,6 +190,8 @@ export function PatientDetail({
|
|||||||
}: {
|
}: {
|
||||||
patient: Patient;
|
patient: Patient;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
// Opens the ambient AI visit scribe (record/transcribe → draft note).
|
||||||
|
onScribe?: () => void;
|
||||||
onTransfer?: () => void;
|
onTransfer?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
// Pops the record graph out into its own dialog (closing this sheet).
|
// Pops the record graph out into its own dialog (closing this sheet).
|
||||||
@@ -287,6 +297,12 @@ export function PatientDetail({
|
|||||||
{t("patients.transfer.action")}
|
{t("patients.transfer.action")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onScribe && (
|
||||||
|
<Button onClick={onScribe} size="sm" type="button" variant="outline">
|
||||||
|
<Mic className="size-4" />
|
||||||
|
{t("scribe.recordVisit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
||||||
<Pencil className="size-4" />
|
<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": {
|
"common": {
|
||||||
"appName": "temetro",
|
"appName": "temetro",
|
||||||
"email": "البريد الإلكتروني",
|
"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": {
|
"common": {
|
||||||
"appName": "temetro",
|
"appName": "temetro",
|
||||||
"email": "E-Mail",
|
"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": {
|
"common": {
|
||||||
"appName": "temetro",
|
"appName": "temetro",
|
||||||
"email": "Email",
|
"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": {
|
"common": {
|
||||||
"appName": "temetro",
|
"appName": "temetro",
|
||||||
"email": "E-mail",
|
"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": {
|
"common": {
|
||||||
"appName": "temetro",
|
"appName": "temetro",
|
||||||
"email": "Iimayl",
|
"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",
|
"name": "frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temetro",
|
"name": "temetro",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"shadcn": "^4.11.0"
|
"shadcn": "^4.11.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user