Merge branch 'pr839tmp' into feat/korean-locale

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-08-28 21:59:36 +02:00
commit 6ff81b2717
74 files changed
+10848 -66

No files matched your search

+1 -1
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), and Polish (`pl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), and Korean (`ko`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
+1 -1
View File
@@ -44,7 +44,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, and Polish end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
+7
View File
@@ -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
+10526
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const outDir = join(repoRoot, "public", "i18n");
const LOCALES = ["de", "en", "fr", "es", "it", "pl"];
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko"];
mkdirSync(outDir, { recursive: true });
+1 -1
View File
@@ -11,7 +11,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
const file = (loc) => `messages/${loc}.json`;
function read(loc) {
+1 -1
View File
@@ -18,7 +18,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
@@ -40,7 +40,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../messages");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
@@ -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",
]);
});
@@ -33,7 +33,7 @@ const EN_PATH = join(MESSAGES_DIR, "en.json");
// intentionally excluded — it carries accepted anglicisms (e.g. "Health
// Score") that would be noise here, and is covered by its own pinned checks in
// `i18n-locale-integrity`.
const LEAK_LOCALES = ["es", "fr", "it", "pl"] as const;
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko"] as const;
function flattenValues(
obj: unknown,
@@ -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", () => {
@@ -20,7 +20,7 @@ import { join } from "node:path";
* the key is a prop, not a call argument. Both halves are asserted here.
*/
const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
const INSIGHTS_PAGES_DIR = join(process.cwd(), "src/app/insights");
/**
@@ -93,7 +93,7 @@ const NOT_IN_THE_FEED = [
/** What an action with no sentence of its own renders as. */
const FALLBACK_KEY = "recordSharing.activity.acted";
/** The six bundles the product ships. */
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
* `*` skips a leading dot and this tree has dot-directories. */
+1 -1
View File
@@ -25,7 +25,7 @@ import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/in
const ROOT = path.resolve(__dirname, "../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Every path the retired bridge occupied. */
const REMOVED_PATHS = [
+1 -1
View File
@@ -50,7 +50,7 @@ describe("managed record settings UI", () => {
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
"utf8",
),
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
...["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) =>
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
),
]);
+7 -3
View File
@@ -38,7 +38,7 @@ import {
* it — which is the part that rots silently.
*/
const ROOT = process.cwd();
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** The published description of the level, off the Zod schema itself. */
function accessDescription(): string {
@@ -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([]);
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "../..");
const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const;
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
const REQUIRED_KEYS = [
// Security: adding a passkey now requires an existing sign-in factor.
+1 -1
View File
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const LOCALES = ["en", "de", "fr", "es", "it", "pl"] as const;
const LOCALES = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
interface Messages {
notFound: {
@@ -69,7 +69,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
+1 -1
View File
@@ -91,7 +91,7 @@ const MAX_OUTPUT_TOKENS = 600;
const requestSchema = z.object({
text: z.string().min(1).max(MAX_TEXT_LENGTH),
/** Optional UI locale; informational hint for the model. */
locale: z.enum(["en", "de", "es", "fr", "it", "pl"]).optional(),
locale: z.enum(["en", "de", "es", "fr", "it", "pl", "ko"]).optional(),
/**
* Optional override of the reference date used to resolve relative
* phrases ("tomorrow", "next Monday"). Defaults to the server's
+2 -1
View File
@@ -32,7 +32,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
+2 -1
View File
@@ -15,7 +15,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
@@ -202,7 +202,7 @@ describe("dashboard.*Short keys exist across all six locales", () => {
// Resolved at test time so the assertion runs against the current
// bundle, not a stale cached snapshot.
const locales = ["en", "de", "fr", "es", "it", "pl"] as const;
const locales = ["en", "de", "fr", "es", "it", "pl", "ko"] as const;
it.each(locales)("%s carries every dashboard.*Short key", async (locale) => {
const messages = (await import(`../../../../messages/${locale}.json`))
@@ -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) {
@@ -15,7 +15,7 @@ import { useTranslations } from "@/lib/i18n/context";
* but the prose has not been hand-reviewed for register, regional
* vocabulary, or domain-specific nuance.
*
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL/KO
* today) and surfaces a small, dismissible strip at the top of the
* authenticated app shell. It tells the user the translation is
* AI-maintained and links to the GitHub translation-feedback issue
@@ -66,7 +66,7 @@ describe("<OfflineBanner>", () => {
});
it("ships the message key in every supported locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
for (const locale of ["en", "de", "fr", "es", "it", "pl", "ko"] as const) {
const m = loadMessages(locale);
expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0);
}
@@ -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",
]);
});
@@ -71,7 +71,7 @@ export interface NaturalLanguageExtractorProps {
* UI locale forwarded to the route as a hint. The route accepts an
* undefined value and falls back to the resolver chain.
*/
locale?: "en" | "de" | "es" | "fr" | "it" | "pl";
locale?: "en" | "de" | "es" | "fr" | "it" | "pl" | "ko";
/**
* Optional override of the API path so the wizard test harness can
* point at a mock server. Defaults to the production route.
@@ -108,7 +108,7 @@ describe("<ImportPanel> — SSR smoke", () => {
});
describe("<AppleHealthEstimateWarning>", () => {
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
"renders localized estimate disclosure for %s",
(locale) => {
const html = render(<AppleHealthEstimateWarning days={3} />, locale);
@@ -7,7 +7,7 @@ import { ALL_METRICS } from "@/lib/validations/thresholds";
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
const render = (
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
@@ -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", () => {
@@ -218,7 +218,7 @@ describe("<AppleHealthCard>", () => {
}
});
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)(
"renders localized Apple Health copy for %s without leaking keys",
(locale) => {
statusPayload = {
@@ -400,7 +400,7 @@ function ProfileSettingsForm({
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
{["de", "en", "es", "fr", "it", "pl", "ko"].map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
+3 -1
View File
@@ -3,7 +3,7 @@
import * as React from "react";
import { CalendarIcon } from "lucide-react";
import type { Matcher } from "react-day-picker";
import { de, enUS, es, fr, it, pl } from "date-fns/locale";
import { de, enUS, es, fr, it, ko, pl } from "date-fns/locale";
import type { Locale as DateFnsLocale } from "date-fns";
import { cn } from "@/lib/utils";
@@ -325,6 +325,8 @@ function resolveDateFnsLocale(
return it;
case "pl":
return pl;
case "ko":
return ko;
default:
return enUS;
}
@@ -170,7 +170,7 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
// issue #659 — the message used to tell a self-hoster to set
// MAXMIND_LICENSE_KEY in this repository's Actions secrets, which is
// not something they can do. Every locale must name the mount instead.
const locales = ["de", "en", "es", "fr", "it", "pl"] as const;
const locales = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
for (const locale of locales) {
const bundle = JSON.parse(
readFileSync(
@@ -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) => {
+11
View File
@@ -141,6 +141,17 @@ const NORMATIVE_KEYWORDS: Record<Locale, readonly string[]> = {
"powyzej",
"ponizej",
],
ko: [
"목표",
"권장",
"정상 범위",
"해야",
"하세요",
"이상",
"이하",
"초과",
"미만",
],
};
/**
@@ -20,11 +20,11 @@ import type { Locale } from "@/lib/i18n/config";
* - grounding: the literal "SNAPSHOT" token, which the grounding rule's
* `must_contain` pins in every locale.
* - GLP-1 dose safety: the brand registry (Mounjaro / Ozempic), which is
* identical across all six locales (an international registry, never
* identical across all seven locales (an international registry, never
* translated) and only ever appears inside the dose-safety contract.
*/
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl"];
const ALL_LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
describe("resolved Coach system prompt — grounding + GLP-1 per locale", () => {
for (const locale of ALL_LOCALES) {
+11
View File
@@ -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}(?:사이|정도|범위)(?:예요|에요|입니다|이에요)/,
],
};
+4
View File
@@ -1130,6 +1130,7 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record<
es: "\n\nREPLY LANGUAGE: respond in Spanish. Mirror the user's register; use natural Spanish health vocabulary.",
it: "\n\nREPLY LANGUAGE: respond in Italian. Mirror the user's register; use natural Italian health vocabulary.",
pl: "\n\nREPLY LANGUAGE: respond in Polish. Mirror the user's register (formal Pan/Pani for medical-adjacent topics); use natural Polish health vocabulary.",
ko: "\n\nREPLY LANGUAGE: respond in Korean. Use the 해요체 register throughout (never 합쇼체 or plain 한다체); use natural Korean health vocabulary.",
};
/**
@@ -1157,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", () => {
+5
View File
@@ -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 {
+2
View File
@@ -54,6 +54,8 @@ export function targetLanguageName(locale: Locale): string {
return "Italian";
case "pl":
return "Polish";
case "ko":
return "Korean";
default:
return "English";
}
@@ -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", () => {
+77
View File
@@ -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. */
+1 -1
View File
@@ -510,7 +510,7 @@ describe("the scale the popover quotes", () => {
).toBeLessThan(100);
});
it.each(["en", "de", "fr", "es", "it", "pl"])(
it.each(["en", "de", "fr", "es", "it", "pl", "ko"])(
"quotes those same two numbers in %s",
(locale) => {
const bundle = JSON.parse(
+2 -1
View File
@@ -33,7 +33,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
@@ -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) {
@@ -27,7 +27,7 @@ import {
} from "@/lib/feature-flags";
const ROOT = path.resolve(__dirname, "../../../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Names the retired switch went by, on the wire and in the database. */
const RETIRED_NAMES = [
+4 -1
View File
@@ -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, "");
}
/**
+2
View File
@@ -38,6 +38,7 @@ const INTL_LOCALE_MAP: Record<Locale, string> = {
es: "es-ES",
it: "it-IT",
pl: "pl-PL",
ko: "ko-KR",
};
export function resolveIntlLocale(locale: Locale): string {
@@ -310,5 +311,6 @@ export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
if (primary.startsWith("es")) return "es";
if (primary.startsWith("it")) return "it";
if (primary.startsWith("pl")) return "pl";
if (primary.startsWith("ko")) return "ko";
return "en";
}
@@ -18,7 +18,7 @@ import { locales, localeLanguageNames, coerceLocale } from "../config";
import { getPhaseMessage } from "@/lib/jobs/reminder-phases";
import { detectRefusal } from "@/lib/ai/coach/refusal";
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl"] as const;
const NON_GERMAN_TRANSLATED = ["es", "fr", "it", "pl", "ko"] as const;
describe("plural tiers", () => {
it("gives Polish its third tier for 2-4 and keeps many for 5+", () => {
+3 -1
View File
@@ -1,4 +1,4 @@
export const locales = ["de", "en", "fr", "es", "it", "pl"] as const;
export const locales = ["de", "en", "fr", "es", "it", "pl", "ko"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
@@ -32,6 +32,7 @@ export const localeLanguageNames: Record<Locale, string> = {
es: "Spanish",
it: "Italian",
pl: "Polish",
ko: "Korean",
};
export const localeLabels: Record<Locale, string> = {
@@ -41,6 +42,7 @@ export const localeLabels: Record<Locale, string> = {
es: "Español",
it: "Italiano",
pl: "Polski",
ko: "한국어",
};
/**
+6 -1
View File
@@ -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. */
+1
View File
@@ -49,6 +49,7 @@ const loaders: Record<Locale, () => Promise<{ default: MessageBundle }>> = {
es: () => import("../../../messages/es.json"),
it: () => import("../../../messages/it.json"),
pl: () => import("../../../messages/pl.json"),
ko: () => import("../../../messages/ko.json"),
};
function initialCache(): Partial<Record<Locale, MessageBundle>> {
+3 -1
View File
@@ -9,7 +9,7 @@
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
* layout picks the active locale's bundle out of it for the RSC handoff
* and `getServerTranslator()` resolves against it. Do NOT import this
* module from client code — the six static JSON imports would drag
* module from client code — the seven static JSON imports would drag
* every locale back into the client chunk (the ~1.4 MiB regression the
* split removed). Client code uses `./load-locale` (static EN fallback
* floor + dynamic per-locale imports) and `./resolve-key` instead.
@@ -20,6 +20,7 @@ import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import koMessages from "../../../messages/ko.json";
import { locales, type Locale } from "./config";
import { resolveKey } from "./resolve-key";
@@ -36,6 +37,7 @@ export const allMessages: Record<Locale, Record<string, unknown>> = {
es: esMessages,
it: itMessages,
pl: plMessages,
ko: koMessages,
};
/**
@@ -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 }) => {
+3
View File
@@ -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(
@@ -173,7 +173,7 @@ describe("enqueueSummaryCatchUp", () => {
});
describe("the toggle copy discloses the catch-up", () => {
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
function autoReadCopy(locale: string): { subLabel: string; honesty: string } {
const bundle = JSON.parse(
@@ -163,7 +163,7 @@ describe("enqueueStatusGeneration", () => {
const send = vi.fn().mockResolvedValue("job-id");
getGlobalBoss.mockReturnValue({ send });
for (const locale of ["de", "en", "fr", "es", "it", "pl"] as const) {
for (const locale of ["de", "en", "fr", "es", "it", "pl", "ko"] as const) {
await enqueueStatusGeneration({ userId: "u1", metric: "mood", locale });
}
+1 -1
View File
@@ -217,7 +217,7 @@ describe("buildMoodReminderPayload", () => {
expect(p.body).toBe("How are you feeling today?");
});
it.each(["es", "fr", "it", "pl"] as const)(
it.each(["es", "fr", "it", "pl", "ko"] as const)(
"returns native (non-EN) strings for locale=%s",
(locale) => {
const p = buildMoodReminderPayload(locale);
+13 -1
View File
@@ -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")})`;
+3 -3
View File
@@ -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",
]);
});
@@ -196,7 +196,7 @@ describe("mental-health instruments", () => {
});
it("declares validated item locales honestly (SCI = English only)", () => {
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
for (const locale of ["de", "en", "es", "fr", "it", "pl", "ko"]) {
expect(hasValidatedItems("WHO5", locale)).toBe(true);
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
}
+11 -4
View File
@@ -11,8 +11,9 @@
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
* further assessment". WHO publishes official translations for all six
* app locales; they ship verbatim.
* further assessment". Six app locales ship the WHO/PRU translations
* verbatim; Korean, which neither distribution carries, ships the
* published Kim 2010 translation instead.
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
@@ -98,7 +99,7 @@ export interface InstrumentDefinition {
attribution: string;
}
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/**
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
@@ -188,11 +189,17 @@ const WHO5: InstrumentDefinition = {
actionThreshold: 50,
safetyItemIndex: null,
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
// There is no Korean one, so `ko` carries the published forward/back-
// translation from Kim 2010 instead, which is CC BY-NC and so
// redistributable. Its validation sample was community-dwelling elderly,
// not general adults — narrower than the other six arms.
validatedItemLocales: ALL_APP_LOCALES,
attribution:
"World Health Organization. The World Health Organization-Five Well-Being " +
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application. Korean " +
"translation: Kim HJ, Moon YS, Son BK, Lee SK, Rho HJ, Kim DH. J Korean " +
"Geriatr Psychiatry 2010;14(2):90-96 (CC BY-NC 3.0).",
};
/**
@@ -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);
+2 -1
View File
@@ -79,7 +79,8 @@ function isLocale(value: string | null | undefined): value is Locale {
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
value === "pl" ||
value === "ko"
);
}
@@ -855,7 +855,7 @@ export const medicationExtractRequest = z
"Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.",
),
locale: z
.enum(["en", "de", "es", "fr", "it", "pl"])
.enum(["en", "de", "es", "fr", "it", "pl", "ko"])
.optional()
.describe("Optional UI locale hint for the model."),
today: z
+1 -1
View File
@@ -119,7 +119,7 @@ const profileSettings = z
.nullable()
.describe("Calendar date (`YYYY-MM-DD`), not an instant."),
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]).nullable(),
timezone: z.string(),
unitPreference: z.enum(["metric", "imperial"]),
timeFormat: z.enum(["AUTO", "H12", "H24"]),
+4 -1
View File
@@ -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"]).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(),
+1 -1
View File
@@ -19,7 +19,7 @@ export const createManagedProfileSchema = z
.object({
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
timezone: z
.string()
.min(1)
+57 -2
View File
@@ -63,6 +63,55 @@ const suggestionsDe: Record<string, string> = {
'Vorhersehbare Ersetzungen wie „@" statt „a" helfen nicht wesentlich',
};
const warningsKo: Record<string, string> = {
"Straight rows of keys are easy to guess":
"키보드에서 일렬로 놓인 키는 쉽게 추측돼요",
"Short keyboard patterns are easy to guess":
"짧은 키보드 패턴은 쉽게 추측돼요",
'Repeats like "aaa" are easy to guess': '"aaa" 같은 반복은 쉽게 추측돼요',
'Repeats like "abcabc" are easy to guess':
'"abcabc" 같은 반복은 쉽게 추측돼요',
"Sequences like abc or 6543 are easy to guess":
"abc나 6543 같은 연속은 쉽게 추측돼요",
"Recent years are easy to guess": "최근 연도는 쉽게 추측돼요",
"Dates are often easy to guess": "날짜는 대체로 쉽게 추측돼요",
"This is a top-10 common password": "가장 흔한 비밀번호 10개 안에 들어요",
"This is a top-100 common password": "가장 흔한 비밀번호 100개 안에 들어요",
"This is a very common password": "아주 흔한 비밀번호예요",
"This is similar to a commonly used password":
"자주 쓰이는 비밀번호와 비슷해요",
"A word by itself is easy to guess": "단어 하나만으로는 쉽게 추측돼요",
"Names and surnames by themselves are easy to guess":
"이름이나 성만으로는 쉽게 추측돼요",
"Common names and surnames are easy to guess":
"흔한 이름과 성은 쉽게 추측돼요",
};
const suggestionsKo: Record<string, string> = {
"Use a few words, avoid common phrases":
"단어를 여러 개 쓰고, 흔한 문구는 피해 주세요",
"No need for symbols, digits, or uppercase letters":
"기호, 숫자, 대문자를 꼭 넣지 않아도 돼요",
"Add another word or two. Uncommon words are better.":
"단어를 한두 개 더 붙여 주세요. 흔하지 않은 단어일수록 좋아요.",
"Use a longer keyboard pattern with more turns":
"방향이 자주 바뀌는 더 긴 키보드 패턴을 써 주세요",
"Avoid repeated words and characters": "반복되는 단어와 문자는 피해 주세요",
"Avoid sequences": "연속된 배열은 피해 주세요",
"Avoid recent years": "최근 연도는 피해 주세요",
"Avoid years that are associated with you":
"본인과 관련된 연도는 피해 주세요",
"Avoid dates and years that are associated with you":
"본인과 관련된 날짜와 연도는 피해 주세요",
"Capitalization doesn't help very much": "대문자로 바꿔도 큰 도움이 안 돼요",
"All-uppercase is almost as easy to guess as all-lowercase":
"전부 대문자로 써도 전부 소문자만큼 쉽게 추측돼요",
"Reversed words aren't much harder to guess":
"단어를 거꾸로 써도 추측 난이도는 별로 오르지 않아요",
"Predictable substitutions like '@' instead of 'a' don't help very much":
"'a' 대신 '@'처럼 뻔한 치환은 큰 도움이 안 돼요",
};
// English: identity map for all known strings (the library already returns
// English, but we wrap them so unknown values still pass through unchanged).
const warningsEn: Record<string, string> = Object.fromEntries(
@@ -77,8 +126,14 @@ export interface ZxcvbnTranslations {
}
export function getZxcvbnTranslations(locale: Locale): ZxcvbnTranslations {
const warnings = locale === "de" ? warningsDe : warningsEn;
const suggestions = locale === "de" ? suggestionsDe : suggestionsEn;
const warnings =
locale === "de" ? warningsDe : locale === "ko" ? warningsKo : warningsEn;
const suggestions =
locale === "de"
? suggestionsDe
: locale === "ko"
? suggestionsKo
: suggestionsEn;
return {
translate(text: string): string {
return warnings[text] ?? suggestions[text] ?? text;
+2
View File
@@ -17,6 +17,7 @@ import esMessages from "./messages/es.json";
import frMessages from "./messages/fr.json";
import itMessages from "./messages/it.json";
import plMessages from "./messages/pl.json";
import koMessages from "./messages/ko.json";
primeMessages("de", deMessages);
primeMessages("en", enMessages);
@@ -24,3 +25,4 @@ primeMessages("es", esMessages);
primeMessages("fr", frMessages);
primeMessages("it", itMessages);
primeMessages("pl", plMessages);
primeMessages("ko", koMessages);