From 3577da6966e0224933e4d9eb8cd18e9987b286b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 14:50:17 +0200 Subject: [PATCH] fix(coach): narrow the pt-BR population-norm patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sentence the grounding check reads as population guidance has its numbers exempted from the evidence check. Two of the pt-BR patterns matched sentences about the reader's own readings: "(entre|de) ... geralmente" fired on "Sua pressão de 158/97 geralmente fica...", and a singular "médico ... recomenda" with a 30-character gap fired on "Seu médico recomenda manter a pressão em 158/97". Either let an invented figure through unchecked. Every pt-BR entry now needs a population subject or a reference noun: plural guideline, doctor or specialist subjects next to their verb, plural adults needing something, and "a/o normal" without a possessive. Portuguese month names join the written-date pass so "21 de julho" is not graded as a figure, and quilos, por cento and passos join the unit words. Negative cases prove the reader's-own sentences are checked again; positive cases keep the reference sentences exempt. --- .../__tests__/coach-prose-grounding.test.ts | 36 +++++++++++++++++++ src/lib/ai/coach/coach-prose-grounding.ts | 27 ++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) 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 ef2f20b14..c3d6c2bf4 100644 --- a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -600,6 +600,7 @@ describe("tokenizing prose written in the reader's language", () => { ["es", "El 21 de julio, su tensión era de 128 mmHg."], ["it", "Il 21 luglio la pressione era di 128 mmHg."], ["pl", "21 lipca ciśnienie wynosiło 128 mmHg."], + ["pt-BR", "Em 21 de julho, sua pressão era de 128 mmHg."], ] as const)("leaves a written %s date alone", (locale, prose) => { const findings = findUnverifiedCoachNumbers( prose, @@ -623,3 +624,38 @@ describe("tokenizing prose written in the reader's language", () => { expect(findings.map((f) => f.value)).not.toContain(21); }); }); + +/** + * A sentence framed as population guidance has its numbers exempted from the + * check, so a framing pattern that also matches a sentence about the reader's + * own readings lets an invented figure through unchecked. The Brazilian + * Portuguese bank needs a population subject or a reference noun for that + * reason; the first two cases here were exempted by the broader patterns it + * started with. + */ +describe("Brazilian Portuguese population-norm framing", () => { + const payloads = [{ metric: "bp", latest: 128 }]; + + it.each([ + "Sua pressão de 158/97 geralmente fica acima do seu padrão.", + "Seu médico recomenda manter a pressão em 158/97.", + "Entre as suas medições, a pressão normalmente fica em 158/97.", + "Para um adulto como você, 158/97 geralmente precisa de atenção.", + ])("still checks the numbers in: %s", (prose) => { + const values = findUnverifiedCoachNumbers(prose, payloads, "pt-BR").map( + (f) => f.value, + ); + expect(values).toContain(158); + expect(values).toContain(97); + }); + + it.each([ + "A faixa normal do pulso em repouso fica entre 60 e 100 bpm.", + "Os médicos recomendam de 7 a 9 horas de sono por noite.", + "A maioria dos adultos precisa de 7 a 9 horas de sono.", + "A pressão arterial normal de um adulto fica abaixo de 120/80 mmHg.", + "Os valores de referência da glicemia em jejum vão de 70 a 99 mg/dL.", + ])("exempts the reference figures in: %s", (prose) => { + expect(findUnverifiedCoachNumbers(prose, payloads, "pt-BR")).toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index b4cd0846b..027667237 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -156,6 +156,10 @@ const UNIT_WORDS: Readonly> = { "livres", "libra", "libras", + "quilo", + "quilos", + "quilograma", + "quilogramas", "funt", "funty", ], @@ -170,6 +174,7 @@ const UNIT_WORDS: Readonly> = { "pour cent", "pourcent", "por ciento", + "por cento", "porciento", "per cento", "procent", @@ -214,6 +219,7 @@ const UNIT_WORDS: Readonly> = { "pasos", "passo", "passi", + "passos", "krok", "kroki", "krokow", @@ -626,6 +632,8 @@ const MONTHS = [ "stycznia|lutego|marca|kwietnia|maja|czerwca|lipca|sierpnia|września|wrzesnia|października|pazdziernika|listopada|grudnia", // nl "januari|februari|maart|mei|augustus", + // pt-BR + "janeiro|fevereiro|março|marco|maio|junho|julho|setembro|outubro|novembro|dezembro", ].join("|"); /** Parse a raw number token honouring the reply language's separators. */ @@ -635,7 +643,7 @@ function parseLocaleNumber(raw: string, locale: Locale): number | null { // EN: "," groups thousands, "." is the decimal point. normalised = raw.replace(/,/g, ""); } else { - // DE / fr / es / it / pl / nl: "." groups thousands, "," is the decimal + // DE / fr / es / it / pl / nl / pt-BR: "." groups thousands, "," is the decimal // point. normalised = raw.replace(/\./g, "").replace(/,/g, "."); } @@ -880,18 +888,23 @@ const POPULATION_NORM_PATTERNS: Record = { /\b(?:volwassenen|mensen)\s+(?:hebben\s+)?(?:meestal|doorgaans|gewoonlijk|normaal\s+gesproken|over\s+het\s+algemeen)\s+(?:nodig|hebben)\b/i, /\b(?:referentie|normaal|aanbevolen)\w*\s*(?:bereik|waarden?)\b/i, /\bligt\s+(?:normaal\s+gesproken|meestal|doorgaans|gewoonlijk)\s+(?:tussen|rond|op)\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+. + ], + // pt-BR. Every entry needs a population subject or a reference noun: a + // reading of the reader's own ("Sua pressão de 158/97 geralmente fica…", + // "Seu médico recomenda…") is a claim about their data, and an exempted + // sentence is one whose numbers are never checked. "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, + /\b(?:as\s+|os\s+)?(?:diretrizes|m[eé]dicos|especialistas)\s+(?:geralmente\s+|em\s+geral\s+)?(?:dizem|recomendam|aconselham|sugerem|indicam|consideram)\b/i, + /\b(?:os\s+)?(?:adultos|as\s+pessoas)\s+(?:geralmente\s+|normalmente\s+|em\s+geral\s+)?(?:precisam|necessitam)\s+de\b/i, + /\b(?:a|o)\s+(?:frequ[êe]ncia\s+card[íi]aca|pulso|press[ãa]o(?:\s+arterial)?|glicemia|glicose)(?:\s+de\s+repouso)?\s+(?:normal|saud[áa]vel|t[íi]pica|t[íi]pico)\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*(?:범위|치|값)/,