mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(i18n): make the Korean locale actually work end to end
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.
This commit is contained in:
1 parent
f1b0420479
commit
f7cc47850a
29 files changed
+207
-18
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -3175,9 +3175,9 @@
|
||||
"debt": {
|
||||
"title": "수면 부채",
|
||||
"value": "{hours}시간 {minutes}분 부족",
|
||||
"caption": "최근 {nights}일 밤 동안 남은 부족분",
|
||||
"caption": "최근 {nights}일 밤 동안 부족한 수면",
|
||||
"clearValue": "모두 채웠어요",
|
||||
"clearCaption": "최근 {nights}일 밤 동안 남은 부족분이 없어요",
|
||||
"clearCaption": "최근 {nights}일 밤 동안 부족한 수면이 없어요",
|
||||
"learning": "아직 수면을 익히는 중이에요 — 지금까지 {counted}일 밤, 믿을 만한 부족분을 읽으려면 {remaining}일 밤이 더 필요해요.",
|
||||
"computedInfo": "이건 HealthLog가 자체적으로 낸 추정치예요. 최근 밤들의 필요 수면에서 실제 수면을 뺀 누적 잔액이고, 보충 수면이 이를 갚아 나가요. 자체 필요 수면 모델을 쓰는 웨어러블의 수면 부채 수치와는 다를 수 있어요."
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@ const FINALITY_PHRASES: Record<string, readonly string[]> = {
|
||||
"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<string, string> = {
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -193,6 +193,14 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"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", () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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("<ClinicianView> 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) {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("the managed-profile card, in the shared-access section", () => {
|
||||
/<select[^>]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/
|
||||
.exec(html)?.[0]
|
||||
.match(/<option/g) ?? [];
|
||||
expect(options).toHaveLength(6);
|
||||
expect(options).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("lists the profiles this account looks after, and only those", () => {
|
||||
|
||||
@@ -252,6 +252,7 @@ describe("normative-claim detection across the shipped locales", () => {
|
||||
es: "Su sistólica debería mantenerse por debajo del objetivo de 130 mmHg.",
|
||||
it: "La sistolica dovrebbe restare al di sotto dell’obiettivo di 130 mmHg.",
|
||||
pl: "Ciśnienie skurczowe powinno pozostać poniżej celu 130 mmHg.",
|
||||
ko: "수축기 혈압은 목표인 130 mmHg 아래로 유지해야 해요.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -266,6 +267,7 @@ describe("normative-claim detection across the shipped locales", () => {
|
||||
es: "Su media de 7 días excede en 4 mmHg su mediana de 90 días.",
|
||||
it: "La media a 7 giorni supera di 4 mmHg la mediana a 90 giorni.",
|
||||
pl: "Twoja średnia z 7 dni przekracza medianę z 90 dni o 4 mmHg.",
|
||||
ko: "7일 평균이 90일 중앙값보다 4 mmHg 더 커요.",
|
||||
};
|
||||
|
||||
it.each(locales)("carries a non-empty keyword bank for %s", (locale) => {
|
||||
|
||||
@@ -141,6 +141,17 @@ const NORMATIVE_KEYWORDS: Record<Locale, readonly string[]> = {
|
||||
"powyzej",
|
||||
"ponizej",
|
||||
],
|
||||
ko: [
|
||||
"목표",
|
||||
"권장",
|
||||
"정상 범위",
|
||||
"해야",
|
||||
"하세요",
|
||||
"이상",
|
||||
"이하",
|
||||
"초과",
|
||||
"미만",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -867,6 +867,17 @@ const POPULATION_NORM_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
/\bdoros(?:li|łych)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:potrzebują|wymagają)\b/i,
|
||||
/\b(?:zakres|przedział)\s+(?:referencyjny|normy|prawidłowy|zalecany)\b/i,
|
||||
/\b(?:wynosi|mieści\s+się)\s+(?:zazwyczaj|zwykle|na\s+ogół)\s+(?:od|między|około)\b/i,
|
||||
], // No `\b` next to Hangul (it is an ASCII \w boundary), and the object sits
|
||||
// between subject and verb in Korean, so each entry allows a gap where the
|
||||
// Latin banks put \s+.
|
||||
ko: [
|
||||
/정상\s*범위(?:는|가|를|로|의)/,
|
||||
/(?:참고|기준|권장)\s*(?:범위|치|값)/,
|
||||
/(?:건강한|대부분의)\s*(?:성인|사람들?|인구)/,
|
||||
/일반\s*인구(?:는|에서|의)/,
|
||||
/(?:지침|가이드라인|의사들?|전문가들?)(?:은|이|에서|에서는|에 따르면)?[^.?!]{0,25}(?:권(?:장|해)|권고|제안|조언)/,
|
||||
/(?:성인|사람들?|대부분의?\s*사람들?)(?:은|이|의)?\s*(?:보통|일반적으로|대개|평균적으로)\s*[^.?!]{0,20}(?:필요|권장|적당)/,
|
||||
/(?:보통|일반적으로|대개)\s*[^.?!]{0,15}(?:사이|정도|범위)(?:예요|에요|입니다|이에요)/,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1158,6 +1158,9 @@ export function getCoachSystemPrompt(
|
||||
base = COACH_PROMPT_DE;
|
||||
} else if (locale === "en") {
|
||||
base = COACH_PROMPT_EN;
|
||||
} else if (locale === "ko") {
|
||||
// ko ships no safety-contract matrix, so it is not a NativeCoachLocale.
|
||||
base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK.ko;
|
||||
} else {
|
||||
try {
|
||||
base = buildNativeCoachPrompt(locale, PROMPT_VERSION);
|
||||
|
||||
@@ -56,6 +56,7 @@ const DRUG_LEVEL_CONCEPT_TOKENS: Record<Locale, readonly string[]> = {
|
||||
es: ["nivel", "concentración", "pico", "valle", "Cmax", "terapéutica"],
|
||||
it: ["livello", "concentrazione", "picco", "valle", "Cmax", "terapeutica"],
|
||||
pl: ["poziom", "stężeni", "szczyt", "minimum", "Cmax", "terapeutyczn"],
|
||||
ko: ["농도", "혈중", "최고", "최저", "Cmax", "치료"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ const EXPECTED_LANGUAGE_NAME: Record<Locale, string> = {
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
pl: "Polish",
|
||||
ko: "Korean",
|
||||
};
|
||||
|
||||
/** Which body each locale is expected to compose. */
|
||||
@@ -49,6 +50,7 @@ const EXPECTED_BODY: Record<Locale, "de" | "en"> = {
|
||||
es: "en",
|
||||
it: "en",
|
||||
pl: "en",
|
||||
ko: "en",
|
||||
};
|
||||
|
||||
describe("output-language helper", () => {
|
||||
|
||||
@@ -982,6 +982,7 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
|
||||
es: "\n\nREPLY LANGUAGE: render all user-facing strings in Spanish (peninsular preferred). Use natural Spanish health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
it: "\n\nREPLY LANGUAGE: render all user-facing strings in Italian. Use natural Italian health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
pl: "\n\nREPLY LANGUAGE: render all user-facing strings in Polish. Use natural Polish health vocabulary with formal Pan/Pani register for medical-adjacent topics. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
ko: "\n\nREPLY LANGUAGE: render all user-facing strings in Korean, in the 해요체 register throughout (never 합쇼체 or plain 한다체). Use natural Korean health vocabulary. The severity / sourceWindow / sourceMetric / topic enum values stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -991,6 +992,10 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
|
||||
export function getStrictInsightsSystemPrompt(locale: Locale): string {
|
||||
if (locale === "de") return SYSTEM_PROMPT_DE;
|
||||
if (locale === "en") return SYSTEM_PROMPT_EN;
|
||||
// ko ships no safety-contract matrix, so it is not a NativeCoachLocale.
|
||||
if (locale === "ko") {
|
||||
return SYSTEM_PROMPT_EN + INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK.ko;
|
||||
}
|
||||
try {
|
||||
return buildNativeInsightsPrompt(locale, PROMPT_VERSION);
|
||||
} catch {
|
||||
|
||||
@@ -23,6 +23,7 @@ const DOSE_VIOLATIONS: Record<Locale, string> = {
|
||||
es: "Aumente su dosis a 2,4 mg la próxima semana.",
|
||||
it: "Aumenti la sua dose a 2,4 mg la prossima settimana.",
|
||||
pl: "Proszę zwiększyć dawkę do 2,4 mg w przyszłym tygodniu.",
|
||||
ko: "다음 주에 2.4mg으로 늘려 보세요.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,7 @@ const DOSE_CLEAN: Record<Locale, string> = {
|
||||
es: "Está en la semana 3 con 7,5 mg, y su peso está bajando.",
|
||||
it: "È alla settimana 3 con 7,5 mg, e il suo peso sta calando.",
|
||||
pl: "Jest Pan w 3. tygodniu na 7,5 mg, a waga spada.",
|
||||
ko: "3주째 7.5mg을 복용 중이고, 체중은 내려가고 있어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — dose-prescription, all six locales", () => {
|
||||
@@ -72,6 +74,7 @@ const RISK_VIOLATIONS: Record<Locale, string> = {
|
||||
es: "Su riesgo es del 12%.",
|
||||
it: "Il suo rischio è del 12%.",
|
||||
pl: "Pana ryzyko wynosi 12%.",
|
||||
ko: "10년 심혈관 위험도는 약 12%예요.",
|
||||
};
|
||||
|
||||
/** A plain percentage that is NOT a clinical risk claim must pass. */
|
||||
@@ -82,6 +85,7 @@ const RISK_CLEAN: Record<Locale, string> = {
|
||||
es: "Su adherencia fue del 92% en los últimos 30 días.",
|
||||
it: "La sua aderenza è stata del 92% negli ultimi 30 giorni.",
|
||||
pl: "Przestrzeganie zaleceń wyniosło 92% w ostatnich 30 dniach.",
|
||||
ko: "지난 30일 복약 순응도는 92%였어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — risk-score, all six locales", () => {
|
||||
@@ -324,6 +328,7 @@ const CAUSAL_VIOLATIONS: Record<Locale, string> = {
|
||||
es: "Su peso bajó porque durmió más.",
|
||||
it: "Il suo peso è calato perché ha dormito di più.",
|
||||
pl: "Waga spadła, ponieważ spał Pan więcej.",
|
||||
ko: "수면 때문에 체중이 줄었어요.",
|
||||
};
|
||||
|
||||
/** Descriptive association framing is what the contract REQUIRES. */
|
||||
@@ -334,6 +339,7 @@ const CAUSAL_CLEAN: Record<Locale, string> = {
|
||||
es: "Su peso evolucionó junto con su sueño esta semana.",
|
||||
it: "Il suo peso si è mosso insieme al suo sonno questa settimana.",
|
||||
pl: "Waga zmieniała się razem ze snem w tym tygodniu.",
|
||||
ko: "이번 주 체중은 수면과 함께 움직였어요.",
|
||||
};
|
||||
|
||||
describe("screenModelOutput — causal claims, all six locales", () => {
|
||||
|
||||
@@ -202,6 +202,35 @@ const DOSE_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
`\\b(?:nast[ęe]pna|kolejna|nowa)\\s+dawka\\b[^.?!]{0,30}\\b[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
], // Korean is verb-final: the dose comes first and the change verb closes the
|
||||
// clause ("2.4mg으로 늘려 보세요"), the mirror image of the Latin banks. A
|
||||
// target-marking particle on the dose is required, the same demand the Latin
|
||||
// banks make of "to" / "by" / "auf" — without it the permitted restatement
|
||||
// "7.5mg을 복용 중이고, 체중은 내려가고 있어요" matched the lowering pattern.
|
||||
ko: [
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:올리|올려|늘리|늘려|증량|높이|높여|인상)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\s*(?:으로|로|까지)[^.?!]{0,20}(?:내리|내려|줄이|줄여|감량|낮추|낮춰|감축)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:권해|권장|권고|추천|제안)[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`[\\d.,]+\\s*${DOSE_UNIT}\\b[^.?!]{0,40}(?:권해|권장|권고|추천|제안|드심|드셔|복용하세요|시도해)`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:다음|새)\\s*(?:용량|단계|복용량)[^.?!]{0,30}[\\d.,]+\\s*${DOSE_UNIT}\\b`,
|
||||
"i",
|
||||
),
|
||||
// Both a medication object AND a trial cue are required, as in the EN bank,
|
||||
// so "걸음 수를 두 배로 늘려 2주간 해보세요" never trips.
|
||||
/(?:약|알|정|인슐린|주사|복용|용량)[^.?!]{0,25}(?:절반|반으로|두\s?배|건너뛰|중단|끊)[^.?!]{0,40}(?:주간|주 동안|일 동안|달 동안|보세요|보십시오|보자|해 ?보)/,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -301,6 +330,20 @@ const RISK_NOUN_PL = "(?:ryzyk\\w*|prawdopodobieństw\\w*)";
|
||||
const RESULT_VERB_PL =
|
||||
"(?:umieszcza\\s+(?:cię|pana|panią)|klasyfikuje\\s+(?:cię|pana|panią)|znajdujesz\\s+się\\s+w|wpadasz\\s+w|kwalifikuje\\s+(?:cię|pana|panią))";
|
||||
|
||||
// Korean carries no `\\b` next to a Hangul token: `\\b` is an ASCII \\w boundary
|
||||
// and Hangul is not \\w, so /\\b위험/ matches nothing. Spelled-out percentages are
|
||||
// omitted deliberately — Korean writes "12%", and the Sino-Korean numerals
|
||||
// ("이", "삼") collide with common particles.
|
||||
const QUAL_NUM_KO = "(?:\\d{1,3}\\s*(?:%|퍼센트|프로))";
|
||||
const RISK_NOUN_KO = "(?:위험(?:도|률|성)?|확률|가능성)";
|
||||
const HORIZON_KO = "(?:10년|십\\s?년|평생)\\s*(?:위험(?:도|률)?|리스크)";
|
||||
const RESULT_VERB_KO =
|
||||
"(?:(?:에|으로|로)\\s*(?:분류|해당|속하|들어가)|군에\\s*(?:속하|해당))";
|
||||
const RISK_BAND_KO =
|
||||
"(?:고|중간|중등도|저|높은|낮은)\\s*위험\\s*(?:군|범위|등급|구간)";
|
||||
const RISK_LEVEL_KO = "(?:높|상승|증가|중등도|경계|우려)";
|
||||
const RISK_VERDICT_KO = `(?:은|는|이|가)\\s*[^.?!]{0,10}${RISK_LEVEL_KO}`;
|
||||
|
||||
const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
en: [
|
||||
// (1) digit percent adjacent to a risk noun, either order
|
||||
@@ -413,6 +456,26 @@ const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
new RegExp(`${QUAL_NUM_PL}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"),
|
||||
new RegExp(`\\b${ENGINE}\\b[^.?!]{0,60}${RESULT_VERB_PL}`, "i"),
|
||||
],
|
||||
ko: [
|
||||
new RegExp(`${RISK_NOUN_KO}[^.?!]{0,15}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,15}${RISK_NOUN_KO}`, "i"),
|
||||
new RegExp(`${HORIZON_KO}[^.?!]{0,40}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,40}${HORIZON_KO}`, "i"),
|
||||
new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_KO}`, "i"),
|
||||
new RegExp(`${QUAL_NUM_KO}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RESULT_VERB_KO}`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,60}${RISK_BAND_KO}`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`(?:\\b${ENGINE}\\b|${HORIZON_KO})[^.?!]{0,40}${RISK_VERDICT_KO}`,
|
||||
"i",
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -492,6 +555,18 @@ const CAUSAL_PATTERNS: Record<Locale, readonly RegExp[]> = {
|
||||
/\bdzi[ęe]ki\b/i,
|
||||
/\bodpowiedzialn\w*\s+za\b/i,
|
||||
],
|
||||
ko: [
|
||||
/때문(?:에|이)/,
|
||||
/(?:으로|로)\s*인(?:해|하여|한)/,
|
||||
/탓(?:에|이|으로)/,
|
||||
/(?:이|가)\s*원인/,
|
||||
/원인(?:은|이에요|입니다|이라|으로)/,
|
||||
/유발/,
|
||||
/초래/,
|
||||
/야기/,
|
||||
/덕분(?:에|이)/,
|
||||
/(?:영향을|결과로)\s*(?:줘서|줬|나타났|이어졌)/,
|
||||
],
|
||||
};
|
||||
|
||||
const BANKS: Record<OutboundContract, Record<Locale, readonly RegExp[]>> = {
|
||||
@@ -542,6 +617,7 @@ const DOSE_CONTINUATION: Record<Locale, RegExp> = {
|
||||
es: /\b(?:siga\s+(?:tomando|con)|continúe|mantenga|según\s+lo\s+prescrito)\b/i,
|
||||
it: /\b(?:continui\s+(?:a|con|il)|mantenga|come\s+prescritto)\b/i,
|
||||
pl: /\b(?:kontynuuj|przyjmuj\s+dalej|zgodnie\s+z\s+zaleceniem|pozosta[ńn]\s+przy)\b/i,
|
||||
ko: /(?:그대로\s*(?:유지|복용)|계속\s*(?:복용|드시|유지)|처방(?:대로|받은\s*대로)|지시(?:대로|받은\s*대로)|유지하세요)/,
|
||||
};
|
||||
|
||||
const DOSE_CHANGE_STEM: Record<Locale, RegExp> = {
|
||||
@@ -551,6 +627,7 @@ const DOSE_CHANGE_STEM: Record<Locale, RegExp> = {
|
||||
es: /\b(?:aument|reduzc|reduc|baj|disminu|dobl|omit|salt)\w*/i,
|
||||
it: /\b(?:aument|riduc|ridur|abbass|diminu|raddoppi|salt|dimezz)\w*/i,
|
||||
pl: /\b(?:zwiększ|zmniejsz|obniż|podnie[śs]|podwyższ|zreduk|pomi[ńn]|opu[śs][ćc])\w*/i,
|
||||
ko: /(?:올리|올려|늘리|늘려|증량|높이|높여|인상|내리|내려|줄이|줄여|감량|낮추|낮춰|감축|절반|반으로|두\s?배|건너뛰|중단|끊)/,
|
||||
};
|
||||
|
||||
/** Sentence-level split — the dose exemption must be scoped to one sentence. */
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("runDocumentSummary — screened prose", () => {
|
||||
es: "Este informe sugiere aumentar su dosis a 2,4 mg la próxima semana.",
|
||||
it: "Questo referto suggerisce di aumentare la sua dose a 2,4 mg la prossima settimana.",
|
||||
pl: "Ten raport sugeruje, aby zwiększyć dawkę do 2,4 mg w przyszłym tygodniu.",
|
||||
ko: "이 보고서는 다음 주에 2.4mg으로 늘려 보라고 권해요.",
|
||||
};
|
||||
|
||||
for (const locale of locales) {
|
||||
|
||||
@@ -62,7 +62,10 @@ export interface LabLoincMapping {
|
||||
* resolved before resolves to the same key now.
|
||||
*/
|
||||
export function normaliseLabKey(s: string): string {
|
||||
return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9]/g, "");
|
||||
// Hangul syllables are kept alongside the ASCII range: stripping to
|
||||
// [a-z0-9] alone reduced every Korean analyte name to the empty string, so
|
||||
// `buildLocalisedAliases` indexed none of them and no Korean label resolved.
|
||||
return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9가-힣]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,12 @@ const COMBINING_MARKS = /[\u0300-\u036f]/gu;
|
||||
* printed range's "3,5-5,0", a unit's "mg/dL".
|
||||
*/
|
||||
export function stripDiacritics(raw: string): string {
|
||||
return raw.normalize("NFD").replace(COMBINING_MARKS, "");
|
||||
// Recomposed with NFC after the strip because NFD splits Hangul into JAMO,
|
||||
// and jamo are not in the COMBINING_MARKS range, so they survive: without
|
||||
// this the folded form of "목표" is a jamo sequence that no precomposed
|
||||
// literal in any table can match. Latin/Polish/German folds are unaffected —
|
||||
// the combining marks are already gone, so there is nothing to recompose.
|
||||
return raw.normalize("NFD").replace(COMBINING_MARKS, "").normalize("NFC");
|
||||
}
|
||||
|
||||
/** Collapse every apostrophe variant onto the straight ASCII one. */
|
||||
|
||||
@@ -21,6 +21,7 @@ const DOSE_VIOLATION: Record<string, string> = {
|
||||
es: '{"summary":"Aumente su dosis a 2,4 mg la próxima semana."}',
|
||||
it: '{"summary":"Aumenti la sua dose a 2,4 mg la prossima settimana."}',
|
||||
pl: '{"summary":"Proszę zwiększyć dawkę do 2,4 mg w przyszłym tygodniu."}',
|
||||
ko: '{"summary":"다음 주에 2.4mg으로 늘려 보세요."}',
|
||||
};
|
||||
|
||||
const CAUSAL_VIOLATION: Record<string, string> = {
|
||||
@@ -30,6 +31,7 @@ const CAUSAL_VIOLATION: Record<string, string> = {
|
||||
es: '{"summary":"Su peso bajó porque durmió más."}',
|
||||
it: '{"summary":"Il suo peso è calato perché ha dormito di più."}',
|
||||
pl: '{"summary":"Waga spadła, ponieważ spał Pan więcej."}',
|
||||
ko: '{"summary":"수면 때문에 체중이 줄었어요."}',
|
||||
};
|
||||
|
||||
const CLEAN: Record<string, string> = {
|
||||
@@ -39,6 +41,7 @@ const CLEAN: Record<string, string> = {
|
||||
es: '{"summary":"Su peso promedia 82 kg esta semana, estable frente al mes pasado."}',
|
||||
it: '{"summary":"Il suo peso è in media di 82 kg questa settimana, stabile rispetto al mese scorso."}',
|
||||
pl: '{"summary":"Waga wynosi średnio 82 kg w tym tygodniu, stabilnie wobec zeszłego miesiąca."}',
|
||||
ko: '{"summary":"이번 주 체중은 평균 82kg으로, 지난달과 비슷해요."}',
|
||||
};
|
||||
|
||||
describe("finalizeStatusSummary — dose prescriptions are withheld", () => {
|
||||
|
||||
@@ -249,6 +249,7 @@ describe("tone harness — deterministic fallbacks", () => {
|
||||
es: /no hay ninguna valoración/,
|
||||
it: /non è disponibile alcuna valutazione/,
|
||||
pl: /nie ma (?:tu )?żadnej oceny/,
|
||||
ko: /평가가 없어요/,
|
||||
};
|
||||
|
||||
describe.each(FLOORS)("$name floor", ({ build }) => {
|
||||
|
||||
@@ -479,6 +479,7 @@ const NO_READ_LEAD: Localized<string> = {
|
||||
es: "Ahora mismo no hay ninguna valoración disponible.",
|
||||
it: "Al momento non è disponibile alcuna valutazione.",
|
||||
pl: "Na razie nie ma tu żadnej oceny.",
|
||||
ko: "지금은 이 카드에 대한 평가가 없어요.",
|
||||
};
|
||||
|
||||
function noReadLead(locale: InsightLocale): string {
|
||||
@@ -520,6 +521,7 @@ const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = {
|
||||
es: (n) => `Ahora mismo no hay ninguna valoración${n}.`,
|
||||
it: (n) => `Al momento non è disponibile alcuna valutazione${n}.`,
|
||||
pl: (n) => `Na razie nie ma żadnej oceny${n}.`,
|
||||
ko: (n) => `지금은${n} 평가가 없어요.`,
|
||||
};
|
||||
|
||||
const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
|
||||
@@ -529,6 +531,7 @@ const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = {
|
||||
es: (m) => ` de «${m}»`,
|
||||
it: (m) => ` per «${m}»`,
|
||||
pl: (m) => ` dla „${m}”`,
|
||||
ko: (m) => ` '${m}'에 대한`,
|
||||
};
|
||||
|
||||
export function getNoKeyBiomarkerStatusText(
|
||||
|
||||
@@ -259,6 +259,15 @@ export const RANGE_PROSE: Record<Locale, RangeProse> = {
|
||||
"wartosci referencyjne",
|
||||
"normy laboratoryjne",
|
||||
],
|
||||
}, // Korean marks a bound with a POSTPOSITION — "5.0 이하", "3.5 이상" — and the
|
||||
// matchers here are prefix-anchored, so only the prefix-shaped notation is
|
||||
// listed. A postfixed Korean range still falls through to text-only, which
|
||||
// `isUnreadableRange` reports rather than hides.
|
||||
ko: {
|
||||
twoSided: ["에서", "부터"],
|
||||
upper: ["최대", "최고"],
|
||||
lower: ["최소", "최저"],
|
||||
filler: ["참고치", "참고범위", "정상치", "정상범위", "기준치", "기준범위"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -291,7 +300,10 @@ function proseAlternation(field: keyof RangeProse): string {
|
||||
* "e" and "y" are one letter long, and a bare `e` that could sit between two
|
||||
* digits would read "3.5e5" as a window.
|
||||
*/
|
||||
const SYMBOL_SEP = String.raw`-|–|—|‒|…|\.{2,3}`;
|
||||
// U+007E and the fullwidth U+FF5E are the separator a Korean lab prints
|
||||
// ("3.5~5.0"); both are added here rather than to `twoSided` because they are
|
||||
// symbols, and the symbol branch needs no surrounding whitespace.
|
||||
const SYMBOL_SEP = String.raw`-|–|—|‒|~|~|…|\.{2,3}`;
|
||||
|
||||
/** Prefixes that state a ceiling only. */
|
||||
const UPPER_PREFIX = `(?:<=|<|≤|${proseAlternation("upper")})`;
|
||||
|
||||
@@ -337,9 +337,9 @@ describe("resolveRichMetric — localised metric names", () => {
|
||||
expect(partial.sort()).toEqual([
|
||||
"BODY_TEMPERATURE_DEVIATION: en",
|
||||
"BREATHING_DISTURBANCE_EVENT: en",
|
||||
"FAT_FREE_MASS: de,en,es",
|
||||
"FAT_MASS: de,en,es,pl",
|
||||
"LEAN_BODY_MASS: de,en,es",
|
||||
"FAT_FREE_MASS: de,en,es,ko",
|
||||
"FAT_MASS: de,en,es,pl,ko",
|
||||
"LEAN_BODY_MASS: de,en,es,ko",
|
||||
"WALKING_STEADINESS_EVENT: en",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -73,10 +73,10 @@ const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [
|
||||
];
|
||||
|
||||
describe("mood context vocabulary labels", () => {
|
||||
it("reads six locales and a non-empty vocabulary", () => {
|
||||
it("reads seven locales and a non-empty vocabulary", () => {
|
||||
// Both ends have to be there before anything below proves anything. A
|
||||
// guard that walks an empty list is a green light nobody earned.
|
||||
expect(LOCALES.length).toBe(6);
|
||||
expect(LOCALES.length).toBe(7);
|
||||
expect(VALUE_GROUPS.length).toBeGreaterThan(0);
|
||||
const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0);
|
||||
expect(total).toBeGreaterThan(30);
|
||||
|
||||
@@ -59,7 +59,10 @@ const profilePatchSchema = z
|
||||
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
|
||||
dateOfBirth: z.string().date().nullable().optional(),
|
||||
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]).nullable().optional(),
|
||||
locale: z
|
||||
.enum(["de", "en", "es", "fr", "it", "pl", "ko"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
timezone: timezoneSchema.optional(),
|
||||
unitPreference: z.enum(["metric", "imperial"]).optional(),
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
||||
|
||||
Reference in new issue
Block a user