timmy-talking-turd/src/domain.js
Timmy 3ae2a72b67
All checks were successful
Quality gates / quality (pull_request) Successful in 1m19s
fix: recognize past-tense vomiting in chat safety
2026-08-20 17:04:10 +00:00

96 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.';
const URGENT_TEXT_PATTERNS = Object.freeze([
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?) (?:black|dark[- ]?red))s?\b/i],
['severePain', /\b(?:severe|constant|unrelenting) (?:abdominal|stomach|belly) pain\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)?|hurl(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|spew(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|the) cookies|los(?:e|t|ing|es) (?:my|the) lunch|emesis)\b/i],
['fever', /\bfever(?:ish)?\b/i],
['cannotPassGas', /\b(?:cannot|can[']?t|cant|unable to|not able to) pass gas\b/i],
]);
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) => symptoms[key] === true);
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 Ill 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]);