From c256d945b3a34bd4f1d3fc335df4a2b3cb064b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 23 Jul 2026 09:10:07 +0200 Subject: [PATCH] fix(ai): stop the outbound risk guard from blocking this app's own scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The named-risk-engine pattern in the outbound safety screen used score2? (the 2 optional), so it matched bare "score" too — any mention of one of this app's own computed, documented scores (Sleep Score, Readiness Score, Health Score, Recovery Score, Stress Score, Strain Score) tripped the bank meant only to catch a fabricated named clinical-risk calculator (SCORE2, Framingham, ASCVD, QRISK). Require the literal "SCORE2" instead. The other risk patterns — percentage-shaped risk claims and the other three named engines — are untouched, so a genuinely fabricated risk figure or a named risk-calculator claim still blocks. Closes #587. --- .../ai/coach/__tests__/outbound-guard.test.ts | 28 +++++++++++ .../safety/__tests__/outbound-screen.test.ts | 48 +++++++++++++++++++ src/lib/ai/safety/outbound-screen.ts | 10 +++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/coach/__tests__/outbound-guard.test.ts b/src/lib/ai/coach/__tests__/outbound-guard.test.ts index 6f4e20214..e429fc47a 100644 --- a/src/lib/ai/coach/__tests__/outbound-guard.test.ts +++ b/src/lib/ai/coach/__tests__/outbound-guard.test.ts @@ -67,6 +67,34 @@ describe("screenCoachReply — risk score", () => { }); }); +// Issue #587 — a grounded Sleep Score explanation was replaced with the +// generic clinical-risk refusal because the named-risk-engine pattern +// matched bare "score", not just the SCORE2 risk calculator it was meant to +// name. +describe("screenCoachReply — issue #587 ordinary wellness-score wording", () => { + const allowed = [ + "Your Sleep Score is based on sleep duration and stage balance — it's currently in a healthy range for you.", + "Your Readiness Score is lower than your recent baseline, likely from the shorter sleep window.", + "This Health Score is based on the available tracked pillars: BP, weight, mood, and compliance.", + ]; + for (const reply of allowed) { + it(`allows: ${reply.slice(0, 40)}`, () => { + const d = screenCoachReply(reply, "en"); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + }); + } + + it("still blocks a reply naming the SCORE2 risk engine", () => { + const d = screenCoachReply( + "Based on SCORE2, your cardiovascular risk profile looks elevated.", + "en", + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + }); +}); + describe("screenCoachReply — clean replies", () => { it("passes a grounded, non-prescriptive reply", () => { const reply = diff --git a/src/lib/ai/safety/__tests__/outbound-screen.test.ts b/src/lib/ai/safety/__tests__/outbound-screen.test.ts index 27f66b697..be4a54b3a 100644 --- a/src/lib/ai/safety/__tests__/outbound-screen.test.ts +++ b/src/lib/ai/safety/__tests__/outbound-screen.test.ts @@ -107,6 +107,54 @@ describe("screenModelOutput — risk-score, all six locales", () => { } }); +// ── issue #587 — bare "score" is not a fabricated clinical risk score ───── + +/** + * The named-risk-engine pattern used `score2?` (the `2` optional), so it + * matched bare "score" too — any mention of one of THIS APP's own computed, + * documented scores (Sleep Score, Readiness Score, Health Score, …) tripped + * the fabricated-risk-engine bank meant for SCORE2/Framingham/ASCVD/QRISK. + */ +const WELLNESS_SCORE_CLEAN: readonly string[] = [ + "Your Sleep Score is based on sleep duration and stage balance.", + "Your Readiness Score is lower than your recent baseline.", + "This Health Score is based on the available tracked pillars.", + "Your Recovery Score and Stress Score both moved this week.", + "The Strain Score reflects yesterday's training load.", +]; + +describe("screenModelOutput — issue #587 ordinary wellness-score wording", () => { + for (const text of WELLNESS_SCORE_CLEAN) { + it(`passes: ${text}`, () => { + const d = screenModelOutput(text, "en", CONVERSATIONAL_CONTRACTS); + expect(d.block).toBe(false); + expect(d.reason).toBeNull(); + }); + } + + it("still blocks the literal named risk engine SCORE2", () => { + const d = screenModelOutput( + "Based on SCORE2, your cardiovascular risk profile looks elevated.", + "en", + CONVERSATIONAL_CONTRACTS, + ); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + }); + + it("still blocks Framingham, ASCVD, and QRISK by name", () => { + for (const engine of [ + "your Framingham result was concerning", + "the ASCVD estimate points to elevated risk", + "your QRISK figure came back high", + ]) { + const d = screenModelOutput(engine, "en", CONVERSATIONAL_CONTRACTS); + expect(d.block).toBe(true); + expect(d.reason).toBe("risk_score"); + } + }); +}); + // ── causal claims (GROUND RULE 12) ─────────────────────────────────────── const CAUSAL_VIOLATIONS: Record = { diff --git a/src/lib/ai/safety/outbound-screen.ts b/src/lib/ai/safety/outbound-screen.ts index 899f5dd78..1be3a7210 100644 --- a/src/lib/ai/safety/outbound-screen.ts +++ b/src/lib/ai/safety/outbound-screen.ts @@ -215,8 +215,14 @@ const RISK_PATTERNS: Record = { /\b\d{1,3}\s*%\s+(?:risk|chance|probability|likelihood)\b/i, /\b(?:risk|chance|probability|likelihood)\s+(?:of|is|at)\s+(?:about\s+|roughly\s+|~)?\d{1,3}\s*%/i, /\b(?:10[- ]year|ten[- ]year|lifetime)\s+(?:cardiovascular|cardiac|heart|stroke|mortality|cvd|ascvd)\s+risk\b/i, - // Named risk engines are fabrications on any surface — the server runs none. - /\b(?:framingham|ascvd|score2?|qrisk)\b/i, + // Named risk engines are fabrications on any surface — the server runs + // none. `score2?` (the `2` optional) was the #587 bug: it matched bare + // "score" too, so every mention of this app's OWN computed scores + // (Sleep Score, Readiness Score, Health Score, …) tripped the named- + // engine bank. SCORE2 is a specific clinical risk calculator (like + // Framingham/ASCVD/QRISK) — only the literal name is a fabrication + // signal; the bare word "score" carries none on its own. + /\b(?:framingham|ascvd|score2|qrisk)\b/i, ], de: [ /\brisiko\s+(?:von|bei|liegt\s+bei)\s+(?:etwa\s+|ungefähr\s+|~)?\d{1,3}\s*%/i,