Move all three skills into skills/; add root README; fix paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
312742cfef
commit
d95e5d452d
84 files changed
+97
-31
No files matched your search
@@ -0,0 +1,276 @@
|
||||
<!-- ===== 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>
|
||||
Reference in new issue
Block a user