diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 202e7ec9d..fa60753c9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13235,6 +13235,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -13274,6 +13275,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -13313,6 +13315,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -13352,6 +13355,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -13391,6 +13395,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -13431,6 +13436,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -13533,6 +13539,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -13585,6 +13592,7 @@ paths: - ko - nl - pt-BR + - ru description: Optional UI-locale override, any language the app ships; defaults to the session locale. responses: "200": @@ -27962,6 +27970,7 @@ components: - ko - nl - pt-BR + - ru practiceName: type: string maxLength: 120 @@ -28676,6 +28685,7 @@ components: - ko - nl - pt-BR + - ru - type: "null" timezone: type: string @@ -30499,6 +30509,7 @@ components: - ko - nl - pt-BR + - ru today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." @@ -31468,6 +31479,7 @@ components: - ko - nl - pt-BR + - ru description: One of the interface locales this build ships. required: - locale @@ -31510,6 +31522,7 @@ components: - ko - nl - pt-BR + - ru scope: type: object properties: @@ -31681,6 +31694,7 @@ components: - ko - nl - pt-BR + - ru attachmentIds: minItems: 1 maxItems: 5 @@ -35679,6 +35693,7 @@ components: - ko - nl - pt-BR + - ru required: - message InboundFactEditRequest: @@ -37178,6 +37193,7 @@ components: - ko - nl - pt-BR + - ru timezone: type: string minLength: 1 @@ -37234,6 +37250,7 @@ components: - ko - nl - pt-BR + - ru timezone: type: string minLength: 1 @@ -37454,6 +37471,7 @@ components: - ko - nl - pt-BR + - ru - type: "null" timezone: type: string @@ -60477,6 +60495,7 @@ components: - ko - nl - pt-BR + - ru required: - locale additionalProperties: false @@ -89017,6 +89036,7 @@ components: - ko - nl - pt-BR + - ru - type: "null" timezone: type: string diff --git a/messages/ru.json b/messages/ru.json index c7655e83b..0f2fcd695 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -650,7 +650,7 @@ "weight": "Вес", "bloodPressure": "АД", "pulse": "Пульс", - "bodyFat": "Жировая масса", + "bodyFat": "Процент жировой массы", "sleep": "Сон", "steps": "Шаги", "vo2Max": "VO₂ max", @@ -660,7 +660,7 @@ "bloodPressureSysShort": "АД (сист.)", "bloodPressureDiaShort": "АД (диаст.)", "pulseShort": "Пульс", - "bodyFatShort": "Жировая масса", + "bodyFatShort": "% жира", "moodShort": "Настроение", "sleepShort": "Сон", "stepsShort": "Шаги", @@ -758,7 +758,7 @@ "weight": "Вес", "bloodPressure": "АД", "pulse": "Пульс", - "bodyFat": "Жировая масса", + "bodyFat": "Процент жировой массы", "glucose": "Глюкоза крови", "sleep": "Сон", "steps": "Шаги", @@ -809,7 +809,7 @@ "typeBpSys": "Сист.", "typeBpDia": "Диаст.", "typePulse": "Пульс", - "typeBodyFat": "Жировая масса", + "typeBodyFat": "Процент жировой массы", "typeSleep": "Сон", "typeSteps": "Шаги", "typeBloodGlucose": "Глюкоза крови", @@ -2558,7 +2558,7 @@ "weight": "Вес", "bloodPressure": "АД", "pulse": "Пульс", - "bodyFat": "Жировая масса", + "bodyFat": "Процент жировой массы", "sleep": "Сон", "steps": "Шаги", "hrv": "Вариабельность сердечного ритма", @@ -3134,7 +3134,7 @@ "spo2": "Кислород в крови", "pulse_wave_velocity": "Скорость пульсовой волны", "vascular_age": "Сосудистый возраст", - "body_fat": "Жировая масса", + "body_fat": "Процент жировой массы", "fat_mass": "Жировая масса", "fat_free_mass": "Обезжиренная масса", "muscle_mass": "Мышечная масса", @@ -4055,17 +4055,17 @@ } }, "bodyFat": { - "title": "Жировая масса", + "title": "Процент жировой массы", "description": "Ваш процент жировой массы в динамике.", - "chartTitle": "Жировая масса", + "chartTitle": "Процент жировой массы", "emptyState": { "title": "Пока нет данных о жировой массе", "description": "Измерения жировой массы появятся здесь, как только синхронизируются весы с анализом состава тела или когда вы запишете одно вручную.", - "cta": "Записать жировую массу" + "cta": "Записать процент жира" } }, "navFatMass": "Жировая масса", - "navBodyFat": "Жировая масса", + "navBodyFat": "Процент жировой массы", "muscleMass": { "title": "Мышечная масса", "description": "Ваша мышечная масса в динамике, в килограммах.", @@ -4997,7 +4997,7 @@ "metricBpSys": "АД (систолическое)", "metricBpDia": "АД (диастолическое)", "metricPulse": "Пульс в покое", - "metricBodyFat": "Жировая масса", + "metricBodyFat": "Процент жировой массы", "metricSleep": "Длительность сна", "metricSteps": "Шаги за день", "metricGlucoseFasting": "Глюкоза (натощак)", @@ -5642,7 +5642,7 @@ "weight": "Вес", "bloodPressure": "АД", "pulse": "Пульс", - "bodyFat": "Жировая масса", + "bodyFat": "Процент жировой массы", "bodyTemperature": "Температура тела", "spo2": "Насыщение кислородом", "hrv": "Вариабельность сердечного ритма", @@ -10003,7 +10003,7 @@ "PULSE": "Пульс", "BLOOD_GLUCOSE": "Глюкоза крови", "OXYGEN_SATURATION": "Насыщение кислородом", - "BODY_FAT": "Жировая масса", + "BODY_FAT": "Процент жировой массы", "BODY_TEMPERATURE": "Температура тела", "FAT_MASS": "Жировая масса", "FAT_FREE_MASS": "Обезжиренная масса", @@ -14095,4 +14095,4 @@ "endsOn": "Конец" } } -} \ No newline at end of file +} diff --git a/scripts/i18n-key-tool.mjs b/scripts/i18n-key-tool.mjs index ca4eb0cfd..4aa1ca599 100644 --- a/scripts/i18n-key-tool.mjs +++ b/scripts/i18n-key-tool.mjs @@ -11,7 +11,7 @@ */ import { readFileSync, writeFileSync } from "node:fs"; -const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"]; +const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR", "ru"]; const file = (loc) => `messages/${loc}.json`; function read(loc) { diff --git a/scripts/split-description.mjs b/scripts/split-description.mjs index 4c6e9374f..dcbd68e61 100644 --- a/scripts/split-description.mjs +++ b/scripts/split-description.mjs @@ -18,7 +18,7 @@ */ import { readFileSync, writeFileSync } from "node:fs"; -const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"]; +const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR", "ru"]; const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u; /** diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 27b6e688a..9c5349e11 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94362, + "total": 94379, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -22,14 +22,14 @@ "components/timeline/timeline-geometry.ts": 1288, "lib/ai/anthropic-client.ts": 804, "lib/ai/coach/about-me.ts": 1012, - "lib/ai/coach/coach-prose-grounding.ts": 1079, + "lib/ai/coach/coach-prose-grounding.ts": 1091, "lib/ai/coach/persistence.ts": 1218, "lib/ai/coach/results/metric-table-tool.ts": 1102, "lib/ai/coach/snapshot.ts": 1346, "lib/ai/coach/stream-events.ts": 817, "lib/ai/coach/types.ts": 1011, "lib/ai/codex-client.ts": 907, - "lib/ai/prompts/insight-generator.ts": 1110, + "lib/ai/prompts/insight-generator.ts": 1115, "lib/ai/provider.ts": 1628, "lib/analytics/score/reader.ts": 998, "lib/analytics/sleep-night.ts": 1345, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a9e9c0346..39d620275 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 345, - "total": 111929, + "total": 111928, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -45,7 +45,7 @@ "app/coach/plans/page.tsx :: CoachPlansBody": 216, "app/insights/catalog/page.tsx :: MetricCatalogPage": 204, "app/insights/medications/page.tsx :: InsightsMedikamentePage": 265, - "app/insights/page-client.tsx :: InsightsPageClient": 376, + "app/insights/page-client.tsx :: InsightsPageClient": 375, "app/labs/page.tsx :: LabsPage": 228, "app/medications/page-client.tsx :: MedicationsPageClient": 590, "app/notifications/page.tsx :: NotificationsPage": 327, diff --git a/src/__tests__/dynamic-key-exhaustiveness.test.ts b/src/__tests__/dynamic-key-exhaustiveness.test.ts index 42e78665b..5e16325ab 100644 --- a/src/__tests__/dynamic-key-exhaustiveness.test.ts +++ b/src/__tests__/dynamic-key-exhaustiveness.test.ts @@ -166,7 +166,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => { expect(LOCALES.length).toBeGreaterThanOrEqual(6); }); - it("discovers all nine shipped locales", () => { + it("discovers all ten shipped locales", () => { expect(LOCALES.map((l) => l.locale).sort()).toEqual([ "de", "en", @@ -177,6 +177,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => { "nl", "pl", "pt-BR", + "ru", ]); }); diff --git a/src/__tests__/i18n-no-dot-separator.test.ts b/src/__tests__/i18n-no-dot-separator.test.ts index f464e6809..4a35ee626 100644 --- a/src/__tests__/i18n-no-dot-separator.test.ts +++ b/src/__tests__/i18n-no-dot-separator.test.ts @@ -30,6 +30,14 @@ const hasSeparator = (value: string) => /** Key path -> why the separator is right there. Empty by intent. */ const ALLOWED: Record = {}; +/** + * Bundles where a spaced dash is punctuation the language requires rather than + * a fragment join. Russian uses the dash where English puts a comma or a full + * stop ("Сводка за неделю — всё спокойно"), so the ru bundle is exempt from + * the dash rule; the middle dot is still flagged there. + */ +const DASH_EXEMPT_BUNDLES = new Set(["ru.json"]); + function flatten( obj: unknown, prefix: string, @@ -66,7 +74,13 @@ describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () => it(`${file} has no dot or dash separator outside the allowlist`, () => { const offenders = [...values] - .filter(([key, value]) => hasSeparator(value) && !(key in ALLOWED)) + .filter(([key, value]) => { + if (!hasSeparator(value) || key in ALLOWED) return false; + if (DASH_EXEMPT_BUNDLES.has(file) && !value.includes(" · ")) { + return false; + } + return true; + }) .map(([key, value]) => `${key} = ${JSON.stringify(value)}`); expect(offenders).toEqual([]); }); diff --git a/src/components/clinician/__tests__/clinician-view-locales.test.tsx b/src/components/clinician/__tests__/clinician-view-locales.test.tsx index 366f794a4..4fa3094b9 100644 --- a/src/components/clinician/__tests__/clinician-view-locales.test.tsx +++ b/src/components/clinician/__tests__/clinician-view-locales.test.tsx @@ -223,9 +223,9 @@ const FULL_RECORD = { const DOTTED_KEY = /\b[a-z][A-Za-z0-9]*(\.[A-Za-z0-9_]+){2,}\b/g; describe(" resolves every label in every locale", () => { - it("covers all nine shipped locales", () => { + it("covers all ten shipped locales", () => { // A floor: an empty locale list would make the loop below assert nothing. - expect(locales.length).toBe(9); + expect(locales.length).toBe(10); }); for (const locale of locales) { diff --git a/src/components/insights/derived/__tests__/coach-read-strip-latest-date.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-latest-date.test.tsx index 6d48301bb..8650624ba 100644 --- a/src/components/insights/derived/__tests__/coach-read-strip-latest-date.test.tsx +++ b/src/components/insights/derived/__tests__/coach-read-strip-latest-date.test.tsx @@ -93,6 +93,7 @@ describe("CoachReadStrip — the latest reading's day", () => { ko: "10월 1일", pl: "1 października", "pt-BR": "1 de outubro", + ru: "1 октября", }; for (const [locale, date] of Object.entries(expected) as Array< [Locale, string] diff --git a/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts index 93689f42a..7643df2d2 100644 --- a/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts +++ b/src/components/measurement-reminders/__tests__/type-labels-i18n.test.ts @@ -41,7 +41,7 @@ const REQUIRED_KEYS = [ ]; describe("measurement-reminder + bulk-share dynamic i18n keys", () => { - it("discovers all nine shipped locales", () => { + it("discovers all ten shipped locales", () => { expect(LOCALES.map((l) => l.locale).sort()).toEqual([ "de", "en", @@ -52,6 +52,7 @@ describe("measurement-reminder + bulk-share dynamic i18n keys", () => { "nl", "pl", "pt-BR", + "ru", ]); }); diff --git a/src/components/settings/integrations/__tests__/integration-health-copy.test.ts b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts index 65623f293..d626eb1b4 100644 --- a/src/components/settings/integrations/__tests__/integration-health-copy.test.ts +++ b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts @@ -8,6 +8,7 @@ import { } from "@/lib/integrations/connection-health"; import de from "../../../../../messages/de.json"; +import ru from "../../../../../messages/ru.json"; import en from "../../../../../messages/en.json"; import es from "../../../../../messages/es.json"; import fr from "../../../../../messages/fr.json"; @@ -36,6 +37,7 @@ const CATALOGS: Record = { nl, ko, "pt-BR": ptBR, + ru: ru, }; function translator(locale: Locale) { diff --git a/src/lib/ai/__tests__/citation-coverage-logging.test.ts b/src/lib/ai/__tests__/citation-coverage-logging.test.ts index 00d002f61..3b7a29b2b 100644 --- a/src/lib/ai/__tests__/citation-coverage-logging.test.ts +++ b/src/lib/ai/__tests__/citation-coverage-logging.test.ts @@ -255,6 +255,7 @@ describe("normative-claim detection across the shipped locales", () => { nl: "Je bovendruk zou onder de streefwaarde van 130 mmHg moeten blijven.", ko: "수축기 혈압은 목표인 130 mmHg 아래로 유지해야 해요.", "pt-BR": "Sua sistólica deveria ficar abaixo da meta de 130 mmHg.", + ru: "Ваше систолическое должно оставаться ниже целевого уровня 130 мм рт. ст.", }; /** @@ -273,6 +274,7 @@ describe("normative-claim detection across the shipped locales", () => { ko: "7일 평균이 90일 중앙값보다 4 mmHg 더 커요.", "pt-BR": "Sua média de 7 dias está 4 mmHg mais alta que a sua mediana de 90 dias.", + ru: "Среднее за 7 дней — 128 мм рт. ст., медиана за 90 дней — 124.", }; it.each(locales)("carries a non-empty keyword bank for %s", (locale) => { diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts index 89ed32629..4fcf5c15e 100644 --- a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -679,6 +679,7 @@ describe("population-norm banks, one per locale", () => { nl: "Gezonde volwassenen slapen 7 tot 9 uur.", ko: "건강한 성인은 7~9시간 자요.", "pt-BR": "A maioria dos adultos precisa de 7 a 9 horas de sono.", + ru: "Здоровые взрослые обычно спят от 7 до 9 часов.", }; it.each(locales)("%s reads its reference sentence as a norm", (locale) => { diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index 7f1a050c3..cb2e7c131 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -635,12 +635,18 @@ const MONTHS = [ "januari|februari|maart|mei|augustus", // pt-BR "janeiro|fevereiro|março|marco|maio|junho|julho|setembro|outubro|novembro|dezembro", + // ru — genitive, as a date spells it ("21 июля") + "января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря", ].join("|"); /** Parse a raw number token honouring the reply language's separators. */ function parseLocaleNumber(raw: string, locale: Locale): number | null { let normalised: string; - if (locale === "en") { + if (locale === "ru") { + // RU: a space (usually a no-break one) groups thousands, "," is the + // decimal point. + normalised = raw.replace(/[\s\u00A0]/g, "").replace(/,/g, "."); + } else if (locale === "en") { // EN: "," groups thousands, "." is the decimal point. normalised = raw.replace(/,/g, ""); } else { @@ -673,7 +679,11 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { const numFrag = locale === "en" ? String.raw`[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?|[+-]?\d+(?:\.\d+)?` - : String.raw`[+-]?\d{1,3}(?:\.\d{3})+(?:,\d+)?|[+-]?\d+(?:,\d+)?`; + : locale === "ru" + ? // ru groups thousands with a space (usually a no-break one) and + // writes the decimal point as a comma: "1 200,5". + String.raw`[+-]?\d{1,3}(?:[\s\u00A0]\d{3})+(?:,\d+)?|[+-]?\d+(?:,\d+)?` + : String.raw`[+-]?\d{1,3}(?:\.\d{3})+(?:,\d+)?|[+-]?\d+(?:,\d+)?`; const runPass = (re: RegExp, handle: (m: RegExpExecArray) => void): void => { re.lastIndex = 0; @@ -704,7 +714,9 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { // perfectly correct sentence. runPass( new RegExp( - String.raw`\b(?:(?:${MONTHS})\s+\d{1,2}(?:st|nd|rd|th)?|\d{1,2}\.?\s+(?:de|di|del|dell'|d')?\s*(?:${MONTHS}))\b`, + // Cyrillic-safe edges: `\b` is ASCII-only, so «21 июля» has no + // boundary after the month name. + String.raw`(? { diff --git a/src/lib/ai/coach/population-norms/__tests__/liveness.test.ts b/src/lib/ai/coach/population-norms/__tests__/liveness.test.ts index 145e4e072..dd648e396 100644 --- a/src/lib/ai/coach/population-norms/__tests__/liveness.test.ts +++ b/src/lib/ai/coach/population-norms/__tests__/liveness.test.ts @@ -92,6 +92,16 @@ const SENTENCES: Record = { "Os adultos precisam de 8 horas de sono.", "A pressão arterial normal fica abaixo de 120/80.", ], + ru: [ + "Нормальный диапазон — от 60 до 100.", + "Референсный интервал составляет от 4 до 6.", + "Большинство взрослых спит 7–9 часов.", + "Взрослые обычно спят около 8 часов.", + "Врачи рекомендуют 150 минут в неделю.", + "В общей популяции средний уровень — около 120.", + "Здоровые показатели лежат в этих пределах.", + "В среднем по популяции это около 120.", + ], }; describe("population-norm banks: no pattern is dead", () => { diff --git a/src/lib/ai/coach/population-norms/ru.ts b/src/lib/ai/coach/population-norms/ru.ts index 403e7b03a..7cf5b77b7 100644 --- a/src/lib/ai/coach/population-norms/ru.ts +++ b/src/lib/ai/coach/population-norms/ru.ts @@ -1,11 +1,36 @@ /** Population-norm framing, ru. */ +/** + * Cyrillic-safe word edges. JavaScript defines `\b` and `\w` over ASCII only, + * so `\bнормальн` never matches before a Cyrillic letter and `\w*` stops at + * the first Russian one. These lookarounds treat [а-яё] as word letters. + */ +const START = "(? = { nl: ["spiegel", "concentratie", "piek", "dal", "Cmax", "therapeutisch"], "pt-BR": ["nível", "concentração", "pico", "vale", "Cmax", "terapêutic"], ko: ["농도", "혈중", "최고", "최저", "Cmax", "치료"], + ru: ["уровень", "концентраци", "пик", "минимум", "Cmax", "терапевтическ"], }; /** diff --git a/src/lib/ai/prompts/__tests__/output-language-contract.test.ts b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts index 57ed9fd0e..508063a5d 100644 --- a/src/lib/ai/prompts/__tests__/output-language-contract.test.ts +++ b/src/lib/ai/prompts/__tests__/output-language-contract.test.ts @@ -42,6 +42,7 @@ const EXPECTED_LANGUAGE_NAME: Record = { nl: "Dutch", ko: "Korean", "pt-BR": "Brazilian Portuguese", + ru: "Russian", }; /** Which body each locale is expected to compose. */ @@ -55,6 +56,7 @@ const EXPECTED_BODY: Record = { nl: "en", ko: "en", "pt-BR": "en", + ru: "en", }; describe("output-language helper", () => { diff --git a/src/lib/ai/prompts/insights-reply-footers.ts b/src/lib/ai/prompts/insights-reply-footers.ts index ea1d90710..5f8f24b7c 100644 --- a/src/lib/ai/prompts/insights-reply-footers.ts +++ b/src/lib/ai/prompts/insights-reply-footers.ts @@ -16,7 +16,7 @@ export const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record< 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.", nl: "\n\nREPLY LANGUAGE: render all user-facing strings in Dutch, in the informal je/jij register throughout (never formal u). Use natural Dutch 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.", 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.", - ru: "\n\nREPLY LANGUAGE — THIS OVERRIDES THE LANGUAGE RULE ABOVE. The \"Respond in English\" instruction earlier in this prompt does NOT apply to this request: write EVERY user-facing string in Russian (русский язык), with natural Russian medical vocabulary and the standard Russian lab terminology, addressing the user with «вы». Never answer in English. The severity / sourceWindow / sourceMetric / topic enum values still stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.", + ru: '\n\nREPLY LANGUAGE — THIS OVERRIDES THE LANGUAGE RULE ABOVE. The "Respond in English" instruction earlier in this prompt does NOT apply to this request: write EVERY user-facing string in Russian (русский язык), with natural Russian medical vocabulary and the standard Russian lab terminology, addressing the user with «вы». Never answer in English. The severity / sourceWindow / sourceMetric / topic enum values still stay in lowercase English exactly as listed in OUTPUT FORMAT — those are contract keys, NOT translations.', "pt-BR": "\n\nREPLY LANGUAGE: render all user-facing strings in Brazilian Portuguese (pt-BR, never European Portuguese), addressing the user as você throughout. Use natural Brazilian 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.", }; diff --git a/src/lib/ai/prompts/output-language.ts b/src/lib/ai/prompts/output-language.ts index 31e56358a..195c1c546 100644 --- a/src/lib/ai/prompts/output-language.ts +++ b/src/lib/ai/prompts/output-language.ts @@ -60,6 +60,8 @@ export function targetLanguageName(locale: Locale): string { return "Dutch"; case "pt-BR": return "Brazilian Portuguese"; + case "ru": + return "Russian"; default: return "English"; } diff --git a/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts b/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts index fe10e420c..c1f5d2388 100644 --- a/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts +++ b/src/lib/ai/safety/__tests__/locale-bank-liveness.test.ts @@ -26,6 +26,7 @@ import { bank as pl } from "../outbound-screen/locales/pl"; import { bank as nl } from "../outbound-screen/locales/nl"; import { bank as ko } from "../outbound-screen/locales/ko"; import { bank as ptBR } from "../outbound-screen/locales/pt-br"; +import { bank as ru } from "../outbound-screen/locales/ru"; import type { LocaleBank } from "../outbound-screen/shared"; const BANKS: Record = { @@ -38,6 +39,7 @@ const BANKS: Record = { nl, ko, "pt-BR": ptBR, + ru: ru, }; const SENTENCES: Record = { @@ -376,6 +378,47 @@ const SENTENCES: Record = { "Isso é culpa do sal.", "Continue tomando sua metformina conforme prescrito.", ], + ru: [ + "2000 мг увеличьте.", + "Принимайте 2 таблетки метформина вечером.", + "Увеличьте дозу до 2,4 мг.", + "Принимайте 2000 мг в сутки.", + "Начните с 5 мг завтра.", + "Удвойте витамин D до 4000 МЕ.", + "Примите дополнительную дозу инсулина.", + "Дозу вдвое увеличьте.", + "Дозу пропустите сегодня.", + "Пропустите вечернюю дозу.", + "Дозу ещё одну примите.", + "Дозу снизьте.", + "Снизьте дозу.", + "2,4 мг до 5 мг — переходите.", + "Врач рекомендует 5 мг.", + "2,4 мг — так стоит начинать.", + "Следующая доза — 5 мг.", + "Дозу снизить вдвое на две недели.", + "Ваш риск около 12%.", + "12% — это ваш риск.", + "Ваш 10-летний сердечно-сосудистый риск — около 12%.", + "Около 12% — ваш 10-летний риск.", + "SCORE2 даёт 14%.", + "14% — это SCORE2.", + "SCORE2 относит вас к высокой группе.", + "SCORE2 попадает в высокий риск.", + "Ваш 10-летний риск — высокий.", + "Вес снизился, потому что вы больше спали.", + "Это из-за соли.", + "Сон — причина.", + "Это было вызвано стрессом.", + "Это приводит к просадке.", + "Это привело к просадке.", + "Это вследствие поездки.", + "Это обусловлено жарой.", + "Это следствие нагрузки.", + "Благодаря прогулкам это снизилось.", + "Это является результатом нагрузки.", + "Продолжайте принимать 7,5 мг как назначено.", + ], }; function patternsOf(bank: LocaleBank): Array<[string, RegExp]> { diff --git a/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts index c9b264faa..e502c69b0 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen-false-positives.test.ts @@ -175,6 +175,24 @@ const HARMLESS: Record = { "운동량을 천천히 늘리세요.", "담배를 끊으세요.", ], + ru: [ + "Поговорите с врачом, прежде чем менять дозу.", + "Обсудите с врачом: снижать дозу?", + "Если врач согласен, снижайте дозу постепенно.", + "Пока не снижайте дозу.", + "Никогда не снижайте дозу из-за пропущенного приёма пищи без указаний.", + "Не принимайте половину таблетки, если фармацевт не сказал, что она делится.", + "Увеличить дозу? Только с врачом.", + "Стоит ли снижать инсулин? Это вопрос к вашей диабетической команде.", + "Снизьте кофеин днём.", + "Немного сократите соль на этой неделе.", + "Постепенно увеличивайте активность.", + "Врач сказал снизить дозу на неделю.", + "Вы принимаете метформин 1000 мг дважды в день, как в плане.", + "Продолжайте принимать рамиприл 5 мг как раньше.", + "Снизьте потребление сахара вдвое.", + "Спросите фармацевта, можно ли делить таблетку пополам.", + ], }; describe("dose screen false-positive corpus", () => { diff --git a/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts b/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts index 1941501e6..136106250 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen-named-meds.test.ts @@ -36,6 +36,7 @@ const NAMES: Record = { nl: ["Ramipril 5 mg", "Amlodipine", "Eliquis"], "pt-BR": ["Ramipril 5 mg", "Anlodipino", "Eliquis"], ko: ["라미프릴", "암로디핀", "엘리퀴스"], + ru: ["Рамиприл 5 мг", "Амлодипин", "Эликвис"], }; const VIOLATIONS: Record = { @@ -124,6 +125,18 @@ const VIOLATIONS: Record = { "인슐린을 추가로 맞으세요.", "하루 두 알 드세요.", ], + ru: [ + "Потом примите лишний рамиприл.", + "пропустите амлодипин", + "примите ещё одну таблетку рамиприла", + "Отмените кроворазжижающее перед пробежкой.", + "Примите две таблетки рамиприла сегодня вечером.", + "Пропустите Эликвис завтра.", + "Примите дополнительную дозу инсулина перед пробежкой.", + "Вам стоит отменить Эликвис перед забегом.", + "Подумайте о том, чтобы на неделю отменить рамиприл.", + "Примите половину таблетки амлодипина.", + ], }; const CLEAN: Record = { @@ -223,6 +236,26 @@ const CLEAN: Record = { "물을 한 잔 더 드세요.", "라미프릴을 한 알 더 드시지 마세요.", ], + ru: [ + "Ваш журнал показывает, что рамиприл принят полностью.", + "Не пропускайте рамиприл.", + "Никогда не принимайте лишний амлодипин взамен пропущенного.", + "Во вторник вы приняли лишний рамиприл — так в журнале.", + "В журнале видно, что вы дважды пропустили амлодипин на прошлой неделе.", + "По вашей схеме одна таблетка рамиприла каждое утро.", + "Эликвис отменили в марте, это видно в журнале.", + "Спросите врача, стоит ли отменять кроворазжижающее перед операцией.", + "Поговорите с врачом, прежде чем пропускать рамиприл.", + "Рамиприл и амлодипин оба снижают давление.", + "Погуляйте лишние полчаса после ужина.", + "Потратьте лишнюю минуту, чтобы записать приём лекарства.", + "Откажитесь от лишнего кофе сегодня.", + "Прекратите пробежку, если закружится голова.", + "Принимайте рамиприл как назначено.", + "Сделайте измерение перед утренним амлодипином.", + "Я бы посоветовал сначала спросить врача, прежде чем отменять рамиприл.", + "Пожалуйста, не принимайте лишний рамиприл вдогонку.", + ], }; describe("screenModelOutput — a named medication in the dose-change class", () => { diff --git a/src/lib/ai/safety/__tests__/outbound-screen.test.ts b/src/lib/ai/safety/__tests__/outbound-screen.test.ts index e31c57899..a45249fd5 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen.test.ts @@ -26,6 +26,7 @@ const DOSE_VIOLATIONS: Record = { nl: "Verhoog je dosis volgende week naar 2,4 mg.", "pt-BR": "Aumente sua dose para 2,4 mg na próxima semana.", ko: "다음 주에 2.4mg으로 늘려 보세요.", + ru: "Увеличьте дозу до 2,4 мг со следующей недели.", }; /** @@ -43,6 +44,7 @@ const DOSE_CLEAN: Record = { nl: "Je zit in week 3 op 7,5 mg, en je gewicht gaat omlaag.", "pt-BR": "Você está na semana 3 com 7,5 mg, e seu peso está diminuindo.", ko: "3주째 7.5mg을 복용 중이고, 체중은 내려가고 있어요.", + ru: "Вы третью неделю на 7,5 мг, и вес понемногу снижается.", }; describe("screenModelOutput — dose-prescription, all six locales", () => { @@ -103,6 +105,7 @@ const RISK_VIOLATIONS: Record = { nl: "Je risico ligt op ongeveer 12%.", "pt-BR": "Seu risco é de cerca de 12%.", ko: "10년 심혈관 위험도는 약 12%예요.", + ru: "Ваш 10-летний сердечно-сосудистый риск — около 12%.", }; /** A plain percentage that is NOT a clinical risk claim must pass. */ @@ -116,6 +119,7 @@ const RISK_CLEAN: Record = { nl: "Je therapietrouw was 92% in de afgelopen 30 dagen.", "pt-BR": "Sua adesão foi de 92% nos últimos 30 dias.", ko: "지난 30일 복약 순응도는 92%였어요.", + ru: "Приверженность лечению за последние 30 дней — 92%.", }; describe("screenModelOutput — risk-score, all six locales", () => { @@ -374,6 +378,7 @@ const CAUSAL_VIOLATIONS: Record = { nl: "Je gewicht daalde omdat je meer sliep.", "pt-BR": "Seu peso caiu porque você dormiu mais.", ko: "수면 때문에 체중이 줄었어요.", + ru: "Ваш вес снизился, потому что вы больше спали.", }; /** Descriptive association framing is what the contract REQUIRES. */ @@ -387,6 +392,7 @@ const CAUSAL_CLEAN: Record = { nl: "Je gewicht bewoog deze week mee met je slaap.", "pt-BR": "Seu peso acompanhou o seu sono nesta semana.", ko: "이번 주 체중은 수면과 함께 움직였어요.", + ru: "На этой неделе вес двигался вместе со сном.", }; describe("screenModelOutput — causal claims, all six locales", () => { @@ -623,6 +629,26 @@ const DOSE_CLASS_VIOLATIONS: Record = { "저녁 인슐린 용량을 건너뛰세요.", "메트포르민 복용을 중단하세요.", ], + ru: [ + "Снизьте дозу вдвое.", + "Уменьшите дозу наполовину.", + "Сократите дозу.", + "Увеличьте инсулин.", + "Немного снизьте рамиприл.", + "Увеличьте метформин до 2000 мг в сутки.", + "Поднимите инсулин на 4 единицы сегодня вечером.", + "Удвойте витамин D до 4000 МЕ.", + "Принимайте 2000 мг в сутки.", + "Принимайте 3 таблетки метформина с ужином.", + "Пропустите вечернюю дозу сегодня.", + "Отмените вечерний укол инсулина.", + "Прекратите приём метформина.", + "Вам стоит прекратить приём лизиноприла.", + "Примите ещё одну таблетку рамиприла.", + "Начните с 5 мг завтра.", + "Перейдите на 10 мг.", + "Снизьте дозу до 1000 мг.", + ], }; /** @@ -751,6 +777,32 @@ const DOSE_CLASS_CLEAN: Record = { "담배를 끊으세요.", "혈당을 140mg/dL 아래로 유지해 보세요.", ], + ru: [ + "Снизьте потребление сахара вдвое.", + "Сократите время у экрана наполовину.", + "Погуляйте полчаса.", + "Снизьте потребление соли.", + "Спросите врача, стоит ли снижать дозу.", + "Вашу дозу снизили вдвое в мае.", + "Пока не снижайте дозу.", + "Увеличьте активность.", + "Снижайте давление прогулками.", + "Принимайте лекарства как назначено.", + "Вы принимаете 1000 мг дважды в день.", + "В вашем журнале указано 2000 мг.", + "В журнале указано, что вы принимаете 1000 мг дважды в день и не пропустили ни одной дозы.", + "Спросите врача, стоит ли увеличивать дозу.", + "Поговорите с врачом, прежде чем прекращать метформин.", + "Не пропускайте вечернюю дозу.", + "Никогда не прекращайте метформин без разговора с врачом.", + "Вашу дозу повысили до 2000 мг в мае.", + "Удвойте шаги на две недели.", + "Пропустите вечернюю прогулку, если идёт дождь.", + "Уберите телефон за час до сна.", + "Пейте воду до 2000 мл в день.", + "Держите глюкозу ниже 140 мг/дл после еды.", + "Сбросьте лишнюю минуту на запись лекарства.", + ], }; describe("screenModelOutput — the dose-change class, every locale", () => { diff --git a/src/lib/ai/safety/outbound-screen/locales/ru.ts b/src/lib/ai/safety/outbound-screen/locales/ru.ts index 2e28d4f5f..46e082301 100644 --- a/src/lib/ai/safety/outbound-screen/locales/ru.ts +++ b/src/lib/ai/safety/outbound-screen/locales/ru.ts @@ -10,47 +10,50 @@ import { } from "../shared"; const RU_MED_NOUN = - "(?:доз[аыуей]|дозу|таблетк\\w*|капсул\\w*|препарат\\w*|лекарств\\w*|инсулин\\w*|инъекц\\w*|укол\\w*|средств\\w*|терапи\\w*|при[её]м\\w*)"; + "(?:доз[аыуей]|дозу|таблетк[а-яё]*|капсул[а-яё]*|препарат[а-яё]*|лекарств[а-яё]*|инсулин[а-яё]*|инъекц[а-яё]*|укол[а-яё]*|средств[а-яё]*|терапи[а-яё]*|при[её]м[а-яё]*)"; /** Russian dosage-form counters, with the case endings an object takes. */ -const RU_COUNT = "(?:таблетк\\w*|капсул\\w*|капл\\w*|доз\\w*|единиц\\w*|ед\\.?|мл)"; +const RU_COUNT = + "(?:таблетк[а-яё]*|капсул[а-яё]*|капл[а-яё]*|доз[а-яё]*|единиц[а-яё]*|ед\\.?|мл)"; const RU_TARGET = `(?:[\\d.,]+(?:\\s*${DRUG_UNIT}${UNIT_END}|\\s*${RU_COUNT})|(?:одну|одна|две|два|три|четыре|половину|полторы|пол)\\s*${RU_COUNT})`; /** * A Russian imperative or recommendation that closes a dose clause. The - * prohibitive forms ("не увеличивайте", "не снижайте") never reach the verb + * trailing lookahead keeps the past tense out: «пропустили» must not read as + * «пропусти». The prohibitive forms ("не увеличивайте") never reach the verb * from a negation-aware lead, and the talking-about forms ("увеличил") carry a * different ending. */ const RU_IMPERATIVE = - "(?:(?:увеличьте|увеличь|повысьте|повысь|поднимите|подними|нарастите|снизьте|снизь|уменьшите|уменьши|понизьте|понизь|опустите|опусти|сократите|сократи|удвойте|удвой|уполовиньте|разделите|пропустите|пропусти|отмените|отмени|прекратите|прекрати|примите|принимайте|начните|начинайте|перейдите))"; + "(?:(?:увеличьте|увеличь|повысьте|повысь|поднимите|подними|нарастите|снизьте|снизь|уменьшите|уменьши|понизьте|понизь|опустите|опусти|сократите|сократи|удвойте|удвой|уполовиньте|разделите|пропустите|пропусти|отмените|отмени|прекратите|прекрати|примите|принимайте|начните|начинайте|перейдите|прекратить|отменить|пропустить|снизить|увеличить|повысить|уменьшить|понизить|сократить|удвоить|начать|перейти))(?![а-яё])"; // Russian writes "12%" with digits; the spelled-out counters ("двенадцать // процентов") are rare in model prose and collide with the numeral declension, // so only the digit forms are matched. -const QUAL_NUM_RU = "(?:\\d{1,3}\\s*(?:%|процент\\w*))"; +const QUAL_NUM_RU = "(?:\\d{1,3}\\s*(?:%|процент[а-яё]*))"; -const RISK_NOUN_RU = "(?:риск\\w*|вероятност\\w*|шанс\\w*)"; +const RISK_NOUN_RU = "(?:риск[а-яё]*|вероятност[а-яё]*|шанс[а-яё]*)"; const HORIZON_RU = - "(?:(?:10|десяти)[- ]?летн\\w*|пожизненн\\w*)\\s*(?:риск\\w*|сердечно[- ]сосудист\\w*|смертност\\w*)"; + "(?:(?:10|десяти)[- ]?летн[а-яё]*|пожизненн[а-яё]*)\\s*(?:риск[а-яё]*|сердечно[- ]сосудист[а-яё]*|смертност[а-яё]*)"; const RESULT_VERB_RU = - "(?:относит\\w*|попада\\w*|классифицир\\w*|перевод\\w*|помеща\\w*|оказыва\\w*\\s*в)"; + "(?:относит[а-яё]*|попада[а-яё]*|классифицир[а-яё]*|перевод[а-яё]*|помеща[а-яё]*|оказыва[а-яё]*\\s*в)"; const RISK_BAND_RU = - "(?:высок|повышенн|промежуточн|умеренн|низк)\\w*\\s*(?:риск\\w*|категор\\w*|групп\\w*|диапазон\\w*|полос\\w*)"; + "(?:высок|повышенн|промежуточн|умеренн|низк)[а-яё]*\\s*(?:риск[а-яё]*|категор[а-яё]*|групп[а-яё]*|диапазон[а-яё]*|полос[а-яё]*)"; const RISK_LEVEL_RU = "(?:высок|повышен|умеренн|низк|пограничн|тревожн)"; const RISK_VERDICT_RU = `(?:[—–-]|это|является|находится|означает)\\s*[^.?!]{0,10}${RISK_LEVEL_RU}`; const RU_MED_CLASS = - "(?:антикоагулянт\\w*|антиагрегант\\w*|статины|статин\\w*|бета[- ]блокатор\\w*|диуретик\\w*|антидепрессант\\w*|метформин\\w*|инсулин\\w*)"; + "(?:антикоагулянт[а-яё]*|антиагрегант[а-яё]*|статины|статин[а-яё]*|бета[- ]блокатор[а-яё]*|диуретик[а-яё]*|антидепрессант[а-яё]*|метформин[а-яё]*|инсулин[а-яё]*|витамин[а-яё]*|кроворазжижающ[а-яё]*|кроверазжижающ[а-яё]*|рамиприл[а-яё]*|амлодипин[а-яё]*|лизиноприл[а-яё]*|эналаприл[а-яё]*|лозартан[а-яё]*|бисопролол[а-яё]*|аторвастатин[а-яё]*|розувастатин[а-яё]*|семаглутид[а-яё]*|оземпик[а-яё]*|апиксабан[а-яё]*|эликвис[а-яё]*|варфарин[а-яё]*|левотироксин[а-яё]*|эутирокс[а-яё]*|омепразол[а-яё]*|пантопразол[а-яё]*)"; /** Signals that the number-free change shapes stand down for (`unlessDeferred`). */ -const RU_DEFERRAL = "\\?|врач\\w*|доктор\\w*|специалист\\w*|эндокринолог\\w*|фармацевт\\w*|обсуди\\w*|посовету\\w*|спроси\\w*|если\\s+только|не\\s+"; +const RU_DEFERRAL = + "\\?|врач[а-яё]*|доктор[а-яё]*|специалист[а-яё]*|эндокринолог[а-яё]*|фармацевт[а-яё]*|обсуди[а-яё]*|посовету[а-яё]*|спроси[а-яё]*|если\\s+только|как\\s+назначено|как\\s+обычно|как\\s+и\\s+раньше|не\\s+"; export const bank: LocaleBank = { vocabulary: (names) => @@ -59,17 +62,80 @@ export const bank: LocaleBank = { // A stated amount closed by an imperative or a recommendation: // "2000 мг увеличьте", "принимайте 2 таблетки". new RegExp(`${RU_TARGET}[^.?!]{0,25}${RU_IMPERATIVE}`, "i"), + // Verb-first, the usual Russian word order. The amount has to be either a + // counted dosage form ("принимайте 2 таблетки") or a unit reached through a + // dose word ("увеличьте дозу до 2,4 мг"), so a plain instruction about salt + // or sodium ("снизьте натрий до 1500 мг") stays clean. + unlessDeferred( + RU_DEFERRAL, + new RegExp( + `${RU_IMPERATIVE}[^.?!]{0,15}(?:[\\d.,]+|одну|одна|две|два|три|четыре|половину|полторы)\\s*(?:таблетк[а-яё]*|капсул[а-яё]*|доз[а-яё]*|инъекц[а-яё]*|укол[а-яё]*)`, + "i", + ), + ), + unlessDeferred( + RU_DEFERRAL, + new RegExp( + `${RU_IMPERATIVE}[^.?!]{0,25}(?:доз[а-яё]*|таблетк[а-яё]*|капсул[а-яё]*|препарат[а-яё]*|лекарств[а-яё]*|инсулин[а-яё]*|витамин[а-яё]*|при[её]м[а-яё]*)[^.?!]{0,10}[\\d.,]+\\s*(?:мкг|мг|мл|ед|ме)${UNIT_END}`, + "i", + ), + ), + // "Принимайте 2000 мг в сутки", "Примите 500 мг": the intake verb carries + // the whole instruction, no dose noun in between. + unlessDeferred( + RU_DEFERRAL, + new RegExp( + `(?:примите|принимайте|выпейте|выпить|принять)[^.?!]{0,12}[\\d.,]+\\s*(?:мкг|мг|мл|ед|ме)${UNIT_END}`, + "i", + ), + ), + // "Начните с 5 мг", "Перейдите на 10 мг". + unlessDeferred( + RU_DEFERRAL, + new RegExp( + `(?:начните|начинайте|перейдите|переходите)[^.?!]{0,15}[\\d.,]+\\s*(?:мкг|мг|мл|ед|ме)${UNIT_END}`, + "i", + ), + ), + // "Удвойте витамин D до 4000 МЕ" — the doubling verb plus any unit. + unlessDeferred( + RU_DEFERRAL, + new RegExp( + `(?:удвойте|удвой|уполовиньте|уполовинь)[^.?!]{0,25}[\\d.,]+\\s*(?:мкг|мг|мл|ед|ме)${UNIT_END}`, + "i", + ), + ), + // Verb-first extra dose: "примите дополнительную дозу инсулина". + new RegExp( + `${RU_IMPERATIVE}[^.?!]{0,15}(?:ещ[её]|дополнительн[а-яё]*|лишн[а-яё]*)\\s*(?:доз[а-яё]*|таблетк[а-яё]*|капсул[а-яё]*|инъекц[а-яё]*|укол[а-яё]*|инсулин[а-яё]*)`, + "i", + ), + // Verb-first over the medication noun alone: "снизьте дозу", "отмените + // инъекцию". Deferral words ("спросите врача") stand it down, exactly as + // the verb-second shape above does. + unlessDeferred( + RU_DEFERRAL, + new RegExp(`${RU_IMPERATIVE}[^.?!]{0,12}${med}`, "i"), + ), // A medication object halved, doubled, skipped or stopped by imperative. new RegExp( - `${med}[^.?!]{0,20}(?:(?:вдвое|в\\s*два\\s*раза|пополам|вдвое\\s+меньше|наполовину)\\s*(?:увеличьте|увеличь|снизьте|снизь|уменьшите|уменьши|сократите|разделите|удвойте)|(?:пропустите|пропусти|пропускать|отмените|отмени|прекратите|прекрати|бросьте))`, + `${med}[^.?!]{0,20}(?:(?:вдвое|в\\s*два\\s*раза|пополам|вдвое\\s+меньше|наполовину)\\s*(?:увеличьте|увеличь|снизьте|снизь|уменьшите|уменьши|сократите|разделите|удвойте)|(?:пропустите|пропусти|пропускать|отмените|отмени|прекратите|прекрати|бросьте)(?![а-яё]))`, + "i", ), new RegExp( - `(?:доз[уа]|при[её]м|инъекци\\w*|укол\\w*)\\s*(?:пропустите|пропусти|пропускать|отмените|отмени|прекратите|прекрати)`, + `(?:доз[уа]|при[её]м|инъекци[а-яё]*|укол[а-яё]*)\\s*(?:пропустите|пропусти|пропускать|отмените|отмени|прекратите|прекрати)`, + "i", + ), + // The same skip/stop in verb-first order: "пропустите дозу метформина". + new RegExp( + `(?:пропустите|пропусти|отмените|отмени|прекратите|прекрати|бросьте|пропустить|отменить|прекратить)(?![а-яё])[^.?!]{0,15}${med}`, + "i", ), // One more dose of a medication, closed by an imperative: // "примите ещё одну таблетку метформина". new RegExp( - `${med}[^.?!]{0,12}(?:ещ[её]|дополнительн\\w*|лишн\\w*)\\s*(?:одну|одна|две|дозу|таблетку|капсулу)?\\s*(?:примите|принимайте|выпейте|прими)`, + `${med}[^.?!]{0,12}(?:ещ[её]|дополнительн[а-яё]*|лишн[а-яё]*)\\s*(?:одну|одна|две|дозу|таблетку|капсулу)?\\s*(?:примите|принимайте|выпейте|прими)`, + "i", ), // A medication object raised or lowered by imperative with no amount, // standing down where a question, a negation or a clinician appears. @@ -77,6 +143,7 @@ export const bank: LocaleBank = { RU_DEFERRAL, new RegExp( `${med}[^.?!\\d]{0,8}?(?:увеличьте|увеличь|повысьте|повысь|поднимите|подними|снизьте|снизь|уменьшите|уменьши|понизьте|понизь|опустите|опусти|сократите|сократи)`, + "i", ), ), ], @@ -89,7 +156,9 @@ export const bank: LocaleBank = { "i", ), new RegExp( - `(?:увелич|повыс|подним|нараст|сниз|уменьш|пониз|опуст|сократ)\\w*[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, + // The lookahead keeps the past tense out: «дозу повысили» is a record, + // «повысьте дозу» is a prescription. + `(?:увелич|повыс|подним|нараст|сниз|уменьш|пониз|опуст|сократ)(?!и?л)[а-яё]*[^.?!]{0,40}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), new RegExp( @@ -101,12 +170,15 @@ export const bank: LocaleBank = { "i", ), new RegExp( - `(?:следующ|нов|целев|рабоч)\\w*\\s*(?:доз|этап|титрац)\\w*[^.?!]{0,30}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, + `(?:следующ|нов|целев|рабоч)[а-яё]*\\s*(?:доз|этап|титрац)[а-яё]*[^.?!]{0,30}[\\d.,]+\\s*${DOSE_UNIT}${UNIT_END}`, "i", ), unlessDeferred( RU_DEFERRAL, - /(?:доз|таблетк|капсул|инсулин|препарат|лекарств|приём|прием)[^.?!]{0,25}(?:вдвое|в два раза|пополам|пропуст|отмен|прекрат|удво|уполовин)[^.?!]{0,40}(?:недел|дн|месяц|попробуйте|можно)/, + new RegExp( + `(?:доз[а-яё]*|таблетк[а-яё]*|капсул[а-яё]*|инсулин[а-яё]*|препарат[а-яё]*|лекарств[а-яё]*|при[её]м[а-яё]*)[^.?!]{0,25}(?:вдвое|в\\s*два\\s*раза|пополам|пропуст|отмен|прекрат|удво|уполовин)[^.?!]{0,40}(?:недел|дн[а-яё]*|месяц|попробуйте|можно)`, + "i", + ), ), ], risk: [ @@ -140,7 +212,7 @@ export const bank: LocaleBank = { /обусловлен\w*/i, /следстви\w*/i, /благодаря/i, - /явля\w+\s+(?:причиной|результатом)/i, + /явля[а-яё]+\s+(?:причиной|результатом)/i, ], continuation: /(?:продолжайте|продолжайте\s+принимать|продолжить\s+при[её]м|как\s+назначено|по\s+назначению|как\s+обычно|как\s+и\s+раньше|в\s+той\s+же\s+дозе|не\s+меняйте)/i, diff --git a/src/lib/ai/safety/outbound-screen/shared.ts b/src/lib/ai/safety/outbound-screen/shared.ts index 9ada2c71e..31259d885 100644 --- a/src/lib/ai/safety/outbound-screen/shared.ts +++ b/src/lib/ai/safety/outbound-screen/shared.ts @@ -20,7 +20,7 @@ export const COUNT_UNIT = * units plus the count forms; `ml` is left out on purpose because * "increase your water intake to 2000 ml" is ordinary hydration advice. */ -export const DRUG_UNIT = `(?:mg|mcg|µg|iu|ui|i\\.u\\.|units?|unidades?|unità|unités?|jednostek|jednostki|eenheden|eenheid|ie|i\\.e\\.|einheiten|${COUNT_UNIT})`; +export const DRUG_UNIT = `(?:mg|mcg|µg|iu|ui|i\\.u\\.|units?|unidades?|unità|unités?|jednostek|jednostki|eenheden|eenheid|ie|i\\.e\\.|einheiten|мкг|мг|мл|ед|ме|${COUNT_UNIT})`; /** * Dose units, shared across every locale. The GLP-1 + general oral-dose diff --git a/src/lib/documents/__tests__/describe-screen.test.ts b/src/lib/documents/__tests__/describe-screen.test.ts index e7a146a8a..5b89037cc 100644 --- a/src/lib/documents/__tests__/describe-screen.test.ts +++ b/src/lib/documents/__tests__/describe-screen.test.ts @@ -34,6 +34,7 @@ describe("runDocumentSummary — screened prose", () => { nl: "Dit rapport stelt voor om de dosis volgende week naar 2,4 mg te verhogen.", "pt-BR": "Este laudo sugere aumentar sua dose para 2,4 mg na próxima semana.", + ru: "В отчёте предлагается увеличить дозу до 2,4 мг на следующей неделе.", }; for (const locale of locales) { diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts index af29c284c..03699feae 100644 --- a/src/lib/fhir/lab-loinc.ts +++ b/src/lib/fhir/lab-loinc.ts @@ -65,7 +65,7 @@ export function normaliseLabKey(s: string): string { // 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, ""); + return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9а-яё가-힣]/g, ""); } /** diff --git a/src/lib/insights/__tests__/status-summary-screen.test.ts b/src/lib/insights/__tests__/status-summary-screen.test.ts index 7a2be6255..54adee516 100644 --- a/src/lib/insights/__tests__/status-summary-screen.test.ts +++ b/src/lib/insights/__tests__/status-summary-screen.test.ts @@ -23,6 +23,7 @@ const DOSE_VIOLATION: Record = { pl: '{"summary":"Proszę zwiększyć dawkę do 2,4 mg w przyszłym tygodniu."}', ko: '{"summary":"다음 주에 2.4mg으로 늘려 보세요."}', nl: '{"summary":"Verhoog je dosis volgende week naar 2,4 mg."}', + ru: '{"summary":"Подумайте об увеличении дозы до 10 мг на следующей неделе."}', "pt-BR": '{"summary":"Aumente sua dose para 2,4 mg na próxima semana."}', }; @@ -35,6 +36,7 @@ const CAUSAL_VIOLATION: Record = { pl: '{"summary":"Waga spadła, ponieważ spał Pan więcej."}', ko: '{"summary":"수면 때문에 체중이 줄었어요."}', nl: '{"summary":"Je gewicht daalde omdat je meer sliep."}', + ru: '{"summary":"Ваш вес снизился, потому что вы больше спали."}', "pt-BR": '{"summary":"Seu peso caiu porque você dormiu mais."}', }; @@ -47,6 +49,7 @@ const CLEAN: Record = { pl: '{"summary":"Waga wynosi średnio 82 kg w tym tygodniu, stabilnie wobec zeszłego miesiąca."}', ko: '{"summary":"이번 주 체중은 평균 82kg으로, 지난달과 비슷해요."}', nl: '{"summary":"Je gewicht ligt deze week gemiddeld op 82 kg, stabiel ten opzichte van vorige maand."}', + ru: '{"summary":"Ваш вес на этой неделе в среднем 82 кг, стабильно к прошлому месяцу."}', "pt-BR": '{"summary":"Seu peso está em média em 82 kg nesta semana, estável em relação ao mês passado."}', }; diff --git a/src/lib/insights/eval/__tests__/tone-harness.test.ts b/src/lib/insights/eval/__tests__/tone-harness.test.ts index 2d81e1cde..324bbe697 100644 --- a/src/lib/insights/eval/__tests__/tone-harness.test.ts +++ b/src/lib/insights/eval/__tests__/tone-harness.test.ts @@ -257,6 +257,7 @@ describe("tone harness — deterministic fallbacks", () => { nl: /geen beoordeling/, "pt-BR": /n[aã]o h[áa] (?:uma )?avalia[çc][aã]o/i, ko: /평가가 없어요/, + ru: /оценки нет|нет оценки/i, }; describe.each(FLOORS)("$name floor", ({ build }) => { diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 871f6bea1..814787ec7 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -103,6 +103,7 @@ const VERDICT_STEADY: Localized = { it: "Stabile, come al solito", pl: "Stabilnie, tak jak zwykle", nl: "Stabiel en zoals gewoonlijk", + ru: "Стабильно, как обычно", "pt-BR": "Estável e como de costume", }; @@ -114,6 +115,7 @@ const VERDICT_FAVOURABLE: Localized = { it: "Sta andando nella direzione giusta", pl: "To zmierza w dobrym kierunku", nl: "Dit gaat de goede kant op", + ru: "Это движется в хорошую сторону", "pt-BR": "Isso está indo numa boa direção", }; @@ -125,6 +127,7 @@ const VERDICT_OFF_USUAL: Localized = { it: "Ultimamente un po’ fuori dal tuo intervallo abituale", pl: "Ostatnio nieco poza twoim zwykłym zakresem", nl: "De laatste tijd iets buiten je gebruikelijke bereik", + ru: "В последнее время немного выходит за ваши обычные рамки", "pt-BR": "Ultimamente um pouco fora do seu padrão habitual", }; @@ -199,6 +202,8 @@ const VALUE_VS_BASELINE: Localized< `${l} wynosi obecnie ${c} — ${higher ? "powyżej" : "poniżej"} twojej zwykłej średniej ${b}.`, nl: (l, c, b, higher) => `${l} staat nu op ${c}, ${higher ? "hoger" : "lager"} dan je gebruikelijke gemiddelde van ${b}.`, + ru: (l, c, b, higher) => + `${l} сейчас ${c} — ${higher ? "выше" : "ниже"} вашей обычной средней ${b}.`, "pt-BR": (l, c, b, higher) => `${l} está em ${c} agora, ${higher ? "acima" : "abaixo"} da sua média habitual de ${b}.`, }; @@ -215,6 +220,7 @@ const VALUE_AT_BASELINE: Localized<(label: string, current: string) => string> = `${l} wynosi obecnie ${c}, czyli mniej więcej tyle co twoja zwykła średnia.`, nl: (l, c) => `${l} staat op ${c}, ongeveer op je gebruikelijke gemiddelde.`, + ru: (l, c) => `${l} сейчас ${c}, примерно на уровне вашей обычной средней.`, "pt-BR": (l, c) => `${l} está em ${c}, bem perto da sua média habitual.`, }; @@ -226,6 +232,7 @@ const VALUE_ALONE: Localized<(label: string, current: string) => string> = { it: (l, c) => `${l} è a ${c} in questo momento.`, pl: (l, c) => `${l} wynosi obecnie ${c}.`, nl: (l, c) => `${l} staat nu op ${c}.`, + ru: (l, c) => `${l} сейчас ${c}.`, "pt-BR": (l, c) => `${l} está em ${c} agora.`, }; @@ -237,6 +244,7 @@ const CLOSER_IN_RANGE: Localized = { it: "Rientra nel tuo intervallo abituale — niente su cui intervenire adesso.", pl: "Mieści się to w twoim zwykłym zakresie — nic, na co trzeba teraz reagować.", nl: "Dat valt binnen je gebruikelijke bereik, dus niets waar je nu iets mee hoeft te doen.", + ru: "Это в ваших обычных пределах — сейчас ничего предпринимать не нужно.", "pt-BR": "Isso está dentro da sua faixa habitual, nada que peça uma ação agora.", }; @@ -249,6 +257,7 @@ const CLOSER_UNKNOWN_SWING: Localized = { it: "Qualche misurazione in più renderà la tendenza affidabile — tienila d’occhio nei prossimi giorni.", pl: "Kilka kolejnych pomiarów sprawi, że tendencja stanie się wiarygodna — obserwuj ją przez najbliższe dni.", nl: "Met nog een paar metingen wordt de trend betrouwbaar. Houd hem de komende dagen in de gaten.", + ru: "Ещё несколько измерений сделают тенденцию надёжной — последите за ней в ближайшие дни.", "pt-BR": "Mais algumas medições vão tornar a tendência confiável. Acompanhe nos próximos dias.", }; @@ -325,6 +334,7 @@ const BLOOD_PRESSURE_COPY: FallbackCopy = { it: "la tua pressione sanguigna", pl: "twoje ciśnienie krwi", nl: "je bloeddruk", + ru: "ваше давление", "pt-BR": "sua pressão arterial", }, unit: "", @@ -337,6 +347,7 @@ const BLOOD_PRESSURE_COPY: FallbackCopy = { it: "Qualche misurazione a riposo nelle stesse condizioni mostrerà se è la tendenza o un valore isolato.", pl: "Kilka spokojnych pomiarów w tych samych warunkach pokaże, czy to tendencja, czy pojedynczy wyskok.", nl: "Een paar rustige metingen onder dezelfde omstandigheden laten zien of dit de trend is of een losse uitschieter.", + ru: "Несколько спокойных измерений в одинаковых условиях покажут, это тенденция или разовый выброс.", "pt-BR": "Algumas medições tranquilas nas mesmas condições vão mostrar se isso é a tendência ou um valor isolado.", }, @@ -351,6 +362,7 @@ const WEIGHT_COPY: FallbackCopy = { it: "il tuo peso", pl: "twoja waga", nl: "je gewicht", + ru: "ваш вес", "pt-BR": "seu peso", }, unit: "", @@ -363,6 +375,7 @@ const WEIGHT_COPY: FallbackCopy = { it: "Pesati alla stessa ora per qualche giorno e la normale oscillazione quotidiana si compensa da sé.", pl: "Waż się przez kilka dni o tej samej porze, a normalne dobowe wahania same się wyrównają.", nl: "Weeg je een paar dagen op hetzelfde tijdstip, dan middelt de normale schommeling van dag tot dag zich vanzelf uit.", + ru: "Взвешивайтесь несколько дней в одно и то же время — обычные суточные колебания сгладятся сами.", "pt-BR": "Pese-se no mesmo horário por alguns dias, e a oscilação normal do dia a dia se acerta sozinha.", }, @@ -377,6 +390,7 @@ const PULSE_COPY: FallbackCopy = { it: "il tuo battito a riposo", pl: "twoje tętno spoczynkowe", nl: "je rusthartslag", + ru: "ваш пульс в покое", "pt-BR": "seu pulso em repouso", }, unit: " bpm", @@ -389,6 +403,7 @@ const PULSE_COPY: FallbackCopy = { it: "Misuralo da rilassato e alla stessa ora del giorno per vedere se la direzione tiene.", pl: "Mierz je na spokojnie i o tej samej porze dnia, żeby zobaczyć, czy kierunek się utrzyma.", nl: "Meet hem ontspannen en op hetzelfde moment van de dag, dan zie je of de richting aanhoudt.", + ru: "Измеряйте его спокойно и в одно и то же время дня, чтобы понять, держится ли направление.", "pt-BR": "Meça com calma e no mesmo horário do dia para ver se a direção se mantém.", }, @@ -403,6 +418,7 @@ const BMI_COPY: FallbackCopy = { it: "il tuo IMC", pl: "twoje BMI", nl: "je BMI", + ru: "ваш ИМТ", "pt-BR": "seu IMC", }, unit: "", @@ -415,6 +431,7 @@ const BMI_COPY: FallbackCopy = { it: "Leggilo insieme all’andamento del tuo peso e alla massa grassa nell’arco di qualche settimana, non giorno per giorno.", pl: "Czytaj je razem z tendencją wagi i poziomem tkanki tłuszczowej w skali kilku tygodni, a nie dzień po dniu.", nl: "Bekijk hem samen met je gewichtstrend en vetpercentage over een paar weken, niet dag voor dag.", + ru: "Смотрите на него вместе с динамикой веса и процентом жира за несколько недель, а не день ко дню.", "pt-BR": "Leia junto com a tendência do seu peso e o percentual de gordura ao longo de algumas semanas, não dia a dia.", }, @@ -429,6 +446,7 @@ const MOOD_COPY: FallbackCopy = { it: "il tuo umore", pl: "twój nastrój", nl: "je stemming", + ru: "ваше настроение", "pt-BR": "seu humor", }, unit: "", @@ -441,6 +459,7 @@ const MOOD_COPY: FallbackCopy = { it: "Registralo per qualche giorno — uno schema che si ripete dice più di un singolo giorno.", pl: "Zapisuj go przez kilka dni — powtarzający się wzorzec mówi więcej niż pojedynczy dzień.", nl: "Houd hem een paar dagen bij. Een terugkerend patroon zegt meer dan één losse dag.", + ru: "Записывайте его несколько дней — повторяющаяся картина говорит больше, чем один отдельный день.", "pt-BR": "Registre por alguns dias. Um padrão que se repete diz mais do que um dia isolado.", }, @@ -455,6 +474,7 @@ const ADHERENCE_COPY: FallbackCopy = { it: "la tua aderenza alla terapia", pl: "twoja regularność przyjmowania leków", nl: "je medicijntrouw", + ru: "ваша приверженность лечению", "pt-BR": "sua adesão à medicação", }, unit: "%", @@ -467,6 +487,7 @@ const ADHERENCE_COPY: FallbackCopy = { it: "Un orario fisso o un promemoria breve è ciò che intercetta meglio le dimenticanze ripetute.", pl: "Stała pora albo krótkie przypomnienie najlepiej wyłapuje powtarzające się pominięcia.", nl: "Een vast tijdstip of een korte herinnering vangt herhaalde vergeten innames het best op.", + ru: "Фиксированное время или короткое напоминание лучше всего ловят повторяющиеся пропуски.", "pt-BR": "Um horário fixo ou um lembrete rápido é a forma mais confiável de evitar as tomadas esquecidas que se repetem.", }, @@ -481,6 +502,7 @@ const GENERIC_METRIC_POINTER: Localized = { it: "Qualche misurazione costante mostrerà se è la direzione o un singolo giorno.", pl: "Kilka konsekwentnych pomiarów pokaże, czy to kierunek, czy pojedynczy dzień.", nl: "Een paar consistente metingen laten zien of dit de richting is of één enkele dag.", + ru: "Несколько последовательных измерений покажут, это направление или один отдельный день.", "pt-BR": "Algumas medições consistentes vão mostrar se essa é a direção ou só um dia isolado.", }; @@ -544,6 +566,7 @@ const NO_READ_LEAD: Localized = { pl: "Na razie nie ma tu żadnej oceny.", nl: "Voor deze kaart is er nu geen beoordeling.", ko: "지금은 이 카드에 대한 평가가 없어요.", + ru: "Сейчас по этой карточке оценки нет.", "pt-BR": "Não há avaliação disponível para este cartão no momento.", }; @@ -566,6 +589,7 @@ export function getNoKeyGeneralStatusText(locale: InsightLocale): string { it: "Questi numeri diventano leggibili nell’arco di più settimane anziché di singoli giorni, e rilevati a orari costanti per restare confrontabili. Il segnale che conta è più indicatori che scivolano insieme nella stessa direzione sfavorevole, non un valore in un giorno.", pl: "Te liczby stają się czytelne w skali kilku tygodni, a nie pojedynczych dni, i tylko wtedy, gdy są zbierane o stałych porach, żeby pozostały porównywalne. Sygnałem, który się liczy, jest kilka wskaźników dryfujących jednocześnie w tę samą niekorzystną stronę, a nie jedna wartość jednego dnia.", nl: "Deze cijfers worden leesbaar over meerdere weken, niet over losse dagen, en gemeten op vaste momenten zodat ze vergelijkbaar blijven. Het signaal dat telt is dat meerdere waarden tegelijk dezelfde ongunstige kant op schuiven, niet één waarde op één dag.", + ru: "Эти цифры становятся понятными на дистанции нескольких недель, а не по отдельным дням, и только если снимать их в одно и то же время, чтобы они оставались сопоставимыми. Значимый сигнал — это несколько показателей, которые одновременно уходят в одну и ту же неблагоприятную сторону, а не одно значение за один день.", "pt-BR": "Estes números ficam legíveis ao longo de várias semanas, não em dias isolados, e medidos em horários consistentes para continuarem comparáveis. O sinal que conta são várias métricas se movendo ao mesmo tempo na mesma direção desfavorável, não um valor em um único dia.", }); @@ -591,6 +615,7 @@ const BIOMARKER_NAMED_LEAD: Localized<(named: string) => string> = { pl: (n) => `Na razie nie ma żadnej oceny${n}.`, nl: (n) => `Er is nu geen beoordeling${n}.`, ko: (n) => `지금은${n} 평가가 없어요.`, + ru: (n) => `Сейчас нет оценки${n}.`, "pt-BR": (n) => `Não há avaliação${n} no momento.`, }; @@ -603,6 +628,7 @@ const BIOMARKER_NAMED_CLAUSE: Localized<(marker: string) => string> = { pl: (m) => ` dla „${m}”`, nl: (m) => ` voor "${m}"`, ko: (m) => ` '${m}'에 대한`, + ru: (m) => ` по показателю «${m}»`, "pt-BR": (m) => ` para "${m}"`, }; @@ -622,6 +648,7 @@ export function getNoKeyBiomarkerStatusText( it: "Un valore di laboratorio si legge prima di tutto rispetto ai tuoi prelievi precedenti — un intervallo di riferimento è un ancoraggio grossolano, non un verdetto. Uno che si è mosso appena nell’arco di più prelievi racconta una storia diversa da uno che ha appena fatto un salto. Il tuo prossimo controllo medico è il posto giusto per contestualizzarlo.", pl: "Wynik laboratoryjny czyta się najpierw na tle twoich wcześniejszych oznaczeń — zakres referencyjny to zgrubna kotwica, a nie wyrok. Wynik, który przez kilka oznaczeń niemal się nie ruszył, mówi co innego niż taki, który właśnie podskoczył. Najbliższa kontrola lekarska to właściwe miejsce, żeby go osadzić w kontekście.", nl: "Een labwaarde lees je eerst tegen je eigen eerdere metingen. Een referentiebereik is een ruw ankerpunt, geen oordeel. Een waarde die over meerdere metingen nauwelijks beweegt, vertelt iets anders dan een waarde die net een sprong heeft gemaakt. Je volgende controle bij de arts is de juiste plek om hem in context te plaatsen.", + ru: "Лабораторный показатель читается прежде всего относительно ваших собственных прошлых сдач: референсный интервал — грубый ориентир, а не приговор. Значение, которое почти не меняется от сдачи к сдаче, рассказывает не то же самое, что значение, которое только что скакнуло. Следующий приём у врача — правильное место, чтобы это истолковать.", "pt-BR": "Um valor de exame se lê primeiro em relação às suas coletas anteriores. Uma faixa de referência é um ponto de apoio aproximado, não um veredito. Um valor que mal se moveu ao longo de várias coletas conta uma história diferente de um que acabou de dar um salto. Sua próxima consulta é o lugar certo para colocá-lo em contexto.", })}`; @@ -641,6 +668,7 @@ export function getNoKeyBloodPressureStatusText( it: "La pressione si legge su una serie di misurazioni a riposo in condizioni confrontabili — la direzione nell’arco di più giorni dice molto più di un singolo valore isolato, e sistolica e diastolica si leggono insieme anziché una alla volta.", pl: "Ciśnienie staje się czytelne dopiero w serii spokojnych pomiarów w porównywalnych warunkach — kierunek z kilku dni mówi znacznie więcej niż pojedynczy wyskok, a wartość skurczowa i rozkurczowa czyta się razem, a nie osobno.", nl: "Bloeddruk wordt leesbaar over een reeks rustige metingen onder vergelijkbare omstandigheden. De richting over meerdere dagen zegt veel meer dan één uitschieter, en de boven- en onderdruk lees je samen in plaats van los van elkaar.", + ru: "Давление становится понятным по серии спокойных измерений в сопоставимых условиях: направление за несколько дней говорит гораздо больше, чем один выброс, а систолическое с диастолическим читают вместе, а не по отдельности.", "pt-BR": "A pressão arterial fica legível ao longo de uma série de medições tranquilas em condições parecidas. A direção ao longo de vários dias diz muito mais do que um valor fora da curva, e a sistólica e a diastólica são lidas juntas, não separadamente.", }) @@ -661,6 +689,7 @@ export function getNoKeyWeightStatusText( it: "Il peso si legge come andamento e non come numero del giorno — pesato in condizioni costanti, la normale oscillazione quotidiana si compensa da sé. Quello che regge è la direzione nell’arco di settimane, letta insieme alla pressione e all’IMC.", pl: "Wagę czyta się jako przebieg, a nie jako liczbę z jednego dnia — przy ważeniu w stałych warunkach normalne dobowe wahania same się wyrównają. Nośny jest kierunek z kilku tygodni, czytany razem z ciśnieniem i BMI.", nl: "Gewicht lees je als trend, niet als getal van de dag. Weeg je onder vaste omstandigheden, dan middelt de normale schommeling van dag tot dag zich vanzelf uit. Wat telt is de richting over weken, samen gelezen met bloeddruk en BMI.", + ru: "Вес читается как тенденция, а не как цифра за день: если взвешиваться в одинаковых условиях, обычные суточные колебания сглаживаются сами. Значимо направление за недели — вместе с давлением и ИМТ.", "pt-BR": "O peso se lê como tendência, não como número do dia. Pesando-se em condições consistentes, a oscilação normal do dia a dia se acerta sozinha. O que conta é a direção ao longo das semanas, lida junto com a pressão arterial e o IMC.", }) @@ -681,6 +710,7 @@ export function getNoKeyPulseStatusText( it: "Il battito a riposo si legge meglio se misurato da rilassato e alla stessa ora del giorno. I picchi brevi sono ordinari; quello che spicca è uno schema che tiene per più giorni, o uno scostamento ripetuto dal tuo intervallo abituale.", pl: "Tętno spoczynkowe najlepiej czytać zmierzone na spokojnie i o tej samej porze dnia. Krótkie skoki są czymś zwyczajnym; uwagę zwraca wzorzec utrzymujący się przez kilka dni albo powtarzające się oddalanie od twojego zwykłego zakresu.", nl: "De rusthartslag lees je het best als je hem ontspannen en op hetzelfde moment van de dag meet. Korte pieken zijn gewoon; wat opvalt is een patroon dat meerdere dagen aanhoudt, of steeds weer afwijken van je gebruikelijke bereik.", + ru: "Пульс в покое лучше читать, если мерить его спокойно и в одно и то же время дня. Короткие всплески обычны; внимание привлекает картина, которая держится несколько дней, или повторяющийся уход от вашего обычного диапазона.", "pt-BR": "O pulso em repouso se lê melhor quando medido com calma e no mesmo horário do dia. Picos curtos são normais; o que chama atenção é um padrão que se mantém por vários dias, ou um afastamento repetido da sua faixa habitual.", }) @@ -701,6 +731,7 @@ export function getNoKeyBmiStatusText( it: "L’IMC è un orientamento grossolano più che un verdetto, e assume senso letto insieme all’andamento del tuo peso e alla composizione corporea. Un movimento sostenuto nell’arco di settimane regge dove un valore singolo non regge.", pl: "BMI to zgrubna orientacja, a nie wyrok, i nabiera sensu dopiero czytane razem z tendencją wagi i składem ciała. Utrzymujący się ruch w skali tygodni jest nośny tam, gdzie pojedyncza wartość nie jest.", nl: "BMI is een ruwe oriëntatie, geen oordeel, en zegt het meest samen met je gewichtstrend en lichaamssamenstelling. Een aanhoudende beweging over weken zegt iets waar één losse waarde dat niet doet.", + ru: "ИМТ — грубый ориентир, а не приговор, и больше всего говорит вместе с динамикой веса и составом тела. Устойчивое движение за недели значит то, чего не значит одно отдельное значение.", "pt-BR": "O IMC é uma orientação aproximada, não um veredito, e diz mais quando lido junto com a tendência do seu peso e a sua composição corporal. Um movimento sustentado ao longo de semanas diz algo que um valor isolado não diz.", }) @@ -721,6 +752,7 @@ export function getNoKeyMedicationComplianceStatusText( it: "L’aderenza si legge come costanza nell’arco di settimane più che come una serie di giornate perfette — per singolo farmaco e nel quadro complessivo. Le dimenticanze ripetute alla stessa ora sono lo schema che conta, ed è una routine fissa a intercettarle di solito.", pl: "Regularność przyjmowania leków czyta się jako stałość w skali tygodni, a nie jako serię idealnych dni — osobno dla każdego leku i w całym obrazie. Powtarzające się pominięcia o tej samej porze to wzorzec, który się liczy, a wyłapuje je zwykle stały rytm dnia.", nl: "Medicijntrouw lees je als regelmaat over weken, niet als een reeks perfecte dagen, per medicijn en in het geheel. Herhaald vergeten op hetzelfde moment van de dag is het patroon dat telt, en een vaste routine vangt dat meestal op.", + ru: "Приверженность лечению читается как постоянство за недели, а не как череда идеальных дней — по каждому препарату и в целом. Повторяющиеся пропуски в одно и то же время суток и есть та картина, которая важна, и лучше всего её ловит заведённый распорядок.", "pt-BR": "A adesão se lê como regularidade ao longo de semanas, não como uma sequência de dias perfeitos, por medicamento e no conjunto. Esquecimentos repetidos no mesmo horário do dia são o padrão que conta, e uma rotina fixa costuma resolvê-los.", }) @@ -741,6 +773,7 @@ export function getNoKeyMoodStatusText( it: "L’umore si legge nell’arco di settimane più che nei singoli giorni — quello che regge sono gli schemi ricorrenti e come si allineano con gli altri tuoi valori. È una fase bassa che si protrae quella che merita attenzione.", pl: "Nastrój czyta się w skali tygodni, a nie pojedynczych dni — nośne są powtarzające się wzorce i to, jak układają się względem twoich pozostałych wartości. Uwagi wymaga przede wszystkim dłużej utrzymujący się spadek.", nl: "Stemming lees je over weken, niet over losse dagen. Wat telt zijn terugkerende patronen en hoe die samenhangen met je andere waarden. Een aanhoudende periode van lage stemming is wat aandacht verdient.", + ru: "Настроение читается на дистанции недель, а не отдельных дней: значимы повторяющиеся картины и то, как они совпадают с другими вашими показателями. Внимания заслуживает затянувшийся период сниженного настроения.", "pt-BR": "O humor se lê ao longo de semanas, não em dias isolados. O que conta são os padrões que se repetem e como eles se relacionam com as suas outras métricas. Um período prolongado de humor baixo é o que merece atenção.", }) diff --git a/src/lib/labs/parse-reference-range.ts b/src/lib/labs/parse-reference-range.ts index 59dafbbdb..e52906e84 100644 --- a/src/lib/labs/parse-reference-range.ts +++ b/src/lib/labs/parse-reference-range.ts @@ -355,7 +355,7 @@ export const RANGE_PROSE: Record = { "до", "референс", "референсные значения", - "референсный интервал", + "интервал нормы", "норма", "показатели нормы", "реф.", diff --git a/src/lib/mental-health/__tests__/instrument-wording.test.ts b/src/lib/mental-health/__tests__/instrument-wording.test.ts index 20023b924..8a1db2a3b 100644 --- a/src/lib/mental-health/__tests__/instrument-wording.test.ts +++ b/src/lib/mental-health/__tests__/instrument-wording.test.ts @@ -263,6 +263,8 @@ describe("validated entries carry the published wording", () => { ); expect(unvalidated.sort()).toEqual( [ + "GAD7.ru", + "PHQ9.ru", "SCI.de", "SCI.es", "SCI.fr", @@ -270,7 +272,9 @@ describe("validated entries carry the published wording", () => { "SCI.ko", "SCI.nl", "SCI.pt-BR", + "SCI.ru", "WHO5.pt-BR", + "WHO5.ru", ].sort(), ); expect(isValidatedWording("SCI", "de")).toBe(false); diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts index f9714e487..2b07fa729 100644 --- a/src/lib/mental-health/instruments.ts +++ b/src/lib/mental-health/instruments.ts @@ -498,5 +498,6 @@ export const AUDIT_C_STANDARD_DRINK: Readonly> = { nl: "Eén drankje is een standaardglas met ongeveer 10 g pure alcohol, de WHO-definitie waarop deze vragen gebaseerd zijn.", "pt-BR": "Uma bebida corresponde a uma dose padrão de cerca de 10 g de álcool puro, a definição da OMS na qual estas perguntas se baseiam.", + ru: "Одна порция — это стандартная доза примерно 10 г чистого алкоголя, то есть 330 мл пива крепостью 5%, 140 мл вина крепостью 12% или 40 мл крепкого напитка 40%; это определение ВОЗ, на котором построены эти вопросы.", ko: "표준 1잔: 양주잔으로 양주 1잔, 와인잔으로 와인 1잔, 막걸리 1사발이 각각 표준 1잔이다. 맥주는 캔맥주 1캔 또는 작은 병맥주 1병이며 생맥주는 500 mL가 표준 1잔이다. 소주는 1/4병이 표준 1잔에 해당한다.", }; diff --git a/src/lib/mental-health/wording/gad7/ru.ts b/src/lib/mental-health/wording/gad7/ru.ts index a7b0a94a3..59e1155a8 100644 --- a/src/lib/mental-health/wording/gad7/ru.ts +++ b/src/lib/mental-health/wording/gad7/ru.ts @@ -3,7 +3,7 @@ * published validated one), faithful to the English original and on the same * 0-3 scale. * - * Источник: Рабочий перевод HealthLog с английского оригинала GAD-7 (Spitzer, Kroenke, Williams & Löwe, Arch Intern Med 2006;166:1092-1097). Валидированный русскоязычный вариант в открытом доступе отсутствует. + * Источник: рабочий перевод HealthLog с английского оригинала GAD-7 (Spitzer, Kroenke, Williams & Löwe, Arch Intern Med 2006;166:1092-1097). Валидированные русскоязычные версии существуют (например, Золотарева, 2023), но их формулировки здесь не воспроизводятся, поэтому validated: false. */ import type { InstrumentWording } from "../types"; diff --git a/src/lib/mental-health/wording/phq9/ru.ts b/src/lib/mental-health/wording/phq9/ru.ts index 889d824c2..b2ffba23d 100644 --- a/src/lib/mental-health/wording/phq9/ru.ts +++ b/src/lib/mental-health/wording/phq9/ru.ts @@ -3,7 +3,7 @@ * published validated one), faithful to the English original and on the same * 0-3 scale, so scores stay comparable across languages. * - * Источник: Рабочий перевод HealthLog с английского оригинала PHQ-9 (Kroenke, Spitzer & Williams, J Gen Intern Med 2001;16:606-613). Валидированный русскоязычный вариант в открытом доступе отсутствует. + * Источник: рабочий перевод HealthLog с английского оригинала PHQ-9 (Kroenke, Spitzer & Williams, J Gen Intern Med 2001;16:606-613). Валидированные русскоязычные версии существуют (например, Золотарева, 2023; официальные переводы на сайте Pfizer), но их формулировки здесь не воспроизводятся, поэтому validated: false. */ import type { InstrumentWording } from "../types"; diff --git a/src/lib/mental-health/wording/who5/ru.ts b/src/lib/mental-health/wording/who5/ru.ts index 3bb5521d6..8a178e0da 100644 --- a/src/lib/mental-health/wording/who5/ru.ts +++ b/src/lib/mental-health/wording/who5/ru.ts @@ -11,10 +11,10 @@ const wording: InstrumentWording = { validated: false, stems: { period: "За последние две недели…" }, items: [ - "Я чувствовал себя бодрым и в хорошем настроении", - "Я чувствовал себя спокойно и расслабленно", - "Я чувствовал себя активным и полным сил", - "Я просыпался свежим и отдохнувшим", + "Я чувствовал(а) себя бодрым(ой) и в хорошем настроении", + "Я чувствовал(а) себя спокойно и расслабленно", + "Я чувствовал(а) себя активным(ой) и полным(ой) сил", + "Я просыпался(-ась) свежим(ей) и отдохнувшим(ей)", "Моя повседневная жизнь была наполнена тем, что мне интересно", ], anchors: { diff --git a/src/lib/mood/__tests__/context-vocabulary.test.ts b/src/lib/mood/__tests__/context-vocabulary.test.ts index 717eca9af..3a71ff424 100644 --- a/src/lib/mood/__tests__/context-vocabulary.test.ts +++ b/src/lib/mood/__tests__/context-vocabulary.test.ts @@ -73,10 +73,10 @@ const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [ ]; describe("mood context vocabulary labels", () => { - it("reads nine locales and a non-empty vocabulary", () => { + it("reads ten 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(9); + expect(LOCALES.length).toBe(10); expect(VALUE_GROUPS.length).toBeGreaterThan(0); const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0); expect(total).toBeGreaterThan(30); diff --git a/src/lib/onboarding/__tests__/first-result-config.test.ts b/src/lib/onboarding/__tests__/first-result-config.test.ts index a386869cd..5eebd8325 100644 --- a/src/lib/onboarding/__tests__/first-result-config.test.ts +++ b/src/lib/onboarding/__tests__/first-result-config.test.ts @@ -186,8 +186,8 @@ describe("connect-source state copy", () => { ) as Record, })); - it("covers all nine locales", () => { - expect(LOCALES.length).toBe(9); + it("covers all ten locales", () => { + expect(LOCALES.length).toBe(10); }); it.each(LOCALES)( diff --git a/src/lib/profile/country-label.ts b/src/lib/profile/country-label.ts index f4dd81039..43e39cdaf 100644 --- a/src/lib/profile/country-label.ts +++ b/src/lib/profile/country-label.ts @@ -18,6 +18,7 @@ const NAMES: Readonly>>> = nl: { DE: "Duitsland", KR: "Zuid-Korea", US: "Verenigde Staten" }, ko: { DE: "독일", KR: "대한민국", US: "미국" }, "pt-BR": { DE: "Alemanha", KR: "Coreia do Sul", US: "Estados Unidos" }, + ru: { DE: "Германия", KR: "Южная Корея", US: "США" }, }; export function countryLabel( diff --git a/src/lib/support/__tests__/support-report-redaction.test.ts b/src/lib/support/__tests__/support-report-redaction.test.ts index bf9613186..11ec740d1 100644 --- a/src/lib/support/__tests__/support-report-redaction.test.ts +++ b/src/lib/support/__tests__/support-report-redaction.test.ts @@ -382,6 +382,7 @@ describe("host names under every app language's country codes", () => { ko: ["kr"], nl: ["nl", "be"], "pt-BR": ["br", "pt", "ao", "mz"], + ru: ["ru", "by", "kz", "ua"], }; it("covers every shipped locale", () => { diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts index 0ba0ccf3b..e5855e2b9 100644 --- a/src/lib/support/redact.ts +++ b/src/lib/support/redact.ts @@ -95,6 +95,10 @@ export const HOST_TLDS = [ "dk", "no", "fi", + "ru", + "by", + "kz", + "ua", // Home-network and private suffixes. "local", "lan",