fix(coach): let the off-topic gate read the four languages it could not

The gate is "looks off-topic AND does not look health", and both halves
were English/German word lists. Two things followed. An off-topic ask in
French, Spanish, Italian or Polish was never recognised. And a genuine
health question in those languages that happened to brush an English deny
word was refused outright: "Ho una serie di misurazioni strane" trips
"serie", and nothing on the allow side knew "misurazioni", so a question
about the reader's own data came back as a refusal.

The allow side is no longer transcribed. Every metric label key and the
health nav labels already name these concepts in all six bundles, and
those labels are the nouns people type, so the vocabulary is read from
`messages/<locale>.json` and a seventh language is understood the day its
bundle lands. Matching is whole-word over a shared fold applied to both
sides. A subtractive list keeps label scaffolding out; French "bien-être"
alone would have contributed "bien" and "etre" and marked nearly every
French sentence as health.

The deny side stays hand-written — these are not app labels — and its new
arms match the folded message. A pattern anchored with \b cannot match a
word starting with "ż", because neither the preceding space nor the letter
is an ASCII word character, so there is no boundary between them.

The prompt-injection bank is untouched. It reads the person's own message
and the foreign-text path is fenced elsewhere; this is defence in depth,
not an open door.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 18:03:28 +02:00
1 parent 267497dd3a
commit 4d77fe2a43
2 files changed
+349 -2

No files matched your search

@@ -5,6 +5,7 @@ import {
COACH_REFUSAL_INJECTION_EN,
COACH_REFUSAL_OUT_OF_SCOPE_DE,
COACH_REFUSAL_OUT_OF_SCOPE_EN,
derivedHealthTokens,
detectRefusal,
} from "../refusal";
@@ -131,3 +132,82 @@ describe("detectRefusal", () => {
expect(decision.refuse).toBe(false);
});
});
/**
* The off-topic gate is `looksOffTopic && !looksHealth`, so both halves of it
* were English/German only: an off-topic ask in the other four languages was
* never recognised, and — worse — a HEALTH question in those languages that
* happened to brush an English deny word ("serie", "film") had nothing on the
* allow side to rescue it and was refused.
*
* The allow side is now read out of the message bundles, so these pin the
* derived property rather than a transcription: the words come from
* `MEASUREMENT_TYPE_LABEL_KEYS` and the health nav labels, and a seventh
* language becomes understood when its bundle lands.
*/
describe("detectRefusal across the shipped languages", () => {
it("derives a health vocabulary from every shipped bundle", () => {
const tokens = derivedHealthTokens();
expect(tokens.size).toBeGreaterThan(100);
// One metric label per locale, as that bundle spells it.
for (const word of [
"weight",
"gewicht",
"poids",
"peso",
"misurazioni",
"tetno",
]) {
expect(tokens.has(word)).toBe(true);
}
});
it("keeps everyday words out of the derived vocabulary", () => {
// "bien-être" would otherwise contribute "bien" and "etre", which appear
// in almost any French sentence and would disable the gate for French.
for (const word of ["bien", "etre", "estado", "average", "jour", "high"]) {
expect(derivedHealthTokens().has(word)).toBe(false);
}
});
/**
* The case the module's own docblock names for English — "is my BP trend
* related to the weather?" stays on-topic — asked in the other four
* languages. Each message trips the deny bank AND names the user's own data,
* so it survives only when the allow side speaks that language too. These
* are the assertions that go red if the allow side stops reading the
* bundles.
*/
it.each([
["fr", "Est-ce que la météo influence mes mesures de poids ?"],
["es", "¿El pronóstico del tiempo afecta a mis mediciones de peso?"],
["it", "Il meteo influenza le mie misurazioni di peso?"],
["pl", "Czy pogoda wpływa na moje pomiary wagi?"],
] as const)(
"passes through a health question in %s that brushes a deny word",
(locale, message) => {
expect(detectRefusal({ message, locale }).refuse).toBe(false);
},
);
it("does not refuse an Italian health question that brushes a deny word", () => {
// "serie" is banked as off-topic (film series). Before the allow side read
// the bundles, nothing here said "health" and the question was refused.
const decision = detectRefusal({
message: "Ho una serie di misurazioni strane, cosa vedi?",
locale: "it",
});
expect(decision.refuse).toBe(false);
});
it.each([
["fr", "Raconte-moi une blague sur la météo."],
["es", "Cuéntame un chiste y dame una receta."],
["it", "Raccontami una barzelletta sul meteo."],
["pl", "Opowiedz mi żart o pogodzie."],
] as const)("refuses an off-topic ask in %s", (locale, message) => {
const decision = detectRefusal({ message, locale });
expect(decision.refuse).toBe(true);
expect(decision.reason).toBe("out_of_scope");
});
});
+269 -2
View File
@@ -23,7 +23,10 @@
* route can choose its bias.
*/
import type { Locale } from "@/lib/i18n/config";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { labelsInEveryLocale } from "@/lib/i18n/localised-label-index";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
/**
* Refusal copy for the streaming response — UI-rendered as `token` SSE frames
@@ -145,6 +148,265 @@ const OFF_TOPIC_TOKENS: readonly RegExp[] = [
/\b(?:flight|flug|hotel|trip|urlaub|vacation|travel|reise)\b/i,
];
/**
* The same deny bank for the other four shipped languages, matched against the
* FOLDED message rather than the raw one.
*
* Two reasons it cannot simply join the array above. JavaScript's `\b` is
* defined over ASCII word characters, so `\bżart\b` never matches: the space
* before "ż" is a non-word character and so is "ż" itself, which means there is
* no boundary between them. And a reply may spell a word with or without its
* diacritic. Folding both sides first solves both at once — "żart" and "zart"
* fold together, and every entry here is ASCII, so the boundaries hold.
*
* Only words that mean ONE thing are banked. Spanish "tiempo" and French
* "temps" are the everyday word for both weather and time, so neither is here;
* "meteo", "pronostico" and "pogoda" carry no second reading. A wrong entry on
* the deny side costs a refused health question, which is the expensive
* direction, so a missing word is preferred to a loose one.
*/
const OFF_TOPIC_TOKENS_FOLDED: readonly RegExp[] = [
/\b(?:meteo|pronostico|previsioni|pogoda|prognoza)\b/,
/\b(?:noticias|notizie|aktualnosci|wiadomosci|elecciones|elezioni|wybory|elections)\b/,
/\b(?:blague|chiste|barzelletta|zart|poeme|poema|poesia|wiersz|opowiadanie)\b/,
/\b(?:acciones|azioni|akcje|criptomoneda|criptovaluta|kryptowaluta|inwestycja)\b/,
/\b(?:pelicula|cancion|canzone|piosenka|muzyka|musica|musique)\b/,
/\b(?:receta|ricetta|recette|przepis|cucinare|cocinar|gotowanie)\b/,
/\b(?:vuelo|volo|wakacje|vacaciones|vacanze|vacances|urlop|podroz)\b/,
];
/* ──────────────────────────────────────────────────────────────────────────
* The health allow-list's second half — DERIVED, not transcribed.
*
* `HEALTH_TOKENS` above is a hand-written English/German bank. It carries
* vocabulary no bundle has (`mmhg`, `hrv`, `withings`, `bpm`) and is kept, but
* on its own it made the off-topic gate misfire across the four other shipped
* languages: the deny bank matches "serie", so an Italian asking "ho una serie
* di misurazioni strane" tripped it, and nothing in the allow bank recognised
* "misurazioni" as health, so a question about the user's own data was refused.
*
* The words that fix this are not this module's to invent. Every metric the app
* tracks and every health surface it navigates to is already labelled in all six
* bundles, and those labels are exactly the nouns a person types. So the second
* half of the allow-list is READ from the bundles: a seventh language starts
* being understood the day `messages/xx.json` lands, with no edit here.
*
* Whole-word matching against the folded message, not substring — the derived
* set is much larger than the hand bank and a substring match over it would
* turn ordinary prose into a health signal. That costs inflected forms
* ("pomiarów" does not match the label's "pomiary"); the hand bank and the
* `defaultAllow` bias absorb the miss, and a miss here only means the message
* is not positively marked health, never that it is refused on its own.
* ────────────────────────────────────────────────────────────────────────── */
/** Health surfaces the sidebar names — the nouns a user asks about by name. */
const HEALTH_SURFACE_LABEL_KEYS: readonly string[] = [
"nav.measurements",
"nav.medications",
"nav.mood",
"nav.cycle",
"nav.labs",
"nav.vorsorge",
"nav.illness",
"nav.vaccinations",
"nav.mentalWellbeing",
];
/**
* Shortest derived token kept. Three-letter labels are where the collisions
* live — French "Pas" (steps) is also the negation particle, Polish "Sen"
* (sleep) sits inside ordinary words — and admitting them would mark almost
* any sentence in those languages as health.
*/
const MIN_DERIVED_TOKEN_LENGTH = 4;
/**
* Words that appear inside a metric label while carrying no health meaning of
* their own. Three groups, all of them found by reading the derived set rather
* than guessed at:
*
* - label scaffolding — "average", "daily", "time in …", "… event";
* - comparatives the label uses to name a variant — "high heart rate event",
* "Frequenza cardiaca alta";
* - words that are health-shaped in English but everyday elsewhere. French
* "bien-être" contributes "bien" and "etre", two of the commonest words in
* the language; leaving them in would have marked nearly every French
* sentence as health and disabled the off-topic gate for French entirely.
*/
const GENERIC_LABEL_WORDS: ReadonlySet<string> = new Set([
// articles, prepositions and copulas long enough to survive the length floor
"alla",
"alle",
"dans",
"della",
"delle",
"dell",
"para",
"przez",
"przy",
"bien",
"etre",
"estado",
// label scaffolding
"average",
"media",
"medio",
"moyenne",
"moyen",
"mittel",
"mittelwert",
"srednie",
"durchschnittlicher",
"durchschnittliche",
"total",
"totale",
"gesamt",
"time",
"tempo",
"temps",
"tiempo",
"zeit",
"czas",
"jour",
"giorno",
"giornaliero",
"dzienne",
"dziennym",
"daily",
"taglich",
"minute",
"minutes",
"minuten",
"minuti",
"minutos",
"minutowego",
"index",
"indice",
"value",
"wert",
"count",
"anzahl",
"event",
"ereignis",
"evento",
"zdarzenie",
"level",
"niveau",
"nivel",
"livello",
"poziom",
"notification",
"notifica",
"powiadomienie",
"alert",
"alerte",
"aviso",
"avviso",
"hinweis",
"rate",
"rapport",
"rapporto",
"course",
"support",
"load",
"charge",
"double",
"doble",
"doppio",
"libre",
"free",
"scale",
"need",
"besoin",
"necesidad",
"fabbisogno",
"zapotrzebowanie",
"tour",
"vita",
"force",
"presa",
"seance",
"ambiente",
"environment",
"environnement",
"otoczenie",
"umgebung",
// comparatives naming a label variant
"high",
"hohe",
"alta",
"alto",
"elevee",
"wysokim",
"maksymalne",
"maxima",
"maximale",
"maximaler",
"maximum",
"massima",
"bassa",
"basse",
"baja",
"niedrige",
"niskim",
"forte",
]);
/**
* Split a label or a message into comparable whole words: accent-folded, lower
* cased, and cut on every non-alphanumeric run so a parenthesised label
* ("Audio exposure (headphones)") yields the noun rather than "(headphones)".
*
* Applied to BOTH sides — the bundle label and the user's message go through
* this one function, so the index cannot drift from the lookup.
*/
function healthWordTokens(raw: string): string[] {
return foldForMatch(raw)
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length >= MIN_DERIVED_TOKEN_LENGTH);
}
/**
* Every health noun the bundles label, folded, across every shipped locale.
* Built once at module load — a few hundred short string folds over catalogs
* the server translator already holds resident.
*/
function buildDerivedHealthTokens(): ReadonlySet<string> {
const tokens = new Set<string>();
const keys = [
...Object.values(MEASUREMENT_TYPE_LABEL_KEYS),
...HEALTH_SURFACE_LABEL_KEYS,
];
for (const key of keys) {
for (const label of labelsInEveryLocale(key)) {
for (const word of healthWordTokens(label)) {
if (GENERIC_LABEL_WORDS.has(word)) continue;
tokens.add(word);
}
}
}
return tokens;
}
const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
/**
* Test seam: the guard suite asserts the derived set is non-empty and that
* every shipped locale contributes to it, so a bundle that stops carrying the
* metric labels fails the suite rather than quietly narrowing the allow-list.
*/
export function derivedHealthTokens(): ReadonlySet<string> {
return DERIVED_HEALTH_TOKENS;
}
/** True when any whole word of the message is a health noun from the bundles. */
function matchesDerivedHealthToken(message: string): boolean {
for (const word of healthWordTokens(message)) {
if (DERIVED_HEALTH_TOKENS.has(word)) return true;
}
return false;
}
export interface DetectRefusalParams {
/** Raw user-input message. */
message: string;
@@ -187,8 +449,13 @@ export function detectRefusal(
}
}
const looksHealth = HEALTH_TOKENS.some((p) => p.test(trimmed));
const looksOffTopic = OFF_TOPIC_TOKENS.some((p) => p.test(trimmed));
const looksHealth =
HEALTH_TOKENS.some((p) => p.test(trimmed)) ||
matchesDerivedHealthToken(trimmed);
const folded = foldForMatch(trimmed);
const looksOffTopic =
OFF_TOPIC_TOKENS.some((p) => p.test(trimmed)) ||
OFF_TOPIC_TOKENS_FOLDED.some((p) => p.test(folded));
if (looksOffTopic && !looksHealth) {
return {