Compare commits

...

10 Commits

Author SHA1 Message Date
e48eeb18c4 docs: add CLAUDE.md for project guidance and conventions 2026-08-21 00:52:00 +03:30
63bad336b9 docs: mark transcript salvage as specified but not built
The backend returns the transcript on VOICE_EXTRACT_FAILED and the client
never reads it, so dictation the clinic paid for is shipped in an error
body and dropped. The spec claimed the whole feature was implemented; it
now names the gap and the two ways out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 05:05:04 +08:00
78e756b78f fix(backend): stop showing the clinician null, NaN and the wrong failure
Two ways a voice failure described itself wrongly.

describe() built the quoted-back text from fields that are all nullable on
the wire, and toVoiceIntent casts rather than checks — so a half-classified
deadline rendered as “null null” — not a usable date, and an offset with no
amount as “+NaN day”. Blank is already handled by the sheet; it now falls
back to that.

The DTO's constraints resolved to unrelated codes: maxLength fell through
to VALIDATION_FIELD_REQUIRED, so an oversized recording said a field was
missing, and isIn maps to VALIDATION_LANGUAGE_INVALID, so an unsupported
container said the language was invalid. Both now name their own code —
the validation factory already returns a message verbatim when it is itself
a known ErrorCode, so this needs no change to the shared mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 05:04:30 +08:00
118853ce73 fix(frontend): stop the level meter re-rendering the whole workspace
The meter wrote React state from a requestAnimationFrame loop, and the hook
lives in TreatmentWorkspace — so every frame re-rendered the details editor,
the FDI chart, the lab panel and the history rail. About 7,200 whole-tree
renders across a two-minute recording, while the user is dictating.

Now samples every frame but publishes at LEVEL_POLL_MS, the rate the
elapsed timer already used. Peaks between publishes are carried forward, so
the meter stays responsive to transients rather than sampling at 10 Hz.

Also adds the catch the start path never had: new MediaRecorder() and
recorder.start() both throw on some browsers, and by then the stream is
live. The rejection went unhandled, the UI sat at 'idle' showing nothing,
and the browser's recording indicator stayed lit until unmount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 05:04:30 +08:00
d226a2b294 fix(frontend): persist the lab case a voice result creates
applyVoiceResult put the lab draft in state and stopped there. Every other
path that creates a LabCaseDraft — handleContinueToLab, handleLabCasesChange
— immediately runs persistDraft + persistLabCases, and the autosave effect
only watches `details`. So applying a voice result carrying a lab, a due
date and a prosthesis map, then reloading, kept the detail and silently
dropped all three: the surviving detail made it look like the save worked.

applyVoiceResult moves below persistDraft/persistLabCases so it can call
them, and writes detailsRef itself before persisting — persistDraft reads
that ref, and setDetails has not rendered by the time the save runs. The
ref is already written imperatively elsewhere for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 05:04:15 +08:00
2f92f2745b fix(frontend): untick prosthesis when a picked tooth breaks its map
initialVoiceSelection deliberately never auto-ticks an incomplete
prosthesis map, because a detail with an untyped tooth cannot ship — it
fails at dispatch instead. Picking a candidate tooth walked straight
through that rule: the tick was seeded once, so a map that was complete at
extraction stayed ticked after a tooth with no prosthesis type joined it,
and Apply attached a map assertCompleteToothProsthesisMap rejects.

Recomputed on each pick, and only ever downwards — re-ticking is the
clinician's call, not a side effect of un-picking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 05:04:15 +08:00
4bf1bf4389 docs: record the FDI-first tooth rule in the voice spec
The spec's tooth section described the design that the first live test
disproved — descriptive phrasing primary, bare numerals refused in en.
Rewrites §6 around the rule the product actually wants, records the chip
affordance in §7, and closes open item §11.5: a two-digit number is FDI in
all three locales, with the Universal-numbering trade-off written down
rather than left implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 04:54:31 +08:00
54e4fa8628 feat(frontend): let the clinician pick the tooth from the candidates
An under-specified tooth was a dead end: the sheet said what was missing
and the clinician had to leave and hunt for it on the chart. The readings
are enumerable, so the review sheet now renders them as chips — the one
interactive part of an otherwise read-only confirmation step.

A pick is folded into the result by withChosenTeeth() rather than tracked
alongside it, so the rows, the mini chart, the prosthesis warning and
applyVoiceResult all keep reading a single VoiceExtractionResult and none
of them has to know the chips exist. It unions rather than toggles: a
candidate can coincidentally be a tooth the recording already produced, and
tapping it must not deselect that one.

Two things that would otherwise make the chips look functional while
applying nothing: the teeth row is ticked on the first pick (it starts
unticked when the recording produced no teeth of its own), and the apply
count is now intersected with row availability so it cannot promise to
apply a row with nothing in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 04:54:31 +08:00
1b22dfa36d feat(backend): offer the candidate teeth for an unspecified quadrant
A tooth the resolver refuses to guess at is currently a dead end: the sheet
says the quadrant was missing and the clinician has to leave and find the
tooth on the chart. But the readings are enumerable — "دو" is one of four
teeth, "دو بالا" one of two — so unresolved items now carry them.

Narrowed by whatever was actually said, so this stays a choice offered to
the clinician rather than a guess made for them. Only tooth_missing_quadrant
carries candidates; a wrong position or a deciduous tooth has nothing to
choose between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 04:44:20 +08:00
215aa1fd87 feat(backend): read a spoken tooth number as its FDI code
The prompt had this backwards. "Never output an FDI tooth code unless the
speaker used FDI notation. Prefer arch + side + position" pushed the model
to decompose speech into "upper / patient_right / six", so the clinician
effectively had to *describe* every tooth. Saying "دندون بیست و شش" — the
way a dentist actually dictates — was the unsupported path.

FDI is what clinicians speak, so the prompt now teaches the notation
instead of forbidding it: first digit = quadrant from the patient's own
point of view, second digit = position from the midline. arch/side/position
stays as the reading of a *described* tooth, where a single digit is a
position and the quadrant comes from words.

Two guards come with it, because bare numbers are now teeth: a single digit
alone still refuses to guess a quadrant, and dates, counts and quantities
are explicitly not teeth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 04:43:19 +08:00
19 changed files with 610 additions and 157 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

@@ -187,6 +187,7 @@ export const ErrorCode = {
// 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',

View File

@@ -6,6 +6,7 @@ import {
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 = [
@@ -32,10 +33,14 @@ export class ExtractVoiceDto {
*/
@IsString()
@IsBase64()
@MaxLength(8_000_000)
// Both constraints name their own code. Left to the default mapping, `maxLength` falls
// through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to
// VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field
// was missing, and an unsupported container told them their language was invalid.
@MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG })
audio: string;
@IsIn(VOICE_AUDIO_FORMATS)
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
format: VoiceAudioFormat;
/**

View File

@@ -346,3 +346,40 @@ describe('resolveDueDate', () => {
});
});
});
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

@@ -77,16 +77,36 @@ 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 here is nullable on the wire and `toVoiceIntent` casts rather than checks,
* so a half-classified deadline arrives with nulls in it. The review sheet renders this
* verbatim — `"null null" — not a usable date` in front of a clinician is worse than the
* reason on its own, which the sheet already handles for a blank string.
*/
function describe(intent: DueIntent): string {
const usable = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
switch (intent?.kind) {
case 'weekday':
return `${intent.which} ${intent.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 `+${intent.amount} ${intent.unit}`;
return usable(intent.amount)
? `+${intent.amount} ${intent.unit ?? ''}`.trim()
: '';
case 'jalali':
return `${intent.jy}/${intent.jm}/${intent.jd}`;
return [intent.jy, intent.jm, intent.jd].every(usable)
? `${intent.jy}/${intent.jm}/${intent.jd}`
: '';
case 'gregorian':
return `${intent.y}-${intent.m}-${intent.d}`;
return [intent.y, intent.m, intent.d].every(usable)
? `${intent.y}-${intent.m}-${intent.d}`
: '';
default: {
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
// established is a string — echo it so the review row names what was heard.

View File

@@ -3,21 +3,19 @@ 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. Tooth references are usually quadrant-relative:',
'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.',
'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does',
'occur — use the "fdi" field only for that.',
'A bare "دندون دو" carries no quadrant: report position 2 with arch and side null.',
].join(' '),
nl: [
'The clinician is speaking Dutch and uses FDI notation, which is standard in the',
'Netherlands. "rechtsboven zes" = upper right six. A bare two-digit number is FDI.',
'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. IMPORTANT: a bare two-digit number is ambiguous,',
'because Universal numbering and FDI disagree ("tooth 14" is a different tooth in each).',
'Set "fdi" ONLY when the speaker made the notation explicit (e.g. "FDI one four").',
'Otherwise describe the tooth with arch/side/position, or leave it unresolved.',
'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(' '),
};
@@ -41,19 +39,32 @@ export function buildExtractionPrompt(
'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. Never output an FDI tooth code unless the speaker used FDI notation. Prefer',
' arch + side + position.',
'3. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
'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.",
'4. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
' If no deadline was mentioned, use due.kind = "none".',
'5. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
' what was heard.',
'6. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.',
'7. A tooth number spoken WITHOUT a quadrant ("دندون دو", "tooth two") does not identify',
' a tooth — four teeth carry that position. Still report it: set "position" and leave',
' "arch" and "side" null. Never pick a quadrant that was not said.',
'',
'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,
'',

View File

@@ -21,7 +21,7 @@ import { WEEKDAYS } from './voice.types';
export type WireToothIntent = {
spoken: string;
/** Two-digit FDI code, only when the speaker genuinely used FDI notation. */
/** 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;
@@ -66,7 +66,8 @@ const TOOTH_SCHEMA = {
fdi: {
type: ['string', 'null'],
description:
'Two-digit FDI code ONLY if the speaker used FDI notation. Otherwise null.',
'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: {
@@ -76,7 +77,8 @@ const TOOTH_SCHEMA = {
},
position: {
type: ['integer', 'null'],
description: '1 = central incisor … 8 = third molar.',
description:
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
},
},
} as const;

View File

@@ -173,10 +173,47 @@ describe('resolveToothIntents', () => {
const result = resolveToothIntents([bare]);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([
{ spoken: 'دندون دو', reason: 'tooth_missing_quadrant' },
{
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 [

View File

@@ -1,6 +1,15 @@
import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi';
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[];
@@ -68,6 +77,32 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
return 'malformed';
}
/**
* The teeth still consistent with what *was* heard.
*
* Narrowed by whatever the clinician did say, so "دو" offers four and "دو بالا" offers
* two. This is not a guess — it is the full set of readings, handed to the clinician to
* choose from rather than picked on their behalf.
*/
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() : '';
@@ -101,14 +136,22 @@ export function resolveToothIntents(
}
const reason = unresolvedReason(intent);
const spoken = spokenOf(intent);
const candidates =
reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : [];
// Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost
// references would collapse into one blank review row and a tooth would vanish.
// references would collapse into one blank review row and a tooth would vanish. The
// candidates are part of the identity: the same word with a different arch heard
// offers a different choice.
if (spoken) {
const key = `${spoken}::${reason}`;
const key = `${spoken}::${reason}::${candidates.join(',')}`;
if (seenUnresolved.has(key)) continue;
seenUnresolved.add(key);
}
unresolved.push({ spoken, reason });
unresolved.push(
candidates.length > 0
? { spoken, reason, candidates }
: { spoken, reason },
);
}
return { teeth: [...teeth].sort(), unresolved };

View File

@@ -77,4 +77,11 @@ 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, when a choice would settle it.
* Only `tooth_missing_quadrant` carries these: "دو" leaves four teeth on the table,
* "دو بالا" leaves two. The review sheet offers them so an under-specified tooth is one
* tap from resolved rather than a dead end.
*/
candidates?: string[];
};

View File

@@ -1,7 +1,9 @@
# Voice treatment entry
**Status:** Implemented on `feat/voice-treatment-entry` — unreviewed, and blocked on the
ASR spike (§11 item 1) before it is trustworthy in front of patients
**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
@@ -415,12 +417,20 @@ that justified this whole design.
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`).
- **English carries a numbering hazard the other locales do not.** A clinician trained
under Universal numbering says "tooth number 14" and means a different tooth than FDI
14. `nl` is safe — the Netherlands uses FDI — but `en` is not. The `en` prompt must
therefore not accept a bare two-digit number as `explicitFdi` without the speaker
having made the notation explicit; ambiguous English numerals resolve to
**unresolved**. See §11.
- **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()`
@@ -472,6 +482,10 @@ that justified this whole design.
- 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.
- 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`.
@@ -524,7 +538,14 @@ English Nest exception for a user-facing failure.
| `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced |
| `VOICE_RATE_LIMITED` | throttle |
**Transcript salvage:** when ASR succeeded and only extraction failed, the response still
**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
@@ -607,12 +628,11 @@ enabling this for real clinics.
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 is unresolved as a product question.** Enabling `en` means
deciding what "tooth number 14" means when the speaker's notation is unknown —
Universal or FDI. The spec's current answer is to refuse ambiguous bare numerals in
`en`, which is safe but will feel broken to a US-trained clinician. Options are: refuse
(current), an org-level notation preference, or restricting `en` to quadrant-relative
phrasing. Decide before `en` ships to a real clinic; `fa` and `nl` are unaffected.
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.
@@ -688,7 +708,7 @@ Settled in a grilling session on 2026-08-20.
| 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 |
| 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 |
@@ -704,7 +724,7 @@ Settled in a grilling session on 2026-08-20.
| 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 |
| 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 |
| 15 | Gating | `Plan.features` flag — its first consumer |

View File

@@ -904,12 +904,13 @@
"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": "quadrant not said — e.g. “upper right two”",
"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",
@@ -1261,6 +1262,7 @@
"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.",

View File

@@ -905,12 +905,13 @@
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.",
"voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
"voiceNotUnderstood": "شناسایی نشد",
"voicePickTooth": "کدام دندان؟",
"voiceDiscard": "انصراف",
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
"voiceUnresolved": {
"not_permanent_tooth": "دندان دائمی نیست",
"position_out_of_range": "شماره دندان معتبر نیست",
"tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»",
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
"malformed": "قابل خواندن نبود",
"span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد",
"unknown_catalog_code": "در فهرست این مطب نیست",
@@ -1262,6 +1263,7 @@
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
"VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمی‌شود.",
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",

View File

@@ -904,12 +904,13 @@
"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": "kwadrant niet genoemd — bijv. “rechtsboven twee”",
"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",
@@ -1261,6 +1262,7 @@
"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.",

View File

@@ -1,5 +1,10 @@
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import type { FdiToothId } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
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) {
@@ -35,9 +40,57 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply
};
}
/** How many rows will actually be applied — drives the confirm button's label. */
export function countSelected(selection: VoiceApplySelection): number {
return Object.values(selection).filter(Boolean).length;
/**
* How many rows will actually be applied — drives the confirm button's label.
*
* Intersected with availability rather than counting ticks: a row can be ticked and then
* lose its content (the last candidate tooth un-picked), 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 clinician's candidate picks into the extracted result.
*
* Everything downstream reads a `VoiceExtractionResult` — row availability, the mini
* chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here
* means none of them has to know the chips exist.
*
* Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the
* recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth;
* and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a
* single group, so no tooth can be lost on the way through.
*/
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. */

View File

@@ -496,82 +496,6 @@ export function TreatmentWorkspace({
[appointments, selectedAppointmentId],
);
/**
* 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;
}
setDetails((prev) => [...prev, detail]);
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),
}));
}
setLabCaseDrafts((prev) => [...prev, draft]);
}
setVoiceResult(null);
},
[selectedAppointment?.purpose, treatmentCatalog],
);
const voice = useVoiceCapture({
// The locale the clinician is actually reading and speaking in. Sent explicitly so
@@ -1986,6 +1910,110 @@ 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. The codebase
// already writes this ref imperatively after a save for the same reason.
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);
// Every other path that creates a lab draft persists it immediately, and the
// autosave effect only watches `details`. Left in state alone, the destination
// lab, the due date and the whole prosthesis map vanish on the next reload —
// silently, because the detail itself does survive.
void (async () => {
try {
const saved = await persistDraft({ force: true });
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;
@@ -2759,7 +2787,7 @@ export function TreatmentWorkspace({
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labs={orgs}
onApply={(selection) => applyVoiceResult(voiceResult, selection)}
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onDiscard={() => setVoiceResult(null)}
/>
) : null}

View File

@@ -16,12 +16,13 @@ import {
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 { LinkedOrganizationOption } from '@/types/treatment';
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
interface VoiceReviewSheetProps {
@@ -29,7 +30,8 @@ interface VoiceReviewSheetProps {
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[];
labs: LinkedOrganizationOption[];
onApply: (selection: VoiceApplySelection) => void;
/** The result is handed back because the sheet may have added teeth the model missed. */
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onDiscard: () => void;
}
@@ -54,12 +56,37 @@ export function VoiceReviewSheet({
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
initialVoiceSelection(result),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
const available = useMemo(() => voiceRowAvailability(result), [result]);
const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]);
const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]);
const nothingToApply = !hasAnythingToApply(result);
const selectedCount = countSelected(selection);
// 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,
// The picked tooth has no prosthesis type, which makes the map unshippable. Leaving
// the row ticked would apply a map that `assertCompleteToothProsthesisMap` rejects
// at dispatch — the exact failure the never-auto-tick-incomplete rule exists to
// prevent. 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 ?? '';
@@ -80,7 +107,7 @@ export function VoiceReviewSheet({
</h2>
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
{result.transcript}
{effective.transcript}
</p>
{nothingToApply ? (
@@ -94,7 +121,7 @@ export function VoiceReviewSheet({
onChange={toggle('treatmentType')}
>
<span className="text-sm text-text-primary">
{labelFor(result.treatmentType, treatmentCatalog)}
{labelFor(effective.treatmentType, treatmentCatalog)}
</span>
</Row>
) : null}
@@ -120,26 +147,26 @@ export function VoiceReviewSheet({
onChange={toggle('comment')}
>
<span className="text-sm whitespace-pre-wrap text-text-primary">
{result.comment}
{effective.comment}
</span>
</Row>
) : null}
{available.prosthesis && result.prosthesis ? (
{available.prosthesis && effective.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
result.prosthesis.complete
effective.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(result.prosthesis.missingTeeth, locale),
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{Object.entries(result.prosthesis.byTooth)
{Object.entries(effective.prosthesis.byTooth)
.map(
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
)
@@ -153,38 +180,62 @@ export function VoiceReviewSheet({
label={t('entryStepLab')}
checked={selection.lab}
onChange={toggle('lab')}
warning={result.labMatchExact ? undefined : t('voiceLabInexact')}
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
>
<span className="text-sm text-text-primary">
{labs.find((lab) => lab.id === result.labId)?.name ?? result.labId}
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
</span>
</Row>
) : null}
{available.dueDate && result.dueDate ? (
{available.dueDate && effective.dueDate ? (
<Row
label={t('dueDateLabel')}
checked={selection.dueDate}
onChange={toggle('dueDate')}
>
<span className="text-sm text-text-primary">
{formatDate(civilDateToLocalDate(result.dueDate))}
{formatDate(civilDateToLocalDate(effective.dueDate))}
</span>
</Row>
) : null}
</div>
)}
{result.unresolved.length > 0 ? (
{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">
{result.unresolved.map((item, index) => (
{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>
@@ -199,7 +250,7 @@ export function VoiceReviewSheet({
type="button"
variant="primary"
disabled={selectedCount === 0}
onClick={() => onApply(selection)}
onClick={() => onApply(selection, effective)}
fullWidth
className="sm:w-auto"
>

View File

@@ -219,6 +219,14 @@ export function useVoiceCapture({
// 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 already live. Without this the promise rejects
// unhandled, the UI sits at 'idle' with nothing shown, and the browser's
// recording indicator stays lit until the workspace unmounts.
teardown();
setPhase('idle');
onError(clientError('VOICE_MIC_DENIED'));
} finally {
startingRef.current = false;
}
@@ -262,12 +270,23 @@ function attachLevelMeter(
source.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
// Sample every frame so a transient is not missed, but publish at LEVEL_POLL_MS.
// This hook lives in TreatmentWorkspace, so an unthrottled setLevel re-renders the
// details editor, the FDI chart and the lab panel on every animation frame — about
// 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));
setLevel(Math.min(1, peak / 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);

View File

@@ -16,6 +16,11 @@ 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 {