AI desktop path: plain-HTML Review and Cost tabs over the Qt bridge, gate in Python

ai_sections.html pastes into the no-AI dashboard.html; ai_bridge.py adds one
ai_request slot that hands every AI contract request (incl. GET /roi) to the
review backend's dispatch(). Desktop page check reruns the web review and cost
cases plus a tampered-page gate case. Eval fixture e-pyqt-ai added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 15:32:45 +05:30
1 parent 128f153f84
commit d89dce3c33
13 files changed
+203 -14

No files matched your search

@@ -34,6 +34,9 @@ body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
.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>
@@ -43,6 +46,8 @@ body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
<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">
@@ -61,6 +66,29 @@ body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
</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.
@@ -71,18 +99,21 @@ 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 -----
// ----- 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>');
document.querySelector('.nav-item[data-tab="review"]').onclick=()=>showTab('review');
document.querySelector('.content').append($('ai-review-tab').content.cloneNode(true));
'<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(); });
@@ -192,5 +223,42 @@ function renderSave(){
<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>