diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index d55b2c9..830a5e6 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -53,6 +53,7 @@ jobs: npm run test:mobile-capture npm run test:sleek npm run test:manual-fallback + npm run test:provisional - name: Dependency audit run: npm audit --audit-level=high - name: Syntax checks diff --git a/app.js b/app.js index 022975b..0306d4e 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,7 @@ import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.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'; const runtimeConfig = { @@ -26,6 +27,7 @@ let entries = loadEntries(); let view = 'home'; let photoHint = ''; let aiSuggestion = null; +let suggestionState = createSuggestionState(null); let visionStatus = null; let photoFirstMode = 'pick'; let agentStatus = null; @@ -40,7 +42,15 @@ function draftBanner(){ return `
Draft kept in this session: ${esc(draftNotice(form))}
`; } 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 `
${esc(copy.label)}Type ${form.bristolType} · ${esc(form.color)}You’re in charge—tap any type to correct it.

${esc(copy.detail)}

`; +} +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)} function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); } 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)}} 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(){ 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}} @@ -147,7 +157,7 @@ function photoFirstBody(mode,error=''){ if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}Photo kept on this device

${esc(photoHint)}

${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.

`;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()}Photo awaiting AI analysis

${esc(photoHint)}

`;} if(mode==='analyzing')return `
Timmy

Timmy is looking at form and color…

Not symptoms. Not disease. Not whether Taco Bell was a strategic error.

`; if(mode==='error')return `
↻

Timmy couldn’t analyze that safely.

${esc(error||'Continue manually or try a clearer photo.')}

`; - if(aiSuggestion?.status==='suggestion')return `
AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE
BRISTOL FORMType ${aiSuggestion.bristolType}
VISIBLE COLOR${esc(aiSuggestion.color)}

${esc(aiSuggestion.observations||'Visual match found.')}

${esc(aiSuggestion.warning)}

`; + if(suggestionState.status==='pending'){const copy=provenanceCopy(suggestionState,form);return `
${esc(copy.label)} · ${Math.round(suggestionState.suggestion.confidence*100)}% CONFIDENCE
BRISTOL FORMType ${suggestionState.suggestion.bristolType}
VISIBLE COLOR${esc(suggestionState.suggestion.color)}

${esc(aiSuggestion.observations||'Visual match found.')}

${esc(copy.detail)}

${esc(aiSuggestion.warning)}

`;} return `
?

No confident match.

${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}

`; } 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=``;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.'))}); 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('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)}); - document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)}); + document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')}); + document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();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 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){ document.querySelector('.sheet-backdrop')?.remove(); const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;document.body.append(wrap); document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step); } function stepBody(step){ - if(step===1)return `${aiSuggestion?.status==='suggestion'?`
AI PREFILLEDType ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}You’re in charge—tap any type to correct it.
`:'

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)}

`:''}${aiSuggestion?.status==='suggestion'?'

AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.

':'

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

'}
`; + 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.

`; } 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=()=>{form.bristolType=Number(b.dataset.type);form.typeChosen=true;aiSuggestion=null;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===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} } 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)}} diff --git a/artifacts/photo-first-result-mobile.png b/artifacts/photo-first-result-mobile.png index 292e34b..3af6520 100644 Binary files a/artifacts/photo-first-result-mobile.png and b/artifacts/photo-first-result-mobile.png differ diff --git a/artifacts/provisional-suggestion-confirmed-mobile.png b/artifacts/provisional-suggestion-confirmed-mobile.png new file mode 100644 index 0000000..424ad17 Binary files /dev/null and b/artifacts/provisional-suggestion-confirmed-mobile.png differ diff --git a/artifacts/provisional-suggestion-corrected-mobile.png b/artifacts/provisional-suggestion-corrected-mobile.png new file mode 100644 index 0000000..31576ef Binary files /dev/null and b/artifacts/provisional-suggestion-corrected-mobile.png differ diff --git a/artifacts/provisional-suggestion-pending-mobile.png b/artifacts/provisional-suggestion-pending-mobile.png new file mode 100644 index 0000000..3af6520 Binary files /dev/null and b/artifacts/provisional-suggestion-pending-mobile.png differ diff --git a/package.json b/package.json index 34020b3..6b2679d 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,15 @@ "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/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: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: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", "start": "node server.mjs" }, diff --git a/scripts/build_release.py b/scripts/build_release.py index 350f06d..906a2fd 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -112,6 +112,7 @@ def main() -> int: run(["npm", "run", "test:mobile-capture"], tree) run(["npm", "run", "test:sleek"], 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_env = dict(server_env) 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" 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/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(["bash", "-n", "scripts/bootstrap_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" "- 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" "- 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" @@ -207,7 +209,7 @@ def main() -> int: "codec": "h264", "pixel_format": "yuv420p", "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": { "unit_security": "passed", @@ -215,6 +217,7 @@ def main() -> int: "photo_first_acceptance": "passed", "sleek_hermes_chat_acceptance": "passed", "manual_fallback_draft_recovery": "passed", + "provisional_suggestion_state": "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 7cec61d..e518b93 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -136,8 +136,18 @@ await page.locator('#ai-consent').check(); await tap('#analyze-photo', 450); 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 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 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 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); @@ -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 tap('#manual-from-scan', 500); 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'); -await caption('One tap still reaches manual Bristol entry', 1500); +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, and the draft keeps your corrected value', 1800); await tap('#discard-draft', 500); await caption('Start over clears the kept draft — nothing lingers silently', 1500); await tap('#close-sheet', 450); diff --git a/src/suggestion.js b/src/suggestion.js new file mode 100644 index 0000000..4e425f8 --- /dev/null +++ b/src/suggestion.js @@ -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.`, + }; +} diff --git a/styles.css b/styles.css index ca91348..8364f7f 100644 --- a/styles.css +++ b/styles.css @@ -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} @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}} +/* #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} diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index c09b60b..0a58a8f 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -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/); }); +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 () => { const [packageJson, workflow] = await Promise.all([ readFile(packagePath, 'utf8').then(JSON.parse), diff --git a/tests/provisional-suggestion.acceptance.mjs b/tests/provisional-suggestion.acceptance.mjs new file mode 100644 index 0000000..5612e24 --- /dev/null +++ b/tests/provisional-suggestion.acceptance.mjs @@ -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'); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 7b80742..0d04bfc 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -38,6 +38,18 @@ test('release demo exercises the vision-offline manual path and the retained dra 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 () => { const builder = await readFile(builderPath, 'utf8'); 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, /"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/); +}); diff --git a/tests/suggestion.test.js b/tests/suggestion.test.js new file mode 100644 index 0000000..ea9840d --- /dev/null +++ b/tests/suggestion.test.js @@ -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); +});