diff --git a/rpa-dashboard-ai/SKILL.md b/rpa-dashboard-ai/SKILL.md index 1de3601..e9e987a 100644 --- a/rpa-dashboard-ai/SKILL.md +++ b/rpa-dashboard-ai/SKILL.md @@ -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 `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 `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 `contract.md`. | | `templates/reference/` | Superbill Review extracts, marked EXAMPLE ONLY. Use them to port extra polish, never copy their project names. | | `frontend.md`, `detect_frontend.py` | Existing frontend: detect, report, keep or restyle. | | `themes.md`, `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. (`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 ``. 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: `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. diff --git a/rpa-dashboard-ai/templates/contract.md b/rpa-dashboard-ai/templates/contract.md index 2674ca1..6dc7544 100644 --- a/rpa-dashboard-ai/templates/contract.md +++ b/rpa-dashboard-ai/templates/contract.md @@ -50,3 +50,18 @@ Errors answer with a non-2xx status and `{detail: ""}`. 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. diff --git a/rpa-dashboard-ai/templates/desktop/ai_bridge.py b/rpa-dashboard-ai/templates/desktop/ai_bridge.py new file mode 100644 index 0000000..ab5b017 --- /dev/null +++ b/rpa-dashboard-ai/templates/desktop/ai_bridge.py @@ -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()) diff --git a/rpa-dashboard-ai/templates/desktop/ai_sections.html b/rpa-dashboard-ai/templates/desktop/ai_sections.html new file mode 100644 index 0000000..cbc4944 --- /dev/null +++ b/rpa-dashboard-ai/templates/desktop/ai_sections.html @@ -0,0 +1,196 @@ + + + + + + diff --git a/rpa-dashboard-ai/templates/web/server_stub.py b/rpa-dashboard-ai/templates/web/server_stub.py index b1775e1..363b9c7 100644 --- a/rpa-dashboard-ai/templates/web/server_stub.py +++ b/rpa-dashboard-ai/templates/web/server_stub.py @@ -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) diff --git a/tests/page_check_ai_desktop/conftest.py b/tests/page_check_ai_desktop/conftest.py new file mode 100644 index 0000000..73d10b1 --- /dev/null +++ b/tests/page_check_ai_desktop/conftest.py @@ -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 . + +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("", sections + "\n"), 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) diff --git a/tests/page_check_ai_desktop/test_ai_desktop.py b/tests/page_check_ai_desktop/test_ai_desktop.py new file mode 100644 index 0000000..e122efa --- /dev/null +++ b/tests/page_check_ai_desktop/test_ai_desktop.py @@ -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 diff --git a/tests/test_templates.py b/tests/test_templates.py index d337dd2..3645cfa 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -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", - "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): ruff = shutil.which("ruff") or pytest.skip("ruff not on PATH") r = subprocess.run([ruff, "check", "--select", HOOK_RULES, "--no-cache", str(ROOT / template)],