feat: make AI suggestions visibly provisional and correction-first (#13)
Quality gates / quality (pull_request) Successful in 1m45s
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:
1 parent
3e87f881c5
commit
5aee7436f2
15 files changed
+366
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 `<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 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&©.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()}<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==='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>`;
|
||||
}
|
||||
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.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=`<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);
|
||||
}
|
||||
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===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===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>`:''}${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>`;
|
||||
}
|
||||
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
|
||||
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)}}
|
||||
|
||||
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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user