Compare commits
10 Commits
17d3c5ca25
...
e48eeb18c4
| Author | SHA1 | Date | |
|---|---|---|---|
| e48eeb18c4 | |||
| 63bad336b9 | |||
| 78e756b78f | |||
| 118853ce73 | |||
| d226a2b294 | |||
| 2f92f2745b | |||
| 4bf1bf4389 | |||
| 54e4fa8628 | |||
| 1b22dfa36d | |||
| 215aa1fd87 |
108
CLAUDE.md
Normal file
108
CLAUDE.md
Normal 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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
'',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 clinic’s 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.",
|
||||
|
||||
@@ -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": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user