The treatment form was overhauled after voice v1 merged (a3c14a1,7f92e73,72f885d,5d3597f): one prosthesis type per tooth became stacked jobs, jaw-level appliances and a category tree. Voice still compiled against it but could no longer express it, and in two places wrote data the form itself refuses. Rewrites the extraction contract (§5), the resolver rules and the unresolved-reason table (§6), the review sheet (§7) and verification (§12), and records decisions 34-46. Adds the repos: block and a progress ledger so the task resolves from the branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1048 lines
58 KiB
Markdown
1048 lines
58 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 /treatments/voice-extract`
|
||
|
||
- 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).
|
||
|
||
### `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 4 subcategory names, no collisions. The resolver classifies by lookup. A
|
||
test asserts the disjointness, because a future catalog entry named `crown` would quietly turn
|
||
a leaf into an ambiguity.
|
||
|
||
### 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.
|
||
|
||
---
|
||
|
||
## 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, no availability endpoint. The `Plan.features`
|
||
design in §8 is deferred, not dropped.
|
||
|
||
### 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.
|
||
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 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; nothing is
|
||
applied until one is picked;
|
||
- **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 |
|