From d4d3f83fb188780cab3ea35e5881a16597b56731 Mon Sep 17 00:00:00 2001 From: Vasanth Date: Fri, 16 Oct 2026 15:06:44 +0530 Subject: [PATCH 1/5] AI web starter: bands, review split pane, approve gate, server stub (checkpoint) React + Vite + Tailwind starter with the no-AI shell, band legend, review split pane with drafts and verify, and a server stub that refuses approval while flagged fields are unverified. Band self-check and AI page check. Both page checks share one Chromium via tests/conftest.py. Co-Authored-By: Claude Opus 5.5 --- .../templates/reference/backend/bands.py | 64 - rpa-dashboard-ai/templates/web/bands.py | 47 + rpa-dashboard-ai/templates/web/index.html | 27 + .../templates/web/package-lock.json | 2030 +++++++++++++++++ rpa-dashboard-ai/templates/web/package.json | 22 + rpa-dashboard-ai/templates/web/server_stub.py | 187 ++ rpa-dashboard-ai/templates/web/src/App.tsx | 105 + rpa-dashboard-ai/templates/web/src/ai.ts | 57 + .../templates/web/src/components/Overview.tsx | 81 + .../templates/web/src/components/Report.tsx | 50 + .../templates/web/src/components/Review.tsx | 183 ++ .../templates/web/src/components/RunPanel.tsx | 43 + .../templates/web/src/config.d.ts | 12 + rpa-dashboard-ai/templates/web/src/index.css | 75 + rpa-dashboard-ai/templates/web/src/main.tsx | 7 + rpa-dashboard-ai/templates/web/src/store.ts | 139 ++ rpa-dashboard-ai/templates/web/tsconfig.json | 14 + rpa-dashboard-ai/templates/web/vite.config.ts | 10 + tests/conftest.py | 12 + tests/page_check/conftest.py | 9 - tests/page_check_ai/conftest.py | 128 ++ tests/page_check_ai/test_ai_review.py | 140 ++ tests/page_check_ai/test_ai_shared.py | 19 + tests/test_bands.py | 41 + 24 files changed, 3429 insertions(+), 73 deletions(-) delete mode 100644 rpa-dashboard-ai/templates/reference/backend/bands.py create mode 100644 rpa-dashboard-ai/templates/web/bands.py create mode 100644 rpa-dashboard-ai/templates/web/index.html create mode 100644 rpa-dashboard-ai/templates/web/package-lock.json create mode 100644 rpa-dashboard-ai/templates/web/package.json create mode 100644 rpa-dashboard-ai/templates/web/server_stub.py create mode 100644 rpa-dashboard-ai/templates/web/src/App.tsx create mode 100644 rpa-dashboard-ai/templates/web/src/ai.ts create mode 100644 rpa-dashboard-ai/templates/web/src/components/Overview.tsx create mode 100644 rpa-dashboard-ai/templates/web/src/components/Report.tsx create mode 100644 rpa-dashboard-ai/templates/web/src/components/Review.tsx create mode 100644 rpa-dashboard-ai/templates/web/src/components/RunPanel.tsx create mode 100644 rpa-dashboard-ai/templates/web/src/config.d.ts create mode 100644 rpa-dashboard-ai/templates/web/src/index.css create mode 100644 rpa-dashboard-ai/templates/web/src/main.tsx create mode 100644 rpa-dashboard-ai/templates/web/src/store.ts create mode 100644 rpa-dashboard-ai/templates/web/tsconfig.json create mode 100644 rpa-dashboard-ai/templates/web/vite.config.ts create mode 100644 tests/conftest.py create mode 100644 tests/page_check_ai/conftest.py create mode 100644 tests/page_check_ai/test_ai_review.py create mode 100644 tests/page_check_ai/test_ai_shared.py create mode 100644 tests/test_bands.py diff --git a/rpa-dashboard-ai/templates/reference/backend/bands.py b/rpa-dashboard-ai/templates/reference/backend/bands.py deleted file mode 100644 index 5e447e3..0000000 --- a/rpa-dashboard-ai/templates/reference/backend/bands.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Confidence-band classification — single source of truth for the cut points. - -The workbook colours and the review queue MUST agree about what counts as red. -If each applied its own threshold they would drift the first time one is tuned, -and a reviewer would see a red cell the queue never asked them to check. -Everything that needs a band imports from here. -""" -from __future__ import annotations - -from typing import Optional - -# --------------------------------------------------------------------------- -# Cut points -# --------------------------------------------------------------------------- -# Calibrated against the measured distribution over all 225 extracted rows of -# the six sample documents: -# -# 1.00 69.8% crisp print -# 0.90-0.99 18.2% crisp print -# 0.80-0.89 11.0% almost entirely handwritten CPT and modifiers -# 0.70-0.79 0.0% <- empty; the natural gap in this data -# <0.70 1.1% handwritten modifiers -# -# 0.80 left only 1.1% of cells red so nearly everything went green and the -# colour carried no information. 0.90 sits in the empty band and separates -# the handwriting cluster from printed text. -CONFIDENCE_THRESHOLD: float = 0.90 - -# Above CONFIDENCE_THRESHOLD the 0.90-0.99 band is still 18.2% of cells, and -# routing treats it differently from a clean 1.00: reviewers sample it rather -# than either trusting it outright or checking every one. A second cut at -# 0.95 names that band so the sheet shows what the routing rule already assumes. -# -# GREEN >= 0.95 clean print, straight through -# AMBER 0.90-0.95 slightly unclear, sampled -# RED < 0.90 handwriting cluster, always reviewed -GREEN_THRESHOLD: float = 0.95 - -# --------------------------------------------------------------------------- -# Band name constants -# --------------------------------------------------------------------------- -BAND_GREEN = "green" -BAND_AMBER = "amber" -BAND_RED = "red" - - -def band_of(score: Optional[float]) -> Optional[str]: - """Return the confidence band name, or ``None`` when the value was never scored. - - WHAT THE BAND CLAIMS: green means the source text this value came from was - transcribed cleanly. It does NOT mean the value is correct. The score is - the model's own legibility rating, so a confident misread comes back scored - high. Verifying correctness needs a second, independent read. Red is the - useful direction: it reliably surfaces handwriting. - - Amber does not mean "probably fine". It means the reading was good enough to - escape the handwriting cluster but not clean enough to trust unsampled, which - is a routing decision, not a quality claim. - """ - if score is None: - return None - if score >= GREEN_THRESHOLD: - return BAND_GREEN - return BAND_AMBER if score >= CONFIDENCE_THRESHOLD else BAND_RED diff --git a/rpa-dashboard-ai/templates/web/bands.py b/rpa-dashboard-ai/templates/web/bands.py new file mode 100644 index 0000000..364f09e --- /dev/null +++ b/rpa-dashboard-ai/templates/web/bands.py @@ -0,0 +1,47 @@ +"""Confidence bands: the single source of truth for the cut points. + +Copy this file into the backend. Everything that needs a band (the review queue, +an exported sheet, the approve gate) imports from here, so they never disagree +about what counts as red. + + GREEN >= 0.95 clean print, straight through + AMBER 0.90-0.95 slightly unclear, sampled + RED < 0.90 handwriting or a messy scan, always reviewed + BLANK a mandatory field with no value, always flagged + UNSCORED no word scores came back for the value + +Calibration (from the prototype these rules came from): crisp print scored 0.90 and +up, handwriting clustered in 0.80-0.89, and 0.70-0.79 was empty. 0.90 sits in that +gap. 0.95 splits off the 0.90-0.99 band that reviewers sample rather than trust. + +What a band claims: the score is the model's own legibility rating. Green means the +text was read cleanly, NOT that the value is correct; a confident misread scores +green. Red is the useful direction: it reliably surfaces handwriting. +""" +from __future__ import annotations + +from typing import Iterable, Optional + +GREEN_THRESHOLD = 0.95 +CONFIDENCE_THRESHOLD = 0.90 +BANDS = ("green", "amber", "red", "blank", "unscored") + + +def band_of(score: float) -> str: + if score >= GREEN_THRESHOLD: + return "green" + return "amber" if score >= CONFIDENCE_THRESHOLD else "red" + + +def field_band(word_scores: Optional[Iterable[float]], value: Optional[str], mandatory: bool = False) -> str: + """A field's band. It takes its WORST word score, so one shaky word can't hide in + a clean-looking field. A blank mandatory field is flagged whatever its score.""" + if mandatory and not (value or "").strip(): + return "blank" + scores = list(word_scores or []) + return band_of(min(scores)) if scores else "unscored" + + +def needs_review(band: str) -> bool: + """Every band but green must be verified by a person before the run can be approved.""" + return band != "green" diff --git a/rpa-dashboard-ai/templates/web/index.html b/rpa-dashboard-ai/templates/web/index.html new file mode 100644 index 0000000..1398304 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/index.html @@ -0,0 +1,27 @@ + + + + + + +{{APP_NAME}} + + + +
+ + + diff --git a/rpa-dashboard-ai/templates/web/package-lock.json b/rpa-dashboard-ai/templates/web/package-lock.json new file mode 100644 index 0000000..98ed2d5 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/package-lock.json @@ -0,0 +1,2030 @@ +{ + "name": "rpa-ai-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rpa-ai-dashboard", + "dependencies": { + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.2", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + } + } +} diff --git a/rpa-dashboard-ai/templates/web/package.json b/rpa-dashboard-ai/templates/web/package.json new file mode 100644 index 0000000..5907075 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "rpa-ai-dashboard", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.2", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.4" + } +} diff --git a/rpa-dashboard-ai/templates/web/server_stub.py b/rpa-dashboard-ai/templates/web/server_stub.py new file mode 100644 index 0000000..b1775e1 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/server_stub.py @@ -0,0 +1,187 @@ +"""Sample-data backend for the AI review dashboard: runs, review rows and the approve gate. + + npm run build && python server_stub.py # page + API on http://127.0.0.1:8000 + python server_stub.py & npm run dev # or Vite on :5173, proxying /api here + +It stands in for the project's backend until that exists. A real backend serves the +same endpoints (templates/contract.md) with the same rules: bands from bands.py, and +approval refused while any flagged field is unverified. The page never decides that. +Standard library only. +""" +import json +import re +import threading +import time +from html import escape +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + +from bands import BANDS, field_band, needs_review + +HERE = Path(__file__).resolve().parent +API = "/api" # same as API_BASE in index.html +REASON = {"green": None, "amber": "Slightly unclear, sample check", "red": "Unclear print, check every word", + "blank": "Required field is empty", "unscored": "Not found in the transcription"} +# (field, value, word scores, mandatory). Swap in the fields the project's extraction returns. +SAMPLE_FIELDS = [ + ("Invoice number", "INV-1001", [1.0], True), + ("Invoice date", "2026-10-01", [0.99, 0.97], True), + ("Vendor", "Contoso Supplies", [0.98, 0.92], True), + ("PO number", "PO-4471", [0.86], False), + ("Due date", "", [], True), + ("Total", "2,450.00", [0.99], True), + ("Tax", "196.00", [], False), +] +RUNS, ROWS = {}, {} +_next_row = [1] + + +def put_run(job_id, document, fields, status="done", pages_done=1, pages_total=1, model="sample-model"): + """Add or replace a run. fields: [(field, value, word scores, mandatory)].""" + RUNS[job_id] = {"job_id": job_id, "document": document, "status": status, "model": model, + "pages_done": pages_done, "pages_total": pages_total, "approved": False, "error": None} + ROWS[job_id] = [] + for field, value, words, mandatory in fields: + band = field_band(words, value, mandatory) + ROWS[job_id].append({"row_id": _next_row[0], "field": field, "value": value, "band": band, + "score": min(words) if words else None, "reason": REASON[band], + "verified": False, "corrected_value": None}) + _next_row[0] += 1 + return RUNS[job_id] + + +def reset(): + RUNS.clear() + ROWS.clear() + put_run("job-1002", "invoice-1002.pdf", SAMPLE_FIELDS, status="running", pages_done=1, pages_total=3) + put_run("job-1001", "invoice-1001.pdf", SAMPLE_FIELDS, pages_total=2, pages_done=2) + + +def run_view(job_id): + run, rows = RUNS[job_id], ROWS[job_id] + done = run["status"] == "done" + return {**run, "summary": {b: sum(r["band"] == b for r in rows) for b in BANDS} if done else None} + + +def unverified(job_id): + return [r for r in ROWS[job_id] if needs_review(r["band"]) and not r["verified"]] + + +# ---- routes: (method, path regex, handler(match groups..., body) -> (status, payload)) ---- +def list_runs(body): + return 200, {"runs": [run_view(j) for j in RUNS]} + + +def get_run(job_id, body): + return 200, run_view(job_id) + + +def review(job_id, body): + return 200, {"rows": ROWS[job_id] if RUNS[job_id]["status"] == "done" else []} + + +def patch_row(row_id, body): + row = next(r for rows in ROWS.values() for r in rows if r["row_id"] == int(row_id)) + row.update({k: body[k] for k in ("corrected_value", "verified") if k in body}) + return 200, row + + +def verify_remaining(job_id, body): + left = unverified(job_id) + for r in left: + r["verified"] = True + return 200, {"verified": len(left)} + + +def approve(job_id, body): + left = unverified(job_id) + if RUNS[job_id]["status"] != "done": + return 409, {"detail": "The run hasn't finished extracting."} + if left: # the approve gate + return 409, {"detail": f"{len(left)} flagged field(s) still need verifying."} + RUNS[job_id]["approved"] = True + return 200, {"ok": True} + + +def document(job_id, body): + """A stand-in for the source document. A real backend returns the PDF or page image.""" + lines = "".join(f'{escape(f)}: {escape(v)}' + for i, (f, v, *_) in enumerate(SAMPLE_FIELDS)) + svg = (f'' + f'' + f'{escape(RUNS[job_id]["document"])} (sample){lines}') + return 200, svg + + +ROUTES = [ + ("GET", r"/runs", list_runs), + ("GET", r"/runs/([\w-]+)", get_run), + ("GET", r"/runs/([\w-]+)/review", review), + ("GET", r"/runs/([\w-]+)/document", document), + ("PATCH", r"/review/(\d+)", patch_row), + ("POST", r"/runs/([\w-]+)/verify-remaining", verify_remaining), + ("POST", r"/runs/([\w-]+)/approve", approve), + ("POST", r"/set-dark", lambda body: (200, {})), +] + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(HERE / "dist"), **kwargs) + + def log_message(self, *args): + pass + + def do_GET(self): + self.api("GET") if self.path.startswith(API + "/") else super().do_GET() + + def do_POST(self): + self.api("POST") + + def do_PATCH(self): + self.api("PATCH") + + def api(self, method): + 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 + svg = isinstance(payload, str) + data = payload.encode() if svg else json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "image/svg+xml" if svg else "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def make_server(host="127.0.0.1", port=8000): + reset() + return ThreadingHTTPServer((host, port), Handler) + + +def simulate_extraction(): + """Demo only: the running sample run reads one page every 3 seconds, then finishes.""" + while True: + time.sleep(3) + for run in RUNS.values(): + if run["status"] == "running": + run["pages_done"] += 1 + if run["pages_done"] >= run["pages_total"]: + run["status"] = "done" + + +if __name__ == "__main__": + server = make_server() + threading.Thread(target=simulate_extraction, daemon=True).start() + print("Sample backend on http://127.0.0.1:8000 (Ctrl+C to stop)") + server.serve_forever() diff --git a/rpa-dashboard-ai/templates/web/src/App.tsx b/rpa-dashboard-ai/templates/web/src/App.tsx new file mode 100644 index 0000000..d6c8134 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/App.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { act, clearRun, toggleDark, useStore } from './store' +import { Overview } from './components/Overview' +import { RunPanel } from './components/RunPanel' +import { Report } from './components/Report' +import { Review } from './components/Review' + +const TABS = { + dashboard: ['Dashboard', 'Overview of every batch', <>], + run: ['Run', 'Live progress and log', ], + review: ['Review', 'Check extracted fields against the document', <>], + report: ['Report', 'Result of every processed item', <>], + // OPTIONAL: admin/roles tab goes here +} as const +type Tab = keyof typeof TABS + +const DOT = { ready: 'bg-ok', done: 'bg-ok', running: 'bg-warn animate-pulse', error: 'bg-bad' } + +export function App() { + const { status, user, dark } = useStore() + const [tab, setTab] = useState('dashboard') + const [unsaved, setUnsaved] = useState(0) // review edits not saved yet + + const show = (t: Tab) => { + if (t === tab) return + if (unsaved && !window.confirm(`${unsaved} unsaved change(s) on this document. Discard them and leave?`)) return + setUnsaved(0) + setTab(t) + if (t === 'dashboard') act('load_dashboard') + if (t === 'report') act('load_latest_report') + } + const run = () => { + show('run') + clearRun() + act('start_automation') + } + + return ( +
+ + +
+
+
+
{TABS[tab][0]}
+
{TABS[tab][1]}
+
+
+ + +
+ + {status.msg || status.state} +
+
+
+ +
+ {tab === 'dashboard' && } + {tab === 'run' && } + {tab === 'review' && } + {tab === 'report' && } +
+
+
+ ) +} diff --git a/rpa-dashboard-ai/templates/web/src/ai.ts b/rpa-dashboard-ai/templates/web/src/ai.ts new file mode 100644 index 0000000..1f29c4b --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/ai.ts @@ -0,0 +1,57 @@ +/** The AI contract additions (see contract.md): runs, review rows, the approve gate. */ +export type Band = 'green' | 'amber' | 'red' | 'blank' | 'unscored' +export type RunStatus = 'pending' | 'queued' | 'running' | 'done' | 'failed' +export type Run = { + job_id: string + document: string + status: RunStatus + model: string + pages_done: number + pages_total: number + summary: Record | null // null until the run is done + approved: boolean + error: string | null +} +export type ReviewRow = { + row_id: number + field: string + value: string | null + band: Band + score: number | null + reason: string | null + verified: boolean + corrected_value: string | null +} + +export const BUSY: RunStatus[] = ['queued', 'running'] // polled every 1.5s while in one of these + +/** Legend order, label, and the colour token. Band colours are semantic: never on buttons or headings. */ +export const BANDS: { band: Band; label: string; colour: string }[] = [ + { band: 'green', label: 'Clean ≥0.95', colour: 'var(--clean)' }, + { band: 'amber', label: 'Check 0.90–0.95', colour: 'var(--verify)' }, + { band: 'red', label: 'Review <0.90', colour: 'var(--review)' }, + { band: 'blank', label: 'Missing (required)', colour: 'var(--absent)' }, + { band: 'unscored', label: 'Unscored', colour: 'var(--absent)' }, +] +export const CAVEAT = 'Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.' + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(API_BASE + path, { ...init, headers: { 'Content-Type': 'application/json' } }) + if (!res.ok) { + // The server says why it refused (the approve gate answers 409 with the count). + const body = await res.json().catch(() => null) + throw new Error(body?.detail ?? `${res.status} ${res.statusText}`) + } + return res.json() +} + +export const ai = { + runs: () => req<{ runs: Run[] }>('/runs'), + run: (id: string) => req(`/runs/${id}`), + rows: (id: string) => req<{ rows: ReviewRow[] }>(`/runs/${id}/review`), + patchRow: (rowId: number, body: { corrected_value?: string | null; verified: boolean }) => + req(`/review/${rowId}`, { method: 'PATCH', body: JSON.stringify(body) }), + verifyRemaining: (id: string) => req<{ verified: number }>(`/runs/${id}/verify-remaining`, { method: 'POST' }), + approve: (id: string) => req<{ ok: boolean }>(`/runs/${id}/approve`, { method: 'POST' }), + documentUrl: (id: string) => `${API_BASE}/runs/${id}/document`, +} diff --git a/rpa-dashboard-ai/templates/web/src/components/Overview.tsx b/rpa-dashboard-ai/templates/web/src/components/Overview.tsx new file mode 100644 index 0000000..861d340 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/components/Overview.tsx @@ -0,0 +1,81 @@ +import { useStore } from '../store' + +// batch state -> [label, colour token, pill colour] +export const STATES: Record = { + completed: ['Completed', 'var(--ok)', 'bg-ok'], + inprogress: ['In progress', 'var(--warn)', 'bg-warn'], + notcompleted: ['Not completed', 'var(--bad)', 'bg-bad'], + cleared: ['Cleared', 'var(--muted)', 'bg-muted'], +} + +function Kpi({ label, n, colour, live }: { label: string; n: number; colour: string; live?: boolean }) { + return ( +
+
+
+
{n}
+
{label}
+
+
+ ) +} + +export function Overview() { + const { batches } = useStore() + const count = Object.fromEntries(Object.keys(STATES).map((k) => [k, batches.filter((b) => b.state === k).length])) + const total = Object.values(count).reduce((a, x) => a + x, 0) + let acc = 0 + const stops = Object.keys(STATES).flatMap((k) => { + if (!count[k]) return [] + const from = (acc / total) * 100 + acc += count[k] + return [`${STATES[k][1]} ${from}% ${(acc / total) * 100}%`] + }) + + return ( +
+
+ + + 0} /> + +
+
+
+
Batch status split
+
+
`${STATES[k][0]} ${count[k]}`).join(', ') : 'no batches yet')} + style={{ background: total ? `conic-gradient(${stops.join(',')})` : 'conic-gradient(var(--border) 0 100%)' }}> +
+
{batches.length}
+
batches
+
+
+
    + {Object.keys(STATES).map((k) => ( +
  • + + {STATES[k][0]} · {count[k]} +
  • + ))} +
+
+
+
+
Recent batches
+ {batches.length === 0 &&
No batches yet
} + {batches.slice(0, 6).map((b) => { + const s = STATES[b.state] ?? [b.state, '', 'bg-warn'] + return ( +
+ #{b.batch_id} · {b.date} + {s[0]} +
+ ) + })} +
+
+
+ ) +} diff --git a/rpa-dashboard-ai/templates/web/src/components/Report.tsx b/rpa-dashboard-ai/templates/web/src/components/Report.tsx new file mode 100644 index 0000000..d411ea5 --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/components/Report.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react' +import { useStore, type ReportRow } from '../store' + +const PILL: Record = { success: 'bg-ok', attention: 'bg-warn', failed: 'bg-bad' } + +// ponytail: a CSV that Excel opens. Have the backend send a real .xlsx if the sheet needs formatting. +function exportCsv(rows: ReportRow[]) { + const cell = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"` + const csv = [REPORT_COLUMNS.map((c) => cell(c.label)), ...rows.map((r) => REPORT_COLUMNS.map((c) => cell(r[c.key])))] + .map((line) => line.join(',')).join('\r\n') + const a = document.createElement('a') + a.href = URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv' })) + a.download = 'report.csv' + a.click() + URL.revokeObjectURL(a.href) +} + +export function Report() { + const { reportRows } = useStore() + const [q, setQ] = useState('') + const rows = reportRows.filter((r) => !q || Object.values(r).join(' ').toLowerCase().includes(q.toLowerCase())) + + return ( +
+
+ setQ(e.target.value)} /> + +
+
+ + + {REPORT_COLUMNS.map((c) => )} + + + {rows.length === 0 && } + {rows.map((r, i) => ( + + {REPORT_COLUMNS.map((c) => ( + + ))} + + ))} + +
{c.label}
No rows yet
+ {c.key === 'status' ? {r.status} : String(r[c.key] ?? '')} +
+
+
+ ) +} diff --git a/rpa-dashboard-ai/templates/web/src/components/Review.tsx b/rpa-dashboard-ai/templates/web/src/components/Review.tsx new file mode 100644 index 0000000..022ab1c --- /dev/null +++ b/rpa-dashboard-ai/templates/web/src/components/Review.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from 'react' +import { ai, BANDS, BUSY, CAVEAT, type Band, type ReviewRow, type Run } from '../ai' + +const FILTERS: { label: string; band: Band | 'all' }[] = [ + { label: 'All', band: 'all' }, { label: 'Red', band: 'red' }, { label: 'Amber', band: 'amber' }, + { label: 'Missing', band: 'blank' }, { label: 'Unscored', band: 'unscored' }, +] +const colourOf = (b: Band) => BANDS.find((x) => x.band === b)!.colour +const settled = (r: ReviewRow) => r.corrected_value ?? r.value ?? '' + +/** + * The review split pane: the source document beside its extracted fields. + * Typing makes a draft; nothing is written until Save. Verify signs off one field + * as it reads. Approve asks the server, which refuses while flagged fields are unverified. + */ +export function Review({ onUnsaved }: { onUnsaved: (n: number) => void }) { + const [runs, setRuns] = useState() + const [jobId, setJobId] = useState() + const [run, setRun] = useState() + const [rows, setRows] = useState([]) + const [drafts, setDrafts] = useState>({}) + const [filter, setFilter] = useState('all') + const [refusal, setRefusal] = useState() + const [error, setError] = useState() + const [saving, setSaving] = useState(false) + + const dirty = rows.filter((r) => r.row_id in drafts && drafts[r.row_id] !== settled(r)) + useEffect(() => onUnsaved(dirty.length), [dirty.length, onUnsaved]) + useEffect(() => { + if (!dirty.length) return + const warn = (e: BeforeUnloadEvent) => e.preventDefault() + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, [dirty.length]) + + useEffect(() => { + ai.runs().then((d) => { setRuns(d.runs); setJobId(d.runs[0]?.job_id) }) + .catch((e) => setError(`Can't load runs from ${API_BASE}: ${e.message}. Is the backend (or server_stub.py) running?`)) + }, []) + + // Load the chosen run, and poll it every 1.5s while it is still extracting. + useEffect(() => { + if (!jobId) return + let alive = true + let timer: ReturnType + setRun(undefined); setRows([]); setDrafts({}); setRefusal(undefined) + const tick = async () => { + try { + const r = await ai.run(jobId) + if (!alive) return + setRun(r) + if (BUSY.includes(r.status)) timer = setTimeout(tick, 1500) + else { const d = await ai.rows(jobId); if (alive) setRows(d.rows) } + } catch (e) { if (alive) setError((e as Error).message) } + } + tick() + return () => { alive = false; clearTimeout(timer) } + }, [jobId]) + + const reload = async () => { + if (!jobId) return + const [r, d] = await Promise.all([ai.run(jobId), ai.rows(jobId)]) + setRun(r); setRows(d.rows) + } + const pick = (id: string) => { + if (dirty.length && !window.confirm(`${dirty.length} unsaved change(s) on this document. Discard them and leave?`)) return + setJobId(id) + } + const save = async () => { + setSaving(true) + try { + await 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.patchRow(r.row_id, { corrected_value: text === '' || text === (r.value ?? '') ? null : text, verified: true }) + })) + setDrafts({}) + await reload() + } finally { setSaving(false) } + } + const verify = async (r: ReviewRow) => { await ai.patchRow(r.row_id, { verified: !r.verified }); await reload() } + const verifyRemaining = async () => { if (jobId) { await ai.verifyRemaining(jobId); await reload() } } + const approve = async () => { + if (!jobId) return + setRefusal(undefined) + try { await ai.approve(jobId); await reload() } catch (e) { setRefusal((e as Error).message) } + } + + if (error) return
{error}
+ if (!runs) return
Loading runs…
+ if (!runs.length) return
No documents extracted yet.
+ + const shown = filter === 'all' ? rows : rows.filter((r) => r.band === filter) + const left = rows.filter((r) => r.band !== 'green' && !r.verified).length + + return ( +
+
+ + {run && {run.status}} + {run && BUSY.includes(run.status) && page {run.pages_done} of {run.pages_total || '?'}} + {run?.error && {run.error}} + {run?.status === 'done' && ( +
+ {left} to verify + + {run.approved + ? ✓ Approved + : } +
+ )} + {refusal &&

Not approved: {refusal}

} +
+ +
+
    + {BANDS.map((b) => ( +
  • + + {b.label} · {run?.summary?.[b.band] ?? 0} +
  • + ))} +
+ {CAVEAT} +
+ {FILTERS.map((f) => ( + + ))} +
+
+ +
+ +
+ +
    + +
    +
    +
    + + + 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)], From d89dce3c3328f1e112340f0f0efa995e016f692f Mon Sep 17 00:00:00 2001 From: Vasanth Date: Fri, 16 Oct 2026 15:32:45 +0530 Subject: [PATCH 5/5] AI desktop path: plain-HTML Review and Cost tabs over the Qt bridge, gate in Python ai_sections.html pastes into the no-AI dashboard.html; ai_bridge.py adds one ai_request slot that hands every AI contract request (incl. GET /roi) to the review backend's dispatch(). Desktop page check reruns the web review and cost cases plus a tampered-page gate case. Eval fixture e-pyqt-ai added. Co-Authored-By: Claude Opus 5.5 --- rpa-dashboard-ai/SKILL.md | 12 +-- rpa-dashboard-ai/templates/contract.md | 3 + .../templates/desktop/ai_sections.html | 76 ++++++++++++++++++- tests/README.md | 10 ++- tests/evals/run_eval.py | 23 +++++- tests/fixtures/e-pyqt-ai/README.md | 11 +++ tests/fixtures/e-pyqt-ai/bot.py | 31 ++++++++ tests/fixtures/e-pyqt-ai/extractor.py | 34 +++++++++ .../fixtures/e-pyqt-ai/inbox/invoice-2201.pdf | 2 + .../fixtures/e-pyqt-ai/inbox/invoice-2202.pdf | 2 + tests/fixtures/e-pyqt-ai/requirements.txt | 3 + tests/page_check_ai_desktop/conftest.py | 2 +- .../page_check_ai_desktop/test_ai_desktop.py | 8 ++ 13 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/e-pyqt-ai/README.md create mode 100644 tests/fixtures/e-pyqt-ai/bot.py create mode 100644 tests/fixtures/e-pyqt-ai/extractor.py create mode 100644 tests/fixtures/e-pyqt-ai/inbox/invoice-2201.pdf create mode 100644 tests/fixtures/e-pyqt-ai/inbox/invoice-2202.pdf create mode 100644 tests/fixtures/e-pyqt-ai/requirements.txt diff --git a/rpa-dashboard-ai/SKILL.md b/rpa-dashboard-ai/SKILL.md index 421bdd2..084f5ed 100644 --- a/rpa-dashboard-ai/SKILL.md +++ b/rpa-dashboard-ai/SKILL.md @@ -15,7 +15,7 @@ means `../rpa-dashboard-no-ai/templates/`. | `templates/web/pricing.py` | The pricing table (with its source and as-of date) and `cost_for_call`. The backend imports it. | | `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/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_sections.html` | Desktop: the Review tab (band legend, review split pane, drafts, Verify, approve gate) and the Cost tab 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. | @@ -71,9 +71,9 @@ pane, the approve gate and the cost page. Ask which extras to add (choose any, o the model per batch), **retry on another model** (for failed runs). Build only the chosen ones at their `OPTIONAL:` markers, and delete the markers. - **Model picker:** a `` (from `GET /models`, defaulting to the run's own model) and a Retry button that sends `POST /runs/{job_id}/retry {model}`, then polls the run as usual. Both need the endpoints in `templates/contract.md`; add them to the backend (and the stub). @@ -106,11 +106,11 @@ Both need the endpoints in `templates/contract.md`; add them to the backend (and 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`, +2. Paste the whole of `templates/desktop/ai_sections.html` (Review and Cost tabs) 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`. +3. Copy `templates/desktop/ai_bridge.py`, `templates/web/server_stub.py`, + `templates/web/bands.py` and `templates/web/pricing.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. diff --git a/rpa-dashboard-ai/templates/contract.md b/rpa-dashboard-ai/templates/contract.md index b2c679f..88bd978 100644 --- a/rpa-dashboard-ai/templates/contract.md +++ b/rpa-dashboard-ai/templates/contract.md @@ -78,6 +78,9 @@ The same endpoints and rules travel over the Qt bridge instead of HTTP: 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. +- Every endpoint above uses this pair, the cost page's `GET /roi` included (the page sends + `ai_request(id, "GET", "/roi", "{}")` and gets `{runs: [RoiRun], pricing: {source, as_of}}` + back as the `body`). No other AI events or slots exist. - `GET /runs/{job_id}/document` answers an SVG string or a URL the page can load (for example a `file:///` path to the PDF). diff --git a/rpa-dashboard-ai/templates/desktop/ai_sections.html b/rpa-dashboard-ai/templates/desktop/ai_sections.html index cbc4944..7cda176 100644 --- a/rpa-dashboard-ai/templates/desktop/ai_sections.html +++ b/rpa-dashboard-ai/templates/desktop/ai_sections.html @@ -34,6 +34,9 @@ body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580} .rv-fields .btn{width:92px} .rv-save{display:flex;align-items:center;gap:10px;border-top:1px solid var(--border);padding:10px 16px} .rv-save .rv-unsaved{font-size:12px;font-weight:700;color:var(--warn)} +.co-tile{padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border)} +.co-tile .kpi-val{margin-top:4px} +.co-r{text-align:right} @media (max-width:820px){.rv-split{flex-direction:column}.rv-filters{margin-left:0}} @@ -43,6 +46,8 @@ body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
    + +
    @@ -61,6 +66,29 @@ body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
    + + diff --git a/tests/README.md b/tests/README.md index 2ebe9c5..dec0cc4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -29,6 +29,12 @@ uv run --with pytest --with playwright pytest tests by-hand assumptions), plus every no-AI case. Tests set run and review state on the stub directly (the `stub` fixture). To check a generated dashboard folder instead, set `PAGE_CHECK_AI_DIR=path/to/dashboard`. Needs npm on PATH, else it skips. +- `page_check_ai_desktop/`: **Seam 1, AI desktop page.** The no-AI `dashboard.html` with + `ai_sections.html` pasted in, loaded from `file://` with a fake Qt bridge whose + `ai_request` slot goes to `server_stub.dispatch()` in this process (what `ai_bridge.py` + does). Runs the AI web page's review and cost cases and the no-AI cases unchanged, plus + a tampered page calling the slot directly (still refused). To check a generated folder, + set `PAGE_CHECK_AI_DESKTOP_DIR=path/to/dashboard`; `PAGE_CHECK_WIRED=1` as above. - `test_pricing.py`: the AI cost function (normal, long-context, thinking tokens, unknown model). - `test_reference_scan.py`: no patient data, internal IPs, credentials or keys in any @@ -45,7 +51,9 @@ uv run --with pytest --with playwright pytest tests fixture folder with different answers: `c-keep` and `c-restyle` both run on `c-web-existing-frontend`, and also check what changed in the files it already had. An `"ai": True` entry (`d-ai-extraction`) expects the AI skill's React starter and - runs `page_check_ai/` on the folder it built. + runs `page_check_ai/` on the folder it built. An `"ai_desktop": True` entry + (`e-pyqt-ai`) expects the desktop AI page and runs `page_check/` (with the bot replay) + and `page_check_ai_desktop/` on it. For a bot fixture (`b-pyqt-bot`), the eval also dry-runs the generated `bot_runner.py`, saves its events to `evals/results/.run.jsonl`, and replays them in the page check. diff --git a/tests/evals/run_eval.py b/tests/evals/run_eval.py index 6510c18..65b780a 100644 --- a/tests/evals/run_eval.py +++ b/tests/evals/run_eval.py @@ -80,6 +80,17 @@ FIXTURES = { "page_has": ["--accent:#ff3049"], "ai": True, }, + # (e) PyQt bot with an LLM extraction call: the AI skill's desktop path, no web server. + "e-pyqt-ai": { + "prompt": "Build me an RPA dashboard for this project. I can't answer questions while you " + "work, so here are my answers to anything the skill asks. Colours: Tebra Sky. " + "Optional sections: none.", + "frontend": r"^\s*(none|no\b)", + "asks": [r"colou?r", r"section"], + "never_asks": [r"\bkeep\b|restyle", r"desktop|web"], + "bot": True, + "ai_desktop": True, + }, } # A line a restyle may change: a custom property, a comment, a blank, or a web-font link. TOKEN_LINE = r"^\s*(--[\w-]+\s*:.*|/\*.*\*/|]*fonts\.googleapis\.com.*|@import.*fonts\.googleapis\.com.*)?\s*$" @@ -171,6 +182,11 @@ def main(fixture): html += css.read_text(encoding="utf-8") if css.exists() else "" checks["AI path: review starter and server stub built"] = all( (pages[0].parent / f).exists() for f in ["server_stub.py", "bands.py", "src/components/Review.tsx"]) + if cfg.get("ai_desktop"): # the no-AI page with the plain-HTML AI sections, hosted by ai_bridge.py + host = pages[0].with_name("ai_bridge.py") + checks["AI desktop path: sections, ai_bridge and review backend built"] = 'id="ai-review-tab"' in html and all( + pages[0].with_name(f).exists() for f in ["ai_bridge.py", "server_stub.py", "bands.py", "pricing.py"]) + checks["no placeholders left in ai_bridge.py"] = host.exists() and "{{" not in host.read_text(encoding="utf-8") shutil.copy(pages[0], results / f"{fixture}.dashboard.html") checks["no placeholders left"] = "{{" not in html if cfg.get("brand"): @@ -186,8 +202,11 @@ def main(fixture): env.update(bot_checks(project, html, checks, results / f"{fixture}.run.jsonl")) if cfg.get("ai"): env["PAGE_CHECK_AI_DIR"] = str(pages[0].parent) - check_dir = ROOT / "tests" / ("page_check_ai" if cfg.get("ai") else "page_check") - pc = subprocess.run([sys.executable, "-m", "pytest", str(check_dir), "-q"], + check_dirs = [ROOT / "tests" / ("page_check_ai" if cfg.get("ai") else "page_check")] + if cfg.get("ai_desktop"): + env["PAGE_CHECK_AI_DESKTOP_DIR"] = str(pages[0].parent) + check_dirs.append(ROOT / "tests" / "page_check_ai_desktop") + pc = subprocess.run([sys.executable, "-m", "pytest", *map(str, check_dirs), "-q"], env=env, capture_output=True, text=True) checks["page check passes"] = pc.returncode == 0 page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]] diff --git a/tests/fixtures/e-pyqt-ai/README.md b/tests/fixtures/e-pyqt-ai/README.md new file mode 100644 index 0000000..88c0c63 --- /dev/null +++ b/tests/fixtures/e-pyqt-ai/README.md @@ -0,0 +1,11 @@ +# Invoice intake bot + +A desktop RPA bot. It reads each scanned invoice in `inbox/` with an LLM +(`extractor.py`) and queues the fields for a person to review. +The operator window will be a PyQt6 app. + +Run the bot on its own: + +``` +python -m bot +``` diff --git a/tests/fixtures/e-pyqt-ai/bot.py b/tests/fixtures/e-pyqt-ai/bot.py new file mode 100644 index 0000000..9e961bb --- /dev/null +++ b/tests/fixtures/e-pyqt-ai/bot.py @@ -0,0 +1,31 @@ +"""Invoice intake bot: picks up scanned invoices and extracts their fields with an LLM. +Start it with: python -m bot""" +import time +from pathlib import Path + +INBOX = Path(__file__).with_name("inbox") + + +def say(line): + print(line, flush=True) + time.sleep(0.2) + + +def main(): + say("[step 1] Initializing") + docs = sorted(INBOX.glob("*.pdf")) + say(f"[inbox] {len(docs)} invoices waiting") + say("[step 2] Extracting fields") + for i, doc in enumerate(docs, 1): + try: + from extractor import extract_invoice # the LLM call + rows = extract_invoice(doc.read_bytes(), "application/pdf") + say(f"[extract] {doc.name}: {len(rows)} fields ({i}/{len(docs)})") + except Exception as e: # one bad document must not stop the batch + say(f"ERROR: extraction failed for {doc.name}: {type(e).__name__}") + say("[step 3] Queueing for review") + say(f"[all done] {len(docs)} invoices processed") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/e-pyqt-ai/extractor.py b/tests/fixtures/e-pyqt-ai/extractor.py new file mode 100644 index 0000000..19e4d09 --- /dev/null +++ b/tests/fixtures/e-pyqt-ai/extractor.py @@ -0,0 +1,34 @@ +"""The LLM extraction call: one scanned invoice in, its fields out, each with word scores.""" +import json + +from google import genai +from google.genai import types + +MODEL = "gemini-2.5-flash" +FIELDS = { # field name -> mandatory + "Invoice number": True, + "Invoice date": True, + "Vendor": True, + "PO number": False, + "Due date": True, + "Total": True, +} +PROMPT = ( + "Read this invoice. Return JSON: a list of {field, value, word_scores}, one per field in " + + ", ".join(FIELDS) + + ". word_scores is your 0-1 legibility score for each word of the value. Use an empty " + "value and no scores when a field isn't on the page." +) + + +def extract_invoice(document: bytes, mime_type: str) -> list[dict]: + client = genai.Client() # credentials come from the environment + response = client.models.generate_content( + model=MODEL, + contents=[types.Part.from_bytes(data=document, mime_type=mime_type), PROMPT], + config=types.GenerateContentConfig(response_mime_type="application/json"), + ) + rows = json.loads(response.text) + for row in rows: + row["mandatory"] = FIELDS.get(row["field"], False) + return rows diff --git a/tests/fixtures/e-pyqt-ai/inbox/invoice-2201.pdf b/tests/fixtures/e-pyqt-ai/inbox/invoice-2201.pdf new file mode 100644 index 0000000..42a32b3 --- /dev/null +++ b/tests/fixtures/e-pyqt-ai/inbox/invoice-2201.pdf @@ -0,0 +1,2 @@ +%PDF-1.4 +% sample invoice placeholder diff --git a/tests/fixtures/e-pyqt-ai/inbox/invoice-2202.pdf b/tests/fixtures/e-pyqt-ai/inbox/invoice-2202.pdf new file mode 100644 index 0000000..42a32b3 --- /dev/null +++ b/tests/fixtures/e-pyqt-ai/inbox/invoice-2202.pdf @@ -0,0 +1,2 @@ +%PDF-1.4 +% sample invoice placeholder diff --git a/tests/fixtures/e-pyqt-ai/requirements.txt b/tests/fixtures/e-pyqt-ai/requirements.txt new file mode 100644 index 0000000..b7a8fae --- /dev/null +++ b/tests/fixtures/e-pyqt-ai/requirements.txt @@ -0,0 +1,3 @@ +PyQt6 +PyQt6-WebEngine +google-genai diff --git a/tests/page_check_ai_desktop/conftest.py b/tests/page_check_ai_desktop/conftest.py index 73d10b1..c145441 100644 --- a/tests/page_check_ai_desktop/conftest.py +++ b/tests/page_check_ai_desktop/conftest.py @@ -63,7 +63,7 @@ def site(tmp_path_factory): 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"]: + for f in ["server_stub.py", "bands.py", "pricing.py"]: (out / f).write_bytes((AI / "web" / f).read_bytes()) return out diff --git a/tests/page_check_ai_desktop/test_ai_desktop.py b/tests/page_check_ai_desktop/test_ai_desktop.py index e122efa..8ffef30 100644 --- a/tests/page_check_ai_desktop/test_ai_desktop.py +++ b/tests/page_check_ai_desktop/test_ai_desktop.py @@ -16,6 +16,14 @@ from test_ai_review import ( # noqa: E402,F401 test_page_x_of_y_updates_while_running, ) from test_ai_review import FIELDS, FLAGGED, open_review # noqa: E402 +from test_ai_cost import ( # noqa: E402,F401 + cost_page, + test_by_hand_panel_is_labelled_assumptions_and_recomputes, + test_coverage_pill_counts_priced_of_all_documents, + test_month_breakdown_groups_runs_by_month, + test_runs_show_tokens_and_cost_and_unpriced_is_not_zero, + test_spend_tiles_sum_the_priced_runs, +) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "page_check")) from test_contract import ( # noqa: E402,F401