feat: make AI suggestions visibly provisional and correction-first (#13)
Quality gates / quality (pull_request) Successful in 1m45s

Suggested, confirmed, and corrected AI states are now distinct in the log
sheet, and correcting a suggested value clears the AI-confirmed state while
keeping every user-entered field. Screen-reader copy states the AI confidence
and who is in control.

- src/suggestion.js: provenance state machine (pending/confirmed/corrected/manual)
- app.js: renders provenance on the scan result and log steps, tracks corrections
- tests/suggestion.test.js: unit coverage for the state machine and a11y copy
- tests/provisional-suggestion.acceptance.mjs: browser acceptance + screenshots
- record_release_demo.mjs: demo now reviews, confirms, and corrects the suggestion
- CI, release builder, and release notes gate the new contract
This commit is contained in:
timmy committed 2026-10-08 08:09:10 +00:00
1 parent 3e87f881c5
commit 5aee7436f2
15 files changed
+366 -19

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ jobs:
npm run test:mobile-capture npm run test:mobile-capture
npm run test:sleek npm run test:sleek
npm run test:manual-fallback npm run test:manual-fallback
npm run test:provisional
- name: Dependency audit - name: Dependency audit
run: npm audit --audit-level=high run: npm audit --audit-level=high
- name: Syntax checks - name: Syntax checks
+22 -12
View File
@@ -1,6 +1,7 @@
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
import { mergeVisualSuggestion } from './src/analysis.js'; import { mergeVisualSuggestion } from './src/analysis.js';
import { createDraft, draftNotice, isDraftDirty } from './src/draft.js'; import { createDraft, draftNotice, isDraftDirty } from './src/draft.js';
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js';
import { visionReadiness } from './src/vision-readiness.js'; import { visionReadiness } from './src/vision-readiness.js';
const runtimeConfig = { const runtimeConfig = {
@@ -26,6 +27,7 @@ let entries = loadEntries();
let view = 'home'; let view = 'home';
let photoHint = ''; let photoHint = '';
let aiSuggestion = null; let aiSuggestion = null;
let suggestionState = createSuggestionState(null);
let visionStatus = null; let visionStatus = null;
let photoFirstMode = 'pick'; let photoFirstMode = 'pick';
let agentStatus = null; let agentStatus = null;
@@ -40,7 +42,15 @@ function draftBanner(){
return `<div class="draft-resume"><span role="status">Draft kept in this session: <strong>${esc(draftNotice(form))}</strong></span><button class="text-action compact" id="discard-draft">Start over</button></div>`; return `<div class="draft-resume"><span role="status">Draft kept in this session: <strong>${esc(draftNotice(form))}</strong></span><button class="text-action compact" id="discard-draft">Start over</button></div>`;
} }
function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()} function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()}
function clearDraft(){form=createDraft();draftResumed=false} function clearDraft(){form=createDraft();draftResumed=false;suggestionState=createSuggestionState(null)}
function resetSuggestion(){aiSuggestion=null;suggestionState=createSuggestionState(null)}
// A single place where provisional, confirmed, and corrected AI states become visible copy.
function provenanceBlock(){
const copy=provenanceCopy(suggestionState,form);
if(!copy.label)return '';
return `<div class="ai-prefill ai-provenance" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)}</span><strong>Type ${form.bristolType} · ${esc(form.color)}</strong><small>You’re in charge—tap any type to correct it.</small><p class="provenance-note fine" role="status">${esc(copy.detail)}</p></div>`;
}
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&&copy.aria)container.setAttribute('aria-label',copy.aria)}
function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); } function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } } function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } }
@@ -130,7 +140,7 @@ function exportData(){const blob=new Blob([exportLedger(entries)],{type:'applica
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}} async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}} function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
function openPhotoFirst(){beginDraft();photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()} function openPhotoFirst(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){ async function loadVisionStatus(){
try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}} try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
catch{visionStatus={enabled:false,providerReady:false}} catch{visionStatus={enabled:false,providerReady:false}}
@@ -147,7 +157,7 @@ function photoFirstBody(mode,error=''){
if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo kept on this device"><p class="quality-note">${esc(photoHint)}</p><p class="fine">${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.</p><button class="btn btn-primary btn-wide" id="manual-from-scan">${esc(readiness.manualLabel)}</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;} if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo kept on this device"><p class="quality-note">${esc(photoHint)}</p><p class="fine">${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.</p><button class="btn btn-primary btn-wide" id="manual-from-scan">${esc(readiness.manualLabel)}</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
if(mode==='analyzing')return `<div class="analyzing"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`; if(mode==='analyzing')return `<div class="analyzing"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`; if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`; if(suggestionState.status==='pending'){const copy=provenanceCopy(suggestionState,form);return `<div class="scan-result provisional" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)} · ${Math.round(suggestionState.suggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${suggestionState.suggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(suggestionState.suggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="provenance-note" role="status">${esc(copy.detail)}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;}
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`; return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
} }
function showPhotoFirst(mode='pick',error=''){ function showPhotoFirst(mode='pick',error=''){
@@ -155,29 +165,29 @@ function showPhotoFirst(mode='pick',error=''){
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()}; document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))}); document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis; const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis;
document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')}); document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')});
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)}); document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)});
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)}); document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)});
} }
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}} async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}} async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;suggestionState=createSuggestionState(data);showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
function openLogger(){beginDraft();photoHint='';aiSuggestion=null;showLogStep(1)} function openLogger(){beginDraft();photoHint='';resetSuggestion();showLogStep(1)}
function showLogStep(step){ function showLogStep(step){
document.querySelector('.sheet-backdrop')?.remove(); document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${draftBanner()}${stepBody(step)}</section>`;document.body.append(wrap); const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${draftBanner()}${stepBody(step)}</section>`;document.body.append(wrap);
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step); document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
} }
function stepBody(step){ function stepBody(step){
if(step===1)return `${aiSuggestion?.status==='suggestion'?`<div class="ai-prefill"><span class="ai-badge">AI PREFILLED</span><strong>Type ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}</strong><small>You’re in charge—tap any type to correct it.</small></div>`:'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[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])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`; if(step===1)return `${provenanceBlock()||'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[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])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${form.photoDataUrl?`<img class="photo-preview" src="${form.photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${aiSuggestion?.status==='suggestion'?'<p class="fine">AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.</p>':'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`; if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${form.photoDataUrl?`<img class="photo-preview" src="${form.photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${suggestionState.suggestion?`<p class="provenance-note fine" role="status">${esc(provenanceCopy(suggestionState,form).detail)}</p>`:'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['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])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`; const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['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])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
} }
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`} function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
function bindStep(step){ function bindStep(step){
document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)}); document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)});
if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{form.bristolType=Number(b.dataset.type);form.typeChosen=true;aiSuggestion=null;showLogStep(1)});document.querySelector('#next').onclick=()=>{form.typeChosen=true;showLogStep(2)}} 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;['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===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;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}
} }
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)}} 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)}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

+3 -2
View File
@@ -4,14 +4,15 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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/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/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:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs",
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs", "test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:manual-fallback": "node tests/manual-fallback.acceptance.mjs", "test:manual-fallback": "node tests/manual-fallback.acceptance.mjs",
"test:provisional": "node tests/provisional-suggestion.acceptance.mjs",
"test:staging-smoke": "node tests/staging.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/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 && 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/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:diff": "bash scripts/check_diff.sh", "check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs" "start": "node server.mjs"
}, },
+5 -2
View File
@@ -112,6 +112,7 @@ def main() -> int:
run(["npm", "run", "test:mobile-capture"], tree) run(["npm", "run", "test:mobile-capture"], tree)
run(["npm", "run", "test:sleek"], tree) run(["npm", "run", "test:sleek"], tree)
run(["npm", "run", "test:manual-fallback"], tree) run(["npm", "run", "test:manual-fallback"], tree)
run(["npm", "run", "test:provisional"], tree)
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm" demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
demo_env = dict(server_env) demo_env = dict(server_env)
demo_env["TIMMY_RELEASE_VERSION"] = version demo_env["TIMMY_RELEASE_VERSION"] = version
@@ -135,7 +136,7 @@ def main() -> int:
contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg" 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(["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) 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/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/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(["node", "--check", file], tree)
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree) run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree) run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
@@ -163,6 +164,7 @@ def main() -> int:
"- Smart free-text Timmy chat backed by authenticated server-side Hermes session continuity\n" "- Smart free-text Timmy chat backed by authenticated server-side Hermes session continuity\n"
"- Agent safety: exact-origin HttpOnly session, bounded text-only ledger context, no browser credentials or session IDs\n" "- 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" "- 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"
"- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\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" "- 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" "- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n"
@@ -207,7 +209,7 @@ def main() -> int:
"codec": "h264", "codec": "h264",
"pixel_format": "yuv420p", "pixel_format": "yuv420p",
"recorded_from_working_app": True, "recorded_from_working_app": True,
"fixture_data": "synthetic-Type-4, deterministic vision suggestion, offline vision status, and deterministic Hermes chat response", "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, offline vision status, and deterministic Hermes chat response",
}, },
"gates": { "gates": {
"unit_security": "passed", "unit_security": "passed",
@@ -215,6 +217,7 @@ def main() -> int:
"photo_first_acceptance": "passed", "photo_first_acceptance": "passed",
"sleek_hermes_chat_acceptance": "passed", "sleek_hermes_chat_acceptance": "passed",
"manual_fallback_draft_recovery": "passed", "manual_fallback_draft_recovery": "passed",
"provisional_suggestion_state": "passed",
"dependency_audit_high": "passed", "dependency_audit_high": "passed",
"syntax": "passed", "syntax": "passed",
"secret_scan": "passed", "secret_scan": "passed",
+13 -3
View File
@@ -136,8 +136,18 @@ await page.locator('#ai-consent').check();
await tap('#analyze-photo', 450); await tap('#analyze-photo', 450);
await page.getByText(/83% confidence/i).waitFor(); await page.getByText(/83% confidence/i).waitFor();
await caption('AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis', 1700); await caption('AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis', 1700);
await indicate('.scan-result[data-provenance="pending"]');
await caption('The suggestion stays provisional — nothing is applied or saved until you confirm it', 1700);
await tap('#use-suggestion', 450); await tap('#use-suggestion', 450);
await caption('The user reviews every suggestion before saving', 1100); await page.locator('.ai-provenance[data-provenance="confirmed"]').waitFor();
await caption('Confirming is explicit, and every suggested value is still yours to overrule', 1600);
await tap('[data-type="2"]', 500);
await page.locator('[data-provenance="corrected"]').waitFor();
await caption('Change a suggested value and Timmy clears the AI-confirmed state', 1700);
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('#close-sheet', 450); await tap('#close-sheet', 450);
await caption('Closing the log keeps the draft in this session instead of throwing it away', 1600); 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); await caption('Now the same photo flow while the vision worker is offline', 1500);
@@ -151,8 +161,8 @@ await page.getByText(/Ready for your review/i).waitFor();
await caption('No consent prompt and no upload when analysis cannot run', 1600); await caption('No consent prompt and no upload when analysis cannot run', 1600);
await tap('#manual-from-scan', 500); await tap('#manual-from-scan', 500);
const keptDraft = await page.locator('.draft-resume').innerText({ timeout: 5000 }); const keptDraft = await page.locator('.draft-resume').innerText({ timeout: 5000 });
if (!/Type 4/.test(keptDraft)) throw new Error('The retained draft must show exactly what the user is keeping'); if (!/Type 2/.test(keptDraft)) throw new Error('The retained draft must show the value the user corrected to');
await caption('One tap still reaches manual Bristol entry', 1500); await caption('One tap still reaches manual Bristol entry, and the draft keeps your corrected value', 1800);
await tap('#discard-draft', 500); await tap('#discard-draft', 500);
await caption('Start over clears the kept draft — nothing lingers silently', 1500); await caption('Start over clears the kept draft — nothing lingers silently', 1500);
await tap('#close-sheet', 450); await tap('#close-sheet', 450);
+93
View File
@@ -0,0 +1,93 @@
// Tracks where a log entry's visual fields came from, so an AI suggestion is never
// shown as if the user had confirmed it.
export const SUGGESTION_STATUS = {
none: 'none',
pending: 'pending',
confirmed: 'confirmed',
corrected: 'corrected',
manual: 'manual',
};
const VISUAL_FIELDS = ['bristolType', 'color'];
export function createSuggestionState(suggestion = null) {
if (!suggestion || suggestion.status !== 'suggestion') {
return { status: SUGGESTION_STATUS.none, suggestion: null, affirmed: false, correctedFields: [] };
}
return {
status: SUGGESTION_STATUS.pending,
suggestion: {
bristolType: suggestion.bristolType,
color: suggestion.color,
confidence: Number(suggestion.confidence),
},
affirmed: false,
correctedFields: [],
};
}
export function confirmSuggestion(state = {}) {
if (!state.suggestion || state.status !== SUGGESTION_STATUS.pending) return { ...state };
return { ...state, status: SUGGESTION_STATUS.confirmed, affirmed: true };
}
export function changeVisualField(state = {}, field, value) {
const correctedFields = [...(state.correctedFields || [])];
if (!VISUAL_FIELDS.includes(field)) return { ...state, correctedFields };
const suggested = state.suggestion ? state.suggestion[field] : undefined;
if (suggested === undefined) {
return {
...state,
status: state.status === SUGGESTION_STATUS.none ? SUGGESTION_STATUS.manual : state.status,
correctedFields,
};
}
if (value === suggested) return { ...state, correctedFields };
if (!correctedFields.includes(field)) correctedFields.push(field);
return { ...state, status: SUGGESTION_STATUS.corrected, affirmed: false, correctedFields };
}
function percent(value) {
return Math.round(Number(value) * 100);
}
export function provenanceCopy(state = {}, current = {}) {
const suggestion = state.suggestion;
const status = suggestion || state.status === SUGGESTION_STATUS.manual
? state.status
: SUGGESTION_STATUS.none;
if (status === SUGGESTION_STATUS.none) return { status, label: '', detail: '', aria: '' };
if (status === SUGGESTION_STATUS.manual) {
return {
status,
label: 'YOUR OWN ENTRY',
detail: 'You chose every field yourself. No AI suggestion is applied.',
aria: `You chose Type ${current.bristolType}, ${current.color} yourself. No AI suggestion is applied.`,
};
}
const confidence = percent(suggestion.confidence);
const suggestedSummary = `Type ${suggestion.bristolType}, ${suggestion.color}`;
if (status === SUGGESTION_STATUS.pending) {
return {
status,
label: 'AI SUGGESTION',
detail: `Suggested ${suggestedSummary} — provisional and not applied yet. You choose whether to use it.`,
aria: `AI suggestion with ${confidence} percent confidence: ${suggestedSummary}. This is a visual suggestion only, not a diagnosis, and it is not saved until you confirm it.`,
};
}
if (status === SUGGESTION_STATUS.corrected) {
const chosen = `Type ${current.bristolType}, ${current.color}`;
return {
status,
label: 'CORRECTED BY YOU',
detail: `AI suggested ${suggestedSummary}. You changed it to ${chosen} — your values are saved, not the suggestion. Urgency, discomfort, notes, and symptoms must come from you.`,
aria: `You changed the AI suggestion. It suggested ${suggestedSummary}; you chose ${chosen}. Your values are saved, not the suggestion.`,
};
}
return {
status,
label: 'AI PREFILLED',
detail: 'AI suggested only form and visible color, and you confirmed it. Urgency, discomfort, notes, and symptoms must come from you.',
aria: `You confirmed the AI suggestion with ${confidence} percent confidence: ${suggestedSummary}. You can still change any of it; the suggestion is a visual aid, not a diagnosis.`,
};
}
+7
View File
@@ -23,3 +23,10 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
.staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72} .staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72}
@media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}} @media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
/* #13 AI suggestion provenance: provisional, confirmed, and corrected states never look alike */
.scan-result.provisional{border:1.5px dashed var(--teal)}
.ai-provenance[data-provenance="confirmed"]{border:1px solid #b7ded6;background:var(--teal-soft)}
.ai-provenance[data-provenance="corrected"]{border:1px solid #e6cd9c;background:#fdf4e4}
.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}
+14
View File
@@ -50,6 +50,20 @@ test('default unit suite covers the manual fallback and draft contracts', async
assert.match(packageJson.scripts['check:syntax'], /node --check src\/vision-readiness\.js/); assert.match(packageJson.scripts['check:syntax'], /node --check src\/vision-readiness\.js/);
}); });
test('provisional AI suggestion state is a unit-and-browser-gated 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\/suggestion\.test\.js/);
assert.equal(packageJson.scripts['test:provisional'], 'node tests/provisional-suggestion.acceptance.mjs');
assert.match(packageJson.scripts['check:syntax'], /node --check src\/suggestion\.js/);
assert.match(packageJson.scripts['check:syntax'], /node --check tests\/provisional-suggestion\.acceptance\.mjs/);
assert.match(workflow, /npm run test:provisional/);
});
test('staging smoke is an explicit syntax-gated promotion command, never a PR network call', async () => { test('staging smoke is an explicit syntax-gated promotion command, never a PR network call', async () => {
const [packageJson, workflow] = await Promise.all([ const [packageJson, workflow] = await Promise.all([
readFile(packagePath, 'utf8').then(JSON.parse), readFile(packagePath, 'utf8').then(JSON.parse),
@@ -0,0 +1,95 @@
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: 4, color: 'brown', confidence: 0.83,
imageQuality: 'good', observations: 'Smooth, formed appearance.',
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. The AI suggestion is visibly provisional and its screen-reader copy states confidence and user control.
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();
await page.getByText(/83% confidence/i).waitFor();
const suggested = page.locator('[data-provenance="pending"]');
assert.equal(await suggested.count(), 1, 'the AI result is a distinct provisional state');
assert.match(await suggested.innerText(), /AI SUGGESTION/);
const suggestedAria = await suggested.getAttribute('aria-label');
assert.match(suggestedAria, /83 percent confidence/i, 'screen-reader copy states the AI confidence');
assert.match(suggestedAria, /not saved until you confirm/i, 'screen-reader copy states that the user is in control');
await page.screenshot({ path: 'artifacts/provisional-suggestion-pending-mobile.png' });
// 2. Confirming the suggestion produces a different, clearly labelled state.
await page.locator('#use-suggestion').click();
const confirmed = page.locator('[data-provenance="confirmed"]');
assert.equal(await confirmed.count(), 1, 'confirmation is a distinct state from the raw suggestion');
assert.equal(await page.locator('[data-provenance="pending"]').count(), 0);
assert.match(await confirmed.innerText(), /AI PREFILLED/);
assert.match(await confirmed.locator('[role="status"]').innerText(), /you confirmed/i);
await page.screenshot({ path: 'artifacts/provisional-suggestion-confirmed-mobile.png' });
// 3. User context entered after the suggestion survives a correction.
await page.locator('#next').click();
await page.locator('#urgency').fill('3');
await page.locator('#note').fill('After lunch');
await page.locator('#back').click();
await page.locator('[data-type="2"]').click();
const corrected = page.locator('[data-provenance="corrected"]');
assert.equal(await corrected.count(), 1, 'changing the suggestion clears AI-confirmed state');
assert.equal(await confirmed.count(), 0);
assert.equal(await page.locator('[data-provenance="confirmed"]').count(), 0);
assert.match(await corrected.innerText(), /CORRECTED BY YOU/);
assert.match(await corrected.innerText(), /AI suggested Type 4/);
const correctedAria = await corrected.getAttribute('aria-label');
assert.match(correctedAria, /You changed/i);
assert.match(correctedAria, /Type 4/);
assert.match(correctedAria, /Type 2/);
assert.equal(await page.locator('[data-type="2"]').getAttribute('class').then(v => v.includes('selected')), true);
assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), false);
await page.screenshot({ path: 'artifacts/provisional-suggestion-corrected-mobile.png' });
// 4. The correction keeps every user-entered field and reports it honestly on later steps.
await page.locator('#next').click();
assert.equal(await page.locator('#color').inputValue(), 'brown', 'the confirmed color is kept, not silently re-suggested');
assert.equal(await page.locator('#urgency').inputValue(), '3', 'user context is not lost when a suggestion is corrected');
assert.equal(await page.locator('#note').inputValue(), 'After lunch');
assert.match(await page.locator('.provenance-note').last().innerText(), /You changed it to Type 2/);
assert.match(await page.locator('.provenance-note').last().innerText(), /must come from you/i);
// 5. Correcting the second suggested field keeps both corrections in the provenance record.
await page.locator('#color').selectOption('black');
await page.locator('#back').click();
const recolored = page.locator('[data-provenance="corrected"]');
assert.equal(await recolored.count(), 1);
assert.match(await recolored.getAttribute('aria-label'), /Type 2, black/);
assert.deepEqual(errors, []);
await context.close();
await browser.close();
console.log('PASS AI suggestions stay provisional, confirmation is explicit, and correcting a suggestion clears AI-confirmed state without losing user context');
+21
View File
@@ -38,6 +38,18 @@ test('release demo exercises the vision-offline manual path and the retained dra
assert.match(demo, /providerReady: false|visionOffline/); assert.match(demo, /providerReady: false|visionOffline/);
}); });
test('release demo shows the provisional, confirmed, and corrected suggestion states on camera', async () => {
const demo = await readFile(demoPath, 'utf8');
assert.match(demo, /\[data-provenance="pending"\]/);
assert.match(demo, /The suggestion stays provisional — nothing is applied or saved until you confirm it/);
assert.match(demo, /Confirming is explicit, and every suggested value is still yours to overrule/);
assert.match(demo, /Change a suggested value and Timmy clears the AI-confirmed state/);
assert.match(demo, /\[data-provenance="corrected"\]/);
assert.match(demo, /Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours/);
assert.match(demo, /Type 2/, 'the retained draft in the demo must show the value the user corrected to');
});
test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => { test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => {
const builder = await readFile(builderPath, 'utf8'); const builder = await readFile(builderPath, 'utf8');
assert.match(builder, /"test:sleek"/); assert.match(builder, /"test:sleek"/);
@@ -52,3 +64,12 @@ test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax',
assert.match(builder, /Sleek three-destination shell/); assert.match(builder, /Sleek three-destination shell/);
assert.match(builder, /"bash", "-n", "scripts\/bootstrap_selfhost_smolvlm\.sh"/); assert.match(builder, /"bash", "-n", "scripts\/bootstrap_selfhost_smolvlm\.sh"/);
}); });
test('release builder gates the provisional suggestion states the same way CI does', async () => {
const builder = await readFile(builderPath, 'utf8');
assert.match(builder, /"test:provisional"/);
assert.match(builder, /"provisional_suggestion_state": "passed"/);
assert.match(builder, /"src\/suggestion\.js"/);
assert.match(builder, /AI suggestions stay provisional/);
});
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from '../src/suggestion.js';
test('keeps an AI suggestion provisional until the user acts on it', () => {
const state = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
assert.equal(state.status, 'pending');
assert.equal(state.affirmed, false);
assert.deepEqual(state.suggestion, { bristolType: 4, color: 'brown', confidence: 0.83 });
});
test('treats a missing or unusable suggestion as no suggestion at all', () => {
assert.deepEqual(createSuggestionState(null).status, 'none');
assert.deepEqual(createSuggestionState(undefined).suggestion, null);
assert.deepEqual(createSuggestionState({ status: 'needs_user_input', reason: 'The image is too uncertain to prefill safely.' }).status, 'none');
});
test('confirms a suggestion only when the user explicitly accepts it', () => {
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
const confirmed = confirmSuggestion(pending);
assert.equal(confirmed.status, 'confirmed');
assert.equal(confirmed.affirmed, true);
assert.deepEqual(confirmed.suggestion, pending.suggestion);
const nothing = confirmSuggestion(createSuggestionState(null));
assert.equal(nothing.status, 'none');
assert.equal(nothing.affirmed, false);
assert.equal(confirmSuggestion(confirmed).status, 'confirmed');
});
test('changing a suggested field clears AI-confirmed state but keeps the context', () => {
const confirmed = confirmSuggestion(createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 }));
const corrected = changeVisualField(confirmed, 'bristolType', 2);
assert.equal(corrected.status, 'corrected');
assert.equal(corrected.affirmed, false);
assert.deepEqual(corrected.suggestion, { bristolType: 4, color: 'brown', confidence: 0.83 });
assert.deepEqual(corrected.correctedFields, ['bristolType']);
const recorrected = changeVisualField(corrected, 'color', 'black');
assert.deepEqual(recorrected.correctedFields, ['bristolType', 'color']);
assert.deepEqual(changeVisualField(recorrected, 'note', 'ignored').correctedFields, ['bristolType', 'color']);
});
test('does not invent an AI correction when the user never had a suggestion', () => {
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
assert.equal(manual.status, 'manual');
assert.equal(manual.suggestion, null);
assert.deepEqual(manual.correctedFields, []);
});
test('re-picking the suggested value is not treated as a correction', () => {
const confirmed = confirmSuggestion(createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 }));
assert.equal(changeVisualField(confirmed, 'bristolType', 4).status, 'confirmed');
assert.deepEqual(changeVisualField(confirmed, 'bristolType', 4).correctedFields, []);
});
test('labels suggested, confirmed, corrected, and manual states distinctly', () => {
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
const confirmed = confirmSuggestion(pending);
const corrected = changeVisualField(confirmed, 'bristolType', 2);
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
const labels = [pending, confirmed, corrected, manual].map(state => provenanceCopy(state, { bristolType: 2, color: 'brown' }).label);
assert.deepEqual(labels, ['AI SUGGESTION', 'AI PREFILLED', 'CORRECTED BY YOU', 'YOUR OWN ENTRY']);
assert.equal(provenanceCopy(createSuggestionState(null), {}).label, '');
});
test('screen-reader copy carries the AI confidence and who is in control', () => {
const pending = createSuggestionState({ status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.83 });
const confirmed = confirmSuggestion(pending);
const corrected = changeVisualField(confirmed, 'bristolType', 2);
const manual = changeVisualField(createSuggestionState(null), 'bristolType', 6);
assert.match(provenanceCopy(pending, { bristolType: 4, color: 'brown' }).aria, /83 percent confidence/i);
assert.match(provenanceCopy(pending, { bristolType: 4, color: 'brown' }).aria, /not saved until you confirm/i);
assert.match(provenanceCopy(confirmed, { bristolType: 4, color: 'brown' }).aria, /83 percent confidence/i);
assert.match(provenanceCopy(confirmed, { bristolType: 4, color: 'brown' }).aria, /You confirmed/i);
const correctedAria = provenanceCopy(corrected, { bristolType: 2, color: 'brown' }).aria;
assert.match(correctedAria, /You changed/i);
assert.match(correctedAria, /Type 4/);
assert.match(correctedAria, /Type 2/);
assert.equal(corrected.affirmed, false);
assert.match(provenanceCopy(manual, { bristolType: 6, color: 'brown' }).aria, /No AI suggestion/i);
});