Expand deterministic symptom escalation regression suite #62
|
|
@ -3,12 +3,29 @@ const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Co
|
|||
// Deterministic red-flag language table. Every match escalates and nothing that
|
||||
// consumes this table can weaken it; the regression suite pins both the positive
|
||||
// phrases and the idiomatic non-medical phrasings that must stay out.
|
||||
//
|
||||
// Context-handling contract (see tests/symptom-escalation.regression.test.js):
|
||||
// positives key on the clinical vocabulary itself, and nonclinical controls are
|
||||
// expressed as structured exclusions around it (named-infection compounds,
|
||||
// figurative idioms, concrete objects). Continuation wording after a symptom
|
||||
// word must NEVER be narrowed through an allowlist — arbitrary natural
|
||||
// continuations ("right now", "is 103", "started this morning") keep escalating.
|
||||
const URGENT_TEXT_PATTERNS = Object.freeze([
|
||||
['blood', /\b(?:rectal bleeding|bleeding from (?:the |my |his |her |their )?(?:rectum|bottom)|rectal bleed(?:ing)?|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:has|contains|is bloody|looks bloody)|bloody (?:stool|poop|bowel movement))\b/i],
|
||||
// Blood: explicit clinical objects/shapes only, so ordinary stool sentences
|
||||
// ("my stool has been normal", "the poop contains seeds") stay silent while
|
||||
// every blood-mention shape (in/on/with/has/contains/looks bloody) escalates.
|
||||
['blood', /\b(?:rectal bleeding|rectal bleed(?:ing)?|bleeding from (?:the |my |his |her |their )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the |his |her |their )?(?:stool|poop|bowel movement|rectum)|(?:bloody|blood-streaked) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:with blood|has blood|contains blood|has (?:blood clots|bloody streaks)|is bloody|looks bloody|looks like blood))\b/i],
|
||||
['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?(?: like)?|appears?|seems?|was) (?:a )?(?:very )?(?:really )?(?:black|dark[- ]?red))s?\b/i],
|
||||
['severePain', /\b(?:(?:severe|constant|unrelenting) (?:abdominal|stomach|belly) pain|(?:severe|unrelenting) pain (?:in|around) my (?:abdomen|belly))\b/i],
|
||||
// Severe pain: severity adjective + organ noun, with any determiner (or none)
|
||||
// and optional location qualifiers between the preposition and the organ —
|
||||
// never a specific possessive.
|
||||
['severePain', /\b(?:(?:severe|constant|unrelenting|intense|excruciating) (?:abdominal|stomach|belly) pain|(?:severe|constant|unrelenting|intense|excruciating) pain (?:in|around) (?:the |my |his |her |your |their |this )?(?:(?:lower|upper|left|right) )*(?:abdomen|belly))\b/i],
|
||||
['vomiting', /\b(?:vomit(?:ing|ed|s)?|throw(?:ing|s)? up|threw up|thrown up|puk(?:e|ed|ing|es)|barf(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|your|his|her|our|their|the) cookies|los(?:e|t|ing|es) (?:my|your|his|her|our|their|the) lunch|(?:i|we|you|he|she|they|someone) (?:(?:have|had|just|already|recently|am|are|was|were|kept) )?(?:hurl(?:s|ed|ing)?|spew(?:s|ed|ing)?)(?=\s*(?:[.!?]|$|again\b|twice\b|all night\b))|emesis)\b(?!\s+(?:my|your|his|her|our|their)\s+(?:hands|arms)|\s+confetti\b|\s+(?:a |the )?(?:scaffold|wall|barricade|tent)\b)/i],
|
||||
['fever', /\bfever(?:ish)?\b(?:$|[.!?]|\s+(?:of|and|or|since|all|last|this|for|with|tonight|today|yesterday|again|plus|but)\b|\s*$)/i],
|
||||
// Fever: the bare clinical word escalates with any continuation. Exclusions
|
||||
// are structured nonclinical contexts only — topic compounds
|
||||
// (malaria/yellow/dengue/cabin), the fever tree, and figurative
|
||||
// "feverish about"/"feverish with excitement".
|
||||
['fever', /(?<!\b(?:malaria|yellow|dengue|cabin)\s)\bfever(?:ish)?\b(?!\s+tree\b)(?!\s+about\b)(?!\s+with\s+excitement\b)/i],
|
||||
['cannotPassGas', /\b(?:cannot|can['’]?t|cant|unable to|not able to) pass (?:any )?gas\b/i],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import {
|
|||
resolveHermesAgentConfig,
|
||||
runHermesCliTurn,
|
||||
} from '../src/hermes-agent-service.js';
|
||||
import { detectUrgentText } from '../src/domain.js';
|
||||
import { detectUrgentText, urgentSymptomCopy } from '../src/domain.js';
|
||||
|
||||
// Pinned literally so any drift in the authoritative copy fails this suite.
|
||||
const URGENT_MESSAGE_COPY = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.';
|
||||
assert.equal(urgentSymptomCopy.flagsMessage, URGENT_MESSAGE_COPY);
|
||||
|
||||
const origin = 'http://127.0.0.1:4173';
|
||||
const configured = () => resolveHermesAgentConfig({
|
||||
|
|
@ -50,6 +54,49 @@ test('every red flag phrase is intercepted at the service boundary with zero Her
|
|||
}
|
||||
});
|
||||
|
||||
// The review-reported false negatives plus their case/punctuation/contraction
|
||||
// variants. Each phrase is proven twice: once against the detector and once
|
||||
// through the authoritative service gate, which must override before any
|
||||
// Hermes call happens.
|
||||
const REVIEW_REGRESSION_PHRASES = [
|
||||
'I have a fever right now',
|
||||
'I HAVE A FEVER RIGHT NOW!',
|
||||
'my fever is 103',
|
||||
'My fever is 103.',
|
||||
'my fever is 103.5 degrees',
|
||||
'fever started this morning',
|
||||
'Fever started this morning?',
|
||||
'severe pain in the abdomen.',
|
||||
'Severe pain in THE abdomen!!',
|
||||
'severe pain around the abdomen',
|
||||
"I've had a fever since monday",
|
||||
];
|
||||
|
||||
test('review-reported false negatives escalate at the detector for every variant', async () => {
|
||||
for (const phrase of REVIEW_REGRESSION_PHRASES) {
|
||||
const result = detectUrgentText(phrase);
|
||||
assert.equal(result.urgent, true, JSON.stringify(phrase));
|
||||
assert.equal(result.message, URGENT_MESSAGE_COPY, phrase);
|
||||
}
|
||||
});
|
||||
|
||||
test('review-reported false negatives are intercepted at the service boundary with zero Hermes calls', async () => {
|
||||
for (const phrase of REVIEW_REGRESSION_PHRASES) {
|
||||
const calls = [];
|
||||
const service = createHermesAgentService({
|
||||
config: configured(),
|
||||
randomToken: () => 'review-regression-cookie',
|
||||
runTurn: async input => { calls.push(input); return { reply: 'unsafe upstream reply', sessionId: 'unsafe-session' }; },
|
||||
});
|
||||
await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' });
|
||||
const result = await service.chat({ origin, cookieToken: 'review-regression-cookie', payload: { message: phrase, ledger: [] } });
|
||||
assert.equal(calls.length, 0, `${JSON.stringify(phrase)} must never reach Hermes`);
|
||||
assert.equal(result.safetyOverride, true, phrase);
|
||||
assert.match(result.reply, /medical help/i, phrase);
|
||||
assert.doesNotMatch(result.reply, /unsafe upstream/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmed ledger symptoms and note language override chat before Hermes is called', async () => {
|
||||
for (const ledger of [
|
||||
[{ bristolType: 4, symptoms: { blood: true } }],
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ const POSITIVE_EXPRESSIONS = {
|
|||
'constant belly pain all day',
|
||||
'unrelenting abdominal pain',
|
||||
'It is constant stomach pain',
|
||||
// Ordinary determiners and regions must not defeat the flag (issue review).
|
||||
'severe pain in the abdomen',
|
||||
'Severe pain in THE abdomen!!',
|
||||
'severe pain around the abdomen.',
|
||||
'severe pain around my belly',
|
||||
'severe pain in lower right abdomen',
|
||||
'severe abdominal pain.',
|
||||
],
|
||||
vomiting: [
|
||||
'I threw up',
|
||||
|
|
@ -84,6 +91,27 @@ const POSITIVE_EXPRESSIONS = {
|
|||
'running a fever since last night',
|
||||
'fever of 102',
|
||||
'I feel feverish',
|
||||
// Ordinary symptom phrasing the continuation-word allowlist missed (issue review).
|
||||
'I have a fever right now',
|
||||
'I HAVE A FEVER RIGHT NOW!',
|
||||
'my fever is 103',
|
||||
'My fever is 103.',
|
||||
'my fever is 103.5 degrees',
|
||||
'fever started this morning',
|
||||
'Fever started this morning?',
|
||||
'fever since this morning',
|
||||
"I've had a fever all day",
|
||||
"I've had a fever since monday, and I feel awful.",
|
||||
'fever, chills, and body aches',
|
||||
'fever; vomiting; dehydration',
|
||||
'fever with a rash',
|
||||
'fever but no other symptoms',
|
||||
'fever plus chills',
|
||||
'do I have a fever? yes.',
|
||||
'fever came back tonight',
|
||||
'fever went away, then returned',
|
||||
'fever again after lunch',
|
||||
'fever for three days',
|
||||
],
|
||||
cannotPassGas: [
|
||||
'I cannot pass gas',
|
||||
|
|
@ -94,6 +122,15 @@ const POSITIVE_EXPRESSIONS = {
|
|||
],
|
||||
};
|
||||
|
||||
// Phrasings main already escalated that the expanded suite must never regress.
|
||||
const PRESERVED_EXPRESSIONS = {
|
||||
blood: [
|
||||
'there is poop with blood',
|
||||
'stool with blood this morning',
|
||||
'bowel movement with blood',
|
||||
],
|
||||
};
|
||||
|
||||
const NEGATIVE_EXPRESSIONS = [
|
||||
// Established non-urgent controls.
|
||||
'My blood pressure was checked',
|
||||
|
|
@ -123,6 +160,12 @@ const NEGATIVE_EXPRESSIONS = [
|
|||
'yellow fever outbreak in history class',
|
||||
'dengue fever is studied in class',
|
||||
'The fever tree is a plant',
|
||||
'The kids were feverish with excitement before the trip.',
|
||||
'Cabin fever is real during long winters.',
|
||||
// Ordinary stool sentences that must stay non-urgent (no blood words present).
|
||||
'My stool has been normal this week',
|
||||
'The poop contains seeds',
|
||||
'The bowel movement contains fiber',
|
||||
// Pass through other things than gas.
|
||||
'I cannot pass the salt',
|
||||
'unable to pass the exam',
|
||||
|
|
@ -176,7 +219,20 @@ test('escalates every positive expression in the regression matrix and reports i
|
|||
total += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(total, 56, 'regression matrix size is pinned');
|
||||
assert.equal(total, 82, 'regression matrix size is pinned');
|
||||
});
|
||||
|
||||
test('phrasings escalated on main are never lost while the patterns expand', () => {
|
||||
let preserved = 0;
|
||||
for (const [key, expressions] of Object.entries(PRESERVED_EXPRESSIONS)) {
|
||||
for (const expression of expressions) {
|
||||
const result = detectUrgentText(expression);
|
||||
assert.equal(result.urgent, true, JSON.stringify(expression));
|
||||
assert.ok(result.flags.includes(key), `${JSON.stringify(expression)} must map to ${key}, got ${result.flags}`);
|
||||
preserved += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(preserved, 3, 'preserved-behavior list is pinned');
|
||||
});
|
||||
|
||||
test('keeps ordinary language out of escalation across the negative matrix', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user