Quality gates / quality (pull_request) Successful in 2m38s
Deterministic escalation only fired on a narrow set of literal phrases, so real reports like "there is blood when I wipe" or "I have tarry stools" reached the chat provider without the fixed medical guidance. Expanding the suite found 18 of 18 checked clinical phrasings missing. - domain: cover bleeding reported by wiping/toilet paper/anatomy/mixed-in phrasing, black-tarry-dark-red stool, reported temperature and high temperature, abdominal-scoped severe pain in every natural word order, and past-tense/slang inability to pass gas. Near-miss wording that must stay quiet (paper cut, blood pressure, black beans, dark red wine, a normal temperature, knee or back pain, passed gas, no gas in the car) is pinned. - domain: a symptom reported in any truthy shape (1, "yes", "true") now counts as reported. Reading only the literal boolean `true` silently cleared a symptom, which is the one direction this boundary may never fail in. - hermes-agent-service: the gateway ledger sanitizer used the same strict check, so a browser-supplied ledger entry could have its symptom cleared before the escalation check. - tests/escalation-regression.test.js: per-flag positive and negative language coverage, model output can neither set nor clear a symptom/urgency/discomfort/note, and the escalation copy is byte-identical for every flag and under a missing, throwing, or malicious provider. Registered in the default unit gate. - scripts/record_release_demo.mjs: the recording now types the new bleeding and tarry phrasing, and seeds a fictional ledger whose note says "stool was tarry yesterday", then asserts in the browser that exactly one new Timmy bubble carries the app's own copy and that the turn never reaches Hermes. Three new captions are required release gates. Boundary unchanged: no new inference, no diagnosis, no image claim. Escalation only ever gets broader on user-reported symptoms.
140 lines
7.9 KiB
JavaScript
140 lines
7.9 KiB
JavaScript
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
|
||
const URGENT_MESSAGE = '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.';
|
||
// Bleeding is reported in more shapes than "blood in my stool": wiping, toilet paper, or the
|
||
// anatomy word the person actually uses. Each alternative still requires a bowel or wiping
|
||
// context, and the near-miss wording that must stay quiet ("a paper cut", "blood pressure")
|
||
// is pinned in tests/escalation-regression.test.js.
|
||
const BLEEDING_TEXT = new RegExp(`\\b(?:${[
|
||
'rectal bleeding',
|
||
'bleeding from (?:the )?(?:rectum|bottom|back passage|anus)',
|
||
'(?:blood|bleeding)\\b[^.!?]{0,30}\\b(?:when i wipe|when wiping|on (?:the )?toilet paper)',
|
||
'(?:blood|bleeding)\\b[^.!?]{0,20}\\b(?:from|in|out of) (?:my|the) (?:bottom|rectum|back passage|anus)',
|
||
'(?:blood|bleeding)\\b[^.!?]{0,20}\\b(?:in|mixed (?:in|into)) (?:my |the )?(?:poo|poop|stool|bowel movement)',
|
||
'(?:diarrh(?:ea|oea)|loose stools?)\\b[^.!?]{0,15}\\bwith blood',
|
||
'blood(?:y)? (?:in|on|with) (?:my |the )?(?:poo|poop|stool|bowel movement)',
|
||
'(?:stool|poop|poo) (?:has|contains|with) blood',
|
||
].join('|')})\\b`, 'i');
|
||
const URGENT_TEXT_PATTERNS = Object.freeze([
|
||
['blood', BLEEDING_TEXT],
|
||
['blackOrDarkRed', new RegExp(`\\b(?:${[
|
||
'(?:black|dark[- ]?red|tarry) (?:stool|stools|poop|poo|bowel movement|bathroom visit)s?',
|
||
'(?:stool|stools|poop|poo|bowel movement) (?:is|are|was|were|looks?|looked|seems?|came out) (?:very |pitch )?(?:black|dark[- ]?red|tarry)',
|
||
'(?:stool|stools|poop|poo) (?:that )?looks? like tar',
|
||
'black[^.!?]{0,15}\\btarry',
|
||
].join('|')})\\b`, 'i')],
|
||
['severePain', new RegExp(`\\b(?:${[
|
||
'(?:severe|constant|unrelenting|unbearable|worst) (?:abdominal|stomach|belly|tummy|abdomen|gut) pain',
|
||
'(?:abdominal|stomach|belly|tummy|abdomen|gut)[^.!?]{0,20}\\bpain\\b[^.!?]{0,20}\\b(?:severe|constant|unrelenting|unbearable|worse)\\b',
|
||
'(?:severe|constant|unrelenting|unbearable|worst)\\b[^.!?]{0,40}\\bpain\\b[^.!?]{0,40}\\b(?:abdominal|stomach|belly|tummy|abdomen|gut)\\b',
|
||
'\\bpain\\b[^.!?]{0,25}\\b(?:abdominal|stomach|belly|tummy|abdomen|gut)\\b[^.!?]{0,25}\\b(?:severe|constant|unrelenting|unbearable|worse)\\b',
|
||
].join('|')})\\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/i],
|
||
['fever', new RegExp(`\\b(?:${[
|
||
'fever(?:ish)?',
|
||
'running a (?:high )?temperature',
|
||
'high temperature',
|
||
'(?:temp|temperature)\\b[^.!?]{0,15}\\b(?:of |is |was |at |hit |reached )?(?:1\\d\\d(?:\\.\\d+)?|3[89](?:\\.\\d+)?|4[01](?:\\.\\d+)?)\\b',
|
||
].join('|')})\\b`, 'i')],
|
||
['cannotPassGas', new RegExp(`\\b(?:${[
|
||
"(?:cannot|can['’]?t|cant|unable to|not able to|not|never|stopped|have not|haven['’]?t|did not|didn['’]?t)\\b[^.!?]{0,30}\\bpass(?:ing|ed)? (?:any )?(?:gas|wind|fart)\\b",
|
||
'\\bno (?:gas|wind)\\b[^.!?]{0,20}\\b(?:passing|passed|coming)\\b',
|
||
'\\b(?:cannot|can[\'’]?t|not)\\b[^.!?]{0,15}\\bfart(?:ing)?\\b',
|
||
].join('|')})\\b`, 'i')],
|
||
]);
|
||
|
||
// A reported symptom is a claim. Anything truthy-ish is read as reported; only an explicit clear
|
||
// (false, 0, "no", blank, or absent) is read as not reported. Dropping a value because it was not
|
||
// the literal boolean `true` would silently clear a symptom the person reported, which is the one
|
||
// direction this boundary may never fail in.
|
||
export function isReportedSymptom(value) {
|
||
if (value === true) return true;
|
||
if (typeof value === 'number') return value === 1;
|
||
if (typeof value === 'string') return /^(?:true|yes|y|1)$/i.test(value.trim());
|
||
return false;
|
||
}
|
||
|
||
export function bucketForBristolType(type) {
|
||
const value = Number(type);
|
||
if (value === 1 || value === 2) return 'constipation';
|
||
if (value === 3 || value === 4) return 'typical';
|
||
if (value >= 5 && value <= 7) return 'loose';
|
||
return 'unknown';
|
||
}
|
||
|
||
export function detectUrgentFlags(symptoms = {}) {
|
||
const flags = URGENT_KEYS.filter((key) => isReportedSymptom(symptoms[key]));
|
||
return {
|
||
urgent: flags.length > 0,
|
||
flags,
|
||
message: flags.length
|
||
? URGENT_MESSAGE
|
||
: 'No urgent symptom was selected. This tracker is not a diagnosis; seek care whenever you are worried or symptoms persist.',
|
||
};
|
||
}
|
||
|
||
export function detectUrgentText(text = '') {
|
||
const flags = URGENT_TEXT_PATTERNS.filter(([, pattern]) => pattern.test(String(text))).map(([key]) => key);
|
||
return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' };
|
||
}
|
||
|
||
export function hasUrgentLedgerContext(entries = []) {
|
||
return Array.isArray(entries) && entries.some(entry => detectUrgentFlags(entry?.symptoms).urgent || detectUrgentText(entry?.note).urgent);
|
||
}
|
||
|
||
export const urgentChatMessage = `Pause and get medical help. ${URGENT_MESSAGE}`;
|
||
|
||
export function buildTimmySummary(entries = []) {
|
||
if (!entries.length) return 'No logs yet. Add one when you are ready and I’ll summarize the pattern—not diagnose it.';
|
||
const counts = entries.reduce((acc, entry) => {
|
||
const bucket = bucketForBristolType(entry.bristolType);
|
||
acc[bucket] = (acc[bucket] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
const pieces = [`${entries.length} ${entries.length === 1 ? 'log' : 'logs'}`];
|
||
if (counts.typical) pieces.push(`${counts.typical} typical`);
|
||
if (counts.constipation) pieces.push(`${counts.constipation} on the firm side`);
|
||
if (counts.loose) pieces.push(`${counts.loose} on the loose side`);
|
||
return `${pieces.join(' · ')}. Patterns matter more than one entry. You choose what to eat; I only help you notice changes.`;
|
||
}
|
||
|
||
export function sanitizeEntry(input = {}) {
|
||
const symptoms = {};
|
||
for (const key of URGENT_KEYS) symptoms[key] = input.symptoms?.[key] === true;
|
||
const bristolType = Math.min(7, Math.max(1, Number(input.bristolType) || 4));
|
||
return {
|
||
id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`),
|
||
occurredAt: new Date(input.occurredAt || Date.now()).toISOString(),
|
||
bristolType,
|
||
color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.color) ? input.color : 'brown',
|
||
urgency: Math.min(4, Math.max(0, Number(input.urgency) || 0)),
|
||
discomfort: Math.min(4, Math.max(0, Number(input.discomfort) || 0)),
|
||
note: String(input.note || '').trim().slice(0, 500),
|
||
photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '',
|
||
symptoms,
|
||
};
|
||
}
|
||
|
||
export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) {
|
||
if (width < 640 || height < 480) return 'Move a little closer or use a higher-resolution photo. The image stays on this device.';
|
||
if (brightness < 0.12) return 'Add more light before saving. Timmy only checks whether the photo is usable.';
|
||
if (brightness > 0.95) return 'Reduce glare before saving. Timmy only checks whether the photo is usable.';
|
||
return 'Ready for your review. Choose the matching Bristol form yourself; Timmy does not interpret the picture.';
|
||
}
|
||
|
||
export function exportLedger(entries, exportedAt = new Date().toISOString()) {
|
||
return JSON.stringify({
|
||
product: 'Timmy the Talking Turd',
|
||
schemaVersion: 1,
|
||
exportedAt,
|
||
entries: Array.isArray(entries) ? entries : [],
|
||
}, null, 2);
|
||
}
|
||
|
||
export function importLedger(text) {
|
||
const parsed = JSON.parse(text);
|
||
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.');
|
||
return parsed.entries.map(sanitizeEntry);
|
||
}
|
||
|
||
export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]);
|