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