fix(coach): narrow the pt-BR population-norm patterns

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 <metric> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 14:50:17 +02:00
1 parent 68b428e3f5
commit 3577da6966
2 files changed
+56 -7

No files matched your search

@@ -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([]);
});
});
+20 -7
View File
@@ -156,6 +156,10 @@ const UNIT_WORDS: Readonly<Record<NumKind, readonly string[]>> = {
"livres",
"libra",
"libras",
"quilo",
"quilos",
"quilograma",
"quilogramas",
"funt",
"funty",
],
@@ -170,6 +174,7 @@ const UNIT_WORDS: Readonly<Record<NumKind, readonly string[]>> = {
"pour cent",
"pourcent",
"por ciento",
"por cento",
"porciento",
"per cento",
"procent",
@@ -214,6 +219,7 @@ const UNIT_WORDS: Readonly<Record<NumKind, readonly string[]>> = {
"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<Locale, readonly RegExp[]> = {
/\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*(?:범위|치|값)/,