Compare commits

..

1 Commits

Author SHA1 Message Date
19b5671b0d improvement: Frontend Dockerfile updated so that aseests in public folder loads on the website.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 4m51s
Production — tag build, push, deploy / deploy (push) Successful in 2m47s
2026-09-06 14:20:28 +03:30
49 changed files with 697 additions and 5010 deletions

View File

@@ -7,6 +7,7 @@ alwaysApply: false
# Appointments
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
- **Patient:** create/update must use a **named** patient of this org (`createdByOrganizationId`, not walk-in). Helper `ensurePatientInOrg`. Do not book another clinics patient UUID.
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).

View File

@@ -11,6 +11,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
- **CLINIC** orgs: patients, appointments, treatment, staff.
- **LAB** orgs: cases, tasks, lab workflows.
- Named **patients** are scoped to `createdByOrganizationId`. `mobile` stays globally unique — other-org / walk-in hit `PATIENT_MOBILE_UNAVAILABLE` (no shared row).
- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ.
## Agent behavior

View File

@@ -1,6 +1,6 @@
---
description: Brand and FDI SVG sources live under frontend/src/assets, not public/
globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**
globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**,frontend/Dockerfile
alwaysApply: false
---
@@ -11,3 +11,4 @@ alwaysApply: false
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
- **Prosthesis catalog illustrations:** source `frontend/src/assets/prosthesis-catalog/*.svg` (painted navy, not `currentColor`). Serve the same filenames from `frontend/public/prosthesis-catalog/` as `<img>` — Next cannot import these as URLs under Turbopack 16.1, and denture/veneer are too large to inline. Map codes in `prosthesisCatalogIcons.ts`. Copy into `public/` when adding a file.
- **`public/`** is only for files that must be fetched by URL (e.g. og images, prosthesis catalog icons). Do not put themeable brand/FDI SVGs there — `<img src>` cannot inherit `currentColor`.
- **Docker:** Next `output: 'standalone'` does **not** include `public/`. `frontend/Dockerfile` must `COPY` builder `/app/public` to `./public` next to `server.js` (after the standalone copy). Missing this 404s `/prosthesis-catalog/*.svg` in staging/prod.

View File

@@ -13,7 +13,7 @@ alwaysApply: false
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites: `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
Skill: `.cursor/skills/lab-case-share-link/SKILL.md`

View File

@@ -0,0 +1,12 @@
---
description: Clinic patients — org-scoped named records, globally unique mobile
globs: backend/src/modules/patients/**,backend/src/modules/appointments/appointments.service.ts,backend/src/modules/treatments/treatments.service.ts,frontend/src/components/ui/patient/**,frontend/src/lib/api/patients.ts
alwaysApply: false
---
# Patients
- List / get / update / create: **this org** + `isWalkIn: false` (`createdByOrganizationId`).
- `Patient.mobile` stays **globally unique**. Same-org named create returns `{ existing: true }`. Other org, walk-in, or null creator → `PATIENT_MOBILE_UNAVAILABLE` (409). Do **not** return or mention the other clinics row.
- Appointment create/update and `POST /treatments` named `patientId`: `ensurePatientInOrg` (named + this org). Walk-in sentinel is per clinic (`walk-in-patient.ts`), hidden from Patients/search/booking.
- History / lab-case lists still query treatments for **this** `organizationId` even if the patient UUID is guessed.

View File

@@ -10,7 +10,7 @@ alwaysApply: false
- **Login + register:** `useEnterAppWhenAuthenticated` after org ready → `appPathAfterAuth()` (`consumeAuthRedirect()` once, else `/today`).
- **Login `?from=`:** `storeAuthRedirectFromPath` **before** that hook (effect order).
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. ❌ Do not put the hook on invite pages (logged-in visitors must finish accept).
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. Same for `/accept-invite` `mode: password_setup` (password fields only). ❌ Do not put the hook on invite pages (logged-in visitors must finish accept). Login does not special-case `passwordHash: null` — those users cannot sign in until they set a password via the setup link.
- **Forgot password:** navigates itself to `/settings/account?reset=1`. ❌ Do not add the enter-app hook there.
- **Multi-org:** redirect stays in sessionStorage until `selectOrganization()` → `appPathAfterAuth()`.
- ❌ Never `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial`.

13
.cursor/rules/staff.mdc Normal file
View File

@@ -0,0 +1,13 @@
---
description: Staff passwords — setup link only; never set another users password
globs: backend/src/modules/staff/**,frontend/src/components/ui/staff/**,frontend/src/app/**/accept-invite/**,frontend/src/lib/api/staff.ts
alwaysApply: false
---
# Staff passwords
- Owner / `TAB_STAFF_EDIT` may **clear** a password, never set one for someone else.
- `POST /staff/members/:membershipId/clear-password`: `passwordHash: null`, delete sessions, revoke unused invites on that membership, mint a 7-day `/accept-invite` URL. Refuse owner, self, pending, disabled (`STAFF_CANNOT_CLEAR_OWN_PASSWORD`, `STAFF_PASSWORD_CLEAR_ACTIVE_ONLY`).
- List DTO: `hasPassword` boolean only (never the hash). `previewInvite` `mode`: `join` | `password_setup` from `membership.isActive`.
- Login page unchanged — null hash is invalid credentials until they set a password on the setup link.
- `/accept-invite` `password_setup`: password fields only; then `login()` + `navigateIntoAppIfOrgSelected` (no enter-app hook).

View File

@@ -56,7 +56,7 @@ Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`).
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`.
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite: `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial` — double consume sends user to `/today`.
5. Multi-org: redirect stays in storage until `selectOrganization()``appPathAfterAuth()`.
6. Forgot-password navigates to account reset itself — do not add the enter-app hook there.

View File

@@ -43,7 +43,7 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Production Docker must copy `public/` into the standalone image (see `.cursor/rules/frontend-assets.mdc`). Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
@@ -188,7 +188,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards |
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` |
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }`. Named `patientId` must be this org (`ensurePatientInOrg`). |
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |
@@ -202,7 +202,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Named patients are this-org only (`createdByOrganizationId`); another clinics mobile is `PATIENT_MOBILE_UNAVAILABLE`, not a shared row. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.

View File

@@ -62,7 +62,11 @@ frontend/src/
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). Patient must belong to this org (`ensurePatientInOrg`). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
**Patients (quick ref):** List/get/update/create are this-org named patients (`createdByOrganizationId`, `isWalkIn: false`). Mobile stays globally unique. Same-org mobile create returns `existing: true`; other org / walk-in / null creator → `PATIENT_MOBILE_UNAVAILABLE` (409, no leak). See `.cursor/rules/patients.mdc`.
**Staff (quick ref):** Owner / `TAB_STAFF_EDIT` can **remove** a password (`POST /staff/members/:id/clear-password`) and copy a setup link — never set one for someone else. Login page unchanged (`passwordHash: null` cannot sign in). `/accept-invite` `password_setup` is password-only. See `.cursor/rules/staff.mdc`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
@@ -72,7 +76,7 @@ frontend/src/
- Token on first ship → `/{locale}/lab-case/{token}` after login.
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
- **Clinic:** treatment **provider** with `TAB_TREATMENT_EDIT` — read-only tasks, can comment. Same QR as lab Cases appears on Treatment **Lab dispatch** after send (dest/jobs beside the thumb; tracker + comments full width below).
- Logged out → login with `?from=``storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite accept then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
- Logged out → login with `?from=``storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite (including `password_setup`) then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`.

View File

@@ -39,7 +39,6 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or
| `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 |
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts`) |
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
@@ -103,8 +102,8 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
### Tests
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts`, `voiceReviewRows.ts` and `toothSelectionGroups.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
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.
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. Next `output: 'standalone'` does **not** include `public/`; `frontend/Dockerfile` copies `/app/public` next to `server.js` (catalog icons at `/prosthesis-catalog/*.svg`). Production tags are immutable — CI clones `--branch $tag`; cut a new `v*` instead of moving an existing tag.

View File

@@ -33,7 +33,7 @@ Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/regist
| Path | Role |
|------|------|
| `backend/Dockerfile` | API image |
| `frontend/Dockerfile` | Web image |
| `frontend/Dockerfile` | Web image (`standalone` + copy `public/` for catalog icons) |
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |

View File

@@ -71,11 +71,7 @@ export const LAB_WORKFLOW_STEPS = [
] as const;
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
export type ProsthesisStackGroup =
| 'restoration'
| 'implant'
| 'post_core'
| 'arch';
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
export type ProsthesisTypeSeed = {
code: string;
@@ -91,13 +87,7 @@ export type ProsthesisTypeSeed = {
};
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const;
const INDIRECT_FULL = [
...SCAN_DESIGN_MODEL,
'milling_wet',
'sinter',
'stain',
'glaze',
] as const;
const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const;
const INDIRECT_LAYERED = [
...SCAN_DESIGN_MODEL,
'milling_wet',
@@ -125,24 +115,9 @@ const KEEP_LAYERED = [
'glaze',
'polish_prep',
] as const;
const DENTURE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const APPLIANCE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const POST_CORE = [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
] as const;
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const;
function crown(
code: string,
@@ -190,8 +165,7 @@ function indirect(
subcategory: indication,
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps:
technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
};
}
@@ -226,24 +200,10 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'stain',
'glaze',
]),
crown('full_metal_crown', 5, [
...SCAN_DESIGN_MODEL,
'milling_wet',
'polish_prep',
]),
crown('temporary_resin_crown', 6, [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
]),
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']),
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']),
crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, [
'intraoral_scan',
'design',
'milling_dry',
'polish_prep',
]),
crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('press_ceramic', 9, [
'intraoral_scan',
'design',
@@ -283,11 +243,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'model_create',
'polish_prep',
]),
implant('ti_base_abutment', 41, [
'intraoral_scan',
'model_create',
'polish_prep',
]),
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
implant('multi_unit_abutment', 42, [
'intraoral_scan',
'choosing_abutment',
@@ -299,11 +255,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'milling_wet',
'polish_prep',
]),
implant('zirconia_abutment', 44, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
]),
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
implant(
'screw_retained',
45,
@@ -498,11 +450,7 @@ export {
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
specialized_restoration: {
en: 'Specialized Restoration',
fa: 'ترمیم تخصصی',
nl: 'Gespecialiseerde Restauratie',
},
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
@@ -512,16 +460,8 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
clinic_visit: {
en: 'Clinic Visit',
fa: 'ویزیت درمانگاه',
nl: 'Kliniekbezoek',
},
continue_treatment: {
en: 'Continue Treatment',
fa: 'ادامه درمان',
nl: 'Behandeling Voortzetten',
},
clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' },
continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' },
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
@@ -531,182 +471,50 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: {
en: 'Monolithic Zirconia',
fa: 'زیرکونیا مونولیتیک',
nl: 'Monolithisch Zirconia',
},
glass_ceramic_crown: {
en: 'Glass Ceramic Crown',
fa: 'روکش سرامیک شیشه‌ای',
nl: 'Glaskeramische Kroon',
},
full_metal_crown: {
en: 'Full Metal Crown',
fa: 'روکش تمام فلز',
nl: 'Volledige Metalen Kroon',
},
temporary_resin_crown: {
en: 'Temporary Resin Crown',
fa: 'روکش موقت رزینی',
nl: 'Tijdelijke Harskroon',
},
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشه‌ای', nl: 'Glaskeramische Kroon' },
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
press_ceramic: {
en: 'Pressed Ceramic / IPS e.max',
fa: 'سرامیک پرس / IPS e.max',
nl: 'Perskeramiek / IPS e.max',
},
veneer_full_contour: {
en: 'Veneer · Full contour',
fa: 'ونیر · تمام‌کانتور',
nl: 'Veneer · Full contour',
},
veneer_layered: {
en: 'Veneer · Layered ceramic',
fa: 'ونیر · سرامیک لایه‌ای',
nl: 'Veneer · Gelaagd keramiek',
},
inlay_full_contour: {
en: 'Inlay · Full contour',
fa: 'اینلی · تمام‌کانتور',
nl: 'Inlay · Full contour',
},
inlay_layered: {
en: 'Inlay · Layered ceramic',
fa: 'اینلی · سرامیک لایه‌ای',
nl: 'Inlay · Gelaagd keramiek',
},
onlay_full_contour: {
en: 'Onlay · Full contour',
fa: 'آنلی · تمام‌کانتور',
nl: 'Onlay · Full contour',
},
onlay_layered: {
en: 'Onlay · Layered ceramic',
fa: 'آنلی · سرامیک لایه‌ای',
nl: 'Onlay · Gelaagd keramiek',
},
overlay_full_contour: {
en: 'Overlay · Full contour',
fa: 'اورلی · تمام‌کانتور',
nl: 'Overlay · Full contour',
},
overlay_layered: {
en: 'Overlay · Layered ceramic',
fa: 'اورلی · سرامیک لایه‌ای',
nl: 'Overlay · Gelaagd keramiek',
},
cast_post_core: {
en: 'Cast Post & Core',
fa: 'پست و کور ریختگی',
nl: 'Gegoten Stiftopbouw',
},
fiber_post_core: {
en: 'Fiber Post & Core',
fa: 'پست و کور فایبر',
nl: 'Fiber Stiftopbouw',
},
customized_abutment: {
en: 'Custom Abutment (Titanium)',
fa: 'اباتمنت سفارشی (تیتانیوم)',
nl: 'Aangepast abutment (titanium)',
},
prefabricated_abutment: {
en: 'Prefabricated Abutment',
fa: 'اباتمنت آماده',
nl: 'Prefab Abutment',
},
ti_base_abutment: {
en: 'Ti Base Abutment',
fa: 'اباتمنت پایه تیتانیوم',
nl: 'Ti Basis Abutment',
},
multi_unit_abutment: {
en: 'Multi Unit Abutment',
fa: 'اباتمنت مولتی یونیت',
nl: 'Multi Unit Abutment',
},
zirconia_abutment: {
en: 'Custom Abutment (Zirconia)',
fa: 'اباتمنت سفارشی (زیرکونیا)',
nl: 'Aangepast abutment (zirconia)',
},
press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' },
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' },
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' },
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' },
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' },
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' },
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' },
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' },
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' },
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' },
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' },
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' },
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' },
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
complete_denture: {
en: 'Complete Denture',
fa: 'دنچر کامل',
nl: 'Volledige prothese',
},
partial_denture: {
en: 'Partial Denture',
fa: 'دنچر پارسیل',
nl: 'Partiële prothese',
},
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' },
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' },
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
night_guard_soft: {
en: 'Night Guard · Soft',
fa: 'نایت گارد · نرم',
nl: 'Nachtbeugel · Zacht',
},
night_guard_hard: {
en: 'Night Guard · Hard',
fa: 'نایت گارد · سخت',
nl: 'Nachtbeugel · Hard',
},
night_guard_dual: {
en: 'Night Guard · Dual laminate',
fa: 'نایت گارد · دو لایه',
nl: 'Nachtbeugel · Dual laminate',
},
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' },
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' },
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' },
bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
clear_aligner: {
en: 'Clear Aligner',
fa: 'الاینر شفاف',
nl: 'Clear aligner',
},
soft_structure: {
en: 'Soft Structure',
fa: 'ساختار نرم',
nl: 'Zachte Structuur',
},
surgical_guide: {
en: 'Surgical Guide',
fa: 'گاید جراحی',
nl: 'Chirurgische mal',
},
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' },
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
veneer_zirconia: {
en: 'Veneer Zirconia',
fa: 'ونیر زیرکونیا',
nl: 'Veneer Zirconia',
},
veneer_ips_press: {
en: 'Veneer IPS Press',
fa: 'ونیر IPS پرس',
nl: 'Veneer IPS Press',
},
veneer_ips_cad: {
en: 'Veneer IPS CAD',
fa: 'ونیر IPS CAD',
nl: 'Veneer IPS CAD',
},
zirconia_overlay: {
en: 'Zirconia Overlay',
fa: 'اورلی زیرکونیا',
nl: 'Zirconia Overlay',
},
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
};
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
intraoral_scan: {
en: 'Intraoral Scan',
fa: 'اسکن داخل دهان',
nl: 'Intraorale Scan',
},
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
choosing_abutment: {
en: 'Choosing Abutment',
fa: 'انتخاب اباتمنت',
@@ -722,11 +530,7 @@ export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' },
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
polish_prep: {
en: 'Polish/Prep',
fa: 'پولیش/آماده‌سازی',
nl: 'Polijsten/Voorbereiding',
},
polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آماده‌سازی', nl: 'Polijsten/Voorbereiding' },
packing: { en: 'Packing', fa: 'بسته‌بندی', nl: 'Verpakken' },
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
@@ -745,58 +549,8 @@ function labelsToTranslations(
return out;
}
/**
* The 7 prosthesis categories, worded from `frontend/messages/*.json`'s `category_*` keys
* (decision 47) so the extraction prompt and the manual picker read identically on day one.
* The frontend keeps its own message keys — migrating it to read these is out of scope
* (decision 48) — this seed is written *from* those keys precisely so the two agree at the
* point they diverge.
*/
export const PROSTHESIS_CATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
crown: { en: 'Crowns', fa: 'روکش‌ها', nl: 'Kronen' },
indirect: {
en: 'Veneer/Inlay/Onlay/Overlay',
fa: 'ونیر/اینلی/آنلی/اورلی',
nl: 'Veneer/Inlay/Onlay/Overlay',
},
implant: { en: 'Implants', fa: 'ایمپلنت', nl: 'Implantaten' },
post_core: { en: 'Post & core', fa: 'پست و کور', nl: 'Stiftopbouw' },
removable: { en: 'Removable', fa: 'متحرک', nl: 'Uitneembaar' },
appliance: { en: 'Appliances', fa: 'اپلاینس‌ها', nl: 'Apparatuur' },
digital: { en: 'Digital', fa: 'دیجیتال', nl: 'Digitaal' },
};
/**
* Only the 5 real subcategories — the `sub_*` message key set is wider (it also carries
* technique names and two leaf codes); those are not subcategory catalog entities.
*/
export const PROSTHESIS_SUBCATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
veneer: { en: 'Veneer', fa: 'ونیر', nl: 'Veneer' },
inlay: { en: 'Inlay', fa: 'اینلی', nl: 'Inlay' },
onlay: { en: 'Onlay', fa: 'آنلی', nl: 'Onlay' },
overlay: { en: 'Overlay', fa: 'اورلی', nl: 'Overlay' },
night_guard: { en: 'Night guard', fa: 'نایت گارد', nl: 'Nachtbeugel' },
};
export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
...labelsToTranslations(
CatalogEntityKind.LAB_WORKFLOW_STEP,
WORKFLOW_STEP_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_CATEGORY,
PROSTHESIS_CATEGORY_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
PROSTHESIS_SUBCATEGORY_LABELS,
),
...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
];

View File

@@ -1,6 +0,0 @@
-- AlterEnum
-- Category and subcategory labels have no backend source today (spec §5, decision 47):
-- CatalogEntityKind covered only TREATMENT_TYPE / PROSTHESIS_TYPE / LAB_WORKFLOW_STEP, so
-- CatalogLabelService could not resolve a category at all. Additive only, no data loss.
ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_CATEGORY';
ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_SUBCATEGORY';

View File

@@ -332,8 +332,6 @@ enum CatalogEntityKind {
TREATMENT_TYPE
PROSTHESIS_TYPE
LAB_WORKFLOW_STEP
PROSTHESIS_CATEGORY
PROSTHESIS_SUBCATEGORY
}
model CatalogTranslation {

View File

@@ -62,14 +62,6 @@ export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
...FDI_LOWER_ARCH_ORDER,
]);
/**
* Not FDI codes — jaw-level prosthesis targets on `LabCaseToothProsthesis.tooth`. Mirrors
* `frontend/src/components/treatment/prosthesisTree.ts`'s `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER`
* so the voice contract speaks the same sentinel the manual chart already writes.
*/
export const ARCH_TOOTH_UPPER = 'UA';
export const ARCH_TOOTH_LOWER = 'LA';
export function isFdiTooth(value: unknown): value is string {
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
}

View File

@@ -69,9 +69,7 @@ export class ProsthesisCatalogService implements OnModuleInit {
this.loaded = true;
}
async list(
localeInput?: string | null,
): Promise<ProsthesisTypeCatalogEntry[]> {
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
@@ -94,64 +92,17 @@ export class ProsthesisCatalogService implements OnModuleInit {
stackGroup: row.stackGroup,
};
})
.sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
);
}
/** The 7 distinct category codes present in the active catalog, with labels in `locale`. */
async listCategories(
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
return this.listDistinct(
(row) => row.category,
CatalogEntityKind.PROSTHESIS_CATEGORY,
localeInput,
);
}
/** The 5 distinct subcategory codes (veneer, inlay, onlay, overlay, night_guard, …). */
async listSubcategories(
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
return this.listDistinct(
(row) => row.subcategory,
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
localeInput,
);
}
private async listDistinct(
pick: (row: CatalogRow) => string,
entityKind: CatalogEntityKind,
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [
...new Set([...this.byCode.values()].map(pick).filter(Boolean)),
].sort();
const labels = await this.catalogLabels.resolveLabels(
entityKind,
codes,
locale,
);
return codes.map((code) => ({ code, label: labels.get(code) ?? code }));
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
assertKnownProsthesisType(code: string): void {
this.ensureLoaded();
if (!this.byCode.has(code)) {
throw new AppException(
ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE,
HttpStatus.BAD_REQUEST,
);
throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST);
}
}
async getStepCodesForProsthesisType(
prosthesisTypeCode: string,
): Promise<string[]> {
async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise<string[]> {
const type = await this.prisma.prosthesisType.findUnique({
where: { code: prosthesisTypeCode },
select: {
@@ -169,10 +120,7 @@ export class ProsthesisCatalogService implements OnModuleInit {
return type.steps.map((s) => s.labWorkflowStep.code);
}
async resolveStepLabels(
stepCodes: string[],
locale: CatalogLocale,
): Promise<Map<string, string>> {
async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise<Map<string, string>> {
return this.catalogLabels.resolveLabels(
CatalogEntityKind.LAB_WORKFLOW_STEP,
stepCodes,

View File

@@ -8,24 +8,14 @@ const LOCALE_NOTES: Record<string, string> = {
'("دندون شماره ۲۶"). 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.',
'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just',
'"بالا"/"پایین" in context, or "هر دو فک" (both jaws).',
'A note is asked for with "بنویس", "یادداشت کن", "این را یادداشت کن", "بنویس که",',
'"در توضیحات بنویس". Anything else the clinician says is not a note.',
].join(' '),
nl: [
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is',
'"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).',
'A note is asked for with "schrijf op", "noteer", "zet in de notities". Anything else',
'the clinician says is not a note.',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
].join(' '),
en: [
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower',
'jaw", or "both jaws".',
'A note is asked for with "write this in the notes", "note that", "add a note",',
'"put in the comments". Anything else the clinician says is not a note.',
'all tooth 26. The descriptive form is "upper right six".',
].join(' '),
};
@@ -34,63 +24,6 @@ function codeList(entries: { code: string; label: string }[]): string {
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n');
}
/** "per-tooth" | "jaw" | "per-tooth or jaw" — derived from the leaves' own chartRegion, never hardcoded. */
function regionNote(regions: ReadonlySet<string>): string {
const isJaw = regions.has('arch');
const isTooth = regions.has('crown') || regions.has('root');
if (isJaw && isTooth)
return 'per-tooth or jaw, depending on the specific code';
return isJaw ? 'jaw' : 'per-tooth';
}
/**
* Renders the prosthesis catalog as the tree it is: category -> (subcategory ->) leaves.
* Nothing here is hardcoded — every code, label and region annotation comes from the data
* `buildCatalog` fetched, so a catalog change needs no prompt change.
*/
function prosthesisTree(catalog: ExtractionCatalog): string {
const byCategory = new Map<string, ExtractionCatalog['prosthesisTypes']>();
for (const leaf of catalog.prosthesisTypes) {
const list = byCategory.get(leaf.category) ?? [];
list.push(leaf);
byCategory.set(leaf.category, list);
}
const lines: string[] = [];
for (const category of catalog.prosthesisCategories) {
const leaves = byCategory.get(category.code) ?? [];
const regions = new Set(leaves.map((l) => l.chartRegion));
lines.push(
`CATEGORY ${category.code} = ${category.label} (${regionNote(regions)})`,
);
const bySubcategory = new Map<string, typeof leaves>();
const bare: typeof leaves = [];
for (const leaf of leaves) {
if (leaf.subcategory) {
const list = bySubcategory.get(leaf.subcategory) ?? [];
list.push(leaf);
bySubcategory.set(leaf.subcategory, list);
} else {
bare.push(leaf);
}
}
for (const leaf of bare) {
lines.push(` - ${leaf.code} = ${leaf.label}`);
}
for (const sub of catalog.prosthesisSubcategories) {
const subLeaves = bySubcategory.get(sub.code);
if (!subLeaves || subLeaves.length === 0) continue;
lines.push(` SUBCATEGORY ${sub.code} = ${sub.label}`);
for (const leaf of subLeaves) {
lines.push(` - ${leaf.code} = ${leaf.label}`);
}
}
}
return lines.join('\n');
}
export function buildExtractionPrompt(
transcript: string,
catalog: ExtractionCatalog,
@@ -103,22 +36,17 @@ export function buildExtractionPrompt(
'You are a parser, not an assistant: report only what was said.',
'',
'HARD RULES',
'1. Never invent a code. treatmentType and prosthesis[].types[] 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 (or omit it from prosthesis[].types[]).',
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
' must be codes from the lists below. labId must be an id from the lab list. If what you',
' heard is not in a list, use null.',
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
" flip to the viewer's point of view.",
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
' If no deadline was mentioned, use due.kind = "none".',
'4. Copy the exact spoken words for each tooth, jaw and prosthesis instruction into',
' "spoken", so the clinician can see what was heard.',
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
' what was heard.',
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.',
'6. A whole jaw is not a tooth. Never put a jaw in the "teeth" list. A jaw appears only',
' as a prosthesis[].targets entry with "arch" set and "position" null.',
'7. Never decide something is a note. Fill "comment" only when the clinician asked for',
' one, and report the words they used in "commentTrigger". Speech you cannot place in',
' a field is simply not reported — it is never a note.',
'',
'TOOTH NUMBERS',
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
@@ -143,24 +71,8 @@ export function buildExtractionPrompt(
'TREATMENT TYPE CODES',
codeList(catalog.treatmentTypes),
'',
'PROSTHESIS WORK — prosthesis[]',
'One entry per spoken instruction: "these targets get these jobs". Each entry is',
'{ targets, types, spoken }:',
'- targets: the teeth OR jaw(s) this instruction is for. A jaw target is a tooth object',
' with "arch" set ("upper", "lower", or "both" for both jaws) and "position" left null —',
' never invent a tooth number for a jaw-level appliance. A tooth target is the normal',
' fdi / arch+side+position shape above.',
'- types: one or more codes from the tree below, applied to every target in this entry.',
' More than one code means a STACK on the same tooth — e.g. an abutment plus a crown on',
' one implant site: types: ["zirconia_abutment", "monolithic_zirconia"].',
' If only the general term was said ("روکش", "veneer") and not a specific material, use',
' the CATEGORY or SUBCATEGORY code instead of guessing a leaf.',
'- A tooth named with no job at all still belongs in the top-level "teeth" list, not here.',
'- If several teeth share one job, list them all as targets in one entry rather than',
' repeating the entry — "12 and 13, PFM crown on both" -> one entry, two targets.',
'',
'PROSTHESIS TYPE CODES (tree — CATEGORY and SUBCATEGORY are marked; the rest are leaves)',
prosthesisTree(catalog),
'PROSTHESIS TYPE CODES',
codeList(catalog.prosthesisTypes),
'',
'LABS THIS CLINIC CAN SEND TO',
catalog.labs.length > 0
@@ -169,13 +81,8 @@ export function buildExtractionPrompt(
'',
'OTHER FIELDS',
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
'- comment + commentTrigger: a note is written ONLY when the clinician asked for one.',
' "commentTrigger" is the exact words that asked, copied from the transcript. "comment"',
' is what they then dictated, WITHOUT those words: for "بنویس که بیمار حساسیت به سرما',
' دارد", commentTrigger is "بنویس که" and comment is "بیمار حساسیت به سرما دارد".',
' If nobody asked, BOTH are null. Never sweep up leftover speech, filler, small talk or',
' a diagnosis nobody asked you to record. A comment without a trigger is discarded, so',
' guessing costs the clinician the note.',
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
' treatment type, teeth or deadline.',
'- labMatchExact: true only when the spoken name matched a lab name exactly.',
].join('\n');

View File

@@ -1,22 +1,10 @@
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi';
import {
PROSTHESIS_TYPES,
PROSTHESIS_CATEGORY_LABELS,
PROSTHESIS_SUBCATEGORY_LABELS,
} from '../../../prisma/catalog-seed-data';
import {
resolveConnectedSpans,
resolveProsthesisAssignment,
resolveProsthesis,
resolveVoiceIntent,
type ProsthesisLeaf,
type ResolveContext,
} from './extraction.resolver';
import type {
ProsthesisAssignment,
ToothIntent,
UnresolvedItem,
VoiceIntent,
} from './voice.types';
import type { ToothIntent, VoiceIntent } from './voice.types';
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
kind: 'explicit',
@@ -24,75 +12,12 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
spoken,
});
const positional = (
overrides: Partial<Extract<ToothIntent, { kind: 'positional' }>> = {},
): ToothIntent => ({
kind: 'positional',
arch: undefined as never,
side: undefined as never,
position: Number.NaN,
spoken: '',
...overrides,
});
const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [
{
code: 'pfm_crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'monolithic_zirconia',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'zirconia_abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
},
{
code: 'screw_retained',
category: 'implant',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'night_guard_soft',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
},
{
code: 'complete_denture',
category: 'removable',
subcategory: '',
chartRegion: 'arch',
},
{
code: 'partial_denture',
category: 'removable',
subcategory: '',
chartRegion: 'crown',
},
];
const CTX: ResolveContext = {
todayIso: '2025-10-11',
weekStartJs: 6, // Saturday — the fa week
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
prosthesisLeaves: PROSTHESIS_LEAVES,
prosthesisCategoryCodes: new Set([
'crown',
'implant',
'removable',
'appliance',
]),
prosthesisSubcategoryCodes: new Set(['night_guard']),
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
};
@@ -186,317 +111,122 @@ describe('resolveConnectedSpans', () => {
});
});
describe('resolveProsthesisAssignment', () => {
function resolve(assignment: ProsthesisAssignment, index = 0) {
const unresolved: UnresolvedItem[] = [];
const resolved = resolveProsthesisAssignment(
assignment,
index,
CTX,
unresolved,
describe('resolveProsthesis', () => {
const allowed = CTX.prosthesisTypeCodes;
it('expands the default across every tooth', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
['14', '15'],
allowed,
);
return { resolved, unresolved };
}
it('resolves explicit tooth targets to FDI codes', () => {
const { resolved } = resolve({
targets: [tooth('12'), tooth('13')],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام برای ۱۲ و ۱۳',
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'15': 'monolithic_zirconia',
});
expect(resolved.targets.sort()).toEqual(['12', '13']);
expect(resolved.types).toEqual(['pfm_crown']);
expect(result.prosthesis?.complete).toBe(true);
});
it('resolves a stack — more than one type on the same target', () => {
const { resolved } = resolve({
targets: [tooth('12')],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
});
expect(resolved.targets).toEqual(['12']);
expect(resolved.types.sort()).toEqual([
'monolithic_zirconia',
'zirconia_abutment',
]);
});
it('resolves an arch target with no position to the jaw sentinel', () => {
const { resolved } = resolve({
targets: [positional({ arch: 'upper' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد فک بالا',
});
expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
});
it('resolves "both jaws" to both sentinels', () => {
const { resolved } = resolve({
targets: [positional({ arch: 'both' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد هر دو فک',
});
expect(resolved.targets.sort()).toEqual(
[ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(),
);
});
it('classifies a leaf, a category and a subcategory code independently', () => {
const leaf = resolve({
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: '',
});
expect(leaf.resolved.types).toEqual(['pfm_crown']);
expect(leaf.unresolved).toEqual([]);
const category = resolve({
targets: [tooth('12')],
types: ['crown'],
spoken: 'روکش',
});
expect(category.resolved.types).toEqual([]);
expect(category.unresolved).toEqual([
it('applies per-tooth overrides on top of the default', () => {
const result = resolveProsthesis(
{
spoken: 'روکش',
reason: 'prosthesis_type_ambiguous',
candidates: ['monolithic_zirconia', 'pfm_crown'],
assignmentIndex: 0,
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
},
]);
const subcategory = resolve({
targets: [positional({ arch: 'upper' })],
types: ['night_guard'],
spoken: 'نایت گارد',
});
expect(subcategory.resolved.types).toEqual([]);
expect(subcategory.unresolved[0]).toMatchObject({
reason: 'prosthesis_type_ambiguous',
candidates: ['night_guard_soft'],
['14', '26'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'26': 'pfm_crown',
});
expect(result.prosthesis?.complete).toBe(true);
});
it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => {
const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code));
const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category));
const subcategoryCodes = new Set(
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(
(code): code is string => Boolean(code),
),
it('marks the map incomplete when a tooth ends up untyped', () => {
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
const result = resolveProsthesis(
{
defaultType: null,
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
},
['14', '15'],
allowed,
);
for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false);
for (const code of subcategoryCodes) {
expect(leafCodes.has(code)).toBe(false);
expect(categoryCodes.has(code)).toBe(false);
expect(result.prosthesis?.complete).toBe(false);
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
});
it('rejects a catalog code the clinic does not have', () => {
const result = resolveProsthesis(
{ defaultType: 'gold_foil', overrides: [] },
['14'],
allowed,
);
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
]);
});
it('ignores an override for a tooth that is not selected', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
},
['14'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
expect(result.unresolved).toEqual([
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
]);
});
it('reports no prosthesis at all when the object carries nothing usable', () => {
// An empty-but-present map would paint a plain restoration with a fabricated
// "incomplete, cannot ship" warning.
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
expect(
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
).toBeNull();
}
});
it('reports a code not in the supplied catalog', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['gold_foil'],
spoken: '',
});
expect(resolved.types).toEqual([]);
expect(unresolved).toEqual([
it('reports no prosthesis when there are no teeth to type', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
[],
allowed,
);
expect(result.prosthesis).toBeNull();
});
it('distinguishes a tooth it could not understand from one that is not selected', () => {
// Different corrective actions: add the tooth, versus repeat yourself.
const result = resolveProsthesis(
{
spoken: 'gold_foil',
reason: 'unknown_catalog_code',
assignmentIndex: 0,
defaultType: 'monolithic_zirconia',
overrides: [
{
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
type: 'pfm_crown',
},
],
},
['14'],
allowed,
);
expect(result.unresolved).toEqual([
{ spoken: 'نود و نه', reason: 'malformed' },
]);
});
it('rejects an arch code aimed at a tooth', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['night_guard_soft'],
spoken: 'دندون ۱۲ نایت گارد',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toEqual([
{ spoken: '12', reason: 'code_not_valid_for_target', assignmentIndex: 0 },
]);
it('returns null when no prosthesis was spoken', () => {
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
});
it('rejects a tooth code aimed at a jaw', () => {
const { resolved, unresolved } = resolve({
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['pfm_crown'],
spoken: 'فک بالا',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toEqual([
{
spoken: 'فک بالا',
reason: 'code_not_valid_for_target',
assignmentIndex: 0,
},
]);
});
it('narrows a mixed-region category to the leaves the target can carry', () => {
// complete_denture is 'arch', partial_denture is 'crown'. Deferring the check until a
// leaf was picked left nothing to complete it, and wrote a complete denture onto one
// tooth. The target kind narrows the candidates instead, so an impossible leaf is never
// offered.
const toothTarget = resolve({
targets: [tooth('12')],
types: ['removable'],
spoken: 'دنچر برای ۱۲',
});
expect(toothTarget.resolved.targets).toEqual(['12']);
expect(toothTarget.unresolved).toContainEqual(
expect.objectContaining({
reason: 'prosthesis_type_ambiguous',
candidates: ['partial_denture'],
}),
);
expect(toothTarget.unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
const jawTarget = resolve({
targets: [positional({ arch: 'upper' })],
types: ['removable'],
spoken: 'دنچر فک بالا',
});
expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
expect(jawTarget.unresolved).toContainEqual(
expect.objectContaining({
reason: 'prosthesis_type_ambiguous',
candidates: ['complete_denture'],
}),
);
expect(jawTarget.unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
});
it('rejects a category with no leaf the target can carry', () => {
// `implant` spans root + crown, both tooth regions, so it is not a mixed tooth/arch
// category and must not defer. Aimed at a jaw it is a contradiction.
const { resolved, unresolved } = resolve({
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['implant'],
spoken: 'ایمپلنت بالا',
});
expect(resolved.targets).toEqual([]);
expect(resolved.types).toEqual([]);
expect(unresolved).toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
expect(unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
);
});
it('rejects a stack where only one code suits the target', () => {
// One legal crown must not admit an arch-only appliance onto the same tooth. Validating
// with `some` over the stack's regions did exactly that.
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['pfm_crown', 'night_guard_soft'],
spoken: 'دندون ۱۲ روکش و نایت گارد',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toContainEqual(
expect.objectContaining({
spoken: '12',
reason: 'code_not_valid_for_target',
}),
);
});
it('accepts a stack whose every code suits the target', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'دندون ۱۲ ایمپلنت با روکش زیرکونیا',
});
expect(resolved.targets).toEqual(['12']);
expect(resolved.types).toEqual([
'zirconia_abutment',
'monolithic_zirconia',
]);
expect(unresolved).toEqual([]);
});
it('a target with no types resolves with an empty types[], and does not fail the assignment', () => {
const { resolved } = resolve({
targets: [tooth('13'), tooth('14')],
types: [],
spoken: '۱۳ و ۱۴',
});
// Nothing to validate a region against yet, so a bare target still resolves as valid
// geometry — the frontend renders it struck through ("no prosthesis heard") because
// `types` is empty and no `prosthesis_type_ambiguous` item references this assignment.
expect(resolved.targets.sort()).toEqual(['13', '14']);
expect(resolved.types).toEqual([]);
});
it('one target failing validity does not exclude the rest of the assignment', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12'), positional({ arch: 'upper' })],
types: ['pfm_crown'],
spoken: '',
});
expect(resolved.targets).toEqual(['12']);
expect(unresolved).toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
});
it('carries an assignment target that could not be resolved, with the arch candidates', () => {
const { unresolved } = resolve({
targets: [positional({ spoken: 'نایت گارد' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد',
});
expect(unresolved).toContainEqual({
spoken: 'نایت گارد',
reason: 'arch_not_spoken',
candidates: ['upper', 'lower'],
assignmentIndex: 0,
});
});
it('carries the assignment index on a missing-quadrant target', () => {
const { unresolved } = resolve({
targets: [positional({ position: 2, spoken: 'دندون دو' })],
types: ['pfm_crown'],
spoken: 'دندون دو روکش',
});
expect(unresolved).toContainEqual(
expect.objectContaining({
reason: 'tooth_missing_quadrant',
assignmentIndex: 0,
}),
);
});
});
describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => {
const locales = ['fa', 'en', 'nl'] as const;
it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))(
'category %s',
(_code, labels) => {
for (const locale of locales) {
expect(labels[locale]?.trim()).toBeTruthy();
}
},
);
it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))(
'subcategory %s',
(_code, labels) => {
for (const locale of locales) {
expect(labels[locale]?.trim()).toBeTruthy();
}
},
);
});
describe('resolveVoiceIntent', () => {
@@ -505,8 +235,7 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('14'), tooth('15')],
connectedSpans: [],
comment: ' حساسیت به سرما ',
commentTrigger: 'بنویس که',
prosthesis: [],
prosthesis: null,
labId: null,
labMatchExact: false,
due: null,
@@ -517,7 +246,7 @@ describe('resolveVoiceIntent', () => {
expect(result.treatmentType).toBe('restoration');
expect(result.teeth).toEqual(['14', '15']);
expect(result.comment).toBe('حساسیت به سرما');
expect(result.prosthesisAssignments).toEqual([]);
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([]);
});
@@ -570,150 +299,27 @@ describe('resolveVoiceIntent', () => {
expect(result.labMatchExact).toBe(false);
});
it('forces treatmentType to prosthesis when an assignment resolves', () => {
it('applies prosthesis over the span-expanded tooth set', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'restoration',
prosthesis: [
{
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: 'روکش برای ۱۲',
},
],
treatmentType: 'prosthesis',
teeth: [tooth('14')],
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
},
CTX,
);
expect(result.treatmentType).toBe('prosthesis');
expect(result.prosthesisAssignments).toEqual([
{ targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
// 15 was never spoken but is part of the bridge, so it must carry a type too.
expect(result.teeth).toEqual(['14', '15', '16']);
expect(result.prosthesis?.complete).toBe(true);
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
'14',
'15',
'16',
]);
});
it('does not force prosthesis when every assignment resolved no target', () => {
const result = resolveVoiceIntent(
{
...base,
prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }],
},
CTX,
);
expect(result.treatmentType).toBe('restoration');
});
it('does not treat a spoken jaw as a broken tooth', () => {
// "یه کامپلیت دنچر برای فک بالا" — the model names the jaw in `teeth` as well as in the
// assignment target. The arch reached the form correctly, but the duplicate reported
// `position_out_of_range`, so the sheet asked which tooth was meant. No tooth was said.
const result = resolveVoiceIntent(
{
...base,
treatmentType: null,
teeth: [positional({ arch: 'upper', spoken: 'فک بالا' })],
prosthesis: [
{
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['complete_denture'],
spoken: 'یه کامپلیت دنچر برای فک بالا',
},
],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([]);
expect(result.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
expect(result.treatmentType).toBe('prosthesis');
});
it('drops a both-jaws reference from the teeth list too', () => {
const result = resolveVoiceIntent(
{
...base,
teeth: [positional({ arch: 'both', spoken: 'هر دو فک' })],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([]);
});
it('still reports a tooth whose position is out of range', () => {
// The jaw filter must not swallow a real fault: a position was given, and it is wrong.
const result = resolveVoiceIntent(
{
...base,
teeth: [
positional({
arch: 'upper',
side: 'patient_right',
position: 9,
spoken: 'دندون نه',
}),
],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toContainEqual({
spoken: 'دندون نه',
reason: 'position_out_of_range',
});
});
it('still offers candidates for a tooth described without its quadrant', () => {
// arch + position, no side: a real tooth, under-specified. Must survive the filter.
const result = resolveVoiceIntent(
{
...base,
teeth: [positional({ arch: 'upper', position: 2, spoken: 'دو بالا' })],
},
CTX,
);
expect(result.unresolved).toContainEqual(
expect.objectContaining({
spoken: 'دو بالا',
reason: 'tooth_missing_quadrant',
candidates: ['12', '22'],
}),
);
});
it('drops a note the clinician never asked for', () => {
// The whole point: leftover speech the model decided was a note is discarded. Before this
// rule the prompt told it to sweep up "the parts already captured" as a comment.
const result = resolveVoiceIntent(
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: null },
CTX,
);
expect(result.comment).toBeNull();
});
it('drops a note whose trigger is only whitespace', () => {
const result = resolveVoiceIntent(
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: ' ' },
CTX,
);
expect(result.comment).toBeNull();
});
it('keeps a note that was asked for, trimmed', () => {
const result = resolveVoiceIntent(
{ ...base, comment: ' حساسیت به سرما ', commentTrigger: 'بنویس که' },
CTX,
);
expect(result.comment).toBe('حساسیت به سرما');
});
it('reports no note when the trigger was heard but nothing followed it', () => {
const result = resolveVoiceIntent(
{ ...base, comment: ' ', commentTrigger: 'بنویس که' },
CTX,
);
expect(result.comment).toBeNull();
});
it('resolves a due date through the same context', () => {
const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
@@ -722,7 +328,7 @@ describe('resolveVoiceIntent', () => {
expect(result.dueDate).toBe('2025-10-16');
});
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
it('collects unresolved items from every stage', () => {
const result = resolveVoiceIntent(
{
...base,
@@ -730,34 +336,16 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('51', 'شیری')],
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
prosthesis: [
{
targets: [tooth('99', 'نود و نه')],
types: ['pfm_crown'],
spoken: '',
},
],
},
CTX,
);
const reasons = result.unresolved.map((u) => u.reason).sort();
expect(reasons).toEqual([
'invalid_date',
'malformed',
'not_permanent_tooth',
'span_not_same_arch',
'unknown_catalog_code',
]);
const outsideAssignment = result.unresolved.filter(
(u) => u.reason === 'not_permanent_tooth',
);
expect(outsideAssignment[0].assignmentIndex).toBeUndefined();
const insideAssignment = result.unresolved.filter(
(u) => u.reason === 'malformed',
);
expect(insideAssignment[0].assignmentIndex).toBe(0);
});
it('treats an empty comment as absent', () => {

View File

@@ -1,14 +1,7 @@
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
isFdiTooth,
normalizeFdiCode,
sameArch,
sortInArchOrder,
teethBetweenInclusive,
toFdi,
type Arch,
type PatientSide,
} from '../../common/fdi';
import { resolveDueDate } from './due-date.resolver';
import {
@@ -17,10 +10,9 @@ import {
} from './tooth-intent.resolver';
import type {
ConnectedSpanIntent,
ProsthesisAssignment,
ProsthesisIntent,
ToothIntent,
UnresolvedItem,
UnresolvedReason,
VoiceIntent,
} from './voice.types';
@@ -30,16 +22,16 @@ export type ResolvedToothGroup = {
teeth: string[];
};
/**
* One spoken instruction, resolved: these targets (FDI codes, or `'UA'`/`'LA'` jaw sentinels —
* mirrors the manual chart's own convention) get these leaf codes. Empty `targets` or `types`
* means every target, or every type, this assignment named turned out unresolved; the
* assignment still appears so its index keeps meaning for `UnresolvedItem.assignmentIndex`.
*/
export type ResolvedProsthesisAssignment = {
targets: string[];
types: string[];
spoken: string;
export type ResolvedProsthesis = {
/** FDI code → prosthesis type code. */
byTooth: Record<string, string>;
/**
* True when every selected tooth carries a code. A prosthesis detail cannot be shipped
* otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap
* here rather than letting it fail at dispatch.
*/
complete: boolean;
missingTeeth: string[];
};
export type ResolvedExtraction = {
@@ -47,33 +39,23 @@ export type ResolvedExtraction = {
teeth: string[];
toothSelectionGroups: ResolvedToothGroup[];
comment: string | null;
prosthesisAssignments: ResolvedProsthesisAssignment[];
prosthesis: ResolvedProsthesis | null;
labId: string | null;
labMatchExact: boolean;
dueDate: string | null;
unresolved: UnresolvedItem[];
};
/** A leaf prosthesis catalog entry, as `ProsthesisCatalogService.list()` already returns it. */
export type ProsthesisLeaf = {
code: string;
category: string;
subcategory: string;
chartRegion: string;
};
export type ResolveContext = {
todayIso: string;
/** JS weekday index the clinician's week starts on — see weekStartForLocale. */
weekStartJs: number;
treatmentTypeCodes: ReadonlySet<string>;
prosthesisLeaves: readonly ProsthesisLeaf[];
prosthesisCategoryCodes: ReadonlySet<string>;
prosthesisSubcategoryCodes: ReadonlySet<string>;
prosthesisTypeCodes: ReadonlySet<string>;
linkedLabIds: ReadonlySet<string>;
};
function spokenOf(intent: unknown): string {
function spokenOf(intent: ToothIntent): string {
const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}
@@ -183,323 +165,76 @@ export function resolveConnectedSpans(
};
}
// --- Prosthesis assignments -------------------------------------------------------------
type TargetResolution =
| { kind: 'tooth'; fdi: string }
| { kind: 'jaw'; arch: 'upper' | 'lower' | 'both' }
| { kind: 'unresolved'; reason: UnresolvedReason; candidates?: string[] };
const QUADRANT_ARCHES: readonly Arch[] = ['upper', 'lower'];
const QUADRANT_SIDES: readonly PatientSide[] = [
'patient_right',
'patient_left',
];
/** The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. */
function quadrantCandidatesForTarget(
target: Extract<ToothIntent, { kind: 'positional' }>,
): string[] {
const arches =
target.arch === 'upper' || target.arch === 'lower'
? [target.arch]
: QUADRANT_ARCHES;
const sides =
target.side === 'patient_right' || target.side === 'patient_left'
? [target.side]
: QUADRANT_SIDES;
const codes: string[] = [];
for (const arch of arches) {
for (const side of sides) {
const fdi = toFdi(arch, side, target.position);
if (fdi) codes.push(fdi);
}
}
return codes.sort();
}
/**
* A prosthesis assignment target is a tooth or a jaw — nothing on the wire declares which.
* A position with no quadrant is the familiar "دو" ambiguity; no position at all, with an
* arch, names a jaw; no position AND no arch means the jaw itself was never spoken.
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is
* how clinicians actually speak.
*/
function resolveAssignmentTarget(target: ToothIntent): TargetResolution {
if (!target || typeof target !== 'object') {
return { kind: 'unresolved', reason: 'malformed' };
export function resolveProsthesis(
intent: ProsthesisIntent | null | undefined,
teeth: readonly string[],
allowed: ReadonlySet<string>,
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
if (!intent || typeof intent !== 'object')
return { prosthesis: null, unresolved: [] };
const unresolved: UnresolvedItem[] = [];
const defaultType = resolveCatalogCode(intent.defaultType, allowed);
if (intent.defaultType != null && !defaultType) {
unresolved.push({
spoken: String(intent.defaultType),
reason: 'unknown_catalog_code',
});
}
if (target.kind === 'explicit') {
const fdi = normalizeFdiCode((target as { fdi?: unknown }).fdi);
if (isFdiTooth(fdi)) return { kind: 'tooth', fdi };
// Quadrants 1-4 are permanent; a well-formed quadrant+position reaching here is 5-8:
// deciduous. Anything else is noise.
return {
kind: 'unresolved',
reason: /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed',
};
const byTooth: Record<string, string> = {};
const selected = new Set(teeth);
if (defaultType) {
for (const tooth of teeth) byTooth[tooth] = defaultType;
}
if (target.kind === 'positional') {
const positionGiven =
typeof target.position === 'number' && !Number.isNaN(target.position);
const archGiven =
target.arch === 'upper' ||
target.arch === 'lower' ||
target.arch === 'both';
const sideGiven =
target.side === 'patient_right' || target.side === 'patient_left';
if (positionGiven) {
if (
!Number.isInteger(target.position) ||
target.position < 1 ||
target.position > 8
) {
return { kind: 'unresolved', reason: 'position_out_of_range' };
}
if (archGiven && target.arch !== 'both' && sideGiven) {
const fdi = toFdi(target.arch, target.side, target.position);
if (fdi) return { kind: 'tooth', fdi };
}
// A position was said but the tooth's own quadrant was not (or "both" was said for
// what must be a single tooth) — the familiar "دو" ambiguity.
return {
kind: 'unresolved',
reason: 'tooth_missing_quadrant',
candidates: quadrantCandidatesForTarget(target),
};
}
if (archGiven) return { kind: 'jaw', arch: target.arch };
// Neither a tooth position nor a jaw was said.
return {
kind: 'unresolved',
reason: 'arch_not_spoken',
candidates: ['upper', 'lower'],
};
}
return { kind: 'unresolved', reason: 'malformed' };
}
function archSentinels(arch: 'upper' | 'lower' | 'both'): string[] {
if (arch === 'upper') return [ARCH_TOOTH_UPPER];
if (arch === 'lower') return [ARCH_TOOTH_LOWER];
return [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER];
}
type TypeClassification =
| { kind: 'leaf'; code: string; chartRegion: string }
| {
kind: 'category' | 'subcategory';
code: string;
leafCodes: string[];
regions: Set<string>;
}
| { kind: 'unknown' };
/** `types[]` may hold a leaf, or one category / subcategory code — the namespaces are disjoint. */
function classifyTypeCode(
rawCode: unknown,
ctx: ResolveContext,
): TypeClassification {
if (typeof rawCode !== 'string' || !rawCode.trim())
return { kind: 'unknown' };
const code = rawCode.trim();
const leaf = ctx.prosthesisLeaves.find((l) => l.code === code);
if (leaf) return { kind: 'leaf', code, chartRegion: leaf.chartRegion };
if (ctx.prosthesisCategoryCodes.has(code)) {
const leaves = ctx.prosthesisLeaves.filter((l) => l.category === code);
return {
kind: 'category',
code,
leafCodes: leaves.map((l) => l.code),
regions: new Set(leaves.map((l) => l.chartRegion)),
};
}
if (ctx.prosthesisSubcategoryCodes.has(code)) {
const leaves = ctx.prosthesisLeaves.filter((l) => l.subcategory === code);
return {
kind: 'subcategory',
code,
leafCodes: leaves.map((l) => l.code),
regions: new Set(leaves.map((l) => l.chartRegion)),
};
}
return { kind: 'unknown' };
}
const TOOTH_REGIONS = new Set(['crown', 'root']);
const JAW_REGIONS = new Set(['arch']);
function acceptableRegions(kind: 'tooth' | 'jaw'): ReadonlySet<string> {
return kind === 'tooth' ? TOOTH_REGIONS : JAW_REGIONS;
}
/**
* One spoken instruction, resolved. Stack legality (`canStackLeaf`, screw-retained exclusion)
* is never checked here — that lives in the frontend's `prosthesisTree.ts` (decision 38); this
* only confirms a code exists and that its region suits its target.
*/
export function resolveProsthesisAssignment(
assignment: ProsthesisAssignment,
index: number,
ctx: ResolveContext,
unresolved: UnresolvedItem[],
): ResolvedProsthesisAssignment {
// Targets are resolved BEFORE types, because the target kind is what narrows a spoken
// category. "پروتز متحرک برای دندون ۱۲" must offer only `partial_denture` — the one
// `removable` leaf with a tooth region — not the whole category. Deferring the region check
// until a leaf is picked (the earlier reading of decision 49) left nothing to complete it,
// and wrote a complete denture onto a single tooth.
const rawTargets = Array.isArray(assignment?.targets)
? assignment.targets
const overrides: ProsthesisIntent['overrides'] = Array.isArray(
intent.overrides,
)
? intent.overrides
: [];
const resolvedTargets: {
kind: 'tooth' | 'jaw';
spoken: string;
codes: string[];
}[] = [];
for (const rawTarget of rawTargets) {
const resolution = resolveAssignmentTarget(rawTarget);
const spoken = spokenOf(rawTarget);
if (resolution.kind === 'unresolved') {
unresolved.push(
resolution.candidates
? {
spoken,
reason: resolution.reason,
candidates: resolution.candidates,
assignmentIndex: index,
}
: { spoken, reason: resolution.reason, assignmentIndex: index },
);
for (const override of overrides) {
const tooth = resolveToothIntent(override?.tooth);
const type = resolveCatalogCode(override?.type, allowed);
const spoken = spokenOf(override?.tooth) || String(override?.type ?? '');
if (!tooth) {
unresolved.push({ spoken, reason: 'malformed' });
continue;
}
resolvedTargets.push(
resolution.kind === 'tooth'
? { kind: 'tooth', spoken, codes: [resolution.fdi] }
: { kind: 'jaw', spoken, codes: archSentinels(resolution.arch) },
);
}
// The regions any resolved target of this assignment can carry. Empty means no target
// resolved yet — a bare "نایت گارد" with no jaw — and then nothing can be narrowed or
// validated, so every leaf stays on offer.
const allowedRegions = new Set<string>();
for (const target of resolvedTargets) {
for (const region of acceptableRegions(target.kind))
allowedRegions.add(region);
}
const suits = (region: string) =>
allowedRegions.size === 0 || allowedRegions.has(region);
const rawTypes = Array.isArray(assignment?.types) ? assignment.types : [];
const leafCodes = new Set<string>();
const leafRegions = new Set<string>();
// Set when a named type cannot suit this assignment's targets at all. The targets are then
// dropped without a second report — one `code_not_valid_for_target` per contradiction, not
// one per target — so the assignment applies nothing rather than half of what was said.
let regionConflict = false;
for (const rawCode of rawTypes) {
const classification = classifyTypeCode(rawCode, ctx);
if (classification.kind === 'leaf') {
leafCodes.add(classification.code);
leafRegions.add(classification.chartRegion);
// A tooth we understood perfectly well but which is not part of this detail. Saying
// so is actionable ("add tooth 37, or drop it"); calling it malformed is not.
if (!selected.has(tooth)) {
unresolved.push({ spoken, reason: 'tooth_not_selected' });
continue;
}
if (
classification.kind === 'category' ||
classification.kind === 'subcategory'
) {
const leaves = classification.leafCodes.filter((code) => {
const leaf = ctx.prosthesisLeaves.find((l) => l.code === code);
return leaf ? suits(leaf.chartRegion) : false;
});
// A category with no leaf this target can carry is a contradiction, not an ambiguity:
// `implant` aimed at a jaw, or `appliance` aimed at a tooth.
if (leaves.length === 0) {
unresolved.push({
spoken: spokenOf(assignment) || classification.code,
reason: 'code_not_valid_for_target',
assignmentIndex: index,
});
regionConflict = true;
continue;
}
unresolved.push({
spoken: spokenOf(assignment) || classification.code,
reason: 'prosthesis_type_ambiguous',
candidates: [...leaves].sort(),
assignmentIndex: index,
});
if (!type) {
unresolved.push({ spoken, reason: 'unknown_catalog_code' });
continue;
}
if (typeof rawCode === 'string' && rawCode.trim()) {
unresolved.push({
spoken: rawCode,
reason: 'unknown_catalog_code',
assignmentIndex: index,
});
}
byTooth[tooth] = type;
}
const targets = new Set<string>();
for (const target of resolvedTargets) {
// EVERY named leaf must suit the target, not merely one of them. `some` let a legal
// crown carry an arch-only appliance onto the same tooth.
if (regionConflict) continue;
const accepted = acceptableRegions(target.kind);
const valid = [...leafRegions].every((region) => accepted.has(region));
if (!valid) {
unresolved.push({
spoken: target.spoken,
reason: 'code_not_valid_for_target',
assignmentIndex: index,
});
continue;
}
for (const code of target.codes) targets.add(code);
// Nothing usable was said about prosthesis. Returning an empty-but-present map would
// paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
if (Object.keys(byTooth).length === 0) {
return { prosthesis: null, unresolved };
}
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
return {
targets: [...targets],
types: [...leafCodes],
spoken: spokenOf(assignment),
prosthesis: {
byTooth,
complete: missingTeeth.length === 0,
missingTeeth,
},
unresolved,
};
}
/**
* A whole jaw is not a tooth, and the model often names one in `teeth` as well as in the
* prosthesis target it belongs to. An arch with no position then reaches `unresolvedReason`,
* which reports `position_out_of_range` — a range fault for a value that was never a number —
* and the sheet asks the clinician to repair a tooth nobody said. The jaw already reaches the
* form through its assignment, so drop the duplicate instead.
*
* A position that IS given stays: arch + position without a side is a real tooth described
* without its quadrant, and must keep offering its candidate chips.
*/
function isJawReference(intent: ToothIntent): boolean {
if (intent?.kind !== 'positional') return false;
const archGiven =
intent.arch === 'upper' ||
intent.arch === 'lower' ||
intent.arch === 'both';
return archGiven && !Number.isInteger(intent.position);
}
/** Compose every resolver into the payload the review sheet renders. */
export function resolveVoiceIntent(
intent: VoiceIntent,
@@ -507,9 +242,7 @@ export function resolveVoiceIntent(
): ResolvedExtraction {
const unresolved: UnresolvedItem[] = [];
const toothResult = resolveToothIntents(
(intent?.teeth ?? []).filter((tooth) => !isJawReference(tooth)),
);
const toothResult = resolveToothIntents(intent?.teeth ?? []);
unresolved.push(...toothResult.unresolved);
const spanResult = resolveConnectedSpans(
@@ -529,31 +262,18 @@ export function resolveVoiceIntent(
});
}
const rawAssignments = Array.isArray(intent?.prosthesis)
? intent.prosthesis
: [];
const prosthesisAssignments = rawAssignments.map((assignment, index) =>
resolveProsthesisAssignment(assignment, index, ctx, unresolved),
);
// `prosthesis` is the only labDependent treatment type — any assignment that actually
// landed on a target forces it, and the sheet locks that row while it is ticked (decision 41).
const hasResolvedAssignment = prosthesisAssignments.some(
(a) => a.targets.length > 0,
const prosthesisResult = resolveProsthesis(
intent?.prosthesis,
spanResult.teeth,
ctx.prosthesisTypeCodes,
);
unresolved.push(...prosthesisResult.unresolved);
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
if (due.unresolved) unresolved.push(due.unresolved);
// A note is written only when the clinician asked for one. The model reports the words that
// asked ("بنویس که", "write this in the notes"); without them, whatever it put in `comment`
// is leftover speech it decided was a note, and it is dropped. Matching the phrase itself
// stays in the prompt, so this check needs no per-locale vocabulary.
const commentAsked =
typeof intent?.commentTrigger === 'string' &&
intent.commentTrigger.trim().length > 0;
const comment =
commentAsked && typeof intent?.comment === 'string' && intent.comment.trim()
typeof intent?.comment === 'string' && intent.comment.trim()
? intent.comment.trim()
: null;
@@ -567,11 +287,11 @@ export function resolveVoiceIntent(
}
return {
treatmentType: hasResolvedAssignment ? 'prosthesis' : treatmentType,
treatmentType,
teeth: spanResult.teeth,
toothSelectionGroups: spanResult.groups,
comment,
prosthesisAssignments,
prosthesis: prosthesisResult.prosthesis,
labId,
labMatchExact: labId ? intent?.labMatchExact === true : false,
dueDate: due.dueDate,

View File

@@ -23,8 +23,8 @@ const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
teeth: [],
connectedSpans: [],
comment: null,
commentTrigger: null,
prosthesis: [],
prosthesisDefaultType: null,
prosthesisOverrides: [],
labId: null,
labMatchExact: false,
due: emptyDue,
@@ -177,70 +177,26 @@ describe('toVoiceIntent', () => {
expect(result.due).toEqual({ kind: 'lunar_month' });
});
it('yields an empty array, never null, when nothing was spoken about prosthesis', () => {
expect(toVoiceIntent(wire()).prosthesis).toEqual([]);
it('reports no prosthesis when neither a default nor an override was given', () => {
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
});
it('narrows a prosthesis assignment, targets and types alike', () => {
const result = toVoiceIntent(
wire({
prosthesis: [
{
targets: [{ ...positionalTooth, fdi: '12' }],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
},
],
}),
);
expect(result.prosthesis).toEqual([
{
targets: [{ kind: 'explicit', fdi: '12', spoken: 'شش بالا راست' }],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
},
]);
});
it('narrows a jaw target — arch given, position null', () => {
const result = toVoiceIntent(
wire({
prosthesis: [
{
targets: [
{
spoken: 'فک بالا',
fdi: null,
arch: 'both',
side: null,
position: null,
},
],
types: ['night_guard_soft'],
spoken: 'نایت گارد هر دو فک',
},
],
}),
);
expect(result.prosthesis[0].targets[0]).toEqual({
kind: 'positional',
arch: 'both',
side: null,
position: Number.NaN,
spoken: 'فک بالا',
it('builds a prosthesis intent from a default alone', () => {
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
expect(result.prosthesis).toEqual({
defaultType: 'pfm_crown',
overrides: [],
});
});
it('survives a malformed prosthesis entry', () => {
expect(() =>
toVoiceIntent(
wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }),
),
).not.toThrow();
it('builds a prosthesis intent from overrides alone', () => {
const result = toVoiceIntent(
wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }),
wire({
prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }],
}),
);
expect(result.prosthesis).toEqual([{ targets: [], types: [], spoken: '' }]);
expect(result.prosthesis?.defaultType).toBeNull();
expect(result.prosthesis?.overrides).toHaveLength(1);
});
it('narrows connected spans', () => {

View File

@@ -2,7 +2,7 @@ import { normalizeFdiCode } from '../../common/fdi';
import type {
ConnectedSpanIntent,
DueIntent,
ProsthesisAssignment,
ProsthesisIntent,
ToothIntent,
VoiceIntent,
Weekday,
@@ -20,7 +20,7 @@ export type WireToothIntent = {
spoken: string;
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
fdi: string | null;
arch: 'upper' | 'lower' | 'both' | null;
arch: 'upper' | 'lower' | null;
side: 'patient_right' | 'patient_left' | null;
position: number | null;
};
@@ -39,19 +39,13 @@ export type WireDue = {
d: number | null;
};
export type WireProsthesisAssignment = {
targets: WireToothIntent[];
types: string[];
spoken: string;
};
export type WireVoiceIntent = {
treatmentType: string | null;
teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null;
commentTrigger: string | null;
prosthesis: WireProsthesisAssignment[];
prosthesisDefaultType: string | null;
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
labId: string | null;
labMatchExact: boolean;
due: WireDue;
@@ -64,21 +58,15 @@ const TOOTH_SCHEMA = {
properties: {
spoken: {
type: 'string',
description: 'The exact transcript words for this tooth (or jaw).',
description: 'The exact transcript words for this tooth.',
},
fdi: {
type: ['string', 'null'],
description:
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
'when the tooth was described in words instead of numbered, or the target is a jaw.',
},
arch: {
type: ['string', 'null'],
enum: ['upper', 'lower', 'both', null],
description:
'"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' +
'both jaws), never for a single tooth.',
'when the tooth was described in words instead of numbered.',
},
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
side: {
type: ['string', 'null'],
enum: ['patient_right', 'patient_left', null],
@@ -87,33 +75,7 @@ const TOOTH_SCHEMA = {
position: {
type: ['integer', 'null'],
description:
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' +
'code. Null when this target is a jaw rather than a tooth.',
},
},
} as const;
const PROSTHESIS_ASSIGNMENT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['targets', 'types', 'spoken'],
properties: {
targets: {
type: 'array',
description: 'The teeth or jaws this instruction applies to.',
items: TOOTH_SCHEMA,
},
types: {
type: 'array',
description:
'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' +
'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' +
'fine when only the general term was said.',
items: { type: 'string' },
},
spoken: {
type: 'string',
description: 'The exact transcript words for this instruction.',
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
},
},
} as const;
@@ -126,8 +88,8 @@ export const VOICE_INTENT_JSON_SCHEMA = {
'teeth',
'connectedSpans',
'comment',
'commentTrigger',
'prosthesis',
'prosthesisDefaultType',
'prosthesisOverrides',
'labId',
'labMatchExact',
'due',
@@ -137,13 +99,7 @@ export const VOICE_INTENT_JSON_SCHEMA = {
type: ['string', 'null'],
description: 'A treatment type CODE from the supplied list, or null.',
},
teeth: {
type: 'array',
description:
'Individual teeth only. A whole jaw NEVER belongs here — put it in ' +
'prosthesis[].targets with "arch" set and "position" null.',
items: TOOTH_SCHEMA,
},
teeth: { type: 'array', items: TOOTH_SCHEMA },
connectedSpans: {
type: 'array',
description: 'Bridges / splinted units. Endpoints inclusive.',
@@ -156,24 +112,21 @@ export const VOICE_INTENT_JSON_SCHEMA = {
},
comment: {
type: ['string', 'null'],
description:
'The note the clinician explicitly dictated, in the spoken language, WITHOUT the ' +
'words that asked for it. Null unless they actually asked for a note. Never put ' +
'leftover speech here.',
description: 'Clinical notes, in the spoken language.',
},
commentTrigger: {
prosthesisDefaultType: {
type: ['string', 'null'],
description:
'The exact words that asked for a note, copied from the transcript (e.g. ' +
'"بنویس که", "write this in the notes"). Null when nobody asked. A comment with ' +
'no trigger is discarded.',
'A prosthesis type CODE applied to every tooth unless overridden.',
},
prosthesis: {
prosthesisOverrides: {
type: 'array',
description:
'One entry per spoken instruction: these targets get these jobs. No default and no ' +
'overrides — every entry names its own targets.',
items: PROSTHESIS_ASSIGNMENT_SCHEMA,
items: {
type: 'object',
additionalProperties: false,
required: ['tooth', 'type'],
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
},
},
labId: {
type: ['string', 'null'],
@@ -237,7 +190,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
}
return {
kind: 'positional',
arch: wire?.arch as 'upper' | 'lower' | 'both',
arch: wire?.arch as 'upper' | 'lower',
side: wire?.side as 'patient_right' | 'patient_left',
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
spoken,
@@ -283,35 +236,36 @@ function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
}
}
function toProsthesisAssignment(
wire: WireProsthesisAssignment | undefined | null,
): ProsthesisAssignment {
const targets = Array.isArray(wire?.targets) ? wire.targets : [];
const types = Array.isArray(wire?.types) ? wire.types : [];
return {
targets: targets.map(toToothIntent),
types: types.filter((code): code is string => typeof code === 'string'),
spoken: typeof wire?.spoken === 'string' ? wire.spoken : '',
};
}
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
const overrides = Array.isArray(wire?.prosthesisOverrides)
? wire.prosthesisOverrides
: [];
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
from: toToothIntent(span?.from),
to: toToothIntent(span?.to),
}));
const hasProsthesis =
wire?.prosthesisDefaultType != null || overrides.length > 0;
const prosthesis: ProsthesisIntent | null = hasProsthesis
? {
defaultType: wire?.prosthesisDefaultType ?? null,
overrides: overrides.map((o) => ({
tooth: toToothIntent(o?.tooth),
type: o?.type,
})),
}
: null;
return {
treatmentType: wire?.treatmentType ?? null,
teeth: teeth.map(toToothIntent),
connectedSpans,
comment: wire?.comment ?? null,
commentTrigger: wire?.commentTrigger ?? null,
prosthesis: assignments.map(toProsthesisAssignment),
prosthesis,
labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true,
due: toDueIntent(wire?.due),

View File

@@ -12,18 +12,7 @@ const CONFIG = {
const CATALOG = {
treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }],
prosthesisTypes: [
{
code: 'pfm_crown',
label: 'روکش پی‌اف‌ام',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
],
prosthesisCategories: [{ code: 'crown', label: 'روکش‌ها' }],
prosthesisSubcategories: [],
prosthesisTypes: [{ code: 'pfm_crown', label: 'روکش پی‌اف‌ام' }],
labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }],
};
@@ -122,21 +111,8 @@ describe('OpenRouterExtractionProvider', () => {
],
connectedSpans: [],
comment: null,
prosthesis: [
{
targets: [
{
spoken: 'یک چهار',
fdi: '14',
arch: null,
side: null,
position: null,
},
],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام روی ۱۴',
},
],
prosthesisDefaultType: 'pfm_crown',
prosthesisOverrides: [],
labId: 'lab-1',
labMatchExact: true,
due: {

View File

@@ -34,22 +34,7 @@ export interface AsrProvider {
export type ExtractionCatalog = {
/** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */
treatmentTypes: { code: string; label: string }[];
/**
* Leaf prosthesis types — the full tree shape, not just code/label, so the prompt can
* present it as a tree and mark which codes are jaw-level (`chartRegion: 'arch'`).
*/
prosthesisTypes: {
code: string;
label: string;
category: string;
subcategory: string;
chartRegion: string;
stackGroup: string;
}[];
/** The 7 category codes, so "روکش" resolves to `crown` rather than a guessed leaf. */
prosthesisCategories: { code: string; label: string }[];
/** The 5 subcategory codes (veneer, inlay, onlay, overlay, night_guard). */
prosthesisSubcategories: { code: string; label: string }[];
prosthesisTypes: { code: string; label: string }[];
/** The clinic's linked labs — a closed choice list. */
labs: { id: string; name: string }[];
};

View File

@@ -35,12 +35,9 @@ export type VoiceAvailability = {
maxRecordingMs: number | null;
};
/**
* The transcript is deliberately absent. A raw dictation can carry the patient's spoken name, so
* it never leaves the server — it is logged there instead (§10). Nothing the client renders needs
* it, and what is not sent cannot leak through the network tab or an error reporter.
*/
export type VoiceExtractionResponse = ResolvedExtraction;
export type VoiceExtractionResponse = ResolvedExtraction & {
transcript: string;
};
@Injectable()
export class VoiceService {
@@ -122,20 +119,13 @@ export class VoiceService {
);
}
// The transcript's only destination. Logged before extraction so it survives an extraction
// failure too, and kept out of logTelemetry so that method's patient-free guarantee stays
// true. This line DOES carry what the clinician said, which may include a patient's name.
this.logger.log(
`voice transcript [${catalogLocale}]: ${transcript.trim()}`,
);
// Stage 2 — structure it. A failure here returns a code only; the transcript stays in the
// server log above, never in the response.
// Stage 2 — structure it. On failure the transcript still goes back to the client so
// the words the clinician already paid for are not lost (transcript salvage).
let resolved: ResolvedExtraction;
let llmCost: number | null = null;
try {
// Inside the try: a catalog/DB failure here must surface as VOICE_EXTRACT_FAILED, which
// the clinician can act on, rather than a generic 500.
// Inside the try: the transcript is already paid for, so a catalog/DB failure here
// must still salvage it rather than becoming a generic 500 that throws it away.
const catalog = await this.buildCatalog(organizationId, catalogLocale);
const result = await extraction.extract(
transcript,
@@ -148,17 +138,13 @@ export class VoiceService {
todayIso,
weekStartJs: weekStartForLocale(catalogLocale),
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
prosthesisLeaves: catalog.prosthesisTypes,
prosthesisCategoryCodes: new Set(
catalog.prosthesisCategories.map((c) => c.code),
),
prosthesisSubcategoryCodes: new Set(
catalog.prosthesisSubcategories.map((c) => c.code),
prosthesisTypeCodes: new Set(
catalog.prosthesisTypes.map((t) => t.code),
),
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
});
} catch (error) {
throw this.toAppException(error, 'extraction');
throw this.toAppException(error, 'extraction', transcript);
}
this.logTelemetry({
@@ -170,7 +156,7 @@ export class VoiceService {
resolved,
});
return resolved;
return { ...resolved, transcript };
}
private assertOrganization(user: { organizationId?: string }): string {
@@ -258,17 +244,9 @@ export class VoiceService {
organizationId: string,
locale: string,
): Promise<ExtractionCatalog> {
const [
treatmentTypes,
prosthesisTypes,
prosthesisCategories,
prosthesisSubcategories,
labs,
] = await Promise.all([
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
this.treatmentCatalog.list(locale, null),
this.prosthesisCatalog.list(locale),
this.prosthesisCatalog.listCategories(locale),
this.prosthesisCatalog.listSubcategories(locale),
this.listLinkedLabs(organizationId),
]);
@@ -276,11 +254,10 @@ export class VoiceService {
treatmentTypes: treatmentTypes
.filter((entry) => entry.availableInTreatment)
.map((entry) => ({ code: entry.code, label: entry.label })),
// `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here —
// the prompt now presents the catalog as the tree it is (§5).
prosthesisTypes,
prosthesisCategories,
prosthesisSubcategories,
prosthesisTypes: prosthesisTypes.map((entry) => ({
code: entry.code,
label: entry.label,
})),
labs,
};
}
@@ -313,6 +290,7 @@ export class VoiceService {
private toAppException(
error: unknown,
stage: 'asr' | 'extraction',
transcript?: string,
): AppException {
if (error instanceof Error && error.name === 'AbortError') {
// The clinician cancelled; not a failure worth a translated message.
@@ -327,9 +305,11 @@ export class VoiceService {
stage === 'asr'
? ErrorCode.VOICE_ASR_FAILED
: ErrorCode.VOICE_EXTRACT_FAILED;
// No details: the transcript used to ride along here for a salvage dialog that was never
// built, so it was serialized onto the wire and dropped. It stays on the server now.
return new AppException(code, HttpStatus.BAD_GATEWAY);
return new AppException(
code,
HttpStatus.BAD_GATEWAY,
transcript ? { transcript } : undefined,
);
}
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
@@ -353,7 +333,7 @@ export class VoiceService {
treatmentType: resolved.treatmentType != null,
teeth: resolved.teeth.length,
comment: resolved.comment != null,
prosthesisAssignments: resolved.prosthesisAssignments.length,
prosthesisComplete: resolved.prosthesis?.complete ?? null,
lab: resolved.labId != null,
dueDate: resolved.dueDate != null,
},

View File

@@ -8,19 +8,14 @@ import type { Arch, PatientSide } from '../../common/fdi';
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
*/
/**
* A single spoken tooth reference, OR — inside a prosthesis assignment's `targets` — a jaw.
* Nothing on the wire declares "this is a jaw": a positional reference with no `position` at
* all names an arch instead of a tooth, and the resolver tells the two apart (§5, §6).
* `arch: 'both'` only ever appears here, never on a resolved single tooth.
*/
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
export type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string }
| {
kind: 'positional';
arch: Arch | 'both';
arch: Arch;
side: PatientSide;
/** 1 = central incisor … 8 = third molar. Absent (NaN) when the target is a jaw. */
/** 1 = central incisor … 8 = third molar. */
position: number;
spoken: string;
};
@@ -47,18 +42,10 @@ export type Weekday = (typeof WEEKDAYS)[number];
/** Two teeth defining an inclusive connected (bridge) span. */
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
/**
* One spoken instruction: these targets get these jobs. Replaces the old
* `prosthesisDefaultType` + `prosthesisOverrides` pair — a default with per-tooth overrides
* has a precedence rule, and a precedence rule has a wrong side (decision 35).
*/
export type ProsthesisAssignment = {
/** Each one a tooth or a jaw — see `ToothIntent`. */
targets: ToothIntent[];
/** Leaf codes, or one category / subcategory code the clinician named generically. */
types: string[];
/** The transcript span, echoed back to the clinician. */
spoken: string;
export type ProsthesisIntent = {
/** Catalog code applied to every tooth unless overridden. */
defaultType: string | null;
overrides: { tooth: ToothIntent; type: string }[];
};
export type VoiceIntent = {
@@ -66,14 +53,7 @@ export type VoiceIntent = {
teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[];
comment: string | null;
/**
* The exact spoken words that asked for a note — "بنویس که", "write this in the notes".
* Null when nothing asked. The resolver keeps `comment` only when this is present, so the
* model cannot decide on its own that leftover speech was a note.
*/
commentTrigger: string | null;
/** Empty array, never null. */
prosthesis: ProsthesisAssignment[];
prosthesis: ProsthesisIntent | null;
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
labId: string | null;
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
@@ -87,15 +67,10 @@ export type UnresolvedReason =
| 'position_out_of_range'
/** A position was understood but no quadrant was spoken — four teeth match. */
| 'tooth_missing_quadrant'
/** A category or subcategory was heard, not a material. */
| 'prosthesis_type_ambiguous'
/** A jaw-level appliance with no jaw spoken. */
| 'arch_not_spoken'
/** An arch code aimed at a tooth, or a tooth code aimed at a jaw. */
| 'code_not_valid_for_target'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export type UnresolvedItem = {
@@ -103,15 +78,8 @@ export type UnresolvedItem = {
spoken: string;
reason: UnresolvedReason;
/**
* Values still consistent with what was heard: FDI codes for `tooth_missing_quadrant`,
* leaf codes for `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. The
* sheet offers these as chips.
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
*/
candidates?: string[];
/**
* Set only when this item was raised while resolving a `prosthesis` assignment's targets or
* types. A picked chip then inherits that assignment's `types[]` (or supplies the missing
* leaf to it) instead of resolving to a jobless tooth (decision 50).
*/
assignmentIndex?: number;
};

View File

@@ -1,532 +0,0 @@
---
type: task-progress
status: active
repos:
- repo: dyolink
path: ~/PersonalProjects/dyolink
branch: feat/treatment/add-voice-input-for-new-treatment-form
base: origin/master
role: extraction contract + resolvers + review sheet
spec_slug: voice-treatment-entry
merge_after: []
---
# PROGRESS — Voice Treatment Entry
Spec: [spec.md](./spec.md)
Adapting the merged voice feature to the overhauled prosthesis model (spec §5§7,
decisions 3446). v1 shipped on `master`; this branch revises it.
Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
| # | Work item | Repo | Status | Notes / refs |
|---|-----------|------|--------|--------------|
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | ✅ | §9. `frontend/src/lib/voice/audioFormat.ts` — the final `return null` after the loop is now `return ''` |
| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | ✅ | §2 render policy, `TreatmentWorkspace.tsx` |
| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | ✅ | §12. `vitest@3.2.7`, `frontend/vitest.config.ts`, `npm run test` script; `CLAUDE.md` Tests section rewritten |
| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | ✅ | §5. `voice.service.ts` now forwards the full `ProsthesisTypeCatalogEntry[]` plus category/subcategory lists |
| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | ✅ | §5, `extraction.wire.ts``prosthesis: WireProsthesisAssignment[]` |
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | ✅ | §5, §6, `extraction.resolver.ts``resolveProsthesisAssignment`, `resolveAssignmentTarget`, `classifyTypeCode` |
| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | ✅ | §6 reason table — `voice.types.ts` |
| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | ✅ | §5. `resolveVoiceIntent``hasResolvedAssignment` |
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | ✅ | §5, `extraction.prompt.ts``prosthesisTree()` renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge |
| 10 | Backend Jest suites for items 68, including the namespace-disjointness assertion | `dyolink` | ✅ | §12. `extraction.resolver.spec.ts` — disjointness test reads `catalog-seed-data.ts`'s live `PROSTHESIS_TYPES`, not a written count |
| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | ✅ | `types/voice.ts``VoiceProsthesisAssignment[]`, `assignmentIndex` |
| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | ✅ | §6, §7 — `prosthesisTargetLines`, `joblessProsthesisTargets`, `withChosenArch`/`withChosenProsthesisLeaf` |
| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | ✅ | §7. Merged "Teeth and prosthesis" row; tooth / jaw / material chip kinds |
| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | ✅ | §6, §7 — via `prosthesisTargetLines`, which routes every stack through `applyLeafToJobs` |
| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | ✅ | §12. 37 tests total, `npx vitest run` green |
| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | ✅ | i18n is mandatory, not a follow-up — new `voiceUnresolved.*` reasons, `voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused`; retired `voiceProsthesisIncomplete` (all-or-nothing gone) |
| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | 🟡 | Every machine gate green, migration and seed now applied and verified (2026-09-07 entry below). **Manual pass still not run** — Safari, iPad, and the stack / jaw / chip flows in `fa` |
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | ✅ | §5, decision 47. Applied via `migrate deploy` and seeded: enum carries both kinds, 7 categories + 5 subcategories per locale, `crown``روکش‌ها` |
| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | ✅ | §6, decision 50. `UnresolvedItem.assignmentIndex`; `VoiceReviewSheet` chips inherit via `withChosenTeeth(...,index)` / `withChosenArch` / `withChosenProsthesisLeaf` |
## Key decisions
Full table in the spec's §13, rows 3446. The load-bearing ones:
- **One assignment list, no default type.** A default plus per-tooth overrides has a
precedence rule, and that rule already mis-filled tooth 13 once.
- **An arch target is derived from the code's `chartRegion`, not declared on the wire.**
The catalog already answers it, and `partial_denture` already carries `chartRegion: 'crown'`.
- **Stack rules stay frontend-only.** The backend's dispatch check knows nothing about
stacking; porting `canStackLeaf` would make voice stricter than the manual path and create
a second copy of a rule that must never disagree.
- **Teeth and prosthesis merge into one sheet row for lab-dependent types.** Two ticks can
save an empty detail today, because `persistDraft` prunes lab-dependent details to their jobs.
- **Nothing is guessed.** A missing material, a missing jaw and a missing quadrant all become
chips. A jobless tooth is named and left out.
- **Categories become real catalog entities** (decision 47). Two new `CatalogEntityKind` values
and seeded translations, rather than bare untranslated codes in the prompt on `fa`.
- **A chip must inherit its assignment's jobs** (decision 50), or it resolves to a jobless tooth
and the tap does nothing.
## Deviations from spec
(none yet)
## Next steps / open questions
- Re-run `/orchestrate --dry` with a `gapNote`, so the surveyor re-runs instead of replaying its
cached `gaps_found` verdict.
- Amin reviews and approves this spec revision. That approval is the only human gate before
`/orchestrate --dry`.
- Open items 15 and 16 in §11 are carried forward unchanged and are **not** in this branch:
the pre-authentication body limit on the voice path, and transcript salvage still being
specified but not built.
- `feat/voice-treatment-entry` and `backup/pre-rebase-voice` are stale local branches from the
v1 work. Safe to delete once this lands.
---
## 2026-09-07 — Gap-check halted (nothing built, nothing pushed)
- **Phase:** Gap-check
- **Reason:** the spec has 1 gap that blocks implementation — nothing was built.
- **Repo:** dyolink @ `feat/treatment/add-voice-input-for-new-treatment-form`
### Blocking gap 1 — `missing_constraint` (dyolink)
**Summary:** The prompt must present prosthesis categories and subcategories so the model can
return `crown` for "روکش", but no localized label for a category or subcategory exists anywhere
the backend can read. `CatalogEntityKind` has only TREATMENT_TYPE / PROSTHESIS_TYPE /
LAB_WORKFLOW_STEP, and the only category labels in the repo are frontend message keys
(`category_crown`, `sub_night_guard`) plus a hardcoded single-locale map in a dev export script.
**Spec quotes:**
> **The prosthesis list is no longer flat.** `buildCatalog` already receives `category`, `subcategory`, `chartRegion` and `stackGroup` from `ProsthesisCatalogService` and throws all four away. It now passes them through, so the prompt presents the catalog as the tree it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the prompt — the shape comes from the data.
> Treatment types and prosthesis types come from `CatalogLabelService` in the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of `prosthesis` and `monolithic_zirconia` rather than being asked to translate. Catalog entities store a stable `code` and no label — never hardcode a label.
> "روکش" is *crown* — a category with nine leaves, not a material. The model returns the category code it actually heard rather than guessing `pfm_crown`, and the review sheet offers the leaves as chips (§7).
**Why blocking:** Work item 9 ("Prompt: present the catalog as a tree") cannot be built.
`ProsthesisCatalogService.list()` returns `category` and `subcategory` as bare codes with no label
(prosthesis-catalog.service.ts:82-95), and `CatalogLabelService` cannot resolve them because
`CatalogEntityKind` has no category kind (schema.prisma:331-335). The three ways out have
different costs and one breaks an explicit repo rule, so a builder cannot choose: (a) add
PROSTHESIS_CATEGORY / PROSTHESIS_SUBCATEGORY to the enum plus a migration and seeded translations
— a schema change the spec never mentions; (b) hardcode a fa/en/nl label map in the backend, which
the spec and CLAUDE.md both forbid; (c) send bare codes only, which makes the whole category
feature unmeasured on the weakest locale.
**Question:** Where do the localized labels for the 7 prosthesis categories and 5 subcategories
come from for the extraction prompt: a new `CatalogEntityKind` (PROSTHESIS_CATEGORY /
PROSTHESIS_SUBCATEGORY) with a migration and seeded `CatalogTranslation` rows, or bare codes with
no labels in the prompt?
**Suggested spec change:** Add to §5: "Category and subcategory labels have no backend source
today — `CatalogEntityKind` covers only TREATMENT_TYPE, PROSTHESIS_TYPE and LAB_WORKFLOW_STEP, and
`category_*` / `sub_*` exist only in `frontend/messages/*.json`. Add `PROSTHESIS_CATEGORY` and
`PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind` (one migration), seed their fa/en/nl translations
from the existing message keys, and resolve them through `CatalogLabelService` like every other
catalog label."
**Evidence:** backend/prisma/schema.prisma:331; backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:82; frontend/messages/en.json:1124; backend/prisma/export-prosthesis-catalog.ts:16
### Non-blocking notes
**Note 1 — `ambiguity` (dyolink).** An unresolved item carries only `spoken`, `reason` and
`candidates`, with no link back to the assignment it came from. On a prosthesis detail a
`tooth_missing_quadrant` chip therefore resolves to a tooth with no job, which the "tooth with no
job is named and left out" rule then discards — so tapping the chip does nothing.
> **A single digit alone is never resolved.** "دندون دو" names four teeth. It is reported as `tooth_missing_quadrant` **with the candidate codes attached**
> **A tooth with no job is named and left out.**
> Picking one folds the choice into the result, so an under-specified item is one tap from resolved instead of a dead end.
*Why not blocking:* a builder can add an assignment reference to the unresolved item. But §6's
reason table reads as a complete contract and its "Carries" column names only `candidates`, so a
builder following it literally ships a dead chip on exactly the prosthesis flow §12 asks them to
test.
*Question:* Should `VoiceUnresolvedItem` gain an assignment reference (for example
`assignmentIndex`) so a chosen quadrant chip inherits that assignment's job stack?
*Suggested spec change:* In §6's reason table, add: "`tooth_missing_quadrant` and
`arch_not_spoken` also carry the index of the assignment they came from, so a picked chip inherits
that assignment's `types[]` rather than becoming a jobless tooth."
*Evidence:* frontend/src/types/voice.ts:15-25; frontend/src/components/treatment/voiceReviewRows.ts:66
**Note 2 — `ambiguity` (dyolink).** "The resolver confirms [jaw vs tooth] against the
`chartRegion` of the assignment's codes" is undefined when `types[]` holds a category whose leaves
have mixed regions. `removable` is exactly that: `complete_denture` and `overdenture` are
`chartRegion: 'arch'`, `partial_denture` is `'crown'`.
> A target that names an arch and no position is a jaw, and the resolver confirms that against the `chartRegion` of the assignment's codes.
> `partial_denture` — a `removable` code that is nonetheless assigned per tooth — already ships with `chartRegion: 'crown'` in `catalog-seed-data.ts`, so the exception needs no special case either.
> A code whose region contradicts its target is reported, never coerced.
*Why not blocking:* deferring the region check until the ambiguity chip is picked is a reasonable
default. Worth stating so the builder does not emit `code_not_valid_for_target` for a category that
has not been narrowed yet.
*Question:* When `types[]` holds a category whose leaves span both `crown` and `arch` regions (only
`removable` today), is the region check deferred until the clinician picks a leaf chip?
*Suggested spec change:* In §5 under "A target is a tooth or a jaw": "When `types[]` holds a
category whose leaves have more than one `chartRegion`, the region check is deferred — the item
resolves to `prosthesis_type_ambiguous` and the region is confirmed against the leaf the clinician
picks."
*Evidence:* backend/prisma/catalog-seed-data.ts:266-288
**Note 3 — `brownfield` (dyolink).** §3 names the endpoint `POST /treatments/voice-extract`; the
shipped route is `POST /voice/extract` (`@Controller('voice')` + `@Post('extract')`), and
`common/body-parsers.ts` hardcodes `/api/voice/extract` to select the 10 MB body limit.
> `POST /treatments/voice-extract`
*Why not blocking:* no work item asks to move the route. But if a builder aligned the route with
§3, `isVoiceExtractPath` would stop matching and every real recording would 413 against the 100 kb
default, which reads as a broken microphone rather than a route change.
*Question:* Should §3 be corrected to the shipped path `POST /voice/extract`, since no work item
moves the route?
*Suggested spec change:* Change §3's heading to `POST /voice/extract` and note that
`VOICE_EXTRACT_PATH` in `common/body-parsers.ts` must move with it if it ever changes.
*Evidence:* backend/src/modules/voice/voice.controller.ts:23,36; backend/src/common/body-parsers.ts:10
**Note 4 — `contradiction` (dyolink).** §11 item 13 says v1 ships with "no availability endpoint",
but §4 requires the frontend to learn enabled locales from the API, and `GET /voice/availability`
exists and is what `voiceForEditor` reads today.
> ~~**Availability API**~~ — **resolved for v1:** voice ships **open to everyone** with a configured locale profile. No plan check, no availability endpoint.
> The frontend must learn which locales are enabled **from the API**, not from a `NEXT_PUBLIC_*` var
*Why not blocking:* work item 2 ("`voiceForEditor` also checks `isMediaRecorderSupported()`")
presupposes `voiceAvailability` stays, so the working reading is obvious. The stale line just needs
correcting so nobody deletes a live endpoint.
*Question:* Confirm the availability endpoint stays in v1 and only the plan check is deferred?
*Suggested spec change:* Reword item 13 to "no plan check; the availability endpoint ships and
reports configured locales and `maxRecordingMs` only."
*Evidence:* backend/src/modules/voice/voice.controller.ts:28; frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625
**Note 5 — `brownfield` (dyolink).** §5's disjointness counts are slightly off: the seed defines 41
distinct prosthesis codes (not 42) and 5 subcategory names — veneer, inlay, onlay, overlay,
night_guard (not 4). The disjointness claim itself holds: no leaf code equals any category or
subcategory name.
> This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 category names and 4 subcategory names, no collisions.
*Why not blocking:* the test asserts disjointness, not counts, and disjointness is true against the
current seed. Flagged so the builder writes the test against the live catalog rather than a
hardcoded 42/7/4.
*Question:* May the disjointness test assert against the live catalog instead of the counts written
in §5?
*Suggested spec change:* Drop the exact counts and keep only "the leaf, category and subcategory
namespaces are disjoint; a test asserts it against the live catalog".
*Evidence:* backend/prisma/catalog-seed-data.ts:121-400
### Files read during the gap-check
`docs/specs/voice-treatment-entry/spec.md`, `docs/specs/voice-treatment-entry/progress.md`,
`backend/src/modules/voice/extraction.prompt.ts`, `backend/src/modules/voice/extraction.wire.ts`,
`backend/src/modules/voice/voice.providers.ts`, `backend/src/modules/voice/voice.service.ts:243`,
`backend/src/modules/voice/voice.controller.ts:23`,
`backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:72`,
`backend/prisma/catalog-seed-data.ts`, `backend/prisma/schema.prisma:331`,
`backend/prisma/export-prosthesis-catalog.ts:16`, `backend/src/common/fdi.ts`,
`backend/src/common/body-parsers.ts:10`,
`backend/src/modules/treatments/lab-case-send.validation.ts:116`,
`frontend/src/components/treatment/prosthesisTree.ts`,
`frontend/src/components/treatment/voiceReviewRows.ts`, `frontend/src/types/voice.ts`,
`frontend/src/lib/voice/audioFormat.ts`, `frontend/src/lib/voice/useVoiceCapture.ts:157`,
`frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625` and `:2184`,
`frontend/src/components/ui/treatment/FdiToothChart.tsx:218`,
`frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx:200`,
`frontend/messages/en.json:1124`, `frontend/package.json`
(all paths relative to `/Users/aminmsvi/PersonalProjects/dyolink`)
### How to resume
Close the gaps in the spec, then re-run. Pass `gapNote` describing what you changed, or the
analyst replays this same verdict from cache.
---
## 2026-09-07 — Gaps closed, ready to re-run
All five findings are now answered in the spec. Nothing was built in the halted run, so there is
no code to revisit.
| Finding | Resolution | Spec |
|---|---|---|
| **Blocking** — no backend source for category labels | Add `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind`, one migration, seed fa/en/nl from the existing `prosthesis.category_*` / `sub_*` keys, resolve via `CatalogLabelService`. Frontend keeps its own keys — migrating it is out of scope | §5, decisions 4748 |
| Note 1 — chip with no assignment link | Unresolved items raised inside an assignment carry `assignmentIndex`; a picked chip inherits that assignment's `types[]` | §6, decision 50 |
| Note 2 — mixed-region category | `removable` defers its region check to the picked leaf; never `code_not_valid_for_target` while still a category | §5, decision 49 |
| Note 3 — wrong endpoint path in §3 | Corrected to `POST /voice/extract`, with the `body-parsers.ts` coupling called out. No work item moves the route | §3 |
| Note 4 — availability endpoint contradiction | §11 item 13 corrected: the endpoint exists, only the plan check is deferred | §11 |
| Note 5 — disjointness counts | Verified independently: **42** leaf codes (the surveyor's 41 missed `screw_retained`, a multi-line `implant(` call) and **5** subcategories (the spec said 4, missing `night_guard`). The test now asserts against the live catalog, not a written count | §5, §12 |
Referee relays: 0. Gate repairs: 0. Neither phase was reached.
---
## 2026-09-07 — Implementation (dyolink, extraction contract + resolvers + review sheet)
All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.
### Backend
- `backend/prisma/schema.prisma``PROSTHESIS_CATEGORY`, `PROSTHESIS_SUBCATEGORY` added to
`CatalogEntityKind` (additive, no data loss).
- `backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql`
hand-written `ALTER TYPE ... ADD VALUE` migration, following the exact pattern of the repo's
one precedent (`20260718180000_lab_case_activity_task_assigned`). **Not applied** — see
"Not run" below.
- `backend/prisma/catalog-seed-data.ts``PROSTHESIS_CATEGORY_LABELS` (7) and
`PROSTHESIS_SUBCATEGORY_LABELS` (5), worded from the frontend's existing `category_*`/`sub_*`
message keys (decision 47/48), folded into `CATALOG_TRANSLATIONS`.
- `backend/src/common/fdi.ts``ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` sentinels, mirroring the
frontend's `prosthesisTree.ts` convention so both sides speak the same jaw target.
- `backend/src/modules/voice/voice.types.ts``ProsthesisAssignment` replaces
`ProsthesisIntent` (default+overrides retired, decision 35); `UnresolvedReason` gains
`prosthesis_type_ambiguous`, `arch_not_spoken`, `code_not_valid_for_target`, loses
`tooth_not_selected`; `UnresolvedItem.assignmentIndex` added (decision 50).
- `backend/src/modules/voice/extraction.wire.ts` — wire schema carries `prosthesis:
WireProsthesisAssignment[]`; `WireToothIntent.arch` gains `'both'`.
- `backend/src/modules/voice/extraction.resolver.ts` — rewritten: `resolveAssignmentTarget`
(tooth vs. jaw, mirrors the old `tooth_missing_quadrant`/new `arch_not_spoken` split),
`classifyTypeCode` (leaf / category / subcategory, disjoint namespaces), and
`resolveProsthesisAssignment` composing both plus the region-validity check (deferred for a
mixed-region category — `removable` today, decision 49 — computed generically from the
catalog's own chart regions rather than hardcoding the category name). `resolveVoiceIntent`
forces `treatmentType` to `prosthesis` when any assignment resolves a target (decision 41).
`resolveProsthesis`/`ResolvedProsthesis` retired outright.
- `backend/src/modules/voice/extraction.prompt.ts` — `prosthesisTree()` renders the catalog as
CATEGORY → (SUBCATEGORY →) leaf from the data `buildCatalog` supplies; no catalog knowledge
hardcoded in the prompt text itself.
- `backend/src/modules/voice/voice.providers.ts`, `voice.service.ts` — `ExtractionCatalog`
carries the full leaf shape plus `prosthesisCategories`/`prosthesisSubcategories`;
`buildCatalog` no longer strips `category`/`subcategory`/`chartRegion`/`stackGroup`.
- `backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts` —
`listCategories()`/`listSubcategories()`, resolved through `CatalogLabelService` like every
other catalog label.
- Jest: `extraction.resolver.spec.ts` rewritten around `resolveProsthesisAssignment` (stacks,
jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the
live `PROSTHESIS_TYPES`, region validity both ways, the `removable` deferral, assignment-index
attribution); `extraction.wire.spec.ts` and `openrouter.provider.spec.ts` updated for the new
wire shape. 209 backend tests pass (121 in `modules/voice`).
### Frontend
- `frontend/src/lib/voice/audioFormat.ts` — `pickRecordingMimeType`'s final fallback is now `''`
instead of `null` (item 1's Safari fix — modern Safari's `isTypeSupported` can reject every
preferred container yet still record when let choose).
- `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx` — `voiceForEditor` now also
requires `isMediaRecorderSupported()`; `applyVoiceResult` rewritten: teeth vs. prosthesis are
mutually exclusive per `isLabDependentResult`, the prosthesis stack is built through
`prosthesisTargetLines` (which routes every leaf through `applyLeafToJobs`), and jaw targets
write `LabCaseToothProsthesisDraft` rows keyed on the `UA`/`LA` sentinels — no separate
arch-specific code path needed beyond what `prosthesisTree.ts` already provides.
- `frontend/src/types/voice.ts` — `VoiceProsthesisAssignment[]` replaces the byTooth map;
`VoiceUnresolvedItem.assignmentIndex`.
- `frontend/src/components/treatment/voiceReviewRows.ts` — rewritten: `isLabDependentResult`,
merged-row `voiceRowAvailability`, `withChosenArch`/`withChosenProsthesisLeaf` (decision 50),
`prosthesisTargetLines` (previews the stack via `applyLeafToJobs`, names refused jobs),
`joblessProsthesisTargets` (decision 40 — named, struck through, never silently dropped or
silently applied), `prosthesisChartData` (crown/root/arch tints for the merged row's chart).
- `frontend/src/components/ui/treatment/VoiceReviewSheet.tsx` — merged "Teeth and prosthesis"
row for a labDependent type; three independent candidate-chip kinds (tooth, jaw, leaf) each
folding through their own `voiceReviewRows` helper; `voiceProsthesisIncomplete` warning
removed (all-or-nothing retired).
- i18n: `en.json`/`fa.json`/`nl.json` — `voiceUnresolved.*` updated for the new/retired reasons,
`voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused` added,
`voiceProsthesisIncomplete` removed.
- `frontend/package.json`, `frontend/vitest.config.ts` — `vitest@3.2.7` (pinned to a version
whose peer `@types/node` range still includes the repo's `^20`; vitest 4/5 require `>=22`),
`npm run test` → `vitest run`, alias-only config (`@` → `src/`).
- `frontend/src/components/treatment/prosthesisTree.spec.ts`,
`voiceReviewRows.spec.ts` — 37 Vitest cases covering stack legality, `applyLeafToJobs`
precedence, `toothRegionColors`, row availability, chip folding, the merged-row preview, and
the jobless/pending distinction.
- `CLAUDE.md` — Tests section rewritten; frontend command table gains `npx vitest run`.
### Verification run
- `cd backend && npm test` — 209/209 pass (121 in `modules/voice`).
- `cd backend && npm run build` — clean (after `npm install`, which pulled in `@sentry/nestjs`
that `node_modules` was missing — unrelated to this change, pre-existing on this checkout).
- `cd backend && npx prisma generate` — succeeds against the updated schema (no DB needed);
confirms `CatalogEntityKind.PROSTHESIS_CATEGORY`/`PROSTHESIS_SUBCATEGORY` compile everywhere
they're used.
- `cd frontend && npx tsc --noEmit` — clean.
- `cd frontend && npx vitest run` — 37/37 pass.
- `cd frontend && npm run build` — production build succeeds.
- ESLint on every touched file — 0 errors, 0 new warnings (pre-existing warnings elsewhere in
`TreatmentWorkspace.tsx`, unrelated to this change, left untouched).
### Not run (environment limitation, not a design gap)
- `npm run prisma:migrate && npm run prisma:seed` against a live Postgres — this sandbox has no
running Docker daemon (`docker info` never came up after several minutes and `open -a Docker`
did not launch it), so the migration was never applied to a database and the new
`CatalogTranslation` rows were never seeded. The migration SQL and seed data are written and
reviewed against the one existing precedent in this repo; **run both before merging**.
- The full manual pass in §12 (Safari/iPad recording, live extraction against the real OpenRouter
API, the specific stack/jaw/ambiguity scenarios) — needs a browser and a live backend, neither
available in this session.
### Deviations from spec / judgement calls made while implementing
- **A target with empty `types[]` still resolves as a target**, with `types: []` on its
assignment — not excluded from the assignment's `targets` array. The sheet (frontend) treats
`types.length === 0` with no matching `prosthesis_type_ambiguous` unresolved item as "jobless,
struck through" (decision 40), and the same empty-types-plus-ambiguous-item combination as
"pending a material pick" instead. This keeps the wire contract simple (no extra field) at the
cost of the frontend doing that one bit of inference from `unresolved` — documented in both
`extraction.resolver.ts` and `voiceReviewRows.ts`.
- **The mixed-region deferral (decision 49) is computed generically** from each category's
actual leaf chart-regions (`regions.size === 1` → validate immediately, else defer) rather than
special-cased for `removable` by name. This also defers `implant` (which spans `root` and
`crown` via `screw_retained`) — the spec's prose says "only `removable` today" as an
observation about the current catalog, not an instruction to hardcode that name, and deferring
a category no test forbids deferring is the safer default.
- **`voice.dto.ts`/`voice.controller.ts` needed no changes.** The scout's item 6 ("validation for
new schema shape") does not apply: the DTO validates the client's audio submission
(`audio`/`format`/`timeZone`/`durationMs`/`locale`), which is unrelated to the LLM's structured
output shape that changed. Confirmed by reading both files; not a silent skip.
---
## 2026-09-07 — Re-run reached Ship; stopped, and the challenge phase is incomplete
Recorded by hand: the `record-stop:Ship` agent failed on the session spend limit before it
could write this.
**Stop reason:** `no MR template found in dyolink — refusing to invent a description.` Expected
and flagged at preflight: `.gitea/` holds only `workflows/`, and the remote is Gitea, not GitLab.
A `--dry` run pushes nothing regardless.
| Phase | Result |
|---|---|
| Gap-check | **clear** — the five gaps closed in `77e2ed4` were accepted |
| Scout | 1 of 3 Explore sweeps returned; 2 ended without structured output |
| Implement | **done** — all 19 work items, ~3,100 insertions across 25 files + 4 new files |
| Gate | **green** — backend 16 suites / 209 tests, `nest build` 0, `prisma validate` ok; frontend Vitest 2 files / 37 tests, `tsc --noEmit` 0, `next build` 0 |
| Refute | **incomplete** — `correctness` refuted with 2 findings; `regression-risk` never ran (spend limit) |
| Ship | stopped, no template. `clerk` also failed on the spend limit |
Gate repairs: 0. Referee relays: 0. Nothing committed, nothing pushed.
### Two confirmed findings — verified by hand, not taken on the critic's word
1. **`VoiceReviewSheet.tsx:169` — a picked tooth chip is silently dropped.** `pickCandidate`
sets `teeth: prev.teeth || available.teeth`, but `available` is memoised from `effective`,
which depends on `chosenTeeth`. Both `setChosenTeeth` and `setSelection` run in the same
handler, so the updater closes over the pre-pick `available`, where `available.teeth` is
`false` because `result.teeth` is empty. `prev.teeth` is false too, so it stays false
permanently. The row then renders with the tooth on the chart and the box unticked, and Apply
drops it. This is a variant of the original live-test failure — "ترمیم برای دندون دو".
2. **`VoiceReviewSheet.tsx:213` + `TreatmentWorkspace.tsx:2215` — decision 41 not implemented.**
The treatmentType row is a plain `toggle('treatmentType')` with no lock, and
`applyVoiceResult` derives `labDependent` from `result.treatmentType` rather than the
`detail.treatmentType` it writes. Untick the type row on a forced-prosthesis recording and a
`restoration` detail is saved carrying prosthesis lab rows — the state decision 41 exists to
make unreachable.
### Lint
Backend touched files: 0 errors, 0 warnings. Frontend touched files: 10 warnings, all
pre-existing in `TreatmentWorkspace.tsx`. The repo-wide backend baseline (1,288 errors, 1,107
prettier-fixable) is untouched by this diff.
## Next steps / open questions
- The diff is **unreviewed on the `regression-risk` lens**. Green gate plus one refuting lens is
not the design's bar; the script continued only because the threshold counts refusals and the
second critic errored rather than refused.
- Fix the two findings, then resume from Refute so both lenses grade the same diff.
- The Ship phase cannot pass in this repo until there is an MR template, or until the MR is
opened by hand. Opening one is gated regardless.
---
## 2026-09-07 — Second refute round, both lenses, on the repaired diff
Ran both critics directly rather than resuming the workflow: a resume would have replayed the
`correctness` verdict from cache against a diff that had changed, and re-run the Ship phase that
cannot pass in this repo anyway.
**`correctness` refuted with 4 findings — all confirmed by hand, all fixed in `f1a4594`.**
Three were the same root cause: the region check never actually ran. Each wrote a job the manual
chart cannot produce, and each passed all 209 tests.
| Finding | Effect |
|---|---|
| The deferred region check had nothing to complete it | "پروتز متحرک برای دندون ۱۲" wrote a complete denture onto tooth 12 |
| `some` over a stack's regions | `['pfm_crown','night_guard_soft']` on tooth 12 wrote a night guard onto that tooth |
| `regions.size === 1` also deferred `implant` (root + crown, both tooth regions) | an implant aimed at a jaw resolved as a `UA` target |
| The `e271858` lock ticked the row and the payload but not the count | the sheet read "Apply 1 field" while two landed |
Fix: targets resolve **before** types, so the target kind narrows a category's candidates and an
impossible leaf is never offered. The deferral concept is gone. Region validation is now
`every`, and a category with no usable leaf drops the assignment's targets rather than leaving
them jobless. Four tests added.
**`regression-risk` refuted with 2 findings.**
1. Already fixed in `f1a4594` — the spec file's `filter(Boolean)` left two `tsc` errors. It added
a fact worth keeping: **`.gitea/workflows/*` run no test, lint or typecheck step at all.**
`nest build` excludes `**/*spec.ts` and ts-jest runs transpile-only under
`isolatedModules`, so nothing in this repo would ever have caught it. Repo-wide gap, not this
branch's.
2. **Open — needs a decision, not a fix.** See below.
### Open question: teeth are unreachable when the merged row is unticked
On a forced-prosthesis result `voiceRowAvailability` sets `teeth: false`, so no teeth row renders
and `selection.teeth` can never become true. Untick the merged prosthesis row (which releases the
decision-41 lock) and then untick the type row, and both write paths in `applyVoiceResult` are
skipped — the detail is appended with `teeth: []`. `master` kept the teeth here, because the rows
were separate.
This is decision 39 behaving exactly as approved: teeth and prosthesis apply together. But it
means a clinician who wants only the teeth from a prosthesis dictation, intending to pick
materials by hand, has no way to get them. Not a spec violation — an unstated consequence.
Deliberately not changed unilaterally.
---
## 2026-09-07 — Migration and seed proven against dev Postgres
The last unverified gate. The inspector had run `prisma validate`, which only parses the schema;
the build compiled solely because the generated client in `node_modules` already carried the new
enum values.
Found five migrations pending, not one — this dev database had not been migrated since the
prosthesis overhaul landed on `master` on 1 September (`a3c14a1`, `72f885d`). Prisma applies in
order, so proving one required applying all five.
Used **`prisma migrate deploy`, not `migrate dev`**: `deploy` applies pending migrations and never
offers to reset, so there is no path where a drift prompt drops dev data. §12 should say `deploy`
for this reason.
Counted the `DELETE` in `…_prosthesis_tree_multi_type` before applying rather than trusting its
own "no-op for current data" comment: **0 rows matched**, against 2 rows total in
`lab_case_tooth_prosthesis`. Both login accounts survived (`users` = 2).
Verified after seeding:
| Check | Result |
|---|---|
| `enum_range(CatalogEntityKind)` | now 5 values, including `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` |
| Seeded `PROSTHESIS_CATEGORY` rows | 7 per locale × fa/en/nl |
| Seeded `PROSTHESIS_SUBCATEGORY` rows | 5 per locale × fa/en/nl |
| `crown` in `fa` | `روکش‌ها` — the label that makes "روکش" resolvable to a category |
| Code path | `prosthesis-catalog.service.ts:108,119` resolves both kinds via `CatalogLabelService` |
**Repo-wide gap worth acting on separately:** `.gitea/workflows/*` run no test, lint or typecheck
step — only build and deploy. Combined with `nest build` excluding `**/*spec.ts` and ts-jest
running transpile-only under `isolatedModules`, nothing in this repo would ever catch a type error
in a spec file. That is how two of them reached this branch unnoticed.
## Remaining before this is shippable
- The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is
the report that started the revision, and the stack / jaw / chip flows in `fa`.
- The Ship phase cannot pass in this repo: no MR template exists, and opening a merge request is
gated regardless.

View File

@@ -1,33 +1,14 @@
---
type: task
status: active
created: 2026-08-20
repos:
- repo: dyolink
path: ~/PersonalProjects/dyolink
branch: feat/treatment/add-voice-input-for-new-treatment-form
base: origin/master
role: extraction contract + resolvers + review sheet
spec_slug: voice-treatment-entry
merge_after: []
aliases: [voice treatment entry, voice input, dictation, mic]
---
# Voice treatment entry
**Status:** v1 is merged to `master` (`c07f550``dc10d8d`). The treatment form was then
overhauled on top of it (`a3c14a1`, `7f92e73`, `72f885d`, `5d3597f`): one prosthesis type
per tooth became **stacked jobs**, **jaw-level appliances** and a **category tree**. Voice
still compiles against that model but can no longer express it, and in two places now
writes data the form itself refuses. This revision adapts the extraction contract (§5),
the resolvers (§6) and the review sheet (§7), and carries two recording defects found on
Safari (§2, §9).
**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 · **Revised:** 2026-09-07
**Created:** 2026-08-20
Fill a `TreatmentDetail` — including its lab dispatch — by speaking, instead of by
tapping through the type dropdown, the FDI chart, the prosthesis job popover and the lab
tapping through the type dropdown, the FDI chart, the prosthesis wizard and the lab
picker.
---
@@ -49,7 +30,7 @@ One recording produces **exactly one** `TreatmentDetail`, and may fill every fie
| `teeth` | FDI codes, via tooth-intent resolver |
| `toothSelectionGroups` | connected (bridge) / single spans |
| `comment` | cleaned dictated notes |
| lab: `LabCaseToothProsthesis` rows | one assignment list — a target is a tooth **or a jaw**, and carries a **stack** of job codes |
| lab: `prosthesisTypeCode` per tooth | default type + per-tooth overrides |
| lab: `destinationOrganizationId` | matched against the clinic's linked labs |
| lab: `dueDate` | via due-date intent resolver |
@@ -199,12 +180,6 @@ Three different reasons for "no", rendered differently:
"This feature isn't yours" and "not right now" are different statements. Absence avoids a
permanently dead control; disabling avoids the button resizing as the day strip moves.
**The technical row was specified and never implemented.** `voiceForEditor` gated the mic on
the server's availability response alone — `enabled && locales.includes(locale)` — and never
called `isMediaRecorderSupported()`. So the control rendered on a browser that cannot record
and failed on tap, which is how the Safari report arrived. It now tests both. That is what
"segment absent" meant all along.
`TAB_TREATMENT_EDIT` is resolved via `common/membership-permissions.ts`, never by reading
`membership.permissions` directly.
@@ -272,14 +247,7 @@ mirroring and Jalali conversion — testable instead of hopeful.
### Endpoint
`POST /voice/extract``@Controller('voice')` + `@Post('extract')`, so the full path behind the
global prefix is `/api/voice/extract`. Earlier revisions of this spec named it
`/treatments/voice-extract`, which was never the shipped route. **No work item moves it.**
> ⚠ The path is duplicated in `common/body-parsers.ts`, which matches `/api/voice/extract` to
> select the large JSON body limit. Moving the route without moving `VOICE_EXTRACT_PATH` makes
> every real recording 413 against the 100 kb default, which reads as a broken microphone rather
> than a routing change.
`POST /treatments/voice-extract`
- Guards: `JwtAuthGuard` + `ClinicOrgGuard`.
- Service-level check of `TAB_TREATMENT_EDIT`. The plan flag is **not** checked in v1
@@ -411,24 +379,19 @@ type VoiceIntent = {
treatmentType: string | null; // catalog code, from the supplied closed list
teeth: ToothIntent[];
connectedSpans: { from: ToothIntent; to: ToothIntent }[];
comment: string | null; // only when asked for — see below
commentTrigger: string | null; // the words that asked; null discards the comment
prosthesis: ProsthesisAssignment[]; // empty array, never null
comment: string | null;
prosthesis: {
defaultType: string | null; // catalog code
overrides: { tooth: ToothIntent; type: string }[];
} | null;
labId: string | null; // must be one of the supplied linked-lab ids
labMatchExact: boolean;
due: DueIntent | null;
};
/** One spoken instruction: these targets get these jobs. */
type ProsthesisAssignment = {
targets: ToothIntent[]; // each one a tooth, or a jaw — see below
types: string[]; // leaf codes, or one category / subcategory code
spoken: string; // the transcript span, echoed back to the clinician
};
type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string }
| { kind: 'positional'; arch: 'upper' | 'lower' | 'both';
| { kind: 'positional'; arch: 'upper' | 'lower';
side: 'patient_right' | 'patient_left'; position: number; spoken: string };
type DueIntent =
@@ -438,126 +401,12 @@ type DueIntent =
| { kind: 'gregorian'; y: number; m: number; d: number };
```
The wire form stays deliberately flat (`extraction.wire.ts`): strict `json_schema` mode has
poor support for discriminated unions, so every variant field is present and nullable and
`toVoiceIntent` narrows it.
### One assignment list, no default
`prosthesisDefaultType` and `prosthesisOverrides` are gone. A default that fills every tooth
and is then overwritten per tooth has a precedence rule, and a precedence rule has a wrong
side — the first live prosthesis test previewed PFZ on tooth 13 and filled PFM. A single list
where every entry names its own targets has no precedence to get wrong.
Targets are `ToothIntent`s; the examples below write them as bare FDI codes for brevity.
- `{ targets: [12, 13], types: ['pfm_crown'] }` — two teeth, one job. Everything the default
expressed, without the default.
- `{ targets: [12], types: ['zirconia_abutment', 'monolithic_zirconia'] }` — one tooth, a
**stack**. This is what the old shape could not say at all.
### A target is a tooth or a jaw, and the code decides which
Nothing on the wire declares "this is a jaw". A target that names an arch and no position is
a jaw, and the resolver confirms that against the `chartRegion` of the assignment's codes.
`arch` gains `'both'`, which the old enum could not express.
Deriving it costs no second source of truth. The catalog already answers the question, and
`partial_denture` — a `removable` code that is nonetheless assigned per tooth — already ships
with `chartRegion: 'crown'` in `catalog-seed-data.ts`, so the exception needs no special case
either.
A code whose region contradicts its target is reported, never coerced. An arch appliance
aimed at tooth 12, or a crown aimed at the upper jaw, resolves to
`code_not_valid_for_target` (§6).
**A category whose leaves span both regions defers the check.** `removable` is the only one
today: `complete_denture` and `overdenture` are `chartRegion: 'arch'` while `partial_denture` is
`'crown'`. There is no region to check until a leaf is chosen, so such an assignment resolves to
`prosthesis_type_ambiguous` and the region is confirmed against the leaf the clinician picks —
never emitted as `code_not_valid_for_target` while it is still a category.
### `types[]` may hold a leaf or a category
"روکش" is *crown* — a category with nine leaves, not a material. The model returns the
category code it actually heard rather than guessing `pfm_crown`, and the review sheet offers
the leaves as chips (§7).
This needs no extra field because the namespaces are disjoint: **42** leaf codes against **7**
category names and **5** subcategory names (`veneer`, `inlay`, `onlay`, `overlay`,
`night_guard`), no collisions. The resolver classifies by lookup. The test asserts disjointness
against the **live catalog**, not against these counts, because a future entry named `crown`
would quietly turn a leaf into an ambiguity and a hardcoded count would not notice.
#### Category labels need a backend source — build it
The prompt cannot offer a category the model can name unless that category has a label in the
actor's locale, and today it has none. `CatalogEntityKind` covers only `TREATMENT_TYPE`,
`PROSTHESIS_TYPE` and `LAB_WORKFLOW_STEP` (`schema.prisma`), so `CatalogLabelService` cannot
resolve a category at all. The only category labels in the repo are the `prosthesis.category_*`
and `prosthesis.sub_*` keys in `frontend/messages/*.json`, plus a hardcoded single-locale map in
the dev export script. Sending bare codes would leave `crown` and `post_core` untranslated
beside fully-labelled leaves, on the locale this feature exists for.
So this branch adds them as first-class catalog entities:
- add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind` — one
migration, no data loss, the enum is additive;
- seed `CatalogTranslation` rows for the 7 categories and the 5 subcategories in `fa`, `en` and
`nl`, taking the wording from the existing `prosthesis.category_*` / `prosthesis.sub_*` keys
so the two surfaces read identically on day one;
- resolve them through `CatalogLabelService` like every other catalog label. No label is
hardcoded in backend code, and the house rule holds unchanged.
The `sub_*` key set is wider than the subcategory set — it also carries technique names
(`sub_full_contour`, `sub_layered`) and two leaf codes. Seed only the five real subcategories.
> **Accepted consequence:** the frontend keeps its own `category_*` / `sub_*` message keys, so
> the wording now lives in two places. Migrating the frontend to read these labels from the API
> is **out of scope for this branch** — it would touch the prosthesis picker, the case sheet and
> the print layout, none of which this task otherwise opens. The seed is written from the message
> files precisely so the two agree at the point they diverge.
### A note is written only when the clinician asks for one
The model does not decide that something was a note. It reports the exact words that asked —
«بنویس که», "write this in the notes" — in `commentTrigger`, and `comment` holds what was
dictated after them, without the asking words. The resolver keeps `comment` only when a trigger
is present, so the rule is enforced by code rather than trusted to the prompt.
The instruction this replaces said "clinical notes, in the language spoken. Omit the parts
already captured as treatment type, teeth, prosthesis work or deadline" — which told the model to
sweep up whatever was left over. Filler, small talk and an unrequested diagnosis all became a
persisted clinical note, and `comment` is the one durable trace of a recording (§10).
Speech that fits no field and was not asked to be a note is simply not reported. The sheet shows
what was captured, so nothing is hidden by leaving it out.
Per-locale trigger vocabulary lives in the prompt's locale notes, exactly like the tooth
vocabulary. The resolver only checks that a trigger was reported, so it needs no per-locale
knowledge and stays locale-neutral by construction (§6).
### Prosthesis work implies the treatment type
`prosthesis` is the only `labDependent` treatment type. Any resolved assignment therefore
forces `treatmentType` to `prosthesis`, and the sheet locks that row while the prosthesis row
is ticked. Without it, a recording that names an appliance but no treatment type seeds the
type from the appointment purpose, `prosthesisAssignActive` stays false, the plain chart
renders, and the lab rows are orphaned behind a chart that cannot show them.
### Closed lists
Every code-valued field is constrained to a **closed list supplied in the prompt**:
- Treatment types and prosthesis types come from `CatalogLabelService` in the actor's
locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of
`prosthesis` and `monolithic_zirconia` rather than being asked to translate. Catalog
entities store a stable `code` and no label — never hardcode a label.
- **The prosthesis list is no longer flat.** `buildCatalog` already receives `category`,
`subcategory`, `chartRegion` and `stackGroup` from `ProsthesisCatalogService` and throws
all four away. It now passes them through, so the prompt presents the catalog as the tree
it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the
prompt — the shape comes from the data.
- Lab candidates are the clinic's linked labs only (`OrganizationLink`), passed as
`{ id, name }`. The model may return one of those ids or `null`, nothing else.
@@ -567,11 +416,9 @@ Every unresolved or rejected item is reported, never silently dropped.
## 6. Resolvers
Both live in `backend/src/`, pure and Jest-covered. That placement was originally forced —
the frontend had no test runner at all. This revision adds **Vitest** for the frontend's pure
helpers (§12), so the split is now a judgement rather than a constraint: intent resolution
stays on the backend because it is the trust boundary, and the stack rules stay on the
frontend because that is where the form's own rules already live.
Both live in `backend/src/`, pure and Jest-covered. The frontend has **no test runner**
(no jest/vitest, zero spec files) — putting them there would forfeit the testability
that justified this whole design.
### `resolveToothIntent()`
@@ -619,60 +466,16 @@ frontend because that is where the form's own rules already live.
- Connected spans validate through the shipped helpers — `areArchNeighbors`, `sameArch`,
`teethBetweenInclusive`. **Never a 1-tooth connected group.** Anything invalid degrades
to singles and is flagged on the review sheet. Spans stay their own array rather than
folding into assignments: linking is offered on the plain chart too (the non-prosthesis
`onToggleLink` branch in `TreatmentWorkspace.tsx`), so it is not prosthesis-only.
- **An assignment target is a selection.** On a `prosthesis` detail a tooth exists only by
carrying a job: `applyCodes` in `ProsthesisAssignChart` returns early on an empty code
list, and `pruneDetailTeethToJobs` strips the rest before every save. So
`tooth_not_selected` is unreachable for prosthesis work and is retired.
- **A tooth with no job is named and left out.** "۱۲ و ۱۳، روکش برای ۱۲" adds only 12; 13
appears struck through in the sheet reading *no prosthesis heard*. Keeping it would
manufacture a state the manual chart cannot produce and `persistDraft` would delete on the
way out.
- **Stack legality is not a resolver concern.** `canStackLeaf`, `categoryDisabledForJobs` and
the screw-retained exclusion live in `frontend/src/components/treatment/prosthesisTree.ts`,
and the backend's own dispatch check knows nothing about stacking. Porting them would make
the voice path stricter than the manual path and create two copies of a rule that must never
disagree. The sheet and the apply path both route through `applyLeafToJobs` instead (§7).
The backend checks only that a code exists and that its region suits its target.
- **All-or-nothing is retired.** `assertCompleteToothProsthesisMap` still requires every
*remaining* tooth on a prosthesis detail to carry a code, but the new form guarantees that by
pruning rather than by refusing, and it now also accepts a detail with no teeth and an arch
job. So an incomplete map no longer blocks a tick; the field survives only to name the
targets that will be dropped.
to singles and is flagged on the review sheet.
- Prosthesis: expand `defaultType` across all teeth, then apply per-tooth overrides.
- **All-or-nothing.** `assertCompleteToothProsthesisMap` requires every tooth on a
`prosthesis` detail to carry a `prosthesisTypeCode` or the send throws
`TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE`. So if even one tooth ends untyped, the
prosthesis row is marked incomplete and stays unticked — the unshippable state surfaces
at review, where it is cheap, not at dispatch minutes later on another screen.
- **Apply order is teeth → groups → prosthesis**, so `pruneToothProsthesisForGroups`
behaves.
### Unresolved reasons
Every one is rendered with what was heard, so the clinician sees what the system did not
understand. Three of them carry candidates and become chips (§7).
| Reason | Meaning | Carries |
|---|---|---|
| `not_permanent_tooth` | deciduous, or outside the permanent set | — |
| `position_out_of_range` | a position outside 18 | — |
| `tooth_missing_quadrant` | a lone digit — four teeth match, fewer when an arch or side was also heard | `candidates`: FDI codes |
| `prosthesis_type_ambiguous` | a category or subcategory was heard, not a material | `candidates`: leaf codes |
| `arch_not_spoken` | a jaw-level appliance with no jaw | `candidates`: `upper`, `lower` |
| `code_not_valid_for_target` | an arch code aimed at a tooth, or a tooth code aimed at a jaw | — |
| `unknown_catalog_code` | a code the supplied catalog does not contain | — |
| `span_not_same_arch` | a connected span crossing arches | — |
| `malformed` | neither an FDI code nor a usable description | — |
| `invalid_date` | a date the resolver cannot build | — |
`tooth_not_selected` is removed — assignments now define their own teeth.
**An unresolved item that came from an assignment carries that assignment's index.**
Without it a chip is decorative: picking a quadrant for "دندون دو" on a prosthesis detail
produces a tooth with no job, which the *jobless tooth is left out* rule above then discards, so
the tap changes nothing. `tooth_missing_quadrant`, `arch_not_spoken` and
`prosthesis_type_ambiguous` therefore carry `assignmentIndex`, and a picked chip inherits that
assignment's `types[]` — or, for `prosthesis_type_ambiguous`, supplies the missing leaf to that
assignment's existing targets. An item raised outside any assignment (a tooth spoken in the
`teeth` array alone) carries no index and folds into `teeth` as it does today.
---
## 7. Review sheet
@@ -684,89 +487,34 @@ assignment's existing targets. An item raised outside any assignment (a tooth sp
> same constraint the realtime soft-refresh already lives under: never remount the
> treatment form, never clear a draft.
- Renders one row per extracted field in the app's own vocabulary: translated catalog labels,
and a mini FDI chart for the teeth rather than a list of numbers. **The transcript is not
shown** — it never reaches the browser at all (§10).
- Renders the transcript, then one row per extracted field in the app's own vocabulary:
translated catalog labels, and a mini FDI chart for the teeth rather than a list of
numbers.
- Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also
what creates the new detail — see §2.
- Rows default to ticked **except** the lab row when `labMatchExact` is false — shipping to a
lab always requires a deliberate tick.
- Rows default to ticked **except**:
- the lab row when `labMatchExact` is false — shipping to a lab always requires a
deliberate tick;
- any row carrying an unresolved item or an incomplete prosthesis map.
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
clinician can see what the system did not understand.
- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any
per-detail convenience that would top the case up afterwards has to be suppressed for a
voice-created case, because a default that quietly adds a prosthesis type to a tooth the
sheet never mentioned turns the confirmation step into a lie about what it was going to
do — which is the whole reason the step exists.
### Teeth and prosthesis are one row
For a lab-dependent type they are not independent, and two checkboxes let the clinician save a
state the form immediately undoes. On `master` today, ticking **teeth** and unticking
**prosthesis** on a prosthesis detail saves an **empty detail**: `persistDraft` prunes
lab-dependent details to their jobs and `applyVoiceResult` persists straight after applying.
So a lab-dependent type gets one **Teeth and prosthesis** row that applies together. Every
other type keeps a plain teeth row and has no prosthesis row at all. The merged row is:
- the existing read-only mini `FdiToothChart`, now fed `crownColors` / `rootColors` from
`toothRegionColors` and `archHighlight` from the arch jobs. The chart already accepts all
three, so showing stacks and jaw work costs no new component;
- a line underneath naming each target and what lands on it —
`۱۲: ایمپلنت + روکش زیرکونیا · ۱۳: روکش پی‌اف‌ام · فک بالا: نایت گارد`.
The chart catches a misheard tooth number at a glance; the list confirms the material. Either
one alone is the weaker check.
> **Accepted consequence:** on a prosthesis dictation the teeth are reachable only through this
> row. `voiceRowAvailability` sets `teeth: false` for a lab-dependent result, so no plain teeth
> row exists and `selection.teeth` never becomes true. Untick the merged row — which releases the
> decision-41 lock — then untick the type row, and the detail applies with no teeth at all.
> `master` kept them, because the rows were separate.
>
> This is decision 39 doing what it was chosen for, and it follows from *the sheet is a contract*
> below: the row that displayed those teeth was unticked, so applying them anyway would apply
> something the clinician refused. The cost is real and accepted — a clinician who wants only the
> teeth from a prosthesis dictation, intending to pick materials by hand, has to tap them in. If
> that turns out to matter in use, the fix is a spec change, not a patch.
### The sheet previews the stack that will actually land
The merged row builds its preview through `applyLeafToJobs` — the same function the manual
chart writes through — and `applyVoiceResult` applies through it too. A job the stack rules
refuse, such as an implant plus a post & core on one tooth, is shown struck through and named.
Not silently dropped, and not silently applied. The rules stay in one file (§6).
### Three chip kinds, one pattern
An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is
interactive. Picking one folds the choice into the result, so an under-specified item is one
tap from resolved instead of a dead end. Everything the sheet renders comes from that folded
result, not the raw one.
| Heard | Missing | Chips |
|---|---|---|
| "دندون دو" | the quadrant | the four FDI candidates, narrowed by any arch or side also heard |
| "روکش" | the material | the leaves of that category or subcategory |
| "نایت گارد" | the jaw | Upper / Lower, multi-pick — picking both is how a both-jaw appliance is expressed, since the manual chart has no *both* control either |
Nothing is guessed on the clinician's behalf. A per-category default material would have the
same shape as the auto-fill this design already rejects.
### The sheet is a contract
**Confirm fills exactly what the sheet previewed — no more.** Any per-detail convenience that
would top the case up afterwards has to be suppressed for a voice-created case, because a
default that quietly adds a prosthesis type to a tooth the sheet never mentioned turns the
confirmation step into a lie about what it was going to do — which is the whole reason the
step exists.
> The v1 branch carried an exemption for one such default, the dispatch panel's
> remembered-prosthesis auto-fill. `master` deleted that feature outright (`f52ad6b`), so the
> exemption went with it and nothing enforces this rule in code today. It is a constraint on
> whatever gets added next, not a description of something that exists.
This is also why a missing material becomes chips rather than a per-category default, and why
a jobless tooth is named rather than quietly filled.
### RTL
Logical `text-start` / `text-end` only, never `text-left` / `text-right`. Dates via
`lib/i18n/format.ts`. The merged row's per-target line reads right to left in `fa`, so the
separator is a bare `·` with no direction of its own.
> This branch carried an exemption for one such default, the dispatch panel's
> remembered-prosthesis auto-fill. `origin/master` deleted that feature outright
> (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule
> in code today. It is a constraint on whatever gets added next, not a description of
> something that exists.
- An item that carries `candidates` renders them as **tappable chips** — the one place the
sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and
ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a
dead end. Everything the sheet renders comes from that folded result, not the raw one.
- RTL-safe: logical `text-start` / `text-end` only, never `text-left`/`text-right`.
Dates via `lib/i18n/format.ts`.
---
@@ -833,25 +581,26 @@ that throws after permission was already granted. All three are "this browser ca
and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting
in site settings for a permission nothing ever asked for.
**The container list is a preference, not a requirement.** `pickRecordingMimeType` returned
`null` when `MediaRecorder.isTypeSupported` rejected all six candidates — five of which are
WebM or OGG, which Safari cannot record. So Safari was refused outright, even though it
records `audio/mp4`, `mimeTypeToFormat` already maps that to `m4a`, and the backend accepts
`m4a`. It now falls back to the empty hint, which is the *let the browser choose* path the
function already had for Safari versions that shipped no `isTypeSupported`; `onstop` derives
the real container from `recorder.mimeType`, as it already did. `VOICE_UNSUPPORTED_FORMAT` is
left for a browser that genuinely cannot record.
**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.
**Transcript salvage — dropped, not deferred.** `VOICE_EXTRACT_FAILED` used to carry
`details.transcript` so a failure dialog could offer the words back as a note. It was never
built, and the transcript no longer reaches the client at all (§10), so the dialog as specified
cannot be built either. `toAppException` now returns a code and no `details`.
When ASR succeeded and only extraction failed, the response still
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action
**creates a new detail with only `comment` set to the transcript** — everything else left
at `newDetail()` defaults. The words were captured and paid for; only the structure was
lost.
The trade, stated plainly: when ASR succeeded and only extraction failed, the words were
captured and paid for, and the clinician cannot be offered them. They are in the server log,
readable by an operator, not by the person who spoke them. The clinician re-dictates.
This keeps the feature's one invariant intact: **voice never writes into an existing
detail.** Dictating into an already-filled detail is a separate, later feature with its
own voice-to-text control scoped to that field (§1, out of scope).
Reversing this needs a decision about the transcript leaving the server, not just client code.
It also does not bypass the confirmation rule — the dialog shows the transcript, and the
dentist taps to accept it. That review matters, because a raw transcript carries ASR
errors and may contain the patient's spoken name, and `comment` is persisted (§10).
---
@@ -860,20 +609,7 @@ Reversing this needs a decision about the transcript leaving the server, not jus
- Audio is held **in memory for the request only**. Never written to disk, never a Prisma
row. Note this is deliberately unlike treatment attachments, which do persist to
`backend/uploads/treatments`.
- **The transcript never leaves the server.** It is not in the success response and not in any
error body. A raw dictation can carry the patient's spoken name, and the review sheet has no
need of it — the resolved rows are what the clinician confirms.
- The transcript **is** written to the server log, once per recording, at **info** level
(`voice.service.ts`, immediately after the emptiness check so an extraction failure still
records it). This is a deliberate exception to the rule below, and the only durable trace
besides `comment`. It is logged before extraction, not inside `logTelemetry`, so that
method's patient-free guarantee stays literally true.
> ⚠ Consequence to accept: patient words persist in production server logs at default level.
> Whatever retention and access control applies to those logs now applies to dictation. The
> repo's other sensitive-text path — a vendor error body that can echo the request back —
> uses `debug` level and truncates to 500 characters
> (`openrouter.provider.ts`). Moving this line to `debug` is a one-word change.
- The transcript goes to the browser for the review sheet and dies with it.
- The `comment` field persists a cleaned version of what was said — that is legitimate
clinical record-keeping and is the only durable trace.
- Telemetry is **structured and patient-free**: clip duration, which fields resolved,
@@ -963,13 +699,8 @@ enabling this for real clinics.
(§2). Still worth timing a realistic worst-case prosthesis dictation during item 1 to
confirm 2 minutes is comfortable rather than tight.
13. ~~**Availability API**~~**resolved for v1:** voice ships **open to everyone** with a
configured locale profile. **No plan check.** The `Plan.features` design in §8 is deferred,
not dropped.
*Corrected 2026-09-07:* this item previously read "no availability endpoint", which
contradicted §4 and the shipped code. `GET /voice/availability` **does** exist and is what
`voiceForEditor` reads; it reports the configured locales and `maxRecordingMs`, and carries
no plan check. Only the plan gate is deferred, not the endpoint.
configured locale profile. No plan check, no availability endpoint. The `Plan.features`
design in §8 is deferred, not dropped.
### New
@@ -984,99 +715,39 @@ enabling this for real clinics.
`Plan.features` gate, lower `maxMs` or the throttle, or add an org-level monthly minute
budget.
15. **The voice body limit is raised before any guard runs.** `isVoiceExtractPath` in
`common/body-parsers.ts` selects the large JSON limit by path, and Express body parsers
run ahead of `JwtAuthGuard`. So an unauthenticated request to that path may upload the
full clip-sized body before anything rejects it. The throttle does not help: it is a guard
too. Bounded by the DTO cap and by the reverse proxy's own limit, but it is a
pre-authentication allocation and nobody has decided whether that is acceptable. Not
changed in this revision.
16. ~~**Transcript salvage is still specified and not built.**~~**resolved 2026-09-10:** of
its two options, "stop returning the transcript" was taken. The transcript is no longer in
the success response or in `details`, the review sheet no longer shows it, and it is logged
on the server instead (§9, §10). The `ApiError['details']` shape mismatch goes away with it.
---
## 12. Verification
### Machine gates
- `cd backend && npm test` — the existing suites, plus new coverage for the revised contract:
- an assignment resolves its targets, and a target that names an arch with no position
resolves to the `UA` / `LA` sentinels;
- a leaf code, a category code and a subcategory code are each classified correctly, and
the three namespaces are asserted disjoint;
- an arch code aimed at a tooth, and a tooth code aimed at a jaw, both resolve to
`code_not_valid_for_target`;
- a resolved assignment forces `treatmentType` to `prosthesis`;
- a target with no types is reported and excluded, and does not fail the whole assignment;
- a `removable` category defers its region check instead of emitting
`code_not_valid_for_target`;
- every one of the 7 categories and 5 subcategories resolves a non-empty label in `fa`, `en`
and `nl` — the check that catches a missed `CatalogTranslation` seed row;
- an unresolved item raised inside an assignment carries that assignment's index, and one
raised outside any assignment does not.
The pre-existing suites stay: `resolveToothIntent` in all four quadrants, out-of-range
rejection, deciduous → unresolved, `resolveDueDate` per-locale week start, "this" vs "next",
Jalali leap year and month-end, and connected-span validation.
- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all
four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate`
(per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the
Jalali port, prosthesis expansion + completeness, and connected-span validation.
- `cd backend && npm run build` — cross-cutting backend gate.
- `cd backend && npm run prisma:migrate && npm run prisma:seed` — the new `CatalogEntityKind`
values and their translation rows. The seed never wipes, so re-running it is safe.
- `cd frontend && npx vitest run`**new**. One dev dependency, one config, one script,
covering the pure helpers only: `prosthesisTree.ts` (stack legality, `applyLeafToJobs`
precedence, `toothRegionColors`) and `voiceReviewRows.ts` (row availability, the merged
row, folding chips into the result). No React, no DOM. `CLAUDE.md` is updated in the same
commit — "there are no frontend tests" stops being true.
- `cd frontend && npx tsc --noEmit` — frontend type gate.
- `cd frontend && npm run build` — production build.
- ESLint on every touched file, no new warnings.
### Manual
fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked lab. Then:
**Carried forward, still required**
- **past day** → both segments disabled, control still split (not absent);
- **locale with no profile** → control renders unsplit, identical to today;
- **fa vs en** → mic sits at the logical end in both, on the same side as the chip's trash;
- **cancel mid-recording** → chip strip unchanged, no orphan detail;
- **confirm** → always appends a new chip, whatever the active detail contains;
- **Add half** → behaves exactly as it did before this change;
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
- **hold past 2:00** → auto-stops and proceeds to processing, not an error;
- **cancel during processing** → the vendor request is actually aborted;
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
there.
**New to this revision**
- **Safari on macOS, and Safari on iPad** → the mic records, and the clip reaches the server
as `m4a`. This is the report that started the revision;
- **a browser with no `MediaRecorder`** → the Add button renders unsplit, and no mic appears;
- **stack** — "دندون ۱۲ ایمپلنت با روکش زیرکونیا" → the sheet shows both jobs on 12, the chart
colours crown and root differently, and the chart after apply shows the same stack;
- **jaw appliance** — "نایت گارد فک بالا" → the sheet shows an upper-jaw row, the treatment
type row reads *prosthesis* and is locked, and apply produces a `UA` row with no teeth;
- **jaw not spoken** — "نایت گارد" → Upper / Lower chips; tapping both produces `UA` and `LA`;
- **material not spoken** — "دندون ۱۲ روکش" → chips for the nine crown leaves, and the row
reads *روکش* in Persian, not `crown`. Nothing is applied until one is picked;
- **quadrant chip on a prosthesis detail** — "دندون دو روکش زیرکونیا" → picking a quadrant chip
produces a tooth **carrying that assignment's job**, not a jobless tooth that is then
discarded;
- **tooth with no job** — "۱۲ و ۱۳، روکش پی‌اف‌ام برای ۱۲" → 13 is struck through in the sheet,
apply adds only 12, and 13 is **not** silently saved and then deleted;
- **illegal stack** — "دندون ۱۲ ایمپلنت و پست و کور" → the refused job is struck through and
named; apply writes only the legal one, and the manual chart agrees;
- **contradiction** — "دندون ۱۲ نایت گارد" → reported as `code_not_valid_for_target`, nothing
applied for that assignment;
- **two different materials** — "۱۲ روکش پی‌اف‌ام، ۱۳ روکش پی‌اف‌زد" → the form shows both.
Nothing may rewrite a per-tooth type the sheet already showed;
- **non-lab-dependent type** — "ترمیم برای دندون ۱۴" → a plain teeth row, no prosthesis row,
and the teeth survive the save.
- `cd frontend && npx tsc --noEmit` — frontend gate.
- Manual: fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked
lab. Then specifically:
- **past day** → both segments disabled, control still split (not absent);
- **locale with no profile** → control renders unsplit, identical to today;
- **fa vs en** → mic sits at the logical end in both, on the same side as the chip's
trash;
- **cancel mid-recording** → chip strip unchanged, no orphan detail;
- **confirm** → always appends a new chip, whatever the active detail contains;
- **Add half** → behaves exactly as it did before this change;
- **tap 🎤 during the Lab wizard step** → confirm returns to the treatment step;
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
- **hold past 2:00** → auto-stops and proceeds to processing, not an error;
- **cancel during processing** → the vendor request is actually aborted;
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
there. They live on the lab case, which the autosave effect does not watch, so this is
the check that catches a lab draft left unsaved in component state;
- **record straight after opening a visit**, while the blank chip is still untyped, and
confirm with a lab ticked → no error toast: confirm detects the preview treatment and
skips the lab-case save rather than posting an id the server has never seen;
- **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows
both, and the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder. Nothing may
rewrite a per-tooth type the sheet already showed.
---
@@ -1095,14 +766,14 @@ Settled in a grilling session on 2026-08-20.
| 7 | Due date | Intent + deterministic resolver |
| 8 | Resolver location | Backend, Jalali math ported |
| 9 | Prosthesis | Default type + overrides, all-or-nothing |
| 10 | Retention | Discard audio and transcript, non-PHI telemetry only **partly superseded by 52**: the transcript is now logged on the server at info level |
| 10 | Retention | Discard audio and transcript, non-PHI telemetry only |
| 11 | Capture | Tap to start/stop, hard cap (see 27) |
| 12 | Failure UX | Stage-aware codes, transcript salvage |
| 13 | Locales | Provider registry per locale; all three locales enabled |
| 14 | Reachability | Registry now, slots filled per deployment |
| 23 | ASR model | `openai/whisper-1` for **every** locale; registry kept so `fa` can diverge |
| 24 | Extraction model | **`google/gemini-3.7-flash`**; escalation path documented in §4 |
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail. **Superseded by 53**: salvage is dropped |
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail |
| 26 | Throttle | Configurable; v1 default 6 requests / 60s per user |
| 27 | Duration cap | **2 minutes**, configurable via `maxMs` |
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
@@ -1129,49 +800,3 @@ UI placement settled in a second grilling session on 2026-08-20.
| 20 | Side | Logical end (`border-s`), exactly like the chip's trash |
| 21 | Creation | On confirm, never on tap |
| 22 | Creation rule | Confirm **always** appends a new detail — no blank-reuse guard |
Adapting to the overhauled treatment form, in a grilling session on 2026-09-07.
| # | Question | Decision |
|---|---|---|
| 34 | Scope | Full parity — voice reaches stacked jobs, jaw-level appliances and the category tree |
| 35 | Intent shape | One `prosthesis` assignment list. `prosthesisDefaultType` + `prosthesisOverrides` are removed, and with them the precedence rule that mis-filled tooth 13 (§5) |
| 36 | Arch targets | Derived, not declared — an arch with no position, confirmed against the code's `chartRegion`. `arch` gains `'both'` (§5) |
| 37 | Partial codes | `types[]` may hold a category or subcategory code; the namespaces are disjoint and a test asserts it (§5) |
| 38 | Stack legality | Frontend only, in `prosthesisTree.ts`. Both the sheet and the apply path route through `applyLeafToJobs`; the backend never learns the rules (§6) |
| 39 | Sheet rows | Teeth and prosthesis merge into one row for lab-dependent types — they are not independent, and two ticks could save an empty detail (§7) |
| 40 | Tooth with no job | Named and left out. The manual chart cannot produce a jobless tooth, so voice must not either (§6) |
| 41 | Type coupling | A resolved assignment forces `treatmentType` to `prosthesis` and locks that row (§5) |
| 42 | Missing material | Chips for the category's leaves. Never a per-category default — that is the auto-fill shape this design rejects (§7) |
| 43 | Missing jaw | Upper / Lower chips, multi-pick. Picking both is how a both-jaw appliance is expressed (§7) |
| 44 | Frontend tests | Vitest added for the pure helpers. The split between backend and frontend resolvers becomes a judgement rather than a constraint (§6, §12) |
| 45 | Recording defects | Carried in this branch: the container fallback that refused Safari, and the render gate that never checked `isMediaRecorderSupported()` (§2, §9) |
| 46 | Delivery | One merge request |
Closing the gaps the `/orchestrate` surveyor found on 2026-09-07, before any code was written.
| # | Question | Decision |
|---|---|---|
| 47 | Category labels | Add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind`, one migration, translations seeded from the existing `prosthesis.category_*` / `sub_*` message keys, resolved through `CatalogLabelService`. Bare codes were the alternative and would have been weakest on `fa` (§5) |
| 48 | Frontend label source | Out of scope. The frontend keeps its own message keys; the wording lives in two places by choice, and the seed is written from the message files so they agree (§5) |
| 49 | Mixed-region categories | A category whose leaves span `crown` and `arch` — only `removable` today — defers its region check to the picked leaf, and is never reported as `code_not_valid_for_target` while still a category (§5) |
| 50 | Chips that came from an assignment | `tooth_missing_quadrant`, `arch_not_spoken` and `prosthesis_type_ambiguous` carry `assignmentIndex`, so a picked chip inherits that assignment's jobs. Without it the chip resolves to a jobless tooth, which decision 40 discards — a chip that does nothing (§6) |
Corrections to the v1 text found in the same pass: the endpoint is `POST /voice/extract`, not
`/treatments/voice-extract` (§3); `GET /voice/availability` does exist and only the plan check is
deferred (§11 item 13); the catalog has 5 subcategories, not 4, and the disjointness test asserts
against the live catalog rather than a written count (§5).
Transcript handling revised on 2026-09-10.
| # | Question | Decision |
|---|---|---|
| 51 | Who sees the transcript | Nobody outside the server. It is absent from the success response and from every error body, and the review sheet does not render it — a raw dictation can carry the patient's spoken name, and what is not sent cannot leak through the network tab or an error reporter (§7, §10) |
| 52 | Where it goes instead | One **info**-level server log line per recording, written before extraction so a failed extraction still records it, and outside `logTelemetry` so that method stays patient-free. Accepted consequence: patient words persist in production logs at default level; `debug` is a one-word change (§10) |
| 53 | Transcript salvage | Dropped, not deferred. Reversing it needs a decision about the transcript leaving the server, not just client code. This supersedes decision 25 (§9) |
Notes made explicit on 2026-09-10.
| # | Question | Decision |
|---|---|---|
| 54 | What becomes a note | Only what the clinician explicitly asked to be written. The model reports the asking words in `commentTrigger`; the resolver discards `comment` without one, so a prompt drift cannot quietly persist unrequested speech as a clinical note (§5) |

View File

@@ -50,6 +50,8 @@ ENV HOSTNAME=0.0.0.0
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
# Standalone does not include public/; catalog icons are <img src="/prosthesis-catalog/*.svg">.
COPY --from=builder --chown=dyolink:nodejs /app/public ./public
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").

View File

@@ -60,4 +60,4 @@ npm install
## Docker
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**.
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**. Next `standalone` does not include `public/` — `frontend/Dockerfile` copies it so `/prosthesis-catalog/*.svg` is served in production.

View File

@@ -159,9 +159,8 @@
"verifyFailed": "Invalid or expired verification code."
},
"landing": {
"heroTitle": "Connect Dental Clinics & Labs",
"heroHighlight": "Seamlessly",
"heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.",
"heroTitle": "Nudentic is a digital workflow platform for modern dentistry.",
"heroSubtitle": "It brings clinical and laboratory workflows, case information, communication, and patient records into one structured environment.",
"featureClinicsTitle": "For Clinics",
"featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.",
"featureLabsTitle": "For Labs",
@@ -926,26 +925,20 @@
"voiceProcessing": "Reading the recording…",
"voiceReviewTitle": "Check what was understood",
"voiceNothingExtracted": "Nothing usable was picked up from that recording.",
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
"voiceNotUnderstood": "Not understood",
"voicePickTooth": "Which tooth?",
"voicePickJaw": "Which jaw?",
"voiceTypeForcedByProsthesis": "Prosthesis work sets this type — it cannot be unticked.",
"voiceTeethAndProsthesis": "Teeth and prosthesis",
"voiceNoProsthesisHeard": "no prosthesis heard",
"voiceStackRefused": "not added — cannot be combined",
"voiceDiscard": "Discard",
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
"voiceUnresolved": {
"not_permanent_tooth": "not a permanent tooth",
"position_out_of_range": "not a valid tooth position",
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”",
"prosthesis_type_ambiguous": "a general term, not a specific material — pick one below",
"arch_not_spoken": "which jaw was this for? — pick below",
"code_not_valid_for_target": "does not fit that tooth or jaw",
"malformed": "could not be read",
"span_not_same_arch": "a bridge cannot span both jaws",
"unknown_catalog_code": "not in this clinics list",
"tooth_not_selected": "that tooth is not part of this detail",
"invalid_date": "not a usable date"
},
"voiceFailed": "Voice entry failed. Please try again."

View File

@@ -159,9 +159,8 @@
"verifyFailed": "کد تأیید نامعتبر یا منقضی شده است."
},
"landing": {
"heroTitle": "اتصال کلینیک‌ها و لابراتوارهای دندانپزشکی",
"heroHighlight": "به‌صورت یکپارچه",
"heroSubtitle": "ارتباط بین متخصصان دندانپزشکی را ساده و سریع کنید. با یک دوره آزمایشی رایگان ۳۰ روزه، بدون نیاز به کارت اعتباری، شروع کنید.",
"heroTitle": "Nudentic یک پلتفرم گردش‌کار دیجیتال برای دندانپزشکی مدرن است.",
"heroSubtitle": "این پلتفرم گردش‌کارهای بالینی و لابراتواری، اطلاعات پرونده، ارتباط و سوابق بیمار را در یک محیط ساختاریافته کنار هم می‌آورد.",
"featureClinicsTitle": "برای کلینیک‌ها",
"featureClinicsDescription": "بیماران و نوبت‌ها را مدیریت کنید و پرونده‌ها را فوراً به لابراتوارها ارسال کنید.",
"featureLabsTitle": "برای لابراتوارها",
@@ -927,26 +926,20 @@
"voiceProcessing": "در حال پردازش گفتار…",
"voiceReviewTitle": "بررسی آنچه دریافت شد",
"voiceNothingExtracted": "از این ضبط چیز قابل استفاده‌ای برداشت نشد.",
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.",
"voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
"voiceNotUnderstood": "شناسایی نشد",
"voicePickTooth": "کدام دندان؟",
"voicePickJaw": "کدام فک؟",
"voiceTypeForcedByProsthesis": "کار پروتز این نوع درمان را تعیین می‌کند — قابل برداشتن نیست.",
"voiceTeethAndProsthesis": "دندان‌ها و پروتز",
"voiceNoProsthesisHeard": "پروتزی شنیده نشد",
"voiceStackRefused": "افزوده نشد — قابل ترکیب نیست",
"voiceDiscard": "انصراف",
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
"voiceUnresolved": {
"not_permanent_tooth": "دندان دائمی نیست",
"position_out_of_range": "شماره دندان معتبر نیست",
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
"prosthesis_type_ambiguous": "یک عنوان کلی است، نه یک متریال مشخص — یکی را از پایین انتخاب کنید",
"arch_not_spoken": "برای کدام فک بود؟ — از پایین انتخاب کنید",
"code_not_valid_for_target": "با این دندان یا فک همخوانی ندارد",
"malformed": "قابل خواندن نبود",
"span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد",
"unknown_catalog_code": "در فهرست این مطب نیست",
"tooth_not_selected": "این دندان بخشی از این مورد نیست",
"invalid_date": "تاریخ قابل استفاده نیست"
},
"voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید."

View File

@@ -159,9 +159,8 @@
"verifyFailed": "Ongeldige of verlopen verificatiecode."
},
"landing": {
"heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria",
"heroHighlight": "Naadloos",
"heroSubtitle": "Stroomlijn de communicatie tussen tandheelkundige professionals. Start met een gratis proefperiode van 30 dagen, zonder creditcard.",
"heroTitle": "Nudentic is een digitaal workflowplatform voor de moderne tandheelkunde.",
"heroSubtitle": "Het brengt klinische en laboratoriumworkflows, casusinformatie, communicatie en patiëntendossiers samen in één gestructureerde omgeving.",
"featureClinicsTitle": "Voor Klinieken",
"featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.",
"featureLabsTitle": "Voor Laboratoria",
@@ -926,26 +925,20 @@
"voiceProcessing": "Opname wordt gelezen…",
"voiceReviewTitle": "Controleer wat is begrepen",
"voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.",
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
"voiceNotUnderstood": "Niet begrepen",
"voicePickTooth": "Welk element?",
"voicePickJaw": "Welke kaak?",
"voiceTypeForcedByProsthesis": "Prothesewerk bepaalt dit type — dit kan niet worden uitgevinkt.",
"voiceTeethAndProsthesis": "Elementen en prothese",
"voiceNoProsthesisHeard": "geen prothese gehoord",
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",
"voiceDiscard": "Verwerpen",
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
"voiceUnresolved": {
"not_permanent_tooth": "geen blijvend element",
"position_out_of_range": "geen geldige elementpositie",
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”",
"prosthesis_type_ambiguous": "een algemene term, geen specifiek materiaal — kies hieronder",
"arch_not_spoken": "voor welke kaak was dit? — kies hieronder",
"code_not_valid_for_target": "past niet bij dat element of die kaak",
"malformed": "kon niet worden gelezen",
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
"tooth_not_selected": "dat element hoort niet bij dit onderdeel",
"invalid_date": "geen bruikbare datum"
},
"voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw."

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint",
"test": "vitest run"
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -37,7 +36,6 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.7"
"typescript": "^5"
}
}

View File

@@ -52,12 +52,11 @@ export default function HomePage() {
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight">
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-semibold mb-4 sm:mb-6 leading-tight text-balance max-w-3xl mx-auto">
{t('heroTitle')}
<span className="text-primary"> {t('heroHighlight')}</span>
</h1>
<p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-2xl mx-auto">
<p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-3xl mx-auto">
{t('heroSubtitle')}
</p>

View File

@@ -1,203 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
canStackLeaf,
catalogByCode,
effectiveChartRegion,
PARTIAL_DENTURE_CODE,
removeTeethFromGroups,
toothRegionColors,
} from './prosthesisTree';
import type { FdiToothId } from '@/types/treatment';
import type { ToothSelectionGroup } from './toothSelectionGroups';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'monolithic_zirconia',
sortOrder: 2,
label: 'Monolithic Zirconia',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 3,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'screw_retained',
sortOrder: 4,
label: 'Screw Retained',
category: 'implant',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 5,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: PARTIAL_DENTURE_CODE,
sortOrder: 6,
label: 'Partial Denture',
category: 'removable',
subcategory: '',
chartRegion: 'arch',
stackGroup: 'arch',
},
{
code: 'night_guard_soft',
sortOrder: 7,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const byCode = catalogByCode(CATALOG);
describe('canStackLeaf', () => {
it('allows an implant plus a crown restoration on the same tooth', () => {
expect(canStackLeaf(['zirconia_abutment'], 'pfm_crown', byCode)).toBe(true);
});
it('refuses a post & core alongside an implant', () => {
expect(canStackLeaf(['zirconia_abutment'], 'cast_post_core', byCode)).toBe(false);
});
it('refuses an implant alongside a post & core', () => {
expect(canStackLeaf(['cast_post_core'], 'zirconia_abutment', byCode)).toBe(false);
});
it('refuses a second restoration once screw-retained already paints the crown', () => {
expect(canStackLeaf(['screw_retained'], 'pfm_crown', byCode)).toBe(false);
});
it('allows two restorations to replace each other (no illegal stack)', () => {
expect(canStackLeaf(['pfm_crown'], 'monolithic_zirconia', byCode)).toBe(true);
});
it('refuses a code the catalog does not have', () => {
expect(canStackLeaf([], 'gold_foil', byCode)).toBe(false);
});
});
describe('applyLeafToJobs', () => {
it('stacks an implant and a crown restoration on one tooth', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'pfm_crown', byCode);
expect(jobs.sort()).toEqual(['pfm_crown', 'zirconia_abutment'].sort());
});
it('a same-stack-group leaf replaces rather than stacking beside the old one', () => {
// PFM previewed on 13, then PFZ heard for the same tooth: the second live test this repo
// ran on real recordings — the first stack rule bug that had to be fixed.
const jobs = applyLeafToJobs(['pfm_crown'], 'monolithic_zirconia', byCode);
expect(jobs).toEqual(['monolithic_zirconia']);
});
it('leaves the jobs untouched when the stack rules refuse the leaf', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'cast_post_core', byCode);
expect(jobs).toEqual(['zirconia_abutment']);
});
});
describe('effectiveChartRegion', () => {
it('overrides partial_denture to crown even though its catalog chartRegion is arch', () => {
expect(effectiveChartRegion({ code: PARTIAL_DENTURE_CODE, chartRegion: 'arch' })).toBe('crown');
});
it('leaves every other code as the catalog says', () => {
expect(effectiveChartRegion({ code: 'zirconia_abutment', chartRegion: 'root' })).toBe('root');
});
});
describe('toothRegionColors', () => {
it('paints a crown-region code into crownColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'pfm_crown' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeUndefined();
});
it('paints a root-region code into rootColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'zirconia_abutment' }],
CATALOG,
);
expect(root['12']).toBeTruthy();
expect(crown['12']).toBeUndefined();
});
it('paints both crown and root for an arch-region code on a real tooth', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'night_guard_soft' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeTruthy();
});
it('skips jaw sentinel rows — they have no crown or root to paint', () => {
const { crown, root } = toothRegionColors(
[
{ tooth: ARCH_TOOTH_UPPER, prosthesisTypeCode: 'night_guard_soft' },
{ tooth: ARCH_TOOTH_LOWER, prosthesisTypeCode: 'night_guard_soft' },
],
CATALOG,
);
expect(Object.keys(crown)).toHaveLength(0);
expect(Object.keys(root)).toHaveLength(0);
});
});
describe('removeTeethFromGroups', () => {
const bridge: ToothSelectionGroup = {
groupId: 'g1',
kind: 'connected',
teeth: ['14', '13', '12'] as FdiToothId[],
};
it('does not leave a pontic-less bridge when a middle tooth goes', () => {
// pruneDetailTeethToJobs routes through here, so this is the voice path: a job removed
// from 13 must not leave 12 and 14 wired together as one unit.
const out = removeTeethFromGroups([bridge], ['13'] as FdiToothId[]);
expect(out.every((g) => g.kind === 'single')).toBe(true);
expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14']));
expect(new Set(out.map((g) => g.groupId)).size).toBe(2);
});
it('keeps the remaining span when an end tooth goes', () => {
const out = removeTeethFromGroups([bridge], ['12'] as FdiToothId[]);
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('connected');
expect(out[0].teeth).toEqual(['14', '13']);
});
});

View File

@@ -6,7 +6,6 @@ import {
deriveTeethFromGroups,
groupsFromFlatTeeth,
newGroupId,
splitDisconnectedRuns,
} from '@/components/treatment/toothSelectionGroups';
const PROSTHESIS_CATEGORY_ORDER = [
@@ -338,9 +337,11 @@ export function removeTeethFromGroups(
for (const group of groups) {
const kept = group.teeth.filter((t) => !drop.has(t));
if (kept.length === 0) continue;
// Removing a middle tooth can leave a connected group non-contiguous, which is no longer
// one bridge. splitDisconnectedRuns keeps the runs that are still spans.
next.push(...splitDisconnectedRuns({ ...group, teeth: kept }));
if (kept.length === 1) {
next.push({ groupId: group.groupId, kind: 'single', teeth: kept });
} else {
next.push({ ...group, teeth: kept, kind: group.kind === 'connected' ? 'connected' : 'single' });
}
}
return next.length > 0 ? next : groupsFromFlatTeeth([]);
}

View File

@@ -1,131 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { FdiToothId } from '@/types/treatment';
import type { ToothSelectionGroup } from './toothSelectionGroups';
import {
areArchNeighbors,
connectedTeethSet,
groupsFromFlatTeeth,
linkedEdgesFromGroups,
splitDisconnectedRuns,
} from './toothSelectionGroups';
const bridge = (teeth: string[], groupId = 'g1'): ToothSelectionGroup => ({
groupId,
kind: 'connected',
teeth: teeth as FdiToothId[],
});
describe('splitDisconnectedRuns', () => {
it('leaves a contiguous bridge alone, keeping its id', () => {
// 14-13-12 are consecutive in FDI_UPPER_LEFT_TO_RIGHT.
const out = splitDisconnectedRuns(bridge(['12', '13', '14']));
expect(out).toEqual([
{ groupId: 'g1', kind: 'connected', teeth: ['14', '13', '12'] },
]);
});
it('splits a bridge that lost its middle tooth into two singles', () => {
// The reported case: a 12-13-14 span reduced to 12 and 14 is not a bridge, it is two
// separate crowns. 13 is the pontic, and it is gone.
const out = splitDisconnectedRuns(bridge(['12', '14']));
expect(out.map((g) => [g.kind, g.teeth])).toEqual([
['single', ['14']],
['single', ['12']],
]);
});
it('keeps each side that is still a span', () => {
// 16-15-14 | gap at 13 | 12-11
const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15', '16']));
expect(out.map((g) => [g.kind, g.teeth])).toEqual([
['connected', ['16', '15', '14']],
['connected', ['12', '11']],
]);
});
it('gives the first run the original id and the rest fresh ids', () => {
const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15'], 'keep-me'));
expect(out[0].groupId).toBe('keep-me');
expect(out[1].groupId).not.toBe('keep-me');
expect(out[1].groupId).toBeTruthy();
});
it('never emits a one-tooth connected group', () => {
const out = splitDisconnectedRuns(bridge(['12']));
expect(out).toEqual([{ groupId: 'g1', kind: 'single', teeth: ['12'] }]);
});
it('splits a bridge spanning two arches, which is never one span', () => {
// areArchNeighbors is false across arches, so an upper tooth and a lower one cannot be
// consecutive whatever their numbers.
const out = splitDisconnectedRuns(bridge(['12', '42']));
expect(out.every((g) => g.kind === 'single')).toBe(true);
expect(out).toHaveLength(2);
});
it('passes a single group through untouched', () => {
const single: ToothSelectionGroup = {
groupId: 'g2',
kind: 'single',
teeth: ['26'] as FdiToothId[],
};
expect(splitDisconnectedRuns(single)).toEqual([single]);
});
});
describe('groupsFromFlatTeeth', () => {
it('does not keep a bridge across a tooth that is no longer selected', () => {
// This is what produced a pontic-less bridge: the filter dropped 13 and the group kept
// its `connected` kind, so both crowns shared one selectionGroupId.
const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[], [
bridge(['12', '13', '14']),
]);
expect(out.some((g) => g.kind === 'connected')).toBe(false);
expect(connectedTeethSet(out).size).toBe(0);
expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14']));
// Two crowns must not share a selection group, or the lab builds them as one unit.
expect(new Set(out.map((g) => g.groupId)).size).toBe(2);
});
it('keeps a bridge whose teeth all survive', () => {
const out = groupsFromFlatTeeth(['12', '13', '14'] as FdiToothId[], [
bridge(['12', '13', '14']),
]);
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('connected');
});
it('adds a tooth no existing group covers', () => {
const out = groupsFromFlatTeeth(['12', '13', '26'] as FdiToothId[], [
bridge(['12', '13']),
]);
expect(connectedTeethSet(out)).toEqual(new Set(['12', '13']));
expect(out.find((g) => g.teeth.includes('26' as FdiToothId))?.kind).toBe('single');
});
it('makes one single group per tooth when there is nothing to preserve', () => {
const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[]);
expect(out.map((g) => g.kind)).toEqual(['single', 'single']);
});
});
describe('linkedEdgesFromGroups', () => {
it('draws no edge across a gap, so the split matches what was already drawn', () => {
// The marks were already correct before the split — only the group kind and its shared
// id were wrong. This pins that the two now agree.
expect(linkedEdgesFromGroups([bridge(['12', '14'])]).size).toBe(0);
expect(linkedEdgesFromGroups([bridge(['12', '13'])]).size).toBe(1);
});
});
describe('areArchNeighbors', () => {
it('reads adjacency from arch order, not from the numbers', () => {
// 12 and 13 are neighbours; 12 and 14 are two apart with 13 between them.
expect(areArchNeighbors('12' as FdiToothId, '13' as FdiToothId)).toBe(true);
expect(areArchNeighbors('12' as FdiToothId, '14' as FdiToothId)).toBe(false);
// Across the midline, 11 and 21 are adjacent even though the numbers jump.
expect(areArchNeighbors('11' as FdiToothId, '21' as FdiToothId)).toBe(true);
// Across arches, never.
expect(areArchNeighbors('12' as FdiToothId, '42' as FdiToothId)).toBe(false);
});
});

View File

@@ -58,8 +58,11 @@ export function groupsFromFlatTeeth(
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) => ({ ...g, teeth: sortInArchOrder(g.teeth) }))
.flatMap(splitDisconnectedRuns);
.map((g) => ({
...g,
kind: normalizeGroupKind(g.kind, g.teeth),
teeth: sortInArchOrder(g.teeth),
}));
const covered = new Set(next.flatMap((g) => g.teeth));
for (const tooth of teeth) {
if (!covered.has(tooth)) {
@@ -118,37 +121,6 @@ export function areArchNeighbors(a: FdiToothId, b: FdiToothId): boolean {
return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1;
}
/**
* A bridge is a contiguous span, so a connected group that has lost a middle tooth is no longer
* one bridge. `linkedEdgesFromGroups` already refuses to draw an edge between non-neighbours, but
* the group keeps its `connected` kind and its single `groupId` — and that id becomes the
* `selectionGroupId` on every lab row, so task generation would build one unit spanning the gap.
* A "bridge" from 12 to 14 with no pontic is work no lab can make.
*
* Split what is left into contiguous runs; a run of one tooth becomes a single. The first run
* keeps the original `groupId`, so lab rows already pointing at it stay valid, and
* `pruneToothProsthesisForGroups` re-maps the rest.
*/
export function splitDisconnectedRuns(group: ToothSelectionGroup): ToothSelectionGroup[] {
if (group.kind !== 'connected' || group.teeth.length < 2) {
return [{ ...group, kind: normalizeGroupKind(group.kind, group.teeth) }];
}
const ordered = sortInArchOrder(group.teeth);
const runs: FdiToothId[][] = [[ordered[0]]];
for (let i = 1; i < ordered.length; i++) {
if (areArchNeighbors(ordered[i - 1], ordered[i])) {
runs[runs.length - 1].push(ordered[i]);
} else {
runs.push([ordered[i]]);
}
}
return runs.map((teeth, index) => ({
groupId: index === 0 ? group.groupId : newGroupId(),
kind: teeth.length >= 2 ? ('connected' as const) : ('single' as const),
teeth,
}));
}
function archOrder(tooth: FdiToothId): FdiToothId[] | null {
if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) {
return FDI_UPPER_LEFT_TO_RIGHT;
@@ -313,7 +285,10 @@ export function applyShiftRange(
teeth: g.teeth.filter((t) => !union.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.flatMap(splitDisconnectedRuns);
.map((g) => ({
...g,
kind: normalizeGroupKind(g.kind, g.teeth),
}));
for (const tooth of unionTeeth) {
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });

View File

@@ -1,273 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { VoiceExtractionResult } from '@/types/voice';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from './prosthesisTree';
import {
countSelected,
hasAnythingToApply,
initialVoiceSelection,
isLabDependentResult,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth,
} from './voiceReviewRows';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 2,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 3,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: 'night_guard_soft',
sortOrder: 4,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const LAB_DEPENDENT = new Set(['prosthesis']);
function baseResult(overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult {
return {
treatmentType: 'restoration',
teeth: [],
toothSelectionGroups: [],
comment: null,
prosthesisAssignments: [],
labId: null,
labMatchExact: false,
dueDate: null,
unresolved: [],
...overrides,
};
}
describe('isLabDependentResult', () => {
it('is true only when the resolved type is in the labDependent set', () => {
expect(isLabDependentResult(baseResult({ treatmentType: 'prosthesis' }), LAB_DEPENDENT)).toBe(
true,
);
expect(isLabDependentResult(baseResult({ treatmentType: 'restoration' }), LAB_DEPENDENT)).toBe(
false,
);
});
});
describe('voiceRowAvailability', () => {
it('shows a plain teeth row for a non-lab-dependent type', () => {
const result = baseResult({ teeth: ['14'] });
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(true);
expect(available.prosthesis).toBe(false);
});
it('merges teeth and prosthesis into one row for a lab-dependent type', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(false);
expect(available.prosthesis).toBe(true);
});
it('shows the merged row for a jaw appliance with no teeth at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' },
],
});
expect(voiceRowAvailability(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
});
describe('initialVoiceSelection', () => {
it('ticks an available lab-dependent prosthesis row even when the stack is incomplete', () => {
// All-or-nothing is retired (decision 40) — an incomplete map no longer blocks a tick.
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: '' }],
});
expect(initialVoiceSelection(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
it('never ticks lab when the match was inexact', () => {
const result = baseResult({ labId: 'lab-1', labMatchExact: false });
expect(initialVoiceSelection(result, LAB_DEPENDENT).lab).toBe(false);
});
});
describe('countSelected', () => {
it('intersects the selection with availability rather than counting raw ticks', () => {
const selection = { treatmentType: true, teeth: true, comment: true, prosthesis: true, lab: true, dueDate: true };
const available = { treatmentType: true, teeth: false, comment: true, prosthesis: false, lab: true, dueDate: false };
expect(countSelected(selection, available)).toBe(3);
});
});
describe('withChosenTeeth', () => {
it('folds a plain candidate into the top-level teeth list', () => {
const result = baseResult({ teeth: ['14'] });
const next = withChosenTeeth(result, ['26']);
expect(next.teeth).toEqual(['14', '26']);
});
it('folds an assignment-scoped candidate into that assignment target list, not the plain list', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['pfm_crown'], spoken: 'دندون دو روکش' }],
});
const next = withChosenTeeth(result, ['12'], 0);
expect(next.prosthesisAssignments[0].targets).toEqual(['12']);
expect(next.teeth).toEqual([]);
});
});
describe('withChosenArch', () => {
it('folds a picked jaw into the assignment that named no jaw at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['night_guard_soft'], spoken: 'نایت گارد' }],
});
const next = withChosenArch(result, 0, 'upper');
expect(next.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
});
it('picking both jaws is how a both-jaw appliance is expressed', () => {
let next = withChosenArch(
baseResult({ prosthesisAssignments: [{ targets: [], types: [], spoken: '' }] }),
0,
'upper',
);
next = withChosenArch(next, 0, 'lower');
expect(next.prosthesisAssignments[0].targets.sort()).toEqual(
[ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER].sort(),
);
});
});
describe('withChosenProsthesisLeaf', () => {
it('supplies the missing leaf to the assignment that only named a category', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: 'روکش' }],
});
const next = withChosenProsthesisLeaf(result, 0, 'pfm_crown');
expect(next.prosthesisAssignments[0].types).toEqual(['pfm_crown']);
// A resolved assignment forces the type — the row locks exactly as the backend does.
expect(next.treatmentType).toBe('prosthesis');
});
});
describe('prosthesisTargetLines', () => {
it('previews the stack that will actually land, through applyLeafToJobs', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'pfm_crown'], spoken: '' }],
CATALOG,
);
expect(lines).toEqual([{ target: '12', isJaw: false, applied: ['zirconia_abutment', 'pfm_crown'], refused: [] }]);
});
it('names a refused job rather than silently dropping or silently applying it', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'cast_post_core'], spoken: '' }],
CATALOG,
);
expect(lines[0].applied).toEqual(['zirconia_abutment']);
expect(lines[0].refused).toEqual(['cast_post_core']);
});
it('marks a jaw target as such', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
expect(lines[0].isJaw).toBe(true);
});
});
describe('joblessProsthesisTargets', () => {
it('names a tooth left in the plain teeth list with no matching assignment', () => {
const result = baseResult({
teeth: ['12', '13'],
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('names an assignment target whose types were empty from the start', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('does not call a target jobless while it is pending a material pick', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }],
unresolved: [{ spoken: 'روکش', reason: 'prosthesis_type_ambiguous', assignmentIndex: 0 }],
});
expect(joblessProsthesisTargets(result)).toEqual([]);
});
});
describe('prosthesisChartData', () => {
it('derives the arch highlight from applied jaw jobs, upper and lower alike', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, [], CATALOG);
expect(data.archHighlight).toBe('both');
expect(data.selectedTeeth.size).toBe(0);
});
it('collects every real tooth, applied or jobless, into selectedTeeth', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, ['13'], CATALOG);
expect([...data.selectedTeeth].sort()).toEqual(['12', '13']);
});
});
describe('hasAnythingToApply', () => {
it('is false when the recording produced nothing usable', () => {
expect(hasAnythingToApply(baseResult({ treatmentType: null }), LAB_DEPENDENT)).toBe(false);
});
it('is true once any row is available', () => {
expect(hasAnythingToApply(baseResult({ teeth: ['14'] }), LAB_DEPENDENT)).toBe(true);
});
});

View File

@@ -1,73 +1,35 @@
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
archSentinels,
canStackLeaf,
catalogByCode,
isArchSentinel,
toothRegionColors,
type ArchTarget,
} from '@/components/treatment/prosthesisTree';
import type { FdiToothId } from '@/types/treatment';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceProsthesisAssignment,
VoiceUnresolvedItem,
VoiceProsthesisResult,
} from '@/types/voice';
/** `prosthesis` is the app's only labDependent treatment type today, but this stays generic. */
export function isLabDependentResult(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Boolean(result.treatmentType && labDependentCodes.has(result.treatmentType));
}
function hasProsthesisWork(result: VoiceExtractionResult): boolean {
return result.prosthesisAssignments.some((a) => a.targets.length > 0 || a.types.length > 0);
}
/**
* Which rows the review sheet renders at all — a row with nothing extracted is noise.
*
* Teeth and prosthesis are never both available: a lab-dependent type merges them into one
* `prosthesis` row (decision 39), because two independent ticks can save an empty detail —
* `persistDraft` prunes a lab-dependent detail to its jobs.
*/
export function voiceRowAvailability(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
) {
const labDependent = isLabDependentResult(result, labDependentCodes);
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
export function voiceRowAvailability(result: VoiceExtractionResult) {
return {
treatmentType: result.treatmentType != null,
teeth: !labDependent && result.teeth.length > 0,
prosthesis: labDependent && (result.teeth.length > 0 || hasProsthesisWork(result)),
teeth: result.teeth.length > 0,
comment: Boolean(result.comment?.trim()),
prosthesis: result.prosthesis != null,
lab: result.labId != null,
dueDate: result.dueDate != null,
};
}
/**
* Everything available ticks itself, with one exception: an inexactly-matched lab, because it
* is the one extracted value whose error leaves the building. All-or-nothing prosthesis maps
* are retired (decision 40) — an incomplete stack no longer blocks a tick.
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
* it is the one extracted value whose error leaves the building; and an incomplete
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
*/
export function initialVoiceSelection(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): VoiceApplySelection {
const available = voiceRowAvailability(result, labDependentCodes);
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
const available = voiceRowAvailability(result);
return {
treatmentType: available.treatmentType,
teeth: available.teeth,
comment: available.comment,
prosthesis: available.prosthesis,
prosthesis: available.prosthesis && result.prosthesis?.complete === true,
lab: available.lab && result.labMatchExact,
dueDate: available.dueDate,
};
@@ -86,75 +48,37 @@ export function countSelected(
).length;
}
/** Every resolved assignment target, across every assignment. */
function allAssignmentTargets(result: VoiceExtractionResult): Set<string> {
return new Set(result.prosthesisAssignments.flatMap((a) => a.targets));
}
/** Forces treatmentType to `prosthesis` the moment any assignment carries a real target. */
function withProsthesisForced(result: VoiceExtractionResult): VoiceExtractionResult {
const forced = result.prosthesisAssignments.some((a) => a.targets.length > 0);
return forced ? { ...result, treatmentType: 'prosthesis' } : result;
/** 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 a picked FDI/quadrant candidate into the result.
*
* `assignmentIndex` set: the chip came from resolving a `prosthesisAssignments` entry, so the
* pick becomes that assignment's target — inheriting its `types`, not a jobless tooth
* (decision 50). `assignmentIndex` absent: the chip came from the plain `teeth` list and folds
* in there, exactly as before.
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
*
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
* produced, and tapping it must not deselect that one.
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
*/
export function withChosenTeeth(
result: VoiceExtractionResult,
chosen: readonly FdiToothId[],
assignmentIndex?: number,
): VoiceExtractionResult {
if (chosen.length === 0) return result;
if (assignmentIndex != null) {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, ...chosen])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
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,
};
}
/** Fold a picked jaw ('upper' / 'lower') into the assignment that named no jaw at all. */
export function withChosenArch(
result: VoiceExtractionResult,
assignmentIndex: number,
arch: 'upper' | 'lower',
): VoiceExtractionResult {
const sentinel = arch === 'upper' ? ARCH_TOOTH_UPPER : ARCH_TOOTH_LOWER;
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, sentinel])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Fold a picked leaf into the assignment that only named a category or subcategory. */
export function withChosenProsthesisLeaf(
result: VoiceExtractionResult,
assignmentIndex: number,
leafCode: string,
): VoiceExtractionResult {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, types: [...new Set([...a.types, leafCode])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> {
const connected = new Set<FdiToothId>();
@@ -165,138 +89,7 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
return connected;
}
export type VoiceProsthesisTargetLine = {
target: string;
isJaw: boolean;
/** Leaf codes that will actually land, in landing order. */
applied: string[];
/** Leaf codes the stack rules refused — shown struck through, never silently dropped. */
refused: string[];
};
/**
* The stack that will actually land, built through `applyLeafToJobs` — the same function the
* manual chart writes through (§7). A code the stack rules refuse (an implant plus a post &
* core on one tooth) is named as refused rather than silently dropped or silently applied.
*/
export function prosthesisTargetLines(
assignments: readonly VoiceProsthesisAssignment[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisTargetLine[] {
const byCode = catalogByCode(catalog);
const byTarget = new Map<string, { applied: string[]; refused: string[] }>();
for (const assignment of assignments) {
for (const target of assignment.targets) {
const entry = byTarget.get(target) ?? { applied: [], refused: [] };
for (const code of assignment.types) {
if (canStackLeaf(entry.applied, code, byCode)) {
entry.applied = applyLeafToJobs(entry.applied, code, byCode);
} else {
entry.refused.push(code);
}
}
byTarget.set(target, entry);
}
}
return [...byTarget.entries()].map(([target, { applied, refused }]) => ({
target,
isJaw: isArchSentinel(target),
applied,
refused,
}));
}
/**
* FDI codes / jaw sentinels named somewhere (an assignment target, or the plain `teeth` list)
* but ending up with no job at all — struck through in the sheet reading "no prosthesis heard"
* (decision 40). A target still pending a material pick (a `prosthesis_type_ambiguous` chip
* for its assignment) is not jobless; it is simply not resolved yet.
*/
export function joblessProsthesisTargets(result: VoiceExtractionResult): string[] {
const pendingIndexes = new Set(
result.unresolved
.filter((u) => u.reason === 'prosthesis_type_ambiguous' && u.assignmentIndex != null)
.map((u) => u.assignmentIndex as number),
);
const jobless = new Set<string>();
const covered = new Set<string>();
const pending = new Set<string>();
result.prosthesisAssignments.forEach((assignment, index) => {
if (pendingIndexes.has(index)) {
for (const target of assignment.targets) pending.add(target);
} else if (assignment.types.length === 0) {
for (const target of assignment.targets) jobless.add(target);
} else {
for (const target of assignment.targets) covered.add(target);
}
});
for (const tooth of result.teeth) {
if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth);
}
return [...jobless];
}
export type VoiceProsthesisChartData = {
crownColors: Partial<Record<FdiToothId, string>>;
rootColors: Partial<Record<FdiToothId, string>>;
archHighlight: ArchTarget | null;
/** Every real tooth involved — a target with a job, or a jobless one named alongside it. */
selectedTeeth: Set<FdiToothId>;
};
/** Feeds the merged row's `FdiToothChart` — crown/root tints plus the arch highlight. */
export function prosthesisChartData(
lines: readonly VoiceProsthesisTargetLine[],
jobless: readonly string[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisChartData {
const rows = lines.flatMap((line) =>
line.applied.map((code) => ({ tooth: line.target, prosthesisTypeCode: code })),
);
const { crown, root } = toothRegionColors(rows, catalog);
const hasUpper = lines.some((l) => l.target === ARCH_TOOTH_UPPER && l.applied.length > 0);
const hasLower = lines.some((l) => l.target === ARCH_TOOTH_LOWER && l.applied.length > 0);
const archHighlight: ArchTarget | null =
hasUpper && hasLower ? 'both' : hasUpper ? 'upper' : hasLower ? 'lower' : null;
const selectedTeeth = new Set<FdiToothId>();
for (const line of lines) {
if (!line.isJaw) selectedTeeth.add(line.target as FdiToothId);
}
for (const tooth of jobless) {
if (!isArchSentinel(tooth)) selectedTeeth.add(tooth as FdiToothId);
}
return { crownColors: crown, rootColors: root, archHighlight, selectedTeeth };
}
/** Which unresolved items belong to which chip section — the sheet renders both identically. */
export function unresolvedWithoutAssignment(
result: VoiceExtractionResult,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex == null);
}
export function unresolvedForAssignment(
result: VoiceExtractionResult,
assignmentIndex: number,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex === assignmentIndex);
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Object.values(voiceRowAvailability(result, labDependentCodes)).some(Boolean);
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
return Object.values(voiceRowAvailability(result)).some(Boolean);
}
export { archSentinels, allAssignmentTargets };

View File

@@ -31,7 +31,6 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { voiceApi } from '@/lib/api/voice';
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
import { isMediaRecorderSupported } from '@/lib/voice/audioFormat';
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
import type {
VoiceApplySelection,
@@ -62,7 +61,6 @@ import {
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -551,13 +549,6 @@ export function TreatmentWorkspace({
const draftHydratingRef = useRef(false);
const workspaceModeRef = useRef(workspaceMode);
workspaceModeRef.current = workspaceMode;
/**
* `persistDraft` prunes a lab-dependent detail down to the teeth its jobs cover, and it reads
* this ref — not React state. So any handler that changes the drafts and then saves in the
* same tick must write the ref beside `setLabCaseDrafts`; the render-time assignment below has
* not run yet. Skipping it saves the detail with no teeth, and the lab-case request that
* follows is then rejected with TREATMENT_TOOTH_NOT_ON_DETAIL.
*/
const labCaseDraftsRef = useRef(labCaseDrafts);
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
@@ -630,18 +621,9 @@ export function TreatmentWorkspace({
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
});
/**
* Absence is the unavailable state — the Add button then renders unsplit. Gated on both the
* server's availability response AND the browser's own recording support: without the
* latter check the control rendered on a browser that cannot record and failed on tap
* (the Safari report that started this revision, §2).
*/
/** Absence is the unavailable state — the Add button then renders unsplit. */
const voiceForEditor =
voiceAvailability?.enabled &&
voiceAvailability.locales.includes(locale) &&
isMediaRecorderSupported()
? voice
: undefined;
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
const selectedStandalone = useMemo(
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
@@ -2210,16 +2192,7 @@ export function TreatmentWorkspace({
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
// Teeth and prosthesis are one row for a lab-dependent type (decision 39) — ticking
// "teeth" independently of "prosthesis" could save an empty detail, since
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
// alone drives both below; `selection.teeth` only ever applies to the plain row.
// Derived from the detail actually being written, never from `result.treatmentType`.
// Reading the result meant that unticking the type row still took the prosthesis branch,
// saving lab rows on whatever type the appointment purpose had seeded (decision 41).
const labDependent = labDependentCodes.has(detail.treatmentType);
if (!labDependent && selection.teeth) {
if (selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
@@ -2230,25 +2203,6 @@ export function TreatmentWorkspace({
detail.comment = result.comment;
}
// The stack that will actually land, built through the same `applyLeafToJobs` the
// manual chart writes through (§7) — a code the stack rules refuse is not applied.
const prosthesisLines =
labDependent && selection.prosthesis
? prosthesisTargetLines(result.prosthesisAssignments, prosthesisCatalog)
: [];
// An assignment target is a selection: a tooth exists on a prosthesis detail only by
// carrying a job (decision 40) — never from the plain `teeth` field.
const prosthesisTeeth = prosthesisLines
.filter((line) => !line.isJaw && line.applied.length > 0)
.map((line) => line.target as FdiToothId);
if (labDependent && selection.prosthesis) {
detail.teeth = prosthesisTeeth;
detail.toothSelectionGroups = groupsFromFlatTeeth(
prosthesisTeeth,
result.toothSelectionGroups,
);
}
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet.
@@ -2258,7 +2212,7 @@ export function TreatmentWorkspace({
// 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 =
prosthesisLines.some((line) => line.applied.length > 0) ||
(selection.prosthesis && result.prosthesis) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
@@ -2271,21 +2225,27 @@ export function TreatmentWorkspace({
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
draft.toothProsthesis = prosthesisLines.flatMap((line) => {
const groupId = line.isJaw
? ''
: (detail.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(line.target),
)?.groupId ?? '');
return line.applied.map((prosthesisTypeCode) => ({
detailClientId: detail.clientId,
tooth: line.target,
prosthesisTypeCode,
selectionGroupId: groupId,
}));
});
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];
labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases);
// Persist the new detail first so lab-case rows can use real treatmentDetailIds.
@@ -2309,10 +2269,8 @@ export function TreatmentWorkspace({
},
[
labCaseDrafts,
labDependentCodes,
persistDraft,
persistLabCases,
prosthesisCatalog,
selectedAppointment?.purpose,
showError,
t,
@@ -2440,7 +2398,6 @@ export function TreatmentWorkspace({
const updatedLabCases = cleaned.map((lc) =>
lc.clientId === orphan.clientId ? { ...lc, detailClientId: activeDetailId } : lc,
);
labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(orphan.clientId);
@@ -2459,7 +2416,6 @@ export function TreatmentWorkspace({
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(next.clientId);
@@ -3246,7 +3202,6 @@ export function TreatmentWorkspace({
result={voiceResult}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labDependentCodes={labDependentCodes}
labs={orgs}
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onDiscard={() => setVoiceResult(null)}

View File

@@ -10,44 +10,31 @@ import {
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '@/components/treatment/prosthesisTree';
import {
connectedTeethFromResult,
countSelected,
hasAnythingToApply,
initialVoiceSelection,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth,
} from '@/components/treatment/voiceReviewRows';
import { useLocale } from 'next-intl';
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceUnresolvedItem,
} from '@/types/voice';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
interface VoiceReviewSheetProps {
result: VoiceExtractionResult;
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[];
/** `prosthesis` today, but kept generic — the same set the workspace already tracks. */
labDependentCodes: ReadonlySet<string>;
labs: LinkedOrganizationOption[];
/** The result is handed back because the sheet may have added teeth or jobs the model missed. */
/** The result is handed back because the sheet may have added teeth the model missed. */
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onDiscard: () => void;
}
type ArchPick = 'upper' | 'lower';
/**
* Confirmation step between the model's output and the form.
*
@@ -58,152 +45,50 @@ export function VoiceReviewSheet({
result,
treatmentCatalog,
prosthesisCatalog,
labDependentCodes,
labs,
onApply,
onDiscard,
}: VoiceReviewSheetProps) {
const t = useTranslations('treatment');
const locale = useLocale();
const { formatDate } = useAppFormatters();
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
initialVoiceSelection(result, labDependentCodes),
initialVoiceSelection(result),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
// Every kind of candidate chip the sheet can offer, tracked separately because each folds
// into the result a different way (decision 50). Toggling off removes only the clinician's
// own pick — nothing the recording already produced is ever un-added.
const [chosenTeeth, setChosenTeeth] = useState<FdiToothId[]>([]);
const [chosenAssignmentTeeth, setChosenAssignmentTeeth] = useState<Record<number, FdiToothId[]>>(
{},
);
const [chosenArches, setChosenArches] = useState<Record<number, ArchPick[]>>({});
const [chosenLeaves, setChosenLeaves] = useState<Record<number, string[]>>({});
// 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]);
// Everything below renders from `effective`, never from `result` — a candidate picked from
// the chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => {
let next = withChosenTeeth(result, chosenTeeth);
for (const [index, teeth] of Object.entries(chosenAssignmentTeeth)) {
next = withChosenTeeth(next, teeth, Number(index));
}
for (const [index, arches] of Object.entries(chosenArches)) {
for (const arch of arches) next = withChosenArch(next, Number(index), arch);
}
for (const [index, leaves] of Object.entries(chosenLeaves)) {
for (const leaf of leaves) next = withChosenProsthesisLeaf(next, Number(index), leaf);
}
return next;
}, [result, chosenTeeth, chosenAssignmentTeeth, chosenArches, chosenLeaves]);
const available = useMemo(
() => voiceRowAvailability(effective, labDependentCodes),
[effective, labDependentCodes],
);
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
const prosthesisLines = useMemo(
() => prosthesisTargetLines(effective.prosthesisAssignments, prosthesisCatalog),
[effective, prosthesisCatalog],
);
const joblessTargets = useMemo(() => joblessProsthesisTargets(effective), [effective]);
const chartData = useMemo(
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
[prosthesisLines, joblessTargets, prosthesisCatalog],
);
// Decision 41: prosthesis jobs exist on one treatment type only, so a ticked prosthesis row
// pins the type row. Locking it is what makes "restoration detail carrying lab rows"
// unreachable rather than merely discouraged.
const typeForcedByProsthesis =
available.prosthesis && selection.prosthesis && effective.treatmentType != null;
const nothingToApply = !hasAnythingToApply(effective);
const selectedCount = countSelected(selection, available);
// What Apply actually sends. The locked type row is ticked on screen, so it has to be
// ticked in the count and in the payload alike — counting the raw `selection` showed
// "Apply 1" while two fields landed.
const effectiveSelection: VoiceApplySelection = typeForcedByProsthesis
? { ...selection, treatmentType: true }
: selection;
const nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
const selectedCount = countSelected(effectiveSelection, available);
const targetLabel = (target: string): string => {
if (target === ARCH_TOOTH_UPPER) return t('upperArch');
if (target === ARCH_TOOTH_LOWER) return t('lowerArch');
return target;
const pickCandidate = (tooth: FdiToothId) => {
const nextChosen = chosen.includes(tooth)
? chosen.filter((t) => t !== tooth)
: [...chosen, tooth];
setChosen(nextChosen);
setSelection((prev) => ({
...prev,
// The teeth row starts unticked whenever the recording produced no teeth of its own,
// and a picked tooth that is not ticked applies nothing.
teeth: true,
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
// clinician's call.
prosthesis:
prev.prosthesis &&
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
}));
};
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
const isPicked = (item: VoiceUnresolvedItem, code: string): boolean => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken') {
return index != null && (chosenArches[index] ?? []).includes(code as ArchPick);
}
if (item.reason === 'prosthesis_type_ambiguous') {
return index != null && (chosenLeaves[index] ?? []).includes(code);
}
if (index != null) return (chosenAssignmentTeeth[index] ?? []).includes(code as FdiToothId);
return chosenTeeth.includes(code as FdiToothId);
};
/**
* A picked candidate has no meaning unless its own row is ticked, so each branch ticks the
* row it feeds. Never derive that from `available`: it is memoised from `effective`, which
* this handler is in the middle of changing, so it still reports the row as unavailable and
* the pick would apply nothing.
*/
const pickCandidate = (item: VoiceUnresolvedItem, code: string) => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken' && index != null) {
setChosenArches((prev) => {
const cur = prev[index] ?? [];
const arch = code as ArchPick;
return {
...prev,
[index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch],
};
});
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
if (item.reason === 'prosthesis_type_ambiguous' && index != null) {
setChosenLeaves((prev) => {
const cur = prev[index] ?? [];
return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] };
});
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
if (index != null) {
setChosenAssignmentTeeth((prev) => {
const cur = prev[index] ?? [];
const tooth = code as FdiToothId;
return {
...prev,
[index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth],
};
});
setSelection((prev) => ({ ...prev, prosthesis: true }));
return;
}
const tooth = code as FdiToothId;
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
setSelection((prev) => ({ ...prev, teeth: true }));
};
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
if (item.reason === 'arch_not_spoken') return code === 'upper' ? t('upperArch') : t('lowerArch');
if (item.reason === 'prosthesis_type_ambiguous') return labelFor(code, prosthesisCatalog);
return code;
};
const promptFor = (item: VoiceUnresolvedItem): string | null => {
if (item.reason === 'arch_not_spoken') return t('voicePickJaw');
if (item.reason === 'tooth_missing_quadrant') return t('voicePickTooth');
return null;
};
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
setSelection((prev) => ({ ...prev, [key]: checked }));
@@ -219,6 +104,10 @@ export function VoiceReviewSheet({
{t('voiceReviewTitle')}
</h2>
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
{effective.transcript}
</p>
{nothingToApply ? (
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
) : (
@@ -226,10 +115,8 @@ export function VoiceReviewSheet({
{available.treatmentType ? (
<Row
label={t('treatmentType')}
checked={selection.treatmentType || typeForcedByProsthesis}
checked={selection.treatmentType}
onChange={toggle('treatmentType')}
locked={typeForcedByProsthesis}
warning={typeForcedByProsthesis ? t('voiceTypeForcedByProsthesis') : undefined}
>
<span className="text-sm text-text-primary">
{labelFor(effective.treatmentType, treatmentCatalog)}
@@ -251,59 +138,6 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis ? (
<Row
label={t('voiceTeethAndProsthesis')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
>
<div className="mt-1">
<FdiToothChart
readOnly
compact
scale={0.55}
selected={chartData.selectedTeeth}
crownColors={chartData.crownColors}
rootColors={chartData.rootColors}
archHighlight={chartData.archHighlight}
/>
</div>
<p className="mt-1 text-sm text-text-primary">
{prosthesisLines.map((line, i) => (
<span key={`line-${line.target}`}>
{i > 0 ? ' · ' : ''}
{targetLabel(line.target)}:{' '}
{line.applied.map((code, j) => (
<span key={code}>
{j > 0 ? ' + ' : ''}
{labelFor(code, prosthesisCatalog)}
</span>
))}
{line.refused.map((code) => (
<span
key={code}
title={t('voiceStackRefused')}
className="text-text-muted line-through"
>
{' + '}
{labelFor(code, prosthesisCatalog)}
</span>
))}
</span>
))}
{joblessTargets.map((target, i) => (
<span
key={`jobless-${target}`}
className="text-text-muted line-through"
>
{prosthesisLines.length > 0 || i > 0 ? ' · ' : ''}
{targetLabel(target)}: {t('voiceNoProsthesisHeard')}
</span>
))}
</p>
</Row>
) : null}
{available.comment ? (
<Row
label={t('comments')}
@@ -316,6 +150,29 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis && effective.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
effective.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{Object.entries(effective.prosthesis.byTooth)
.map(
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
)
.join(' · ')}
</span>
</Row>
) : null}
{available.lab ? (
<Row
label={t('entryStepLab')}
@@ -355,29 +212,23 @@ export function VoiceReviewSheet({
{t(`voiceUnresolved.${item.reason}`)}
{item.candidates && item.candidates.length > 0 ? (
<span className="mt-1 flex flex-wrap items-center gap-1">
{promptFor(item) ? (
<span className="text-text-muted">{promptFor(item)}</span>
) : null}
{item.candidates.map((code) => {
const picked = isPicked(item, code);
<span className="text-text-muted">{t('voicePickTooth')}</span>
{item.candidates.map((tooth) => {
const picked = chosen.includes(tooth as FdiToothId);
return (
<button
key={code}
key={tooth}
type="button"
aria-pressed={picked}
aria-label={
item.reason === 'tooth_missing_quadrant'
? t('toothAria', { fdi: code })
: candidateLabel(item, code)
}
onClick={() => pickCandidate(item, code)}
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'
}`}
>
{candidateLabel(item, code)}
{tooth}
</button>
);
})}
@@ -397,7 +248,7 @@ export function VoiceReviewSheet({
type="button"
variant="primary"
disabled={selectedCount === 0}
onClick={() => onApply(effectiveSelection, effective)}
onClick={() => onApply(selection, effective)}
fullWidth
className="sm:w-auto"
>
@@ -414,20 +265,17 @@ function Row({
checked,
onChange,
warning,
locked,
children,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
warning?: string;
/** Ticked and not untickable — the value is implied by another row (decision 41). */
locked?: boolean;
children: React.ReactNode;
}) {
return (
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
<Checkbox checked={checked} onChange={onChange} label={label} disabled={locked} />
<Checkbox checked={checked} onChange={onChange} label={label} />
<div className="mt-1 ps-7 min-w-0">{children}</div>
{warning ? (
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
@@ -447,3 +295,12 @@ function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
function formatToothList(teeth: readonly string[], locale: string): string {
try {
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
} catch {
return teeth.join(', ');
}
}

View File

@@ -22,13 +22,7 @@ export function pickRecordingMimeType(): string | null {
for (const type of PREFERRED_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(type)) return type;
}
// None of the preferred containers passed `isTypeSupported` — a Safari version whose check
// exists but answers false for a container it can still record (e.g. plain `audio/mp4`).
// The preference list is not a requirement: fall back to the "let the browser choose" hint
// rather than refusing outright. `onstop` derives the real container from
// `recorder.mimeType`, so this is only wrong when the browser genuinely cannot record at
// all — and `new MediaRecorder()` / `recorder.start()` throwing is handled at the call site.
return '';
return null;
}
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */

View File

@@ -6,12 +6,10 @@ export type VoiceUnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
| 'tooth_missing_quadrant'
| 'prosthesis_type_ambiguous'
| 'arch_not_spoken'
| 'code_not_valid_for_target'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export interface VoiceUnresolvedItem {
@@ -19,42 +17,26 @@ export interface VoiceUnresolvedItem {
spoken: string;
reason: VoiceUnresolvedReason;
/**
* Values still consistent with what was heard, when a choice would settle it — the review
* sheet offers them as chips. FDI codes for `tooth_missing_quadrant`, leaf codes for
* `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`.
* 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[];
/**
* Set only when this item came from resolving a `prosthesisAssignments` entry. A picked
* chip then inherits that assignment's `types` (or supplies the missing leaf to it) instead
* of resolving to a jobless tooth.
*/
assignmentIndex?: number;
}
/**
* One spoken instruction, resolved: these targets — FDI codes, or `'UA'`/`'LA'` jaw sentinels
* (`ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` in `prosthesisTree.ts`) — get these leaf codes. Empty
* `types` means the target was named with no job at all (struck through in the sheet); empty
* `targets` with a `prosthesis_type_ambiguous` unresolved item at this index means the target
* is pending a material pick.
*/
export interface VoiceProsthesisAssignment {
targets: string[];
types: string[];
spoken: string;
export interface VoiceProsthesisResult {
byTooth: Record<string, string>;
/** False means the case cannot ship — every tooth needs a prosthesis type. */
complete: boolean;
missingTeeth: FdiToothId[];
}
export interface VoiceExtractionResult {
/**
* No transcript. A raw dictation can carry the patient's spoken name, so the server never
* sends it — it is logged there instead (spec §10).
*/
transcript: string;
treatmentType: string | null;
teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[];
comment: string | null;
prosthesisAssignments: VoiceProsthesisAssignment[];
prosthesis: VoiceProsthesisResult | null;
labId: string | null;
/** When false, the lab row must not tick itself — the name only approximately matched. */
labMatchExact: boolean;

View File

@@ -1,15 +0,0 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
/**
* Covers the pure helpers only — no React, no DOM. `@/*` mirrors `tsconfig.json`'s path so a
* spec can import the same modules the app does.
*/
export default defineConfig({
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
test: {
include: ['src/**/*.spec.ts'],
},
});

View File

@@ -26,6 +26,8 @@ https://nudentic.ir
`:latest` is **staging only** (wixur.ir baked in). Production compose must pin `TAG=v1.0.1`.
**Tags are immutable.** CI clones `--branch $tag`. Do not move/reuse an existing `v*` to pick up a Dockerfile or copy fix — cut a new version. The frontend standalone image must `COPY` `public/` (`frontend/Dockerfile`); without it, `/prosthesis-catalog/*.svg` 404s.
### One-time on the Linux server
1. **HTTP registry** — Gitea is `http://wixur.ir:3000`. In `/etc/docker/daemon.json`:
@@ -136,7 +138,7 @@ This pushes:
- `dyolink/dyolink-backend:latest`
- `dyolink/dyolink-frontend:latest`
**When to rebuild:** domain changes, frontend env (`NEXT_PUBLIC_*`) changes, or new app release.
**When to rebuild:** domain changes, frontend env (`NEXT_PUBLIC_*`) changes, files under `frontend/public/`, or a new app release.
---

View File

@@ -35,7 +35,7 @@ http://wixur.ir:3000 → Gitea (native, no Docker)
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
| nginx | `nginx:alpine` |
Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. After changing the public URL, re-run the Gitea workflow (or push to `master`) and set `FRONTEND_URL` in `C:\dyolink\secrets\backend.staging.env` to the same origin.
Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. After changing the public URL, re-run the Gitea workflow (or push to `master`) and set `FRONTEND_URL` in `C:\dyolink\secrets\backend.staging.env` to the same origin. The same `frontend/Dockerfile` must copy `public/` into the standalone image (catalog icons).
---