docs: cut the comments that were not earning their place

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 23:46:56 +08:00
parent 562ef2ae6e
commit dc10d8dbe3
26 changed files with 155 additions and 306 deletions

View File

@@ -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;
}

View File

@@ -1,19 +1,11 @@
/**
* Persian (Extended Arabic-Indic, U+06F0U+06F9) zero, and Arabic-Indic (U+0660U+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) => {

View File

@@ -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 [];

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.

View File

@@ -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;

View File

@@ -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<Weekday, number> = {
saturday: 6,
sunday: 0,
@@ -26,7 +25,6 @@ const WEEKDAY_TO_JS: Record<Weekday, number> = {
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<string, number> = {
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<DueIntent, { kind: 'weekday' }>,
@@ -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));

View File

@@ -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' });
}

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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[];
};