197 lines
13 KiB
HTML
197 lines
13 KiB
HTML
<!-- ===== AI SECTIONS (desktop) =====
|
||
Paste this whole block into the no-AI dashboard.html, just before </body>. It adds a Review
|
||
tab: band legend, band filters, the source document beside its extracted fields, drafts,
|
||
Verify, and Approve. Plain HTML, the page's own theme tokens, no build step.
|
||
Every request goes over the Qt bridge as one slot call, ai_request(id, method, path, body),
|
||
and comes back as an `ai_response` envelope (see ../contract.md, "Desktop"). The approve
|
||
gate is the Python backend's: this page only asks, and shows the refusal.
|
||
-->
|
||
<style>
|
||
/* BAND COLOURS: semantic only (swatches, the rule down a field). The same in every preset and both modes. */
|
||
body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
|
||
.rv-bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding:14px 18px}
|
||
.rv-bar label{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600}
|
||
.rv-bar select{padding:7px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||
.rv-muted{font-size:12px;color:var(--muted)}
|
||
.rv-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||
.rv-alert{width:100%;border-radius:10px;padding:7px 12px;background:var(--bad-bg);color:var(--bad);font-size:12px;font-weight:700}
|
||
.rv-legend{display:flex;flex-wrap:wrap;gap:4px 16px}
|
||
.rv-legend li{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600}
|
||
.rv-sw{width:10px;height:10px;border-radius:3px;display:inline-block}
|
||
.rv-filters{margin-left:auto;display:flex;gap:4px}
|
||
.rv-filters .btn,.rv-bar .btn,.rv-fields .btn,.rv-save .btn{padding:6px 11px;font-size:12px}
|
||
.rv-split{display:flex;gap:14px;min-height:460px}
|
||
.rv-doc{flex:1;min-height:420px;border:1px solid var(--border);border-radius:16px;background:#fff}
|
||
.rv-pane{flex:1;display:flex;flex-direction:column;padding:0;overflow:hidden;margin-bottom:0}
|
||
.rv-fields{flex:1;overflow-y:auto;padding:16px}
|
||
.rv-fields li{display:grid;grid-template-columns:minmax(80px,130px) 1fr auto auto;align-items:center;gap:8px;margin-bottom:9px}
|
||
.rv-fields li.done{opacity:.7}
|
||
.rv-name{font-size:12px;font-weight:600;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rv-fields input{min-width:0;padding:7px 10px;border:1px solid var(--border);border-left-width:4px;border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||
.rv-fields input.dirty{outline:2px solid var(--warn)}
|
||
.rv-score{width:36px;text-align:right;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||
.rv-note{grid-column:1/-1;margin-top:-4px;font-size:11px;color:var(--muted)}
|
||
.rv-fields .btn{width:92px}
|
||
.rv-save{display:flex;align-items:center;gap:10px;border-top:1px solid var(--border);padding:10px 16px}
|
||
.rv-save .rv-unsaved{font-size:12px;font-weight:700;color:var(--warn)}
|
||
@media (max-width:820px){.rv-split{flex-direction:column}.rv-filters{margin-left:0}}
|
||
</style>
|
||
|
||
<template id="ai-review-tab">
|
||
<div id="tab-review" class="hidden">
|
||
<div class="card rv-bar" id="rv-head">
|
||
<label>Document <select id="rv-doc-pick" aria-label="Document"></select></label>
|
||
<span id="rv-state"></span>
|
||
<div class="rv-right" id="rv-actions"></div>
|
||
<p class="rv-alert hidden" id="rv-alert" role="alert"></p>
|
||
</div>
|
||
<div class="card rv-bar">
|
||
<ul class="rv-legend" id="rv-legend" aria-label="Confidence bands"></ul>
|
||
<span class="rv-muted">Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.</span>
|
||
<div class="rv-filters" id="rv-filters" role="group" aria-label="Filter by band"></div>
|
||
</div>
|
||
<div class="rv-split">
|
||
<iframe class="rv-doc" id="rv-doc" title="Source document"></iframe>
|
||
<div class="card rv-pane">
|
||
<p class="rv-muted hidden" id="rv-wait" style="padding:18px">Fields appear when extraction finishes.</p>
|
||
<ul class="rv-fields" id="rv-fields" aria-label="Extracted fields"></ul>
|
||
<div class="rv-save hidden" id="rv-save"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
(()=>{
|
||
// Legend order, label and colour token. Cut points come from bands.py on the Python side.
|
||
const BANDS=[['green','Clean ≥0.95','var(--clean)'],['amber','Check 0.90–0.95','var(--verify)'],
|
||
['red','Review <0.90','var(--review)'],['blank','Missing (required)','var(--absent)'],['unscored','Unscored','var(--absent)']];
|
||
const FILTERS=[['All','all'],['Red','red'],['Amber','amber'],['Missing','blank'],['Unscored','unscored']];
|
||
const BUSY=['queued','running']; // polled every 1.5s while in one of these
|
||
const colourOf=b=>BANDS.find(x=>x[0]===b)[2];
|
||
const settled=r=>r.corrected_value??r.value??'';
|
||
|
||
// ----- the tab: nav button after Report, section in the content area -----
|
||
TABS.review=['Review','Check extracted fields, then approve'];
|
||
document.querySelector('.nav-item[data-tab="report"]').insertAdjacentHTML('afterend',
|
||
'<button class="nav-item" data-tab="review" title="Review"><svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M20 12v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h11"/></svg><span>Review</span></button>');
|
||
document.querySelector('.nav-item[data-tab="review"]').onclick=()=>showTab('review');
|
||
document.querySelector('.content').append($('ai-review-tab').content.cloneNode(true));
|
||
const baseShow=showTab;
|
||
showTab=t=>{
|
||
const onReview=!$('tab-review').classList.contains('hidden');
|
||
if(onReview && t!=='review' && !leaveOk()) return;
|
||
baseShow(t);
|
||
if(t==='review') loadRuns();
|
||
};
|
||
window.addEventListener('beforeunload',e=>{ if(dirty().length) e.preventDefault(); });
|
||
|
||
// ----- transport: one bridge slot out, one `ai_response` envelope back -----
|
||
const pending={}; let seq=0;
|
||
function ai(method,path,body){
|
||
if(!bridge) return Promise.reject(new Error('Review needs the desktop app (no Qt bridge here).'));
|
||
return new Promise((ok,fail)=>{ const id=++seq; pending[id]=[ok,fail];
|
||
bridge.ai_request(id,method,path,JSON.stringify(body||{})); });
|
||
}
|
||
const baseHandle=handle;
|
||
handle=(event,d)=>{
|
||
if(event!=='ai_response') return baseHandle(event,d);
|
||
const p=pending[d.id]; delete pending[d.id];
|
||
if(p) d.status<300 ? p[0](d.body) : p[1](new Error((d.body&&d.body.detail)||`Error ${d.status}`));
|
||
};
|
||
|
||
// ----- state -----
|
||
let runs=[], jobId=null, run=null, rows=[], drafts={}, filter='all', refusal=null, timer=null;
|
||
const dirty=()=>rows.filter(r=>r.row_id in drafts && drafts[r.row_id]!==settled(r));
|
||
const leaveOk=()=>!dirty().length || confirm(`${dirty().length} unsaved change(s) on this document. Discard them and leave?`);
|
||
const fail=e=>{ refusal=e.message; renderHead(); };
|
||
|
||
async function loadRuns(){
|
||
try{ runs=(await ai('GET','/runs')).runs; }catch(e){ return fail(e); }
|
||
$('rv-doc-pick').innerHTML=runs.map(r=>`<option value="${esc(r.job_id)}">${esc(r.document)}</option>`).join('');
|
||
if(!runs.length){ refusal=null; $('rv-state').innerHTML='<span class="rv-muted">No documents extracted yet.</span>'; return; }
|
||
openRun(runs.some(r=>r.job_id===jobId) ? jobId : runs[0].job_id);
|
||
}
|
||
$('rv-doc-pick').onchange=e=>{ if(leaveOk()) openRun(e.target.value); else e.target.value=jobId; };
|
||
|
||
function openRun(id){
|
||
clearTimeout(timer);
|
||
jobId=id; run=null; rows=[]; drafts={}; refusal=null;
|
||
$('rv-doc-pick').value=id;
|
||
ai('GET',`/runs/${id}/document`).then(doc=>{ if(id===jobId) $('rv-doc').src=
|
||
String(doc).startsWith('<') ? 'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(doc) : doc; }).catch(()=>{});
|
||
render(); tick(id);
|
||
}
|
||
async function tick(id){ // load the run, and poll it while it is still extracting
|
||
try{
|
||
const r=await ai('GET',`/runs/${id}`); if(id!==jobId) return;
|
||
run=r;
|
||
if(BUSY.includes(r.status)) timer=setTimeout(()=>tick(id),1500);
|
||
else{ const d=await ai('GET',`/runs/${id}/review`); if(id!==jobId) return; rows=d.rows; }
|
||
render();
|
||
}catch(e){ if(id===jobId) fail(e); }
|
||
}
|
||
async function reload(){
|
||
const id=jobId, [r,d]=await Promise.all([ai('GET',`/runs/${id}`),ai('GET',`/runs/${id}/review`)]);
|
||
if(id===jobId){ run=r; rows=d.rows; render(); }
|
||
}
|
||
|
||
// ----- actions -----
|
||
async function act(fn){ refusal=null; try{ await fn(); }catch(e){ refusal=e.message; } await reload().catch(fail); }
|
||
const save=()=>act(()=>Promise.all(dirty().map(r=>{ const text=drafts[r.row_id].trim();
|
||
// Same as extracted (or emptied) means "the extracted value was right", not an edit.
|
||
return ai('PATCH',`/review/${r.row_id}`,{corrected_value: text===''||text===(r.value??'') ? null : text, verified:true});
|
||
})).then(()=>{ drafts={}; }));
|
||
const verify=r=>act(()=>ai('PATCH',`/review/${r.row_id}`,{verified:!r.verified}));
|
||
const verifyRemaining=()=>act(()=>ai('POST',`/runs/${jobId}/verify-remaining`));
|
||
const approve=()=>act(()=>ai('POST',`/runs/${jobId}/approve`).catch(e=>{ throw new Error('Not approved: '+e.message); }));
|
||
|
||
// ----- render -----
|
||
function render(){ renderHead(); renderLegend(); renderFields(); renderSave(); }
|
||
function renderHead(){
|
||
const busy=run && BUSY.includes(run.status), pill=!run?'':run.status==='done'?'success':run.status==='failed'?'failed':'attention';
|
||
$('rv-state').innerHTML = run ? `<span class="pill ${pill}">${esc(run.status)}</span>`
|
||
+ (busy ? ` <span class="rv-muted">page ${esc(run.pages_done)} of ${esc(run.pages_total||'?')}</span>` : '')
|
||
+ (run.error ? ` <span class="rv-muted" style="color:var(--bad)">${esc(run.error)}</span>` : '') : '';
|
||
const left=rows.filter(r=>r.band!=='green' && !r.verified).length, n=dirty().length, acts=$('rv-actions');
|
||
acts.innerHTML = run && run.status==='done' ? `<span class="rv-muted">${left} to verify</span>
|
||
<button class="btn" id="rv-vr"${!left||run.approved?' disabled':''}>Verify remaining</button>`
|
||
+ (run.approved ? '<span class="pill success">✓ Approved</span>'
|
||
: `<button class="btn primary" id="rv-ap"${n?' disabled title="Save or discard your edits first"':''}>Approve</button>`) : '';
|
||
if($('rv-vr')) $('rv-vr').onclick=verifyRemaining;
|
||
if($('rv-ap')) $('rv-ap').onclick=approve;
|
||
$('rv-alert').textContent=refusal||''; $('rv-alert').classList.toggle('hidden',!refusal);
|
||
}
|
||
function renderLegend(){
|
||
const s=(run&&run.summary)||{};
|
||
$('rv-legend').innerHTML=BANDS.map(([b,label,c])=>`<li><i class="rv-sw" style="background:${c}"></i>${label} · ${s[b]||0}</li>`).join('');
|
||
$('rv-filters').innerHTML=FILTERS.map(([label,b])=>`<button class="btn${filter===b?' primary':''}" aria-pressed="${filter===b}" data-band="${b}">${label}</button>`).join('');
|
||
$('rv-filters').querySelectorAll('button').forEach(btn=>btn.onclick=()=>{ filter=btn.dataset.band; renderLegend(); renderFields(); });
|
||
}
|
||
function renderFields(){
|
||
$('rv-wait').classList.toggle('hidden',!run||run.status==='done');
|
||
const shown=filter==='all'?rows:rows.filter(r=>r.band===filter);
|
||
$('rv-fields').innerHTML=shown.map(r=>{ const draft=drafts[r.row_id]??settled(r), isDirty=draft!==settled(r);
|
||
const note=[r.reason, r.corrected_value!=null ? `extracted <s>${esc(r.value||'empty')}</s>` : ''].filter(Boolean);
|
||
return `<li data-row="${r.row_id}" class="${r.verified&&!isDirty?'done':''}">
|
||
<span class="rv-name" title="${esc(r.field)}">${esc(r.field)}</span>
|
||
<input aria-label="${esc(r.field)}" value="${esc(draft)}" placeholder="empty" title="${esc(r.reason||'')}" class="${isDirty?'dirty':''}" style="border-left-color:${colourOf(r.band)}">
|
||
<span class="rv-score" title="Legibility score: how clearly it was read, not whether it is right">${r.score==null?'—':Number(r.score).toFixed(2)}</span>
|
||
<button class="btn" aria-label="Verify ${esc(r.field)}" aria-pressed="${!!r.verified}"${isDirty?' disabled':''}>${r.verified?'✓ Verified':'Verify'}</button>
|
||
${note.length?`<span class="rv-note">${note.map(x=>x===r.reason?esc(x):x).join(' · ')}</span>`:''}</li>`; }).join('');
|
||
$('rv-fields').querySelectorAll('li').forEach(li=>{ const r=rows.find(x=>x.row_id===+li.dataset.row), box=li.querySelector('input');
|
||
box.oninput=()=>{ drafts[r.row_id]=box.value; const d=box.value!==settled(r); // a draft: nothing is sent until Save
|
||
box.classList.toggle('dirty',d); li.querySelector('button').disabled=d; renderSave(); renderHead(); };
|
||
box.onkeydown=e=>{ if(e.key==='Escape'){ box.value=settled(r); box.oninput(); } };
|
||
li.querySelector('button').onclick=()=>verify(r); });
|
||
}
|
||
function renderSave(){
|
||
const n=dirty().length;
|
||
$('rv-save').classList.toggle('hidden',!n);
|
||
$('rv-save').innerHTML = n ? `<span class="rv-unsaved">${n} unsaved change${n===1?'':'s'}</span>
|
||
<button class="btn" id="rv-discard" style="margin-left:auto">Discard</button><button class="btn primary" id="rv-save-btn">Save</button>` : '';
|
||
if(n){ $('rv-discard').onclick=()=>{ drafts={}; render(); }; $('rv-save-btn').onclick=save; }
|
||
}
|
||
})();
|
||
</script>
|