WIP: AI desktop sections, bridge slot, desktop page check

This commit is contained in:
Vasanth committed 2026-10-16 15:25:27 +05:30
1 parent 8951ac05a5
commit 472e18de0d
8 files changed
+465 -16

No files matched your search

+30 -6
View File
@@ -1,6 +1,6 @@
---
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)
@@ -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/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/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. |
| `<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. |
@@ -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
uses PyQt, choose desktop and say so.
- **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.
Tell the user, and offer the web path or the no-AI desktop page
(`rpa-dashboard-no-ai`) without the AI sections.
- **Desktop:** the no-AI PyQt page with `templates/desktop/ai_sections.html` pasted
in, hosted by `templates/desktop/ai_bridge.py`. No web server, no npm.
## 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.
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
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
field is unverified: the page never decides that. For the shell, set
`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.
## 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;
- 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
whether the review data is sample data or the backend's.
+15
View File
@@ -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>
+16 -9
View File
@@ -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)