Files
timmy-talking-turd/src/domain.js
T
timmy 2094a07d9f
Quality gates / quality (pull_request) Successful in 18m50s
feat(portability): version the backup, migrate older files, and keep AI provenance (#35)
A backup is the person's own record and has to keep working as the AI fields evolve.

- Export stamps schema version 2 and carries the confirmed logs plus the training
  contribution receipts, including withdrawn tombstones; both are written through an
  allowlist so unknown keys and credentials never leave the device.
- Import accepts version 1 and 2. A version 1 file is upgraded, the notice names the
  version it came from, and entries that predate provenance stay null instead of being
  given a source they never recorded.
- A file from a newer Timmy is refused with an update instruction, malformed, oversized
  and over-limit files fail closed, and a refused import leaves the ledger unchanged.
- A log saved from a confirmed or corrected AI suggestion records its provenance
  (source, suggested visible fields, model, confirmedAt) and the entry sheet states it.
- Service worker shell cache v5 -> v6 so installed clients actually receive the change.

Evidence: tests/portability.test.js (7 unit cases), tests/portability.acceptance.mjs
(real browser: v1 upgrade, future refusal with unchanged ledger, provenance recorded,
export v2, round trip), release demo scenes with on-camera caption proof, and the
release builder gate portable_backup_restore.

Refs #35
2026-10-22 11:08:55 +00:00

262 lines
14 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.';
// 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,
provenance: sanitizeProvenance(input.provenance),
};
}
// Provenance is the only record of whether a confirmed log began as an AI suggestion. It is kept to
// an allowlist so a backup can never smuggle a credential, a prompt, or a raw model reply out of the
// device, and it is `null` for logs that never had one — a missing record is not a manual entry.
const PROVENANCE_SOURCES = Object.freeze(['ai-confirmed', 'ai-corrected']);
function sanitizeProvenance(value) {
if (!value || typeof value !== 'object') return null;
if (!PROVENANCE_SOURCES.includes(value.source)) return null;
const provenance = { source: value.source };
if (typeof value.model === 'string' && value.model.trim()) provenance.model = value.model.trim().slice(0, 80);
const suggestedType = Number(value.suggestedBristolType);
if (Number.isInteger(suggestedType) && suggestedType >= 1 && suggestedType <= 7) {
provenance.suggestedBristolType = suggestedType;
}
if (['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(value.suggestedColor)) {
provenance.suggestedColor = value.suggestedColor;
}
const confirmedAt = value.confirmedAt ? new Date(value.confirmedAt) : null;
if (confirmedAt && !Number.isNaN(confirmedAt.valueOf())) provenance.confirmedAt = confirmedAt.toISOString();
return provenance;
}
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 const LEDGER_EXPORT_VERSION = 2;
export function exportLedger(entries, exportedAt = new Date().toISOString()) {
return exportLedgerBackup({ entries, exportedAt });
}
// A backup is written through an allowlist, so only fields this build understands leave the device.
// Entries are re-sanitized on the way out as well as in: an unknown key on an entry is not part of
// the person's record, and a crafted key must never be laundered through a backup.
export function exportLedgerBackup({ entries = [], contributions = [], exportedAt = new Date().toISOString() } = {}) {
const list = Array.isArray(entries) ? entries.map(sanitizeEntry) : [];
const receipts = (Array.isArray(contributions) ? contributions : []).map(sanitizeContribution).filter(Boolean);
return JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: LEDGER_EXPORT_VERSION,
exportedAt,
entries: list,
contributions: receipts,
}, null, 2);
}
const CONTRIBUTION_STATES = Object.freeze(['queued-local', 'withdrawn']);
const CONTRIBUTION_ARTIFACTS = Object.freeze(['raw-image', 'confirmed-label']);
function isoOrNull(value) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? null : parsed.toISOString();
}
export function sanitizeContribution(input = {}) {
const contributionId = String(input?.contributionId || '').trim().slice(0, 120);
if (!contributionId) return null;
const state = CONTRIBUTION_STATES.includes(input.state) ? input.state : 'queued-local';
const receipt = {
contributionId,
entryId: String(input.entryId || '').trim().slice(0, 120),
consentVersion: String(input.consentVersion || '').trim().slice(0, 60),
consentedAt: isoOrNull(input.consentedAt),
labels: {
bristolType: Math.min(7, Math.max(1, Number(input.labels?.bristolType) || 4)),
color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.labels?.color) ? input.labels.color : 'brown',
},
state,
};
const withdrawnAt = isoOrNull(input.withdrawnAt);
if (state === 'withdrawn' && withdrawnAt) receipt.withdrawnAt = withdrawnAt;
if (state === 'withdrawn') {
receipt.artifacts = (Array.isArray(input.artifacts) ? input.artifacts : [])
.filter(artifact => CONTRIBUTION_ARTIFACTS.includes(artifact?.artifact))
.map(artifact => ({
artifact: artifact.artifact,
contributionId: String(artifact.contributionId || contributionId).slice(0, 120),
state: artifact.state === 'withdrawn' ? 'withdrawn' : 'active',
}));
}
return receipt;
}
export function importLedger(text) {
return importLedgerBackup(text).entries;
}
// A backup can be older than this build. Version 1 files record no provenance for their entries, so
// the migration leaves that field empty instead of guessing a source, and the caller is told which
// version the file came from.
export const LEDGER_IMPORT_LIMITS = Object.freeze({ maxTextLength: 16 * 1024 * 1024, maxEntries: 5000 });
export function importLedgerBackup(text) {
const raw = String(text ?? '');
if (raw.length > LEDGER_IMPORT_LIMITS.maxTextLength) {
throw new Error('That backup is too large for Timmy to read safely. Nothing was imported.');
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('That file is not readable JSON, so nothing was imported.');
}
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
throw new Error('This is not a supported Timmy backup.');
}
const fromVersion = Number(parsed.schemaVersion);
if (Number.isInteger(fromVersion) && fromVersion > LEDGER_EXPORT_VERSION) {
throw new Error(`This backup was written by a newer version of Timmy (version ${fromVersion}). Update Timmy before importing it; nothing was imported.`);
}
if (!Number.isInteger(fromVersion) || fromVersion < 1) {
throw new Error('This is not a supported Timmy backup.');
}
if (parsed.entries.length > LEDGER_IMPORT_LIMITS.maxEntries) {
throw new Error('That backup holds more logs than Timmy will import at once. Nothing was imported.');
}
const entries = parsed.entries.map(sanitizeEntry);
const rawContributions = Array.isArray(parsed.contributions) ? parsed.contributions : [];
const contributions = rawContributions.map(sanitizeContribution).filter(Boolean);
const migrated = fromVersion < LEDGER_EXPORT_VERSION;
const notices = migrated
? [`Restored ${entries.length} ${entries.length === 1 ? 'log' : 'logs'} from a version ${fromVersion} backup and upgraded it to version ${LEDGER_EXPORT_VERSION}.`]
: [];
// Skipping an unreadable receipt silently would tell someone their opt-in vanished. Say it.
const skipped = rawContributions.length - contributions.length;
if (skipped > 0) {
notices.push(`${skipped} training contribution ${skipped === 1 ? 'receipt' : 'receipts'} could not be read and ${skipped === 1 ? 'was' : 'were'} skipped.`);
}
return {
entries,
contributions,
fromVersion,
migrated,
notices,
exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : null,
};
}
export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]);