Files
Rpa_Skills/rpa-dashboard-ai/templates/desktop/ai_sections.html
T
VasanthandClaude Opus 5.5 461798127a AI sections fall back to fetch with no Qt bridge, for non-React web frontends
ai_sections.html fetches {API_BASE}+path when window.qt is absent; a page-check case routes those requests to server_stub.dispatch(). AI SKILL.md step 6 and frontend.md send plain-HTML/Vue/Angular/Svelte projects to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:55:30 +05:30

277 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- ===== 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.
In PyQt, 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"). With no
Qt (a plain-HTML web project), it fetches {API_BASE}{path} from the backend instead, the
web endpoints in ../contract.md. The approve gate is the 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)}
.co-tile{padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border)}
.co-tile .kpi-val{margin-top:4px}
.co-r{text-align:right}
@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>
<!-- OPTIONAL: retry on another model (failed runs) goes here -->
<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>
<template id="ai-cost-tab">
<div id="tab-cost" class="hidden">
<div class="toolbar"><span class="pill" id="co-cover"></span><span class="rv-muted" id="co-source"></span></div>
<div class="kpi-row" id="co-tiles"></div>
<div class="card"><div class="c-title">Month by month</div>
<div class="tbl-wrap"><table aria-label="Spend by month"><thead><tr><th>Month</th><th>Documents priced</th><th class="co-r">Spend</th></tr></thead><tbody id="co-months"></tbody></table></div>
</div>
<section class="card" aria-labelledby="co-by-hand">
<div class="c-title" id="co-by-hand">Against doing it by hand</div>
<p class="rv-muted" style="margin-bottom:12px">These are your assumptions, not a measurement. Change them to match the team.</p>
<div class="toolbar">
<label class="rv-muted">Minutes per document <input id="co-min" type="number" min="0" value="5" style="width:80px"></label>
<label class="rv-muted">Hourly rate <input id="co-rate" type="number" min="0" value="20" style="width:80px"></label>
</div>
<p id="co-hand" style="font-size:13px"></p>
</section>
<!-- OPTIONAL: model picker (per batch) goes here -->
<div class="card"><div class="c-title">Cost per run</div>
<div class="tbl-wrap"><table aria-label="Cost per run"><thead><tr><th>Document</th><th>Date</th><th>Model</th><th class="co-r">Input</th><th class="co-r">Output</th><th class="co-r">Thinking</th><th class="co-r">Cost</th></tr></thead><tbody id="co-runs"></tbody></table></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 tabs: nav buttons after Report, sections in the content area -----
TABS.review=['Review','Check extracted fields, then approve'];
TABS.cost=['Cost','What the AI cost, run by run'];
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>'
+'<button class="nav-item" data-tab="cost" title="Cost"><svg viewBox="0 0 24 24"><path d="M12 2v20M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg><span>Cost</span></button>');
['review','cost'].forEach(t=>{ document.querySelector(`.nav-item[data-tab="${t}"]`).onclick=()=>showTab(t);
document.querySelector('.content').append($(`ai-${t}-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();
if(t==='cost') loadRoi();
};
window.addEventListener('beforeunload',e=>{ if(dirty().length) e.preventDefault(); });
// ----- transport: in PyQt, one bridge slot out, one `ai_response` envelope back;
// with no Qt (a plain web page), fetch {API_BASE}{path} from the backend -----
const pending={}; let seq=0;
async function aiFetch(method,path,body){
const r=await fetch(API_BASE+path,{method,headers:{'Content-Type':'application/json'},
body:method==='GET'?undefined:JSON.stringify(body||{})});
const b=await r.json().catch(()=>({}));
if(!r.ok) throw new Error(b.detail||`Error ${r.status}`);
return b;
}
function ai(method,path,body){
if(!window.qt) return aiFetch(method,path,body);
if(!bridge) return Promise.reject(new Error('The Qt bridge is not ready yet. Try again.'));
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;
if(!window.qt) $('rv-doc').src=`${API_BASE}/runs/${encodeURIComponent(id)}/document`; // web: the URL itself
else 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; }
}
// ----- cost: measured (tokens and cost per run, priced in Python) kept apart from assumed (typed here) -----
// A run with no recorded token usage is "not priced", never $0, and the coverage pill says how many.
let roi=[];
const money=n=>`$${n.toFixed(n>0&&n<0.01?4:2)}`, num=n=>n==null?'—':n.toLocaleString('en-US');
const monthOf=iso=>iso.slice(0,7); // 'YYYY-MM', UTC
const monthLabel=ym=>new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US',{month:'short',year:'numeric',timeZone:'UTC'});
const sum=rs=>rs.reduce((n,r)=>n+(r.total_cost??0),0);
const tileHtml=(name,value,note)=>`<div class="co-tile" role="group" aria-label="${name}"><div class="kpi-lbl">${name}</div><div class="kpi-val">${value}</div><div class="kpi-lbl">${esc(note)}</div></div>`;
async function loadRoi(){
let d; try{ d=await ai('GET','/roi'); }catch(e){ $('co-source').textContent=`Can't load costs: ${e.message}`; return; }
roi=d.runs; const p=d.pricing||{};
$('co-source').innerHTML=`Rates from ${esc(p.source)}, as of ${esc(p.as_of)}`;
renderCost();
}
function renderCost(){
const priced=roi.filter(r=>r.total_cost!=null), thisMonth=new Date().toISOString().slice(0,7);
const tok=k=>priced.reduce((n,r)=>n+(r[k]??0),0);
$('co-cover').textContent=`${priced.length} of ${roi.length} documents priced`;
$('co-cover').className='pill '+(priced.length===roi.length?'success':'attention');
$('co-tiles').innerHTML=tileHtml('Total spend',money(sum(priced)),`${priced.length} priced runs`)
+tileHtml('This month',money(sum(roi.filter(r=>monthOf(r.created_at)===thisMonth))),monthLabel(thisMonth))
+tileHtml('Tokens',num(tok('input_tokens')+tok('output_tokens')+tok('thinking_tokens')),`in ${num(tok('input_tokens'))} · out ${num(tok('output_tokens'))} · thinking ${num(tok('thinking_tokens'))}`)
+tileHtml('Per document',priced.length?money(sum(priced)/priced.length):'—','average, priced runs');
$('co-months').innerHTML=[...new Set(roi.map(r=>monthOf(r.created_at)))].sort().reverse().map(m=>{
const rs=roi.filter(r=>monthOf(r.created_at)===m), n=rs.filter(r=>r.total_cost!=null).length;
return `<tr><td>${monthLabel(m)}${m===thisMonth?' <span class="rv-muted">this month</span>':''}</td><td>${n} of ${rs.length}</td><td class="co-r">${n?money(sum(rs)):'—'}</td></tr>`; }).join('');
$('co-runs').innerHTML=roi.map(r=>`<tr><td>${esc(r.document)}</td><td>${esc(r.created_at.slice(0,10))}</td><td>${esc(r.model)}</td>
<td class="co-r">${num(r.input_tokens)}</td><td class="co-r">${num(r.output_tokens)}</td><td class="co-r">${num(r.thinking_tokens)}</td>
<td class="co-r">${r.total_cost==null?'<span class="rv-muted">not priced</span>':money(r.total_cost)}</td></tr>`).join('');
renderByHand();
}
function renderByHand(){
const n=roi.filter(r=>r.total_cost!=null).length, min=+$('co-min').value||0, rate=+$('co-rate').value||0;
$('co-hand').innerHTML=`By hand, ${n} documents would take ${num(Math.round(n*min/6)/10)} h, about <b>${money(n*min/60*rate)}</b>. The AI spent <b>${money(sum(roi))}</b> on them.`;
}
$('co-min').oninput=$('co-rate').oninput=renderByHand;
})();
</script>