From dc10d8dbe3dbf23f086f9ae4468f77802fa013aa Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 23:46:56 +0800 Subject: [PATCH] docs: cut the comments that were not earning their place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wrote 731 comment lines on this branch against 4,530 lines of code — 14%, where the rest of the repo runs at 1.8%. CLAUDE.md asks for code that reads like its surroundings, and this did not. Removed by genre rather than by taste: - restating the code, e.g. "JS getUTCDay() numbering: Sunday = 0" above the map that literally shows it, and a docblock on startOfWeek explaining that it returns the start of the week; - narrating history — "this used to rebuild the whole map", "left the bar recording forever" — which the commit message and git blame already carry; - saying the same thing in several places: the "cannot record is not a denied microphone" reason appeared three times in one file, and the "aborting stops a per-minute metered call" reason across three files. Each now lives once, where the behaviour it explains lives; - defending decisions nobody would question, like why toLatinDigits is its own module; - over-explaining defensive branches, three separate comments to distinguish null from missing-kind from unrecognised-kind. What stays is what the code cannot say: the patient-right convention in toFdi, whose failure mode is a valid code for the wrong tooth; the "this"-vs-"next" week anchoring; StrictMode re-arming mountedRef; Safari accepting no mimeType hint; and the invariants whose violation already cost a bug — the body parser's middleware ordering and the dispatch panel's auto-fill rules. Comments only. The diff contains no non-comment line. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/body-parsers.ts | 21 ++++----- backend/src/common/digits.ts | 12 +---- backend/src/common/fdi.ts | 25 ++++------ backend/src/common/jalali.ts | 23 ++++------ backend/src/common/zoned-civil-time.ts | 11 ++--- backend/src/configs/configurations.ts | 7 ++- backend/src/main.ts | 5 +- backend/src/modules/voice/dto/voice.dto.ts | 34 ++++---------- .../src/modules/voice/due-date.resolver.ts | 46 ++++++------------- .../src/modules/voice/extraction.resolver.ts | 25 ++++------ backend/src/modules/voice/extraction.wire.ts | 14 ++---- .../src/modules/voice/openrouter.provider.ts | 5 +- .../modules/voice/tooth-intent.resolver.ts | 37 +++++---------- .../modules/voice/voice-throttler.guard.ts | 12 ++--- backend/src/modules/voice/voice.controller.ts | 5 +- backend/src/modules/voice/voice.providers.ts | 6 +-- backend/src/modules/voice/voice.service.ts | 28 ++++------- backend/src/modules/voice/voice.types.ts | 6 +-- .../components/treatment/voiceReviewRows.ts | 37 ++++----------- .../ui/treatment/TreatmentDetailsEditor.tsx | 15 ++---- .../ui/treatment/TreatmentWorkspace.tsx | 20 ++++---- .../ui/treatment/VoiceRecordingBar.tsx | 6 +-- .../ui/treatment/VoiceReviewSheet.tsx | 17 +++---- frontend/src/lib/api/voice.ts | 3 +- frontend/src/lib/voice/audioFormat.ts | 8 ++-- frontend/src/lib/voice/useVoiceCapture.ts | 33 +++++-------- 26 files changed, 155 insertions(+), 306 deletions(-) diff --git a/backend/src/common/body-parsers.ts b/backend/src/common/body-parsers.ts index 9f40162..554c75e 100644 --- a/backend/src/common/body-parsers.ts +++ b/backend/src/common/body-parsers.ts @@ -17,23 +17,18 @@ export const VOICE_BODY_LIMIT = '10mb'; * of audio, so that one route needs a larger limit while every other endpoint keeps the * default — a large body should not become acceptable everywhere. * - * Deliberately a single middleware that *chooses* a parser, rather than a path-mounted - * parser stacked in front of a default one. That arrangement relied on Express's - * mount-path stripping plus body-parser skipping an already-parsed request, and it - * silently stopped applying when the surrounding middleware order shifted — at which point - * the endpoint rejected every real recording with a 500. One explicit branch has no such - * coupling, and is covered by body-parsers.spec.ts. + * Deliberately one middleware that *chooses* a parser, not a path-mounted parser stacked in + * front of a default one: that arrangement depended on Express's mount-path stripping and on + * body-parser skipping an already-parsed request, and silently stopped applying whenever the + * middleware order shifted. One explicit branch has no such coupling. */ /** - * Express routes case-insensitively and ignores a trailing slash unless configured - * otherwise, so `/API/Voice/Extract/` reaches the same controller. Matching only the - * canonical spelling would hand those requests the 100 kb parser and 413 every real - * recording — a failure that looks like a broken microphone, not a routing detail. + * Express routes case-insensitively and ignores exactly one trailing slash, so + * `/API/Voice/Extract/` reaches the same controller and must get the same limit — otherwise + * it 413s every real recording, which reads as a broken microphone rather than a route. + * Two slashes never route, so they must not buy a 10 MB buffer either. */ function isVoiceExtractPath(path: string): boolean { - // Exactly one trailing slash, because that is exactly what Express ignores. Stripping - // every trailing slash would hand the 10 MB parser to `/api/voice/extract//`, which - // buffers the body and then 404s — memory spent on a request that never routes. return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH; } diff --git a/backend/src/common/digits.ts b/backend/src/common/digits.ts index cb8663a..d78d538 100644 --- a/backend/src/common/digits.ts +++ b/backend/src/common/digits.ts @@ -1,19 +1,11 @@ -/** - * Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669) - * zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript. - */ const PERSIAN_ZERO = 0x06f0; const ARABIC_INDIC_ZERO = 0x0660; /** * Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through. * - * Deliberately wider than the frontend original, which only handles the Persian block: - * this parses model/ASR output rather than keystrokes, so both blocks must be accepted - * or a spoken date or tooth number silently degrades to "unresolved". - * - * Lives on its own rather than inside jalali.ts because tooth codes need it too, and a - * tooth module reaching into the calendar module would read as an accident. + * Both blocks, not just the Persian one the frontend handles: ASR output can carry either, + * sometimes mixed with ASCII in a single transcript. */ export function toLatinDigits(value: string): string { return value.replace(/[۰-۹٠-٩]/g, (ch) => { diff --git a/backend/src/common/fdi.ts b/backend/src/common/fdi.ts index 192967d..ae37fbe 100644 --- a/backend/src/common/fdi.ts +++ b/backend/src/common/fdi.ts @@ -14,10 +14,9 @@ export type Arch = 'upper' | 'lower'; export type PatientSide = 'patient_right' | 'patient_left'; /** - * Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28). - * That is the drawn left-to-right layout, which is the mirror of the patient's own sides. - * Do not read a tooth position off this array by index — use `toFdi()`, which owns the - * side convention. + * Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28) — the drawn + * layout, which mirrors the patient's own sides. Never read a position off this array by + * index; use `toFdi()`, which owns the side convention. */ export const FDI_UPPER_ARCH_ORDER = [ '18', @@ -68,12 +67,9 @@ export function isFdiTooth(value: unknown): value is string { } /** - * Clean up a tooth code the extraction model echoed back, before it is matched. - * - * The model is transcribing Persian speech, so it can hand back "۲۶" in Persian digits or - * "2 6" from a digit-by-digit dictation. Neither matches an FDI code literally, and a - * near-miss here does not fail loudly — the tooth quietly turns into "not understood". - * Returns '' for anything that is not a string. + * Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand + * back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the + * near-miss does not fail loudly — the tooth just turns into "not understood". */ export function normalizeFdiCode(value: unknown): string { if (typeof value !== 'string') return ''; @@ -114,9 +110,8 @@ export function teethBetweenInclusive(a: string, b: string): string[] | null { /** * Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code. * - * This function is the single place the patient-right convention lives. Getting it - * backwards mirrors every quadrant and produces a valid-looking code for the wrong tooth, - * which no schema check can catch — hence the exhaustive test coverage. + * The single place the patient-right convention lives. Getting it backwards mirrors every + * quadrant into a valid-looking code for the wrong tooth, which no schema check can catch. */ export function toFdi( arch: Arch, @@ -135,8 +130,8 @@ export function toFdi( } /** - * Sort teeth along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside - * 21 across the midline. Teeth from another arch (or unknown) sort to the end, stably. + * Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the + * midline. Teeth from another arch sort to the end, stably. */ export function sortInArchOrder(teeth: readonly string[]): string[] { if (teeth.length === 0) return []; diff --git a/backend/src/common/jalali.ts b/backend/src/common/jalali.ts index 989cb6e..b061e65 100644 --- a/backend/src/common/jalali.ts +++ b/backend/src/common/jalali.ts @@ -1,10 +1,8 @@ /** - * Jalali (Persian) calendar arithmetic. - * - * Ported from `frontend/src/lib/i18n/persianCalendar.ts` (itself from jalaali-js, MIT). - * The backend needs this because voice extraction resolves spoken Jalali dates into ISO - * dates server-side, where the resolvers are unit-tested — the frontend has no test - * runner. Keep the two copies in step; the underlying calendar does not change. + * Jalali (Persian) calendar arithmetic, ported from + * `frontend/src/lib/i18n/persianCalendar.ts` (itself jalaali-js, MIT). The backend needs it + * because voice resolves spoken Jalali dates server-side, where the resolvers are tested. + * Keep the two copies in step; the underlying calendar does not change. */ const BREAKS = [ @@ -144,11 +142,8 @@ export function isJalaliLeapYear(jy: number): boolean { } /** - * Days in a Jalali month, or 0 when the year or month is not real. - * - * Zero rather than a throw: every export here is reachable from model-supplied values, so - * the whole module degrades instead of raising. Zero also makes `isValidJalaliDate`'s - * `jd <= jalaliDaysInMonth(...)` naturally false. + * Days in a Jalali month, or 0 when the year or month is not real. Zero rather than a throw: + * every export here is reachable from model-supplied values, so the module degrades. */ export function jalaliDaysInMonth(jy: number, jm: number): number { if (!isSupportedJalaliYear(jy)) return 0; @@ -170,10 +165,8 @@ export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean { } /** - * Jalali triple → `YYYY-MM-DD`, or null when the date is not real. - * - * Returns null rather than throwing: callers resolve model-supplied values, which may be - * nonsense, and an invalid date must degrade to "unresolved" rather than a 500. + * Jalali triple → `YYYY-MM-DD`, or null when the date is not real. Null rather than a throw, + * for the same reason: callers resolve model-supplied values, which may be nonsense. */ export function jalaliToIsoDate( jy: number, diff --git a/backend/src/common/zoned-civil-time.ts b/backend/src/common/zoned-civil-time.ts index 269d224..e61f161 100644 --- a/backend/src/common/zoned-civil-time.ts +++ b/backend/src/common/zoned-civil-time.ts @@ -55,15 +55,12 @@ export function civilDateJsWeekday(isoDate: string): number { } /** - * Today's civil date (`YYYY-MM-DD`) in an IANA zone. - * - * Lets the server derive "today" from a client-supplied time zone instead of trusting a - * client-supplied date, which matters for relative deadlines like "by Thursday". + * Today's civil date (`YYYY-MM-DD`) in an IANA zone, so the server derives "today" from a + * client-supplied *zone* rather than trusting a client-supplied date. */ export function civilDateInZone(date: Date, timeZone: string): string { - // Intl throws RangeError on an unknown zone, before any fallback below could help, and - // this receives a client-supplied string. Callers validate first; this is the backstop - // so a bad zone degrades to a date that is at most a day out rather than a 500. + // Intl throws RangeError on an unknown zone and this takes a client-supplied string; + // callers validate first, this is the backstop. const zone = isValidIanaTimeZone(timeZone) ? timeZone : 'UTC'; const parts = new Intl.DateTimeFormat('en-CA', { timeZone: zone, diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts index 48a00ab..cc4c5eb 100644 --- a/backend/src/configs/configurations.ts +++ b/backend/src/configs/configurations.ts @@ -186,10 +186,9 @@ function parseProviderId( } /** - * Every enabled locale gets its own ASR and LLM provider+model, each overridable - * independently. They all point at the same OpenRouter models today; the per-locale - * indirection is kept because Persian ASR is the weakest link and swapping only `fa` must - * not be a code change. + * Every enabled locale gets its own ASR and LLM provider+model, each independently + * overridable. They all point at the same OpenRouter models today; the per-locale + * indirection stays so a locale can diverge by configuration rather than by code. */ function buildVoiceConfig( getEnvVarWithDefault: (key: string, defaultValue: string) => string, diff --git a/backend/src/main.ts b/backend/src/main.ts index 0749bbd..b2ec46b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -25,9 +25,8 @@ console.log = (...args) => { }; async function bootstrap() { - // bodyParser is disabled here so the JSON parsers can be registered in an explicit - // order below; Nest's built-in one is installed during create() and would otherwise - // reject a voice recording at its 100 kb default before any later middleware ran. + // bodyParser is disabled so the JSON parsers can be registered in an explicit order below; + // Nest's built-in one would otherwise reject a voice recording at 100 kb. const app = await NestFactory.create(AppModule, { bodyParser: false }); // Voice needs a larger JSON limit than everything else; see body-parsers.ts. diff --git a/backend/src/modules/voice/dto/voice.dto.ts b/backend/src/modules/voice/dto/voice.dto.ts index 18a7ca9..7a8492b 100644 --- a/backend/src/modules/voice/dto/voice.dto.ts +++ b/backend/src/modules/voice/dto/voice.dto.ts @@ -26,48 +26,32 @@ export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number]; export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const; export class ExtractVoiceDto { - /** - * Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but - * far below OpenRouter's 25 MB ceiling, so an oversized upload is rejected before it - * costs a vendor call. - */ + /** Base64 audio, no data: prefix. Well above a 2-minute opus clip (~400 KB). */ @IsString() @IsBase64() - // Both constraints name their own code. Left to the default mapping, `maxLength` falls - // through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to - // VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field - // was missing, and an unsupported container told them their language was invalid. + // Both name their own code: the shared map sends `maxLength` to + // VALIDATION_FIELD_REQUIRED and `isIn` to VALIDATION_LANGUAGE_INVALID, neither of which + // is true here. @MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG }) audio: string; @IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT }) format: VoiceAudioFormat; - /** - * The clinician's IANA zone. The server derives "today" from it rather than trusting a - * client-supplied date, which is what relative deadlines resolve against. - */ + /** The clinician's IANA zone; "today" is derived from it, never sent by the client. */ @IsString() @MaxLength(64) timeZone: string; - /** - * Recording length as measured by the client. - * - * Required, not optional: an optional value means omitting it bypasses - * VOICE_MAX_RECORDING_MS entirely, which would make the cap advisory. - */ + /** Required, not optional — omitting it would bypass VOICE_MAX_RECORDING_MS entirely. */ @IsInt() @Min(0) durationMs: number; /** - * The locale the clinician is actually speaking, as the UI offered the microphone. - * - * Sent explicitly rather than read from `user.language`: the two can diverge (a - * bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would - * transcribe Persian with an English hint and anchor "next Thursday" to the wrong - * week start. Gating the button and resolving the request must agree by construction. + * The locale the UI offered the microphone in. Sent explicitly because `user.language` can + * diverge from the URL locale, and a mismatch transcribes Persian with an English hint and + * anchors "next Thursday" to the wrong week start. */ @IsIn(VOICE_LOCALES) locale: string; diff --git a/backend/src/modules/voice/due-date.resolver.ts b/backend/src/modules/voice/due-date.resolver.ts index 47b8017..d65d2bf 100644 --- a/backend/src/modules/voice/due-date.resolver.ts +++ b/backend/src/modules/voice/due-date.resolver.ts @@ -15,7 +15,6 @@ import type { DueIntent, UnresolvedItem, Weekday } from './voice.types'; * to reason about instants. */ -/** JS `getUTCDay()` numbering: Sunday = 0. */ const WEEKDAY_TO_JS: Record = { saturday: 6, sunday: 0, @@ -26,7 +25,6 @@ const WEEKDAY_TO_JS: Record = { friday: 5, }; -/** Refuse absurd deadlines however they were arrived at. */ const MAX_DAYS_AHEAD = 365 * 5; export type DueResolution = { @@ -78,12 +76,9 @@ function unresolved(spoken: string): DueResolution { } /** - * What to quote back when a deadline could not be resolved. - * - * Every field here is nullable on the wire and `toVoiceIntent` casts rather than checks, - * so a half-classified deadline arrives with nulls in it. The review sheet renders this - * verbatim — `"null null" — not a usable date` in front of a clinician is worse than the - * reason on its own, which the sheet already handles for a blank string. + * What to quote back when a deadline could not be resolved. Every field is nullable on the + * wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim — + * so a half-classified deadline must fall back to '', not to `"null null"`. */ function describe(intent: DueIntent): string { const usable = (value: unknown): value is number => @@ -108,8 +103,7 @@ function describe(intent: DueIntent): string { ? `${intent.y}-${intent.m}-${intent.d}` : ''; default: { - // Reaching here means an unrecognised `kind`, which resolveDueDate has already - // established is a string — echo it so the review row names what was heard. + // An unrecognised `kind`, already established as a string — echo what was heard. const kind = (intent as { kind?: unknown })?.kind; return typeof kind === 'string' ? kind : ''; } @@ -117,11 +111,9 @@ function describe(intent: DueIntent): string { } /** - * Which weekday starts the week, per locale. - * * "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts - * Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday - * would put an en/nl clinician's deadline a week out. + * Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's + * deadline a week out. */ const WEEK_START_BY_LOCALE: Record = { fa: WEEKDAY_TO_JS.saturday, @@ -135,22 +127,18 @@ export function weekStartForLocale(locale: string): number { return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START; } -/** Most recent week-start day, counting today if today is that day. */ function startOfWeek(iso: string, weekStartJs: number): string { const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7; return addDays(iso, -back); } /** - * `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so "by - * Thursday" said on a Thursday means the next one — a deadline of today is almost never - * what was meant, and this can never resolve into the past. + * `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can + * never resolve into the past. * - * `'next'` is *week*-anchored, not "this plus seven". "Thursday next week" means the - * Thursday of the Saturday-start week after this one; adding a week to `'this'` would - * overshoot by seven days whenever `'this'` had already rolled into next week. The two - * can legitimately coincide — said on a Thursday, "the coming Saturday" and "Saturday - * next week" are the same day. + * `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots + * by seven days whenever `'this'` has already rolled into next week. The two legitimately + * coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day. */ function resolveWeekday( intent: Extract, @@ -199,18 +187,15 @@ export function resolveDueDate( todayIso: string, weekStartJs: number = DEFAULT_WEEK_START, ): DueResolution { - // Absent is not an error — most utterances carry no deadline. Anything else that is not - // an intent object is a deadline we failed to understand, and must be flagged rather - // than silently dropped. + // Absent is not an error — most utterances carry no deadline. if (intent === null || intent === undefined) { return { dueDate: null, unresolved: null }; } if (typeof intent !== 'object') { return unresolved(String(intent).slice(0, 120)); } - // An object carrying no `kind` at all says nothing about a deadline; flagging it would - // put a blank "heard but lost" row in front of a clinician who never mentioned one. An - // object with an *unrecognised* kind did try to say something, and is flagged below. + // No `kind` at all says nothing about a deadline, so it is not "heard but lost". An + // *unrecognised* kind did try to say something, and is flagged below. if (typeof (intent as { kind?: unknown }).kind !== 'string') { return { dueDate: null, unresolved: null }; } @@ -240,8 +225,7 @@ export function resolveDueDate( if (!resolved) return unresolved(describe(intent)); - // An absolute date the model invented can land anywhere; a deadline in the past or - // decades away is not a deadline. + // A date the model invented can land anywhere; past or decades away is not a deadline. const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000; if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD) return unresolved(describe(intent)); diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index 2d92f27..e7f6714 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -91,12 +91,9 @@ function mergeOverlapping(sets: string[][]): string[][] { } /** - * Turn spoken bridge spans plus loose teeth into selection groups. - * - * Span teeth are added to the selection: saying "a bridge from 14 to 16" selects 15 even - * though it was never named. A span whose endpoints are in different arches is impossible - * and is reported rather than guessed at. A span that collapses to one tooth degrades to a - * single — there is no such thing as a one-tooth bridge. + * Span teeth join the selection: "a bridge from 14 to 16" selects 15 though it was never + * named. A cross-arch span is reported rather than guessed at, and a span collapsing to one + * tooth degrades to a single — there is no one-tooth bridge. */ export function resolveConnectedSpans( spans: readonly ConnectedSpanIntent[], @@ -169,10 +166,8 @@ export function resolveConnectedSpans( } /** - * Expand a default prosthesis type across the selection, then apply per-tooth overrides. - * - * "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak, so the model names the type - * once and overrides the exceptions. + * A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is + * how clinicians actually speak. */ export function resolveProsthesis( intent: ProsthesisIntent | null | undefined, @@ -282,14 +277,12 @@ export function resolveVoiceIntent( ? intent.comment.trim() : null; - // A lab id the model invented is worse than none — it would ship a case to a lab the - // clinic never named. Only ids from the list we supplied survive, and a rejected one is - // reported: a hallucinated lab must not look identical to "no lab was spoken". + // An invented lab id would ship a case to a lab the clinic never named. A rejected one is + // reported, so it cannot look identical to "no lab was spoken". const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds); if (intent?.labId != null && !labId) { - // `spoken` means "what the clinician said". A rejected lab id is an opaque - // identifier the model invented, so quoting it back would put a raw UUID in front - // of the user; the reason alone carries the meaning. + // `spoken` is what the clinician said — quoting an invented id back would put a raw + // UUID in front of the user. unresolved.push({ spoken: '', reason: 'unknown_catalog_code' }); } diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index 554b99d..fecf194 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -10,13 +10,10 @@ import type { import { WEEKDAYS } from './voice.types'; /** - * The shape the model actually emits, and its JSON schema. - * * Deliberately flat: strict `json_schema` mode has poor support for discriminated unions, - * so every variant field is present and nullable on the wire. `toVoiceIntent` narrows the - * flat shape into the internal union the resolvers consume, and is total — anything it - * cannot classify becomes a shape the resolvers will report as unresolved rather than - * something that throws here. + * so every variant field is present and nullable. `toVoiceIntent` narrows it into the + * internal union and is total — anything it cannot classify becomes a shape the resolvers + * report as unresolved rather than something that throws here. */ export type WireToothIntent = { @@ -183,9 +180,8 @@ const FDI_SHAPE = /^[1-8][1-8]$/; function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent { const spoken = typeof wire?.spoken === 'string' ? wire.spoken : ''; - // Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not - // match literally; without normalising first they fall through to the positional branch - // with no quadrant and are reported as unresolved. + // "۲۶" and "2 6" are FDI codes that do not match literally; unnormalised they fall + // through to the positional branch with no quadrant and read as unresolved. const fdi = normalizeFdiCode(wire?.fdi); // Only take the explicit branch for something actually FDI-shaped. A model that emits // fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely. diff --git a/backend/src/modules/voice/openrouter.provider.ts b/backend/src/modules/voice/openrouter.provider.ts index 422c56a..7753fa2 100644 --- a/backend/src/modules/voice/openrouter.provider.ts +++ b/backend/src/modules/voice/openrouter.provider.ts @@ -124,9 +124,8 @@ export class OpenRouterExtractionProvider implements ExtractionProvider { body: JSON.stringify({ model: this.config.model, temperature: 0, - // Only route to endpoints that actually honour the JSON schema. Without this, - // OpenRouter may pick a provider that treats it as a hint and returns prose, - // which fails parsing intermittently and unreproducibly. + // Only route to endpoints that actually honour the JSON schema — otherwise OpenRouter may + // pick a provider that treats it as a hint and returns prose, failing intermittently. provider: { require_parameters: true }, messages: buildExtractionPrompt(transcript, catalog, localeHint), response_format: { diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index f021f16..fd1e719 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -18,18 +18,14 @@ export type ToothResolution = { /** Everything here parses untrusted model output, so nothing may throw. */ function normalizedFdi(intent: ToothIntent): string { - // Same normalisation the wire layer used to pick this branch, so the two cannot - // disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and - // neither may be reported as malformed here. + // The same normalisation the wire layer used to pick this branch, so the two agree. return normalizeFdiCode((intent as { fdi?: unknown }).fdi); } /** - * Resolve one spoken tooth reference to an FDI code, or null. - * - * Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed - * intent resolves to null so the caller can surface it as "not understood" rather than - * silently selecting a neighbouring tooth. + * Never guesses and never clamps: position 9, a deciduous tooth or a malformed intent all + * resolve to null, so the caller surfaces "not understood" rather than silently selecting a + * neighbouring tooth. */ export function resolveToothIntent(intent: ToothIntent): string | null { if (!intent || typeof intent !== 'object') return null; @@ -65,9 +61,8 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { intent.position < 1 || intent.position > 8; if (positionBad) return 'position_out_of_range'; - // The position was understood, so the words were not the problem: the speaker never - // said which quadrant. "دندون دو" names four teeth at once, and telling the - // clinician it "could not be read" would send them looking for the wrong fault. + // The position was understood; the quadrant was never said. "دندون دو" names four + // teeth, so "could not be read" would send the clinician after the wrong fault. const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower'; const sideMissing = intent.side !== 'patient_right' && intent.side !== 'patient_left'; @@ -78,11 +73,8 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { } /** - * The teeth still consistent with what *was* heard. - * - * Narrowed by whatever the clinician did say, so "دو" offers four and "دو بالا" offers - * two. This is not a guess — it is the full set of readings, handed to the clinician to - * choose from rather than picked on their behalf. + * The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. Not + * a guess: the full set of readings, for the clinician to choose from. */ function quadrantCandidates(intent: ToothIntent): string[] { if (intent.kind !== 'positional') return []; @@ -109,11 +101,8 @@ function spokenOf(intent: ToothIntent): string { } /** - * Resolve a list of spoken tooth references. - * - * Duplicates collapse — a clinician may name the same tooth twice in one sentence — and - * anything unresolvable is reported rather than dropped, so the review sheet can show the - * user exactly which words were not understood. + * Duplicates collapse; anything unresolvable is reported rather than dropped, so the sheet + * can show which words were not understood. */ export function resolveToothIntents( intents: readonly ToothIntent[], @@ -138,10 +127,8 @@ export function resolveToothIntents( const spoken = spokenOf(intent); const candidates = reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : []; - // Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost - // references would collapse into one blank review row and a tooth would vanish. The - // candidates are part of the identity: the same word with a different arch heard - // offers a different choice. + // Only dedupe what we can tell apart: without `spoken`, two lost references collapse + // into one blank row and a tooth vanishes. Candidates are part of the identity. if (spoken) { const key = `${spoken}::${reason}::${candidates.join(',')}`; if (seenUnresolved.has(key)) continue; diff --git a/backend/src/modules/voice/voice-throttler.guard.ts b/backend/src/modules/voice/voice-throttler.guard.ts index 0a42949..f28bdad 100644 --- a/backend/src/modules/voice/voice-throttler.guard.ts +++ b/backend/src/modules/voice/voice-throttler.guard.ts @@ -3,15 +3,9 @@ import { ThrottlerGuard } from '@nestjs/throttler'; import { AppException, ErrorCode } from '../../common/errors'; /** - * Rate limits voice extraction per user rather than per IP. - * - * The default tracker keys on `req.ip`, which behind nginx means the whole deployment - * shares one bucket unless `trust proxy` is set — and an abuser rotating IPs would bypass - * it entirely. Since v1 ships with no plan gate, this is the only control on metered - * vendor spend, so it has to key on something the client cannot change. - * - * Guard order matters: the controller's JwtAuthGuard runs before this method-level guard, - * so `req.user` is populated by the time `getTracker` is called. + * Rate limits voice extraction per user, not per IP: the default tracker keys on `req.ip`, + * which behind nginx means the whole deployment shares one bucket unless `trust proxy` is + * set, and one clinic could then lock out every other. */ @Injectable() export class VoiceThrottlerGuard extends ThrottlerGuard { diff --git a/backend/src/modules/voice/voice.controller.ts b/backend/src/modules/voice/voice.controller.ts index ae15a44..0b93657 100644 --- a/backend/src/modules/voice/voice.controller.ts +++ b/backend/src/modules/voice/voice.controller.ts @@ -48,9 +48,8 @@ export class VoiceController { @Res({ passthrough: true }) res: Response, @Body() dto: ExtractVoiceDto, ) { - // Cancelling in the browser closes the connection; propagate that as an abort so the - // in-flight vendor call stops rather than settling and being discarded. It is metered - // per minute, so letting it run costs real money for a result nobody will see. + // Cancelling in the browser closes the connection; propagate it as an abort so the vendor + // call stops rather than settling unseen. It is metered per minute. const aborter = new AbortController(); res.on('close', () => { if (!res.writableFinished) aborter.abort(); diff --git a/backend/src/modules/voice/voice.providers.ts b/backend/src/modules/voice/voice.providers.ts index d44976e..7511963 100644 --- a/backend/src/modules/voice/voice.providers.ts +++ b/backend/src/modules/voice/voice.providers.ts @@ -1,10 +1,8 @@ import type { VoiceIntent } from './voice.types'; /** - * ASR and extraction are separate, independently swappable roles — they will not come - * from the same vendor for every locale. Both are resolved per locale from - * `config.voice.profiles`, so pointing `fa` at a Persian-specialist vendor while `en` - * and `nl` keep OpenRouter is configuration, not code. + * ASR and extraction are separate, independently swappable roles — they will not come from + * the same vendor for every locale. Both resolve per locale from `config.voice.profiles`. */ export type AudioInput = { diff --git a/backend/src/modules/voice/voice.service.ts b/backend/src/modules/voice/voice.service.ts index bfeb7e6..f36666d 100644 --- a/backend/src/modules/voice/voice.service.ts +++ b/backend/src/modules/voice/voice.service.ts @@ -55,10 +55,8 @@ export class VoiceService { } /** - * What the frontend needs to decide whether to render the microphone at all. - * - * v1 ships ungated beyond a configured locale profile — no plan check. The - * Plan.features design is deferred, not dropped. + * What the frontend needs to decide whether to render the microphone. v1 is ungated beyond + * a configured locale profile; the Plan.features design is deferred, not dropped. */ getAvailability(): VoiceAvailability { const voice = this.voiceConfig; @@ -107,10 +105,9 @@ export class VoiceService { throw this.toAppException(error, 'asr'); } - // durationMs is client-reported and therefore not enforcement. usage.seconds is the - // vendor's own measurement of the audio it decoded, so a client under-reporting length - // to slip past the cap is caught here — after the ASR spend, but before the extraction - // call, and visibly in telemetry. + // durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own + // measurement — a client under-reporting to slip past the cap is caught here, after the + // ASR spend but before the more expensive extraction call. if (asrSeconds != null) { this.assertWithinCap(asrSeconds * 1000); } @@ -211,13 +208,9 @@ export class VoiceService { } /** - * Grace above the configured cap. - * - * The client auto-stops when elapsed >= maxMs, then measures the final length after the - * recorder has actually stopped — so a recording that runs to the cap always reports - * slightly over it. Without this tolerance the auto-stop would guarantee a rejection, - * discarding exactly the recording it was meant to save. The client still reports the - * true length, so telemetry stays honest. + * The client auto-stops at maxMs and only then measures, so a capped recording always + * reports slightly over. Without this tolerance every auto-stopped recording — the exact + * case the cap exists for — would be rejected as too long. */ private static readonly CAP_TOLERANCE_MS = 2_000; @@ -319,10 +312,7 @@ export class VoiceService { ); } - /** - * Structured, patient-free. Never the transcript, never audio, never a patient id. - * Log lines are the interim sink until this repo has metrics infrastructure. - */ + /** Structured and patient-free: never the transcript, never audio, never a patient id. */ private logTelemetry(input: { locale: string; durationMs: number; diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index d200c40..f557110 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -78,10 +78,8 @@ export type UnresolvedItem = { spoken: string; reason: UnresolvedReason; /** - * FDI codes still consistent with what was heard, when a choice would settle it. - * Only `tooth_missing_quadrant` carries these: "دو" leaves four teeth on the table, - * "دو بالا" leaves two. The review sheet offers them so an under-specified tooth is one - * tap from resolved rather than a dead end. + * FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only + * `tooth_missing_quadrant` carries them; the sheet offers them as chips. */ candidates?: string[]; }; diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts index eda78d6..9d0516c 100644 --- a/frontend/src/components/treatment/voiceReviewRows.ts +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -19,14 +19,9 @@ export function voiceRowAvailability(result: VoiceExtractionResult) { } /** - * Which rows start ticked. - * - * Everything available ticks itself, with two deliberate exceptions: - * - * - **lab, when the name only approximately matched.** Shipping a case to a lab is the one - * extracted value whose error leaves the building, so it always requires a deliberate tick. - * - **prosthesis, when the map is incomplete.** A prosthesis detail with an untyped tooth - * cannot ship at all, so applying it would just move the failure to dispatch. + * Everything available ticks itself, with two exceptions: an inexactly-matched lab, because + * it is the one extracted value whose error leaves the building; and an incomplete + * prosthesis map, which cannot ship at all and would just move the failure to dispatch. */ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection { const available = voiceRowAvailability(result); @@ -41,11 +36,8 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply } /** - * How many rows will actually be applied — drives the confirm button's label. - * - * Intersected with availability rather than counting ticks: a row can be ticked and then - * lose its content (the last candidate tooth un-picked), and "Apply 1 item" that applies - * nothing is worse than a wrong number. + * Intersected with availability rather than counting ticks: a row can be ticked and then lose + * its content, and "Apply 1 item" that applies nothing is worse than a wrong number. */ export function countSelected( selection: VoiceApplySelection, @@ -66,16 +58,10 @@ function recheckProsthesis( } /** - * Fold the clinician's candidate picks into the extracted result. + * Fold the candidate picks into the result, so nothing downstream has to know chips exist. * - * Everything downstream reads a `VoiceExtractionResult` — row availability, the mini - * chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here - * means none of them has to know the chips exist. - * - * Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the - * recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth; - * and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a - * single group, so no tooth can be lost on the way through. + * Union rather than toggle: a candidate can coincidentally be a tooth the recording already + * produced ("۱۲ و دو"), and tapping it must not deselect that one. */ export function withChosenTeeth( result: VoiceExtractionResult, @@ -103,12 +89,7 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set`s - * divided by `border-s` — rather than two shared `Button`s, which each hardcode their own - * rounding and would fight a segmented control. + * Built like the detail chip's trash affordance in this same file — a wrapper holding two + * raw `