From 12065bc0c7b98a6c5cd6f3eadc9f4d56b66c2336 Mon Sep 17 00:00:00 2001 From: dareoon <99291042+dareoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:34 -0300 Subject: [PATCH] feat(i18n): add Brazilian Portuguese support to medication and insight flows Add pt-BR locale coverage across medication extraction, coach prose grounding, no-key insight fallbacks, coach read dates, and lab reference range parsing. - Accept pt-BR natively in NaturalLanguageExtractorProps. - Add Brazilian Portuguese population-norm framing patterns. - Add pt-BR no-assessment fallback copy for all insight floors. - Add pt-BR expected date coverage to the Coach read strip test. - Add pt-BR lab range prose vocabulary. - Keep existing locale behavior and safety-contract coverage unchanged. --- .../coach-read-strip-latest-date.test.tsx | 1 + .../scheduling/natural-language-extractor.tsx | 3 ++- src/lib/ai/coach/coach-prose-grounding.ts | 9 +++++++ .../eval/__tests__/tone-harness.test.ts | 1 + src/lib/labs/parse-reference-range.ts | 24 +++++++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) 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 b10d567eb..c790625ae 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 @@ -91,6 +91,7 @@ describe("CoachReadStrip — the latest reading's day", () => { it: "1 ottobre", ko: "10월 1일", pl: "1 października", + "pt-BR": "1 de outubro", }; for (const [locale, date] of Object.entries(expected) as Array< [Locale, string] diff --git a/src/components/medications/scheduling/natural-language-extractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx index 0824067a5..fdf204b9e 100644 --- a/src/components/medications/scheduling/natural-language-extractor.tsx +++ b/src/components/medications/scheduling/natural-language-extractor.tsx @@ -41,6 +41,7 @@ * button is the open-trigger. */ +import type { Locale } from "@/lib/i18n/config"; import { useCallback, useId, useRef, useState } from "react"; import { Loader2, Sparkles } from "lucide-react"; @@ -72,7 +73,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" | "ko"; + locale?: Locale; /** * Optional override of the API path so the wizard test harness can * point at a mock server. Defaults to the production route. diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index 3b63c5c2d..fa602aa02 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -870,6 +870,15 @@ const POPULATION_NORM_PATTERNS: Record = { ], // 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+. + "pt-BR": [ + /\bfaixa\s+(?:de\s+refer[eê]ncia|normal)\b/i, + /\bvalor(?:es)?\s+de\s+refer[eê]ncia\b/i, + /\b(?:adultos?|pessoas?)\s+saud[áa]ve(?:l|is)\b/i, + /\bmaioria\s+(?:dos\s+)?(?:adultos?|das\s+pessoas)\b/i, + /\b(?:diretrizes|guias|m[eé]dicos?|especialistas?)\b[^.?!]{0,30}\b(?:recomend(?:am|a)|suger(?:em|e)|indic(?:am|a))\b/i, + /\b(?:adultos?|pessoas?)\b[^.?!]{0,30}\b(?:geralmente|normalmente|em\s+geral)\b[^.?!]{0,30}\b(?:precis(?:am|a)|devem|necessit(?:am|a))\b/i, + /\b(?:entre|de)\s+[^.?!]{0,30}\b(?:normalmente|geralmente|em\s+geral)\b/i, + ], ko: [ /정상\s*범위(?:는|가|를|로|의)/, /(?:참고|기준|권장)\s*(?:범위|치|값)/, diff --git a/src/lib/insights/eval/__tests__/tone-harness.test.ts b/src/lib/insights/eval/__tests__/tone-harness.test.ts index c5ab6f687..3921b0f5b 100644 --- a/src/lib/insights/eval/__tests__/tone-harness.test.ts +++ b/src/lib/insights/eval/__tests__/tone-harness.test.ts @@ -254,6 +254,7 @@ describe("tone harness — deterministic fallbacks", () => { es: /no hay ninguna valoración/, it: /non è disponibile alcuna valutazione/, pl: /nie ma (?:tu )?żadnej oceny/, + "pt-BR": /n[aã]o h[áa] (?:uma )?avalia[çc][aã]o/i, ko: /평가가 없어요/, }; diff --git a/src/lib/labs/parse-reference-range.ts b/src/lib/labs/parse-reference-range.ts index 0473649ea..ce9c39f97 100644 --- a/src/lib/labs/parse-reference-range.ts +++ b/src/lib/labs/parse-reference-range.ts @@ -263,6 +263,30 @@ export const RANGE_PROSE: Record = { // 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. + "pt-BR": { + twoSided: ["a", "e"], + upper: [ + "ate", + "menos de", + "abaixo de", + "inferior a", + "maximo", + "no maximo", + ], + lower: [ + "a partir de", + "no minimo", + "minimo", + "acima de", + "maior que", + "maior de", + ], + filler: [ + "valores de referencia", + "valor de referencia", + "faixa de referencia", + ], + }, ko: { twoSided: ["에서", "부터"], upper: ["최대", "최고"],