mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +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
@@ -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