Files
dyolink/backend/src/common/fdi.ts

154 lines
4.6 KiB
TypeScript
Raw Normal View History

/**
* FDI tooth geometry permanent dentition only.
*
* Mirrors `frontend/src/components/treatment/fdiToothMeta.ts` and the adjacency rules in
* `toothSelectionGroups.ts`. Adjacency is defined by position in the arch order, so the
* midline pairs (1121, 4131) are neighbours, exactly as the chart treats them.
*/
import { toLatinDigits } from './digits';
export type Arch = 'upper' | 'lower';
/** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */
export type PatientSide = 'patient_right' | 'patient_left';
/**
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>
2026-08-21 23:46:56 +08:00
* 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',
'17',
'16',
'15',
'14',
'13',
'12',
'11',
'21',
'22',
'23',
'24',
'25',
'26',
'27',
'28',
] as const;
/** Lower arch, same chart ordering: patient's RIGHT (48) → midline → patient's LEFT (38). */
export const FDI_LOWER_ARCH_ORDER = [
'48',
'47',
'46',
'45',
'44',
'43',
'42',
'41',
'31',
'32',
'33',
'34',
'35',
'36',
'37',
'38',
] as const;
export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
...FDI_UPPER_ARCH_ORDER,
...FDI_LOWER_ARCH_ORDER,
]);
feat(voice): adapt voice entry to the stacked-jobs prosthesis model Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
/**
* Not FDI codes jaw-level prosthesis targets on `LabCaseToothProsthesis.tooth`. Mirrors
* `frontend/src/components/treatment/prosthesisTree.ts`'s `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER`
* so the voice contract speaks the same sentinel the manual chart already writes.
*/
export const ARCH_TOOTH_UPPER = 'UA';
export const ARCH_TOOTH_LOWER = 'LA';
export function isFdiTooth(value: unknown): value is string {
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
}
/**
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>
2026-08-21 23:46:56 +08:00
* 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 '';
return toLatinDigits(value).replace(/\s+/g, '');
}
function archOrder(tooth: string): readonly string[] | null {
if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth))
return FDI_UPPER_ARCH_ORDER;
if ((FDI_LOWER_ARCH_ORDER as readonly string[]).includes(tooth))
return FDI_LOWER_ARCH_ORDER;
return null;
}
export function sameArch(a: string, b: string): boolean {
const archA = archOrder(a);
const archB = archOrder(b);
return Boolean(archA && archB && archA === archB);
}
export function areArchNeighbors(a: string, b: string): boolean {
const arch = archOrder(a);
if (!arch || !sameArch(a, b)) return false;
return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1;
}
/** Inclusive span between two teeth of the same arch, in arch order. Null if not comparable. */
export function teethBetweenInclusive(a: string, b: string): string[] | null {
const arch = archOrder(a);
if (!arch || !sameArch(a, b)) return null;
const i = arch.indexOf(a);
const j = arch.indexOf(b);
if (i < 0 || j < 0) return null;
const [from, to] = i <= j ? [i, j] : [j, i];
return [...arch.slice(from, to + 1)];
}
/**
* Arch + patient side + position (1 = central incisor 8 = third molar) FDI code.
*
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>
2026-08-21 23:46:56 +08:00
* 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,
side: PatientSide,
position: number,
): string | null {
if (!Number.isInteger(position) || position < 1 || position > 8) return null;
let quadrant: number;
if (arch === 'upper') {
quadrant = side === 'patient_right' ? 1 : 2;
} else {
quadrant = side === 'patient_left' ? 3 : 4;
}
const code = `${quadrant}${position}`;
return FDI_TOOTH_IDS.has(code) ? code : null;
}
feat(backend): assemble resolved extraction from voice intents Composes the tooth, span, prosthesis, catalog and date resolvers into the payload the review sheet renders. Connected spans expand: "a bridge from 14 to 16" selects 15, which was never spoken. Overlapping spans merge into one bridge, group teeth sort along the arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to a single tooth degrades to a single group without losing that tooth — there is no such thing as a one-tooth bridge. A cross-arch span is impossible and is reported rather than guessed at. Prosthesis expands a default across the selection then applies per-tooth overrides, because "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak. Completeness is computed here so an unshippable map surfaces at review rather than failing later at dispatch. Everything the model names is checked against the catalog we supplied it, and anything rejected is reported rather than dropped — a hallucinated lab id must not look identical to "no lab was spoken", since silence and a wrong lab lead to very different corrective actions. Also fixed, from review of this commit: - an empty prosthesis object no longer fabricates an "incomplete, cannot ship" warning on a plain restoration - an override naming a tooth outside the selection now reports tooth_not_selected rather than malformed; the clinician was understood, the tooth just is not on this detail - a due object with no `kind` is treated as no deadline rather than a blank "heard but lost" row; an unrecognised kind is still flagged, and named Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:41 +03:30
/**
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>
2026-08-21 23:46:56 +08:00
* 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.
feat(backend): assemble resolved extraction from voice intents Composes the tooth, span, prosthesis, catalog and date resolvers into the payload the review sheet renders. Connected spans expand: "a bridge from 14 to 16" selects 15, which was never spoken. Overlapping spans merge into one bridge, group teeth sort along the arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to a single tooth degrades to a single group without losing that tooth — there is no such thing as a one-tooth bridge. A cross-arch span is impossible and is reported rather than guessed at. Prosthesis expands a default across the selection then applies per-tooth overrides, because "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak. Completeness is computed here so an unshippable map surfaces at review rather than failing later at dispatch. Everything the model names is checked against the catalog we supplied it, and anything rejected is reported rather than dropped — a hallucinated lab id must not look identical to "no lab was spoken", since silence and a wrong lab lead to very different corrective actions. Also fixed, from review of this commit: - an empty prosthesis object no longer fabricates an "incomplete, cannot ship" warning on a plain restoration - an override naming a tooth outside the selection now reports tooth_not_selected rather than malformed; the clinician was understood, the tooth just is not on this detail - a due object with no `kind` is treated as no deadline rather than a blank "heard but lost" row; an unrecognised kind is still flagged, and named Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:41 +03:30
*/
export function sortInArchOrder(teeth: readonly string[]): string[] {
if (teeth.length === 0) return [];
const arch = teeth.map((t) => archOrder(t)).find((a) => a !== null) ?? null;
if (!arch) return [...teeth];
const indexOf = (tooth: string) => {
const i = arch.indexOf(tooth);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
return [...teeth].sort((a, b) => indexOf(a) - indexOf(b));
}