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 treatment entry
|
||||||
VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE',
|
VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE',
|
||||||
VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG',
|
VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG',
|
||||||
|
VOICE_UNSUPPORTED_FORMAT: 'VOICE_UNSUPPORTED_FORMAT',
|
||||||
VOICE_ASR_FAILED: 'VOICE_ASR_FAILED',
|
VOICE_ASR_FAILED: 'VOICE_ASR_FAILED',
|
||||||
VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED',
|
VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED',
|
||||||
VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED',
|
VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors/error-codes';
|
||||||
|
|
||||||
/** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */
|
/** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */
|
||||||
export const VOICE_AUDIO_FORMATS = [
|
export const VOICE_AUDIO_FORMATS = [
|
||||||
@@ -32,10 +33,14 @@ export class ExtractVoiceDto {
|
|||||||
*/
|
*/
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsBase64()
|
@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;
|
audio: string;
|
||||||
|
|
||||||
@IsIn(VOICE_AUDIO_FORMATS)
|
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
|
||||||
format: VoiceAudioFormat;
|
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' } };
|
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 {
|
function describe(intent: DueIntent): string {
|
||||||
|
const usable = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
|
|
||||||
switch (intent?.kind) {
|
switch (intent?.kind) {
|
||||||
case 'weekday':
|
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':
|
case 'offset':
|
||||||
return `+${intent.amount} ${intent.unit}`;
|
return usable(intent.amount)
|
||||||
|
? `+${intent.amount} ${intent.unit ?? ''}`.trim()
|
||||||
|
: '';
|
||||||
case 'jalali':
|
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':
|
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: {
|
default: {
|
||||||
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
|
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
|
||||||
// established is a string — echo it so the review row names what was heard.
|
// 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. */
|
/** Locale-specific guidance. Only the tooth vocabulary and numbering habits differ. */
|
||||||
const LOCALE_NOTES: Record<string, string> = {
|
const LOCALE_NOTES: Record<string, string> = {
|
||||||
fa: [
|
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.',
|
'"شش بالا راست" = 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(' '),
|
].join(' '),
|
||||||
nl: [
|
nl: [
|
||||||
'The clinician is speaking Dutch and uses FDI notation, which is standard in the',
|
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
|
||||||
'Netherlands. "rechtsboven zes" = upper right six. A bare two-digit number is FDI.',
|
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
en: [
|
en: [
|
||||||
'The clinician is speaking English. IMPORTANT: a bare two-digit number is ambiguous,',
|
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
|
||||||
'because Universal numbering and FDI disagree ("tooth 14" is a different tooth in each).',
|
'all tooth 26. The descriptive form is "upper right six".',
|
||||||
'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.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,19 +39,32 @@ export function buildExtractionPrompt(
|
|||||||
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
|
'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',
|
' 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.',
|
' heard is not in a list, use null.',
|
||||||
'2. Never output an FDI tooth code unless the speaker used FDI notation. Prefer',
|
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
|
||||||
' arch + side + position.',
|
|
||||||
'3. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
|
|
||||||
" flip to the viewer's point of view.",
|
" 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".',
|
' 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.',
|
' 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.',
|
' 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',
|
'TOOTH NUMBERS',
|
||||||
' "arch" and "side" null. Never pick a quadrant that was not said.',
|
'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,
|
localeNote,
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { WEEKDAYS } from './voice.types';
|
|||||||
|
|
||||||
export type WireToothIntent = {
|
export type WireToothIntent = {
|
||||||
spoken: string;
|
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;
|
fdi: string | null;
|
||||||
arch: 'upper' | 'lower' | null;
|
arch: 'upper' | 'lower' | null;
|
||||||
side: 'patient_right' | 'patient_left' | null;
|
side: 'patient_right' | 'patient_left' | null;
|
||||||
@@ -66,7 +66,8 @@ const TOOTH_SCHEMA = {
|
|||||||
fdi: {
|
fdi: {
|
||||||
type: ['string', 'null'],
|
type: ['string', 'null'],
|
||||||
description:
|
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] },
|
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
|
||||||
side: {
|
side: {
|
||||||
@@ -76,7 +77,8 @@ const TOOTH_SCHEMA = {
|
|||||||
},
|
},
|
||||||
position: {
|
position: {
|
||||||
type: ['integer', 'null'],
|
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;
|
} as const;
|
||||||
|
|||||||
@@ -173,10 +173,47 @@ describe('resolveToothIntents', () => {
|
|||||||
const result = resolveToothIntents([bare]);
|
const result = resolveToothIntents([bare]);
|
||||||
expect(result.teeth).toEqual([]);
|
expect(result.teeth).toEqual([]);
|
||||||
expect(result.unresolved).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', () => {
|
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.
|
// "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess.
|
||||||
for (const half of [
|
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';
|
import type { ToothIntent, UnresolvedItem } from './voice.types';
|
||||||
|
|
||||||
|
const ARCHES: readonly Arch[] = ['upper', 'lower'];
|
||||||
|
const SIDES: readonly PatientSide[] = ['patient_right', 'patient_left'];
|
||||||
|
|
||||||
export type ToothResolution = {
|
export type ToothResolution = {
|
||||||
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
|
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
@@ -68,6 +77,32 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
|
|||||||
return 'malformed';
|
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 {
|
function spokenOf(intent: ToothIntent): string {
|
||||||
const spoken = (intent as { spoken?: unknown })?.spoken;
|
const spoken = (intent as { spoken?: unknown })?.spoken;
|
||||||
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
|
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
|
||||||
@@ -101,14 +136,22 @@ export function resolveToothIntents(
|
|||||||
}
|
}
|
||||||
const reason = unresolvedReason(intent);
|
const reason = unresolvedReason(intent);
|
||||||
const spoken = spokenOf(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
|
// 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) {
|
if (spoken) {
|
||||||
const key = `${spoken}::${reason}`;
|
const key = `${spoken}::${reason}::${candidates.join(',')}`;
|
||||||
if (seenUnresolved.has(key)) continue;
|
if (seenUnresolved.has(key)) continue;
|
||||||
seenUnresolved.add(key);
|
seenUnresolved.add(key);
|
||||||
}
|
}
|
||||||
unresolved.push({ spoken, reason });
|
unresolved.push(
|
||||||
|
candidates.length > 0
|
||||||
|
? { spoken, reason, candidates }
|
||||||
|
: { spoken, reason },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { teeth: [...teeth].sort(), unresolved };
|
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. */
|
/** The transcript span that could not be resolved, so the user can see what was heard. */
|
||||||
spoken: string;
|
spoken: string;
|
||||||
reason: UnresolvedReason;
|
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
|
# Voice treatment entry
|
||||||
|
|
||||||
**Status:** Implemented on `feat/voice-treatment-entry` — unreviewed, and blocked on the
|
**Status:** Implemented on `feat/voice-treatment-entry`, with one specified piece missing —
|
||||||
ASR spike (§11 item 1) before it is trustworthy in front of patients
|
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)
|
**Area:** Treatment workspace (CLINIC orgs)
|
||||||
**Created:** 2026-08-20
|
**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
|
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 (`شش بالا راست`,
|
*prompt*: each enabled locale needs its own spoken tooth vocabulary (`شش بالا راست`,
|
||||||
`upper right six`, `rechtsboven zes`).
|
`upper right six`, `rechtsboven zes`).
|
||||||
- **English carries a numbering hazard the other locales do not.** A clinician trained
|
- **A spoken tooth number is an FDI code, in every locale.** This is how clinicians
|
||||||
under Universal numbering says "tooth number 14" and means a different tooth than FDI
|
actually dictate — "بیست و شش" is tooth 26 — so the prompt *teaches* the notation
|
||||||
14. `nl` is safe — the Netherlands uses FDI — but `en` is not. The `en` prompt must
|
(first digit = quadrant from the patient's own point of view, second = position from
|
||||||
therefore not accept a bare two-digit number as `explicitFdi` without the speaker
|
the midline) rather than refusing it. `arch`/`side`/`position` is the reading of a
|
||||||
having made the notation explicit; ambiguous English numerals resolve to
|
tooth that was **described** instead of numbered, where a single digit is a position
|
||||||
**unresolved**. See §11.
|
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()`
|
### `resolveDueDate()`
|
||||||
|
|
||||||
@@ -472,6 +482,10 @@ that justified this whole design.
|
|||||||
- any row carrying an unresolved item or an incomplete prosthesis map.
|
- any row carrying an unresolved item or an incomplete prosthesis map.
|
||||||
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
|
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
|
||||||
clinician can see what the system did not understand.
|
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`.
|
- RTL-safe: logical `text-start` / `text-end` only, never `text-left`/`text-right`.
|
||||||
Dates via `lib/i18n/format.ts`.
|
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_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced |
|
||||||
| `VOICE_RATE_LIMITED` | throttle |
|
| `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
|
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action
|
||||||
**creates a new detail with only `comment` set to the transcript** — everything else left
|
**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
|
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
|
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
|
remember `whisper-1` is forwarded to OpenAI, so the effective policy is OpenRouter's
|
||||||
plus that provider's.
|
plus that provider's.
|
||||||
5. **English tooth numbering is unresolved as a product question.** Enabling `en` means
|
5. ~~**English tooth numbering**~~ — **resolved:** a bare two-digit number is read as
|
||||||
deciding what "tooth number 14" means when the speaker's notation is unknown —
|
**FDI in all three locales**. FDI is what the product is built on and what clinicians
|
||||||
Universal or FDI. The spec's current answer is to refuse ambiguous bare numerals in
|
dictate. Known trade-off, accepted: a clinician trained under Universal numbering says
|
||||||
`en`, which is safe but will feel broken to a US-trained clinician. Options are: refuse
|
"tooth 14" and means a different tooth, so an `en` clinic needs either training or a
|
||||||
(current), an org-level notation preference, or restricting `en` to quadrant-relative
|
later per-org notation setting. Revisit if a US clinic is onboarded.
|
||||||
phrasing. Decide before `en` ships to a real clinic; `fa` and `nl` are unaffected.
|
|
||||||
6. **`nl` and `en` have no spike data.** The Persian spike (item 1) should be repeated per
|
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
|
locale before that locale's mic is enabled for real users — same protocol, same
|
||||||
scoring, different speaker.
|
scoring, different speaker.
|
||||||
@@ -688,7 +708,7 @@ Settled in a grilling session on 2026-08-20.
|
|||||||
| 1 | Scope | Everything including lab dispatch |
|
| 1 | Scope | Everything including lab dispatch |
|
||||||
| 2 | AI supply chain | Domestic provider originally; OpenRouter for v1, registry keeps both open |
|
| 2 | AI supply chain | Domestic provider originally; OpenRouter for v1, registry keeps both open |
|
||||||
| 3 | Apply model | Review sheet, then apply |
|
| 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 |
|
| 5 | Cardinality | One detail per recording |
|
||||||
| 6 | Lab destination | Closed list of linked labs, explicit confirm, unticked when inexact |
|
| 6 | Lab destination | Closed list of linked labs, explicit confirm, unticked when inexact |
|
||||||
| 7 | Due date | Intent + deterministic resolver |
|
| 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 |
|
| 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 |
|
| 26 | Throttle | Configurable; v1 default 6 requests / 60s per user |
|
||||||
| 27 | Duration cap | **2 minutes**, configurable via `maxMs` |
|
| 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 |
|
| 29 | Cancel | Aborts the in-flight vendor call |
|
||||||
| 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped |
|
| 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped |
|
||||||
| 15 | Gating | `Plan.features` flag — its first consumer |
|
| 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.",
|
"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.",
|
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
|
||||||
"voiceNotUnderstood": "Not understood",
|
"voiceNotUnderstood": "Not understood",
|
||||||
|
"voicePickTooth": "Which tooth?",
|
||||||
"voiceDiscard": "Discard",
|
"voiceDiscard": "Discard",
|
||||||
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
|
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
|
||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "not a permanent tooth",
|
"not_permanent_tooth": "not a permanent tooth",
|
||||||
"position_out_of_range": "not a valid tooth position",
|
"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",
|
"malformed": "could not be read",
|
||||||
"span_not_same_arch": "a bridge cannot span both jaws",
|
"span_not_same_arch": "a bridge cannot span both jaws",
|
||||||
"unknown_catalog_code": "not in this clinic’s list",
|
"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_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_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_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_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_EXTRACT_FAILED": "Could not read the treatment details from the recording.",
|
||||||
"VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",
|
"VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",
|
||||||
|
|||||||
@@ -905,12 +905,13 @@
|
|||||||
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
||||||
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
||||||
"voiceNotUnderstood": "شناسایی نشد",
|
"voiceNotUnderstood": "شناسایی نشد",
|
||||||
|
"voicePickTooth": "کدام دندان؟",
|
||||||
"voiceDiscard": "انصراف",
|
"voiceDiscard": "انصراف",
|
||||||
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "دندان دائمی نیست",
|
"not_permanent_tooth": "دندان دائمی نیست",
|
||||||
"position_out_of_range": "شماره دندان معتبر نیست",
|
"position_out_of_range": "شماره دندان معتبر نیست",
|
||||||
"tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»",
|
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
|
||||||
"malformed": "قابل خواندن نبود",
|
"malformed": "قابل خواندن نبود",
|
||||||
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
||||||
"unknown_catalog_code": "در فهرست این مطب نیست",
|
"unknown_catalog_code": "در فهرست این مطب نیست",
|
||||||
@@ -1262,6 +1263,7 @@
|
|||||||
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
|
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
|
||||||
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
|
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
|
||||||
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
|
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
|
||||||
|
"VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمیشود.",
|
||||||
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
|
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
|
||||||
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
|
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
|
||||||
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
|
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",
|
||||||
|
|||||||
@@ -904,12 +904,13 @@
|
|||||||
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
|
"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.",
|
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
|
||||||
"voiceNotUnderstood": "Niet begrepen",
|
"voiceNotUnderstood": "Niet begrepen",
|
||||||
|
"voicePickTooth": "Welk element?",
|
||||||
"voiceDiscard": "Verwerpen",
|
"voiceDiscard": "Verwerpen",
|
||||||
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
|
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
|
||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "geen blijvend element",
|
"not_permanent_tooth": "geen blijvend element",
|
||||||
"position_out_of_range": "geen geldige elementpositie",
|
"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",
|
"malformed": "kon niet worden gelezen",
|
||||||
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
||||||
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
|
"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_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_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_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_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_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.",
|
||||||
"VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",
|
"VOICE_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 { 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. */
|
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
|
||||||
export function voiceRowAvailability(result: VoiceExtractionResult) {
|
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 {
|
* How many rows will actually be applied — drives the confirm button's label.
|
||||||
return Object.values(selection).filter(Boolean).length;
|
*
|
||||||
|
* 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. */
|
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
|
||||||
|
|||||||
@@ -496,82 +496,6 @@ export function TreatmentWorkspace({
|
|||||||
[appointments, selectedAppointmentId],
|
[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({
|
const voice = useVoiceCapture({
|
||||||
// The locale the clinician is actually reading and speaking in. Sent explicitly so
|
// 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(
|
const handleRemoveDetail = useCallback(
|
||||||
(detailClientId: string) => {
|
(detailClientId: string) => {
|
||||||
if (!canEditTreatmentForDay) return;
|
if (!canEditTreatmentForDay) return;
|
||||||
@@ -2759,7 +2787,7 @@ export function TreatmentWorkspace({
|
|||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
prosthesisCatalog={prosthesisCatalog}
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
labs={orgs}
|
labs={orgs}
|
||||||
onApply={(selection) => applyVoiceResult(voiceResult, selection)}
|
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
|
||||||
onDiscard={() => setVoiceResult(null)}
|
onDiscard={() => setVoiceResult(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import {
|
|||||||
hasAnythingToApply,
|
hasAnythingToApply,
|
||||||
initialVoiceSelection,
|
initialVoiceSelection,
|
||||||
voiceRowAvailability,
|
voiceRowAvailability,
|
||||||
|
withChosenTeeth,
|
||||||
} from '@/components/treatment/voiceReviewRows';
|
} from '@/components/treatment/voiceReviewRows';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
|
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { ProsthesisCatalogEntry } 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';
|
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
||||||
|
|
||||||
interface VoiceReviewSheetProps {
|
interface VoiceReviewSheetProps {
|
||||||
@@ -29,7 +30,8 @@ interface VoiceReviewSheetProps {
|
|||||||
treatmentCatalog: TreatmentCatalogEntry[];
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||||
labs: LinkedOrganizationOption[];
|
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;
|
onDiscard: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +56,37 @@ export function VoiceReviewSheet({
|
|||||||
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
||||||
initialVoiceSelection(result),
|
initialVoiceSelection(result),
|
||||||
);
|
);
|
||||||
|
const [chosen, setChosen] = useState<FdiToothId[]>([]);
|
||||||
|
|
||||||
const available = useMemo(() => voiceRowAvailability(result), [result]);
|
// Everything below renders from `effective`, never from `result` — a tooth picked from
|
||||||
const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]);
|
// the candidate chips has to reach the rows, the chart and the apply count alike.
|
||||||
const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]);
|
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
|
||||||
const nothingToApply = !hasAnythingToApply(result);
|
|
||||||
const selectedCount = countSelected(selection);
|
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 }[]) =>
|
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
|
||||||
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
||||||
@@ -80,7 +107,7 @@ export function VoiceReviewSheet({
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
|
<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>
|
</p>
|
||||||
|
|
||||||
{nothingToApply ? (
|
{nothingToApply ? (
|
||||||
@@ -94,7 +121,7 @@ export function VoiceReviewSheet({
|
|||||||
onChange={toggle('treatmentType')}
|
onChange={toggle('treatmentType')}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<span className="text-sm text-text-primary">
|
||||||
{labelFor(result.treatmentType, treatmentCatalog)}
|
{labelFor(effective.treatmentType, treatmentCatalog)}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -120,26 +147,26 @@ export function VoiceReviewSheet({
|
|||||||
onChange={toggle('comment')}
|
onChange={toggle('comment')}
|
||||||
>
|
>
|
||||||
<span className="text-sm whitespace-pre-wrap text-text-primary">
|
<span className="text-sm whitespace-pre-wrap text-text-primary">
|
||||||
{result.comment}
|
{effective.comment}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{available.prosthesis && result.prosthesis ? (
|
{available.prosthesis && effective.prosthesis ? (
|
||||||
<Row
|
<Row
|
||||||
label={t('prosthesisColType')}
|
label={t('prosthesisColType')}
|
||||||
checked={selection.prosthesis}
|
checked={selection.prosthesis}
|
||||||
onChange={toggle('prosthesis')}
|
onChange={toggle('prosthesis')}
|
||||||
warning={
|
warning={
|
||||||
result.prosthesis.complete
|
effective.prosthesis.complete
|
||||||
? undefined
|
? undefined
|
||||||
: t('voiceProsthesisIncomplete', {
|
: t('voiceProsthesisIncomplete', {
|
||||||
teeth: formatToothList(result.prosthesis.missingTeeth, locale),
|
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<span className="text-sm text-text-primary">
|
||||||
{Object.entries(result.prosthesis.byTooth)
|
{Object.entries(effective.prosthesis.byTooth)
|
||||||
.map(
|
.map(
|
||||||
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
||||||
)
|
)
|
||||||
@@ -153,38 +180,62 @@ export function VoiceReviewSheet({
|
|||||||
label={t('entryStepLab')}
|
label={t('entryStepLab')}
|
||||||
checked={selection.lab}
|
checked={selection.lab}
|
||||||
onChange={toggle('lab')}
|
onChange={toggle('lab')}
|
||||||
warning={result.labMatchExact ? undefined : t('voiceLabInexact')}
|
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<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>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{available.dueDate && result.dueDate ? (
|
{available.dueDate && effective.dueDate ? (
|
||||||
<Row
|
<Row
|
||||||
label={t('dueDateLabel')}
|
label={t('dueDateLabel')}
|
||||||
checked={selection.dueDate}
|
checked={selection.dueDate}
|
||||||
onChange={toggle('dueDate')}
|
onChange={toggle('dueDate')}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<span className="text-sm text-text-primary">
|
||||||
{formatDate(civilDateToLocalDate(result.dueDate))}
|
{formatDate(civilDateToLocalDate(effective.dueDate))}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<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">
|
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||||
{t('voiceNotUnderstood')}
|
{t('voiceNotUnderstood')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1 space-y-0.5">
|
<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">
|
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
|
||||||
{item.spoken ? `“${item.spoken}” — ` : ''}
|
{item.spoken ? `“${item.spoken}” — ` : ''}
|
||||||
{t(`voiceUnresolved.${item.reason}`)}
|
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -199,7 +250,7 @@ export function VoiceReviewSheet({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={selectedCount === 0}
|
disabled={selectedCount === 0}
|
||||||
onClick={() => onApply(selection)}
|
onClick={() => onApply(selection, effective)}
|
||||||
fullWidth
|
fullWidth
|
||||||
className="sm:w-auto"
|
className="sm:w-auto"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ export function useVoiceCapture({
|
|||||||
// minutes of dictation because a timer expired would be the worst failure.
|
// minutes of dictation because a timer expired would be the worst failure.
|
||||||
if (maxMs != null && elapsed >= maxMs) stop();
|
if (maxMs != null && elapsed >= maxMs) stop();
|
||||||
}, LEVEL_POLL_MS);
|
}, 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 {
|
} finally {
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
}
|
}
|
||||||
@@ -262,12 +270,23 @@ function attachLevelMeter(
|
|||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
|
|
||||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
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;
|
if (contextRef.current !== context || context.state === 'closed') return;
|
||||||
analyser.getByteTimeDomainData(data);
|
analyser.getByteTimeDomainData(data);
|
||||||
let peak = 0;
|
let peak = 0;
|
||||||
for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128));
|
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);
|
||||||
};
|
};
|
||||||
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. */
|
/** The transcript span that could not be resolved, so the clinician sees what was heard. */
|
||||||
spoken: string;
|
spoken: string;
|
||||||
reason: VoiceUnresolvedReason;
|
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 {
|
export interface VoiceProsthesisResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user