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
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: rpa-dashboard-ai
|
name: rpa-dashboard-ai
|
||||||
description: Builds a working dashboard for an RPA bot or service that uses AI (an LLM or OCR extraction call). It has everything from rpa-dashboard-no-ai (sidebar, top bar with dark toggle and status dot, KPI cards, batch donut, run panel with steps and live log, report table with search and export) plus confidence bands, a review split pane (source document beside the extracted fields, band filters, draft edits, Verify) and an approve gate the server enforces. Web path is React + Vite + Tailwind with a sample-data server stub. Use when the user asks for an "AI RPA dashboard", an "extraction dashboard", an "LLM/OCR review dashboard", or any RPA/bot dashboard in a project that calls an LLM or OCR to extract fields. Needs rpa-dashboard-no-ai installed next to it.
|
description: Builds a working dashboard for an RPA bot or service that uses AI (an LLM or OCR extraction call). It has everything from rpa-dashboard-no-ai (sidebar, top bar with dark toggle and status dot, KPI cards, batch donut, run panel with steps and live log, report table with search and export) plus confidence bands, a review split pane (source document beside the extracted fields, band filters, draft edits, Verify) and an approve gate the server enforces. Web path is React + Vite + Tailwind with a sample-data server stub; desktop path (PyQt) is the no-AI page plus plain-HTML AI sections over the Qt bridge, gate enforced in Python. Use when the user asks for an "AI RPA dashboard", an "extraction dashboard", an "LLM/OCR review dashboard", or any RPA/bot dashboard in a project that calls an LLM or OCR to extract fields. Needs rpa-dashboard-no-ai installed next to it.
|
||||||
---
|
---
|
||||||
|
|
||||||
# RPA dashboard (AI)
|
# RPA dashboard (AI)
|
||||||
@@ -14,7 +14,9 @@ means `../rpa-dashboard-no-ai/templates/`.
|
|||||||
| `templates/web/` | The React + Vite + Tailwind starter: shell, KPI, donut, run panel, report, **band legend, review split pane, approve gate**, sample data. |
|
| `templates/web/` | The React + Vite + Tailwind starter: shell, KPI, donut, run panel, report, **band legend, review split pane, approve gate**, sample data. |
|
||||||
| `templates/web/server_stub.py` | Sample-data backend for runs, review rows and the approve gate. Standard library only. |
|
| `templates/web/server_stub.py` | Sample-data backend for runs, review rows and the approve gate. Standard library only. |
|
||||||
| `templates/web/bands.py` | The band cut points and rules. The one source of truth: the backend imports it. |
|
| `templates/web/bands.py` | The band cut points and rules. The one source of truth: the backend imports it. |
|
||||||
| `templates/contract.md` | The AI contract: runs, review rows, endpoints and the gate. Extends `<no-ai>contract.md`. |
|
| `templates/desktop/ai_sections.html` | Desktop: the Review tab (band legend, review split pane, drafts, Verify, approve gate) in plain HTML, pasted into `<no-ai>dashboard.html`. |
|
||||||
|
| `templates/desktop/ai_bridge.py` | Desktop: the no-AI `Bridge` plus the `ai_request` slot that hands every AI request to the Python review backend. |
|
||||||
|
| `templates/contract.md` | The AI contract: runs, review rows, endpoints and the gate, over HTTP or the Qt bridge. Extends `<no-ai>contract.md`. |
|
||||||
| `templates/reference/` | Superbill Review extracts, marked EXAMPLE ONLY. Use them to port extra polish, never copy their project names. |
|
| `templates/reference/` | Superbill Review extracts, marked EXAMPLE ONLY. Use them to port extra polish, never copy their project names. |
|
||||||
| `<no-ai>frontend.md`, `<no-ai>detect_frontend.py` | Existing frontend: detect, report, keep or restyle. |
|
| `<no-ai>frontend.md`, `<no-ai>detect_frontend.py` | Existing frontend: detect, report, keep or restyle. |
|
||||||
| `<no-ai>themes.md`, `<no-ai>palette.py` | Theme presets and the custom-colour steps. |
|
| `<no-ai>themes.md`, `<no-ai>palette.py` | Theme presets and the custom-colour steps. |
|
||||||
@@ -56,9 +58,8 @@ Skip this if the user chose keep. Offer:
|
|||||||
Ask only if there is no frontend. If there is one, use its stack. If the project
|
Ask only if there is no frontend. If there is one, use its stack. If the project
|
||||||
uses PyQt, choose desktop and say so.
|
uses PyQt, choose desktop and say so.
|
||||||
- **Web:** the React starter in `templates/web/`, served by the backend.
|
- **Web:** the React starter in `templates/web/`, served by the backend.
|
||||||
- **Desktop:** the plain-HTML AI sections for the PyQt page aren't bundled yet.
|
- **Desktop:** the no-AI PyQt page with `templates/desktop/ai_sections.html` pasted
|
||||||
Tell the user, and offer the web path or the no-AI desktop page
|
in, hosted by `templates/desktop/ai_bridge.py`. No web server, no npm.
|
||||||
(`rpa-dashboard-no-ai`) without the AI sections.
|
|
||||||
|
|
||||||
## 5. Ask which optional sections to build
|
## 5. Ask which optional sections to build
|
||||||
|
|
||||||
@@ -90,6 +91,19 @@ chosen ones at their `OPTIONAL:` markers, and delete the markers.
|
|||||||
(`<no-ai>frontend.md` section 4). Never change the band colour block.
|
(`<no-ai>frontend.md` section 4). Never change the band colour block.
|
||||||
4. Search the output for `{{`. None may be left.
|
4. Search the output for `{{`. None may be left.
|
||||||
|
|
||||||
|
## 6. Build (desktop)
|
||||||
|
|
||||||
|
1. Build the no-AI desktop page and host first, exactly as steps 6 and 7 of
|
||||||
|
`../rpa-dashboard-no-ai/SKILL.md` say: `dashboard/dashboard.html` (theme, placeholders,
|
||||||
|
`SAMPLE_DATA = false` once the bot is wired), `bridge.py` and `bot_runner.py`.
|
||||||
|
2. Paste the whole of `templates/desktop/ai_sections.html` into `dashboard/dashboard.html`,
|
||||||
|
just before `</body>`. Change nothing in it: it uses the page's theme tokens, and its
|
||||||
|
band colours are fixed.
|
||||||
|
3. Copy `templates/desktop/ai_bridge.py`, `templates/web/server_stub.py` and
|
||||||
|
`templates/web/bands.py` into `dashboard/`. Fill `{{APP_NAME}}` in `ai_bridge.py`.
|
||||||
|
4. The app now starts with `python dashboard/ai_bridge.py` (not `bridge.py`).
|
||||||
|
5. Search the output for `{{`. None may be left.
|
||||||
|
|
||||||
## 7. Wire the data
|
## 7. Wire the data
|
||||||
|
|
||||||
The contracts: `<no-ai>contract.md` for the shell, `templates/contract.md` for runs,
|
The contracts: `<no-ai>contract.md` for the shell, `templates/contract.md` for runs,
|
||||||
@@ -103,6 +117,13 @@ review rows and the gate.
|
|||||||
mandatory)`. The approve endpoint must refuse (409 with `detail`) while any flagged
|
mandatory)`. The approve endpoint must refuse (409 with `detail`) while any flagged
|
||||||
field is unverified: the page never decides that. For the shell, set
|
field is unverified: the page never decides that. For the shell, set
|
||||||
`SAMPLE_DATA = false` and serve the no-AI endpoints too.
|
`SAMPLE_DATA = false` and serve the no-AI endpoints too.
|
||||||
|
- **Desktop:** the same rules, over the bridge (`templates/contract.md`, "Desktop").
|
||||||
|
The shell is wired in `bot_runner.py` as the no-AI skill says. For review data, keep
|
||||||
|
`server_stub.py` (replace its `SAMPLE_FIELDS` with the real fields, as above) until
|
||||||
|
the project stores its extraction results. Then write a module with the same
|
||||||
|
`dispatch(method, path, body)` over the project's store (same routes, bands from
|
||||||
|
`bands.py`, approve refused while flagged fields are unverified) and import it in
|
||||||
|
`ai_bridge.py` instead of `server_stub`. The page never decides approval.
|
||||||
- Never invent fields, model names or prices the project doesn't have.
|
- Never invent fields, model names or prices the project doesn't have.
|
||||||
|
|
||||||
## 8. Verify
|
## 8. Verify
|
||||||
@@ -121,6 +142,9 @@ Open the page and check, in light, dark (the toggle) and a window 820px wide or
|
|||||||
"page X of Y" on a running document;
|
"page X of Y" on a running document;
|
||||||
- no `{{` is left and the console shows no script errors.
|
- no `{{` is left and the console shows no script errors.
|
||||||
|
|
||||||
If you can't run npm here, say so and give the user these commands. Finish by
|
Desktop: `python dashboard/bot_runner.py` (dry run: real steps, ending at 100), then
|
||||||
|
`python dashboard/ai_bridge.py`, and check the same list in the window.
|
||||||
|
|
||||||
|
If you can't run npm (or open a window) here, say so and give the user these commands. Finish by
|
||||||
telling the user where the files are, which theme and sections were built, and
|
telling the user where the files are, which theme and sections were built, and
|
||||||
whether the review data is sample data or the backend's.
|
whether the review data is sample data or the backend's.
|
||||||
@@ -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
|
`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.
|
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)
|
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):
|
def run_view(job_id):
|
||||||
run, rows = RUNS[job_id], ROWS[job_id]
|
run, rows = RUNS[job_id], ROWS[job_id]
|
||||||
done = run["status"] == "done"
|
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):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, directory=str(HERE / "dist"), **kwargs)
|
super().__init__(*args, directory=str(HERE / "dist"), **kwargs)
|
||||||
@@ -146,15 +161,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
path = urlparse(self.path).path[len(API):]
|
path = urlparse(self.path).path[len(API):]
|
||||||
size = int(self.headers.get("Content-Length") or 0)
|
size = int(self.headers.get("Content-Length") or 0)
|
||||||
body = json.loads(self.rfile.read(size) or b"{}") if size else {}
|
body = json.loads(self.rfile.read(size) or b"{}") if size else {}
|
||||||
status, payload = 404, {"detail": f"No route for {method} {path}"}
|
status, payload = dispatch(method, path, body)
|
||||||
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
|
|
||||||
svg = isinstance(payload, str)
|
svg = isinstance(payload, str)
|
||||||
data = payload.encode() if svg else json.dumps(payload).encode()
|
data = payload.encode() if svg else json.dumps(payload).encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Seam 1 page check for the AI desktop page: the no-AI dashboard.html with the plain-HTML
|
||||||
|
AI sections (rpa-dashboard-ai/templates/desktop/ai_sections.html) pasted in before </body>.
|
||||||
|
|
||||||
|
Page under test: set PAGE_CHECK_AI_DESKTOP_DIR to a generated dashboard folder (the one with
|
||||||
|
dashboard.html and server_stub.py). Without it, the no-AI template is filled with the no-AI
|
||||||
|
check's TEST_FILL and the AI sections are pasted in, the way the skill does it.
|
||||||
|
|
||||||
|
The page sees a fake Qt bridge, as in the no-AI check: each slot call lands in window.__calls.
|
||||||
|
The AI slot, ai_request(id, method, path, body), goes to the review backend's dispatch() in
|
||||||
|
this process, and the reply comes back as an `ai_response` envelope, exactly what
|
||||||
|
ai_bridge.py's slot does. So the approve gate under test is the Python one. Tests set run and
|
||||||
|
review state on the backend directly (the `stub` fixture). Light, dark and narrow for every case.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
NO_AI = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
|
||||||
|
AI = ROOT / "rpa-dashboard-ai" / "templates"
|
||||||
|
TEST_FILL = {
|
||||||
|
"APP_NAME": "Test Extractor",
|
||||||
|
"APP_SUBTITLE": "Page check",
|
||||||
|
"LOGO_SRC": "",
|
||||||
|
"API_BASE": "",
|
||||||
|
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
|
||||||
|
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
|
||||||
|
}
|
||||||
|
FAKE_BRIDGE = """
|
||||||
|
window.__calls = [];
|
||||||
|
window.qt = {webChannelTransport: {}};
|
||||||
|
window.QWebChannel = function (_transport, ready) {
|
||||||
|
const pyBridge = new Proxy({}, {get: (_o, slot) => (...args) => {
|
||||||
|
window.__calls.push([slot, ...args]);
|
||||||
|
if (slot === 'ai_request') window.__aiRequest(...args).then(window.__pyMsg);
|
||||||
|
}});
|
||||||
|
ready({objects: {pyBridge}});
|
||||||
|
};
|
||||||
|
"""
|
||||||
|
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
|
||||||
|
WIRED = os.environ.get("PAGE_CHECK_WIRED") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_data():
|
||||||
|
if WIRED:
|
||||||
|
pytest.skip("page is wired to a bot, so it has no sample data")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def site(tmp_path_factory):
|
||||||
|
"""The dashboard folder under test: dashboard.html, server_stub.py and bands.py."""
|
||||||
|
if os.environ.get("PAGE_CHECK_AI_DESKTOP_DIR"):
|
||||||
|
return Path(os.environ["PAGE_CHECK_AI_DESKTOP_DIR"]).resolve()
|
||||||
|
html = re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], NO_AI.read_text(encoding="utf-8"))
|
||||||
|
if WIRED:
|
||||||
|
html = html.replace("const SAMPLE_DATA = true", "const SAMPLE_DATA = false")
|
||||||
|
sections = (AI / "desktop" / "ai_sections.html").read_text(encoding="utf-8")
|
||||||
|
out = tmp_path_factory.mktemp("desktop")
|
||||||
|
(out / "dashboard.html").write_text(html.replace("</body>", sections + "\n</body>"), encoding="utf-8")
|
||||||
|
for f in ["server_stub.py", "bands.py"]:
|
||||||
|
(out / f).write_bytes((AI / "web" / f).read_bytes())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def page_url(site):
|
||||||
|
return (site / "dashboard.html").as_uri()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def stub_module(site):
|
||||||
|
sys.path.insert(0, str(site)) # the stub imports bands.py from its own folder
|
||||||
|
spec = importlib.util.spec_from_file_location("desktop_server_stub", site / "server_stub.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub(stub_module):
|
||||||
|
"""The review backend the bridge talks to, reset to its sample data."""
|
||||||
|
stub_module.reset()
|
||||||
|
return stub_module
|
||||||
|
|
||||||
|
|
||||||
|
def ai_request(stub, req_id, method, path, body):
|
||||||
|
"""What ai_bridge.py's ai_request slot does: dispatch, then answer with one envelope."""
|
||||||
|
status, payload = stub.dispatch(method, path, json.loads(body or "{}"))
|
||||||
|
return json.dumps({"event": "ai_response", "data": {"id": req_id, "status": status, "body": payload}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=list(MODES))
|
||||||
|
def page(request, browser, page_url, stub):
|
||||||
|
width, height, dark = MODES[request.param]
|
||||||
|
ctx = browser.new_context(viewport={"width": width, "height": height})
|
||||||
|
ctx.expose_function("__aiRequest", lambda i, method, path, body: ai_request(stub, i, method, path, body))
|
||||||
|
ctx.add_init_script(FAKE_BRIDGE)
|
||||||
|
pg = ctx.new_page()
|
||||||
|
pg.set_default_timeout(5000)
|
||||||
|
errors = []
|
||||||
|
pg.on("pageerror", lambda e: errors.append(e))
|
||||||
|
pg.goto(page_url)
|
||||||
|
assert not errors, f"page script errors: {errors}"
|
||||||
|
if dark:
|
||||||
|
push_to(pg, "apply_dark", {"on": True})
|
||||||
|
yield pg
|
||||||
|
assert not errors, f"page script errors: {errors}"
|
||||||
|
ctx.close()
|
||||||
|
|
||||||
|
|
||||||
|
def push_to(pg, event, data):
|
||||||
|
pg.evaluate("([event, data]) => window.__pyMsg(JSON.stringify({event, data}))", [event, data])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def push(page):
|
||||||
|
return lambda event, data: push_to(page, event, data)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""The AI web page's review cases, run unchanged against the desktop page.
|
||||||
|
|
||||||
|
They use this folder's `page` and `stub` fixtures: the page talks to the review backend
|
||||||
|
over the fake Qt bridge instead of HTTP.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check_ai"))
|
||||||
|
|
||||||
|
from test_ai_review import ( # noqa: E402,F401
|
||||||
|
test_approve_stays_blocked_while_rows_are_unverified,
|
||||||
|
test_band_counts_and_legend_match,
|
||||||
|
test_band_filters_show_the_right_rows,
|
||||||
|
test_draft_edit_is_not_saved_until_save,
|
||||||
|
test_page_x_of_y_updates_while_running,
|
||||||
|
)
|
||||||
|
from test_ai_review import FIELDS, FLAGGED, open_review # noqa: E402
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check"))
|
||||||
|
from test_contract import ( # noqa: E402,F401
|
||||||
|
test_dark_toggle_flips_theme,
|
||||||
|
test_donut_and_legend_match_batch_states,
|
||||||
|
test_kpi_counts_match_batch_states,
|
||||||
|
test_log_lines_show_their_type,
|
||||||
|
test_no_bot_page_runs_on_sample_data,
|
||||||
|
test_plain_browser_first_open_shows_sample_data,
|
||||||
|
test_report_search_filters_rows_and_shows_bucket_pill,
|
||||||
|
test_step_list_follows_step_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
TAMPER = """async (path) => {
|
||||||
|
const reply = new Promise(done => { const base = window.__pyMsg;
|
||||||
|
window.__pyMsg = m => { base(m); if (JSON.parse(m).event === 'ai_response') done(JSON.parse(m).data); }; });
|
||||||
|
bridge.ai_request(999, 'POST', path, '{}');
|
||||||
|
return await reply;
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_changed_page_cannot_approve_over_the_bridge(page, stub):
|
||||||
|
"""The gate is Python's: a page that skips its own buttons and calls the slot is still refused."""
|
||||||
|
stub.put_run("job-t", "bands.pdf", FIELDS)
|
||||||
|
open_review(page, "job-t")
|
||||||
|
reply = page.evaluate(TAMPER, "/runs/job-t/approve")
|
||||||
|
assert reply["id"] == 999 and reply["status"] == 409 and str(FLAGGED) in reply["body"]["detail"]
|
||||||
|
assert stub.RUNS["job-t"]["approved"] is False
|
||||||
@@ -11,7 +11,8 @@ HOOK_RULES = "E9,F63,F7,F82,F811" # same rule set as the PostToolUse lint hook
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py",
|
@pytest.mark.parametrize("template", ["rpa-dashboard-no-ai/templates/bridge.py",
|
||||||
"rpa-dashboard-no-ai/templates/bot_runner.py"])
|
"rpa-dashboard-no-ai/templates/bot_runner.py",
|
||||||
|
"rpa-dashboard-ai/templates/desktop/ai_bridge.py"])
|
||||||
def test_template_passes_lint_hook_unfilled(template):
|
def test_template_passes_lint_hook_unfilled(template):
|
||||||
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH")
|
||||||
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],
|
||||||
|
|||||||
Reference in new issue
Block a user