The review sheet rendered the full dictation at the top of the modal,
unconditionally. A raw transcript can carry the patient's spoken name — the
spec said so itself in §9, while §10 said telemetry must never contain it.
The transcript no longer reaches the browser by any route:
- removed from the success response (VoiceExtractionResponse is now plain
ResolvedExtraction, which never had the field)
- removed from the error body. VOICE_EXTRACT_FAILED carried
details.transcript for a salvage dialog that was never built, and
ApiError['details'] is an array, so the shape never even matched — it was
serialized onto the wire and dropped
- removed from the sheet, and from VoiceExtractionResult. tsc proves that
<p> was the only reader in the whole frontend
It is logged instead: one info line per recording, written immediately after
the emptiness check so a failed extraction still records it, and deliberately
outside logTelemetry so that method's patient-free guarantee stays literally
true.
Accepted consequence, recorded in §10: patient words now persist in
production server logs at default level, so whatever retention and access
control applies to those logs applies to dictation. The repo's other
sensitive-text path (openrouter.provider.ts) uses debug level with
truncation; moving this line to debug is a one-word change.
Transcript salvage is dropped rather than deferred, which settles §11 open
item 16 by taking its second option. When extraction fails the clinician
re-dictates; an operator can read the words in the log, the person who spoke
them cannot.
Spec: §7, §9 and §10 rewritten, item 16 resolved, decisions 51-53 added, and
decisions 10 and 25 marked superseded so the log stops contradicting itself.
No automated coverage for the response shape or the log line: there is no
voice.service.spec.ts — the service is I/O orchestration and has never been
unit tested. Removing the type field is what proves no reader survives.
Gates: backend 216 tests, nest build, ESLint clean on the voice module;
frontend tsc --noEmit clean, 52 Vitest tests, next build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decision 39 couples teeth and jobs on a lab-dependent type, so unticking the
merged row and then the type row applies a detail with no teeth at all. master
kept them, because the rows were separate. Approved as an accepted consequence
rather than patched, since applying teeth the clinician just unticked would
break the sheet-is-a-contract rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
origin/master removed the dispatch panel's remembered-prosthesis auto-fill
(f52ad6b), so the exemption this branch carried for it went away in the
rebase. The spec named the mechanism and a verification step for a feature
that no longer exists.
The rule it existed to enforce still stands and is worth keeping written
down — confirm fills exactly what the sheet previewed — so it stays, marked
as a constraint on whatever gets added next rather than a description of
something in the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Dictating "12 روکش PFM, 13 روکش PFZ" previewed correctly and then landed in
the form as PFM on both teeth.
The stored data was never wrong — the dev database holds 12 → pfm_crown and
13 → pfz_crown with selectionGroupIds matching the detail's groups exactly,
and a page reload renders it correctly. The damage was live client state:
the dispatch panel's "last type used for this lab" default rebuilt the
*entire* map from one code, so a single row reading as unfilled destroyed
every type already set.
Two changes:
- It fills blanks now, and leaves every entry that already carries a type
alone. The bulk "apply to all" select only pre-sets itself when the fill
really did cover every tooth, instead of claiming one type while the rows
below disagree.
- A lab case created by confirming a voice result is exempt from the
default entirely. The review sheet is a contract: topping the case up
with a type for a tooth the preview never showed makes the confirmation
step a lie about what it was going to fill.
The exemption is tracked in workspace state rather than on LabCaseDraft
because a draft field is dropped by mapLabCaseDraftFromApi on the first
server round-trip — exactly the window this failure lives in.
isProsthesisMapComplete is deliberately untouched: its strict
selectionGroupId match succeeds on the real data, so loosening it would
have been a blind change to a working path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend returns the transcript on VOICE_EXTRACT_FAILED and the client
never reads it, so dictation the clinic paid for is shipped in an error
body and dropped. The spec claimed the whole feature was implemented; it
now names the gap and the two ways out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec's tooth section described the design that the first live test
disproved — descriptive phrasing primary, bare numerals refused in en.
Rewrites §6 around the rule the product actually wants, records the chip
affordance in §7, and closes open item §11.5: a two-digit number is FDI in
all three locales, with the Universal-numbering trade-off written down
rather than left implied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implemented across 12 commits on feat/voice-treatment-entry. Still blocked on
the Persian ASR spike before it is trustworthy in front of patients: nothing in
the implementation compensates for a bad transcript.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design spec for filling a TreatmentDetail by voice, settled across three
grilling sessions (30 decisions, logged in the spec).
Key shape:
- two-stage pipeline: OpenRouter whisper-1 -> gemini-3.7-flash
- the LLM emits *intents*, never FDI codes or ISO dates; pure Jest-tested
backend resolvers own quadrant mapping and Jalali conversion
- provider registry keyed by locale so fa can diverge from en/nl
- review sheet confirms before anything touches the form
- audio and transcripts are never persisted
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>