From f7cc47850ac8dc988abef21f112a846a33d30d3b Mon Sep 17 00:00:00 2001 From: aucun6352 Date: Wed, 26 Aug 2026 20:00:44 +0900 Subject: [PATCH] fix(i18n): make the Korean locale actually work end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering `ko` in the `Locale` union left seven locale-keyed tables without an entry, so the build could not type-check and the safety screens, the citation-coverage grader and the reference-range parser read nothing at all for a Korean reader. Three failures were not missing keys but wrong assumptions: - The shared fold decomposes with NFD and strips U+0300-U+036F. Latin accents become plain letters, but Hangul decomposes into jamo, which are not in that range and survived — so folded Korean text was a jamo sequence no precomposed table entry could ever match. It recomposes to NFC now; every Latin, Polish and German fold is byte-identical. - `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte name to the empty string, so the derived index held none of them and a Korean cholesterol result reached a FHIR export uncoded. - The reference-range parser had the hyphen and the dashes but not the tilde a Korean lab actually prints (`3.5~5.0`). The Korean safety banks carry no `\b` next to a Hangul token — `\b` is an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches nothing — and the clause order is the mirror of the Latin banks, since Korean closes with the verb. The dose patterns require a target-marking particle for the same reason the Latin banks require "to"/"by": without it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고 있어요" tripped the lowering pattern. The Coach and Insights prompts route `ko` down the reviewed English body plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so the native builder has no Korean ground rules to compose from. --- CHANGELOG.md | 14 ++++ docs/api/openapi.yaml | 7 ++ messages/ko.json | 4 +- .../copy-matches-behaviour-guard.test.ts | 5 ++ .../dynamic-key-exhaustiveness.test.ts | 3 +- src/__tests__/i18n-english-leak-guard.test.ts | 8 ++ src/__tests__/sharing-boundary-copy.test.ts | 8 +- .../__tests__/clinician-view-locales.test.tsx | 4 +- .../__tests__/type-labels-i18n.test.ts | 3 +- .../managed-profile-affordances.test.tsx | 2 +- .../citation-coverage-logging.test.ts | 2 + src/lib/ai/citation-coverage.ts | 11 +++ src/lib/ai/coach/coach-prose-grounding.ts | 11 +++ src/lib/ai/coach/system-prompt.ts | 3 + .../drug-level-refusal.probe.test.ts | 1 + .../output-language-contract.test.ts | 2 + src/lib/ai/prompts/insight-generator.ts | 5 ++ .../safety/__tests__/outbound-screen.test.ts | 6 ++ src/lib/ai/safety/outbound-screen.ts | 77 +++++++++++++++++++ .../__tests__/describe-screen.test.ts | 1 + src/lib/fhir/lab-loinc.ts | 5 +- src/lib/i18n/fold-for-match.ts | 7 +- .../__tests__/status-summary-screen.test.ts | 3 + .../eval/__tests__/tone-harness.test.ts | 1 + src/lib/insights/no-key-fallbacks.ts | 3 + src/lib/labs/parse-reference-range.ts | 14 +++- src/lib/mcp/__tests__/rich-reads.test.ts | 6 +- .../mood/__tests__/context-vocabulary.test.ts | 4 +- src/lib/record-settings/configuration.ts | 5 +- 29 files changed, 207 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 880236fd0..815835c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +Korean joins as a seventh interface language, and the three things that had to be fixed before it could actually work. + +### Added + +- Korean (`ko`) ships as a seventh interface language, AI-initial and community-maintained alongside French, Spanish, Italian and Polish. The whole surface speaks it: dashboard, settings, share views, the clinician report, notifications and the AI prompts. The Coach and the daily briefing take the reviewed English body with a Korean reply directive rather than a hand-curated Korean one, the same route the other community locales took before they had a safety-contract matrix of their own. + +### Fixed + +- A matcher that reads app text against text a person or a lab typed matched nothing at all in Korean. The shared fold decomposes with NFD and strips the combining marks, which turns Latin accents into plain letters — but Hangul decomposes into jamo, and jamo are not combining marks, so they survived and the folded text no longer resembled any table the app carries. Both readers of that fold were affected: the normative-claim check that grades whether a recommendation needs a citation, and the parser that reads a printed reference range. The fold recomposes now, and every Latin, Polish and German fold is byte-identical to before. +- No Korean analyte name could resolve to a LOINC code. The key normaliser kept `[a-z0-9]` and dropped everything else, so every Korean lab name in the catalogue normalised to the empty string and the derived index held none of them — a Korean account's cholesterol result reached a FHIR export uncoded. +- The reference-range parser now reads the tilde a Korean lab prints (`3.5~5.0`), in both the ASCII and fullwidth forms. It had the hyphen and the dashes and not the separator the reports actually use. + ## [1.37.28] — 2026-08-24 Eleven parts of a shared record were selectable and invisible. Ticking one and sending the link showed the recipient nothing, while the same link's PDF download carried it. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ebba70075..fcc3c3e79 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20248,6 +20248,7 @@ components: - es - it - pl + - ko practiceName: type: string maxLength: 120 @@ -22343,6 +22344,7 @@ components: - fr - it - pl + - ko today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). Format: `YYYY-MM-DD`. Defaults to the server's UTC day." @@ -23234,6 +23236,7 @@ components: - es - it - pl + - ko description: One of the interface locales this build ships. required: - locale @@ -27714,6 +27717,7 @@ components: - fr - it - pl + - ko timezone: type: string minLength: 1 @@ -27929,6 +27933,7 @@ components: - fr - it - pl + - ko - type: "null" timezone: type: string @@ -46021,6 +46026,7 @@ components: - es - it - pl + - ko required: - locale additionalProperties: false @@ -66553,6 +66559,7 @@ components: - fr - it - pl + - ko - type: "null" timezone: type: string diff --git a/messages/ko.json b/messages/ko.json index 6585cac88..88b9e7a70 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3175,9 +3175,9 @@ "debt": { "title": "수면 부채", "value": "{hours}시간 {minutes}분 부족", - "caption": "최근 {nights}일 밤 동안 남은 부족분", + "caption": "최근 {nights}일 밤 동안 부족한 수면", "clearValue": "모두 채웠어요", - "clearCaption": "최근 {nights}일 밤 동안 남은 부족분이 없어요", + "clearCaption": "최근 {nights}일 밤 동안 부족한 수면이 없어요", "learning": "아직 수면을 익히는 중이에요 — 지금까지 {counted}일 밤, 믿을 만한 부족분을 읽으려면 {remaining}일 밤이 더 필요해요.", "computedInfo": "이건 HealthLog가 자체적으로 낸 추정치예요. 최근 밤들의 필요 수면에서 실제 수면을 뺀 누적 잔액이고, 보충 수면이 이를 갚아 나가요. 자체 필요 수면 모델을 쓰는 웨어러블의 수면 부채 수치와는 다를 수 있어요." }, diff --git a/src/__tests__/copy-matches-behaviour-guard.test.ts b/src/__tests__/copy-matches-behaviour-guard.test.ts index 4790ff8eb..b656cda6a 100644 --- a/src/__tests__/copy-matches-behaviour-guard.test.ts +++ b/src/__tests__/copy-matches-behaviour-guard.test.ts @@ -78,6 +78,7 @@ const FINALITY_PHRASES: Record = { "permanente", ], pl: ["nie można cofnąć", "nie można tego cofnąć", "trwale", "nieodwracaln"], + ko: ["되돌릴 수 없", "복구할 수 없", "영구", "완전히 삭제"], }; /** Per-locale phrase that promises the short undo window. */ @@ -88,6 +89,7 @@ const UNDO_PHRASES: Record = { fr: "annuler cette action", it: "annullare", pl: "cofnąć", + ko: "되돌릴 수 있", }; /** Confirmation bodies whose surface renders an Undo affordance. */ @@ -171,6 +173,7 @@ describe("sleep debt is labelled as a balance, not a summed deficit", () => { fr: ["cumulé", "cumulée", "déficit"], it: ["cumulato", "cumulata", "accumulato", "deficit"], pl: ["skumulowan", "niedobór", "niedoboru"], + ko: ["누적", "부족분", "적자", "쌓인"], }; for (const locale of LOCALES) { @@ -296,6 +299,7 @@ describe("the mood prognosis never states a cause", () => { "peggiora il tuo", ], pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"], + ko: ["때문", "탓", "원인", "유발", "초래", "야기", "덕분", "인해"], }; /** Every leaf string under `insights.mood.prognosis`, flattened. */ @@ -394,6 +398,7 @@ describe("the MCP token copy never claims the mint is read-only", () => { fr: ["jeton en lecture seule"], it: ["token in sola lettura"], pl: ["token tylko do odczytu"], + ko: ["읽기 전용 토큰", "읽기전용 토큰"], }; for (const locale of LOCALES) { diff --git a/src/__tests__/dynamic-key-exhaustiveness.test.ts b/src/__tests__/dynamic-key-exhaustiveness.test.ts index 2d9614684..337d49a7f 100644 --- a/src/__tests__/dynamic-key-exhaustiveness.test.ts +++ b/src/__tests__/dynamic-key-exhaustiveness.test.ts @@ -166,13 +166,14 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => { expect(LOCALES.length).toBeGreaterThanOrEqual(6); }); - it("discovers all six shipped locales", () => { + it("discovers all seven shipped locales", () => { expect(LOCALES.map((l) => l.locale).sort()).toEqual([ "de", "en", "es", "fr", "it", + "ko", "pl", ]); }); diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 2239ef51d..424ddf033 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -193,6 +193,14 @@ const LEGIT_IDENTICAL = new Set([ "mentalHealth.sciOptions.problemDuration.2", "mentalHealth.sciOptions.problemDuration.1", "mentalHealth.sciOptions.problemDuration.0", + // Proper nouns, a URL label and an acronym — the accepted written form in + // Korean is the Latin original, so these stay identical to en.json. + "auth.emailPlaceholder", + "settings.fitbitTag", + "settings.googleHealthTag", + "settings.nightscoutUrl", + "admin.aiServerKey.baseUrlLabel", + "admin.webPushVapidSubject", ]); describe("i18n English-leak guard", () => { diff --git a/src/__tests__/sharing-boundary-copy.test.ts b/src/__tests__/sharing-boundary-copy.test.ts index aa0277b78..7c6233c7f 100644 --- a/src/__tests__/sharing-boundary-copy.test.ts +++ b/src/__tests__/sharing-boundary-copy.test.ts @@ -109,10 +109,14 @@ describe("the published boundary matches the one the code draws", () => { // accent: the section is "Anamnèse" in French and "Anamneza" in Polish, // so anything past "anamn" is already language-specific and a matcher // tuned to one of them reports the others as broken. - if (!/anamn/i.test(text)) missing.push(locale); + // Korean shares no Latin stem for either concept, so it is matched on + // its own words: "문진" is the anamnesis section, "계정" the account. + if (!/anamn|문진/i.test(text)) missing.push(locale); // And it must still say the account itself stays with its owner — // dropping that would fix one over-claim by removing the boundary. - expect(text, locale).toMatch(/konto|account|compte|cuenta|konta|koncie/i); + expect(text, locale).toMatch( + /konto|account|compte|cuenta|konta|koncie|계정/i, + ); } expect(missing).toEqual([]); }); diff --git a/src/components/clinician/__tests__/clinician-view-locales.test.tsx b/src/components/clinician/__tests__/clinician-view-locales.test.tsx index 6d8e6e188..ba729e5e8 100644 --- a/src/components/clinician/__tests__/clinician-view-locales.test.tsx +++ b/src/components/clinician/__tests__/clinician-view-locales.test.tsx @@ -223,9 +223,9 @@ const FULL_RECORD = { const DOTTED_KEY = /\b[a-z][A-Za-z0-9]*(\.[A-Za-z0-9_]+){2,}\b/g; describe(" resolves every label in every locale", () => { - it("covers all six shipped locales", () => { + it("covers all seven shipped locales", () => { // A floor: an empty locale list would make the loop below assert nothing. - expect(locales.length).toBe(6); + expect(locales.length).toBe(7); }); for (const locale of locales) { diff --git a/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts index 5f5317c8b..6122de57a 100644 --- a/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts +++ b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts @@ -41,13 +41,14 @@ const REQUIRED_KEYS = [ ]; describe("measurement-reminder + bulk-share dynamic i18n keys", () => { - it("discovers all six shipped locales", () => { + it("discovers all seven shipped locales", () => { expect(LOCALES.map((l) => l.locale).sort()).toEqual([ "de", "en", "es", "fr", "it", + "ko", "pl", ]); }); diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index 58c07f1c3..347184c99 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -276,7 +276,7 @@ describe("the managed-profile card, in the shared-access section", () => { /]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/ .exec(html)?.[0] .match(/