diff --git a/src/__tests__/helpers/ascii-boundary.ts b/src/__tests__/helpers/ascii-boundary.ts new file mode 100644 index 000000000..48636a304 --- /dev/null +++ b/src/__tests__/helpers/ascii-boundary.ts @@ -0,0 +1,107 @@ +/** + * Find `\b` word boundaries that sit next to an accented letter in a regular + * expression's source. + * + * JavaScript defines `\b` over ASCII `\w`, so `santé\b` never matches before a + * space and `\bépisode` never matches after one; an alternative that starts or + * ends that way is dead. The check looks at what the source puts on each side + * of the boundary: a literal letter, a character class, or a group whose + * alternatives start (or end) with one. It is a text heuristic over the + * pattern source, not a regex engine: a boundary next to a backreference or a + * lookaround it does not unwrap is not judged. + */ +function isAccentedLetter(char: string | undefined): boolean { + return char !== undefined && char.charCodeAt(0) > 127 && /\p{L}/u.test(char); +} + +function classEnd(source: string, open: number): number { + let i = open + 1; + while (i < source.length && !(source[i] === "]" && source[i - 1] !== "\\")) { + i += 1; + } + return i; +} + +function classHasAccent(source: string, open: number): boolean { + return [...source.slice(open, classEnd(source, open))].some(isAccentedLetter); +} + +/** Top-level alternatives of the group opening at `open`, and its close. */ +function alternatives(source: string, open: number): string[] { + const prefix = /^\(\?(?:<[=!]|[:=!])/.exec(source.slice(open))?.[0] ?? "("; + const alts: string[] = []; + let depth = 0; + let start = open + prefix.length; + for (let i = open; i < source.length; i += 1) { + const c = source[i]; + if (c === "\\") { + i += 1; + } else if (c === "[") { + i = classEnd(source, i); + } else if (c === "(") { + depth += 1; + } else if (c === ")") { + depth -= 1; + if (depth === 0) { + alts.push(source.slice(start, i)); + return alts; + } + } else if (c === "|" && depth === 1) { + alts.push(source.slice(start, i)); + start = i + 1; + } + } + return alts; +} + +function opensWithAccent(text: string): boolean { + if (text.startsWith("[")) return classHasAccent(text, 0); + if (text.startsWith("(")) return alternatives(text, 0).some(opensWithAccent); + return isAccentedLetter(text[0]); +} + +function closesWithAccent(text: string): boolean { + const trimmed = text.replace(/(?:[?*+]|\{\d+(?:,\d*)?\})+\??$/, ""); + const last = trimmed[trimmed.length - 1]; + if (last === "]") { + let open = trimmed.length - 2; + while ( + open >= 0 && + !(trimmed[open] === "[" && trimmed[open - 1] !== "\\") + ) { + open -= 1; + } + return classHasAccent(trimmed, open); + } + if (last === ")") { + let depth = 0; + for (let i = trimmed.length - 1; i >= 0; i -= 1) { + if (trimmed[i - 1] === "\\") continue; + if (trimmed[i] === ")") depth += 1; + else if (trimmed[i] === "(" && --depth === 0) { + return alternatives(trimmed, i).some(closesWithAccent); + } + } + return false; + } + return isAccentedLetter(last); +} + +/** Offsets of every `\b` in `source` that touches an accented letter. */ +export function accentedBoundaries(source: string): number[] { + const hits: number[] = []; + for ( + let i = source.indexOf("\\b"); + i >= 0; + i = source.indexOf("\\b", i + 2) + ) { + if (source[i - 1] === "\\") continue; + if ( + opensWithAccent(source.slice(i + 2)) || + closesWithAccent(source.slice(0, i)) + ) { + hits.push(i); + } + } + return hits; +} diff --git a/src/lib/ai/coach/population-norms/__tests__/liveness.test.ts b/src/lib/ai/coach/population-norms/__tests__/liveness.test.ts new file mode 100644 index 000000000..145e4e072 --- /dev/null +++ b/src/lib/ai/coach/population-norms/__tests__/liveness.test.ts @@ -0,0 +1,121 @@ +/** + * Every population-norm pattern of every locale has to match at least one + * realistic reference sentence below. A pattern that never fires exempts + * nothing and fails silently; French "en bonne santé" and Polish "zdrowi + * dorośli" were dead that way, the one behind an ASCII `\b` after "é", the + * other spelled for "dorosli" without the "ś". + */ +import { describe, expect, it } from "vitest"; + +import { accentedBoundaries } from "@/__tests__/helpers/ascii-boundary"; +import { locales, type Locale } from "@/lib/i18n/config"; + +import { POPULATION_NORM_PATTERNS } from ".."; + +const SENTENCES: Record = { + en: [ + "The normal range is 60 to 100.", + "Healthy adults sleep 7 to 9 hours.", + "The general population averages 120.", + "Guidelines recommend 150 minutes a week.", + "Adults generally need 7 hours of sleep.", + "The reference range is 4 to 6.", + "The recommended amount is 2 litres.", + ], + de: [ + "Der Normalbereich liegt bei 60 bis 100.", + "Die normale Herzfrequenz liegt bei 60.", + "Gesunde Erwachsene schlafen 8 Stunden.", + "Ärzte empfehlen 150 Minuten pro Woche.", + "Erwachsene brauchen in der Regel 8 Stunden.", + "Normale Werte liegen bei 4 bis 6.", + "Der Puls liegt normalerweise zwischen 60 und 100.", + ], + fr: [ + "La plage normale va de 60 à 100.", + "Les adultes en bonne santé dorment 8 heures.", + "La plupart des adultes dorment 8 heures.", + "Les médecins recommandent 150 minutes.", + "Les adultes ont besoin de 8 heures.", + "Elle se situe généralement entre 60 et 100.", + ], + es: [ + "El rango normal va de 60 a 100.", + "Los adultos sanos duermen 8 horas.", + "La mayoría de los adultos duerme 8 horas.", + "Los médicos recomiendan 150 minutos.", + "Los adultos generalmente necesitan 8 horas.", + "Se sitúa normalmente entre 60 y 100.", + ], + it: [ + "L'intervallo normale va da 60 a 100.", + "Gli adulti sani dormono 8 ore.", + "La maggior parte degli adulti dorme 8 ore.", + "I medici raccomandano 150 minuti.", + "Gli adulti di solito hanno bisogno di 8 ore.", + "Si colloca di solito tra 60 e 100.", + ], + pl: [ + "Prawidłowy zakres wynosi 60 do 100.", + "Zdrowi dorośli śpią 8 godzin.", + "Większość dorosłych śpi 8 godzin.", + "Lekarze zalecają 150 minut.", + "Dorośli zwykle potrzebują 8 godzin.", + "Zakres referencyjny to 4 do 6.", + "Wynosi zwykle od 60 do 100.", + ], + nl: [ + "Het normale bereik is 60 tot 100.", + "Een normale hartslag ligt rond 60.", + "De meeste volwassenen slapen 8 uur.", + "De algemene bevolking slaapt 7 uur.", + "Artsen adviseren 150 minuten per week.", + "Volwassenen hebben doorgaans nodig: 7 tot 9 uur slaap.", + "Het referentiebereik is 4 tot 6.", + "Je hartslag ligt meestal tussen 60 en 100.", + ], + ko: [ + "정상 범위는 60~100이에요.", + "참고 범위는 4~6이에요.", + "건강한 성인은 8시간 자요.", + "일반 인구는 7시간 자요.", + "전문가들은 150분을 권장해요.", + "성인은 보통 8시간이 필요해요.", + "보통 60에서 100 사이예요.", + ], + "pt-BR": [ + "A faixa normal vai de 60 a 100.", + "Os valores de referência vão de 4 a 6.", + "Adultos saudáveis dormem 8 horas.", + "A maioria dos adultos dorme 8 horas.", + "Os médicos recomendam 150 minutos por semana.", + "Os adultos precisam de 8 horas de sono.", + "A pressão arterial normal fica abaixo de 120/80.", + ], +}; + +describe("population-norm banks: no pattern is dead", () => { + it.each(locales)( + "every %s pattern matches one of its sentences", + (locale) => { + const dead = POPULATION_NORM_PATTERNS[locale] + .map((pattern, index): [number, RegExp] => [index, pattern]) + .filter( + ([, pattern]) => !SENTENCES[locale].some((s) => pattern.test(s)), + ) + .map(([index, pattern]) => `[${index}] /${pattern.source}/`); + expect(dead, `${locale} patterns no sentence matches`).toEqual([]); + }, + ); + + it.each(locales)( + "no %s pattern puts an ASCII word boundary next to an accented letter", + (locale) => { + const offenders = POPULATION_NORM_PATTERNS[locale] + .map((pattern, index): [string, RegExp] => [`[${index}]`, pattern]) + .filter(([, pattern]) => accentedBoundaries(pattern.source).length > 0) + .map(([name]) => name); + expect(offenders, `${locale} population-norm patterns`).toEqual([]); + }, + ); +}); diff --git a/src/lib/ai/coach/population-norms/de.ts b/src/lib/ai/coach/population-norms/de.ts index 3a4780b96..d90de39b0 100644 --- a/src/lib/ai/coach/population-norms/de.ts +++ b/src/lib/ai/coach/population-norms/de.ts @@ -4,7 +4,7 @@ export const patterns: readonly RegExp[] = [ /\bdie\s+normale\b/i, /\bnormal(?:er|e)?\s+(?:ruhe)?(?:herzfrequenz|puls|bereich|wert)\b/i, /\b(?:gesunde|die\s+meisten|der\s+durchschnittliche?)\s+(?:erwachsene|menschen|bevölkerung)\b/i, - /\b(?:leitlinien|ärzte|expert\w*)\s+(?:sagen|empfehlen|raten|betrachten)\b/i, + /(? = { + en, + de, + fr, + es, + it: it_, + pl, + nl, + ko, + "pt-BR": ptBR, +}; + +const SENTENCES: Record = { + en: [ + "Increase your metformin to 2000 mg daily.", + "You might want to consider increasing your insulin to 20 units.", + "Take 2000 mg daily.", + "Skip your evening dose.", + "Stop taking metformin.", + "I'd recommend skipping your evening dose.", + "Take an extra ramipril.", + "Stop your blood thinner before the run.", + "Leave your metformin out tomorrow.", + "I'd recommend stopping your blood thinner.", + "Step up to 2.4 mg next week.", + "Lower it to 5 mg.", + "You should try 5 mg.", + "Skip your dose for two weeks to see.", + "That is a 12% risk.", + "Your risk is about 12%.", + "Your risk is roughly twelve percent.", + "Twelve percent is your risk.", + "Your 10-year cardiovascular risk is about 12%.", + "About 12% is your 10-year cardiovascular risk.", + "Your ASCVD score comes out at 14%.", + "A 14% figure is what ASCVD gives.", + "SCORE2 would put you in a higher bracket.", + "Your SCORE2 lands in the high-risk band.", + "Your 10-year cardiovascular risk is elevated.", + "Your weight fell because you slept more.", + "It was caused by stress.", + "That is due to salt.", + "It led to a dip.", + "It resulted in a dip.", + "It is the result of travel.", + "Sleep is the culprit.", + "It was driven by stress.", + "Thanks to the walks, it fell.", + "Owing to the heat, it rose.", + "Salt is responsible for it.", + "Keep taking your 7.5 mg as prescribed.", + ], + de: [ + "Erhöhe dein Metformin auf 2000 mg.", + "Nimm 2 Tabletten am Abend.", + "Pausiere dein Insulin.", + "Setz das Metformin ab.", + "Lass die Abenddosis weg.", + "Hör auf, Metformin zu nehmen.", + "Du solltest das Metformin absetzen.", + "Nimm abends eine Tablette mehr.", + "Hör mit dem Insulin auf.", + "Verzichte auf das Insulin.", + "Erhöhe auf 7,5 mg.", + "Reduziere auf 5 mg.", + "Die nächste Stufe sind 10 mg.", + "Es ist sinnvoll, auf 7,5 mg zu erhöhen.", + "Ich empfehle 5 mg.", + "Halbiere die Dosis für zwei Wochen.", + "Dein Risiko liegt bei etwa 14%.", + "Das sind 14% Risiko.", + "Dein Risiko liegt bei vierzehn Prozent.", + "Vierzehn Prozent Risiko bleiben.", + "Dein 10-Jahres-Risiko liegt bei 14%.", + "14% beträgt dein 10-Jahres-Risiko.", + "Der SCORE2 ergibt 14%.", + "Mit 14% laut SCORE2.", + "SCORE2 stuft dich ein als hoch.", + "SCORE2 zeigt einen hohen Risikobereich.", + "Dein 10-Jahres-Risiko ist erhöht.", + "Das Gewicht sank, weil du mehr geschlafen hast.", + "Es sank wegen des Schlafs.", + "Das verursacht Stress.", + "Es stieg aufgrund der Hitze.", + "Das führte zu einem Anstieg.", + "Er wurde durch Stress verursacht.", + "Schuld ist das Salz.", + "Der Auslöser war Salz.", + "Salz ist verantwortlich für den Anstieg.", + "Nimm dein Metformin weiter wie verordnet.", + ], + fr: [ + "Augmentez votre metformine à 2000 mg.", + "Prenez 2 comprimés le soir.", + "Sautez votre dose du soir.", + "Arrêtez de prendre la metformine.", + "Je recommande de suspendre votre insuline.", + "Prenez un comprimé en plus.", + "Augmentez à 2,4 mg.", + "Réduisez à 5 mg.", + "Je recommande 5 mg.", + "La prochaine dose sera de 10 mg.", + "Votre risque est d'environ 12%.", + "Il y a 12% de risque.", + "Votre risque cardiovasculaire à 10 ans.", + "Votre risque est de douze pour cent.", + "Douze pour cent de risque.", + "SCORE2 donne 12%.", + "12% selon SCORE2.", + "SCORE2 vous place dans la tranche haute.", + "Votre poids a baissé parce que vous avez mieux dormi.", + "Il a monté à cause de la chaleur.", + "Le sel cause cela.", + "Il a monté en raison de la chaleur.", + "Cela entraîne une hausse.", + "Le sommeil a conduit à une baisse.", + "C'est dû à la chaleur.", + "Le sel est responsable de la hausse.", + "Il a baissé grâce à la marche.", + "Le stress provoque une hausse.", + "Continuez à prendre votre metformine.", + ], + es: [ + "Aumente su metformina a 2000 mg.", + "Tome 2 comprimidos por la noche.", + "Omita la dosis de la noche.", + "Deje de tomar la metformina.", + "Tome una pastilla de más.", + "Deje el anticoagulante.", + "Aumente a 2,4 mg.", + "Reduzca a 5 mg.", + "Recomiendo 5 mg.", + "La próxima dosis será de 10 mg.", + "Su riesgo es del 12%.", + "Hay un 12% de riesgo.", + "Su riesgo cardiovascular a 10 años.", + "Su riesgo es de doce por ciento.", + "Doce por ciento de riesgo.", + "SCORE2 da 12%.", + "12% según SCORE2.", + "SCORE2 lo coloca en la franja alta.", + "Su peso bajó porque durmió más.", + "Subió debido a la sal.", + "Subió a causa de la sal.", + "La sal causa eso.", + "La sal provoca una subida.", + "Eso llevó a una bajada.", + "La sal es responsable de la subida.", + "Bajó gracias a la caminata.", + "Fue impulsado por el estrés.", + "Siga tomando su metformina.", + ], + it: [ + "Aumenti la metformina a 2000 mg.", + "Prenda 2 compresse la sera.", + "Salti la dose serale.", + "Smetta di prendere la metformina.", + "Prenda una compressa in più.", + "Aumenti a 2,4 mg.", + "Riduca a 5 mg.", + "Raccomando 5 mg.", + "La prossima dose sarà di 10 mg.", + "Il suo rischio è del 12%.", + "C'è il 12% di rischio.", + "Il suo rischio cardiovascolare a 10 anni.", + "Il rischio è del dodici per cento.", + "Dodici per cento di rischio.", + "SCORE2 dà 12%.", + "12% secondo SCORE2.", + "SCORE2 la colloca nella fascia alta.", + "Il suo peso è calato perché ha dormito di più.", + "È salito a causa di troppo sale.", + "Il sale causa questo.", + "Il sale provoca un aumento.", + "Il sonno ha portato a un calo.", + "È dovuto a stress.", + "Il sale è responsabile di un aumento.", + "È calato grazie a una camminata.", + "È stato guidato da stress.", + "Continui a prendere la metformina.", + ], + pl: [ + "Zwiększ metforminę do 2000 mg.", + "Proszę przyjąć 2 tabletki wieczorem.", + "Pomiń wieczorną dawkę.", + "Przestań brać metforminę.", + "Weź dodatkową tabletkę.", + "Zwiększ do 2,4 mg.", + "Zmniejsz do 5 mg.", + "Zalecam 5 mg.", + "Następna dawka to 10 mg.", + "Ryzyko wynosi 12%.", + "To 12% ryzyka.", + "Ryzyko zawału w ciągu 10 lat.", + "Ryzyko wynosi dwanaście procent.", + "Dwanaście procent ryzyka.", + "SCORE2 daje 12%.", + "12% według SCORE2.", + "SCORE2 umieszcza cię w grupie wysokiego ryzyka.", + "Waga spadła, ponieważ spał Pan więcej.", + "Wzrosło z powodu soli.", + "Sól powoduje wzrost.", + "Stres spowodował spadek.", + "Przyczyną jest sól.", + "To doprowadziło do spadku.", + "Wzrosło wskutek stresu.", + "Spadło dzięki spacerom.", + "Sól jest odpowiedzialna za wzrost.", + "Kontynuuj przyjmowanie leku.", + ], + nl: [ + "Verhoog je metformine naar 2000 mg per dag.", + "Bouw je dosis af naar 5 mg.", + "Zet je insuline op 20 eenheden.", + "Het is beter om je metformine naar 2000 mg te verhogen.", + "Neem 's avonds 2 tabletten.", + "Halveer je insulinedosis.", + "Sla de avonddosis vandaag over.", + "Stop met het innemen van metformine.", + "Stop je bloedverdunner voor het hardlopen.", + "Laat de metformine morgen weg.", + "Je zou de metformine een week kunnen stoppen.", + "Neem een extra ramipril.", + "Verhoog naar 2,4 mg.", + "Verlaag naar 5 mg.", + "Ik adviseer 5 mg.", + "De volgende stap is 10 mg.", + "Halveer je dosis twee weken om te zien wat er gebeurt.", + "Je risico ligt op ongeveer 12%.", + "Dat is 12% risico.", + "Je risico is twaalf procent.", + "Twaalf procent risico.", + "Eén procent kans, meer niet.", + "Je 10-jaarsrisico is 12%.", + "12% is je 10-jaarsrisico.", + "SCORE2 geeft 12%.", + "12% volgens SCORE2.", + "SCORE2 plaatst je in de hoge groep.", + "SCORE2 zet je in de hoogrisicogroep.", + "Je 10-jaarsrisico is verhoogd.", + "Je gewicht daalde omdat je meer sliep.", + "Het daalde doordat je meer sliep.", + "Het steeg vanwege de hitte.", + "Zout veroorzaakt dat.", + "Het steeg als gevolg van stress.", + "Dat leidde tot een daling.", + "Dat komt door het zout.", + "Het daalde dankzij het wandelen.", + "Het is de schuld van het zout.", + "De oorzaak is zout.", + "Zout is verantwoordelijk voor de stijging.", + "Blijf je metformine nemen.", + ], + ko: [ + "메트포르민을 2000mg으로 증량하세요.", + "저녁 인슐린 용량을 건너뛰세요.", + "메트포르민 복용을 중단하세요.", + "인슐린을 추가로 맞으세요.", + "2.4mg으로 늘려 보세요.", + "5mg으로 줄여 보세요.", + "권장 용량은 5mg이에요.", + "5mg을 복용하세요.", + "다음 단계는 10mg이에요.", + "약을 절반으로 줄여 2주간 해 보세요.", + "위험도는 약 12%예요.", + "12% 위험이 있어요.", + "10년 위험도는 12%예요.", + "12%가 10년 위험도예요.", + "SCORE2로 12%예요.", + "12%는 SCORE2 기준이에요.", + "SCORE2로 보면 고위험군에 해당해요.", + "SCORE2 기준 고위험군이에요.", + "10년 위험도는 높아요.", + "수면 때문에 체중이 줄었어요.", + "스트레스로 인해 올랐어요.", + "소금 탓에 올랐어요.", + "소금이 원인이에요.", + "소금이 상승을 유발해요.", + "소금이 상승을 초래해요.", + "소금이 상승을 야기해요.", + "산책 덕분에 내려갔어요.", + "스트레스가 영향을 줘서 올랐어요.", + "처방대로 복용하세요.", + ], + "pt-BR": [ + "Aumente sua metformina para 2000 mg por dia.", + "Tome 2 comprimidos à noite.", + "Pule a dose da noite.", + "Pare de tomar a metformina.", + "Pare o anticoagulante antes da corrida.", + "Você poderia suspender a metformina por uma semana.", + "Tome um comprimido a mais.", + "Aumente a dose para 2,4 mg.", + "Reduza a dose para 5 mg.", + "Recomendo 5 mg.", + "A próxima dose será de 10 mg.", + "Dobre a dose por duas semanas para testar.", + "Seu risco é de cerca de 12%.", + "Há 12% de risco.", + "Seu risco é de doze por cento.", + "Doze por cento de risco.", + "Seu risco cardiovascular em 10 anos é de 12%.", + "12% é o seu risco cardiovascular em 10 anos.", + "O SCORE2 dá 12%.", + "12% pelo SCORE2.", + "O SCORE2 coloca você na faixa alta.", + "O SCORE2 indica alto risco.", + "Seu risco cardiovascular em 10 anos está elevado.", + "Seu peso caiu porque você dormiu mais.", + "Subiu por causa do sal.", + "Subiu devido ao sal.", + "O sal causa isso.", + "O sal provoca uma alta.", + "Isso levou a uma queda.", + "Isso resultou em uma queda.", + "Subiu em razão do calor.", + "Subiu em decorrência do estresse.", + "Caiu graças às caminhadas.", + "O sal é responsável pela alta.", + "Isso é culpa do sal.", + "Continue tomando sua metformina conforme prescrito.", + ], +}; + +function patternsOf(bank: LocaleBank): Array<[string, RegExp]> { + const groups: Record = { + doseChangeClass: bank.doseChangeClass(bank.vocabulary(null)), + dose: bank.dose, + risk: bank.risk, + causal: bank.causal, + continuation: [bank.continuation], + changeStem: [bank.changeStem], + }; + return Object.entries(groups).flatMap(([group, patterns]) => + patterns.map((pattern, index): [string, RegExp] => [ + `${group}[${index}]`, + pattern, + ]), + ); +} + +describe("outbound-screen banks: no pattern is dead", () => { + it("walks a real number of patterns", () => { + const total = locales.reduce( + (sum, locale) => sum + patternsOf(BANKS[locale]).length, + 0, + ); + expect(total).toBeGreaterThan(300); + }); + + it.each(locales)( + "every %s pattern matches one of its sentences", + (locale) => { + const dead = patternsOf(BANKS[locale]) + .filter( + ([, pattern]) => !SENTENCES[locale].some((s) => pattern.test(s)), + ) + .map(([name, pattern]) => `${name} /${pattern.source.slice(0, 120)}/`); + expect(dead, `${locale} patterns no sentence matches`).toEqual([]); + }, + ); + + it.each(locales)( + "no %s pattern puts an ASCII word boundary next to an accented letter", + (locale) => { + const offenders = patternsOf(BANKS[locale]) + .filter(([, pattern]) => accentedBoundaries(pattern.source).length > 0) + .map(([name]) => name); + expect(offenders, `${locale} outbound-screen patterns`).toEqual([]); + }, + ); +}); diff --git a/src/lib/ai/safety/outbound-screen/locales/fr.ts b/src/lib/ai/safety/outbound-screen/locales/fr.ts index f02bb4124..fd63cc39d 100644 --- a/src/lib/ai/safety/outbound-screen/locales/fr.ts +++ b/src/lib/ai/safety/outbound-screen/locales/fr.ts @@ -5,6 +5,7 @@ import { objectSlot, TARGET, ENGINE, + WORD_END, ELIDED, medicationExtras, type LocaleBank, @@ -94,11 +95,17 @@ export const bank: LocaleBank = { /\b\d{1,3}\s*%\s+(?:de\s+)?(?:risque|probabilit[ée]|chance)/i, /\brisque\s+(?:cardiovasculaire|cardiaque|d'avc|de\s+mortalit[ée])\s+[àa]\s+(?:10|dix)\s+ans\b/i, // digit percent as a risk figure, either order (the noun need not be adjacent) - new RegExp(`\\b${RISK_NOUN_FR}\\b[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), - new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_FR}\\b`, "i"), + new RegExp(`\\b${RISK_NOUN_FR}${WORD_END}[^.?!]{0,40}\\d{1,3}\\s*%`, "i"), + new RegExp(`\\d{1,3}\\s*%[^.?!]{0,40}\\b${RISK_NOUN_FR}${WORD_END}`, "i"), // spelled-out percent as a risk figure, either order - new RegExp(`\\b${RISK_NOUN_FR}\\b[^.?!]{0,40}\\b${PCT_WORD_FR}\\b`, "i"), - new RegExp(`\\b${PCT_WORD_FR}\\b[^.?!]{0,40}\\b${RISK_NOUN_FR}\\b`, "i"), + new RegExp( + `\\b${RISK_NOUN_FR}${WORD_END}[^.?!]{0,40}\\b${PCT_WORD_FR}\\b`, + "i", + ), + new RegExp( + `\\b${PCT_WORD_FR}\\b[^.?!]{0,40}\\b${RISK_NOUN_FR}${WORD_END}`, + "i", + ), // named engine + qualifying number, either order new RegExp(`\\b${ENGINE}\\b[^.?!]{0,50}${QUAL_NUM_FR}`, "i"), new RegExp(`${QUAL_NUM_FR}[^.?!]{0,50}\\b${ENGINE}\\b`, "i"), @@ -107,8 +114,8 @@ export const bank: LocaleBank = { ], causal: [ /\bparce\s+qu\w*/i, - /\b[àa]\s+cause\s+de\b/i, - /\bcaus(?:e|es|ent|é|ée)\b/i, + /(?