diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index 830a5e6..96c4c78 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -54,6 +54,7 @@ jobs: npm run test:sleek npm run test:manual-fallback npm run test:provisional + npm run test:followup - name: Dependency audit run: npm audit --audit-level=high - name: Syntax checks diff --git a/PRODUCT.md b/PRODUCT.md index 0f9182b..b41978e 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -29,6 +29,7 @@ Most stool trackers are sterile diaries. Most AI-health demos overclaim. Timmy c - Smart free-text chat with local fallback and an optional fully tool-capable Hermes backend behind exact-origin authentication. - Portable JSON export and delete-all control. - A Timmy voice that is playful about logging and serious about red flags. +- A short user-reported follow-up after a hard/lumpy (Bristol 1–2) log: difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, and hydration. Mild reports receive proportionate non-diagnostic self-care; a confirmed red flag routes into the existing deterministic escalation, and dark brown is never silently escalated as black stool. ## Honest AI boundary diff --git a/README.md b/README.md index c81f2ff..3c14257 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ npm audit --audit-level=high - Free-text smart chat with real server-side Hermes session continuity and local fallback - Authenticated, same-origin, bounded browser-to-agent gateway with no browser-side credentials - Red-flag symptom escalation +- A short user-reported follow-up after a hard/lumpy (Bristol 1–2) log — difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, and hydration. Mild reports get concise non-diagnostic self-care that never claims a photo ruled out bleeding; a confirmed red flag sets the same deterministic escalation the manual checkboxes use - Manual fallback that never depends on AI: when the vision worker is offline or disabled, no AI or consent control is offered and manual Bristol entry stays one action away - Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies - Local browser ledger, calendar, pattern summary, JSON portability, and delete-all diff --git a/app.js b/app.js index 0306d4e..0830a20 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgen import { mergeVisualSuggestion } from './src/analysis.js'; import { createDraft, draftNotice, isDraftDirty } from './src/draft.js'; import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js'; +import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp, needsHardStoolFollowUp } from './src/followup.js'; import { visionReadiness } from './src/vision-readiness.js'; const runtimeConfig = { @@ -36,13 +37,14 @@ let chatError = ''; let chatMessages = [{ role: 'timmy', text: 'Ask me about your confirmed logs, visible patterns, privacy, or how Timmy works.' }]; let draftResumed = false; let form = createDraft(); +let followUpAnswers = createFollowUpAnswers(); function draftBanner(){ if(!draftResumed||!isDraftDirty(form))return ''; return `
Draft kept in this session: ${esc(draftNotice(form))}
`; } function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()} -function clearDraft(){form=createDraft();draftResumed=false;suggestionState=createSuggestionState(null)} +function clearDraft(){form=createDraft();draftResumed=false;suggestionState=createSuggestionState(null);followUpAnswers=createFollowUpAnswers()} function resetSuggestion(){aiSuggestion=null;suggestionState=createSuggestionState(null)} // A single place where provisional, confirmed, and corrected AI states become visible copy. function provenanceBlock(){ @@ -52,6 +54,32 @@ function provenanceBlock(){ } function refreshProvenance(){const copy=provenanceCopy(suggestionState,form);document.querySelectorAll('.provenance-note').forEach(node=>{node.textContent=copy.detail||''});const container=document.querySelector('.ai-provenance');if(container&©.aria)container.setAttribute('aria-label',copy.aria)} +// Hard-stool follow-up (field finding #67). After a Type 1-2 entry Timmy asks a short +// user-reported follow-up: soreness, difficulty passing, hydration, and black/tarry +// character are never inferred from pixels. A confirmed red-flag answer writes the same +// symptom flag the rest of the app already escalates on, so the safety path stays one path. +function followUpState(){return {...followUpAnswers}} +function syncFollowUpFlags(){for(const question of HARD_STOOL_QUESTIONS){if(!question.urgentFlag)continue;const box=document.querySelector(`[data-symptom="${question.urgentFlag}"]`);if(box)box.checked=form.symptoms[question.urgentFlag]===true}} +function followUpOutput(verdict){ + if(verdict.urgent)return `
That answer sets the red-flag check below.

Timmy is not diagnosing you here. The answer you gave is what triggers this, and the check below is the same one manual logging uses.

`; + if(!verdict.answered)return '

Answer what applies. Anything you skip stays unanswered, and none of it is guessed from your photo.

'; + return `
Mild report — general self-care, not a diagnosis.

${esc(verdict.note)}

${esc(verdict.seekCare)}

`; +} +function followUpBlock(){ + if(!needsHardStoolFollowUp(form))return ''; + const state=followUpState(); + const rows=HARD_STOOL_QUESTIONS.map(question=>`
${esc(question.label)}${question.urgentFlag?'Also sets the red-flag check below.':''}
`).join(''); + return `

Type ${form.bristolType} — a few quick questions

Timmy never guesses these from your photo. You report them, and you can skip any of them.

${rows}
${followUpOutput(evaluateHardStoolFollowUp(state))}
`; +} +function refreshFollowUp(){ + const state=followUpState(); + if(document.querySelector('[data-followup-block]')){ + document.querySelectorAll('[data-followup]').forEach(button=>{const selected=state[button.dataset.followup]===(button.dataset.answer==='yes');button.classList.toggle('selected',selected);button.setAttribute('aria-pressed',String(selected))}); + const output=document.querySelector('#followup-output');if(output)output.innerHTML=followUpOutput(evaluateHardStoolFollowUp(state)); + } + const urgent=detectUrgentFlags(form.symptoms);const box=document.querySelector('#urgent-box');if(box)box.innerHTML=urgent.urgent?alertHtml(urgent.message):''; +} + function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); } function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } } function saveEntries() { localStorage.setItem(STORE, JSON.stringify(entries)); } @@ -181,14 +209,14 @@ function showLogStep(step){ function stepBody(step){ if(step===1)return `${provenanceBlock()||'

Choose the closest match yourself. Timmy never treats a suggestion as fact.

'}
${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>``).join('')}
`; if(step===2)return `${form.photoDataUrl?`Private entry preview`:''}${photoHint?`

${esc(photoHint)}

`:''}${suggestionState.suggestion?`

${esc(provenanceCopy(suggestionState,form).detail)}

`:'

Manual-mode photos stay in this browser and receive quality checks only.

'}
`; - const urgent=detectUrgentFlags(form.symptoms);return `

Select anything you have now. This is where Timmy stops joking.

${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>``).join('')}
${urgent.urgent?alertHtml(urgent.message):''}

Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.

`; + const urgent=detectUrgentFlags(form.symptoms);return `

Select anything you have now. This is where Timmy stops joking.

${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>``).join('')}
${followUpBlock()}
${urgent.urgent?alertHtml(urgent.message):''}

Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.

`; } function alertHtml(msg){return `
Pause and get medical help.

${esc(msg)}

`} function bindStep(step){ document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)}); if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{const chosen=Number(b.dataset.type);form.bristolType=chosen;form.typeChosen=true;suggestionState=changeVisualField(suggestionState,'bristolType',chosen);showLogStep(1)});document.querySelector('#next').onclick=()=>{form.typeChosen=true;showLogStep(2)}} if(step===2){const color=document.querySelector('#color');color.value=form.color;color.onchange=()=>{form.color=color.value;suggestionState=changeVisualField(suggestionState,'color',form.color);refreshProvenance()};['urgency','discomfort'].forEach(k=>{const n=document.querySelector('#'+k);n.oninput=()=>{form[k]=Number(n.value);n.nextElementSibling.value=n.value}});document.querySelector('#note').oninput=e=>form.note=e.target.value;document.querySelector('#photo').onchange=handlePhoto;document.querySelector('#back').onclick=()=>showLogStep(1);document.querySelector('#next').onclick=()=>showLogStep(3)} - if(step===3){document.querySelectorAll('[data-symptom]').forEach(c=>c.onchange=()=>{form.symptoms[c.dataset.symptom]=c.checked;document.querySelector('#urgent-box').innerHTML=detectUrgentFlags(form.symptoms).urgent?alertHtml(detectUrgentFlags(form.symptoms).message):''});document.querySelector('#back').onclick=()=>showLogStep(2);document.querySelector('#save').onclick=saveLog} + if(step===3){document.querySelectorAll('[data-symptom]').forEach(c=>c.onchange=()=>{form.symptoms[c.dataset.symptom]=c.checked;const mirrored=HARD_STOOL_QUESTIONS.find(q=>q.urgentFlag===c.dataset.symptom);if(mirrored)followUpAnswers[mirrored.id]=c.checked;refreshFollowUp()});document.querySelectorAll('[data-followup]').forEach(b=>b.onclick=()=>{const question=HARD_STOOL_QUESTIONS.find(q=>q.id===b.dataset.followup);const value=b.dataset.answer==='yes';followUpAnswers[b.dataset.followup]=value;if(question?.urgentFlag)form.symptoms[question.urgentFlag]=value;syncFollowUpFlags();refreshFollowUp()});document.querySelector('#back').onclick=()=>showLogStep(2);document.querySelector('#save').onclick=saveLog} } async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}} function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i{URL.revokeObjectURL(url);reject()};img.src=url})} diff --git a/artifacts/followup-hard-stool-mobile.png b/artifacts/followup-hard-stool-mobile.png new file mode 100644 index 0000000..abaec89 Binary files /dev/null and b/artifacts/followup-hard-stool-mobile.png differ diff --git a/package.json b/package.json index 6b2679d..ff9e34f 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,16 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js", + "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js", "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:mobile-capture": "node tests/mobile-capture.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs", "test:manual-fallback": "node tests/manual-fallback.acceptance.mjs", "test:provisional": "node tests/provisional-suggestion.acceptance.mjs", + "test:followup": "node tests/followup.acceptance.mjs", "test:staging-smoke": "node tests/staging.acceptance.mjs", - "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py", + "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py", "check:diff": "bash scripts/check_diff.sh", "start": "node server.mjs" }, diff --git a/scripts/build_release.py b/scripts/build_release.py index 906a2fd..738bf08 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -113,6 +113,7 @@ def main() -> int: run(["npm", "run", "test:sleek"], tree) run(["npm", "run", "test:manual-fallback"], tree) run(["npm", "run", "test:provisional"], tree) + run(["npm", "run", "test:followup"], tree) demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm" demo_env = dict(server_env) demo_env["TIMMY_RELEASE_VERSION"] = version @@ -136,7 +137,7 @@ def main() -> int: contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg" run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", "fps=1/2,scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree) run(["npm", "audit", "--audit-level=high"], tree) - for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"): + for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"): run(["node", "--check", file], tree) run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree) run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree) @@ -165,6 +166,7 @@ def main() -> int: "- Agent safety: exact-origin HttpOnly session, bounded text-only ledger context, no browser credentials or session IDs\n" "- Vision profiles: hosted and self-hosted SmolVLM2 bootstrap\n" "- AI suggestions stay provisional: suggested, confirmed, and corrected states are distinct and screen-reader described; correcting one clears the AI-confirmed state without losing user context\n" + "- Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions (difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, hydration); mild reports get non-diagnostic self-care, and a confirmed red-flag answer lands on the same deterministic escalation path as manual logging\n" "- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\n" "- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n" "- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n" @@ -209,7 +211,7 @@ def main() -> int: "codec": "h264", "pixel_format": "yuv420p", "recorded_from_working_app": True, - "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, offline vision status, and deterministic Hermes chat response", + "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, offline vision status, and deterministic Hermes chat response", }, "gates": { "unit_security": "passed", @@ -218,6 +220,7 @@ def main() -> int: "sleek_hermes_chat_acceptance": "passed", "manual_fallback_draft_recovery": "passed", "provisional_suggestion_state": "passed", + "hard_stool_followup": "passed", "dependency_audit_high": "passed", "syntax": "passed", "secret_scan": "passed", diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index e518b93..cc4ccbd 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -104,6 +104,7 @@ async function tap(selector, after = 650) { async function indicate(selector) { const target = page.locator(selector).first(); + await target.scrollIntoViewIfNeeded(); const box = await target.boundingBox(); if (!box) throw new Error(`Missing demo target: ${selector}`); await page.evaluate(({ x, y }) => { @@ -148,6 +149,25 @@ await indicate('.ai-provenance[data-provenance="corrected"] .provenance-note'); await tap('#next', 450); await page.getByText(/You changed it to Type 2/).waitFor(); await caption('Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours', 1900); +await tap('#next', 450); +await page.locator('[data-followup-block]').waitFor(); +await caption('Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo', 2600); +await page.locator('[data-question="bloodVisible"]').scrollIntoViewIfNeeded(); +await indicate('[data-question="bloodVisible"]'); +await tap('[data-followup="bloodVisible"][data-answer="no"]', 400); +await caption('Every answer is yours to give, and a skipped question stays unanswered', 1900); +await tap('[data-followup="soreness"][data-answer="yes"]', 350); +await tap('[data-followup="dehydration"][data-answer="yes"]', 350); +await page.locator('#followup-guidance').waitFor(); +await caption('Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis', 2600); +await indicate('#followup-guidance'); +await caption('And it never claims the photo ruled out bleeding', 1900); +await tap('[data-followup="tarryBlack"][data-answer="yes"]', 600); +await page.locator('#urgent-box .alert').waitFor(); +await caption('Say it was truly black and tarry and the same deterministic red-flag path fires instead', 2600); +await tap('[data-followup="tarryBlack"][data-answer="no"]', 500); +await page.locator('#urgent-box .alert').waitFor({ state: 'detached' }); +await caption('Dark brown is not silently escalated, and the flag clears the moment the answer changes', 2200); await tap('#close-sheet', 450); await caption('Closing the log keeps the draft in this session instead of throwing it away', 1600); await caption('Now the same photo flow while the vision worker is offline', 1500); diff --git a/src/followup.js b/src/followup.js new file mode 100644 index 0000000..e26a245 --- /dev/null +++ b/src/followup.js @@ -0,0 +1,66 @@ +// Hard-stool follow-up (field finding #67). +// +// After a hard/lumpy (Bristol 1-2) entry, Timmy asks a short follow-up. Every answer +// is user-reported: soreness, difficulty passing, hydration, and black/tarry character +// are never inferred from a photo. The two confirmed red flags map onto the same +// deterministic symptom keys the rest of the app already escalates on. +import { detectUrgentFlags } from './domain.js'; + +export const HARD_STOOL_TYPES = Object.freeze([1, 2]); + +// `urgentFlag` is the existing deterministic symptom key a "yes" maps onto. Questions +// without one stay non-diagnostic self-care only. +export const HARD_STOOL_QUESTIONS = Object.freeze([ + Object.freeze({ id: 'difficultToPass', label: 'Was it hard or difficult to pass?', urgentFlag: null }), + Object.freeze({ id: 'soreness', label: 'Any soreness or sharp pain afterward?', urgentFlag: null }), + Object.freeze({ id: 'bloodVisible', label: 'Any visible red blood?', urgentFlag: 'blood' }), + Object.freeze({ id: 'tarryBlack', label: 'Was it truly black, sticky, or tarry — not just dark brown?', urgentFlag: 'blackOrDarkRed' }), + Object.freeze({ id: 'dehydration', label: 'Do you feel dehydrated, or is your urine unusually dark?', urgentFlag: null }), +]); + +export const HARD_STOOL_GUIDANCE = Object.freeze([ + 'Drink water steadily through the day rather than a lot at once.', + 'Do not strain or force it; give it time and try again later if needed.', + 'Clean gently, pat dry, and avoid scrubbing sore skin.', + 'A few minutes in a warm sitz bath can ease soreness afterward.', + 'A barrier ointment on the outside skin can reduce irritation.', + 'Food-based fiber with fluids helps over days, not minutes.', +]); + +export const HARD_STOOL_GUIDANCE_NOTE = 'General self-care information, not a diagnosis. Your photo cannot rule out bleeding or another cause.'; +export const HARD_STOOL_SEEK_CARE = 'Contact a clinician or urgent service if pain is severe or worsening, you feel faint or weak, you pass very little urine, or any blood appears.'; + +export function isHardStoolForm(bristolType) { + return HARD_STOOL_TYPES.includes(Number(bristolType)); +} + +export function needsHardStoolFollowUp(draft = {}) { + return isHardStoolForm(draft?.bristolType); +} + +export function createFollowUpAnswers() { + return Object.fromEntries(HARD_STOOL_QUESTIONS.map((question) => [question.id, null])); +} + +function confirmedYes(answers, question) { + // Only an explicit `true` counts. `null` (unanswered) and `false` (explicit no) do not escalate. + return answers?.[question.id] === true; +} + +export function evaluateHardStoolFollowUp(answers = {}) { + const answeredIds = HARD_STOOL_QUESTIONS.filter((question) => typeof answers[question.id] === 'boolean'); + const flags = HARD_STOOL_QUESTIONS.filter((question) => question.urgentFlag && confirmedYes(answers, question)) + .map((question) => question.urgentFlag); + const escalated = detectUrgentFlags(Object.fromEntries(flags.map((flag) => [flag, true]))); + const urgent = escalated.urgent; + return { + answered: answeredIds.length > 0, + unanswered: HARD_STOOL_QUESTIONS.filter((question) => !answeredIds.includes(question)).map((question) => question.id), + urgent, + flags, + message: urgent ? escalated.message : '', + guidance: !urgent && answeredIds.length ? [...HARD_STOOL_GUIDANCE] : [], + note: HARD_STOOL_GUIDANCE_NOTE, + seekCare: urgent ? '' : HARD_STOOL_SEEK_CARE, + }; +} diff --git a/styles.css b/styles.css index 8364f7f..12c7a28 100644 --- a/styles.css +++ b/styles.css @@ -30,3 +30,19 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t .ai-provenance[data-provenance="corrected"] .ai-badge{color:#8a6412} .provenance-note{font-size:12px;line-height:1.45;color:var(--muted);margin:8px 0 0;padding-left:9px;border-left:3px solid #cfc5b6} .ai-provenance[data-provenance="corrected"] .provenance-note{border-left-color:#e0b96a} +.followup{background:#fdf7ea;border:1px solid #eeddb5;border-radius:18px;padding:13px;margin:15px 0 0} +.followup h3{font-size:15px;margin:0 0 4px} +.followup>.fine{margin-bottom:6px} +.followup-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:9px 0;border-top:1px solid var(--line)} +.followup-row>span{font-size:13px;line-height:1.35} +.followup-map{display:block;font-size:10px;font-weight:600;color:var(--muted);margin-top:3px} +.followup-toggle{display:flex;gap:6px} +.followup-toggle button{min-height:40px;min-width:54px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--muted);font-weight:800;font-size:12px;cursor:pointer} +.followup-toggle button.selected{background:var(--ink);color:#fff;border-color:var(--ink)} +.followup-guidance{margin-top:12px;background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:12px} +.followup-guidance>strong{display:block;font-size:13px} +.followup-guidance ul{margin:8px 0 0;padding-left:18px} +.followup-guidance li{font-size:12.5px;line-height:1.45;color:#4d453e;margin-bottom:4px} +.followup-guidance .fine{margin:8px 0 0} +.followup-guidance.escalated{background:var(--red-soft);border-color:#eec1ba} +.followup-guidance.escalated>strong{color:#75261f} diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 0a58a8f..9d346eb 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -64,6 +64,20 @@ test('provisional AI suggestion state is a unit-and-browser-gated contract', asy assert.match(workflow, /npm run test:provisional/); }); +test('hard-stool follow-up is a unit-and-browser-gated safety contract', async () => { + const [packageJsonText, workflow] = await Promise.all([ + readFile(packagePath, 'utf8'), + readFile(workflowPath, 'utf8'), + ]); + const packageJson = JSON.parse(packageJsonText); + + assert.match(packageJson.scripts.test, /tests\/followup\.test\.js/); + assert.equal(packageJson.scripts['test:followup'], 'node tests/followup.acceptance.mjs'); + assert.match(packageJson.scripts['check:syntax'], /node --check src\/followup\.js/); + assert.match(packageJson.scripts['check:syntax'], /node --check tests\/followup\.acceptance\.mjs/); + assert.match(workflow, /npm run test:followup/); +}); + test('staging smoke is an explicit syntax-gated promotion command, never a PR network call', async () => { const [packageJson, workflow] = await Promise.all([ readFile(packagePath, 'utf8').then(JSON.parse), diff --git a/tests/followup.acceptance.mjs b/tests/followup.acceptance.mjs new file mode 100644 index 0000000..b4b8435 --- /dev/null +++ b/tests/followup.acceptance.mjs @@ -0,0 +1,104 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; + +await mkdir('artifacts', { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' }); +const page = await context.newPage(); +const errors = []; +page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); +page.on('pageerror', error => errors.push(error.message)); +await page.route('**/api/vision-status', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: true, modelSeen: true }), +})); +await page.route('**/api/analyze', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'suggestion', isStool: true, bristolType: 2, color: 'brown', confidence: 0.71, + imageQuality: 'good', observations: 'Small, separate, hard-looking lumps.', + warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.', + }), +})); + +await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); +await page.evaluate(() => localStorage.clear()); +await page.reload({ waitUntil: 'networkidle' }); + +// 1. A hard/lumpy photo suggestion still contains only visible form, colour, and quality. +await page.locator('[data-scan]').click(); +await page.getByText(/Self-hosted model ready/i).waitFor(); +await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); +await page.locator('#ai-consent').check(); +await page.locator('#analyze-photo').click(); +const suggestion = page.locator('[data-provenance="pending"]'); +await suggestion.waitFor(); +const suggestionText = await suggestion.innerText(); +assert.match(suggestionText, /Type 2/); +assert.doesNotMatch(suggestionText, /sore|dehydrat|tarry|difficult to pass|blood/i, 'the photo suggestion may never mention user-reported symptoms'); + +// 2. Confirming the suggestion and moving to the safety step offers the follow-up for a hard form. +await page.locator('#use-suggestion').click(); +await page.locator('#next').click(); +await page.locator('#next').click(); +const block = page.locator('[data-followup-block]'); +await block.waitFor(); +assert.equal(await block.count(), 1, 'a Bristol 2 entry asks the hard-stool follow-up'); +assert.equal(await page.locator('[data-followup]').count(), 10, 'five questions, each with an explicit yes and no'); +const askedIds = await page.locator('.followup-row').evaluateAll(rows => rows.map(row => row.dataset.question)); +assert.deepEqual(askedIds.sort(), ['bloodVisible', 'dehydration', 'difficultToPass', 'soreness', 'tarryBlack']); +assert.match(await block.innerText(), /never (guesses|infers)/i, 'the copy states these are user-reported, never inferred'); +assert.equal(await page.locator('[data-followup].selected').count(), 0, 'no question may be pre-answered'); +assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'an unanswered follow-up never escalates'); +assert.match(await page.locator('#followup-output').innerText(), /answer/i, 'the user is asked to answer rather than given invented findings'); + +// 3. A mild hard-stool report gets proportionate non-diagnostic self-care and no escalation. +for (const id of ['difficultToPass', 'soreness', 'dehydration']) await page.locator(`[data-followup="${id}"][data-answer="yes"]`).click(); +await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click(); +await page.locator('[data-followup="bloodVisible"][data-answer="no"]').click(); +const guidance = page.locator('#followup-guidance'); +await guidance.waitFor(); +assert.match(await guidance.innerText(), /not a diagnosis/i); +assert.match(await guidance.innerText(), /cannot rule out/i); +assert.match(await guidance.innerText(), /bleeding/i); +assert.match(await guidance.innerText(), /water|hydrat/i); +assert.match(await guidance.innerText(), /strain/i); +assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'dark brown plus soreness must not escalate'); +await page.screenshot({ path: 'artifacts/followup-hard-stool-mobile.png' }); + +// 4. Confirming truly black/tarry stool lands on the existing deterministic path. +await page.locator('[data-followup="tarryBlack"][data-answer="yes"]').click(); +const alert = page.locator('#urgent-box .alert'); +await alert.waitFor(); +assert.match(await alert.innerText(), /Pause and get medical help\./); +assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), true, 'the confirmed answer sets the app\'s own red-flag control'); + +// 5. Clearing that answer clears the escalation instead of sticking. +await page.locator('[data-followup="tarryBlack"][data-answer="no"]').click(); +assert.equal(await page.locator('#urgent-box .alert').count(), 0); +assert.equal(await page.locator('[data-symptom="blackOrDarkRed"]').isChecked(), false); + +// 6. Visible blood is the other existing deterministic flag. +await page.locator('[data-followup="bloodVisible"][data-answer="yes"]').click(); +await page.locator('#urgent-box .alert').waitFor(); +assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true); + +// 7. Non-hard forms never see the follow-up. +await page.locator('#back').click(); +await page.locator('#back').click(); +await page.locator('[data-type="6"]').click(); +await page.locator('#next').click(); +await page.locator('#next').click(); +assert.equal(await page.locator('[data-followup-block]').count(), 0, 'loose forms are not asked hard-stool questions'); +assert.equal(await page.locator('[data-symptom="blood"]').isChecked(), true, 'an answer the user already confirmed is not silently dropped'); +assert.equal(await page.locator('#urgent-box .alert').count(), 1, 'the deterministic path still reflects that confirmed answer'); +await page.locator('[data-symptom="blood"]').uncheck(); +assert.equal(await page.locator('#urgent-box .alert').count(), 0, 'clearing the confirmed flag clears the escalation'); + +assert.deepEqual(errors, []); +await context.close(); +await browser.close(); +console.log('PASS hard-stool follow-up is user-reported, non-diagnostic for mild reports, and lands on the deterministic escalation path'); diff --git a/tests/followup.test.js b/tests/followup.test.js new file mode 100644 index 0000000..fcd5eaf --- /dev/null +++ b/tests/followup.test.js @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { detectUrgentFlags } from '../src/domain.js'; +import { + HARD_STOOL_QUESTIONS, + HARD_STOOL_TYPES, + createFollowUpAnswers, + evaluateHardStoolFollowUp, + isHardStoolForm, + needsHardStoolFollowUp, +} from '../src/followup.js'; + +// Field finding #67: after a hard/lumpy (Bristol 1-2) log, Timmy asks a short +// user-reported follow-up. Nothing here may be inferred from the photo, dark brown +// must never be silently escalated as black stool, and confirmed red flags must land +// on the same deterministic escalation path the rest of the app already uses. + +const questionIds = () => HARD_STOOL_QUESTIONS.map((question) => question.id); + +test('the hard-stool follow-up only appears for Bristol 1 and 2', () => { + assert.deepEqual([...HARD_STOOL_TYPES], [1, 2]); + assert.equal(isHardStoolForm(1), true); + assert.equal(isHardStoolForm(2), true); + for (const type of [3, 4, 5, 6, 7]) assert.equal(isHardStoolForm(type), false, `Type ${type} is not hard/lumpy`); + assert.equal(isHardStoolForm(undefined), false); + assert.equal(isHardStoolForm('2'), true, 'a form value from the DOM is still the same form'); + assert.equal(needsHardStoolFollowUp({ bristolType: 2 }), true); + assert.equal(needsHardStoolFollowUp({ bristolType: 4 }), false); + assert.equal(needsHardStoolFollowUp({}), false); +}); + +test('every follow-up question starts unanswered and is never pre-filled', () => { + const answers = createFollowUpAnswers(); + + assert.deepEqual(questionIds().slice().sort(), [ + 'bloodVisible', + 'dehydration', + 'difficultToPass', + 'soreness', + 'tarryBlack', + ]); + assert.equal(HARD_STOOL_QUESTIONS.length, 5); + for (const question of HARD_STOOL_QUESTIONS) { + assert.equal(answers[question.id], null, `${question.id} must start unanswered`); + assert.match(question.label, /was it|any |do you/i, `${question.id} must ask the user, not infer`); + } +}); + +test('an unanswered follow-up never escalates and never invents guidance', () => { + const verdict = evaluateHardStoolFollowUp(createFollowUpAnswers()); + + assert.equal(verdict.answered, false); + assert.equal(verdict.urgent, false); + assert.deepEqual(verdict.flags, []); + assert.equal(verdict.message, ''); + assert.deepEqual(verdict.guidance, [], 'unanswered questions must not produce self-care copy'); +}); + +test('a mild hard-stool report keeps non-diagnostic self-care and no escalation', () => { + const verdict = evaluateHardStoolFollowUp({ + difficultToPass: true, + soreness: true, + bloodVisible: false, + tarryBlack: false, + dehydration: true, + }); + + assert.equal(verdict.answered, true); + assert.equal(verdict.urgent, false); + assert.deepEqual(verdict.flags, []); + assert.equal(verdict.message, ''); + assert.ok(verdict.guidance.length >= 3, 'a mild report still gets proportionate self-care'); + assert.equal(verdict.guidance.some((line) => /water|hydrat/i.test(line)), true); + assert.equal(verdict.guidance.some((line) => /strain/i.test(line)), true); +}); + +test('the mild guidance never implies the photo ruled out bleeding', () => { + const verdict = evaluateHardStoolFollowUp({ soreness: true, bloodVisible: false, tarryBlack: false }); + + assert.match(verdict.note, /not a diagnosis/i); + assert.match(verdict.note, /cannot rule out|did not rule out/i); + assert.match(verdict.note, /bleeding/i); + assert.doesNotMatch(verdict.note, /rules out|safe|cleared|no bleeding/i); +}); + +test('confirmed visible blood maps onto the existing deterministic blood red flag', () => { + const verdict = evaluateHardStoolFollowUp({ bloodVisible: true }); + + assert.equal(verdict.urgent, true); + assert.deepEqual(verdict.flags, ['blood']); + assert.equal(verdict.message, detectUrgentFlags({ blood: true }).message, 'urgent copy comes from the canonical path'); + assert.deepEqual(verdict.guidance, [], 'escalation copy must not be softened with self-care'); +}); + +test('confirmed black or tarry stool maps onto the existing deterministic blackOrDarkRed red flag', () => { + const verdict = evaluateHardStoolFollowUp({ tarryBlack: true }); + + assert.equal(verdict.urgent, true); + assert.deepEqual(verdict.flags, ['blackOrDarkRed']); + assert.equal(verdict.message, detectUrgentFlags({ blackOrDarkRed: true }).message); +}); + +test('dark brown is not silently escalated as black stool', () => { + const explicitDarkBrown = evaluateHardStoolFollowUp({ difficultToPass: true, tarryBlack: false }); + const unansweredTarry = evaluateHardStoolFollowUp({ difficultToPass: true }); + const colorAlone = evaluateHardStoolFollowUp({ color: 'black', note: 'dark brown' }); + + assert.equal(explicitDarkBrown.urgent, false, 'answering "dark brown" must not escalate'); + assert.equal(unansweredTarry.urgent, false, 'an unanswered colour question is not a yes'); + assert.equal(colorAlone.urgent, false, 'a draft colour value is never treated as a confirmed tarry finding'); +}); + +test('an unanswered question is not silently treated as a no', () => { + const partial = evaluateHardStoolFollowUp({ soreness: true }); + + assert.equal(partial.answered, true); + assert.equal(partial.urgent, false); + assert.deepEqual(partial.flags, []); + assert.equal(partial.unanswered.length, 4, 'the verdict reports what the user still has not answered'); + const all = evaluateHardStoolFollowUp({ bloodVisible: false, tarryBlack: false, soreness: false, difficultToPass: false, dehydration: false }); + assert.deepEqual(all.unanswered, []); +}); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 0d04bfc..8003d8f 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -73,3 +73,27 @@ test('release builder gates the provisional suggestion states the same way CI do assert.match(builder, /"src\/suggestion\.js"/); assert.match(builder, /AI suggestions stay provisional/); }); + +test('release demo visibly exercises the user-reported hard-stool follow-up and its red-flag handoff', async () => { + const demo = await readFile(demoPath, 'utf8'); + + assert.match(demo, /\[data-followup-block\]/); + assert.match(demo, /Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo/); + assert.match(demo, /\[data-followup="bloodVisible"\]\[data-answer="no"\]/); + assert.match(demo, /Every answer is yours to give, and a skipped question stays unanswered/); + assert.match(demo, /#followup-guidance/); + assert.match(demo, /Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis/); + assert.match(demo, /And it never claims the photo ruled out bleeding/); + assert.match(demo, /\[data-followup="tarryBlack"\]\[data-answer="yes"\]/); + assert.match(demo, /Say it was truly black and tarry and the same deterministic red-flag path fires instead/); + assert.match(demo, /Dark brown is not silently escalated, and the flag clears the moment the answer changes/); +}); + +test('release builder gates the hard-stool follow-up the same way CI does', async () => { + const builder = await readFile(builderPath, 'utf8'); + + assert.match(builder, /"test:followup"/); + assert.match(builder, /"hard_stool_followup": "passed"/); + assert.match(builder, /"src\/followup\.js"/); + assert.match(builder, /Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions/); +});