fix(ai): stop the outbound risk guard from blocking this app's own scores

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 09:10:07 +02:00
1 parent 35a3842bba
commit c256d945b3
3 files changed
+84 -2

No files matched your search

@@ -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 =
@@ -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<Locale, string> = {
+8 -2
View File
@@ -215,8 +215,14 @@ const RISK_PATTERNS: Record<Locale, readonly RegExp[]> = {
/\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,