Skip to content

Match user-written text in every language the app ships - #827

Merged
MBombeck merged 10 commits into
mainfrom
fix/locale-matching
Aug 23, 2026
Merged

Match user-written text in every language the app ships#827
MBombeck merged 10 commits into
mainfrom
fix/locale-matching

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

The application ships six languages. Thirteen places decide what happens to user-authored text by consulting a word list typed out in two of them, or in one. This closes the seven that cost a person the most.

What the defect looks like from the outside

You record a side effect by tapping a chip. The chip writes the translated label into a free-text column, so the same symptom is stored as nausea on an English account and Nudności on a Polish one. The GLP-1 timeline then matched that column against a hand-typed transcription of the English and German labels. Four languages contributed nothing, silently.

The reported surface was the timeline. Two more readers had the same list: the Coach snapshot, and the doctor report's GLP-1 block, where a clinician read an empty side-effect table over a record that had entries.

Similar, elsewhere: a lab reference range printed jusqu'à 5,0 parsed to no bounds at all, so the reading was never flagged normal or abnormal. Cholestérol total got no LOINC code on the FHIR export. A French lab PDF failed the gate that decides whether a document is a lab report, so automatic staging never ran for it. Over MCP, Fer and Żelazo resolved to unknown_nutrient, and poids and Sommeil to nothing.

Derive rather than translate

Translating a list four more times fixes today's four languages and reopens the hole the day a seventh is added. Where the app owns the words, the match index is now read out of messages/<locale>.json for every entry in locales, so a new language is matchable the moment its bundle lands, with no edit to any matcher. Tests assert that property rather than the current list.

This was possible more often than expected, because the vocabularies were already on disk: the side-effect chips are seven keys, labs.catalog.<slug> carries 30 analytes in six languages, nutrients.names.* carries 26 codes in six, and the metric names come from MEASUREMENT_TYPE_LABEL_KEYS.

Where deriving does not reach, said plainly

  • The reference-range parser. jusqu'à, hasta, fino a, do are prose a lab printed, in a language unrelated to the reader's. There is no key. The table is hand-written and typed over the shipped locale union, so a seventh language fails to compile until someone answers how it prints a range.
  • Detected / not detected on the qualitative SNOMED table stays English and German. Coding non détecté or niewykrywalny means inventing clinical vocabulary in four languages nobody here can check, against that module's own rule that an unverified code never beats no code. A test pins that they stay uncoded.
  • Drug names. No localised INN catalogue exists. Rather than translate, the match folds both sides to an orthographic stem, so Metformina, Semaglutyd and Lewotyroksyna reach the right class off the existing English needles with no new entries. Iron is the exception and is labelled as one: preparations are named for the element, so there is no stem for Eisen and Żelazo to meet on.

Four defects found on the way, none of them reported

Accent folding was missing in the lab key normaliser, and it broke German. normaliseLabKey deletes every character outside a-z0-9, so Hämoglobin became hmoglobin and never met the hamoglobin alias. Same for Nüchternglukose and Harnsäure. The aliases only ever matched people who left the umlaut off. This is the primary locale, and it has been wrong for as long as the table has existed.

Puls resolved to pulse-wave velocity over MCP, because a substring pass matched English display names and "pulse-wave velocity" contains puls. Exact localised matching now runs ahead of the fuzzy pass.

Four body-composition metrics collapse onto two words. French and Italian call body fat and fat mass the same thing; three languages use one phrase for fat-free mass and lean body mass. Judging that over the exposed slice alone saw no collision and would have answered kilograms to someone asking for a percentage, so ambiguity is judged over the whole label space and an ambiguous word is refused.

An unreadable reference range was indistinguishable from no range at every call site. It has a name now, and two ingestion paths count it.

Not silent about what does not match

An unmatched side-effect tag is counted on a wide event rather than surfaced: what was vanishing was catalogue tags in unsupported languages, and that is closed at the source; what remains is genuine free text, and putting it on a therapy timeline or in a clinician's PDF would be an invented symptom.

Two folds, on purpose

Three matchers were built in parallel and each grew a fold. Two are the same function and now share one. The third stays different: it decomposes with NFKD, maps sharp s to ss, and joins with underscores. That is a disagreement about who typed the text, not an accident — one side of a lab comparison is a shipped label retyped, where tolerating a different spelling claims more than the fold can prove, while someone naming a metric to an assistant is composing rather than reproducing. Both docblocks say so and name the other.

Break-proofs

Every fix reverted and re-run. Walking only en instead of locales reddens 43 tests across three files; cutting the side-effect index back to English and German reddens 16 across four suites; dropping the accent fold reddens the two German lab cases; unwiring the LOINC index reddens 22. Thirty-odd mutations in total, each isolated by its own case. English behaviour is pinned separately in every affected suite.

Gates: typecheck, lint, format:check, openapi:check, 21,822 unit tests, the touched integration suites, and the production build.

…rd list

The GLP-1 side-effect chips in the mood form write the localised label into
the flat `MoodEntry.tags` column, and three readers matched that label against
a hand-written list of English and German words: the therapy timeline, the
Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales.
Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the
day never reached the timeline, the Coach grounded its answers on an empty
side-effect list, and a clinician read an empty table over a record that had
three months of symptoms. Nothing on any of those surfaces said the table was a
filter rather than the truth. The three lists had also drifted apart from each
other.

The chips are a closed set with stable keys, so matching the label was the
defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue —
seven keys, each with the i18n key its chip renders — and the mood form now
draws its strip from it, so the write side and the read side cannot diverge
again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading
`messages/<locale>.json` for every entry in `locales`, rather than transcribing
the labels into a second list. Transcribing would have fixed today's four
locales and reopened the same hole on the day a seventh is added; deriving
means a new bundle is matchable as soon as it lands, with no edit in the
matcher. Comparison folds case, accents and separators, so the English and
German tags already in the database keep resolving and a straight apostrophe
matches the typographic one the French bundle uses.

All three readers now report the catalogue key instead of the stored string.
The same recorded symptom yields one key whatever language it was tagged in,
which also fixes the mixed-language output an account gets after switching UI
locale; the doctor-report PDF resolves the key through the label map so the
clinician reads it in the report's language.

A tag outside the catalogue is still never counted — a free-text mood tag must
not reach a doctor as an invented symptom, and the rest of somebody's day does
not belong on a therapy timeline. It is no longer dropped without trace: the
timeline route annotates the unresolved count on its wide event, so the
classification rate stays answerable while the tag text stays in the database.

The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`.
Those are removed. No chip writes them, so they only ever matched a hand-typed
tag in two of six languages; keeping them would have rebuilt the defect inside
its own fix. Restoring either symptom means giving it a chip and a label in all
six bundles.

The response schema for `GET /api/insights/glp1-timeline` now enumerates the
keys, and its description no longer documents the bilingual match as if it were
the contract.
… or Polish

The free-text reference-range parser decided what a printed window meant from
an alternation of German and English words. The app ships six locales, and a
lab prints in the language of the country it stands in, so `jusqu'à 5,0`,
`hasta 5,0`, `fino a 5,0` and `do 5,0` all fell through to `low: null,
high: null`. The string was stored, the reading was then judged against the
biomarker's own band or against nothing at all, and no surface anywhere said
that the lab had printed a window the app could not read.

The vocabulary is TABULATED here, not derived from `messages/<locale>.json`
the way an app-owned word list would be. These words are prose a lab printed
on paper; the app owns no string they could be read out of, and the language
of a report has nothing to do with the language of the UI. So the table is
hand-written, keyed by the shipped `Locale` union so that adding a locale
fails to compile until someone has answered how that language prints a range,
and every locale's words are tried against every input at once. The cost is
written down at the table: a report in a language absent from it stays
text-only.

Three things had to change beside the word lists. Introducing words are now
stripped only on a second pass, because Italian "da", Polish "od" and French
"de" also state a floor on their own and stripping first read "da 3,5" as a
bare number. Input and table are diacritic-folded and their apostrophes
normalised, so a table row written "jusqu'a" meets a report's "jusqu'à" and
the curled apostrophe a word processor emits. And one-letter separators ("a",
"e", "y") are only accepted with whitespace on both sides, so "3.5e5" is not
read as a window. Decimal commas already worked and are pinned by the new
cases.

An unreadable window now has a name. `isUnreadableRange` separates "the report
stated nothing" from "the report stated something nobody could read" — the two
were indistinguishable at every call site, which is what let the French case
pass unnoticed. The manual form already showed the reader that the text was
kept but not read; the two ingest paths now count it into their wide event, so
an unsupported notation surfaces as a number rather than as silence.

The German and English lists are the previous alternation split word for word,
and the existing suite covering them passes unchanged.
…ipped locale

A biomarker minted from the labs catalog stores `t("labs.catalog.<slug>")` —
the name in the language of whoever minted it — and that stored name is what
reaches the FHIR exporter as free text. The exporter matched it against an
alias table written in English and German. So an account working in French,
Spanish, Italian or Polish exported "Cholestérol total", "Glucosa en ayunas",
"Glicemia a digiuno" and "Cholesterol całkowity" as text-only concepts with no
LOINC code, on the one document whose whole purpose is being read by another
system.

The names are DERIVED from `messages/<locale>.json` now, not transcribed into
this file. Both sides of the new slug map are app-owned identities with no
language in them, which is what makes it the one edit a new locale does not
need: `locales` grows, `messages/xx.json` lands, and the analyte names it
ships resolve the same day. Transcribing four more languages would have fixed
today's six and reopened the hole at the seventh.

The hand-written aliases keep precedence. They cover what the app owns no
string for — clinical abbreviations, the ways a person shortens a name — and
they were verified one at a time over several releases. A derived name landing
on the same key is a harmless duplicate; one landing on a different key would
be a translation typo re-pointing a verified mapping at another analyte, so
the build refuses it and the guard suite asserts the refusal held.

The lookup fold now drops accents before stripping punctuation. Without it a
name carrying diacritics lost them entirely rather than reducing to its base
letters — "Cholestérol total" folded to `cholestroltotal`, and "Hämoglobin" to
`hmoglobin`. That last one means the German umlaut spellings never resolved
either: the hand-written `hamoglobin` and `nuchternglucose` aliases only ever
caught the un-umlauted typing. Every pre-existing alias is ASCII, so the fold
is a widening and what resolved before resolves to the same key.
The lab form offers "negative" and "positive" as quick picks and writes the
label it rendered, so the column holds the bundle's own string in the language
the reading was recorded in. The SNOMED mapper matched that string against a
list transcribed in English and German, so "négatif", "negativo", "ujemny",
"positif" and "dodatni" all exported without a code — text-only on the one
document meant to be machine-readable.

The negative and positive arms read `labs.form.qualNegative` and
`labs.form.qualPositive` out of every bundle now. The form writes those exact
keys, so the write side and the read side cannot drift, and a new locale is
coded the day its bundle lands.

The detected / not-detected pair stays hand-written English and German, and
that is the honest limit of this change: the app ships no string for those
terms, they are prose a lab printed and a person copied in, and coding "non
détecté" or "niewykrywalny" would mean inventing clinical vocabulary in four
languages nobody here can check. This module's own rule is that an unverified
code is worse than an honest `.text`. When they need coding they need a quick
pick and a bundle key, which is the route the other two arms took. A test pins
that they stay uncoded rather than leaving it to be discovered.

Borderline stays uncoded as before; a test now pins that the derivation did
not sweep its bundle key up by accident. Comparison folds accents as well as
case, so a result typed without them still resolves.
…rman

The gate that decides whether a transcribed document is worth an extraction
call scored four classes of evidence and needed two. Three of them — the
reference-range label, the analyte names, the report header — were written in
English and German, so a French, Spanish, Italian or Polish report scored at
most one: the units, which are the same everywhere. Auto-staging never fired
for it. The document was lost from the flow silently, because the manual
extract button stays available and nothing reads as broken.

The text is folded once — lower case, accents dropped — and every pattern is
matched against that, which on its own recovers a good deal: "Cholestérol",
"Créatinine" and "Emoglobina" reach the existing stems as soon as the accents
are out of the way. The reference-range labels and report headers of the four
locales are added by hand, because they are prose a lab printed and the app
owns no string for them.

The analyte names ARE derived, from `labs.catalog.<slug>` across every shipped
bundle: those are names the app itself displays, and a new locale should not
need an edit here to be recognised. Names under four folded characters are
dropped — "ALT" is German for "old", and a three-letter alternative in a gate
this cheap would make every German letter a lab report. The acronyms worth
having are in the hand-written half, where each was considered on its own.

Which way this errs, stated at the code: a false negative loses a whole
document from auto-staging, while a false positive costs one extraction call
and stages facts a person then declines — nothing commits either way. So the
vocabulary is widened, and the two-distinct-class rule holds the other side.
The derived names extend the analyte class rather than forming a fifth one, so
a document naming one analyte in two languages still scores one. Tests pin
each addition on a case that fails the gate without it, and pin the prose that
must keep failing it.
… class by identity

The derived names extended the analyte class by list index. Reordering the
signal list would have re-pointed them at the units or the report header with
nothing to notice, so the class is a named constant and the check compares
against it.
Three resolvers turned a user-authored word into an internal code by
comparing it against a hand-typed English list. The app ships six
languages, so five of them resolved nothing: `Fer` and `Żelazo` came back
as `unknown_nutrient` while the settings card rendered exactly those
words, and `poids` and `sommeil` reached no series at all.

Stop transcribing. `nutrients.names.*` and `measurements.type*` already
label these concepts in every bundle, so the match index is derived from
`locales` instead — a seventh language works the day its bundle lands,
with no edit at any call site. The nutrient tool's English labels now come
out of `messages/en.json` too, so the wire wording cannot drift from the
screen.

The MCP contract is unchanged in shape and stays protocol-level English.
What moves is which words are understood: `unknown_nutrient` /
`unknown_metric` still mean "could not place this word", distinct from a
placed name answering `no_data`. Nothing turns absence into data.

Two findings the sweep did not predict:

- `Puls` resolved to pulse-wave velocity, because the English
  display-name pass matches substrings and "pulse-wave velocity" contains
  it. An exact name now beats a loose English fragment.
- French and Italian use one phrase for body fat and fat mass, and three
  bundles use one for fat-free mass and lean body mass. A word two
  metrics claim resolves to neither; answering kilograms to someone who
  asked for a percentage is worse than saying the word was not
  recognised. The known set is pinned so a new collision surfaces.

The medication needles are the exception: they are drug names, with no
bundle to derive from. They fold to a normalised INN stem rather than to
a language, so `Metformin`, `Metformine`, `Metformina` and `Metforminum`
are one entry, and `Semaglutyd` and `Atorwastatyna` cost nothing to add.
A guard asserts no two molecules collapse onto one stem. Iron is the one
class with no stem to fold to — it is named for the element — so its word
list is hand-tabulated and says so.

Every English resolution that worked before returns the same answer.
The three locale matchers were built in parallel and each grew its own
fold. Two of them are the same function: the side-effect tag fold and
foldForMatch agree character for character, so one now calls the other.

The third stays. foldLabel decomposes with NFKD, maps sharp s to ss and
joins with underscores; foldForMatch decomposes with NFD, leaves sharp s
alone and joins with spaces. That is a disagreement about who typed the
text, not an accident: one side of a lab comparison is a shipped label
retyped or prose a lab printed, where tolerating a different spelling
claims more than the fold can prove, while someone naming a metric to an
assistant is composing rather than reproducing. Both docblocks now say
so and name the other.
@MBombeck
MBombeck merged commit b52262e into main Aug 23, 2026
23 checks passed
@MBombeck
MBombeck deleted the fix/locale-matching branch August 29, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant