docs: close the gaps the orchestrate surveyor found

The Gap-check phase halted the run on one blocking gap and reported four
notes. Nothing was built.

Blocking: the prompt cannot offer a prosthesis category the model can name,
because no category label exists anywhere the backend can read —
CatalogEntityKind covers only TREATMENT_TYPE, PROSTHESIS_TYPE and
LAB_WORKFLOW_STEP. Adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY as
catalog entities with seeded fa/en/nl translations, rather than sending bare
codes that would read untranslated on the locale this feature exists for.

Also closed: a chip that resolved to a jobless tooth and was then discarded
by the jobless-tooth rule, so the tap did nothing; the undefined region check
for a category whose leaves span crown and arch; the wrong endpoint path in
§3; and the availability-endpoint contradiction in §11.

Corrects two counts of my own: 5 subcategories, not 4 (night_guard was
missed), and the disjointness test now asserts against the live catalog
rather than a number written in prose.

Decisions 47-50. Work items 18-19 added to the ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 04:54:02 +08:00
parent 224663a481
commit 77e2ed4b42
2 changed files with 284 additions and 10 deletions

View File

@@ -272,7 +272,14 @@ mirroring and Jalali conversion — testable instead of hopeful.
### Endpoint
`POST /treatments/voice-extract`
`POST /voice/extract``@Controller('voice')` + `@Post('extract')`, so the full path behind the
global prefix is `/api/voice/extract`. Earlier revisions of this spec named it
`/treatments/voice-extract`, which was never the shipped route. **No work item moves it.**
> ⚠ The path is duplicated in `common/body-parsers.ts`, which matches `/api/voice/extract` to
> select the large JSON body limit. Moving the route without moving `VOICE_EXTRACT_PATH` makes
> every real recording 413 against the 100 kb default, which reads as a broken microphone rather
> than a routing change.
- Guards: `JwtAuthGuard` + `ClinicOrgGuard`.
- Service-level check of `TAB_TREATMENT_EDIT`. The plan flag is **not** checked in v1
@@ -463,16 +470,52 @@ A code whose region contradicts its target is reported, never coerced. An arch a
aimed at tooth 12, or a crown aimed at the upper jaw, resolves to
`code_not_valid_for_target` (§6).
**A category whose leaves span both regions defers the check.** `removable` is the only one
today: `complete_denture` and `overdenture` are `chartRegion: 'arch'` while `partial_denture` is
`'crown'`. There is no region to check until a leaf is chosen, so such an assignment resolves to
`prosthesis_type_ambiguous` and the region is confirmed against the leaf the clinician picks —
never emitted as `code_not_valid_for_target` while it is still a category.
### `types[]` may hold a leaf or a category
"روکش" is *crown* — a category with nine leaves, not a material. The model returns the
category code it actually heard rather than guessing `pfm_crown`, and the review sheet offers
the leaves as chips (§7).
This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7
category names and 4 subcategory names, no collisions. The resolver classifies by lookup. A
test asserts the disjointness, because a future catalog entry named `crown` would quietly turn
a leaf into an ambiguity.
This needs no extra field because the namespaces are disjoint: **42** leaf codes against **7**
category names and **5** subcategory names (`veneer`, `inlay`, `onlay`, `overlay`,
`night_guard`), no collisions. The resolver classifies by lookup. The test asserts disjointness
against the **live catalog**, not against these counts, because a future entry named `crown`
would quietly turn a leaf into an ambiguity and a hardcoded count would not notice.
#### Category labels need a backend source — build it
The prompt cannot offer a category the model can name unless that category has a label in the
actor's locale, and today it has none. `CatalogEntityKind` covers only `TREATMENT_TYPE`,
`PROSTHESIS_TYPE` and `LAB_WORKFLOW_STEP` (`schema.prisma`), so `CatalogLabelService` cannot
resolve a category at all. The only category labels in the repo are the `prosthesis.category_*`
and `prosthesis.sub_*` keys in `frontend/messages/*.json`, plus a hardcoded single-locale map in
the dev export script. Sending bare codes would leave `crown` and `post_core` untranslated
beside fully-labelled leaves, on the locale this feature exists for.
So this branch adds them as first-class catalog entities:
- add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind` — one
migration, no data loss, the enum is additive;
- seed `CatalogTranslation` rows for the 7 categories and the 5 subcategories in `fa`, `en` and
`nl`, taking the wording from the existing `prosthesis.category_*` / `prosthesis.sub_*` keys
so the two surfaces read identically on day one;
- resolve them through `CatalogLabelService` like every other catalog label. No label is
hardcoded in backend code, and the house rule holds unchanged.
The `sub_*` key set is wider than the subcategory set — it also carries technique names
(`sub_full_contour`, `sub_layered`) and two leaf codes. Seed only the five real subcategories.
> **Accepted consequence:** the frontend keeps its own `category_*` / `sub_*` message keys, so
> the wording now lives in two places. Migrating the frontend to read these labels from the API
> is **out of scope for this branch** — it would touch the prosthesis picker, the case sheet and
> the print layout, none of which this task otherwise opens. The seed is written from the message
> files precisely so the two agree at the point they diverge.
### Prosthesis work implies the treatment type
@@ -601,6 +644,15 @@ understand. Three of them carry candidates and become chips (§7).
`tooth_not_selected` is removed — assignments now define their own teeth.
**An unresolved item that came from an assignment carries that assignment's index.**
Without it a chip is decorative: picking a quadrant for "دندون دو" on a prosthesis detail
produces a tooth with no job, which the *jobless tooth is left out* rule above then discards, so
the tap changes nothing. `tooth_missing_quadrant`, `arch_not_spoken` and
`prosthesis_type_ambiguous` therefore carry `assignmentIndex`, and a picked chip inherits that
assignment's `types[]` — or, for `prosthesis_type_ambiguous`, supplies the missing leaf to that
assignment's existing targets. An item raised outside any assignment (a tooth spoken in the
`teeth` array alone) carries no index and folds into `teeth` as it does today.
---
## 7. Review sheet
@@ -876,8 +928,13 @@ enabling this for real clinics.
(§2). Still worth timing a realistic worst-case prosthesis dictation during item 1 to
confirm 2 minutes is comfortable rather than tight.
13. ~~**Availability API**~~**resolved for v1:** voice ships **open to everyone** with a
configured locale profile. No plan check, no availability endpoint. The `Plan.features`
design in §8 is deferred, not dropped.
configured locale profile. **No plan check.** The `Plan.features` design in §8 is deferred,
not dropped.
*Corrected 2026-09-07:* this item previously read "no availability endpoint", which
contradicted §4 and the shipped code. `GET /voice/availability` **does** exist and is what
`voiceForEditor` reads; it reports the configured locales and `maxRecordingMs`, and carries
no plan check. Only the plan gate is deferred, not the endpoint.
### New
@@ -920,11 +977,19 @@ enabling this for real clinics.
- an arch code aimed at a tooth, and a tooth code aimed at a jaw, both resolve to
`code_not_valid_for_target`;
- a resolved assignment forces `treatmentType` to `prosthesis`;
- a target with no types is reported and excluded, and does not fail the whole assignment.
- a target with no types is reported and excluded, and does not fail the whole assignment;
- a `removable` category defers its region check instead of emitting
`code_not_valid_for_target`;
- every one of the 7 categories and 5 subcategories resolves a non-empty label in `fa`, `en`
and `nl` — the check that catches a missed `CatalogTranslation` seed row;
- an unresolved item raised inside an assignment carries that assignment's index, and one
raised outside any assignment does not.
The pre-existing suites stay: `resolveToothIntent` in all four quadrants, out-of-range
rejection, deciduous → unresolved, `resolveDueDate` per-locale week start, "this" vs "next",
Jalali leap year and month-end, and connected-span validation.
- `cd backend && npm run build` — cross-cutting backend gate.
- `cd backend && npm run prisma:migrate && npm run prisma:seed` — the new `CatalogEntityKind`
values and their translation rows. The seed never wipes, so re-running it is safe.
- `cd frontend && npx vitest run`**new**. One dev dependency, one config, one script,
covering the pure helpers only: `prosthesisTree.ts` (stack legality, `applyLeafToJobs`
precedence, `toothRegionColors`) and `voiceReviewRows.ts` (row availability, the merged
@@ -963,8 +1028,11 @@ fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked l
- **jaw appliance** — "نایت گارد فک بالا" → the sheet shows an upper-jaw row, the treatment
type row reads *prosthesis* and is locked, and apply produces a `UA` row with no teeth;
- **jaw not spoken** — "نایت گارد" → Upper / Lower chips; tapping both produces `UA` and `LA`;
- **material not spoken** — "دندون ۱۲ روکش" → chips for the nine crown leaves; nothing is
applied until one is picked;
- **material not spoken** — "دندون ۱۲ روکش" → chips for the nine crown leaves, and the row
reads *روکش* in Persian, not `crown`. Nothing is applied until one is picked;
- **quadrant chip on a prosthesis detail** — "دندون دو روکش زیرکونیا" → picking a quadrant chip
produces a tooth **carrying that assignment's job**, not a jobless tooth that is then
discarded;
- **tooth with no job** — "۱۲ و ۱۳، روکش پی‌اف‌ام برای ۱۲" → 13 is struck through in the sheet,
apply adds only 12, and 13 is **not** silently saved and then deleted;
- **illegal stack** — "دندون ۱۲ ایمپلنت و پست و کور" → the refused job is struck through and
@@ -1045,3 +1113,17 @@ Adapting to the overhauled treatment form, in a grilling session on 2026-09-07.
| 44 | Frontend tests | Vitest added for the pure helpers. The split between backend and frontend resolvers becomes a judgement rather than a constraint (§6, §12) |
| 45 | Recording defects | Carried in this branch: the container fallback that refused Safari, and the render gate that never checked `isMediaRecorderSupported()` (§2, §9) |
| 46 | Delivery | One merge request |
Closing the gaps the `/orchestrate` surveyor found on 2026-09-07, before any code was written.
| # | Question | Decision |
|---|---|---|
| 47 | Category labels | Add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind`, one migration, translations seeded from the existing `prosthesis.category_*` / `sub_*` message keys, resolved through `CatalogLabelService`. Bare codes were the alternative and would have been weakest on `fa` (§5) |
| 48 | Frontend label source | Out of scope. The frontend keeps its own message keys; the wording lives in two places by choice, and the seed is written from the message files so they agree (§5) |
| 49 | Mixed-region categories | A category whose leaves span `crown` and `arch` — only `removable` today — defers its region check to the picked leaf, and is never reported as `code_not_valid_for_target` while still a category (§5) |
| 50 | Chips that came from an assignment | `tooth_missing_quadrant`, `arch_not_spoken` and `prosthesis_type_ambiguous` carry `assignmentIndex`, so a picked chip inherits that assignment's jobs. Without it the chip resolves to a jobless tooth, which decision 40 discards — a chip that does nothing (§6) |
Corrections to the v1 text found in the same pass: the endpoint is `POST /voice/extract`, not
`/treatments/voice-extract` (§3); `GET /voice/availability` does exist and only the plan check is
deferred (§11 item 13); the catalog has 5 subcategories, not 4, and the disjointness test asserts
against the live catalog rather than a written count (§5).