Merge pull request 'feat(treatment): add voice detail entry' (#65) from feat/voice-treatment-entry into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Successful in 7m32s
Registry — build, push, deploy / deploy (push) Failing after 45s

Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/65
Reviewed-by: admin <admin@localhost>
This commit was merged in pull request #65.
This commit is contained in:
2026-08-24 11:13:45 +03:30
48 changed files with 6570 additions and 52 deletions

108
CLAUDE.md Normal file
View File

@@ -0,0 +1,108 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Read first
Project conventions already live in **`AGENTS.md`** (project map + per-feature quick-reference), **`.cursor/rules/*.mdc`** (short always-on / file-scoped rules), and **`.cursor/skills/*/SKILL.md`** (multi-step workflow playbooks). They are plain markdown — read the ones covering the area you touch **before** editing. This file covers only what those do not: commands and cross-cutting architecture.
Per `.cursor/rules/maintain-agent-docs.mdc`: when the user establishes a durable convention, update the matching `.mdc` rule or `SKILL.md` — not this file.
## Commands
There is **no root `package.json`**. Every npm command runs inside `backend/` or `frontend/`.
### Backend (`cd backend`)
| Command | Purpose |
|---|---|
| `npm run start:dev` | API on `http://localhost:3000/api`; Swagger `/api/docs`; AdminJS `/admin` |
| `npm run build` | **Verification gate for cross-cutting backend changes** |
| `npm test` | Jest (`src/**/*.spec.ts`) |
| `npm test -- lab-case-task.generator` | Single suite by path fragment |
| `npm test -- -t "merges teeth"` | Single test by name |
| `npm run test:e2e` | Jest with `test/jest-e2e.json` |
| `npm run lint` | ESLint with `--fix` |
| `docker compose -f docker-compose.postgres.yml up -d` | Dev Postgres (host port from `POSTGRES_PORT` in `.env`) |
| `npm run prisma:generate` / `prisma:migrate` / `prisma:seed` | Client, dev migration, reference-data upsert (seed never wipes) |
| `npx prisma migrate reset` | Dev clean slate — drop, re-migrate, re-seed. Never against staging/prod |
| `npm run prisma:wipe-app-data` / `prisma:reset-treatment` / `prisma:regenerate-tasks` | Targeted dev data scripts |
`DATABASE_URL` must use `localhost` when Nest runs on the host and Postgres in Docker.
### Frontend (`cd frontend`)
| Command | Purpose |
|---|---|
| `npm run dev` | Dev server on **3001** (3000 is the API) |
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
| `npm run build` | Production build (`output: 'standalone'`) |
| `npm run lint` | ESLint via Next |
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
### Git
Do not commit, push, amend, force-push, or skip hooks unless the user explicitly asks.
## Architecture
Dental **clinic ↔ lab** platform. Every user acts inside one `Organization` whose `type` is `CLINIC` (patients, appointments, treatment) or `LAB` (cases, tasks). Most features exist only for one side.
### Request identity: cookie JWT carrying the selected org
There is no `Authorization` header. `JwtStrategy` reads the httpOnly **`accessToken` cookie**, and the JWT payload carries `organizationId` — the org the user currently acts as. `POST /auth/select-organization` re-issues the token with a different org, so **switching orgs means a new token**, and every service scopes queries by `req.user.organizationId`.
On 401 the axios interceptor (`frontend/src/lib/api/client.ts`) refreshes, **re-selects** the org from `localStorage.currentOrganizationId`, then retries the original request — skipping that dance for auth endpoints and public invitation routes. `frontend/src/proxy.ts` (the Next middleware, exported as `proxy`) is a separate, cookie-only route gate that redirects unauthenticated users to `/{locale}/login?from=…`.
### Permissions
`TAB_*_READ` / `TAB_*_EDIT` codes in `backend/src/common/permissions.ts`; **EDIT implies READ**. Owners get org-type defaults merged with stored grants — always resolve via `hasEffectivePermission` / `getEffectivePermissionNames` in `common/membership-permissions.ts`, never by reading `membership.permissions` directly. Controllers stack `JwtAuthGuard` + `ClinicOrgGuard`/`LabOrgGuard`; feature-specific checks belong in the **service**.
### Error contract (spans 3 layers — change all of them)
`AppException(ErrorCode.X)``HttpExceptionFilter``{ success: false, error: { code } }` → axios normalizes to `ApiError``getUserFacingError(err, tErrors, fallback)` resolves `errors.X` from the message files. Adding a user-facing failure means: a code in `common/errors/error-codes.ts`, the throw site, and an `errors.X` key in **all three** of `frontend/messages/{en,fa,nl}.json`. Never throw raw English Nest exceptions for user-facing failures.
### The core domain pipeline
```
Appointment ─┐
├→ Treatment (patient + day) → TreatmentDetail (treatment type + selected teeth)
Walk-in ─────┘ │
│ "send to lab" (clinic side)
LabCase + LabCaseToothProsthesis (per tooth, grouped by sourceKey)
│ generateLabCaseTasks()
ProsthesisType → ProsthesisTypeStep → LabWorkflowStep ⇒ LabCaseTask rows
LAB org: Cases tab + Tasks tab
```
`backend/src/modules/cases/lab-case-task.generator.ts` is the expansion point: it is **idempotent** (returns early if tasks exist) and drives the entire lab-side task list from catalog data. Teeth carry `selectionGroupId` so bridges/connected units survive into task grouping. A `LabCase` can also be lab-origin (`LabCaseOrigin`), created without any clinic treatment.
Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B, `LinkStatus`), plus `OrganizationInvitation` for counterparts not yet on the platform — the invite flow writes both rows in one transaction and stores only the token hash.
### Catalog is code-based and DB-translated
`TreatmentType`, `ProsthesisType`, and `LabWorkflowStep` store a stable `code` and **no label**. Labels come from `CatalogTranslation(entityKind, entityCode, locale)` resolved by `CatalogLabelService` (falls back locale → `en` → humanized code). So: never hardcode a catalog label in backend code, and pass the actor's locale into anything that materializes labels (task generation does). Frontend colors/labels for these codes live in `components/shared/treatmentTypeDisplay.ts` and `components/treatment/prosthesisTypeDisplay.ts`.
### Realtime and unread state
`modules/notifications/user-notification.service.ts` writes `UserNotification` rows and pushes them through the Socket.IO transport in `backend/src/realtime/` (`emitToUserOrg``notification.created`). On the frontend a single `notification.created` event drives three things: the header bell inbox, sidebar **tab badges**, and a *soft* refresh of whatever list is currently open — soft meaning it must not remount components or clear an in-progress treatment draft. Unread is per-user cursor state (`LabCaseUserReadState`, `LabCaseUserTabReadState`) plus the `LabCaseActivity` log — badges clear on opening a case, not on visiting a tab.
### Layout conventions worth knowing before you create a file
- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`.
- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it.
- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input.
- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`.
### Tests
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation (7 suites in `backend/src/**`). There are no frontend tests; `npx tsc --noEmit` is the frontend gate.
## Deployment
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image.

View File

@@ -47,3 +47,28 @@ SMTP_PASSWORD=your_app_password
# SMS_IR_API_KEY=4QKMiSU4Kh7tWPLCdRMV0QpDh8WgF33YkWRS18BcG3vf4QHi
SMS_IR_API_KEY=lwbK7hxmjimNjFS4g5DWahh75EKCgJUfcUIinUQzfQXwXkSp
SMS_IR_TEMPLATE_ID=123456
# ── Voice treatment entry ──────────────────────────────────────────────────────
# Without OPENROUTER_API_KEY the microphone button does not render at all.
OPENROUTER_API_KEY=
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# Locales the microphone is offered in. An unknown locale here fails at boot.
# VOICE_ENABLED_LOCALES=fa,en,nl
# Models, overridable per locale (VOICE_ASR_MODEL_FA, VOICE_LLM_MODEL_NL, ...).
# All locales share these today; the per-locale override exists so Persian can be
# repointed at a specialist ASR vendor without a code change.
# VOICE_ASR_MODEL=openai/whisper-1
# VOICE_LLM_MODEL=google/gemini-3.7-flash
# VOICE_ASR_PROVIDER_FA=openrouter
# VOICE_LLM_PROVIDER_FA=openrouter
# Recording cap in ms (0 = uncapped). 2 minutes bounds worst-case vendor spend at
# about 1.3 cents per recording.
# VOICE_MAX_RECORDING_MS=120000
# Per-user rate limit on the extract endpoint. Unreachable by a human — a recording
# plus processing takes ten seconds at minimum — so it is purely an abuse guard.
# VOICE_THROTTLE_TTL=60
# VOICE_THROTTLE_LIMIT=6

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import configurations from './configs/configurations';
import { AuthModule } from './modules/auth/auth.module';
import { AppController } from './app.controller';
@@ -20,6 +21,7 @@ import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comm
import { TodayModule } from './modules/today/today.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { RealtimeModule } from './realtime/realtime.module';
import { VoiceModule } from './modules/voice/voice.module';
@Module({
imports: [
@@ -27,6 +29,22 @@ import { RealtimeModule } from './realtime/realtime.module';
isGlobal: true,
load: [configurations],
}),
// First use of @nestjs/throttler in this app. Deliberately NOT bound as a global
// APP_GUARD: a globally-bound ThrottlerGuard rate-limits every route against every
// named throttler, which would cap the whole API at the voice limit. ThrottlerGuard
// is applied to the one expensive route instead, so nothing else changes behaviour.
ThrottlerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
throttlers: [
{
name: 'voice',
ttl: (config.get<number>('voice.throttle.ttl') ?? 60) * 1000,
limit: config.get<number>('voice.throttle.limit') ?? 6,
},
],
}),
}),
PrismaModule, // ✅ ADD THIS
CatalogModule,
TreatmentCatalogModule,
@@ -43,9 +61,10 @@ import { RealtimeModule } from './realtime/realtime.module';
TodayModule,
NotificationsModule,
RealtimeModule,
VoiceModule,
AdminModule.forRoot(),
],
controllers: [AppController],
providers: [AppService],
providers: [AppService],
})
export class AppModule {}
export class AppModule {}

View File

@@ -0,0 +1,104 @@
import express, {
type NextFunction,
type Request,
type Response,
} from 'express';
import request from 'supertest';
import { createJsonBodyParser, VOICE_EXTRACT_PATH } from './body-parsers';
/**
* Guards a bug that made the voice endpoint completely unusable while surfacing as a
* generic 500: the large-body limit stopped applying, so every real recording — anything
* past roughly 20 seconds of audio — was rejected by Express's 100 kb default.
*/
type ProbeBody = { keys?: number; type?: string };
function buildApp(): express.Express {
const app = express();
app.use(createJsonBodyParser());
app.post('*splat', (req: Request, res: Response) => {
res.json({ keys: Object.keys((req.body ?? {}) as object).length });
});
// Surface body-parser's own error instead of Express's HTML default page.
app.use(
(
err: { status?: number; type?: string },
_req: Request,
res: Response,
next: NextFunction,
) => {
if (res.headersSent) {
next(err);
return;
}
res.status(err.status ?? 500).json({ type: err.type });
},
);
return app;
}
const bodyOfKb = (kb: number) => ({ audio: 'A'.repeat(kb * 1024) });
describe('createJsonBodyParser', () => {
it('accepts a body far past the default limit on the voice route', async () => {
const res = await request(buildApp())
.post(VOICE_EXTRACT_PATH)
.send(bodyOfKb(300));
expect(res.status).toBe(200);
expect((res.body as ProbeBody).keys).toBe(1);
});
it('accepts a realistic worst-case recording', async () => {
// Two minutes of opus is well under 1 MB, but wav is far larger; 4 MB must pass.
const res = await request(buildApp())
.post(VOICE_EXTRACT_PATH)
.send(bodyOfKb(4096));
expect(res.status).toBe(200);
});
it('keeps the default limit on every other route', async () => {
// The larger limit must not leak app-wide as a side effect.
const res = await request(buildApp())
.post('/api/auth/login')
.send(bodyOfKb(300));
expect(res.status).toBe(413);
expect((res.body as ProbeBody).type).toBe('entity.too.large');
});
it('still parses ordinary bodies on ordinary routes', async () => {
const res = await request(buildApp())
.post('/api/auth/login')
.send({ email: 'a@b.c' });
expect(res.status).toBe(200);
expect((res.body as ProbeBody).keys).toBe(1);
});
it('widens the limit for the spellings Express itself accepts', async () => {
// Express routes case-insensitively and ignores a trailing slash by default, so these
// all reach the voice controller. Any of them taking the 100 kb parser would 413 a
// real recording and read as a broken microphone.
for (const path of [
'/api/voice/extract/',
'/API/Voice/Extract',
'/api/Voice/extract/',
]) {
const res = await request(buildApp()).post(path).send(bodyOfKb(300));
expect(res.status).toBe(200);
}
});
it('does not widen the limit for a path that merely looks similar', async () => {
for (const path of [
'/api/voice/extract/extra',
'/api/voice',
'/voice/extract',
// Express ignores one trailing slash, not two — this one never routes, so it must
// not get the large parser either.
'/api/voice/extract//',
]) {
const res = await request(buildApp()).post(path).send(bodyOfKb(300));
expect(res.status).toBe(413);
}
});
});

View File

@@ -0,0 +1,43 @@
import {
json,
type NextFunction,
type Request,
type RequestHandler,
type Response,
} from 'express';
/** The one route that accepts a large body, and how large. */
export const VOICE_EXTRACT_PATH = '/api/voice/extract';
export const VOICE_BODY_LIMIT = '10mb';
/**
* JSON body parsing for the whole app.
*
* Voice recordings are base64 JSON and pass Express's 100 kb default at roughly 20 seconds
* of audio, so that one route needs a larger limit while every other endpoint keeps the
* default — a large body should not become acceptable everywhere.
*
* Deliberately one middleware that *chooses* a parser, not a path-mounted parser stacked in
* front of a default one: that arrangement depended on Express's mount-path stripping and on
* body-parser skipping an already-parsed request, and silently stopped applying whenever the
* middleware order shifted. One explicit branch has no such coupling.
*/
/**
* Express routes case-insensitively and ignores exactly one trailing slash, so
* `/API/Voice/Extract/` reaches the same controller and must get the same limit — otherwise
* it 413s every real recording, which reads as a broken microphone rather than a route.
* Two slashes never route, so they must not buy a 10 MB buffer either.
*/
function isVoiceExtractPath(path: string): boolean {
return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH;
}
export function createJsonBodyParser(): RequestHandler {
const voiceParser = json({ limit: VOICE_BODY_LIMIT });
const defaultParser = json();
return (req: Request, res: Response, next: NextFunction) =>
isVoiceExtractPath(req.path)
? voiceParser(req, res, next)
: defaultParser(req, res, next);
}

View File

@@ -0,0 +1,31 @@
import { toLatinDigits } from './digits';
/**
* Exercised by the voice pipeline on two untrusted inputs: spoken dates, and the tooth
* code the extraction model echoes back — a Persian-digit "۲۶" that fails to normalise
* costs the clinician a tooth, silently.
*/
describe('toLatinDigits', () => {
it('normalises Persian digits and leaves everything else alone', () => {
expect(
toLatinDigits('\u06F1\u06F4\u06F0\u06F4/\u06F0\u06F7/\u06F2\u06F5'),
).toBe('1404/07/25');
expect(toLatinDigits('1404/07/25')).toBe('1404/07/25');
expect(toLatinDigits('\u062F\u0646\u062F\u0627\u0646 \u06F1\u06F4')).toBe(
'\u062F\u0646\u062F\u0627\u0646 14',
);
});
it('also normalises the Arabic-Indic block, which ASR output can carry', () => {
// U+0660..U+0669, distinct code points from the Persian U+06F0..U+06F9 block.
expect(
toLatinDigits('\u0661\u0664\u0660\u0664/\u0660\u0667/\u0662\u0665'),
).toBe('1404/07/25');
});
it('normalises a transcript that mixes both blocks with ASCII', () => {
expect(toLatinDigits('\u06F1\u06F4 and \u0661\u0665 and 16')).toBe(
'14 and 15 and 16',
);
});
});

View File

@@ -0,0 +1,16 @@
const PERSIAN_ZERO = 0x06f0;
const ARABIC_INDIC_ZERO = 0x0660;
/**
* Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through.
*
* Both blocks, not just the Persian one the frontend handles: ASR output can carry either,
* sometimes mixed with ASCII in a single transcript.
*/
export function toLatinDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const code = ch.charCodeAt(0);
const base = code >= PERSIAN_ZERO ? PERSIAN_ZERO : ARABIC_INDIC_ZERO;
return String(code - base);
});
}

View File

@@ -29,8 +29,10 @@ export const ErrorCode = {
PERMISSION_LAB_ONLY: 'PERMISSION_LAB_ONLY',
PERMISSION_NOT_MEMBER: 'PERMISSION_NOT_MEMBER',
PERMISSION_OWNER_ONLY: 'PERMISSION_OWNER_ONLY',
PERMISSION_PARTICIPATION_SUBSCRIPTION: 'PERMISSION_PARTICIPATION_SUBSCRIPTION',
PERMISSION_ENABLE_PARTICIPATION_FIRST: 'PERMISSION_ENABLE_PARTICIPATION_FIRST',
PERMISSION_PARTICIPATION_SUBSCRIPTION:
'PERMISSION_PARTICIPATION_SUBSCRIPTION',
PERMISSION_ENABLE_PARTICIPATION_FIRST:
'PERMISSION_ENABLE_PARTICIPATION_FIRST',
PERMISSION_CLINIC_WORKING_HOURS: 'PERMISSION_CLINIC_WORKING_HOURS',
PERMISSION_ACCESS_APPOINTMENTS: 'PERMISSION_ACCESS_APPOINTMENTS',
PERMISSION_EDIT_APPOINTMENTS: 'PERMISSION_EDIT_APPOINTMENTS',
@@ -51,7 +53,8 @@ export const ErrorCode = {
VALIDATION_PASSWORD_REQUIRED: 'VALIDATION_PASSWORD_REQUIRED',
VALIDATION_MOBILE_INVALID: 'VALIDATION_MOBILE_INVALID',
VALIDATION_NAME_TOO_SHORT: 'VALIDATION_NAME_TOO_SHORT',
VALIDATION_ORGANIZATION_NAME_REQUIRED: 'VALIDATION_ORGANIZATION_NAME_REQUIRED',
VALIDATION_ORGANIZATION_NAME_REQUIRED:
'VALIDATION_ORGANIZATION_NAME_REQUIRED',
VALIDATION_ORGANIZATION_TYPE_INVALID: 'VALIDATION_ORGANIZATION_TYPE_INVALID',
VALIDATION_TOKEN_REQUIRED: 'VALIDATION_TOKEN_REQUIRED',
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
@@ -69,8 +72,10 @@ export const ErrorCode = {
APPOINTMENT_INVALID_DATE: 'APPOINTMENT_INVALID_DATE',
APPOINTMENT_PROVIDER_NOT_MEMBER: 'APPOINTMENT_PROVIDER_NOT_MEMBER',
APPOINTMENT_PROVIDER_INACTIVE: 'APPOINTMENT_PROVIDER_INACTIVE',
APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT: 'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT',
APPOINTMENT_PROVIDER_NO_WORKING_HOURS: 'APPOINTMENT_PROVIDER_NO_WORKING_HOURS',
APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT:
'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT',
APPOINTMENT_PROVIDER_NO_WORKING_HOURS:
'APPOINTMENT_PROVIDER_NO_WORKING_HOURS',
APPOINTMENT_PROVIDER_NOT_WORKING_DAY: 'APPOINTMENT_PROVIDER_NOT_WORKING_DAY',
APPOINTMENT_OUTSIDE_WORKING_HOURS: 'APPOINTMENT_OUTSIDE_WORKING_HOURS',
APPOINTMENT_NOT_FOUND: 'APPOINTMENT_NOT_FOUND',
@@ -82,7 +87,8 @@ export const ErrorCode = {
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS: 'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS',
WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS:
'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS',
STAFF_UNKNOWN_PERMISSIONS: 'STAFF_UNKNOWN_PERMISSIONS',
STAFF_NO_SUBSCRIPTION: 'STAFF_NO_SUBSCRIPTION',
@@ -147,7 +153,8 @@ export const ErrorCode = {
TREATMENT_ATTACHMENT_NOT_FOUND: 'TREATMENT_ATTACHMENT_NOT_FOUND',
TREATMENT_FILE_UNAVAILABLE: 'TREATMENT_FILE_UNAVAILABLE',
TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS',
TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE: 'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE',
TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE:
'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE',
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
TREATMENT_NOT_FOUND: 'TREATMENT_NOT_FOUND',
TREATMENT_PATIENT_OR_WALK_IN: 'TREATMENT_PATIENT_OR_WALK_IN',
@@ -177,6 +184,15 @@ export const ErrorCode = {
TODAY_INVALID_RANGE: 'TODAY_INVALID_RANGE',
TODAY_INVALID_RANGE_ORDER: 'TODAY_INVALID_RANGE_ORDER',
// Voice treatment entry
VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE',
VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG',
VOICE_UNSUPPORTED_FORMAT: 'VOICE_UNSUPPORTED_FORMAT',
VOICE_ASR_FAILED: 'VOICE_ASR_FAILED',
VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED',
VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED',
VOICE_RATE_LIMITED: 'VOICE_RATE_LIMITED',
// Generic HTTP
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',

View File

@@ -0,0 +1,144 @@
import {
areArchNeighbors,
sortInArchOrder,
FDI_TOOTH_IDS,
isFdiTooth,
sameArch,
teethBetweenInclusive,
toFdi,
} from './fdi';
describe('FDI geometry', () => {
describe('toFdi — the patient-right convention', () => {
// A mirrored quadrant produces a *valid* code for the wrong tooth, so no schema
// check can catch it. These four cases are the guard.
it('maps upper + patient right to quadrant 1', () => {
expect(toFdi('upper', 'patient_right', 6)).toBe('16');
expect(toFdi('upper', 'patient_right', 1)).toBe('11');
});
it('maps upper + patient left to quadrant 2', () => {
expect(toFdi('upper', 'patient_left', 6)).toBe('26');
expect(toFdi('upper', 'patient_left', 8)).toBe('28');
});
it('maps lower + patient left to quadrant 3', () => {
expect(toFdi('lower', 'patient_left', 6)).toBe('36');
});
it('maps lower + patient right to quadrant 4', () => {
expect(toFdi('lower', 'patient_right', 6)).toBe('46');
expect(toFdi('lower', 'patient_right', 8)).toBe('48');
});
it('never clamps an out-of-range position', () => {
expect(toFdi('upper', 'patient_right', 9)).toBeNull();
expect(toFdi('upper', 'patient_right', 0)).toBeNull();
expect(toFdi('upper', 'patient_right', -1)).toBeNull();
expect(toFdi('upper', 'patient_right', 1.5)).toBeNull();
expect(toFdi('upper', 'patient_right', Number.NaN)).toBeNull();
});
});
describe('isFdiTooth', () => {
it('accepts all 32 permanent teeth', () => {
expect(FDI_TOOTH_IDS.size).toBe(32);
for (const tooth of FDI_TOOTH_IDS) expect(isFdiTooth(tooth)).toBe(true);
});
it('rejects deciduous teeth — the chart has no primary dentition', () => {
for (const tooth of ['51', '55', '61', '71', '85']) {
expect(isFdiTooth(tooth)).toBe(false);
}
});
it('rejects garbage', () => {
for (const value of [
'',
'1',
'19',
'10',
'29',
'99',
14,
null,
undefined,
{},
]) {
expect(isFdiTooth(value)).toBe(false);
}
});
});
describe('adjacency', () => {
it('treats neighbours within a quadrant as adjacent', () => {
expect(areArchNeighbors('14', '15')).toBe(true);
expect(areArchNeighbors('15', '14')).toBe(true);
});
it('treats the midline pairs as adjacent', () => {
expect(areArchNeighbors('11', '21')).toBe(true);
expect(areArchNeighbors('41', '31')).toBe(true);
});
it('rejects non-neighbours and cross-arch pairs', () => {
expect(areArchNeighbors('14', '16')).toBe(false);
expect(areArchNeighbors('18', '28')).toBe(false);
expect(areArchNeighbors('14', '44')).toBe(false);
expect(areArchNeighbors('14', '14')).toBe(false);
});
});
describe('sameArch', () => {
it('groups by arch, not by quadrant', () => {
expect(sameArch('18', '28')).toBe(true);
expect(sameArch('48', '38')).toBe(true);
expect(sameArch('18', '48')).toBe(false);
expect(sameArch('14', '99')).toBe(false);
});
});
describe('teethBetweenInclusive', () => {
it('returns the span in arch order regardless of argument order', () => {
expect(teethBetweenInclusive('14', '16')).toEqual(['16', '15', '14']);
expect(teethBetweenInclusive('16', '14')).toEqual(['16', '15', '14']);
});
it('spans the midline', () => {
expect(teethBetweenInclusive('12', '22')).toEqual([
'12',
'11',
'21',
'22',
]);
});
it('returns a single tooth for identical endpoints', () => {
expect(teethBetweenInclusive('14', '14')).toEqual(['14']);
});
it('returns null across arches or for unknown teeth', () => {
expect(teethBetweenInclusive('14', '44')).toBeNull();
expect(teethBetweenInclusive('14', '99')).toBeNull();
expect(teethBetweenInclusive('99', '14')).toBeNull();
});
});
describe('sortInArchOrder', () => {
it('sorts along the arch rather than lexically', () => {
expect(sortInArchOrder(['14', '16', '15'])).toEqual(['16', '15', '14']);
});
it('places 11 beside 21 across the midline', () => {
expect(sortInArchOrder(['21', '11', '12'])).toEqual(['12', '11', '21']);
});
it('is a no-op for an empty or single-tooth list', () => {
expect(sortInArchOrder([])).toEqual([]);
expect(sortInArchOrder(['14'])).toEqual(['14']);
});
it('does not drop teeth it cannot place', () => {
expect(sortInArchOrder(['99', '14']).sort()).toEqual(['14', '99']);
});
});
});

145
backend/src/common/fdi.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* FDI tooth geometry — permanent dentition only.
*
* Mirrors `frontend/src/components/treatment/fdiToothMeta.ts` and the adjacency rules in
* `toothSelectionGroups.ts`. Adjacency is defined by position in the arch order, so the
* midline pairs (1121, 4131) are neighbours, exactly as the chart treats them.
*/
import { toLatinDigits } from './digits';
export type Arch = 'upper' | 'lower';
/** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */
export type PatientSide = 'patient_right' | 'patient_left';
/**
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28) — the drawn
* layout, which mirrors the patient's own sides. Never read a position off this array by
* index; use `toFdi()`, which owns the side convention.
*/
export const FDI_UPPER_ARCH_ORDER = [
'18',
'17',
'16',
'15',
'14',
'13',
'12',
'11',
'21',
'22',
'23',
'24',
'25',
'26',
'27',
'28',
] as const;
/** Lower arch, same chart ordering: patient's RIGHT (48) → midline → patient's LEFT (38). */
export const FDI_LOWER_ARCH_ORDER = [
'48',
'47',
'46',
'45',
'44',
'43',
'42',
'41',
'31',
'32',
'33',
'34',
'35',
'36',
'37',
'38',
] as const;
export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
...FDI_UPPER_ARCH_ORDER,
...FDI_LOWER_ARCH_ORDER,
]);
export function isFdiTooth(value: unknown): value is string {
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
}
/**
* Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand
* back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the
* near-miss does not fail loudly — the tooth just turns into "not understood".
*/
export function normalizeFdiCode(value: unknown): string {
if (typeof value !== 'string') return '';
return toLatinDigits(value).replace(/\s+/g, '');
}
function archOrder(tooth: string): readonly string[] | null {
if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth))
return FDI_UPPER_ARCH_ORDER;
if ((FDI_LOWER_ARCH_ORDER as readonly string[]).includes(tooth))
return FDI_LOWER_ARCH_ORDER;
return null;
}
export function sameArch(a: string, b: string): boolean {
const archA = archOrder(a);
const archB = archOrder(b);
return Boolean(archA && archB && archA === archB);
}
export function areArchNeighbors(a: string, b: string): boolean {
const arch = archOrder(a);
if (!arch || !sameArch(a, b)) return false;
return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1;
}
/** Inclusive span between two teeth of the same arch, in arch order. Null if not comparable. */
export function teethBetweenInclusive(a: string, b: string): string[] | null {
const arch = archOrder(a);
if (!arch || !sameArch(a, b)) return null;
const i = arch.indexOf(a);
const j = arch.indexOf(b);
if (i < 0 || j < 0) return null;
const [from, to] = i <= j ? [i, j] : [j, i];
return [...arch.slice(from, to + 1)];
}
/**
* Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code.
*
* The single place the patient-right convention lives. Getting it backwards mirrors every
* quadrant into a valid-looking code for the wrong tooth, which no schema check can catch.
*/
export function toFdi(
arch: Arch,
side: PatientSide,
position: number,
): string | null {
if (!Number.isInteger(position) || position < 1 || position > 8) return null;
let quadrant: number;
if (arch === 'upper') {
quadrant = side === 'patient_right' ? 1 : 2;
} else {
quadrant = side === 'patient_left' ? 3 : 4;
}
const code = `${quadrant}${position}`;
return FDI_TOOTH_IDS.has(code) ? code : null;
}
/**
* Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the
* midline. Teeth from another arch sort to the end, stably.
*/
export function sortInArchOrder(teeth: readonly string[]): string[] {
if (teeth.length === 0) return [];
const arch = teeth.map((t) => archOrder(t)).find((a) => a !== null) ?? null;
if (!arch) return [...teeth];
const indexOf = (tooth: string) => {
const i = arch.indexOf(tooth);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
return [...teeth].sort((a, b) => indexOf(a) - indexOf(b));
}

View File

@@ -0,0 +1,89 @@
import {
gregorianToJalali,
isJalaliLeapYear,
isValidJalaliDate,
jalaliDaysInMonth,
jalaliToGregorian,
jalaliToIsoDate,
} from './jalali';
describe('jalali calendar', () => {
// Anchors verified against frontend/src/lib/i18n/persianCalendar.ts, the source this
// was ported from, over every day between 1900 and 2100.
it('converts known Jalali dates to ISO', () => {
expect(jalaliToIsoDate(1404, 1, 1)).toBe('2025-03-21'); // Nowruz 1404
expect(jalaliToIsoDate(1404, 7, 25)).toBe('2025-10-17');
expect(jalaliToIsoDate(1404, 6, 31)).toBe('2025-09-22'); // 31-day first half
expect(jalaliToIsoDate(1405, 1, 1)).toBe('2026-03-21');
});
it('round-trips Gregorian → Jalali → Gregorian', () => {
for (const [gy, gm, gd] of [
[2025, 3, 21],
[2025, 10, 17],
[2026, 1, 1],
[2024, 2, 29], // Gregorian leap day
[1999, 12, 31],
] as const) {
const [jy, jm, jd] = gregorianToJalali(gy, gm, gd);
expect(jalaliToGregorian(jy, jm, jd)).toEqual([gy, gm, gd]);
}
});
describe('leap years', () => {
it('gives Esfand 30 days in a leap year and 29 otherwise', () => {
expect(isJalaliLeapYear(1403)).toBe(true);
expect(jalaliDaysInMonth(1403, 12)).toBe(30);
expect(jalaliToIsoDate(1403, 12, 30)).toBe('2025-03-20');
expect(isJalaliLeapYear(1404)).toBe(false);
expect(jalaliDaysInMonth(1404, 12)).toBe(29);
expect(jalaliToIsoDate(1404, 12, 29)).toBe('2026-03-20');
});
it('rejects Esfand 30 in a non-leap year', () => {
expect(isValidJalaliDate(1404, 12, 30)).toBe(false);
expect(jalaliToIsoDate(1404, 12, 30)).toBeNull();
});
});
describe('month lengths', () => {
it('is 31 days for months 1-6 and 30 for 7-11', () => {
for (let m = 1; m <= 6; m += 1)
expect(jalaliDaysInMonth(1404, m)).toBe(31);
for (let m = 7; m <= 11; m += 1)
expect(jalaliDaysInMonth(1404, m)).toBe(30);
});
});
describe('invalid input degrades to null rather than throwing', () => {
// The resolver feeds this model-supplied values, which may be nonsense.
it.each([
['month 13', 1404, 13, 1],
['month 0', 1404, 0, 1],
['day 0', 1404, 1, 0],
['day 32 in a 31-day month', 1404, 1, 32],
['day 31 in a 30-day month', 1404, 7, 31],
['year beyond the conversion table', 9999, 1, 1],
['non-integer day', 1404, 1, 1.5],
])('returns null for %s', (_label, jy, jm, jd) => {
expect(jalaliToIsoDate(jy, jm, jd)).toBeNull();
});
});
describe('no export throws on an unsupported year', () => {
// The whole module is reachable from model-supplied values, so it degrades instead of
// raising — jalaliToIsoDate's guard is not the only entry point.
it.each([9999, -9999, 3178, Number.NaN, 1.5])('year %s', (jy) => {
expect(() => isJalaliLeapYear(jy)).not.toThrow();
expect(() => jalaliDaysInMonth(jy, 1)).not.toThrow();
expect(isJalaliLeapYear(jy)).toBe(false);
expect(jalaliDaysInMonth(jy, 1)).toBe(0);
});
it('returns 0 days for an impossible month', () => {
expect(jalaliDaysInMonth(1404, 0)).toBe(0);
expect(jalaliDaysInMonth(1404, 13)).toBe(0);
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* Jalali (Persian) calendar arithmetic, ported from
* `frontend/src/lib/i18n/persianCalendar.ts` (itself jalaali-js, MIT). The backend needs it
* because voice resolves spoken Jalali dates server-side, where the resolvers are tested.
* Keep the two copies in step; the underlying calendar does not change.
*/
const BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192,
2262, 2324, 2394, 2456, 3178,
];
/** Inclusive lower / exclusive upper Jalali year bounds the conversion table covers. */
export const MIN_JALALI_YEAR = BREAKS[0];
export const MAX_JALALI_YEAR = BREAKS[BREAKS.length - 1];
function div(a: number, b: number): number {
return Math.trunc(a / b);
}
function mod(a: number, b: number): number {
return a - Math.trunc(a / b) * b;
}
function g2d(gy: number, gm: number, gd: number): number {
let d =
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) +
div(153 * mod(gm + 9, 12) + 2, 5) +
gd -
34840408;
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
return d;
}
function d2g(jdn: number): { gy: number; gm: number; gd: number } {
let j = 4 * jdn + 139361631;
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
const i = div(mod(j, 1461), 4) * 5 + 308;
const gd = div(mod(i, 153), 5) + 1;
const gm = mod(div(i, 153), 12) + 1;
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
return { gy, gm, gd };
}
function jalCal(
jy: number,
withoutLeap: boolean,
): { leap?: number; gy: number; march: number } {
const bl = BREAKS.length;
const gy = jy + 621;
let leapJ = -14;
let jp = BREAKS[0];
let jump = 0;
let leap = 0;
let n = 0;
if (jy < jp || jy >= BREAKS[bl - 1]) {
throw new Error(`Invalid Jalaali year ${jy}`);
}
for (let i = 1; i < bl; i += 1) {
const jm = BREAKS[i];
jump = jm - jp;
if (jy < jm) break;
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
n = jy - jp;
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
const march = 20 + leapJ - leapG;
if (withoutLeap) return { gy, march };
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
leap = mod(mod(n + 1, 33) - 1, 4);
if (leap === -1) leap = 4;
return { leap, gy, march };
}
function j2d(jy: number, jm: number, jd: number): number {
const r = jalCal(jy, true);
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
}
function d2j(jdn: number): { jy: number; jm: number; jd: number } {
const { gy } = d2g(jdn);
let jy = gy - 621;
const r = jalCal(jy, false);
const jdn1f = g2d(gy, 3, r.march);
let k = jdn - jdn1f;
let jm: number;
let jd: number;
if (k >= 0) {
if (k <= 185) {
jm = 1 + div(k, 31);
jd = mod(k, 31) + 1;
return { jy, jm, jd };
}
k -= 186;
} else {
jy -= 1;
k += 179;
if (r.leap === 1) k += 1;
}
jm = 7 + div(k, 30);
jd = mod(k, 30) + 1;
return { jy, jm, jd };
}
export function gregorianToJalali(
gy: number,
gm: number,
gd: number,
): [number, number, number] {
const { jy, jm, jd } = d2j(g2d(gy, gm, gd));
return [jy, jm, jd];
}
export function jalaliToGregorian(
jy: number,
jm: number,
jd: number,
): [number, number, number] {
const { gy, gm, gd } = d2g(j2d(jy, jm, jd));
return [gy, gm, gd];
}
/** True when the year is inside the conversion table's supported range. */
export function isSupportedJalaliYear(jy: number): boolean {
return Number.isInteger(jy) && jy >= MIN_JALALI_YEAR && jy < MAX_JALALI_YEAR;
}
/** False for unsupported years rather than throwing — see the module contract. */
export function isJalaliLeapYear(jy: number): boolean {
if (!isSupportedJalaliYear(jy)) return false;
const r = jalCal(jy, false);
return r.leap === 0;
}
/**
* Days in a Jalali month, or 0 when the year or month is not real. Zero rather than a throw:
* every export here is reachable from model-supplied values, so the module degrades.
*/
export function jalaliDaysInMonth(jy: number, jm: number): number {
if (!isSupportedJalaliYear(jy)) return 0;
if (!Number.isInteger(jm) || jm < 1 || jm > 12) return 0;
if (jm <= 6) return 31;
if (jm <= 11) return 30;
return isJalaliLeapYear(jy) ? 30 : 29;
}
/** True when the triple is a real Jalali date inside the supported year range. */
export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) {
return false;
}
if (jy < MIN_JALALI_YEAR || jy >= MAX_JALALI_YEAR) return false;
if (jm < 1 || jm > 12) return false;
if (jd < 1) return false;
return jd <= jalaliDaysInMonth(jy, jm);
}
/**
* Jalali triple → `YYYY-MM-DD`, or null when the date is not real. Null rather than a throw,
* for the same reason: callers resolve model-supplied values, which may be nonsense.
*/
export function jalaliToIsoDate(
jy: number,
jm: number,
jd: number,
): string | null {
if (!isValidJalaliDate(jy, jm, jd)) return null;
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return `${String(gy).padStart(4, '0')}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
}

View File

@@ -1,5 +1,9 @@
import { appointmentWithinWorkingHours } from './working-hours';
import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time';
import {
civilDateInZone,
isValidIanaTimeZone,
zonedWeekdayAndMinutes,
} from './zoned-civil-time';
describe('zoned civil time', () => {
it('accepts IANA zones and rejects garbage', () => {
@@ -20,10 +24,48 @@ describe('zoned civil time', () => {
const start = new Date('2026-08-20T06:15:00.000Z');
const end = new Date('2026-08-20T06:45:00.000Z');
expect(
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'),
appointmentWithinWorkingHours(
start,
end,
[{ startMinute: 8 * 60, endMinute: 17 * 60 }],
'Asia/Tehran',
),
).toBe(true);
expect(
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'),
appointmentWithinWorkingHours(
start,
end,
[{ startMinute: 8 * 60, endMinute: 17 * 60 }],
'UTC',
),
).toBe(false);
});
describe('civilDateInZone', () => {
it('gives the local civil date, which can differ from the UTC date', () => {
// 21:30 UTC is already the next day in Tehran (+03:30).
const instant = new Date('2025-10-11T21:30:00.000Z');
expect(civilDateInZone(instant, 'UTC')).toBe('2025-10-11');
expect(civilDateInZone(instant, 'Asia/Tehran')).toBe('2025-10-12');
});
it('gives the previous day for zones behind UTC just after midnight', () => {
const instant = new Date('2025-10-11T02:00:00.000Z');
expect(civilDateInZone(instant, 'America/New_York')).toBe('2025-10-10');
expect(civilDateInZone(instant, 'Europe/Amsterdam')).toBe('2025-10-11');
});
it('falls back to UTC on an invalid zone rather than throwing', () => {
// Intl raises RangeError on an unknown zone and this takes a client-supplied string.
const instant = new Date('2025-10-11T21:30:00.000Z');
expect(() => civilDateInZone(instant, 'Not/AZone')).not.toThrow();
expect(civilDateInZone(instant, 'Not/AZone')).toBe('2025-10-11');
expect(civilDateInZone(instant, '')).toBe('2025-10-11');
});
it('zero-pads single-digit months and days', () => {
expect(civilDateInZone(new Date('2025-01-05T12:00:00.000Z'), 'UTC')).toBe(
'2025-01-05',
);
});
});
});

View File

@@ -53,3 +53,24 @@ export function civilDateJsWeekday(isoDate: string): number {
const utcNoon = new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0));
return utcNoon.getUTCDay();
}
/**
* Today's civil date (`YYYY-MM-DD`) in an IANA zone, so the server derives "today" from a
* client-supplied *zone* rather than trusting a client-supplied date.
*/
export function civilDateInZone(date: Date, timeZone: string): string {
// Intl throws RangeError on an unknown zone and this takes a client-supplied string;
// callers validate first, this is the backstop.
const zone = isValidIanaTimeZone(timeZone) ? timeZone : 'UTC';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const year = parts.find((p) => p.type === 'year')?.value ?? '1970';
const month = parts.find((p) => p.type === 'month')?.value ?? '01';
const day = parts.find((p) => p.type === 'day')?.value ?? '01';
return `${year}-${month}-${day}`;
}

View File

@@ -1,13 +1,14 @@
// backend/src/config/configuration.ts
/** Matches values accepted by jsonwebtoken `expiresIn` (via ms), e.g. 7d, 15m, or plain seconds. */
const JWT_TIMESPAN_PATTERN =
/^\d+(\.\d+)?(ms|s|m|h|d|w|y)?$/i;
const JWT_TIMESPAN_PATTERN = /^\d+(\.\d+)?(ms|s|m|h|d|w|y)?$/i;
function assertJwtSecret(value: string, envKey: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`❌ Environment variable ${envKey} is required but not set`);
throw new Error(
`❌ Environment variable ${envKey} is required but not set`,
);
}
if (trimmed.length < 16) {
throw new Error(`${envKey} must be at least 16 characters`);
@@ -21,7 +22,9 @@ function assertJwtSecret(value: string, envKey: string): string {
function assertJwtTimespan(value: string, envKey: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`❌ Environment variable ${envKey} is required but not set`);
throw new Error(
`❌ Environment variable ${envKey} is required but not set`,
);
}
if (!/^\d+$/.test(trimmed) && !JWT_TIMESPAN_PATTERN.test(trimmed)) {
throw new Error(
@@ -31,7 +34,10 @@ function assertJwtTimespan(value: string, envKey: string): string {
return trimmed;
}
function parseEnvBoolean(value: string | undefined, defaultValue: boolean): boolean {
function parseEnvBoolean(
value: string | undefined,
defaultValue: boolean,
): boolean {
if (value === undefined || value.trim() === '') {
return defaultValue;
}
@@ -67,8 +73,37 @@ export interface Config {
apiKey: string | null;
templateId: number;
};
voice: VoiceConfig;
}
/** Only OpenRouter today. The indirection exists so a locale can diverge without code. */
export type VoiceProviderId = 'openrouter';
export type VoiceProfile = {
asr: { provider: VoiceProviderId; model: string };
llm: { provider: VoiceProviderId; model: string };
};
export interface VoiceConfig {
openRouter: {
apiKey: string | null;
baseUrl: string;
};
/**
* Locale → provider profile. A locale absent here has no microphone button at all —
* clean absence rather than a dead control.
*/
profiles: Record<string, VoiceProfile>;
/** Recording cap in ms; null means uncapped. */
maxRecordingMs: number | null;
throttle: { ttl: number; limit: number };
}
export const VOICE_LOCALES = ['fa', 'en', 'nl'] as const;
const DEFAULT_ASR_MODEL = 'openai/whisper-1';
const DEFAULT_LLM_MODEL = 'google/gemini-3.7-flash';
export default (): Config => {
// Helper function to get required env var with type safety
const getEnvVar = (key: string): string => {
@@ -129,5 +164,95 @@ export default (): Config => {
apiKey: process.env.SMS_IR_API_KEY?.trim() || null,
templateId: getEnvVarAsNumber('SMS_IR_TEMPLATE_ID', 123456),
},
voice: buildVoiceConfig(getEnvVarWithDefault, getEnvVarAsNumber),
};
};
};
const VOICE_PROVIDER_IDS: readonly VoiceProviderId[] = ['openrouter'];
/** Unknown provider ids fail at boot; silently coercing a typo would ship the wrong vendor. */
function parseProviderId(
value: string | undefined,
envKey: string,
): VoiceProviderId {
const trimmed = value?.trim();
if (!trimmed) return 'openrouter';
if ((VOICE_PROVIDER_IDS as readonly string[]).includes(trimmed)) {
return trimmed as VoiceProviderId;
}
throw new Error(
`${envKey}="${trimmed}" is not a known voice provider (${VOICE_PROVIDER_IDS.join(', ')})`,
);
}
/**
* Every enabled locale gets its own ASR and LLM provider+model, each independently
* overridable. They all point at the same OpenRouter models today; the per-locale
* indirection stays so a locale can diverge by configuration rather than by code.
*/
function buildVoiceConfig(
getEnvVarWithDefault: (key: string, defaultValue: string) => string,
getEnvVarAsNumber: (key: string, defaultValue: number) => number,
): VoiceConfig {
const enabled = getEnvVarWithDefault(
'VOICE_ENABLED_LOCALES',
VOICE_LOCALES.join(','),
)
.split(',')
.map((locale) => locale.trim().toLowerCase())
.filter(Boolean);
for (const locale of enabled) {
// Fail loudly, like parseProviderId. Silently filtering a typo would disable the
// microphone everywhere with nothing to explain why.
if (!(VOICE_LOCALES as readonly string[]).includes(locale)) {
throw new Error(
`❌ VOICE_ENABLED_LOCALES contains unknown locale "${locale}" (known: ${VOICE_LOCALES.join(', ')})`,
);
}
}
const profiles: Record<string, VoiceProfile> = {};
for (const locale of enabled) {
const suffix = locale.toUpperCase();
profiles[locale] = {
asr: {
provider: parseProviderId(
process.env[`VOICE_ASR_PROVIDER_${suffix}`],
`VOICE_ASR_PROVIDER_${suffix}`,
),
model: getEnvVarWithDefault(
`VOICE_ASR_MODEL_${suffix}`,
getEnvVarWithDefault('VOICE_ASR_MODEL', DEFAULT_ASR_MODEL),
),
},
llm: {
provider: parseProviderId(
process.env[`VOICE_LLM_PROVIDER_${suffix}`],
`VOICE_LLM_PROVIDER_${suffix}`,
),
model: getEnvVarWithDefault(
`VOICE_LLM_MODEL_${suffix}`,
getEnvVarWithDefault('VOICE_LLM_MODEL', DEFAULT_LLM_MODEL),
),
},
};
}
const maxRecordingMs = getEnvVarAsNumber('VOICE_MAX_RECORDING_MS', 120_000);
return {
openRouter: {
apiKey: process.env.OPENROUTER_API_KEY?.trim() || null,
baseUrl: getEnvVarWithDefault(
'OPENROUTER_BASE_URL',
'https://openrouter.ai/api/v1',
),
},
profiles,
maxRecordingMs: maxRecordingMs > 0 ? maxRecordingMs : null,
throttle: {
ttl: getEnvVarAsNumber('VOICE_THROTTLE_TTL', 60),
limit: getEnvVarAsNumber('VOICE_THROTTLE_LIMIT', 6),
},
};
}

View File

@@ -1,6 +1,8 @@
// backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import { urlencoded } from 'express';
import { AppModule } from './app.module';
import { createJsonBodyParser } from './common/body-parsers';
import { ValidationPipe } from '@nestjs/common';
import cookieParser from 'cookie-parser'; // 👈 Change this line!
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@@ -23,7 +25,13 @@ console.log = (...args) => {
};
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// bodyParser is disabled so the JSON parsers can be registered in an explicit order below;
// Nest's built-in one would otherwise reject a voice recording at 100 kb.
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Voice needs a larger JSON limit than everything else; see body-parsers.ts.
app.use(createJsonBodyParser());
app.use(urlencoded({ extended: true }));
app.useGlobalFilters(new HttpExceptionFilter());

View File

@@ -1,9 +1,4 @@
const FDI_TOOTH_IDS = new Set([
'11', '12', '13', '14', '15', '16', '17', '18',
'21', '22', '23', '24', '25', '26', '27', '28',
'31', '32', '33', '34', '35', '36', '37', '38',
'41', '42', '43', '44', '45', '46', '47', '48',
]);
import { FDI_TOOTH_IDS } from '../../common/fdi';
export function normalizeTeeth(teeth: unknown): string[] {
if (!Array.isArray(teeth)) {
@@ -35,7 +30,10 @@ export function normalizeToothSelectionGroups(
for (const row of value) {
if (!row || typeof row !== 'object') continue;
const rec = row as Record<string, unknown>;
const groupId = typeof rec.groupId === 'string' && rec.groupId.trim() ? rec.groupId.trim() : '';
const groupId =
typeof rec.groupId === 'string' && rec.groupId.trim()
? rec.groupId.trim()
: '';
if (!groupId) continue;
const kind = rec.kind === 'connected' ? 'connected' : 'single';
const teeth = normalizeTeeth(rec.teeth);
@@ -64,7 +62,8 @@ export function generateTreatmentTitle(
}
const parts = cases.map((c) => {
const label = c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1);
const label =
c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1);
if (c.teeth.length > 0) {
return `${label} ${c.teeth.join(', ')}`;
}

View File

@@ -0,0 +1,58 @@
import {
IsBase64,
IsIn,
IsInt,
IsString,
MaxLength,
Min,
} from 'class-validator';
import { ErrorCode } from '../../../common/errors/error-codes';
/** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */
export const VOICE_AUDIO_FORMATS = [
'webm',
'mp4',
'm4a',
'aac',
'ogg',
'wav',
'mp3',
'flac',
] as const;
export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
/** Locales the app ships; a profile still has to be configured for one to be usable. */
export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const;
export class ExtractVoiceDto {
/** Base64 audio, no data: prefix. Well above a 2-minute opus clip (~400 KB). */
@IsString()
@IsBase64()
// Both name their own code: the shared map sends `maxLength` to
// VALIDATION_FIELD_REQUIRED and `isIn` to VALIDATION_LANGUAGE_INVALID, neither of which
// is true here.
@MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG })
audio: string;
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
format: VoiceAudioFormat;
/** The clinician's IANA zone; "today" is derived from it, never sent by the client. */
@IsString()
@MaxLength(64)
timeZone: string;
/** Required, not optional — omitting it would bypass VOICE_MAX_RECORDING_MS entirely. */
@IsInt()
@Min(0)
durationMs: number;
/**
* The locale the UI offered the microphone in. Sent explicitly because `user.language` can
* diverge from the URL locale, and a mismatch transcribes Persian with an English hint and
* anchors "next Thursday" to the wrong week start.
*/
@IsIn(VOICE_LOCALES)
locale: string;
}

View File

@@ -0,0 +1,385 @@
import { resolveDueDate, weekStartForLocale } from './due-date.resolver';
import type { DueIntent } from './voice.types';
// 2025-10-11 is a Saturday — the first day of the Iranian week.
const FA_WEEK = 6; // Saturday
const EU_WEEK = 1; // Monday
const SATURDAY = '2025-10-11';
const THURSDAY = '2025-10-16';
describe('resolveDueDate', () => {
describe('weekday intents', () => {
it('resolves "this <weekday>" to the coming occurrence', () => {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
SATURDAY,
FA_WEEK,
);
expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days
});
it('resolves "next <weekday>" to that weekday in the following week', () => {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
SATURDAY,
FA_WEEK,
);
expect(result.dueDate).toBe('2025-10-23');
});
it('anchors "next" to the week, not to "this" plus seven', () => {
// Said on Thursday 2025-10-16, the Iranian week runs Sat 10-18 .. Fri 10-24, so its
// Thursday is 10-23. Adding a week to "this Thursday" (already 10-23) would
// overshoot to 10-30 — a lab case a week late.
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
THURSDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-23');
});
it('lets "this" and "next" coincide when they name the same day', () => {
// On a Thursday, "the coming Saturday" and "Saturday next week" are both 10-18.
for (const which of ['this', 'next'] as const) {
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'saturday', which },
THURSDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-18');
}
});
it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
// A deadline of today is almost never what was meant.
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
THURSDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-23');
});
it('never resolves a weekday into the past', () => {
for (const which of ['this', 'next'] as const) {
const result = resolveDueDate(
{ kind: 'weekday', weekday: 'sunday', which },
THURSDAY,
FA_WEEK,
);
expect(result.dueDate).not.toBeNull();
expect(result.dueDate! > THURSDAY).toBe(true);
}
});
it('anchors "next" to a Monday week for en and nl', () => {
// The same sentence means a different day depending on where the week starts.
// Said on Saturday 10-11: the Monday-start week is 10-13..10-19, Thursday = 10-16.
// The Saturday-start week is 10-18..10-24, Thursday = 10-23.
const intent = {
kind: 'weekday',
weekday: 'thursday',
which: 'next',
} as const;
expect(resolveDueDate(intent, SATURDAY, EU_WEEK).dueDate).toBe(
'2025-10-16',
);
expect(resolveDueDate(intent, SATURDAY, FA_WEEK).dueDate).toBe(
'2025-10-23',
);
});
it('maps each locale to its week start', () => {
expect(weekStartForLocale('fa')).toBe(FA_WEEK);
expect(weekStartForLocale('en')).toBe(EU_WEEK);
expect(weekStartForLocale('nl')).toBe(EU_WEEK);
expect(weekStartForLocale('unknown')).toBe(EU_WEEK);
});
it('treats a missing qualifier as "this" rather than failing', () => {
// A bare weekday carries no qualifier; failing would discard a real spoken deadline.
expect(
resolveDueDate(
{
kind: 'weekday',
weekday: 'thursday',
which: null,
} as unknown as DueIntent,
SATURDAY,
FA_WEEK,
).dueDate,
).toBe(THURSDAY);
});
it('rejects an unknown weekday', () => {
expect(
resolveDueDate(
{
kind: 'weekday',
weekday: 'caturday',
which: 'this',
} as unknown as DueIntent,
SATURDAY,
FA_WEEK,
).dueDate,
).toBeNull();
});
});
describe('offset intents', () => {
it('adds days, weeks and months', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'day', amount: 1 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-12');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'week', amount: 1 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-18');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 1 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe('2025-11-11');
});
it('clamps to the end of a shorter month', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 1 },
'2025-01-31',
).dueDate,
).toBe('2025-02-28');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 1 },
'2024-01-31',
).dueDate,
).toBe('2024-02-29');
});
it('crosses a year boundary', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'month', amount: 3 },
'2025-11-15',
).dueDate,
).toBe('2026-02-15');
expect(
resolveDueDate(
{ kind: 'offset', unit: 'day', amount: 30 },
'2025-12-20',
).dueDate,
).toBe('2026-01-19');
});
it('rejects negative, fractional and absurd amounts', () => {
for (const amount of [-1, 1.5, 10_000, Number.NaN]) {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'day', amount },
SATURDAY,
FA_WEEK,
).dueDate,
).toBeNull();
}
});
});
describe('jalali intents', () => {
it('converts by arithmetic, not inference', () => {
expect(
resolveDueDate(
{ kind: 'jalali', jy: 1404, jm: 7, jd: 25 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-17');
});
it('handles the leap-year Esfand 30', () => {
expect(
resolveDueDate(
{ kind: 'jalali', jy: 1403, jm: 12, jd: 30 },
'2025-03-01',
).dueDate,
).toBe('2025-03-20');
});
it('rejects Esfand 30 in a non-leap year', () => {
const result = resolveDueDate(
{ kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
SATURDAY,
);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.reason).toBe('invalid_date');
});
});
describe('gregorian intents', () => {
it('accepts a real date and rejects an impossible one', () => {
expect(
resolveDueDate(
{ kind: 'gregorian', y: 2025, m: 10, d: 17 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe('2025-10-17');
expect(
resolveDueDate(
{ kind: 'gregorian', y: 2025, m: 2, d: 30 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBeNull();
expect(
resolveDueDate(
{ kind: 'gregorian', y: 2025, m: 13, d: 1 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBeNull();
});
});
describe('guard rails', () => {
it('treats a past date as unresolved', () => {
const result = resolveDueDate(
{ kind: 'gregorian', y: 2020, m: 1, d: 1 },
SATURDAY,
);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.reason).toBe('invalid_date');
});
it('treats a date decades away as unresolved', () => {
expect(
resolveDueDate(
{ kind: 'gregorian', y: 2099, m: 1, d: 1 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBeNull();
});
it('accepts today itself via a zero-day offset', () => {
expect(
resolveDueDate(
{ kind: 'offset', unit: 'day', amount: 0 },
SATURDAY,
FA_WEEK,
).dueDate,
).toBe(SATURDAY);
});
it('reports no due date at all when the model said nothing, without flagging it', () => {
expect(resolveDueDate(null, SATURDAY)).toEqual({
dueDate: null,
unresolved: null,
});
expect(resolveDueDate(undefined, SATURDAY)).toEqual({
dueDate: null,
unresolved: null,
});
});
it('treats an object with no kind as no deadline, not a lost one', () => {
// A blank "heard but lost" row in front of a clinician who never mentioned a
// deadline is worse than saying nothing.
expect(resolveDueDate({} as never, SATURDAY)).toEqual({
dueDate: null,
unresolved: null,
});
});
it('flags an unrecognised kind and names it', () => {
const result = resolveDueDate({ kind: 'lunar_month' } as never, SATURDAY);
expect(result.dueDate).toBeNull();
expect(result.unresolved).toEqual({
spoken: 'lunar_month',
reason: 'invalid_date',
});
});
it('flags a non-object deadline instead of silently dropping it', () => {
// A bare string is a deadline we failed to parse, not an absent one — the clinician
// must see that something was heard and lost.
for (const bad of ['next thursday', 42, true]) {
const result = resolveDueDate(bad as never, SATURDAY);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.reason).toBe('invalid_date');
}
expect(
resolveDueDate('next thursday' as never, SATURDAY).unresolved?.spoken,
).toBe('next thursday');
});
it('degrades rather than throwing on a malformed today or intent', () => {
expect(
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, 'not-a-date')
.dueDate,
).toBeNull();
expect(
resolveDueDate({ kind: 'nope' } as unknown as DueIntent, SATURDAY)
.dueDate,
).toBeNull();
});
it('echoes what was heard so the review sheet can show it', () => {
const result = resolveDueDate(
{ kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
SATURDAY,
);
expect(result.unresolved?.spoken).toBe('1404/12/30');
});
});
});
describe('what an unresolvable deadline quotes back', () => {
// The wire shape allows nulls in every field and toVoiceIntent casts rather than
// checks, so these reach the resolver intact. The sheet renders `spoken` verbatim.
it('never puts "null" or "NaN" in front of the clinician', () => {
const bad = [
{ kind: 'weekday', weekday: null, which: null },
{ kind: 'offset', unit: null, amount: null },
{ kind: 'offset', unit: 'day', amount: Number.NaN },
{ kind: 'jalali', jy: null, jm: 7, jd: 25 },
{ kind: 'gregorian', y: 2026, m: null, d: null },
];
for (const intent of bad) {
const result = resolveDueDate(
intent as unknown as DueIntent,
SATURDAY,
FA_WEEK,
);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.spoken ?? '').not.toMatch(/null|NaN/);
}
});
it('still quotes a deadline it did understand the words of', () => {
const result = resolveDueDate(
{
kind: 'weekday',
weekday: 'thursday',
which: null,
} as unknown as DueIntent,
SATURDAY,
FA_WEEK,
);
// A weekday with no "this/next" resolves, so nothing is quoted back at all.
expect(result.dueDate).not.toBeNull();
});
});

View File

@@ -0,0 +1,234 @@
import { jalaliToIsoDate } from '../../common/jalali';
import { civilDateJsWeekday } from '../../common/zoned-civil-time';
import type { DueIntent, UnresolvedItem, Weekday } from './voice.types';
/**
* Spoken deadline → ISO civil date.
*
* The model never does calendar arithmetic; it says what it heard and this decides what
* that means. Jalali conversion in particular is arithmetic here, not inference — an LLM
* asked to turn "۲۵ مهر" into ISO answers confidently and is often wrong, and
* `@IsDateString()` would accept the wrong answer.
*
* Works entirely in civil dates. The caller derives `todayIso` from the actor's IANA zone
* (see `civilDateInZone`) rather than passing a zone in here, so nothing in this file has
* to reason about instants.
*/
const WEEKDAY_TO_JS: Record<Weekday, number> = {
saturday: 6,
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
};
const MAX_DAYS_AHEAD = 365 * 5;
export type DueResolution = {
dueDate: string | null;
unresolved: UnresolvedItem | null;
};
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function isRealCivilDate(iso: string): boolean {
if (!ISO_DATE.test(iso)) return false;
const [y, m, d] = iso.split('-').map(Number);
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
const probe = new Date(Date.UTC(y, m - 1, d));
return (
probe.getUTCFullYear() === y &&
probe.getUTCMonth() === m - 1 &&
probe.getUTCDate() === d
);
}
function toIso(utcMs: number): string {
return new Date(utcMs).toISOString().slice(0, 10);
}
function utcMsOf(iso: string): number {
const [y, m, d] = iso.split('-').map(Number);
return Date.UTC(y, m - 1, d);
}
function addDays(iso: string, days: number): string {
return toIso(utcMsOf(iso) + days * 86_400_000);
}
/** Calendar-month addition with end-of-month clamping (31 Jan + 1 month = 28/29 Feb). */
function addMonths(iso: string, months: number): string {
const [y, m, d] = iso.split('-').map(Number);
const targetMonthIndex = m - 1 + months;
const targetYear = y + Math.floor(targetMonthIndex / 12);
const targetMonth = ((targetMonthIndex % 12) + 12) % 12;
const lastDay = new Date(
Date.UTC(targetYear, targetMonth + 1, 0),
).getUTCDate();
return toIso(Date.UTC(targetYear, targetMonth, Math.min(d, lastDay)));
}
function unresolved(spoken: string): DueResolution {
return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } };
}
/**
* What to quote back when a deadline could not be resolved. Every field is nullable on the
* wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim —
* so a half-classified deadline must fall back to '', not to `"null null"`.
*/
function describe(intent: DueIntent): string {
const usable = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
switch (intent?.kind) {
case 'weekday':
// `which` is legitimately null (it means "this"), the weekday is not.
return [intent.which, intent.weekday]
.filter((part) => typeof part === 'string')
.join(' ');
case 'offset':
return usable(intent.amount)
? `+${intent.amount} ${intent.unit ?? ''}`.trim()
: '';
case 'jalali':
return [intent.jy, intent.jm, intent.jd].every(usable)
? `${intent.jy}/${intent.jm}/${intent.jd}`
: '';
case 'gregorian':
return [intent.y, intent.m, intent.d].every(usable)
? `${intent.y}-${intent.m}-${intent.d}`
: '';
default: {
// An unrecognised `kind`, already established as a string — echo what was heard.
const kind = (intent as { kind?: unknown })?.kind;
return typeof kind === 'string' ? kind : '';
}
}
}
/**
* "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
* Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's
* deadline a week out.
*/
const WEEK_START_BY_LOCALE: Record<string, number> = {
fa: WEEKDAY_TO_JS.saturday,
en: WEEKDAY_TO_JS.monday,
nl: WEEKDAY_TO_JS.monday,
};
const DEFAULT_WEEK_START = WEEKDAY_TO_JS.monday;
export function weekStartForLocale(locale: string): number {
return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
}
function startOfWeek(iso: string, weekStartJs: number): string {
const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
return addDays(iso, -back);
}
/**
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can
* never resolve into the past.
*
* `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots
* by seven days whenever `'this'` has already rolled into next week. The two legitimately
* coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day.
*/
function resolveWeekday(
intent: Extract<DueIntent, { kind: 'weekday' }>,
todayIso: string,
weekStartJs: number,
) {
const targetJs = WEEKDAY_TO_JS[intent.weekday];
if (targetJs === undefined) return null;
// A bare weekday ("پنجشنبه") carries no qualifier, and the model may leave `which`
// null. Treat that as 'this' rather than failing an utterance that named a real day.
const which = intent.which === 'next' ? 'next' : 'this';
if (which === 'this') {
const todayJs = civilDateJsWeekday(todayIso);
let delta = (targetJs - todayJs + 7) % 7;
if (delta === 0) delta = 7;
return addDays(todayIso, delta);
}
if (which === 'next') {
const offsetInWeek = (targetJs - weekStartJs + 7) % 7;
return addDays(startOfWeek(todayIso, weekStartJs), 7 + offsetInWeek);
}
return null;
}
function resolveOffset(
intent: Extract<DueIntent, { kind: 'offset' }>,
todayIso: string,
) {
const { amount, unit } = intent;
if (!Number.isInteger(amount) || amount < 0) return null;
if (unit === 'day')
return amount <= MAX_DAYS_AHEAD ? addDays(todayIso, amount) : null;
if (unit === 'week')
return amount <= 260 ? addDays(todayIso, amount * 7) : null;
if (unit === 'month')
return amount <= 60 ? addMonths(todayIso, amount) : null;
return null;
}
export function resolveDueDate(
intent: DueIntent | null | undefined,
todayIso: string,
weekStartJs: number = DEFAULT_WEEK_START,
): DueResolution {
// Absent is not an error — most utterances carry no deadline.
if (intent === null || intent === undefined) {
return { dueDate: null, unresolved: null };
}
if (typeof intent !== 'object') {
return unresolved(String(intent).slice(0, 120));
}
// No `kind` at all says nothing about a deadline, so it is not "heard but lost". An
// *unrecognised* kind did try to say something, and is flagged below.
if (typeof (intent as { kind?: unknown }).kind !== 'string') {
return { dueDate: null, unresolved: null };
}
if (!isRealCivilDate(todayIso)) {
return unresolved(describe(intent));
}
let resolved: string | null = null;
switch (intent.kind) {
case 'weekday':
resolved = resolveWeekday(intent, todayIso, weekStartJs);
break;
case 'offset':
resolved = resolveOffset(intent, todayIso);
break;
case 'jalali':
resolved = jalaliToIsoDate(intent.jy, intent.jm, intent.jd);
break;
case 'gregorian': {
const candidate = `${String(intent.y).padStart(4, '0')}-${String(intent.m).padStart(2, '0')}-${String(intent.d).padStart(2, '0')}`;
resolved = isRealCivilDate(candidate) ? candidate : null;
break;
}
default:
resolved = null;
}
if (!resolved) return unresolved(describe(intent));
// A date the model invented can land anywhere; past or decades away is not a deadline.
const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000;
if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD)
return unresolved(describe(intent));
return { dueDate: resolved, unresolved: null };
}

View File

@@ -0,0 +1,93 @@
import type { ExtractionCatalog } from './voice.providers';
/** Locale-specific guidance. Only the tooth vocabulary and numbering habits differ. */
const LOCALE_NOTES: Record<string, string> = {
fa: [
'The clinician is speaking Persian. A tooth number can be said as a whole number',
'("بیست و شش" = 26), digit by digit ("دو شش" = 26), or with a lead-in',
'("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy',
'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:',
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
].join(' '),
nl: [
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
].join(' '),
en: [
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
'all tooth 26. The descriptive form is "upper right six".',
].join(' '),
};
function codeList(entries: { code: string; label: string }[]): string {
if (entries.length === 0) return '(none available)';
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n');
}
export function buildExtractionPrompt(
transcript: string,
catalog: ExtractionCatalog,
localeHint: string,
) {
const localeNote = LOCALE_NOTES[localeHint] ?? LOCALE_NOTES.en;
const system = [
'You extract structured dental treatment data from a transcript of a clinician speaking.',
'You are a parser, not an assistant: report only what was said.',
'',
'HARD RULES',
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
' must be codes from the lists below. labId must be an id from the lab list. If what you',
' heard is not in a list, use null.',
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
" flip to the viewer's point of view.",
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
' If no deadline was mentioned, use due.kind = "none".',
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
' what was heard.',
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.',
'',
'TOOTH NUMBERS',
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
'two digits. FDI is built from the two digits:',
" first digit = quadrant, from the PATIENT's own point of view —",
' 1 upper right, 2 upper left, 3 lower left, 4 lower right.',
' (5-8 are those same four quadrants in primary/deciduous teeth.)',
' second digit = position from the midline — 1 central incisor ... 8 third molar.',
'So 26 is the upper left first molar, and 47 is the lower right second molar.',
'',
'- Use "arch" + "side" + "position" only when the tooth is DESCRIBED rather than',
' numbered ("upper right six" -> arch "upper", side "patient_right", position 6).',
'- A single digit is a position, never an FDI code. If a single digit is said with no',
' quadrant words at all, set "position" and leave "arch" and "side" null. Never pick a',
' quadrant that was not said.',
'- If a number is given AND the quadrant is spelled out as well, still use "fdi".',
'- Not every number is a tooth. Dates, counts and quantities ("two teeth", "the 26th")',
' are not teeth, and must never appear in the teeth list.',
'',
localeNote,
'',
'TREATMENT TYPE CODES',
codeList(catalog.treatmentTypes),
'',
'PROSTHESIS TYPE CODES',
codeList(catalog.prosthesisTypes),
'',
'LABS THIS CLINIC CAN SEND TO',
catalog.labs.length > 0
? catalog.labs.map((l) => `- ${l.id} = ${l.name}`).join('\n')
: '(none linked — labId must be null)',
'',
'OTHER FIELDS',
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
' treatment type, teeth or deadline.',
'- labMatchExact: true only when the spoken name matched a lab name exactly.',
].join('\n');
return [
{ role: 'system' as const, content: system },
{ role: 'user' as const, content: transcript },
];
}

View File

@@ -0,0 +1,356 @@
import {
resolveConnectedSpans,
resolveProsthesis,
resolveVoiceIntent,
type ResolveContext,
} from './extraction.resolver';
import type { ToothIntent, VoiceIntent } from './voice.types';
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
kind: 'explicit',
fdi,
spoken,
});
const CTX: ResolveContext = {
todayIso: '2025-10-11',
weekStartJs: 6, // Saturday — the fa week
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
};
describe('resolveConnectedSpans', () => {
it('selects the teeth between the endpoints, which were never named', () => {
// "a bridge from 14 to 16" must select 15 too.
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('16') }],
[],
);
expect(result.teeth).toEqual(['14', '15', '16']);
expect(result.groups).toEqual([
{ groupId: 'voice-c1', kind: 'connected', teeth: ['16', '15', '14'] },
]);
});
it('orders group teeth along the arch, not lexically', () => {
const result = resolveConnectedSpans(
[{ from: tooth('16'), to: tooth('14') }],
[],
);
expect(result.groups[0].teeth).toEqual(['16', '15', '14']);
});
it('spans the midline', () => {
const result = resolveConnectedSpans(
[{ from: tooth('12'), to: tooth('22') }],
[],
);
expect(result.groups[0].teeth).toEqual(['12', '11', '21', '22']);
});
it('merges overlapping spans into one bridge', () => {
const result = resolveConnectedSpans(
[
{ from: tooth('14'), to: tooth('16') },
{ from: tooth('15'), to: tooth('17') },
],
[],
);
const connected = result.groups.filter((g) => g.kind === 'connected');
expect(connected).toHaveLength(1);
expect(connected[0].teeth).toEqual(['17', '16', '15', '14']);
});
it('gives loose teeth their own single groups', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('15') }],
['26'],
);
expect(result.groups).toEqual([
{ groupId: 'voice-c1', kind: 'connected', teeth: ['15', '14'] },
{ groupId: 'voice-s-26', kind: 'single', teeth: ['26'] },
]);
});
it('never produces a one-tooth connected group', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('14') }],
[],
);
expect(result.groups).toEqual([
{ groupId: 'voice-s-14', kind: 'single', teeth: ['14'] },
]);
expect(result.unresolved).toEqual([]);
});
it('reports a cross-arch span rather than guessing', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14', 'چهارده'), to: tooth('44', 'چهل و چهار') }],
[],
);
expect(result.groups).toEqual([]);
expect(result.unresolved).toEqual([
{ spoken: 'چهارده → چهل و چهار', reason: 'span_not_same_arch' },
]);
});
it('reports a span with an unresolvable endpoint', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('99') }],
[],
);
expect(result.unresolved[0].reason).toBe('malformed');
});
it('survives a non-array', () => {
expect(resolveConnectedSpans(undefined as never, ['14']).teeth).toEqual([
'14',
]);
});
});
describe('resolveProsthesis', () => {
const allowed = CTX.prosthesisTypeCodes;
it('expands the default across every tooth', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
['14', '15'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'15': 'monolithic_zirconia',
});
expect(result.prosthesis?.complete).toBe(true);
});
it('applies per-tooth overrides on top of the default', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
},
['14', '26'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'26': 'pfm_crown',
});
expect(result.prosthesis?.complete).toBe(true);
});
it('marks the map incomplete when a tooth ends up untyped', () => {
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
const result = resolveProsthesis(
{
defaultType: null,
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
},
['14', '15'],
allowed,
);
expect(result.prosthesis?.complete).toBe(false);
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
});
it('rejects a catalog code the clinic does not have', () => {
const result = resolveProsthesis(
{ defaultType: 'gold_foil', overrides: [] },
['14'],
allowed,
);
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
]);
});
it('ignores an override for a tooth that is not selected', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
},
['14'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
expect(result.unresolved).toEqual([
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
]);
});
it('reports no prosthesis at all when the object carries nothing usable', () => {
// An empty-but-present map would paint a plain restoration with a fabricated
// "incomplete, cannot ship" warning.
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
expect(
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
).toBeNull();
}
});
it('reports no prosthesis when there are no teeth to type', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
[],
allowed,
);
expect(result.prosthesis).toBeNull();
});
it('distinguishes a tooth it could not understand from one that is not selected', () => {
// Different corrective actions: add the tooth, versus repeat yourself.
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [
{
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
type: 'pfm_crown',
},
],
},
['14'],
allowed,
);
expect(result.unresolved).toEqual([
{ spoken: 'نود و نه', reason: 'malformed' },
]);
});
it('returns null when no prosthesis was spoken', () => {
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
});
});
describe('resolveVoiceIntent', () => {
const base: VoiceIntent = {
treatmentType: 'restoration',
teeth: [tooth('14'), tooth('15')],
connectedSpans: [],
comment: ' حساسیت به سرما ',
prosthesis: null,
labId: null,
labMatchExact: false,
due: null,
};
it('composes a plain restoration', () => {
const result = resolveVoiceIntent(base, CTX);
expect(result.treatmentType).toBe('restoration');
expect(result.teeth).toEqual(['14', '15']);
expect(result.comment).toBe('حساسیت به سرما');
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([]);
});
it('rejects a treatment type outside the catalog', () => {
const result = resolveVoiceIntent(
{ ...base, treatmentType: 'teeth_whitening' },
CTX,
);
expect(result.treatmentType).toBeNull();
expect(result.unresolved).toContainEqual({
spoken: 'teeth_whitening',
reason: 'unknown_catalog_code',
});
});
it('drops a lab id the clinic is not linked to', () => {
// Shipping to a lab the clinic never named is worse than shipping nowhere.
const result = resolveVoiceIntent(
{ ...base, labId: 'lab-elsewhere', labMatchExact: true },
CTX,
);
expect(result.labId).toBeNull();
expect(result.labMatchExact).toBe(false);
});
it('keeps a linked lab and its exactness flag', () => {
const result = resolveVoiceIntent(
{ ...base, labId: 'lab-sina', labMatchExact: true },
CTX,
);
expect(result.labId).toBe('lab-sina');
expect(result.labMatchExact).toBe(true);
});
it('reports a hallucinated lab rather than dropping it silently', () => {
// A near-miss lab id must not look identical to "no lab was spoken".
const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX);
// The id is not what the clinician said — quoting it back shows them a raw UUID.
expect(result.unresolved).toContainEqual({
spoken: '',
reason: 'unknown_catalog_code',
});
});
it('never reports an inexact match as exact when the lab was dropped', () => {
const result = resolveVoiceIntent(
{ ...base, labId: null, labMatchExact: true },
CTX,
);
expect(result.labMatchExact).toBe(false);
});
it('applies prosthesis over the span-expanded tooth set', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'prosthesis',
teeth: [tooth('14')],
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
},
CTX,
);
// 15 was never spoken but is part of the bridge, so it must carry a type too.
expect(result.teeth).toEqual(['14', '15', '16']);
expect(result.prosthesis?.complete).toBe(true);
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
'14',
'15',
'16',
]);
});
it('resolves a due date through the same context', () => {
const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
CTX,
);
expect(result.dueDate).toBe('2025-10-16');
});
it('collects unresolved items from every stage', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'nope',
teeth: [tooth('51', 'شیری')],
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
},
CTX,
);
const reasons = result.unresolved.map((u) => u.reason).sort();
expect(reasons).toEqual([
'invalid_date',
'not_permanent_tooth',
'span_not_same_arch',
'unknown_catalog_code',
]);
});
it('treats an empty comment as absent', () => {
expect(
resolveVoiceIntent({ ...base, comment: ' ' }, CTX).comment,
).toBeNull();
});
});

View File

@@ -0,0 +1,300 @@
import {
sameArch,
sortInArchOrder,
teethBetweenInclusive,
} from '../../common/fdi';
import { resolveDueDate } from './due-date.resolver';
import {
resolveToothIntent,
resolveToothIntents,
} from './tooth-intent.resolver';
import type {
ConnectedSpanIntent,
ProsthesisIntent,
ToothIntent,
UnresolvedItem,
VoiceIntent,
} from './voice.types';
export type ResolvedToothGroup = {
groupId: string;
kind: 'connected' | 'single';
teeth: string[];
};
export type ResolvedProsthesis = {
/** FDI code → prosthesis type code. */
byTooth: Record<string, string>;
/**
* True when every selected tooth carries a code. A prosthesis detail cannot be shipped
* otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap
* here rather than letting it fail at dispatch.
*/
complete: boolean;
missingTeeth: string[];
};
export type ResolvedExtraction = {
treatmentType: string | null;
teeth: string[];
toothSelectionGroups: ResolvedToothGroup[];
comment: string | null;
prosthesis: ResolvedProsthesis | null;
labId: string | null;
labMatchExact: boolean;
dueDate: string | null;
unresolved: UnresolvedItem[];
};
export type ResolveContext = {
todayIso: string;
/** JS weekday index the clinician's week starts on — see weekStartForLocale. */
weekStartJs: number;
treatmentTypeCodes: ReadonlySet<string>;
prosthesisTypeCodes: ReadonlySet<string>;
linkedLabIds: ReadonlySet<string>;
};
function spokenOf(intent: ToothIntent): string {
const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}
/** A code the model returned is only usable if it exists in the catalog we supplied it. */
function resolveCatalogCode(
value: unknown,
allowed: ReadonlySet<string>,
): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed && allowed.has(trimmed) ? trimmed : null;
}
/** Merge any span sets that share a tooth, so overlapping bridges become one group. */
function mergeOverlapping(sets: string[][]): string[][] {
const merged: string[][] = [];
for (const candidate of sets) {
let current = [...candidate];
let index = 0;
while (index < merged.length) {
if (merged[index].some((tooth) => current.includes(tooth))) {
current = [...new Set([...merged[index], ...current])];
merged.splice(index, 1);
index = 0;
continue;
}
index += 1;
}
merged.push(current);
}
return merged;
}
/**
* Span teeth join the selection: "a bridge from 14 to 16" selects 15 though it was never
* named. A cross-arch span is reported rather than guessed at, and a span collapsing to one
* tooth degrades to a single — there is no one-tooth bridge.
*/
export function resolveConnectedSpans(
spans: readonly ConnectedSpanIntent[],
selectedTeeth: readonly string[],
): {
groups: ResolvedToothGroup[];
teeth: string[];
unresolved: UnresolvedItem[];
} {
const unresolved: UnresolvedItem[] = [];
const connectedSets: string[][] = [];
// A span that collapses to one tooth still selected that tooth — it must not vanish.
const loneSpanTeeth: string[] = [];
const list: readonly ConnectedSpanIntent[] = Array.isArray(spans)
? (spans as readonly ConnectedSpanIntent[])
: [];
for (const span of list) {
const from = resolveToothIntent(span?.from);
const to = resolveToothIntent(span?.to);
const spoken = [spokenOf(span?.from), spokenOf(span?.to)]
.filter(Boolean)
.join(' → ');
if (!from || !to) {
unresolved.push({ spoken, reason: 'malformed' });
continue;
}
if (!sameArch(from, to)) {
unresolved.push({ spoken, reason: 'span_not_same_arch' });
continue;
}
const between = teethBetweenInclusive(from, to);
if (!between || between.length === 0) {
unresolved.push({ spoken, reason: 'malformed' });
continue;
}
if (between.length === 1) {
loneSpanTeeth.push(between[0]);
continue; // degrades to a single, below
}
connectedSets.push(between);
}
const groups: ResolvedToothGroup[] = [];
const claimed = new Set<string>();
mergeOverlapping(connectedSets).forEach((set, i) => {
const teeth = sortInArchOrder(set);
teeth.forEach((tooth) => claimed.add(tooth));
groups.push({ groupId: `voice-c${i + 1}`, kind: 'connected', teeth });
});
const spanTeeth = [...groups.flatMap((g) => g.teeth), ...loneSpanTeeth];
const singles = [...new Set([...selectedTeeth, ...spanTeeth])]
.filter((tooth) => !claimed.has(tooth))
.sort();
for (const tooth of singles) {
groups.push({
groupId: `voice-s-${tooth}`,
kind: 'single',
teeth: [tooth],
});
}
return {
groups,
teeth: [...new Set([...selectedTeeth, ...spanTeeth])].sort(),
unresolved,
};
}
/**
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is
* how clinicians actually speak.
*/
export function resolveProsthesis(
intent: ProsthesisIntent | null | undefined,
teeth: readonly string[],
allowed: ReadonlySet<string>,
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
if (!intent || typeof intent !== 'object')
return { prosthesis: null, unresolved: [] };
const unresolved: UnresolvedItem[] = [];
const defaultType = resolveCatalogCode(intent.defaultType, allowed);
if (intent.defaultType != null && !defaultType) {
unresolved.push({
spoken: String(intent.defaultType),
reason: 'unknown_catalog_code',
});
}
const byTooth: Record<string, string> = {};
const selected = new Set(teeth);
if (defaultType) {
for (const tooth of teeth) byTooth[tooth] = defaultType;
}
const overrides: ProsthesisIntent['overrides'] = Array.isArray(
intent.overrides,
)
? intent.overrides
: [];
for (const override of overrides) {
const tooth = resolveToothIntent(override?.tooth);
const type = resolveCatalogCode(override?.type, allowed);
const spoken = spokenOf(override?.tooth) || String(override?.type ?? '');
if (!tooth) {
unresolved.push({ spoken, reason: 'malformed' });
continue;
}
// A tooth we understood perfectly well but which is not part of this detail. Saying
// so is actionable ("add tooth 37, or drop it"); calling it malformed is not.
if (!selected.has(tooth)) {
unresolved.push({ spoken, reason: 'tooth_not_selected' });
continue;
}
if (!type) {
unresolved.push({ spoken, reason: 'unknown_catalog_code' });
continue;
}
byTooth[tooth] = type;
}
// Nothing usable was said about prosthesis. Returning an empty-but-present map would
// paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
if (Object.keys(byTooth).length === 0) {
return { prosthesis: null, unresolved };
}
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
return {
prosthesis: {
byTooth,
complete: missingTeeth.length === 0,
missingTeeth,
},
unresolved,
};
}
/** Compose every resolver into the payload the review sheet renders. */
export function resolveVoiceIntent(
intent: VoiceIntent,
ctx: ResolveContext,
): ResolvedExtraction {
const unresolved: UnresolvedItem[] = [];
const toothResult = resolveToothIntents(intent?.teeth ?? []);
unresolved.push(...toothResult.unresolved);
const spanResult = resolveConnectedSpans(
intent?.connectedSpans ?? [],
toothResult.teeth,
);
unresolved.push(...spanResult.unresolved);
const treatmentType = resolveCatalogCode(
intent?.treatmentType,
ctx.treatmentTypeCodes,
);
if (intent?.treatmentType != null && !treatmentType) {
unresolved.push({
spoken: String(intent.treatmentType),
reason: 'unknown_catalog_code',
});
}
const prosthesisResult = resolveProsthesis(
intent?.prosthesis,
spanResult.teeth,
ctx.prosthesisTypeCodes,
);
unresolved.push(...prosthesisResult.unresolved);
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
if (due.unresolved) unresolved.push(due.unresolved);
const comment =
typeof intent?.comment === 'string' && intent.comment.trim()
? intent.comment.trim()
: null;
// An invented lab id would ship a case to a lab the clinic never named. A rejected one is
// reported, so it cannot look identical to "no lab was spoken".
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
if (intent?.labId != null && !labId) {
// `spoken` is what the clinician said — quoting an invented id back would put a raw
// UUID in front of the user.
unresolved.push({ spoken: '', reason: 'unknown_catalog_code' });
}
return {
treatmentType,
teeth: spanResult.teeth,
toothSelectionGroups: spanResult.groups,
comment,
prosthesis: prosthesisResult.prosthesis,
labId,
labMatchExact: labId ? intent?.labMatchExact === true : false,
dueDate: due.dueDate,
unresolved,
};
}

View File

@@ -0,0 +1,236 @@
import {
toVoiceIntent,
VOICE_INTENT_JSON_SCHEMA,
type WireVoiceIntent,
} from './extraction.wire';
const emptyDue = {
kind: 'none' as const,
weekday: null,
which: null,
unit: null,
amount: null,
jy: null,
jm: null,
jd: null,
y: null,
m: null,
d: null,
};
const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
treatmentType: null,
teeth: [],
connectedSpans: [],
comment: null,
prosthesisDefaultType: null,
prosthesisOverrides: [],
labId: null,
labMatchExact: false,
due: emptyDue,
...overrides,
});
const positionalTooth = {
spoken: 'شش بالا راست',
fdi: null,
arch: 'upper' as const,
side: 'patient_right' as const,
position: 6,
};
describe('VOICE_INTENT_JSON_SCHEMA', () => {
it('is strict — every property required, no extras', () => {
expect(VOICE_INTENT_JSON_SCHEMA.additionalProperties).toBe(false);
expect([...VOICE_INTENT_JSON_SCHEMA.required].sort()).toEqual(
Object.keys(VOICE_INTENT_JSON_SCHEMA.properties).sort(),
);
});
it('requires every field of the due object, since strict mode allows no optionals', () => {
const due = VOICE_INTENT_JSON_SCHEMA.properties.due;
expect([...due.required].sort()).toEqual(
Object.keys(due.properties).sort(),
);
});
});
describe('toVoiceIntent', () => {
it('takes the explicit branch for a code the model wrote in Persian digits', () => {
// The model is reading Persian text back, so "۲۶" and a digit-by-digit "2 6" both
// reach us. Matching only ASCII drops the tooth into the positional branch with no
// quadrant, where it is reported as unresolved — the clinician loses a tooth and is
// told the words were the problem.
for (const raw of ['\u06F2\u06F6', '2 6', ' 26 ', '\u0662\u0666']) {
const [tooth] = toVoiceIntent(
wire({
teeth: [
{
spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634',
fdi: raw,
arch: null,
side: null,
position: null,
},
],
}),
).teeth;
expect(tooth).toEqual({
kind: 'explicit',
fdi: '26',
spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634',
});
}
});
it('narrows a positional tooth', () => {
const result = toVoiceIntent(wire({ teeth: [positionalTooth] }));
expect(result.teeth[0]).toEqual({
kind: 'positional',
arch: 'upper',
side: 'patient_right',
position: 6,
spoken: 'شش بالا راست',
});
});
it('narrows an explicit FDI tooth, which wins over positional fields', () => {
const result = toVoiceIntent(
wire({ teeth: [{ ...positionalTooth, fdi: '14', spoken: 'یک چهار' }] }),
);
expect(result.teeth[0]).toEqual({
kind: 'explicit',
fdi: '14',
spoken: 'یک چهار',
});
});
it('maps due.kind "none" to no deadline', () => {
expect(toVoiceIntent(wire()).due).toBeNull();
});
it('narrows each due kind', () => {
expect(
toVoiceIntent(
wire({
due: {
...emptyDue,
kind: 'weekday',
weekday: 'thursday',
which: 'next',
},
}),
).due,
).toEqual({ kind: 'weekday', weekday: 'thursday', which: 'next' });
expect(
toVoiceIntent(
wire({ due: { ...emptyDue, kind: 'offset', unit: 'week', amount: 2 } }),
).due,
).toEqual({ kind: 'offset', unit: 'week', amount: 2 });
expect(
toVoiceIntent(
wire({ due: { ...emptyDue, kind: 'jalali', jy: 1404, jm: 7, jd: 25 } }),
).due,
).toEqual({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 });
expect(
toVoiceIntent(
wire({
due: { ...emptyDue, kind: 'gregorian', y: 2025, m: 10, d: 17 },
}),
).due,
).toEqual({ kind: 'gregorian', y: 2025, m: 10, d: 17 });
});
it('ignores a non-FDI-shaped fdi and keeps the positional fields', () => {
// A model emitting fdi:"6" alongside correct arch/side/position must still yield 16,
// not lose the tooth to the explicit branch.
const result = toVoiceIntent(
wire({ teeth: [{ ...positionalTooth, fdi: '6' }] }),
);
expect(result.teeth[0]).toEqual({
kind: 'positional',
arch: 'upper',
side: 'patient_right',
position: 6,
spoken: 'شش بالا راست',
});
});
it('rejects impossible FDI shapes from the explicit branch', () => {
for (const fdi of ['99', '0', '140', '9', 'ab']) {
expect(
toVoiceIntent(wire({ teeth: [{ ...positionalTooth, fdi }] })).teeth[0]
.kind,
).toBe('positional');
}
});
it('passes an unrecognised due kind through so it can be flagged', () => {
// Collapsing it to null would make a misunderstood deadline indistinguishable from
// no deadline at all, and the resolver's flagging path unreachable.
const result = toVoiceIntent(
wire({ due: { ...emptyDue, kind: 'lunar_month' as never } }),
);
expect(result.due).toEqual({ kind: 'lunar_month' });
});
it('reports no prosthesis when neither a default nor an override was given', () => {
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
});
it('builds a prosthesis intent from a default alone', () => {
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
expect(result.prosthesis).toEqual({
defaultType: 'pfm_crown',
overrides: [],
});
});
it('builds a prosthesis intent from overrides alone', () => {
const result = toVoiceIntent(
wire({
prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }],
}),
);
expect(result.prosthesis?.defaultType).toBeNull();
expect(result.prosthesis?.overrides).toHaveLength(1);
});
it('narrows connected spans', () => {
const result = toVoiceIntent(
wire({
connectedSpans: [
{
from: { ...positionalTooth, fdi: '14' },
to: { ...positionalTooth, fdi: '16' },
},
],
}),
);
expect(result.connectedSpans[0].from).toEqual({
kind: 'explicit',
fdi: '14',
spoken: 'شش بالا راست',
});
});
it('is total — a missing or malformed payload yields a resolvable shape, not a throw', () => {
// Whatever survives here is reported as unresolved downstream rather than crashing.
for (const bad of [undefined, null, {}, { teeth: 'nope', due: 5 }]) {
expect(() => toVoiceIntent(bad as never)).not.toThrow();
const result = toVoiceIntent(bad as never);
expect(result.teeth).toEqual([]);
expect(result.due).toBeNull();
expect(result.labMatchExact).toBe(false);
}
});
it('coerces a non-boolean labMatchExact to false', () => {
expect(
toVoiceIntent(wire({ labMatchExact: 'yes' as never })).labMatchExact,
).toBe(false);
});
});

View File

@@ -0,0 +1,273 @@
import { normalizeFdiCode } from '../../common/fdi';
import type {
ConnectedSpanIntent,
DueIntent,
ProsthesisIntent,
ToothIntent,
VoiceIntent,
Weekday,
} from './voice.types';
import { WEEKDAYS } from './voice.types';
/**
* Deliberately flat: strict `json_schema` mode has poor support for discriminated unions,
* so every variant field is present and nullable. `toVoiceIntent` narrows it into the
* internal union and is total — anything it cannot classify becomes a shape the resolvers
* report as unresolved rather than something that throws here.
*/
export type WireToothIntent = {
spoken: string;
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
fdi: string | null;
arch: 'upper' | 'lower' | null;
side: 'patient_right' | 'patient_left' | null;
position: number | null;
};
export type WireDue = {
kind: 'weekday' | 'offset' | 'jalali' | 'gregorian' | 'none';
weekday: Weekday | null;
which: 'this' | 'next' | null;
unit: 'day' | 'week' | 'month' | null;
amount: number | null;
jy: number | null;
jm: number | null;
jd: number | null;
y: number | null;
m: number | null;
d: number | null;
};
export type WireVoiceIntent = {
treatmentType: string | null;
teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null;
prosthesisDefaultType: string | null;
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
labId: string | null;
labMatchExact: boolean;
due: WireDue;
};
const TOOTH_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['spoken', 'fdi', 'arch', 'side', 'position'],
properties: {
spoken: {
type: 'string',
description: 'The exact transcript words for this tooth.',
},
fdi: {
type: ['string', 'null'],
description:
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
'when the tooth was described in words instead of numbered.',
},
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
side: {
type: ['string', 'null'],
enum: ['patient_right', 'patient_left', null],
description: "The PATIENT's side, never the viewer's.",
},
position: {
type: ['integer', 'null'],
description:
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
},
},
} as const;
export const VOICE_INTENT_JSON_SCHEMA = {
type: 'object',
additionalProperties: false,
required: [
'treatmentType',
'teeth',
'connectedSpans',
'comment',
'prosthesisDefaultType',
'prosthesisOverrides',
'labId',
'labMatchExact',
'due',
],
properties: {
treatmentType: {
type: ['string', 'null'],
description: 'A treatment type CODE from the supplied list, or null.',
},
teeth: { type: 'array', items: TOOTH_SCHEMA },
connectedSpans: {
type: 'array',
description: 'Bridges / splinted units. Endpoints inclusive.',
items: {
type: 'object',
additionalProperties: false,
required: ['from', 'to'],
properties: { from: TOOTH_SCHEMA, to: TOOTH_SCHEMA },
},
},
comment: {
type: ['string', 'null'],
description: 'Clinical notes, in the spoken language.',
},
prosthesisDefaultType: {
type: ['string', 'null'],
description:
'A prosthesis type CODE applied to every tooth unless overridden.',
},
prosthesisOverrides: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['tooth', 'type'],
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
},
},
labId: {
type: ['string', 'null'],
description: 'An id from the supplied lab list. Never invent one.',
},
labMatchExact: {
type: 'boolean',
description: 'True only when the spoken name matched a lab name exactly.',
},
due: {
type: 'object',
additionalProperties: false,
required: [
'kind',
'weekday',
'which',
'unit',
'amount',
'jy',
'jm',
'jd',
'y',
'm',
'd',
],
properties: {
kind: {
type: 'string',
enum: ['weekday', 'offset', 'jalali', 'gregorian', 'none'],
},
weekday: { type: ['string', 'null'], enum: [...WEEKDAYS, null] },
which: { type: ['string', 'null'], enum: ['this', 'next', null] },
unit: {
type: ['string', 'null'],
enum: ['day', 'week', 'month', null],
},
amount: { type: ['integer', 'null'] },
jy: { type: ['integer', 'null'] },
jm: { type: ['integer', 'null'] },
jd: { type: ['integer', 'null'] },
y: { type: ['integer', 'null'] },
m: { type: ['integer', 'null'] },
d: { type: ['integer', 'null'] },
},
},
},
} as const;
/** Two digits, quadrant 1-8, position 1-8 — the only thing that can be an FDI code. */
const FDI_SHAPE = /^[1-8][1-8]$/;
function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
const spoken = typeof wire?.spoken === 'string' ? wire.spoken : '';
// "۲۶" and "2 6" are FDI codes that do not match literally; unnormalised they fall
// through to the positional branch with no quadrant and read as unresolved.
const fdi = normalizeFdiCode(wire?.fdi);
// Only take the explicit branch for something actually FDI-shaped. A model that emits
// fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely.
if (FDI_SHAPE.test(fdi)) {
return { kind: 'explicit', fdi, spoken };
}
return {
kind: 'positional',
arch: wire?.arch as 'upper' | 'lower',
side: wire?.side as 'patient_right' | 'patient_left',
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
spoken,
};
}
function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
switch (wire?.kind) {
case 'weekday':
return {
kind: 'weekday',
weekday: wire.weekday as Weekday,
which: wire.which as 'this',
};
case 'offset':
return {
kind: 'offset',
unit: wire.unit as 'day',
amount: typeof wire.amount === 'number' ? wire.amount : Number.NaN,
};
case 'jalali':
return {
kind: 'jalali',
jy: wire.jy as number,
jm: wire.jm as number,
jd: wire.jd as number,
};
case 'gregorian':
return {
kind: 'gregorian',
y: wire.y as number,
m: wire.m as number,
d: wire.d as number,
};
case 'none':
case undefined:
return null;
default:
// An unrecognised kind means a deadline WAS spoken and we failed to classify it.
// Passing it through lets the resolver flag it; collapsing it to null would make a
// misunderstood deadline indistinguishable from no deadline at all.
return { kind: wire?.kind } as unknown as DueIntent;
}
}
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
const overrides = Array.isArray(wire?.prosthesisOverrides)
? wire.prosthesisOverrides
: [];
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
from: toToothIntent(span?.from),
to: toToothIntent(span?.to),
}));
const hasProsthesis =
wire?.prosthesisDefaultType != null || overrides.length > 0;
const prosthesis: ProsthesisIntent | null = hasProsthesis
? {
defaultType: wire?.prosthesisDefaultType ?? null,
overrides: overrides.map((o) => ({
tooth: toToothIntent(o?.tooth),
type: o?.type,
})),
}
: null;
return {
treatmentType: wire?.treatmentType ?? null,
teeth: teeth.map(toToothIntent),
connectedSpans,
comment: wire?.comment ?? null,
prosthesis,
labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true,
due: toDueIntent(wire?.due),
};
}

View File

@@ -0,0 +1,197 @@
import {
OpenRouterAsrProvider,
OpenRouterExtractionProvider,
} from './openrouter.provider';
import { VoiceProviderError } from './voice.providers';
const CONFIG = {
apiKey: 'test-key',
baseUrl: 'https://openrouter.test/api/v1',
model: 'm',
};
const CATALOG = {
treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }],
prosthesisTypes: [{ code: 'pfm_crown', label: 'روکش پی‌اف‌ام' }],
labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }],
};
type ChatBody = {
temperature: number;
provider: { require_parameters: boolean };
response_format: { type: string; json_schema: { strict: boolean } };
messages: { role: string; content: string }[];
};
function parseBody(spy: jest.Mock): ChatBody {
const init = (spy.mock.calls[0] as [string, RequestInit])[1];
return JSON.parse(init.body as string) as ChatBody;
}
function mockFetch(response: {
ok: boolean;
status?: number;
body?: unknown;
text?: string;
}) {
const spy = jest.fn().mockResolvedValue({
ok: response.ok,
status: response.status ?? (response.ok ? 200 : 500),
json: () => Promise.resolve(response.body),
text: () => Promise.resolve(response.text ?? ''),
});
global.fetch = spy;
return spy;
}
afterEach(() => {
jest.restoreAllMocks();
});
describe('OpenRouterAsrProvider', () => {
it('posts base64 JSON with the language hint, per the documented STT contract', async () => {
const spy = mockFetch({
ok: true,
body: { text: ' سلام ', usage: { seconds: 12, cost: 0.002 } },
});
const result = await new OpenRouterAsrProvider(CONFIG).transcribe(
{ data: 'BASE64', format: 'webm' },
'fa',
);
const [url, init] = spy.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://openrouter.test/api/v1/audio/transcriptions');
expect(init.headers).toMatchObject({ Authorization: 'Bearer test-key' });
expect(JSON.parse(init.body as string)).toEqual({
model: 'm',
input_audio: { data: 'BASE64', format: 'webm' },
language: 'fa',
});
expect(result.text).toBe('سلام');
expect(result.usage).toEqual({ seconds: 12, costUsd: 0.002 });
});
it('reports missing usage as null rather than zero', async () => {
mockFetch({ ok: true, body: { text: 'x' } });
const result = await new OpenRouterAsrProvider(CONFIG).transcribe(
{ data: 'B', format: 'webm' },
'en',
);
expect(result.usage).toEqual({ seconds: null, costUsd: null });
});
it('raises a staged error without leaking the vendor body', async () => {
// A 4xx can echo the request back, transcript included.
mockFetch({
ok: false,
status: 400,
text: 'transcript: patient name here',
});
const provider = new OpenRouterAsrProvider(CONFIG);
await expect(
provider.transcribe({ data: 'B', format: 'webm' }, 'fa'),
).rejects.toMatchObject({
name: 'VoiceProviderError',
stage: 'asr',
status: 400,
});
await expect(
provider.transcribe({ data: 'B', format: 'webm' }, 'fa'),
).rejects.not.toThrow(/patient name/);
});
});
describe('OpenRouterExtractionProvider', () => {
const wireContent = JSON.stringify({
treatmentType: 'prosthesis',
teeth: [
{ spoken: 'یک چهار', fdi: '14', arch: null, side: null, position: null },
],
connectedSpans: [],
comment: null,
prosthesisDefaultType: 'pfm_crown',
prosthesisOverrides: [],
labId: 'lab-1',
labMatchExact: true,
due: {
kind: 'none',
weekday: null,
which: null,
unit: null,
amount: null,
jy: null,
jm: null,
jd: null,
y: null,
m: null,
d: null,
},
});
it('constrains output with a strict JSON schema and a schema-honouring provider', async () => {
const spy = mockFetch({
ok: true,
body: {
choices: [{ message: { content: wireContent } }],
usage: { cost: 0.0008 },
},
});
const result = await new OpenRouterExtractionProvider(CONFIG).extract(
'روی دندان ۱۴ روکش',
CATALOG,
'fa',
);
const body = parseBody(spy);
expect(body.response_format.type).toBe('json_schema');
expect(body.response_format.json_schema.strict).toBe(true);
// Without require_parameters OpenRouter may route to a provider that treats the
// schema as a hint and returns prose, failing parsing intermittently.
expect(body.provider).toEqual({ require_parameters: true });
expect(body.temperature).toBe(0);
expect(result.intent.treatmentType).toBe('prosthesis');
expect(result.intent.teeth[0]).toEqual({
kind: 'explicit',
fdi: '14',
spoken: 'یک چهار',
});
expect(result.intent.due).toBeNull();
expect(result.costUsd).toBe(0.0008);
});
it('sends the catalog codes and lab ids the model is allowed to choose from', async () => {
const spy = mockFetch({
ok: true,
body: { choices: [{ message: { content: wireContent } }] },
});
await new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa');
const body = parseBody(spy);
const system = body.messages[0].content;
expect(system).toContain('prosthesis');
expect(system).toContain('pfm_crown');
expect(system).toContain('lab-1');
expect(body.messages[1]).toEqual({ role: 'user', content: 'x' });
});
it('fails loudly on unparseable content rather than passing rubbish downstream', async () => {
mockFetch({
ok: true,
body: { choices: [{ message: { content: 'I think tooth 14?' } }] },
});
await expect(
new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'),
).rejects.toBeInstanceOf(VoiceProviderError);
});
it('fails when the model returns no content at all', async () => {
mockFetch({ ok: true, body: { choices: [] } });
await expect(
new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'),
).rejects.toMatchObject({ stage: 'extraction' });
});
});

View File

@@ -0,0 +1,170 @@
import { Logger } from '@nestjs/common';
import {
type AsrProvider,
type AsrResult,
type AudioInput,
type ExtractionCatalog,
type ExtractionProvider,
type ExtractionResult,
VoiceProviderError,
} from './voice.providers';
import { buildExtractionPrompt } from './extraction.prompt';
import {
VOICE_INTENT_JSON_SCHEMA,
toVoiceIntent,
type WireVoiceIntent,
} from './extraction.wire';
type OpenRouterConfig = {
apiKey: string;
baseUrl: string;
model: string;
};
type TranscriptionResponse = {
text?: unknown;
usage?: { seconds?: unknown; cost?: unknown };
};
type ChatResponse = {
choices?: { message?: { content?: unknown } }[];
usage?: { cost?: unknown };
};
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
const errorLogger = new Logger('OpenRouterVoice');
async function readError(
response: Response,
stage: 'asr' | 'extraction',
): Promise<never> {
// A vendor 4xx can echo the request back, transcript included. Keep the body out of the
// thrown message — which callers log and could forward — and out of the default log
// level; it stays available at debug when someone is actively diagnosing.
try {
errorLogger.debug(
`${stage} ${response.status} body: ${(await response.text()).slice(0, 500)}`,
);
} catch {
errorLogger.debug(`${stage} ${response.status} body unreadable`);
}
throw new VoiceProviderError(
stage,
`OpenRouter ${stage} failed with status ${response.status}`,
response.status,
);
}
/** Speech → text via OpenRouter's transcription endpoint (whisper-1 and friends). */
export class OpenRouterAsrProvider implements AsrProvider {
private readonly logger = new Logger(OpenRouterAsrProvider.name);
constructor(private readonly config: OpenRouterConfig) {}
async transcribe(
audio: AudioInput,
localeHint: string,
signal?: AbortSignal,
): Promise<AsrResult> {
const response = await fetch(
`${this.config.baseUrl}/audio/transcriptions`,
{
method: 'POST',
signal,
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.config.model,
input_audio: { data: audio.data, format: audio.format },
language: localeHint,
}),
},
);
if (!response.ok) await readError(response, 'asr');
const body = (await response.json()) as TranscriptionResponse;
const text = typeof body.text === 'string' ? body.text.trim() : '';
this.logger.debug(
`transcribed ${numberOrNull(body.usage?.seconds) ?? '?'}s`,
);
return {
text,
usage: {
seconds: numberOrNull(body.usage?.seconds),
costUsd: numberOrNull(body.usage?.cost),
},
};
}
}
/** Transcript → VoiceIntent via OpenRouter chat completions with a JSON schema. */
export class OpenRouterExtractionProvider implements ExtractionProvider {
constructor(private readonly config: OpenRouterConfig) {}
async extract(
transcript: string,
catalog: ExtractionCatalog,
localeHint: string,
signal?: AbortSignal,
): Promise<ExtractionResult> {
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
signal,
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.config.model,
temperature: 0,
// Only route to endpoints that actually honour the JSON schema — otherwise OpenRouter may
// pick a provider that treats it as a hint and returns prose, failing intermittently.
provider: { require_parameters: true },
messages: buildExtractionPrompt(transcript, catalog, localeHint),
response_format: {
type: 'json_schema',
json_schema: {
name: 'voice_intent',
strict: true,
schema: VOICE_INTENT_JSON_SCHEMA,
},
},
}),
});
if (!response.ok) await readError(response, 'extraction');
const body = (await response.json()) as ChatResponse;
const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new VoiceProviderError(
'extraction',
'OpenRouter returned no content',
);
}
let wire: WireVoiceIntent;
try {
wire = JSON.parse(content) as WireVoiceIntent;
} catch {
// Schema-constrained output should be valid JSON; if it is not, the resolvers can do
// nothing with it, so fail here rather than pass rubbish downstream.
throw new VoiceProviderError(
'extraction',
'OpenRouter returned unparseable JSON',
);
}
return {
intent: toVoiceIntent(wire),
costUsd: numberOrNull(body.usage?.cost),
};
}
}

View File

@@ -0,0 +1,265 @@
import {
resolveToothIntent,
resolveToothIntents,
} from './tooth-intent.resolver';
import type { ToothIntent } from './voice.types';
const positional = (
arch: 'upper' | 'lower',
side: 'patient_right' | 'patient_left',
position: number,
spoken = 'x',
): ToothIntent => ({ kind: 'positional', arch, side, position, spoken });
const explicit = (fdi: string, spoken = 'x'): ToothIntent => ({
kind: 'explicit',
fdi,
spoken,
});
describe('resolveToothIntent', () => {
describe('positional intents', () => {
// "شش بالا راست" — upper right six — must be 16, not 26. A mirrored quadrant is a
// valid code for the wrong tooth and reaches the lab unnoticed.
it('resolves each quadrant from the patient perspective', () => {
expect(resolveToothIntent(positional('upper', 'patient_right', 6))).toBe(
'16',
);
expect(resolveToothIntent(positional('upper', 'patient_left', 6))).toBe(
'26',
);
expect(resolveToothIntent(positional('lower', 'patient_left', 6))).toBe(
'36',
);
expect(resolveToothIntent(positional('lower', 'patient_right', 6))).toBe(
'46',
);
});
it('returns null for an out-of-range position instead of clamping', () => {
expect(
resolveToothIntent(positional('upper', 'patient_right', 9)),
).toBeNull();
expect(
resolveToothIntent(positional('upper', 'patient_right', 0)),
).toBeNull();
});
it('returns null for a malformed arch or side', () => {
expect(
resolveToothIntent(
positional('sideways' as 'upper', 'patient_right', 6),
),
).toBeNull();
expect(
resolveToothIntent(
positional('upper', 'viewer_right' as 'patient_right', 6),
),
).toBeNull();
});
});
describe('explicit intents', () => {
it('accepts a permanent FDI code', () => {
expect(resolveToothIntent(explicit('14'))).toBe('14');
expect(resolveToothIntent(explicit('48'))).toBe('48');
});
it('rejects deciduous codes rather than snapping to a permanent tooth', () => {
expect(resolveToothIntent(explicit('51'))).toBeNull();
expect(resolveToothIntent(explicit('85'))).toBeNull();
});
it('rejects nonsense', () => {
for (const value of ['', '1', '99', '140']) {
expect(resolveToothIntent(explicit(value))).toBeNull();
}
});
});
it('returns null for a missing or unknown intent shape', () => {
expect(resolveToothIntent(undefined as unknown as ToothIntent)).toBeNull();
expect(
resolveToothIntent({ kind: 'guess' } as unknown as ToothIntent),
).toBeNull();
});
});
describe('resolveToothIntents', () => {
it('resolves a mixed list and sorts the result', () => {
const result = resolveToothIntents([
positional('upper', 'patient_right', 5, 'پنج بالا راست'),
explicit('14', 'یک چهار'),
]);
expect(result.teeth).toEqual(['14', '15']);
expect(result.unresolved).toEqual([]);
});
it('collapses a tooth named twice', () => {
const result = resolveToothIntents([
explicit('14', 'چهارده'),
positional('upper', 'patient_right', 4, 'چهار بالا راست'),
]);
expect(result.teeth).toEqual(['14']);
});
it('reports what it could not understand instead of dropping it', () => {
const result = resolveToothIntents([
explicit('14', 'یک چهار'),
explicit('51', 'دندان شیری'),
positional('upper', 'patient_right', 9, 'نه بالا راست'),
]);
expect(result.teeth).toEqual(['14']);
expect(result.unresolved).toEqual([
{ spoken: 'دندان شیری', reason: 'not_permanent_tooth' },
{ spoken: 'نه بالا راست', reason: 'position_out_of_range' },
]);
});
it('does not repeat an identical unresolved item', () => {
const result = resolveToothIntents([
explicit('51', 'شیری'),
explicit('51', 'شیری'),
]);
expect(result.unresolved).toHaveLength(1);
});
it('survives a non-array where the model should have sent a list', () => {
// The model can return an object or a number here; that must degrade, not 500.
for (const bad of [undefined, null, 5, 'teeth', { fdi: '14' }]) {
expect(resolveToothIntents(bad as unknown as ToothIntent[])).toEqual({
teeth: [],
unresolved: [],
});
}
});
it('reads a spoken number as its FDI code, digits in any script', () => {
// The product rule: the number the clinician says IS the tooth. 26 = quadrant 2
// (patient's upper left) + position 6 = first molar.
for (const raw of ['26', ' 26 ', '2 6', '\u06F2\u06F6', '\u0662\u0666']) {
expect(resolveToothIntents([explicit(raw, 'x')]).teeth).toEqual(['26']);
}
});
it('trims an explicit code, matching normalizeTeeth', () => {
expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']);
});
it('distinguishes a deciduous tooth from nonsense in the reason it reports', () => {
// '51' really is a (primary) tooth the chart cannot show; '99' is not a tooth at all.
expect(
resolveToothIntents([explicit('51', 'shiri')]).unresolved[0].reason,
).toBe('not_permanent_tooth');
for (const junk of ['99', '19', '140', '', 'ab']) {
expect(
resolveToothIntents([explicit(junk, `j-${junk}`)]).unresolved[0].reason,
).toBe('malformed');
}
});
it('says the quadrant is missing rather than blaming the words', () => {
// Regression: "ترمیم برای دندون دو" reported "could not be read", sending the
// clinician to look for a transcription fault. Position 2 was understood fine —
// what is missing is the quadrant, and four teeth carry position 2.
const bare = {
kind: 'positional',
arch: null,
side: null,
position: 2,
spoken: 'دندون دو',
} as unknown as ToothIntent;
const result = resolveToothIntents([bare]);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([
{
spoken: 'دندون دو',
reason: 'tooth_missing_quadrant',
// Every reading of "position 2", for the clinician to pick from.
candidates: ['12', '22', '32', '42'],
},
]);
});
it('narrows the candidates by whatever the clinician did say', () => {
const half = (arch: string | null, side: string | null) =>
resolveToothIntents([
{
kind: 'positional',
arch,
side,
position: 2,
spoken: 'دو',
} as unknown as ToothIntent,
]).unresolved[0].candidates;
expect(half('upper', null)).toEqual(['12', '22']);
expect(half('lower', null)).toEqual(['32', '42']);
// Quadrant 1 is the patient's upper right, 4 the lower right.
expect(half(null, 'patient_right')).toEqual(['12', '42']);
expect(half(null, 'patient_left')).toEqual(['22', '32']);
});
it('offers no candidates for a reason a choice cannot settle', () => {
// Nothing to choose between when the position itself was wrong, or the tooth is
// deciduous — offering chips there would invent options.
for (const intent of [
positional('upper', 'patient_right', 9, 'نه'),
explicit('51', 'شیری'),
]) {
expect(
resolveToothIntents([intent]).unresolved[0].candidates,
).toBeUndefined();
}
});
it('reports a missing quadrant for a half-specified tooth too', () => {
// "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess.
for (const half of [
{ arch: 'upper', side: null },
{ arch: null, side: 'patient_right' },
]) {
const result = resolveToothIntents([
{
kind: 'positional',
...half,
position: 2,
spoken: 'دو',
} as unknown as ToothIntent,
]);
expect(result.unresolved[0].reason).toBe('tooth_missing_quadrant');
}
});
it('still calls an out-of-range position out of range when the quadrant is missing', () => {
// Position wins: "nine" is wrong however completely it was said.
const result = resolveToothIntents([
{
kind: 'positional',
arch: null,
side: null,
position: 9,
spoken: 'نه',
} as unknown as ToothIntent,
]);
expect(result.unresolved[0].reason).toBe('position_out_of_range');
});
it('keeps unresolved items separate when the model omits the spoken span', () => {
// Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth.
const result = resolveToothIntents([
{ kind: 'explicit', fdi: '51' } as ToothIntent,
{ kind: 'explicit', fdi: '52' } as ToothIntent,
]);
expect(result.unresolved).toHaveLength(2);
});
it('handles an empty or missing list', () => {
expect(resolveToothIntents([])).toEqual({ teeth: [], unresolved: [] });
expect(resolveToothIntents(undefined as unknown as ToothIntent[])).toEqual({
teeth: [],
unresolved: [],
});
});
});

View File

@@ -0,0 +1,145 @@
import {
isFdiTooth,
normalizeFdiCode,
toFdi,
type Arch,
type PatientSide,
} from '../../common/fdi';
import type { ToothIntent, UnresolvedItem } from './voice.types';
const ARCHES: readonly Arch[] = ['upper', 'lower'];
const SIDES: readonly PatientSide[] = ['patient_right', 'patient_left'];
export type ToothResolution = {
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
teeth: string[];
unresolved: UnresolvedItem[];
};
/** Everything here parses untrusted model output, so nothing may throw. */
function normalizedFdi(intent: ToothIntent): string {
// The same normalisation the wire layer used to pick this branch, so the two agree.
return normalizeFdiCode((intent as { fdi?: unknown }).fdi);
}
/**
* Never guesses and never clamps: position 9, a deciduous tooth or a malformed intent all
* resolve to null, so the caller surfaces "not understood" rather than silently selecting a
* neighbouring tooth.
*/
export function resolveToothIntent(intent: ToothIntent): string | null {
if (!intent || typeof intent !== 'object') return null;
if (intent.kind === 'explicit') {
const fdi = normalizedFdi(intent);
return isFdiTooth(fdi) ? fdi : null;
}
if (intent.kind === 'positional') {
if (intent.arch !== 'upper' && intent.arch !== 'lower') return null;
if (intent.side !== 'patient_right' && intent.side !== 'patient_left')
return null;
return toFdi(intent.arch, intent.side, intent.position);
}
return null;
}
function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
if (!intent || typeof intent !== 'object') return 'malformed';
if (intent.kind === 'explicit') {
const fdi = normalizedFdi(intent);
// Quadrants 1-4 are permanent and would already have resolved, so a well-formed
// quadrant+position reaching here is quadrant 5-8: deciduous. Anything else is noise.
return /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed';
}
if (intent.kind === 'positional') {
const positionBad =
!Number.isInteger(intent.position) ||
intent.position < 1 ||
intent.position > 8;
if (positionBad) return 'position_out_of_range';
// The position was understood; the quadrant was never said. "دندون دو" names four
// teeth, so "could not be read" would send the clinician after the wrong fault.
const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower';
const sideMissing =
intent.side !== 'patient_right' && intent.side !== 'patient_left';
return archMissing || sideMissing ? 'tooth_missing_quadrant' : 'malformed';
}
return 'malformed';
}
/**
* The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. Not
* a guess: the full set of readings, for the clinician to choose from.
*/
function quadrantCandidates(intent: ToothIntent): string[] {
if (intent.kind !== 'positional') return [];
const arches =
intent.arch === 'upper' || intent.arch === 'lower' ? [intent.arch] : ARCHES;
const sides =
intent.side === 'patient_right' || intent.side === 'patient_left'
? [intent.side]
: SIDES;
const codes: string[] = [];
for (const arch of arches) {
for (const side of sides) {
const fdi = toFdi(arch, side, intent.position);
if (fdi) codes.push(fdi);
}
}
return codes.sort();
}
function spokenOf(intent: ToothIntent): string {
const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}
/**
* Duplicates collapse; anything unresolvable is reported rather than dropped, so the sheet
* can show which words were not understood.
*/
export function resolveToothIntents(
intents: readonly ToothIntent[],
): ToothResolution {
const teeth = new Set<string>();
const unresolved: UnresolvedItem[] = [];
const seenUnresolved = new Set<string>();
// Not `intents ?? []`: a model may return an object or a number here, and a
// non-iterable must degrade like any other malformed shape rather than throw.
const list: readonly ToothIntent[] = Array.isArray(intents)
? (intents as readonly ToothIntent[])
: [];
for (const intent of list) {
const fdi = resolveToothIntent(intent);
if (fdi) {
teeth.add(fdi);
continue;
}
const reason = unresolvedReason(intent);
const spoken = spokenOf(intent);
const candidates =
reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : [];
// Only dedupe what we can tell apart: without `spoken`, two lost references collapse
// into one blank row and a tooth vanishes. Candidates are part of the identity.
if (spoken) {
const key = `${spoken}::${reason}::${candidates.join(',')}`;
if (seenUnresolved.has(key)) continue;
seenUnresolved.add(key);
}
unresolved.push(
candidates.length > 0
? { spoken, reason, candidates }
: { spoken, reason },
);
}
return { teeth: [...teeth].sort(), unresolved };
}

View File

@@ -0,0 +1,29 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { AppException, ErrorCode } from '../../common/errors';
/**
* Rate limits voice extraction per user, not per IP: the default tracker keys on `req.ip`,
* which behind nginx means the whole deployment shares one bucket unless `trust proxy` is
* set, and one clinic could then lock out every other.
*/
@Injectable()
export class VoiceThrottlerGuard extends ThrottlerGuard {
protected getTracker(req: Record<string, unknown>): Promise<string> {
const user = req?.user as { id?: unknown } | undefined;
if (typeof user?.id === 'string' && user.id) {
return Promise.resolve(`voice:user:${user.id}`);
}
// Unauthenticated requests never reach here, but fall back rather than share a bucket.
const ip = typeof req?.ip === 'string' ? req.ip : 'unknown';
return Promise.resolve(`voice:ip:${ip}`);
}
/** Without this, ThrottlerException surfaces as INTERNAL_ERROR — there is no 429 fallback. */
protected throwThrottlingException(): Promise<void> {
throw new AppException(
ErrorCode.VOICE_RATE_LIMITED,
HttpStatus.TOO_MANY_REQUESTS,
);
}
}

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ExtractVoiceDto } from './dto/voice.dto';
import { VoiceThrottlerGuard } from './voice-throttler.guard';
import { VoiceService } from './voice.service';
type VoiceRequestUser = { id: string; organizationId?: string };
@ApiTags('voice')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
@Controller('voice')
export class VoiceController {
constructor(private readonly voiceService: VoiceService) {}
@Get('availability')
@ApiOperation({
summary: 'Whether voice entry is configured, and for which locales',
})
getAvailability() {
// The frontend cannot learn this from NEXT_PUBLIC_* — those are baked in at build
// time, so enabling a locale would otherwise require rebuilding the image.
return { success: true, data: this.voiceService.getAvailability() };
}
@Post('extract')
// Guarded here rather than globally, and configured by VOICE_THROTTLE_* rather than
// hardcoded. Availability is deliberately left unthrottled — it is cheap and the
// frontend calls it on load.
@UseGuards(VoiceThrottlerGuard)
@ApiOperation({
summary:
'Transcribe a recording and extract treatment detail intents (TAB_TREATMENT_EDIT)',
})
async extract(
@Req() req: Request & { user: VoiceRequestUser },
@Res({ passthrough: true }) res: Response,
@Body() dto: ExtractVoiceDto,
) {
// Cancelling in the browser closes the connection; propagate it as an abort so the vendor
// call stops rather than settling unseen. It is metered per minute.
const aborter = new AbortController();
res.on('close', () => {
if (!res.writableFinished) aborter.abort();
});
// dto.locale, not req.user.language: the client sends the locale the microphone was
// actually offered in, so the ASR hint, catalog labels and week start all match it.
const data = await this.voiceService.extract(
req.user,
dto,
dto.locale,
aborter.signal,
);
return { success: true, data };
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
import { VoiceController } from './voice.controller';
import { VoiceService } from './voice.service';
@Module({
imports: [ProsthesisCatalogModule],
controllers: [VoiceController],
providers: [VoiceService, PrismaService],
})
export class VoiceModule {}

View File

@@ -0,0 +1,66 @@
import type { VoiceIntent } from './voice.types';
/**
* ASR and extraction are separate, independently swappable roles — they will not come from
* the same vendor for every locale. Both resolve per locale from `config.voice.profiles`.
*/
export type AudioInput = {
/** Raw base64, no data: prefix. */
data: string;
/** Container as the recorder produced it: webm, mp4, m4a, wav, … */
format: string;
};
export type AsrUsage = {
seconds: number | null;
costUsd: number | null;
};
export type AsrResult = {
text: string;
usage: AsrUsage;
};
export interface AsrProvider {
/** `localeHint` is ISO-639-1 and materially improves accuracy — always pass it. */
transcribe(
audio: AudioInput,
localeHint: string,
signal?: AbortSignal,
): Promise<AsrResult>;
}
export type ExtractionCatalog = {
/** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */
treatmentTypes: { code: string; label: string }[];
prosthesisTypes: { code: string; label: string }[];
/** The clinic's linked labs — a closed choice list. */
labs: { id: string; name: string }[];
};
export type ExtractionResult = {
intent: VoiceIntent;
costUsd: number | null;
};
export interface ExtractionProvider {
extract(
transcript: string,
catalog: ExtractionCatalog,
localeHint: string,
signal?: AbortSignal,
): Promise<ExtractionResult>;
}
/** Raised when a vendor call fails; the service maps this onto the staged error codes. */
export class VoiceProviderError extends Error {
constructor(
readonly stage: 'asr' | 'extraction',
message: string,
readonly status?: number,
) {
super(message);
this.name = 'VoiceProviderError';
}
}

View File

@@ -0,0 +1,344 @@
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LinkStatus } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { AppException, ErrorCode } from '../../common/errors';
import {
civilDateInZone,
isValidIanaTimeZone,
} from '../../common/zoned-civil-time';
import type { VoiceConfig, VoiceProfile } from '../../configs/configurations';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { weekStartForLocale } from './due-date.resolver';
import {
resolveVoiceIntent,
type ResolvedExtraction,
} from './extraction.resolver';
import {
OpenRouterAsrProvider,
OpenRouterExtractionProvider,
} from './openrouter.provider';
import {
VoiceProviderError,
type AsrProvider,
type ExtractionCatalog,
type ExtractionProvider,
} from './voice.providers';
import type { ExtractVoiceDto } from './dto/voice.dto';
export type VoiceAvailability = {
enabled: boolean;
locales: string[];
maxRecordingMs: number | null;
};
export type VoiceExtractionResponse = ResolvedExtraction & {
transcript: string;
};
@Injectable()
export class VoiceService {
private readonly logger = new Logger(VoiceService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
) {}
private get voiceConfig(): VoiceConfig {
return this.config.get<VoiceConfig>('voice')!;
}
/**
* What the frontend needs to decide whether to render the microphone. v1 is ungated beyond
* a configured locale profile; the Plan.features design is deferred, not dropped.
*/
getAvailability(): VoiceAvailability {
const voice = this.voiceConfig;
const hasKey = Boolean(voice.openRouter.apiKey);
const locales = hasKey ? Object.keys(voice.profiles) : [];
return {
enabled: locales.length > 0,
locales,
maxRecordingMs: voice.maxRecordingMs,
};
}
async extract(
user: { id: string; organizationId?: string },
dto: ExtractVoiceDto,
locale: string,
signal?: AbortSignal,
): Promise<VoiceExtractionResponse> {
const startedAt = Date.now();
const organizationId = this.assertOrganization(user);
await this.assertCanEditTreatment(user.id, organizationId);
const catalogLocale = normalizeCatalogLocale(locale);
const profile = this.resolveProfile(catalogLocale);
this.assertWithinCap(dto.durationMs);
const timeZone = isValidIanaTimeZone(dto.timeZone) ? dto.timeZone : 'UTC';
const todayIso = civilDateInZone(new Date(), timeZone);
const { asr, extraction } = this.buildProviders(profile);
// Stage 1 — audio never touches disk and is not retained beyond this call.
let transcript: string;
let asrCost: number | null = null;
let asrSeconds: number | null = null;
try {
const result = await asr.transcribe(
{ data: dto.audio, format: dto.format },
catalogLocale,
signal,
);
transcript = result.text;
asrCost = result.usage.costUsd;
asrSeconds = result.usage.seconds;
} catch (error) {
throw this.toAppException(error, 'asr');
}
// durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own
// measurement — a client under-reporting to slip past the cap is caught here, after the
// ASR spend but before the more expensive extraction call.
if (asrSeconds != null) {
this.assertWithinCap(asrSeconds * 1000);
}
if (!transcript.trim()) {
throw new AppException(
ErrorCode.VOICE_NOTHING_RECOGNIZED,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
// Stage 2 — structure it. On failure the transcript still goes back to the client so
// the words the clinician already paid for are not lost (transcript salvage).
let resolved: ResolvedExtraction;
let llmCost: number | null = null;
try {
// Inside the try: the transcript is already paid for, so a catalog/DB failure here
// must still salvage it rather than becoming a generic 500 that throws it away.
const catalog = await this.buildCatalog(organizationId, catalogLocale);
const result = await extraction.extract(
transcript,
catalog,
catalogLocale,
signal,
);
llmCost = result.costUsd;
resolved = resolveVoiceIntent(result.intent, {
todayIso,
weekStartJs: weekStartForLocale(catalogLocale),
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
prosthesisTypeCodes: new Set(
catalog.prosthesisTypes.map((t) => t.code),
),
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
});
} catch (error) {
throw this.toAppException(error, 'extraction', transcript);
}
this.logTelemetry({
locale: catalogLocale,
durationMs: dto.durationMs,
elapsedMs: Date.now() - startedAt,
asrCost,
llmCost,
resolved,
});
return { ...resolved, transcript };
}
private assertOrganization(user: { organizationId?: string }): string {
if (!user?.organizationId) {
throw new AppException(
ErrorCode.AUTH_ORG_NOT_SELECTED,
HttpStatus.BAD_REQUEST,
);
}
return user.organizationId;
}
private async assertCanEditTreatment(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
if (!membership) {
throw new AppException(
ErrorCode.PERMISSION_NOT_MEMBER,
HttpStatus.FORBIDDEN,
);
}
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
throw new AppException(
ErrorCode.PERMISSION_EDIT_TREATMENTS,
HttpStatus.FORBIDDEN,
);
}
}
private resolveProfile(locale: string): VoiceProfile {
const voice = this.voiceConfig;
const profile = voice.profiles[locale];
if (!profile || !voice.openRouter.apiKey) {
throw new AppException(
ErrorCode.VOICE_NOT_AVAILABLE,
HttpStatus.FORBIDDEN,
);
}
return profile;
}
/**
* The client auto-stops at maxMs and only then measures, so a capped recording always
* reports slightly over. Without this tolerance every auto-stopped recording — the exact
* case the cap exists for — would be rejected as too long.
*/
private static readonly CAP_TOLERANCE_MS = 2_000;
private assertWithinCap(durationMs: number) {
const max = this.voiceConfig.maxRecordingMs;
if (max != null && durationMs > max + VoiceService.CAP_TOLERANCE_MS) {
throw new AppException(
ErrorCode.VOICE_CLIP_TOO_LONG,
HttpStatus.PAYLOAD_TOO_LARGE,
);
}
}
private buildProviders(profile: VoiceProfile): {
asr: AsrProvider;
extraction: ExtractionProvider;
} {
const { apiKey, baseUrl } = this.voiceConfig.openRouter;
const base = { apiKey: apiKey!, baseUrl };
return {
asr: new OpenRouterAsrProvider({ ...base, model: profile.asr.model }),
extraction: new OpenRouterExtractionProvider({
...base,
model: profile.llm.model,
}),
};
}
/** Codes with labels in the actor's locale, plus the clinic's linked labs. */
private async buildCatalog(
organizationId: string,
locale: string,
): Promise<ExtractionCatalog> {
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
this.treatmentCatalog.list(locale, null),
this.prosthesisCatalog.list(locale),
this.listLinkedLabs(organizationId),
]);
return {
treatmentTypes: treatmentTypes
.filter((entry) => entry.availableInTreatment)
.map((entry) => ({ code: entry.code, label: entry.label })),
prosthesisTypes: prosthesisTypes.map((entry) => ({
code: entry.code,
label: entry.label,
})),
labs,
};
}
private async listLinkedLabs(
organizationId: string,
): Promise<{ id: string; name: string }[]> {
const [linksA, linksB] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
include: { organizationB: { select: { id: true, name: true } } },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
include: { organizationA: { select: { id: true, name: true } } },
}),
]);
return [
...linksA.map((l) => ({
id: l.organizationB.id,
name: l.organizationB.name,
})),
...linksB.map((l) => ({
id: l.organizationA.id,
name: l.organizationA.name,
})),
];
}
private toAppException(
error: unknown,
stage: 'asr' | 'extraction',
transcript?: string,
): AppException {
if (error instanceof Error && error.name === 'AbortError') {
// The clinician cancelled; not a failure worth a translated message.
return new AppException(ErrorCode.BAD_REQUEST, HttpStatus.BAD_REQUEST);
}
if (error instanceof VoiceProviderError) {
this.logger.warn(`voice ${stage} failed: ${error.message}`);
} else {
this.logger.error(`voice ${stage} failed unexpectedly`, error as Error);
}
const code =
stage === 'asr'
? ErrorCode.VOICE_ASR_FAILED
: ErrorCode.VOICE_EXTRACT_FAILED;
return new AppException(
code,
HttpStatus.BAD_GATEWAY,
transcript ? { transcript } : undefined,
);
}
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
private logTelemetry(input: {
locale: string;
durationMs: number;
elapsedMs: number;
asrCost: number | null;
llmCost: number | null;
resolved: ResolvedExtraction;
}) {
const { resolved } = input;
this.logger.log(
JSON.stringify({
event: 'voice.extract',
locale: input.locale,
clipMs: input.durationMs,
elapsedMs: input.elapsedMs,
costUsd: (input.asrCost ?? 0) + (input.llmCost ?? 0),
resolvedFields: {
treatmentType: resolved.treatmentType != null,
teeth: resolved.teeth.length,
comment: resolved.comment != null,
prosthesisComplete: resolved.prosthesis?.complete ?? null,
lab: resolved.labId != null,
dueDate: resolved.dueDate != null,
},
unresolvedCount: resolved.unresolved.length,
}),
);
}
}

View File

@@ -0,0 +1,85 @@
import type { Arch, PatientSide } from '../../common/fdi';
/**
* What the extraction model is allowed to return.
*
* The model emits *intents*, never resolved values: no FDI codes, no ISO dates. Pure,
* unit-tested resolvers turn intents into domain values, so the two highest-consequence
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
*/
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
export type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string }
| {
kind: 'positional';
arch: Arch;
side: PatientSide;
/** 1 = central incisor … 8 = third molar. */
position: number;
spoken: string;
};
/** A spoken deadline. The model never does calendar arithmetic. */
export type DueIntent =
| { kind: 'weekday'; weekday: Weekday; which: 'this' | 'next' }
| { kind: 'offset'; unit: 'day' | 'week' | 'month'; amount: number }
| { kind: 'jalali'; jy: number; jm: number; jd: number }
| { kind: 'gregorian'; y: number; m: number; d: number };
export const WEEKDAYS = [
'saturday',
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
] as const;
export type Weekday = (typeof WEEKDAYS)[number];
/** Two teeth defining an inclusive connected (bridge) span. */
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
export type ProsthesisIntent = {
/** Catalog code applied to every tooth unless overridden. */
defaultType: string | null;
overrides: { tooth: ToothIntent; type: string }[];
};
export type VoiceIntent = {
treatmentType: string | null;
teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[];
comment: string | null;
prosthesis: ProsthesisIntent | null;
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
labId: string | null;
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
labMatchExact: boolean;
due: DueIntent | null;
};
/** Why a spoken item could not be turned into a domain value. Shown to the user. */
export type UnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
/** A position was understood but no quadrant was spoken — four teeth match. */
| 'tooth_missing_quadrant'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export type UnresolvedItem = {
/** The transcript span that could not be resolved, so the user can see what was heard. */
spoken: string;
reason: UnresolvedReason;
/**
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
*/
candidates?: string[];
};

View File

@@ -0,0 +1,802 @@
# Voice treatment entry
**Status:** Implemented on `feat/voice-treatment-entry`, with one specified piece missing —
the transcript-salvage dialog (§9). First live test on 2026-08-21 sent the tooth path back
for revision — a spoken number is now read as its FDI code (§6).
Still blocked on the ASR spike (§11 item 1) before it is trustworthy in front of patients
**Area:** Treatment workspace (CLINIC orgs)
**Created:** 2026-08-20
Fill a `TreatmentDetail` — including its lab dispatch — by speaking, instead of by
tapping through the type dropdown, the FDI chart, the prosthesis wizard and the lab
picker.
---
## 1. Goal
A clinician on the Treatment tab taps a microphone, describes the treatment for the
already-selected patient in one utterance, and is shown a **review sheet** of what was
understood. Fields they tick are applied to the open detail chip. Nothing is written to
the form without confirmation.
### In scope
One recording produces **exactly one** `TreatmentDetail`, and may fill every field of it:
| Field | Source |
|---|---|
| `treatmentType` | catalog code, matched against locale labels |
| `teeth` | FDI codes, via tooth-intent resolver |
| `toothSelectionGroups` | connected (bridge) / single spans |
| `comment` | cleaned dictated notes |
| lab: `prosthesisTypeCode` per tooth | default type + per-tooth overrides |
| lab: `destinationOrganizationId` | matched against the clinic's linked labs |
| lab: `dueDate` | via due-date intent resolver |
### Out of scope (v1)
- Multiple detail chips from one recording.
- `attachmentIds` — files cannot be dictated.
- Editing an existing detail by voice ("no, make that 15"). Confirming a recording always
creates a **new** detail (see §2).
- Creating the treatment or selecting the patient by voice. A patient is already
selected; voice only fills the form.
- Lab-side (`LAB` org) usage. Clinic only.
---
## 2. User flow and UI integration
### The control: Add detail, split
The `Add detail` button gains a second segment holding the mic. The halves read as
siblings — both end in a new detail — but they are **independent actions**:
- **Add half — unchanged.** Same `onAddDetail`, same seeding, same `setEntryStep`. It
gains a neighbour and nothing else. Its logic is not modified, wrapped or made
conditional.
- **Mic half** — starts a recording. Nothing is created until confirm (below).
The `Add detail` `<Button>` in `TreatmentDetailsEditor` becomes a segmented control built
exactly like the **detail chip's trash affordance** in the same file
(`TreatmentDetailsEditor.tsx`, the chip `<div>` + label `<button>` + remove `<button>`):
- an `inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)]` wrapper;
- two raw `<button>` children divided by `border-s`, each with its own `disabled`,
`title`, `aria-label` and `focus-visible:ring-inset`;
- styled `primary` (`bg-primary text-white`) to preserve the button's current look;
- `w-full sm:w-auto` on the wrapper with the Add half `flex-1`, reproducing today's
`fullWidth` + `sm:w-auto shrink-0` behaviour.
The shared `Button` component is **not** reusable for the halves: it hardcodes
`rounded-[var(--radius-md)]` on each instance and owns auto-pending state, both of which
fight a segmented control. This is precisely why the chip pattern uses raw `<button>`s,
and this control follows it.
**Side:** the mic is the second flex child with `border-s` — the **logical end**, exactly
like the trash. Visually right in `en`/`nl`, visually left in `fa`. No physical
left/right anywhere, per the repo's RTL rule.
```
en / nl (LTR) fa (RTL)
[ Add detail │ 🎤 ] [ 🎤 │ افزودن ]
[ ترمیم 14,15 │ 🗑 ] [ 🗑 │ ترمیم 14,15 ]
↑ same side as the chip's trash, in both directions
```
### Flow
```
[editable day]
▼ tap 🎤 ← mic swaps to ■ (red); Add half disabled
recording ─────────── inline bar below the header row:
│ ● 0:12 / 2:00 ▁▃▇▅▂▆█▄▁ [لغو]
│ auto-stops at the 2:00 cap
▼ tap ■
processing ────────── ● transcribing… ○ extracting [cancel]
review sheet
☑ Type ترمیم
☑ Teeth [mini FDI chart] 14 15
☑ Notes حساسیت به سرما
☐ Lab لابراتوار سینا ⚠ similar name
▼ [Apply n fields]
detail created or filled → normal save flow
```
**No layout shift.** The segmented control never changes size; only the mic's icon and
colour change. The timer and level meter live in a full-width bar inserted between the
header row and the chip strip — the header is `sm:justify-between`, so growing the button
mid-recording would shove the row on every start and every stop.
**Duration is capped at 2 minutes** (`maxMs`, configurable, v1 default `120_000`). The
timer shows `elapsed / 2:00` and the recorder auto-stops at the cap. `maxMs: null` means
uncapped and remains supported, but is not the v1 default.
Two minutes is generous against the longest realistic utterance — a full prosthesis
dictation with type, several teeth, a bridge, prosthesis type, lab and due date — while
bounding worst-case spend. It also sits comfortably inside the vendor limits (§4): 2
minutes of `webm/opus` is well under 1 MB against a 25 MB ceiling, and transcribes in a
few seconds against a 60s processing timeout.
Cost per recording is therefore bounded at **$0.012** of ASR (2 min × $0.006) plus
~$0.0008 of extraction — about **1.3¢ worst case**, against ~0.3¢ for a typical 20s
utterance.
### What confirm does
The detail is created **on confirm**, never on tap. Tapping the mic starts a recording and
nothing else, so cancelling, a vendor failure, a rate-limit, or navigating away leaves the
chip strip untouched — there is no orphan state to clean up.
Confirm **always appends a new detail**. One unconditional rule, no dependence on
invisible state: append `newDetail()` seeded with `defaultTreatmentTypeForAppointment`,
make it active, `setEntryStep('treatment')`, then apply the ticked rows on top — so an
extracted type overrides the seed, and unticking the type row leaves the seeded default in
place.
This reuses `newDetail()` and the same state transitions, but it is a **separate code
path**. `onAddDetail` is not called and not changed.
`setEntryStep('treatment')` matters: `showChrome` is always on, so the control is visible
during the **Lab** wizard step too. Confirming there returns to the treatment step.
**Confirm also saves.** The new detail is persisted immediately (`persistDraft({force:true})`),
and when the result carries a lab, a due date or a prosthesis map the lab case is saved with it
(`persistLabCases`). Not politeness — the autosave effect watches `details` only, so a lab draft
left in component state alone loses the destination lab, the due date and the whole prosthesis
map on the next reload. The detail survives, which is what makes that loss look like a
successful save.
One guard on it: `persistDraft` returns a **preview** treatment instead of saving when any
detail still lacks a treatment type — the blank chip the workspace opens with is enough — and a
preview's detail id falls back to the client id. Confirm therefore checks *what came back*, not
the precondition, and skips the lab-case save when it did not get a real id; posting a lab case
against an id the server has never seen fails the whole save. Checking the result rather than
the condition keeps this true for every early return `persistDraft` has.
> Accepted consequences:
> - Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the
> usual trash affordance.
> - That same blank chip blocks confirm's immediate lab-case save until it is given a type
> or removed; the lab rows stay in local state until the ordinary Lab-step save.
> - Dictating into an existing detail is not supported in v1 — voice always makes a new
> one.
### Render policy
Three different reasons for "no", rendered differently:
| Reason | Condition | Render |
|---|---|---|
| **Technical** | no voice profile for the locale; no `MediaRecorder` | segment **absent** — the control is byte-for-byte today's plain Add button |
| **Commercial** | `Plan.features.voiceTreatmentEntry` false | segment **absent** — ⚠ *not enforced in v1*, see §8 |
| **Contextual** | `!canEdit \|\| disabled` — past day, read-only load, no `TAB_TREATMENT_EDIT` | both segments render and **disable together**, like the chip's trash (`disabled:opacity-40 disabled:cursor-not-allowed`) |
"This feature isn't yours" and "not right now" are different statements. Absence avoids a
permanently dead control; disabling avoids the button resizing as the day strip moves.
`TAB_TREATMENT_EDIT` is resolved via `common/membership-permissions.ts`, never by reading
`membership.permissions` directly.
### Component API
`TreatmentDetailsEditor` gains exactly **one** optional prop. All behaviour —
`MediaRecorder`, the API call, error state — lives in `lib/voice/useVoiceCapture.ts`
(hooks belong in `lib/` per `AGENTS.md`) and is owned by `TreatmentWorkspace`. The editor
stays presentational and renders both the segment and the recording bar with its own
classes, keeping the segmented styling beside the chip pattern it mirrors.
```ts
/** Omit when voice is unavailable — the Add button then renders unsplit. */
voice?: {
phase: 'idle' | 'recording' | 'processing';
elapsedMs: number;
level: number; // 0..1, for the meter
maxMs: number | null; // v1: 120_000 (2 min). null = uncapped, supported but not default
onStart: () => void;
onStop: () => void;
onCancel: () => void;
};
```
`voice === undefined` **is** the absent state above — availability is expressed by
presence rather than a separate flag, so the two cannot disagree.
### Accessibility
- Mic segment carries both `title` and `aria-label`, like the chip's trash, and its label
changes with `phase`.
- Announce **phase transitions** via a `role="status" aria-live="polite"` region — the
same idiom as the existing autosave status line. Do not put `aria-live` on the ticking
timer.
- Focus ring is `focus-visible:ring-inset` tinted primary when idle, red while recording.
---
## 3. Architecture
Two sequential stages, both server-side. The vendor API key never reaches the browser.
```
browser ──audio(base64)──► POST /treatments/voice-extract
┌─────────┴─────────┐
│ 1. AsrProvider │ audio + locale hint → transcript
└─────────┬─────────┘
┌─────────┴─────────┐
│ 2. ExtractionProv │ transcript + catalog + ctx → VoiceIntent
└─────────┬─────────┘
┌─────────┴─────────┐
│ 3. resolvers │ intents → FDI codes, ISO date
│ (pure, tested) │
└─────────┬─────────┘
VoiceExtractionResult (resolved values + the intents
that produced them, for display)
```
**Why intents and not final values:** the model never emits an FDI code and never does
calendar arithmetic. It emits what it *heard*; deterministic, unit-tested code decides
what that means. This is what makes the two highest-consequence mappings — quadrant
mirroring and Jalali conversion — testable instead of hopeful.
### Endpoint
`POST /treatments/voice-extract`
- Guards: `JwtAuthGuard` + `ClinicOrgGuard`.
- Service-level check of `TAB_TREATMENT_EDIT`. The plan flag is **not** checked in v1
(§8).
- Per-user throttle via `@nestjs/throttler` (present in `package.json`, currently wired
nowhere in `src/` — this is its first use, so the module must be registered in
`app.module.ts`). Configurable; v1 default **6 requests / 60s per user**
(`VOICE_THROTTLE_LIMIT`, `VOICE_THROTTLE_TTL`). A human cannot approach that — a
recording plus processing takes ten seconds at minimum — so it is purely an abuse and
runaway-loop guard, which is doing more work than usual given v1 is ungated and
uncapped (open item 14).
- Body: base64 audio + declared format + `clientTodayIso` + IANA `timeZone` +
`treatmentDetailId`/`clientId` for context.
`clientTodayIso` and `timeZone` come from the client per the existing house rule
(`AGENTS.md`): never derive the clinic's civil day from `Date#getDay()` on the UTC
server. Reuse `common/zoned-civil-time.ts`.
---
## 4. Provider registry
ASR and extraction are **separate swappable roles**. They will not come from the same
vendor for every locale.
```ts
interface AsrProvider {
transcribe(audio: AudioInput, localeHint: string): Promise<{ text: string; usage: AsrUsage }>;
}
interface ExtractionProvider {
extract(transcript: string, catalog: CatalogPrompt, ctx: ExtractionContext): Promise<VoiceIntent>;
}
```
Resolved through a registry keyed by locale.
At launch **all three locales use the same profile** — OpenRouter with
`openai/whisper-1` for ASR. The per-locale indirection is kept anyway, because the
locale is the axis along which this is most likely to diverge: Persian ASR is the
weakest link (§11), and swapping only `fa` to a Persian-specialist vendor must not be a
code change.
```
VOICE_PROFILE_FA = openrouter:openai/whisper-1 | openrouter:<llm-model>
VOICE_PROFILE_EN = openrouter:openai/whisper-1 | openrouter:<llm-model>
VOICE_PROFILE_NL = openrouter:openai/whisper-1 | openrouter:<llm-model>
```
The `language` hint is the profile's locale as ISO-639-1 — `fa`, `en`, `nl` — not a
constant.
Reachability is a property of **where you deploy**, not of the code. The same image
serves an Iran-hosted instance with a domestic `fa` profile and a Europe-hosted instance
with an OpenRouter profile; only config differs. A locale with no configured profile has
no microphone button at all (§2).
> The frontend must learn which locales are enabled **from the API**, not from a
> `NEXT_PUBLIC_*` var — those are baked in at build time, so an env-var approach would
> make enabling a locale require rebuilding and repushing the frontend image.
### v1 provider: OpenRouter
Both roles, one API key.
**ASR**`POST https://openrouter.ai/api/v1/audio/transcriptions`
```json
{
"model": "openai/whisper-1",
"input_audio": { "data": "<base64>", "format": "webm" },
"language": "fa"
}
```
- Accepted formats: WAV, MP3, FLAC, M4A, OGG, **WebM**, **AAC**.
Chrome/Android `webm/opus` and Safari/iPad `mp4/aac` both go through unmodified —
**no transcode dependency is required**.
- Limits: 25 MB; 60s upstream *processing* timeout. The 2-minute recording cap (§2) sits
comfortably inside both.
- Request shape **verified** against OpenRouter's STT docs: base64 JSON `input_audio` is
the documented primary path (multipart `file` is the OpenAI-compatible alternative), and
both default model slugs exist on the live models API.
- The cap is enforced twice: the client's reported `durationMs`, and again against the
vendor's own `usage.seconds` — the client's figure is a claim, not enforcement. The
server allows a 2s tolerance, because the client measures length *after* the recorder
stops and a recording that runs to the cap always reports slightly over it.
- Response: `{ text, usage: { seconds, total_tokens, cost } }`.
- Price: `openai/whisper-1` is **$0.006/minute, billed to the nearest second** → $0.002 for
a typical 20s utterance, **$0.012 at the 2-minute cap**. OpenRouter forwards this model
directly to OpenAI.
**Extraction** — OpenRouter chat completions with a JSON schema constraining the output
to `VoiceIntent`. Model id is config (`VOICE_LLM_MODEL`).
**Chosen: `google/gemini-3.7-flash`.**
Cost is deliberately *not* the selection axis. The call is ~1,250 input tokens (system
prompt + catalog labels + JSON schema + linked labs + transcript) and ~200 output, so the
whole candidate field spans about one cent per recording:
| Model | in / out per M tokens | ≈ per call |
|---|---|---|
| `google/gemini-3.7-flash` | $0.375 / $1.875 | $0.0008 |
| `qwen/qwen3.8-27b` | $0.45 / $3.20 | $0.0012 |
| `qwen/qwen3.8-max` | $2 / $6 | $0.0037 |
| `openai/gpt-5.6-terra` | $2 / $12 | $0.0049 |
| `anthropic/claude-opus-5` | $5 / $25 | $0.0113 |
Select for Persian comprehension and reliable constrained JSON instead. Gemini Flash wins
on the reasoning that this task is **not** reasoning-heavy — read a short sentence, pick
codes from a supplied closed list, emit small JSON — and it is simultaneously the cheapest
and lowest-latency candidate, which matters on a pipeline already at 310s.
**Escalation path if Persian comprehension proves weak** on real transcripts (item 1):
`qwen/qwen3.8-max` (leads current multilingual rankings, ~4.6× the cost) and then
`anthropic/claude-opus-5` as the accuracy ceiling. Both are a config change —
`VOICE_LLM_MODEL` — not a code change. Worth knowing when judging that ranking evidence:
it is marked provisional and was measured on the previous Qwen generation.
---
## 5. Extraction contract
The model returns intents only. Illustrative shape:
```ts
type VoiceIntent = {
treatmentType: string | null; // catalog code, from the supplied closed list
teeth: ToothIntent[];
connectedSpans: { from: ToothIntent; to: ToothIntent }[];
comment: string | null;
prosthesis: {
defaultType: string | null; // catalog code
overrides: { tooth: ToothIntent; type: string }[];
} | null;
labId: string | null; // must be one of the supplied linked-lab ids
labMatchExact: boolean;
due: DueIntent | null;
};
type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string }
| { kind: 'positional'; arch: 'upper' | 'lower';
side: 'patient_right' | 'patient_left'; position: number; spoken: string };
type DueIntent =
| { kind: 'weekday'; weekday: string; which: 'this' | 'next' }
| { kind: 'offset'; unit: 'day' | 'week' | 'month'; amount: number }
| { kind: 'jalali'; jy: number; jm: number; jd: number }
| { kind: 'gregorian'; y: number; m: number; d: number };
```
Every code-valued field is constrained to a **closed list supplied in the prompt**:
- Treatment types and prosthesis types come from `CatalogLabelService` in the actor's
locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of
`prosthesis` and `monolithic_zirconia` rather than being asked to translate. Catalog
entities store a stable `code` and no label — never hardcode a label.
- Lab candidates are the clinic's linked labs only (`OrganizationLink`), passed as
`{ id, name }`. The model may return one of those ids or `null`, nothing else.
Every unresolved or rejected item is reported, never silently dropped.
---
## 6. Resolvers
Both live in `backend/src/`, pure and Jest-covered. The frontend has **no test runner**
(no jest/vitest, zero spec files) — putting them there would forfeit the testability
that justified this whole design.
### `resolveToothIntent()`
- Owns the patient-right convention in exactly one place: `upper + patient_right`
quadrant 1, `upper + patient_left` → 2, `lower + patient_left` → 3,
`lower + patient_right` → 4. This is the mirroring bug, and it becomes a unit test.
- Rejects out-of-range positions rather than clamping. Position 9 is unresolved, never 8.
- Permanent dentition only — FDI 1118/2128/3138/4148, matching
`FDI_UPPER_LEFT_TO_RIGHT` / `FDI_LOWER_LEFT_TO_RIGHT`. Deciduous references
("دندان شیری") must resolve to **unresolved**, never snap to a permanent tooth.
- **Locale-neutral by construction.** `ToothIntent` carries `arch`/`side`/`position`, not
words, so the resolver needs no per-locale branches. The locale-specific part is the
*prompt*: each enabled locale needs its own spoken tooth vocabulary (`شش بالا راست`,
`upper right six`, `rechtsboven zes`).
- **A spoken tooth number is an FDI code, in every locale.** This is how clinicians
actually dictate — "بیست و شش" is tooth 26 — so the prompt *teaches* the notation
(first digit = quadrant from the patient's own point of view, second = position from
the midline) rather than refusing it. `arch`/`side`/`position` is the reading of a
tooth that was **described** instead of numbered, where a single digit is a position
and the quadrant comes from words. Revised after the first live test; the original
design had this backwards and made the descriptive form the only supported path.
- **A single digit alone is never resolved.** "دندون دو" names four teeth. It is reported
as `tooth_missing_quadrant` **with the candidate codes attached** — narrowed by whatever
*was* said, so "دو بالا" offers two — and the review sheet turns them into chips. The
clinician chooses; the resolver still never guesses.
- **Digits arrive in three scripts.** `normalizeFdiCode` (`common/fdi.ts`) folds Persian
and Arabic-Indic digits to ASCII and strips the spaces of a digit-by-digit dictation
before anything is matched, at both the wire branch choice and the final validation.
### `resolveDueDate()`
- Takes `clientTodayIso` + IANA `timeZone`; reuses `common/zoned-civil-time.ts`.
- Week start is **per locale**, because "next Thursday" is week-relative: `fa` starts
Saturday, `en` and `nl` start Monday. Hardcoding Saturday put an en/nl clinician's
deadline a week out. One place (`weekStartForLocale`), tested in both directions.
- `'this'` is occurrence-anchored (soonest strictly-future, never resolves into the past);
`'next'` is week-anchored. A missing qualifier is read as `'this'` — a bare weekday
carries none, and failing would discard a real spoken deadline.
- Jalali conversion is arithmetic, not inference: port `jalaliToGregorian` and
`toLatinDigits` from `frontend/src/lib/i18n/persianCalendar.ts` into
`backend/src/common/jalali.ts` with a spec. It is dependency-free integer math
(~60 relevant lines) and the calendar does not change, so the duplication is stable.
### Group / prosthesis rules
- Connected spans validate through the shipped helpers — `areArchNeighbors`, `sameArch`,
`teethBetweenInclusive`. **Never a 1-tooth connected group.** Anything invalid degrades
to singles and is flagged on the review sheet.
- Prosthesis: expand `defaultType` across all teeth, then apply per-tooth overrides.
- **All-or-nothing.** `assertCompleteToothProsthesisMap` requires every tooth on a
`prosthesis` detail to carry a `prosthesisTypeCode` or the send throws
`TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE`. So if even one tooth ends untyped, the
prosthesis row is marked incomplete and stays unticked — the unshippable state surfaces
at review, where it is cheap, not at dispatch minutes later on another screen.
- **Apply order is teeth → groups → prosthesis**, so `pruneToothProsthesisForGroups`
behaves.
---
## 7. Review sheet
**Presentation:** a **modal** on desktop; a **full-screen overlay** on mobile.
> ⚠ On mobile this must be an overlay rendered in place — **not** a Next.js route. A real
> navigation unmounts `TreatmentWorkspace` and destroys the in-progress draft. This is the
> same constraint the realtime soft-refresh already lives under: never remount the
> treatment form, never clear a draft.
- Renders the transcript, then one row per extracted field in the app's own vocabulary:
translated catalog labels, and a mini FDI chart for the teeth rather than a list of
numbers.
- Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also
what creates the new detail — see §2.
- Rows default to ticked **except**:
- the lab row when `labMatchExact` is false — shipping to a lab always requires a
deliberate tick;
- any row carrying an unresolved item or an incomplete prosthesis map.
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
clinician can see what the system did not understand.
- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any
per-detail convenience that would top the case up afterwards has to be suppressed for a
voice-created case, because a default that quietly adds a prosthesis type to a tooth the
sheet never mentioned turns the confirmation step into a lie about what it was going to
do — which is the whole reason the step exists.
> This branch carried an exemption for one such default, the dispatch panel's
> remembered-prosthesis auto-fill. `origin/master` deleted that feature outright
> (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule
> in code today. It is a constraint on whatever gets added next, not a description of
> something that exists.
- An item that carries `candidates` renders them as **tappable chips** — the one place the
sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and
ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a
dead end. Everything the sheet renders comes from that folded result, not the raw one.
- RTL-safe: logical `text-start` / `text-end` only, never `text-left`/`text-right`.
Dates via `lib/i18n/format.ts`.
---
## 8. Gating and configuration
Gate = `TAB_TREATMENT_EDIT``canEditTreatmentForDay` ∧ configured locale profile ∧
`Plan.features.voiceTreatmentEntry`. How each failing condition *renders* is in §2.
`Plan.features` is a `Json` column that already exists on the `Plan` model and is seeded
as `{}` for all five plans — and is **read nowhere in `backend/src/`**. Voice is its first
consumer, so:
- No migration is needed.
- The read should go through a small generic helper, since this establishes the pattern
for every future flag.
- Voice is metered vendor spend, which is why the gate is commercial (plan) rather than a
new `TAB_*` permission — a clinician who can already edit the form gains no capability
from voice, only speed.
The per-user throttle is a separate, non-commercial abuse control.
### v1 ships ungated
**The plan flag is designed but not enforced in v1** — voice is open to every clinic user
who can edit treatments, in every configured locale. `Plan.features.voiceTreatmentEntry`
and the availability API stay documented here as the intended gate, deferred rather than
dropped, so turning them on later is additive.
Consequence to accept deliberately: with no plan gate, the per-user throttle and the 2-minute
recording cap are the **only** controls on metered vendor spend. See open item 14.
---
## 9. Errors
Per the three-layer contract: a code in `common/errors/error-codes.ts`, the throw site,
and an `errors.X` key in **all three** of `frontend/messages/{en,fa,nl}.json`. Never a raw
English Nest exception for a user-facing failure.
| Code | When |
|---|---|
| `VOICE_MIC_DENIED` | microphone permission actually refused, or no input device — **client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site. Reserved for a real permission failure: see the note below |
| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), over vendor limits, or a request body past the DTO's size cap |
| `VOICE_UNSUPPORTED_FORMAT` | **the browser cannot record at all** — no `MediaRecorder`, no container both it and the API accept, or a recorder that throws after permission was granted; and server-side, a `format` outside `VOICE_AUDIO_FORMATS` |
| `VOICE_ASR_FAILED` | transcription stage failed |
| `VOICE_EXTRACT_FAILED` | transcript obtained, structuring failed |
| `VOICE_NOTHING_RECOGNIZED` | empty or unusable transcript |
| `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced |
| `VOICE_RATE_LIMITED` | throttle |
**Two of these are raised by DTO validation, not by a throw site.**
`validationExceptionFactory` returns a constraint's `message` verbatim when the message is
itself a known `ErrorCode`, so the voice DTO names its own failures:
`@MaxLength(…, { message: ErrorCode.VOICE_CLIP_TOO_LONG })` and
`@IsIn(…, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })`. Left to the shared constraint map
they fall through to `VALIDATION_FIELD_REQUIRED` and `VALIDATION_LANGUAGE_INVALID` — an
oversized recording telling the clinician a field is missing, and an unsupported container
telling them their language is invalid. Any new voice constraint should name its code the same
way.
**`VOICE_MIC_DENIED` is only for a real permission failure.** Three client paths used to report
it for something else entirely — no `MediaRecorder`, no acceptable container, and a recorder
that throws after permission was already granted. All three are "this browser cannot record"
and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting
in site settings for a permission nothing ever asked for.
**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED`
carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was
never written — `onError` only resolves a message through `getUserFacingError`, which never
reads `details`, so the transcript is shipped in an error body and dropped. Either build the
dialog below or stop returning the transcript; shipping dictation to the client and
discarding it is the worst of both.
When ASR succeeded and only extraction failed, the response still
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action
**creates a new detail with only `comment` set to the transcript** — everything else left
at `newDetail()` defaults. The words were captured and paid for; only the structure was
lost.
This keeps the feature's one invariant intact: **voice never writes into an existing
detail.** Dictating into an already-filled detail is a separate, later feature with its
own voice-to-text control scoped to that field (§1, out of scope).
It also does not bypass the confirmation rule — the dialog shows the transcript, and the
dentist taps to accept it. That review matters, because a raw transcript carries ASR
errors and may contain the patient's spoken name, and `comment` is persisted (§10).
---
## 10. Data handling
- Audio is held **in memory for the request only**. Never written to disk, never a Prisma
row. Note this is deliberately unlike treatment attachments, which do persist to
`backend/uploads/treatments`.
- The transcript goes to the browser for the review sheet and dies with it.
- The `comment` field persists a cleaned version of what was said — that is legitimate
clinical record-keeping and is the only durable trace.
- Telemetry is **structured and patient-free**: clip duration, which fields resolved,
unresolved count, vendor latency, `usage.cost`, outcome (applied / discarded / failed),
locale. Never the transcript, never audio, never a patient identifier.
The **destination is an open question** — this repo has no metrics infrastructure yet
(open item 9). Emit it as structured log lines in the interim so the fields exist and
can be routed later without changing call sites.
**Cancelling aborts the in-flight vendor call** via `AbortController`, rather than letting
it settle and discarding the result. Note this reduces spend but does not eliminate it:
work already performed upstream may still be billed.
Recordings are clinical descriptions of identifiable patients leaving the server for a
third party. Confirm the provider's retention and training policy in writing before
enabling this for real clinics.
---
## 11. Open items
1. **Persian ASR accuracy on tooth numbers is unmeasured, and it decides the feature.**
Everything downstream assumes a usable transcript; no design choice above compensates
for a bad one. Run this before writing feature code.
*Why tooth numbers specifically, not general accuracy:* a transcript can score well on
WER and still be useless here, because the errors land on the digits.
`چهار` (4) / `چهارده` (14) / `چهل` (40) differ by one syllable. FDI spoken as
`یک چهار` may return as `۱۴`, `14`, or `یک چهار`. Persian and Latin digit scripts mix
within one transcript. Jargon is loanwords (`زیرکونیا`, `پرسلن فیوزد تو متال`,
`اینله`/`آنله`) and clinicians code-switch into English mid-sentence. Suction and
handpiece run in the background.
*Protocol:* ~25 utterances from a dentist reading a script covering explicit FDI,
quadrant-relative phrasing, bridges, prosthesis types, due dates and notes — recorded
on the real device, ideally once quiet and once with the operatory running. POST each
to `/api/v1/audio/transcriptions` with `language: "fa"`. Score **per-tooth-reference
accuracy** (of every tooth spoken, how many survive recoverably?) and jargon
recognition separately. Notes accuracy barely matters. ~6 minutes of audio ≈ **$0.04**.
*Decision it drives:* if tooth accuracy holds, build. If not, the fix is not prompt
tuning — it is pointing the `fa` ASR slot at a Persian-specialist vendor while `en`/`nl`
keep `whisper-1`. That is a config change precisely because of decision 23.
*Unverified lead for that fallback:* recent Persian-ASR benchmark work reports
Qwen3-Omni as the strongest open Persian ASR. Not confirmed as available on
OpenRouter's transcription endpoint — check before relying on it.
*Byproduct:* the recordings become the fixture corpus for item 2.
2. ~~**`VOICE_LLM_MODEL`**~~**resolved:** `google/gemini-3.7-flash` (§4). Still worth
validating on real whisper output during item 1, since clean transcripts flatter every
model; the escalation path if Persian comprehension disappoints is documented in §4 and
is a config change.
3. ~~**Production reachability**~~**resolved:** confirmed reachable from the Iranian
production host. The per-locale registry is retained regardless (decision 23), so the
`fa` profile can still be repointed at a domestic vendor if item 1 goes badly.
4. ~~**Provider retention/training policy**~~**resolved:** restricted via OpenRouter's
account-level privacy/data-policy settings. Note this is an *account* setting, not a
per-request one: re-verify it if the API key or the OpenRouter account changes, and
remember `whisper-1` is forwarded to OpenAI, so the effective policy is OpenRouter's
plus that provider's.
5. ~~**English tooth numbering**~~**resolved:** a bare two-digit number is read as
**FDI in all three locales**. FDI is what the product is built on and what clinicians
dictate. Known trade-off, accepted: a clinician trained under Universal numbering says
"tooth 14" and means a different tooth, so an `en` clinic needs either training or a
later per-org notation setting. Revisit if a US clinic is onboarded.
6. **`nl` and `en` have no spike data.** The Persian spike (item 1) should be repeated per
locale before that locale's mic is enabled for real users — same protocol, same
scoring, different speaker.
7. ~~**Transcript salvage has no target**~~**resolved:** salvage creates a **new
detail** with only `comment` set to the transcript (§9). Preserves the invariant that
voice never writes into an existing detail; dictating into a filled detail becomes a
separate later feature with its own field-scoped control.
8. ~~**Throttle limits**~~**resolved:** configurable, v1 default **6 requests / 60s per
user** (§3). Unreachable by a human; a pure abuse guard.
9. **Telemetry has no sink.** §10 defines exactly what to record but not where it goes —
this repo has no metrics or analytics infrastructure yet. Deliberately deferred until
it does. Interim: structured log lines, so the fields exist and can be routed later
without touching call sites.
10. ~~**Review sheet modality**~~**resolved:** modal on desktop, full-screen **overlay**
on mobile (§7). Not a route — navigating would unmount `TreatmentWorkspace` and destroy
the draft.
11. ~~**Cancel and in-flight requests**~~**resolved:** abort via `AbortController` to
limit spend (§10). Upstream work already performed may still be billed.
12. ~~**Recording duration cap**~~**resolved:** **2 minutes**, configurable via `maxMs`
(§2). Still worth timing a realistic worst-case prosthesis dictation during item 1 to
confirm 2 minutes is comfortable rather than tight.
13. ~~**Availability API**~~**resolved for v1:** voice ships **open to everyone** with a
configured locale profile. No plan check, no availability endpoint. The `Plan.features`
design in §8 is deferred, not dropped.
### New
14. **Cost exposure is bounded but ungated in v1.** With the 2-minute cap (12) and the
6/60s throttle (8), worst case is 12 audio-minutes per user-minute ≈ **$0.072/min**, or
~$4.30 per hour of sustained abuse by one user — bounded, not free. Normal use is far
below this: a clinician doing 60 recordings a day at ~20s each costs about **$0.18/day**.
What remains open is that there is **no per-organization limit at all**, because v1
ships ungated (13). Worth deciding what to watch and at what number to react.
Mitigations, already designed and each a config or flag change: enforce the
`Plan.features` gate, lower `maxMs` or the throttle, or add an org-level monthly minute
budget.
---
## 12. Verification
- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all
four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate`
(per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the
Jalali port, prosthesis expansion + completeness, and connected-span validation.
- `cd backend && npm run build` — cross-cutting backend gate.
- `cd frontend && npx tsc --noEmit` — frontend gate.
- Manual: fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked
lab. Then specifically:
- **past day** → both segments disabled, control still split (not absent);
- **locale with no profile** → control renders unsplit, identical to today;
- **fa vs en** → mic sits at the logical end in both, on the same side as the chip's
trash;
- **cancel mid-recording** → chip strip unchanged, no orphan detail;
- **confirm** → always appends a new chip, whatever the active detail contains;
- **Add half** → behaves exactly as it did before this change;
- **tap 🎤 during the Lab wizard step** → confirm returns to the treatment step;
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
- **hold past 2:00** → auto-stops and proceeds to processing, not an error;
- **cancel during processing** → the vendor request is actually aborted;
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
there. They live on the lab case, which the autosave effect does not watch, so this is
the check that catches a lab draft left unsaved in component state;
- **record straight after opening a visit**, while the blank chip is still untyped, and
confirm with a lab ticked → no error toast: confirm detects the preview treatment and
skips the lab-case save rather than posting an id the server has never seen;
- **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows
both, and the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder. Nothing may
rewrite a per-tooth type the sheet already showed.
---
## 13. Decision log
Settled in a grilling session on 2026-08-20.
| # | Question | Decision |
|---|---|---|
| 1 | Scope | Everything including lab dispatch |
| 2 | AI supply chain | Domestic provider originally; OpenRouter for v1, registry keeps both open |
| 3 | Apply model | Review sheet, then apply |
| 4 | Speech → FDI | LLM emits intent, code resolves. A spoken number **is** the FDI code (revised 2026-08-21, §6) |
| 5 | Cardinality | One detail per recording |
| 6 | Lab destination | Closed list of linked labs, explicit confirm, unticked when inexact |
| 7 | Due date | Intent + deterministic resolver |
| 8 | Resolver location | Backend, Jalali math ported |
| 9 | Prosthesis | Default type + overrides, all-or-nothing |
| 10 | Retention | Discard audio and transcript, non-PHI telemetry only |
| 11 | Capture | Tap to start/stop, hard cap (see 27) |
| 12 | Failure UX | Stage-aware codes, transcript salvage |
| 13 | Locales | Provider registry per locale; all three locales enabled |
| 14 | Reachability | Registry now, slots filled per deployment |
| 23 | ASR model | `openai/whisper-1` for **every** locale; registry kept so `fa` can diverge |
| 24 | Extraction model | **`google/gemini-3.7-flash`**; escalation path documented in §4 |
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail |
| 26 | Throttle | Configurable; v1 default 6 requests / 60s per user |
| 27 | Duration cap | **2 minutes**, configurable via `maxMs` |
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
| 29 | Cancel | Aborts the in-flight vendor call |
| 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped |
Added while getting the first live recordings working (2026-08-21):
| # | Question | Decision |
|---|---|---|
| 31 | Tooth numbering | A spoken number **is** its FDI code, in all three locales. A lone digit stays unresolved and offers its candidate teeth as chips (§6, §7) |
| 32 | What confirm writes | Confirm persists the detail *and* its lab case, because autosave watches `details` only — but skips the lab-case save when it got a preview treatment back (§2) |
| 33 | Preview as contract | Applying a voice result fills exactly what the sheet showed. Per-detail conveniences that would add more are suppressed for that case (§7) |
| 15 | Gating | `Plan.features` flag — its first consumer |
UI placement settled in a second grilling session on 2026-08-20.
| # | Question | Decision |
|---|---|---|
| 16 | Mic action | Independent record action; the Add half's logic is untouched |
| 17 | Record UI | Mic segment toggles ▶/■; inline bar below the header row |
| 18 | Unavailable | Absent for technical + commercial; disabled for contextual |
| 19 | Component API | One optional `voice` object prop; `undefined` means absent |
| 20 | Side | Logical end (`border-s`), exactly like the chip's trash |
| 21 | Creation | On confirm, never on tap |
| 22 | Creation rule | Confirm **always** appends a new detail — no blank-reuse guard |

View File

@@ -899,7 +899,30 @@
"toothAria": "FDI tooth {fdi}",
"toothSelectedSuffix": ", selected",
"sentToAt": "Sent to {orgName} at {datetime}",
"fallbackOrgName": "organization"
"fallbackOrgName": "organization",
"voiceStart": "Record treatment",
"voiceStop": "Stop recording",
"voiceCancel": "Cancel",
"voiceProcessing": "Reading the recording…",
"voiceReviewTitle": "Check what was understood",
"voiceNothingExtracted": "Nothing usable was picked up from that recording.",
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
"voiceNotUnderstood": "Not understood",
"voicePickTooth": "Which tooth?",
"voiceDiscard": "Discard",
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
"voiceUnresolved": {
"not_permanent_tooth": "not a permanent tooth",
"position_out_of_range": "not a valid tooth position",
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”",
"malformed": "could not be read",
"span_not_same_arch": "a bridge cannot span both jaws",
"unknown_catalog_code": "not in this clinics list",
"tooth_not_selected": "that tooth is not part of this detail",
"invalid_date": "not a usable date"
},
"voiceFailed": "Voice entry failed. Please try again."
},
"organizations": {
"loadingOrganization": "Loading organization...",
@@ -1240,6 +1263,14 @@
"LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.",
"LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.",
"BAD_REQUEST": "The request could not be processed.",
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later.",
"VOICE_MIC_DENIED": "Microphone access was blocked. Allow it in your browser settings and try again.",
"VOICE_NOT_AVAILABLE": "Voice entry is not available for this language yet.",
"VOICE_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.",
"VOICE_UNSUPPORTED_FORMAT": "That recording format is not supported on this device.",
"VOICE_ASR_FAILED": "Could not turn the recording into text. Please try again.",
"VOICE_EXTRACT_FAILED": "Could not read the treatment details from the recording.",
"VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",
"VOICE_RATE_LIMITED": "Too many recordings in a short time. Please wait a moment."
}
}

View File

@@ -900,7 +900,30 @@
"toothAria": "دندان FDI {fdi}",
"toothSelectedSuffix": "، انتخاب شده",
"sentToAt": "ارسال به {orgName} در {datetime}",
"fallbackOrgName": "سازمان"
"fallbackOrgName": "سازمان",
"voiceStart": "ثبت گفتاری درمان",
"voiceStop": "توقف ضبط",
"voiceCancel": "لغو",
"voiceProcessing": "در حال پردازش گفتار…",
"voiceReviewTitle": "بررسی آنچه دریافت شد",
"voiceNothingExtracted": "از این ضبط چیز قابل استفاده‌ای برداشت نشد.",
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.",
"voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
"voiceNotUnderstood": "شناسایی نشد",
"voicePickTooth": "کدام دندان؟",
"voiceDiscard": "انصراف",
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
"voiceUnresolved": {
"not_permanent_tooth": "دندان دائمی نیست",
"position_out_of_range": "شماره دندان معتبر نیست",
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
"malformed": "قابل خواندن نبود",
"span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد",
"unknown_catalog_code": "در فهرست این مطب نیست",
"tooth_not_selected": "این دندان بخشی از این مورد نیست",
"invalid_date": "تاریخ قابل استفاده نیست"
},
"voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید."
},
"organizations": {
"loadingOrganization": "در حال بارگذاری سازمان...",
@@ -1241,6 +1264,14 @@
"LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندان‌ها و نوع پروتز را کامل کنید.",
"LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.",
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید.",
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
"VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمی‌شود.",
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
"VOICE_RATE_LIMITED": "تعداد ضبط‌ها در بازه کوتاه زیاد بود. کمی صبر کنید."
}
}
}

View File

@@ -899,7 +899,30 @@
"toothAria": "FDI-tand {fdi}",
"toothSelectedSuffix": ", geselecteerd",
"sentToAt": "Verzonden naar {orgName} op {datetime}",
"fallbackOrgName": "organisatie"
"fallbackOrgName": "organisatie",
"voiceStart": "Behandeling inspreken",
"voiceStop": "Opname stoppen",
"voiceCancel": "Annuleren",
"voiceProcessing": "Opname wordt gelezen…",
"voiceReviewTitle": "Controleer wat is begrepen",
"voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.",
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
"voiceNotUnderstood": "Niet begrepen",
"voicePickTooth": "Welk element?",
"voiceDiscard": "Verwerpen",
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
"voiceUnresolved": {
"not_permanent_tooth": "geen blijvend element",
"position_out_of_range": "geen geldige elementpositie",
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”",
"malformed": "kon niet worden gelezen",
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
"tooth_not_selected": "dat element hoort niet bij dit onderdeel",
"invalid_date": "geen bruikbare datum"
},
"voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw."
},
"organizations": {
"loadingOrganization": "Organisatie laden...",
@@ -1240,6 +1263,14 @@
"LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.",
"LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.",
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw.",
"VOICE_MIC_DENIED": "Microfoontoegang is geblokkeerd. Sta dit toe in uw browserinstellingen en probeer opnieuw.",
"VOICE_NOT_AVAILABLE": "Spraakinvoer is nog niet beschikbaar voor deze taal.",
"VOICE_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.",
"VOICE_UNSUPPORTED_FORMAT": "Dit opnameformaat wordt niet ondersteund.",
"VOICE_ASR_FAILED": "De opname kon niet naar tekst worden omgezet. Probeer het opnieuw.",
"VOICE_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.",
"VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",
"VOICE_RATE_LIMITED": "Te veel opnames in korte tijd. Wacht even."
}
}
}

View File

@@ -0,0 +1,95 @@
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import type { FdiToothId } from '@/types/treatment';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceProsthesisResult,
} from '@/types/voice';
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
export function voiceRowAvailability(result: VoiceExtractionResult) {
return {
treatmentType: result.treatmentType != null,
teeth: result.teeth.length > 0,
comment: Boolean(result.comment?.trim()),
prosthesis: result.prosthesis != null,
lab: result.labId != null,
dueDate: result.dueDate != null,
};
}
/**
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
* it is the one extracted value whose error leaves the building; and an incomplete
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
*/
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
const available = voiceRowAvailability(result);
return {
treatmentType: available.treatmentType,
teeth: available.teeth,
comment: available.comment,
prosthesis: available.prosthesis && result.prosthesis?.complete === true,
lab: available.lab && result.labMatchExact,
dueDate: available.dueDate,
};
}
/**
* Intersected with availability rather than counting ticks: a row can be ticked and then lose
* its content, and "Apply 1 item" that applies nothing is worse than a wrong number.
*/
export function countSelected(
selection: VoiceApplySelection,
available: Record<keyof VoiceApplySelection, boolean>,
): number {
return (Object.keys(selection) as (keyof VoiceApplySelection)[]).filter(
(key) => selection[key] && available[key],
).length;
}
/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */
function recheckProsthesis(
prosthesis: VoiceProsthesisResult,
teeth: readonly FdiToothId[],
): VoiceProsthesisResult {
const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]);
return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 };
}
/**
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
*
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
*/
export function withChosenTeeth(
result: VoiceExtractionResult,
chosen: readonly FdiToothId[],
): VoiceExtractionResult {
if (chosen.length === 0) return result;
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
return {
...result,
teeth,
toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups),
prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null,
};
}
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> {
const connected = new Set<FdiToothId>();
for (const group of result.toothSelectionGroups) {
if (group.kind !== 'connected') continue;
for (const tooth of group.teeth) connected.add(tooth);
}
return connected;
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
return Object.values(voiceRowAvailability(result)).some(Boolean);
}

View File

@@ -2,12 +2,14 @@
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2 } from 'lucide-react';
import { Mic, Square, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
import { VoiceRecordingBar } from '@/components/ui/treatment/VoiceRecordingBar';
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
import type { TreatmentDetailDraft } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
@@ -42,6 +44,12 @@ interface TreatmentDetailsEditorProps {
stepper?: ReactNode;
/** Shown below type + chart + notes (e.g. Continue to lab). */
footer?: ReactNode;
/**
* Voice entry. Omit when unavailable — the Add button then renders unsplit, exactly as
* before this feature existed. Presence *is* the availability flag, so the two cannot
* disagree.
*/
voice?: VoiceCaptureState;
/** Dim the chart until a treatment type is chosen. */
chartLocked?: boolean;
chartLockMessage?: string;
@@ -65,6 +73,7 @@ export function TreatmentDetailsEditor({
onRemoveAttachment,
showChrome = true,
showFields = true,
voice,
chart,
stepper,
footer,
@@ -109,18 +118,31 @@ export function TreatmentDetailsEditor({
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
</div>
<Button
type="button"
variant="primary"
disabled={!canEdit || disabled}
onClick={onAddDetail}
fullWidth
className="sm:w-auto shrink-0"
>
{t('addDetail')}
</Button>
{voice ? (
<AddDetailWithVoice
addLabel={t('addDetail')}
startLabel={t('voiceStart')}
stopLabel={t('voiceStop')}
disabled={!canEdit || disabled}
onAddDetail={onAddDetail}
voice={voice}
/>
) : (
<Button
type="button"
variant="primary"
disabled={!canEdit || disabled}
onClick={onAddDetail}
fullWidth
className="sm:w-auto shrink-0"
>
{t('addDetail')}
</Button>
)}
</div>
{voice ? <VoiceRecordingBar voice={voice} /> : null}
<div className="flex flex-wrap gap-2">
{details.map((d, idx) => {
const detailLocked = isDetailLocked(d);
@@ -312,3 +334,77 @@ function NotesField({
</label>
);
}
/**
* "Add detail", split into two segments with the microphone at the logical end.
*
* Built like the detail chip's trash affordance in this same file — a wrapper holding two
* raw `<button>`s divided by `border-s` — rather than two shared `Button`s, which hardcode
* their own rounding and would fight a segmented control. `border-s` puts the microphone
* visually right in en/nl and left in fa, on the same side as the chip's trash in both.
*
* Add keeps its exact existing behaviour; the microphone is an independent action.
*/
function AddDetailWithVoice({
addLabel,
startLabel,
stopLabel,
disabled,
onAddDetail,
voice,
}: {
addLabel: string;
startLabel: string;
stopLabel: string;
disabled: boolean;
onAddDetail: () => void;
voice: VoiceCaptureState;
}) {
const isRecording = voice.phase === 'recording';
const isBusy = voice.phase !== 'idle';
const micLabel = isRecording ? stopLabel : startLabel;
return (
<div
className={`
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
bg-primary text-white shrink-0 sm:w-auto
${disabled ? 'opacity-60' : ''}
`}
>
<button
type="button"
onClick={onAddDetail}
disabled={disabled || isBusy}
className="
flex-1 px-4 py-2 text-sm font-medium transition-all duration-200
hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset
focus-visible:ring-white/60
disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60
"
>
{addLabel}
</button>
<button
type="button"
onClick={isRecording ? voice.onStop : voice.onStart}
disabled={disabled || voice.phase === 'processing'}
title={micLabel}
aria-label={micLabel}
className={`
inline-flex items-center justify-center border-s border-white/25 px-3
transition-all duration-200 focus:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-white/60
disabled:cursor-not-allowed disabled:opacity-60
${isRecording ? 'bg-red-600 hover:bg-red-700' : 'hover:opacity-90'}
`}
>
{isRecording ? (
<Square className="h-4 w-4 fill-current" aria-hidden />
) : (
<Mic className="h-4 w-4" aria-hidden />
)}
</button>
</div>
);
}

View File

@@ -29,6 +29,14 @@ import {
import { appointmentsApi } from '@/lib/api/appointments';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { voiceApi } from '@/lib/api/voice';
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
import type {
VoiceApplySelection,
VoiceAvailability,
VoiceExtractionResult,
} from '@/types/voice';
import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
@@ -464,6 +472,11 @@ export function TreatmentWorkspace({
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
const [voiceAvailability, setVoiceAvailability] = useState<VoiceAvailability | null>(null);
const [voiceResult, setVoiceResult] = useState<VoiceExtractionResult | null>(null);
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
@@ -489,6 +502,21 @@ export function TreatmentWorkspace({
[appointments, selectedAppointmentId],
);
const voice = useVoiceCapture({
// The locale the clinician is actually reading and speaking in. Sent explicitly so
// the server's ASR hint, catalog labels and week start match what the microphone was
// offered for — req.user.language can drift from the URL locale.
locale,
maxMs: voiceAvailability?.maxRecordingMs ?? null,
onExtracted: setVoiceResult,
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
});
/** Absence is the unavailable state — the Add button then renders unsplit. */
const voiceForEditor =
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
const selectedStandalone = useMemo(
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
[standaloneTreatments, selectedStandaloneId],
@@ -927,12 +955,18 @@ export function TreatmentWorkspace({
let cancelled = false;
void (async () => {
try {
const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([
treatmentsApi.listLinkedOrganizations(),
treatmentCatalogApi.list(),
prosthesisCatalogApi.list(),
]);
const [orgsResponse, catalogResponse, prosthesisResponse, voiceResponse] =
await Promise.all([
treatmentsApi.listLinkedOrganizations(),
treatmentCatalogApi.list(),
prosthesisCatalogApi.list(),
// Voice availability comes from the API, not a NEXT_PUBLIC_* var: those are
// baked in at build time, so enabling a locale would need a frontend rebuild.
// A failure here must not take the whole treatment tab down with it.
voiceApi.availability().catch(() => null),
]);
if (cancelled) return;
setVoiceAvailability(voiceResponse?.data ?? null);
setOrgs(orgsResponse.data);
setTreatmentCatalog(catalogResponse.data);
setProsthesisCatalog(prosthesisResponse.data);
@@ -1935,6 +1969,114 @@ export function TreatmentWorkspace({
],
);
/**
* Voice entry.
*
* Confirm always appends a NEW detail — it never edits an existing one, and never
* touches onAddDetail. Nothing is created until this runs, so cancelling or a failed
* recording leaves the chip strip untouched.
*/
const applyVoiceResult = useCallback(
(result: VoiceExtractionResult, selection: VoiceApplySelection) => {
const detail = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
// Ticked rows land on top of the seeded defaults, so unticking the type row leaves
// the appointment-purpose default rather than a blank.
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
if (selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
teeth: [...group.teeth],
}));
}
if (selection.comment && result.comment) {
detail.comment = result.comment;
}
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet.
detailsRef.current = nextDetails;
setActiveDetailId(detail.clientId);
setEntryStep('treatment');
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
const wantsLabDraft =
(selection.prosthesis && result.prosthesis) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
if (wantsLabDraft) {
const draft = newLabCaseDraft();
draft.detailClientId = detail.clientId;
if (selection.lab && result.labId) {
draft.destinationOrganizationId = result.labId;
}
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
if (selection.prosthesis && result.prosthesis) {
// byTooth keys are plain strings; the group's teeth are FdiToothId.
const groupOf = (tooth: string) =>
result.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(tooth),
)?.groupId ?? '';
// Only teeth that actually landed on the detail. Unticking "teeth" while
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
// the treatment does not contain — nothing downstream filters them, and they
// would reach task generation as work for teeth nobody is treating.
const detailTeeth = new Set<string>(detail.teeth);
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
.filter(([tooth]) => detailTeeth.has(tooth))
.map(([tooth, prosthesisTypeCode]) => ({
detailClientId: detail.clientId,
tooth,
prosthesisTypeCode,
selectionGroupId: groupOf(tooth),
}));
}
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
// Autosave only watches `details`, so a lab draft left in state alone loses the
// lab, the due date and the prosthesis map on reload — silently, because the
// detail itself survives.
void (async () => {
try {
const saved = await persistDraft({ force: true });
// persistDraft returns a *preview* when the details are not persistable — one
// blank detail is enough — and a preview's detail id falls back to the client
// id. Check what came back, not the precondition, so this holds for every early
// return persistDraft has.
const savedDetail = saved.details.find((d) => d.clientId === detail.clientId);
if (!savedDetail?.id || savedDetail.id === detail.clientId) return;
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
})();
}
setVoiceResult(null);
},
[
labCaseDrafts,
persistDraft,
persistLabCases,
selectedAppointment?.purpose,
showError,
t,
tErrors,
treatmentCatalog,
],
);
const handleRemoveDetail = useCallback(
(detailClientId: string) => {
if (!canEditTreatmentForDay) return;
@@ -2478,6 +2620,7 @@ export function TreatmentWorkspace({
}}
showChrome
showFields={entryStep === 'treatment'}
voice={voiceForEditor}
chartLocked={
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
}
@@ -2725,6 +2868,16 @@ export function TreatmentWorkspace({
)}
</div>
</div>
{voiceResult ? (
<VoiceReviewSheet
result={voiceResult}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labs={orgs}
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onDiscard={() => setVoiceResult(null)}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useTranslations } from 'next-intl';
import { Loader2, X } from 'lucide-react';
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
const METER_BARS = 9;
function formatElapsed(ms: number): string {
const totalSeconds = Math.floor(Math.max(0, ms) / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
/**
* Live recording / processing strip.
*
* Sits between the header row and the chip strip rather than inside the segmented control:
* the header is `sm:justify-between`, so growing the button mid-recording would shift the
* whole row.
*/
export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) {
const t = useTranslations('treatment');
if (voice.phase === 'idle') return null;
const isRecording = voice.phase === 'recording';
return (
<div
className="flex items-center gap-3 rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2"
role="status"
aria-live="polite"
>
{isRecording ? (
<>
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-red-500" aria-hidden />
<span className="shrink-0 text-sm tabular-nums text-text-primary">
{formatElapsed(voice.elapsedMs)}
{voice.maxMs != null ? (
<span className="text-text-muted"> / {formatElapsed(voice.maxMs)}</span>
) : null}
</span>
<LevelMeter level={voice.level} />
</>
) : (
<>
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-text-secondary" aria-hidden />
<span className="text-sm text-text-secondary">{t('voiceProcessing')}</span>
</>
)}
<button
type="button"
onClick={voice.onCancel}
title={t('voiceCancel')}
aria-label={t('voiceCancel')}
className="ms-auto inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-red-500/15 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40"
>
<X className="h-3.5 w-3.5" aria-hidden />
{t('voiceCancel')}
</button>
</div>
);
}
/** Proves the microphone is actually hearing something — silence looks identical otherwise. */
function LevelMeter({ level }: { level: number }) {
return (
<span className="flex h-4 flex-1 items-end gap-0.5" aria-hidden>
{Array.from({ length: METER_BARS }, (_, index) => {
// Bars light up left to right as the level rises, with a floor so the meter never
// looks dead while a quiet voice is still being captured.
const threshold = (index + 1) / METER_BARS;
const active = level >= threshold * 0.9;
const height = active ? 30 + threshold * 70 : 20;
return (
<span
key={index}
className={`w-1 rounded-sm transition-all duration-75 ${
active ? 'bg-primary' : 'bg-border'
}`}
style={{ height: `${height}%` }}
/>
);
})}
</span>
);
}

View File

@@ -0,0 +1,306 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import {
connectedTeethFromResult,
countSelected,
hasAnythingToApply,
initialVoiceSelection,
voiceRowAvailability,
withChosenTeeth,
} from '@/components/treatment/voiceReviewRows';
import { useLocale } from 'next-intl';
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
interface VoiceReviewSheetProps {
result: VoiceExtractionResult;
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[];
labs: LinkedOrganizationOption[];
/** The result is handed back because the sheet may have added teeth the model missed. */
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onDiscard: () => void;
}
/**
* Confirmation step between the model's output and the form.
*
* Modal on desktop, bottom sheet on mobile — an overlay and not a route, because navigating
* would unmount TreatmentWorkspace and destroy the in-progress draft.
*/
export function VoiceReviewSheet({
result,
treatmentCatalog,
prosthesisCatalog,
labs,
onApply,
onDiscard,
}: VoiceReviewSheetProps) {
const t = useTranslations('treatment');
const locale = useLocale();
const { formatDate } = useAppFormatters();
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
initialVoiceSelection(result),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
// Everything below renders from `effective`, never from `result` — a tooth picked from
// the candidate chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
const nothingToApply = !hasAnythingToApply(effective);
const selectedCount = countSelected(selection, available);
const pickCandidate = (tooth: FdiToothId) => {
const nextChosen = chosen.includes(tooth)
? chosen.filter((t) => t !== tooth)
: [...chosen, tooth];
setChosen(nextChosen);
setSelection((prev) => ({
...prev,
// The teeth row starts unticked whenever the recording produced no teeth of its own,
// and a picked tooth that is not ticked applies nothing.
teeth: true,
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
// clinician's call.
prosthesis:
prev.prosthesis &&
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
}));
};
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
setSelection((prev) => ({ ...prev, [key]: checked }));
return (
<ResponsiveDialogOverlay onBackdropClick={onDiscard}>
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
aria-labelledby="voice-review-title"
maxWidthClass="sm:max-w-xl"
>
<h2 id="voice-review-title" className="text-base font-semibold text-text-primary">
{t('voiceReviewTitle')}
</h2>
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
{effective.transcript}
</p>
{nothingToApply ? (
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
) : (
<div className="mt-4 space-y-3">
{available.treatmentType ? (
<Row
label={t('treatmentType')}
checked={selection.treatmentType}
onChange={toggle('treatmentType')}
>
<span className="text-sm text-text-primary">
{labelFor(effective.treatmentType, treatmentCatalog)}
</span>
</Row>
) : null}
{available.teeth ? (
<Row label={t('entryStepTeeth')} checked={selection.teeth} onChange={toggle('teeth')}>
<div className="mt-1">
<FdiToothChart
readOnly
compact
scale={0.55}
selected={selectedTeeth}
connectedTeeth={connectedTeeth}
/>
</div>
</Row>
) : null}
{available.comment ? (
<Row
label={t('comments')}
checked={selection.comment}
onChange={toggle('comment')}
>
<span className="text-sm whitespace-pre-wrap text-text-primary">
{effective.comment}
</span>
</Row>
) : null}
{available.prosthesis && effective.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
effective.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{Object.entries(effective.prosthesis.byTooth)
.map(
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
)
.join(' · ')}
</span>
</Row>
) : null}
{available.lab ? (
<Row
label={t('entryStepLab')}
checked={selection.lab}
onChange={toggle('lab')}
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
>
<span className="text-sm text-text-primary">
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
</span>
</Row>
) : null}
{available.dueDate && effective.dueDate ? (
<Row
label={t('dueDateLabel')}
checked={selection.dueDate}
onChange={toggle('dueDate')}
>
<span className="text-sm text-text-primary">
{formatDate(civilDateToLocalDate(effective.dueDate))}
</span>
</Row>
) : null}
</div>
)}
{effective.unresolved.length > 0 ? (
<div className="mt-4 rounded-[var(--radius-md)] border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
{t('voiceNotUnderstood')}
</p>
<ul className="mt-1 space-y-0.5">
{effective.unresolved.map((item, index) => (
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
{item.spoken ? `${item.spoken}” — ` : ''}
{t(`voiceUnresolved.${item.reason}`)}
{item.candidates && item.candidates.length > 0 ? (
<span className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-text-muted">{t('voicePickTooth')}</span>
{item.candidates.map((tooth) => {
const picked = chosen.includes(tooth as FdiToothId);
return (
<button
key={tooth}
type="button"
aria-pressed={picked}
aria-label={t('toothAria', { fdi: tooth })}
onClick={() => pickCandidate(tooth as FdiToothId)}
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
picked
? 'border-transparent bg-primary text-white'
: 'border-border text-text-primary hover:border-border-strong'
}`}
>
{tooth}
</button>
);
})}
</span>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="secondary" onClick={onDiscard} fullWidth className="sm:w-auto">
{t('voiceDiscard')}
</Button>
<Button
type="button"
variant="primary"
disabled={selectedCount === 0}
onClick={() => onApply(selection, effective)}
fullWidth
className="sm:w-auto"
>
{t('voiceApply', { count: selectedCount })}
</Button>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
);
}
function Row({
label,
checked,
onChange,
warning,
children,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
warning?: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
<Checkbox checked={checked} onChange={onChange} label={label} />
<div className="mt-1 ps-7 min-w-0">{children}</div>
{warning ? (
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
{warning}
</p>
) : null}
</div>
);
}
/**
* `new Date('2025-10-17')` parses a civil date as UTC midnight, which renders as the 16th
* west of Greenwich. Build it from its parts so it means the same day everywhere.
*/
function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
function formatToothList(teeth: readonly string[], locale: string): string {
try {
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
} catch {
return teeth.join(', ');
}
}

View File

@@ -0,0 +1,29 @@
import { apiClient } from './client';
import type { VoiceAvailability, VoiceExtractionResult } from '@/types/voice';
export interface ExtractVoicePayload {
/** Base64 audio, no data: prefix. */
audio: string;
format: string;
/** IANA zone — the server derives "today" from it for relative deadlines. */
timeZone: string;
durationMs: number;
/** Locale the clinician is speaking; the server uses it rather than the stored one. */
locale: string;
}
export const voiceApi = {
availability: async (): Promise<{ success: boolean; data: VoiceAvailability }> => {
const response = await apiClient.get('/voice/availability');
return response.data;
},
extract: async (
payload: ExtractVoicePayload,
signal?: AbortSignal,
): Promise<{ success: boolean; data: VoiceExtractionResult }> => {
// Forwarded so cancelling closes the connection; the controller turns that into an abort.
const response = await apiClient.post('/voice/extract', payload, { signal });
return response.data;
},
};

View File

@@ -0,0 +1,60 @@
/** Containers the backend accepts, in the order we prefer to record them. */
const PREFERRED_MIME_TYPES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/aac',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
/**
* Pick a container this browser can record AND the backend accepts. Chrome and Android give
* webm/opus, Safari and iPad mp4/aac; both go to the vendor unmodified, so there is no
* transcode step and the list is an intersection, not a preference.
*/
export function pickRecordingMimeType(): string | null {
if (typeof MediaRecorder === 'undefined') return null;
if (typeof MediaRecorder.isTypeSupported !== 'function') {
// Safari <14.1 shipped MediaRecorder without the feature check; let it choose.
return '';
}
for (const type of PREFERRED_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(type)) return type;
}
return null;
}
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */
export function mimeTypeToFormat(mimeType: string): string {
const base = mimeType.split(';')[0]?.trim().toLowerCase() ?? '';
const subtype = base.startsWith('audio/') ? base.slice('audio/'.length) : base;
// Safari/iOS records `audio/mp4`, but the transcription endpoint's documented container
// list names m4a, not mp4. Same container; send the name the vendor documents, so iPad
// recordings do not fail while Chrome's webm works.
if (subtype === 'x-m4a' || subtype === 'm4a' || subtype === 'mp4') return 'm4a';
if (subtype === 'mpeg') return 'mp3';
return subtype || 'webm';
}
/** Blob → base64 without the `data:` prefix, which the API does not want. */
export async function blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer();
let binary = '';
const bytes = new Uint8Array(buffer);
// Chunked to avoid blowing the argument limit on a two-minute recording.
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
export function isMediaRecorderSupported(): boolean {
return (
typeof window !== 'undefined' &&
typeof MediaRecorder !== 'undefined' &&
typeof navigator !== 'undefined' &&
Boolean(navigator.mediaDevices?.getUserMedia)
);
}

View File

@@ -0,0 +1,300 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { voiceApi } from '@/lib/api/voice';
import type { ApiError } from '@/types/api';
import type { VoiceExtractionResult, VoicePhase } from '@/types/voice';
import {
blobToBase64,
isMediaRecorderSupported,
mimeTypeToFormat,
pickRecordingMimeType,
} from './audioFormat';
export interface UseVoiceCaptureOptions {
/** The locale the clinician is speaking, sent so the server does not have to guess. */
locale: string;
/** null means uncapped; otherwise the recorder auto-stops here. */
maxMs: number | null;
onExtracted: (result: VoiceExtractionResult) => void;
onError: (error: unknown) => void;
}
export interface VoiceCaptureState {
phase: VoicePhase;
elapsedMs: number;
/** 0..1, for the level meter — proves the microphone is actually hearing something. */
level: number;
maxMs: number | null;
onStart: () => void;
onStop: () => void;
onCancel: () => void;
}
const LEVEL_POLL_MS = 100;
/**
* Client-side failures must be ApiError-shaped or getUserFacingError cannot resolve them
* and every one renders the generic fallback, leaving errors.VOICE_MIC_DENIED dead.
*/
function clientError(code: string): ApiError {
return { statusCode: 0, code };
}
/**
* Microphone capture for treatment voice entry.
*
* Lives in lib/ rather than in the editor: TreatmentDetailsEditor stays presentational
* and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/.
*/
export function useVoiceCapture({
locale,
maxMs,
onExtracted,
onError,
}: UseVoiceCaptureOptions): VoiceCaptureState {
const [phase, setPhase] = useState<VoicePhase>('idle');
const [elapsedMs, setElapsedMs] = useState(0);
const [level, setLevel] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startedAtRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const abortRef = useRef<AbortController | null>(null);
/** Set when the user cancels, so the recorder's stop handler discards instead of sending. */
const cancelledRef = useRef(false);
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
const mountedRef = useRef(true);
/**
* Set synchronously on click: `phase` only becomes 'recording' once getUserMedia resolves,
* so a second click during the permission prompt would orphan the first stream.
*/
const startingRef = useRef(false);
const teardown = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
void audioContextRef.current?.close().catch(() => undefined);
audioContextRef.current = null;
recorderRef.current = null;
setLevel(0);
}, []);
// Releasing the microphone on unmount matters: the browser shows a recording indicator
// for as long as the track is live, and an orphaned one looks like the app is listening.
useEffect(() => {
// Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and
// a ref that is only ever set false would leave the hook permanently "unmounted".
mountedRef.current = true;
return () => {
mountedRef.current = false;
cancelledRef.current = true;
abortRef.current?.abort();
try {
recorderRef.current?.stop();
} catch {
// already stopped
}
teardown();
};
}, [teardown]);
const send = useCallback(
async (blob: Blob, mimeType: string, durationMs: number) => {
setPhase('processing');
const controller = new AbortController();
abortRef.current = controller;
try {
const audio = await blobToBase64(blob);
const response = await voiceApi.extract(
{
audio,
format: mimeTypeToFormat(mimeType),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
durationMs,
locale,
},
controller.signal,
);
if (cancelledRef.current) return;
onExtracted(response.data);
} catch (error) {
if (cancelledRef.current || controller.signal.aborted) return;
onError(error);
} finally {
abortRef.current = null;
setPhase('idle');
setElapsedMs(0);
}
},
[locale, onExtracted, onError],
);
const stop = useCallback(() => {
// No recorder means nothing will fire `onstop`, so nothing else moves the phase.
if (!recorderRef.current) {
teardown();
setPhase('idle');
return;
}
try {
recorderRef.current.stop();
} catch {
teardown();
setPhase('idle');
}
}, [teardown]);
const onStart = useCallback(() => {
if (phase !== 'idle' || startingRef.current) return;
if (!isMediaRecorderSupported()) {
// VOICE_UNSUPPORTED_FORMAT, not MIC_DENIED: nothing asked for a permission yet, and
// blaming the microphone sends the clinician into site settings for no reason. Same
// for the two paths below.
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
return;
}
cancelledRef.current = false;
startingRef.current = true;
void (async () => {
try {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
// Permission refused, or no input device. Never a server round-trip.
onError(clientError('VOICE_MIC_DENIED'));
return;
}
if (!mountedRef.current) {
// Permission resolved after the component went away — release it immediately
// rather than leaving the browser's recording indicator lit.
stream.getTracks().forEach((track) => track.stop());
return;
}
const mimeType = pickRecordingMimeType();
if (mimeType === null) {
stream.getTracks().forEach((track) => track.stop());
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
return;
}
streamRef.current = stream;
chunksRef.current = [];
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
recorderRef.current = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunksRef.current.push(event.data);
};
recorder.onstop = () => {
const durationMs = Date.now() - startedAtRef.current;
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType });
teardown();
if (cancelledRef.current || blob.size === 0) {
setPhase('idle');
setElapsedMs(0);
return;
}
// Prefer what the recorder actually produced, then the blob's own type. Old
// Safari accepts no mimeType hint, and defaulting to webm would mislabel its
// mp4/aac clips as something they are not.
void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs);
};
attachLevelMeter(stream, audioContextRef, setLevel);
startedAtRef.current = Date.now();
recorder.start();
setPhase('recording');
setElapsedMs(0);
timerRef.current = setInterval(() => {
const elapsed = Date.now() - startedAtRef.current;
setElapsedMs(elapsed);
// Auto-stop proceeds to processing with what was captured; discarding two
// minutes of dictation because a timer expired would be the worst failure.
if (maxMs != null && elapsed >= maxMs) stop();
}, LEVEL_POLL_MS);
} catch {
// `new MediaRecorder()` and `recorder.start()` both throw on some browsers, and by
// then the stream is live — without this the mic indicator stays lit until unmount.
teardown();
setPhase('idle');
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
} finally {
startingRef.current = false;
}
})();
}, [maxMs, onError, phase, send, stop, teardown]);
const onCancel = useCallback(() => {
cancelledRef.current = true;
// Aborting closes the connection, which aborts the vendor call server-side.
abortRef.current?.abort();
try {
recorderRef.current?.stop();
} catch {
// already stopped
}
teardown();
setPhase('idle');
setElapsedMs(0);
}, [teardown]);
return { phase, elapsedMs, level, maxMs, onStart, onStop: stop, onCancel };
}
/** Drives the level meter from the live stream; failure here must not stop recording. */
function attachLevelMeter(
stream: MediaStream,
contextRef: React.MutableRefObject<AudioContext | null>,
setLevel: (value: number) => void,
) {
try {
const AudioContextCtor =
window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextCtor) return;
const context = new AudioContextCtor();
contextRef.current = context;
const source = context.createMediaStreamSource(stream);
const analyser = context.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
// Sample every frame so a transient is not missed, publish at LEVEL_POLL_MS. The hook
// lives in TreatmentWorkspace, so an unthrottled setLevel is ~7,200 whole-tree renders
// across a two-minute recording.
let peakSinceEmit = 0;
let lastEmit = 0;
const tick = (now: number) => {
if (contextRef.current !== context || context.state === 'closed') return;
analyser.getByteTimeDomainData(data);
let peak = 0;
for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128));
peakSinceEmit = Math.max(peakSinceEmit, peak);
if (now - lastEmit >= LEVEL_POLL_MS) {
lastEmit = now;
setLevel(Math.min(1, peakSinceEmit / 128));
peakSinceEmit = 0;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
} catch {
// A missing or blocked AudioContext costs the meter, not the recording.
}
}

View File

@@ -0,0 +1,64 @@
import type { FdiToothId, ToothSelectionGroup } from '@/types/treatment';
/** Mirrors the backend's ResolvedExtraction — values already resolved, plus what was not. */
export type VoiceUnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
| 'tooth_missing_quadrant'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export interface VoiceUnresolvedItem {
/** The transcript span that could not be resolved, so the clinician sees what was heard. */
spoken: string;
reason: VoiceUnresolvedReason;
/**
* FDI codes still consistent with what was heard, when a choice would settle it — the
* review sheet offers them as chips. Only `tooth_missing_quadrant` carries these.
*/
candidates?: string[];
}
export interface VoiceProsthesisResult {
byTooth: Record<string, string>;
/** False means the case cannot ship — every tooth needs a prosthesis type. */
complete: boolean;
missingTeeth: FdiToothId[];
}
export interface VoiceExtractionResult {
transcript: string;
treatmentType: string | null;
teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[];
comment: string | null;
prosthesis: VoiceProsthesisResult | null;
labId: string | null;
/** When false, the lab row must not tick itself — the name only approximately matched. */
labMatchExact: boolean;
dueDate: string | null;
unresolved: VoiceUnresolvedItem[];
}
export interface VoiceAvailability {
enabled: boolean;
locales: string[];
/** null means uncapped. */
maxRecordingMs: number | null;
}
/** Which review rows the clinician ticked. */
export interface VoiceApplySelection {
treatmentType: boolean;
teeth: boolean;
comment: boolean;
prosthesis: boolean;
lab: boolean;
dueDate: boolean;
}
export type VoicePhase = 'idle' | 'recording' | 'processing';