docs: bring the voice spec in line with the flow as built

The spec still described the flow as designed, not as it works after the
first live recordings.

§2 gains what confirm actually writes: the detail and its lab case are
persisted on confirm, because the autosave effect watches `details` only
and a lab draft left in component state loses the lab, the due date and the
prosthesis map on reload — while the detail survives, which is what makes
that loss look like a save. Plus the guard: a preview treatment comes back
instead when any detail is still untyped, and confirm skips the lab-case
save rather than posting an id the server has never seen.

§9 corrects three codes: VOICE_MIC_DENIED is now only a real permission
failure, VOICE_UNSUPPORTED_FORMAT covers every "this browser cannot record"
path, and both it and VOICE_CLIP_TOO_LONG are named on their DTO
constraints rather than falling through the shared map to an unrelated
message. §8 no longer claims there is no duration cap — there is, 2
minutes, decided before implementation.

§12 gains the checks these changes need, including the reload that catches
an unsaved lab case, and decisions 31-33 record the three rules the live
testing settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 18:54:40 +08:00
parent 8f2d3f97ba
commit 562ef2ae6e

View File

@@ -145,9 +145,25 @@ path**. `onAddDetail` is not called and not changed.
`setEntryStep('treatment')` matters: `showChrome` is always on, so the control is visible `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. 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: > Accepted consequences:
> - Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the > - Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the
> usual trash affordance. > 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 > - Dictating into an existing detail is not supported in v1 — voice always makes a new
> one. > one.
@@ -522,8 +538,8 @@ who can edit treatments, in every configured locale. `Plan.features.voiceTreatme
and the availability API stay documented here as the intended gate, deferred rather than and the availability API stay documented here as the intended gate, deferred rather than
dropped, so turning them on later is additive. dropped, so turning them on later is additive.
Consequence to accept deliberately: with no plan gate and no duration cap (§2), the Consequence to accept deliberately: with no plan gate, the per-user throttle and the 2-minute
per-user throttle is the **only** control on metered vendor spend. See open item 14. recording cap are the **only** controls on metered vendor spend. See open item 14.
--- ---
@@ -535,15 +551,31 @@ English Nest exception for a user-facing failure.
| Code | When | | Code | When |
|---|---| |---|---|
| `VOICE_MIC_DENIED` | browser permission refused**client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site | | `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), or over vendor limits | | `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` | recorder produced a container the profile rejects | | `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_ASR_FAILED` | transcription stage failed |
| `VOICE_EXTRACT_FAILED` | transcript obtained, structuring failed | | `VOICE_EXTRACT_FAILED` | transcript obtained, structuring failed |
| `VOICE_NOTHING_RECOGNIZED` | empty or unusable transcript | | `VOICE_NOTHING_RECOGNIZED` | empty or unusable transcript |
| `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced | | `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced |
| `VOICE_RATE_LIMITED` | throttle | | `VOICE_RATE_LIMITED` | throttle |
**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.
**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED` **Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED`
carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was
never written — `onError` only resolves a message through `getUserFacingError`, which never never written — `onError` only resolves a message through `getUserFacingError`, which never
@@ -701,7 +733,18 @@ enabling this for real clinics.
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop; - 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; - **hold past 2:00** → auto-stops and proceeds to processing, not an error;
- **cancel during processing** → the vendor request is actually aborted; - **cancel during processing** → the vendor request is actually aborted;
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact. - **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
there. They live on the lab case, which the autosave effect does not watch, so this is
the check that catches a lab draft left unsaved in component state;
- **record straight after opening a visit**, while the blank chip is still untyped, and
confirm with a lab ticked → no error toast: confirm detects the preview treatment and
skips the lab-case save rather than posting an id the server has never seen;
- **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows
both, and the dispatch panel's remembered "last type for this lab" does **not** overwrite
either. Also check the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder;
- **a hand-made prosthesis detail with an empty map** → still gets the remembered default
pre-filled. The exemption is for previewed cases only, not a removal of the convenience.
--- ---
@@ -733,6 +776,14 @@ Settled in a grilling session on 2026-08-20.
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part | | 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
| 29 | Cancel | Aborts the in-flight vendor call | | 29 | Cancel | Aborts the in-flight vendor call |
| 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped | | 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped |
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 | | 15 | Gating | `Plan.features` flag — its first consumer |
UI placement settled in a second grilling session on 2026-08-20. UI placement settled in a second grilling session on 2026-08-20.