fix(daily): match a lead's metric by whole words

The terms that decide whether the Today lead already names the top
signal's metric were bare stems: "risen" and "chosen" matched the Polish
"sen" (sleep), "impulse" matched "puls", "Passing" matched the Italian
"passi", and the Korean "잠" matched "잠시" (a moment). A lead about
weight then dropped a sleep headline. Every term now has to start a word,
whole-word terms have to end one (Unicode-aware on both sides), and the
Korean terms carry their particle.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 13:02:04 +02:00
1 parent 25c34b0e7c
commit 7400852fb4
2 files changed
+168 -18

No files matched your search

@@ -793,3 +793,74 @@ describe("signal line under the lead — nothing said twice", () => {
expect(buildTodayOverview(input(), t).signalLine).toBeNull();
});
});
describe("signal line — a metric is named by whole words, not by fragments", () => {
const DELTA = "↓ 50 min vs your usual";
function lineFor(
lead: string,
sourceMetric: NonNullable<
DailyBriefing["signalsOfDay"]
>[number]["sourceMetric"],
) {
return buildTodayOverview(
input({
briefing: {
paragraph: lead,
signalsOfDay: [
{
sourceMetric,
tone: "info",
headline: "A headline the lead does not quote.",
nudge: "n",
delta: DELTA,
},
],
keyFindings: [],
},
}),
t,
).signalLine;
}
// Each lead talks about something else; a fragment inside another word
// used to count as naming the metric, and the headline was dropped.
it.each([
["Your weight has risen slightly this week.", "sleep"], // "sen"
["You have chosen a steady routine.", "sleep"], // "sen"
["Your readings worsen a little after lunch.", "sleep"], // "sen"
["A snug fit for the cuff gave a clean reading.", "sleep"], // "snu"
["Poranne ciśnienie spadło po spacerze.", "sleep"], // "spa" (pl)
["잠시 후 다시 측정해 보세요.", "sleep"], // 잠시 = "a moment"
["Passing showers kept the walk short.", "steps"], // "passi"
["Ho passato una giornata tranquilla.", "steps"], // "passi" (it) inside passato
["A sudden impulse to rest was a good call.", "pulse"], // "puls"
["Der Impuls zur Pause war richtig.", "pulse"], // "puls" (de)
["L'impulsione del giorno era calma.", "pulse"], // "puls" (it)
["Wypadł impuls do odpoczynku.", "pulse"], // "puls" (pl)
] as const)("keeps the headline under %j (%s)", (lead, metric) => {
const line = lineFor(lead, metric);
expect(line?.headline).toBe("A headline the lead does not quote.");
});
it.each([
["Last night you slept seven hours.", "sleep"],
["Dein Schlaf war ruhig.", "sleep"],
["Tu sueño fue tranquilo.", "sleep"],
["Tu as bien dormi.", "sleep"],
["Il sonno è stato regolare.", "sleep"],
["Twój sen był spokojny.", "sleep"],
["Mało snu tej nocy.", "sleep"],
["Spałeś siedem godzin.", "sleep"],
["어젯밤 잠을 잘 잤어요.", "sleep"],
["수면 시간이 길었어요.", "sleep"],
["Hai fatto molti passi oggi.", "steps"],
["Your pulse settled overnight.", "pulse"],
["Dein Puls war ruhig.", "pulse"],
["Twój puls był spokojny.", "pulse"],
["Il polso era regolare.", "pulse"],
["Ton pouls était calme.", "pulse"],
] as const)("still recognises %j as naming %s", (lead, metric) => {
expect(lineFor(lead, metric)).toEqual({ headline: null, delta: DELTA });
});
});
+97 -18
View File
@@ -345,31 +345,110 @@ function restates(text: string, phrase: string): boolean {
return p.length > 0 && normalise(text).includes(p);
}
/**
* A term that must start a word: `stem` lets an inflection follow it
* ("Blutdrucks", "tętna"), `word` must end where the word ends. Unicode-aware
* on both sides, since `\b` only knows ASCII letters and would split "spał".
* Without the boundaries a fragment inside another word named the metric:
* "risen" read as the Polish "sen" (sleep), "impulse" as "puls".
*/
const START = "(?<![\\p{L}\\p{N}])";
const END = "(?![\\p{L}\\p{N}])";
const stem = (...terms: string[]) => terms.map((x) => `${START}${x}`);
const word = (...terms: string[]) => terms.map((x) => `${START}${x}${END}`);
/** Hangul has no word boundary to lean on: the term carries its particle. */
const hangul = (...terms: string[]) => terms;
const terms = (...parts: string[][]) =>
new RegExp(parts.flat().join("|"), "iu");
const WEIGHT_TERMS = terms(
stem("weigh", "gewicht"),
word("peso", "pesos", "poids", "wag[aięe]", "wadze"),
hangul("체중", "몸무게"),
);
/**
* How each briefing metric is named in prose, in every shipped language. The
* lead is model text in the reader's language, so "does the lead already talk
* about this metric" is a question about words. Stems, not whole words, so
* inflected forms ("Blutdrucks", "tętna") still match. A metric missing here
* simply falls back to the headline comparison.
* about this metric" is a question about words, matched at word starts. A
* metric missing here simply falls back to the headline comparison.
*/
const METRIC_TERMS: Partial<
Record<DailyBriefingSignal["sourceMetric"], RegExp>
> = {
bp: /blood\s*pressure|blutdruck|presi[oó]n\s+arterial|tensi[oó]n\s+arterial|pression\s+art[ée]rielle|tension\s+art[ée]rielle|pressione\s+(arteriosa|sanguigna)|ci[sś]nieni|혈압/iu,
weight: /weight|gewicht|peso|poids|wag[ai]|체중|몸무게/iu,
glp1_plateau: /weight|gewicht|peso|poids|wag[ai]|체중|몸무게/iu,
pulse: /pulse|puls|pouls|polso|t[eę]tn|맥박/iu,
resting_hr:
/resting\s+(heart\s*rate|pulse)|ruhepuls|ruhe-?herzfrequenz|en\s+reposo|au\s+repos|a\s+riposo|spoczynkow|안정\s*시?\s*심박/iu,
hrv: /\bhrv\b|heart[-\s]*rate\s+variability|herzfrequenzvariabilit|variabilidad|variabilit[ée]|zmienno[sś][cć]|심박\s*변이/iu,
sleep:
/sleep|slept|schlaf|geschlafen|sue[nñ]o|dormi|sommeil|sonno|sen\b|snu|spa[lł]|수면|잠/iu,
steps: /steps|schritte|pasos|passi|krok|걸음/iu,
mood: /mood|stimmung|[aá]nimo|humeur|umore|nastr[oó]j|기분/iu,
compliance:
/medication|dose|medikament|einnahme|medicaci[oó]n|m[ée]dicament|farmac|lek[iów]|복약|약물/iu,
body_temp: /temperature|temperatur|temperatura|temp[ée]rature|체온/iu,
vo2_max: /vo2|vo₂/iu,
bp: terms(
stem(
"blood\\s*pressure",
"blutdruck",
"presi[oó]n\\s+arterial",
"tensi[oó]n\\s+arterial",
"pression\\s+art[ée]rielle",
"tension\\s+art[ée]rielle",
"pressione\\s+(arteriosa|sanguigna)",
"ci[sś]nieni",
),
hangul("혈압"),
),
weight: WEIGHT_TERMS,
glp1_plateau: WEIGHT_TERMS,
pulse: terms(
word("pulses?", "puls(es)?", "pouls", "polso", "pulso"),
stem("t[eę]tn"),
hangul("맥박"),
),
resting_hr: terms(
stem(
"resting\\s+(heart\\s*rate|pulse)",
"ruhepuls",
"ruhe-?herzfrequenz",
"en\\s+reposo",
"au\\s+repos",
"a\\s+riposo",
"spoczynkow",
),
hangul("안정\\s*시?\\s*심박"),
),
hrv: terms(
word("hrv"),
stem(
"heart[-\\s]*rate\\s+variability",
"herzfrequenzvariabilit",
"variabilidad\\s+de\\s+la\\s+frecuencia",
"variabilit[ée]\\s+de\\s+la\\s+fr[ée]quence",
"variabilit[àa]\\s+della\\s+frequenza",
"zmienno[sś][cć]\\s+rytmu",
),
hangul("심박\\s*변이"),
),
sleep: terms(
stem("sleep", "schlaf", "geschlafen", "dormi"),
word(
"slept",
"sue[nñ]o",
"sommeil",
"sonno",
"sen",
"snu",
"spa[lł](a|e[sś]|a[sś]|y)?",
),
hangul("수면", "잠을", "잠이", "잠은", "잠도"),
),
steps: terms(
word("steps", "schritte", "schritten", "pasos", "passi", "krok(i|ów|ach)"),
hangul("걸음"),
),
mood: terms(
stem("stimmung"),
word("mood", "[aá]nimo", "humeur", "umore", "nastr(ój|oj|oju)"),
hangul("기분"),
),
compliance: terms(
stem("medication", "medikament", "einnahme", "m[ée]dicament", "farmac"),
word("doses?", "dosis", "medicaci[oó]n", "lek(i|ów|u|ami)?"),
hangul("복약", "약물"),
),
body_temp: terms(stem("temperatur", "temp[ée]rature"), hangul("체온")),
vo2_max: terms(stem("vo2", "vo₂")),
};
/** Whether the lead already talks about the metric a signal is drawn from. */