WIP: AI desktop sections, bridge slot, desktop page check
This commit is contained in:
1 parent
8951ac05a5
commit
472e18de0d
8 files changed
+465
-16
No files matched your search
@@ -50,3 +50,18 @@ Errors answer with a non-2xx status and `{detail: "<why>"}`. The page shows `det
|
||||
|
||||
`templates/web/server_stub.py` implements all of this on sample data, standard library
|
||||
only. A real backend serves the same endpoints with the same rules.
|
||||
|
||||
## Desktop (PyQt, no web server)
|
||||
|
||||
The same endpoints and rules travel over the Qt bridge instead of HTTP:
|
||||
|
||||
- Page to Python: one slot, `ai_request(id, method, path, body)`. `path` is the endpoint
|
||||
path above without `{API_BASE}`, `body` is a JSON string (`"{}"` when there is none).
|
||||
- Python to page: one envelope, `{event: "ai_response", data: {id, status, body}}`, with the
|
||||
same `status` and JSON `body` the web endpoint would answer.
|
||||
- `GET /runs/{job_id}/document` answers an SVG string or a URL the page can load (for
|
||||
example a `file:///` path to the PDF).
|
||||
|
||||
`templates/desktop/ai_bridge.py` adds that slot to the no-AI `Bridge`. It hands each
|
||||
request to the review backend's `dispatch(method, path, body)` (`server_stub.py` until the
|
||||
project has its own), so the approve gate is enforced in Python, never in the page.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""PyQt6 host for the AI desktop dashboard: the no-AI Bridge plus one slot for the AI contract.
|
||||
|
||||
Copy next to the no-AI bridge.py and bot_runner.py, with server_stub.py and bands.py.
|
||||
Run it with: python dashboard/ai_bridge.py
|
||||
|
||||
The page sends each AI request (templates/contract.md: runs, review rows, the approve gate)
|
||||
as ai_request(id, method, path, body) and gets one `ai_response` envelope back. The review
|
||||
backend answers it in Python, so the approve gate holds whatever the page does.
|
||||
"""
|
||||
import json, sys, threading
|
||||
from PyQt6.QtCore import QUrl, pyqtSlot
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt6.QtWebChannel import QWebChannel
|
||||
|
||||
import bridge
|
||||
import server_stub as review # sample data. Once the project has its own review backend, import that (same dispatch()).
|
||||
|
||||
|
||||
class AiBridge(bridge.Bridge):
|
||||
@pyqtSlot(int, str, str, str)
|
||||
def ai_request(self, req_id, method, path, body):
|
||||
# ponytail: answered on the GUI thread; move to a worker if the backend gets slow.
|
||||
status, payload = review.dispatch(method, path, json.loads(body or "{}"))
|
||||
self.send_to_js("ai_response", {"id": req_id, "status": status, "body": payload})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=review.simulate_extraction, daemon=True).start() # sample data only: delete with server_stub
|
||||
app = QApplication(sys.argv)
|
||||
win, view = QMainWindow(), QWebEngineView()
|
||||
host, channel = AiBridge(view), QWebChannel()
|
||||
channel.registerObject("pyBridge", host)
|
||||
view.page().setWebChannel(channel)
|
||||
view.setUrl(QUrl.fromLocalFile(str(bridge.HTML_PATH)))
|
||||
win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,196 @@
|
||||
<!-- ===== 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>
|
||||
@@ -58,6 +58,9 @@ def reset():
|
||||
put_run("job-1001", "invoice-1001.pdf", SAMPLE_FIELDS, pages_total=2, pages_done=2)
|
||||
|
||||
|
||||
reset() # sample data from import on: the desktop bridge imports this module without a server
|
||||
|
||||
|
||||
def run_view(job_id):
|
||||
run, rows = RUNS[job_id], ROWS[job_id]
|
||||
done = run["status"] == "done"
|
||||
@@ -126,6 +129,18 @@ ROUTES = [
|
||||
]
|
||||
|
||||
|
||||
def dispatch(method, path, body):
|
||||
"""One API request -> (status, payload). The web handler and the desktop bridge both call this."""
|
||||
for m, pattern, fn in ROUTES:
|
||||
match = re.fullmatch(pattern, path)
|
||||
if m == method and match:
|
||||
try:
|
||||
return fn(*match.groups(), body)
|
||||
except (KeyError, StopIteration):
|
||||
return 404, {"detail": "Not found"}
|
||||
return 404, {"detail": f"No route for {method} {path}"}
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(HERE / "dist"), **kwargs)
|
||||
@@ -146,15 +161,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
path = urlparse(self.path).path[len(API):]
|
||||
size = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(size) or b"{}") if size else {}
|
||||
status, payload = 404, {"detail": f"No route for {method} {path}"}
|
||||
for m, pattern, fn in ROUTES:
|
||||
match = re.fullmatch(pattern, path)
|
||||
if m == method and match:
|
||||
try:
|
||||
status, payload = fn(*match.groups(), body)
|
||||
except (KeyError, StopIteration):
|
||||
status, payload = 404, {"detail": "Not found"}
|
||||
break
|
||||
status, payload = dispatch(method, path, body)
|
||||
svg = isinstance(payload, str)
|
||||
data = payload.encode() if svg else json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
|
||||
Reference in new issue
Block a user