diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts index 39e277b5d..4646f9813 100644 --- a/src/lib/daily/__tests__/today-overview.test.ts +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts index da73f14e2..bb22c469f 100644 --- a/src/lib/daily/today-overview.ts +++ b/src/lib/daily/today-overview.ts @@ -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 = "(? 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 > = { - 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. */