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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user