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 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 15:06:44 +05:30
1 parent 3eb3fdf00e
commit d4d3f83fb1
24 files changed
+3429 -73

No files matched your search

@@ -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
+47
View File
@@ -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"
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<!--
AI RPA dashboard (React + Vite + Tailwind). Everything on the no-AI page, plus
confidence bands, the review split pane and the approve gate.
Fill every {{...}} placeholder in the CONFIG block; they mean the same as on the
no-AI page. Theme = the body / body.dark token blocks in src/index.css only.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{APP_NAME}}</title>
<script>
// ===== CONFIG (fill per project) =====
const APP_NAME = "{{APP_NAME}}";
const APP_SUBTITLE = "{{APP_SUBTITLE}}";
const LOGO_SRC = "{{LOGO_SRC}}";
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Extract","Review","Completed"]
const REPORT_COLUMNS = {{REPORT_COLUMNS_JSON}}; // must include {"key":"status",...}
const API_BASE = "{{API_BASE}}"; // e.g. "/api"
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -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"
}
}
@@ -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'<text x="48" y="{150 + i * 46}" font-size="20">{escape(f)}: {escape(v)}</text>'
for i, (f, v, *_) in enumerate(SAMPLE_FIELDS))
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" width="640" height="820" font-family="Georgia,serif">'
f'<rect width="100%" height="100%" fill="#fff"/><text x="48" y="80" font-size="28">'
f'{escape(RUNS[job_id]["document"])} (sample)</text>{lines}</svg>')
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()
+105
View File
@@ -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', <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>],
run: ['Run', 'Live progress and log', <polygon points="6 4 20 12 6 20 6 4" />],
review: ['Review', 'Check extracted fields against the document', <><path d="M4 4h10l6 6v10H4z" /><path d="m8 14 3 3 5-6" /></>],
report: ['Report', 'Result of every processed item', <><path d="M4 4h16v16H4z" /><path d="M8 9h8M8 13h8M8 17h5" /></>],
// 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<Tab>('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 (
<div className="flex h-screen">
<aside className="flex w-[230px] min-w-[230px] flex-col border-r border-line bg-glass max-[820px]:w-[66px] max-[820px]:min-w-[66px]" style={{ backdropFilter: 'var(--blur)' }}>
<div className="flex items-center gap-3 border-b border-line px-5 py-5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-glass-strong">
{LOGO_SRC && <img src={LOGO_SRC} alt="" className="h-7 w-7 object-contain" onError={(e) => e.currentTarget.remove()} />}
</div>
<div className="max-[820px]:hidden">
<div className="font-head text-[15px] font-bold">{APP_NAME}</div>
<div className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted">{APP_SUBTITLE}</div>
</div>
</div>
<nav className="flex-1 px-3 py-4">
{(Object.keys(TABS) as Tab[]).map((t) => (
<button
key={t}
title={TABS[t][0]}
onClick={() => show(t)}
aria-current={tab === t ? 'page' : undefined}
className={`mb-1 flex w-full cursor-pointer items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-[13.5px] font-semibold ${tab === t ? 'text-white shadow-md' : 'text-muted hover:bg-glass-strong hover:text-ink'}`}
style={tab === t ? { background: 'var(--nav-active-bg)' } : undefined}
>
<svg viewBox="0 0 24 24" className="h-[18px] w-[18px] shrink-0 fill-none stroke-current stroke-2">{TABS[t][2]}</svg>
<span className="max-[820px]:hidden">{TABS[t][0]}</span>
</button>
))}
</nav>
<div className="flex items-center gap-2.5 border-t border-line p-3.5">
<div className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-full text-xs font-bold text-white" style={{ background: 'var(--nav-active-bg)' }}>
{(user.username || 'U')[0].toUpperCase()}
</div>
<div className="max-[820px]:hidden">
<div className="text-[13px] font-bold">{user.username}</div>
<div className="text-[10.5px] text-muted">{user.role}</div>
</div>
</div>
</aside>
<main className="flex flex-1 flex-col overflow-hidden">
<header className="flex items-center gap-3.5 border-b border-line px-7 py-5 shadow-[0_6px_18px_rgba(20,50,70,.10)]"
style={{ background: 'var(--header-bg)', color: 'var(--header-ink)', backdropFilter: 'var(--blur)' }}>
<div>
<div className="font-head text-[21px] font-extrabold">{TABS[tab][0]}</div>
<div className="mt-0.5 text-[12.5px]" style={{ color: 'var(--header-muted)' }}>{TABS[tab][1]}</div>
</div>
<div className="ml-auto flex items-center gap-3">
<button className="btn btn-primary" onClick={run} disabled={status.state === 'running'}>▶ Run</button>
<button className="h-[38px] w-[38px] cursor-pointer rounded-[11px] border border-line bg-glass-strong text-base text-ink"
title="Dark mode" aria-label="Dark mode" aria-pressed={dark} onClick={toggleDark}>
{dark ? '☀' : '🌙'}
</button>
<div className="flex items-center gap-2 rounded-full border border-line bg-glass-strong px-3 py-1.5 text-xs font-bold text-ink">
<span className={`h-2 w-2 rounded-full ${DOT[status.state] ?? 'bg-muted'}`} />
<span>{status.msg || status.state}</span>
</div>
</div>
</header>
<section className="flex min-h-0 flex-1 flex-col overflow-y-auto px-7 py-6 max-[820px]:px-4">
{tab === 'dashboard' && <Overview />}
{tab === 'run' && <RunPanel />}
{tab === 'review' && <Review onUnsaved={setUnsaved} />}
{tab === 'report' && <Report />}
</section>
</main>
</div>
)
}
+57
View File
@@ -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<Band, number> | 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<T>(path: string, init?: RequestInit): Promise<T> {
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<Run>(`/runs/${id}`),
rows: (id: string) => req<{ rows: ReviewRow[] }>(`/runs/${id}/review`),
patchRow: (rowId: number, body: { corrected_value?: string | null; verified: boolean }) =>
req<ReviewRow>(`/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`,
}
@@ -0,0 +1,81 @@
import { useStore } from '../store'
// batch state -> [label, colour token, pill colour]
export const STATES: Record<string, [string, string, string]> = {
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 (
<div role="group" aria-label={label} className="flex items-center gap-3 rounded-2xl border border-line bg-glass-strong px-4 py-4 shadow-sm">
<div className={`h-[42px] w-[42px] shrink-0 rounded-xl ${live ? 'animate-pulse' : ''}`} style={{ background: colour }} />
<div>
<div className="font-head text-[26px] leading-tight font-extrabold tabular-nums">{n}</div>
<div className="mt-0.5 text-[11.5px] font-semibold text-muted">{label}</div>
</div>
</div>
)
}
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 (
<div>
<div className="mb-4 grid grid-cols-4 gap-3.5 max-[820px]:grid-cols-2">
<Kpi label="Total batches" n={batches.length} colour="var(--accent)" />
<Kpi label="Completed" n={count.completed} colour="var(--ok)" />
<Kpi label="In progress" n={count.inprogress} colour="var(--warn)" live={count.inprogress > 0} />
<Kpi label="Not completed" n={count.notcompleted} colour="var(--bad)" />
</div>
<div className="grid grid-cols-2 gap-3.5 max-[820px]:grid-cols-1">
<div className="card">
<div className="card-title">Batch status split</div>
<div className="flex flex-wrap items-center gap-6">
<div role="img" className="relative h-[150px] w-[150px] rounded-full"
aria-label={'Batch status: ' + (total ? Object.keys(STATES).map((k) => `${STATES[k][0]} ${count[k]}`).join(', ') : 'no batches yet')}
style={{ background: total ? `conic-gradient(${stops.join(',')})` : 'conic-gradient(var(--border) 0 100%)' }}>
<div className="absolute inset-[22px] flex flex-col items-center justify-center rounded-full" style={{ background: 'var(--bg-bottom)' }}>
<div className="font-head text-[26px] font-extrabold">{batches.length}</div>
<div className="text-[10px] font-bold uppercase text-muted">batches</div>
</div>
</div>
<ul aria-label="Batch status">
{Object.keys(STATES).map((k) => (
<li key={k} className="mb-2 flex items-center gap-2 text-[12.5px] font-semibold">
<span className="h-2.5 w-2.5 rounded-[3px]" style={{ background: STATES[k][1] }} />
{STATES[k][0]} · {count[k]}
</li>
))}
</ul>
</div>
</div>
<div className="card">
<div className="card-title">Recent batches</div>
{batches.length === 0 && <div className="py-10 text-center text-[13px] text-muted">No batches yet</div>}
{batches.slice(0, 6).map((b) => {
const s = STATES[b.state] ?? [b.state, '', 'bg-warn']
return (
<div key={b.batch_id} className="mb-2 flex items-center gap-2 text-[12.5px] font-semibold">
#{b.batch_id} · {b.date}
<span className={`pill ml-auto ${s[2]}`}>{s[0]}</span>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,50 @@
import { useState } from 'react'
import { useStore, type ReportRow } from '../store'
const PILL: Record<string, string> = { 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 (
<div className="card">
<div className="mb-3.5 flex flex-wrap items-center gap-2.5">
<input type="search" aria-label="Search report" placeholder="Search…" className="input" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn btn-primary ml-auto" onClick={() => exportCsv(rows)}>⬇ Export to Excel</button>
</div>
<div className="max-h-[60vh] overflow-auto rounded-2xl border border-line bg-glass">
<table className="w-full border-collapse whitespace-nowrap text-xs">
<thead>
<tr>{REPORT_COLUMNS.map((c) => <th key={c.key} className="sticky top-0 bg-glass-strong px-3.5 py-3 text-left font-bold">{c.label}</th>)}</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td colSpan={REPORT_COLUMNS.length} className="p-10 text-center text-[13px] text-muted">No rows yet</td></tr>}
{rows.map((r, i) => (
<tr key={i}>
{REPORT_COLUMNS.map((c) => (
<td key={c.key} className="border-b border-line px-3.5 py-2.5">
{c.key === 'status' ? <span className={`pill ${PILL[r.bucket ?? ''] ?? PILL.attention}`}>{r.status}</span> : String(r[c.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -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<Run[]>()
const [jobId, setJobId] = useState<string>()
const [run, setRun] = useState<Run>()
const [rows, setRows] = useState<ReviewRow[]>([])
const [drafts, setDrafts] = useState<Record<number, string>>({})
const [filter, setFilter] = useState<Band | 'all'>('all')
const [refusal, setRefusal] = useState<string>()
const [error, setError] = useState<string>()
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<typeof setTimeout>
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 <div className="card text-[13px] text-bad">{error}</div>
if (!runs) return <div className="card text-[13px] text-muted">Loading runs…</div>
if (!runs.length) return <div className="card text-[13px] text-muted">No documents extracted yet.</div>
const shown = filter === 'all' ? rows : rows.filter((r) => r.band === filter)
const left = rows.filter((r) => r.band !== 'green' && !r.verified).length
return (
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
<div className="card mb-0 flex flex-wrap items-center gap-3 py-3">
<label className="flex items-center gap-2 text-[12.5px] font-semibold">
Document
<select aria-label="Document" className="input" value={jobId} onChange={(e) => pick(e.target.value)}>
{runs.map((r) => <option key={r.job_id} value={r.job_id}>{r.document}</option>)}
</select>
</label>
{run && <span className={`pill ${run.status === 'done' ? 'bg-ok' : run.status === 'failed' ? 'bg-bad' : 'bg-warn'}`}>{run.status}</span>}
{run && BUSY.includes(run.status) && <span className="text-[12.5px] text-muted tabular-nums">page {run.pages_done} of {run.pages_total || '?'}</span>}
{run?.error && <span className="text-[12px] text-bad">{run.error}</span>}
{run?.status === 'done' && (
<div className="ml-auto flex items-center gap-2">
<span className="text-[12px] text-muted">{left} to verify</span>
<button className="btn btn-sm" onClick={verifyRemaining} disabled={!left || run.approved}>Verify remaining</button>
{run.approved
? <span className="pill bg-ok">✓ Approved</span>
: <button className="btn btn-primary btn-sm" onClick={approve} disabled={dirty.length > 0}
title={dirty.length ? 'Save or discard your edits first' : undefined}>Approve</button>}
</div>
)}
{refusal && <p role="alert" className="w-full rounded-lg bg-bad-bg px-3 py-1.5 text-[12px] font-semibold text-bad">Not approved: {refusal}</p>}
</div>
<div className="card mb-0 flex flex-wrap items-center gap-x-5 gap-y-2 py-3">
<ul aria-label="Confidence bands" className="flex flex-wrap gap-x-4 gap-y-1">
{BANDS.map((b) => (
<li key={b.band} className="flex items-center gap-1.5 text-[12px] font-semibold">
<i className="h-2.5 w-2.5 rounded-[3px]" style={{ background: b.colour }} />
{b.label} · {run?.summary?.[b.band] ?? 0}
</li>
))}
</ul>
<span className="text-[11.5px] text-muted">{CAVEAT}</span>
<div role="group" aria-label="Filter by band" className="ml-auto flex gap-1">
{FILTERS.map((f) => (
<button key={f.band} aria-pressed={filter === f.band} onClick={() => setFilter(f.band)}
className={`btn btn-sm ${filter === f.band ? 'btn-primary' : ''}`}>{f.label}</button>
))}
</div>
</div>
<div className="flex min-h-0 flex-1 gap-3 max-[820px]:flex-col">
<iframe title="Source document" src={jobId && ai.documentUrl(jobId)}
className="min-h-[420px] flex-1 rounded-2xl border border-line bg-white" />
<div className="card mb-0 flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{run && run.status !== 'done' && <p className="p-5 text-[13px] text-muted">Fields appear when extraction finishes.</p>}
<ul aria-label="Extracted fields" className="flex-1 overflow-y-auto p-4">
{shown.map((r) => {
const draft = drafts[r.row_id] ?? settled(r)
const isDirty = draft !== settled(r)
return (
<li key={r.row_id} className={`mb-2 grid grid-cols-[minmax(80px,130px)_1fr_auto_auto] items-center gap-2 ${r.verified && !isDirty ? 'opacity-70' : ''}`}>
<span className="truncate text-[12px] font-semibold text-muted" title={r.field}>{r.field}</span>
<input aria-label={r.field} value={draft} placeholder="empty" title={r.reason ?? undefined}
onChange={(e) => setDrafts((d) => ({ ...d, [r.row_id]: e.target.value }))}
onKeyDown={(e) => e.key === 'Escape' && setDrafts((d) => ({ ...d, [r.row_id]: settled(r) }))}
className={`input min-w-0 border-l-4 ${isDirty ? 'outline outline-warn' : ''}`} style={{ borderLeftColor: colourOf(r.band) }} />
<span className="w-9 text-right text-[11px] text-muted tabular-nums" title="Legibility score: how clearly it was read, not whether it is right">
{r.score?.toFixed(2) ?? '—'}
</span>
<button className="btn btn-sm w-[86px]" aria-label={`Verify ${r.field}`} aria-pressed={r.verified}
disabled={isDirty} title={isDirty ? 'Save or discard the edit first' : undefined} onClick={() => verify(r)}>
{r.verified ? '✓ Verified' : 'Verify'}
</button>
{(r.reason || r.corrected_value != null) && (
<span className="col-span-4 -mt-1 text-[11px] text-muted">
{r.reason}{r.reason && r.corrected_value != null && ' · '}
{r.corrected_value != null && <>extracted <s>{r.value || 'empty'}</s></>}
</span>
)}
</li>
)
})}
</ul>
{dirty.length > 0 && (
<div className="flex items-center gap-2.5 border-t border-line px-4 py-2.5">
<span className="text-[12px] font-semibold text-warn">{dirty.length} unsaved change{dirty.length === 1 ? '' : 's'}</span>
<button className="btn btn-sm ml-auto" onClick={() => setDrafts({})} disabled={saving}>Discard</button>
<button className="btn btn-primary btn-sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react'
import { useStore } from '../store'
const LOG_COLOUR = { info: 'text-[#7fc4f0]', success: 'text-[#5fe0a0]', warn: 'text-[#f5c563]', error: 'text-[#f68c82]' }
export function RunPanel() {
const { step, log } = useStore()
const active = !step ? -1 : step.pct >= 100 ? STEPS.length : STEPS.indexOf(step.label)
const logBox = useRef<HTMLDivElement>(null)
useEffect(() => { logBox.current?.scrollTo(0, logBox.current.scrollHeight) }, [log.length])
return (
<div className="card">
<div className="card-title">Run automation</div>
<div className="mb-2 flex gap-3 text-[13px] font-bold">
<span>{step?.label ?? 'Waiting to start'}</span>
<span className="ml-auto text-accent">{step?.pct ?? 0}%</span>
</div>
<div className="mb-4 h-2 overflow-hidden rounded-lg bg-glass-strong">
<div className="h-full transition-[width] duration-500" style={{ width: `${step?.pct ?? 0}%`, background: 'var(--progress-bg)' }} />
</div>
<ol aria-label="Steps">
{STEPS.map((s, i) => {
const c = i < active ? 'done' : i === active ? 'active' : 'pending'
return (
<li key={s} aria-current={c === 'active' ? 'step' : undefined}
className={`mb-1.5 flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[13px] font-semibold ${c === 'done' ? 'bg-ok-bg text-ok' : c === 'active' ? 'bg-warn-bg text-warn' : 'opacity-50'}`}>
<span className={`flex h-[22px] w-[22px] items-center justify-center rounded-full text-[11px] ${c === 'done' ? 'bg-ok text-white' : c === 'active' ? 'bg-warn text-white' : 'bg-glass-strong'}`}>
{c === 'done' ? '✓' : i + 1}
</span>
{s}
</li>
)
})}
</ol>
{/* OPTIONAL: checkpoint tracker goes here */}
<div ref={logBox} role="log" aria-label="Run log"
className="mt-3 max-h-[220px] overflow-y-auto rounded-xl bg-accent-dark px-3.5 py-3 font-mono text-[13px] leading-[1.85] text-[#9fb6c4]">
{log.map((l, i) => <div key={i} className={LOG_COLOUR[l.type] ?? LOG_COLOUR.info}>{l.msg}</div>)}
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
// Globals from the CONFIG block in index.html.
declare const APP_NAME: string
declare const APP_SUBTITLE: string
declare const LOGO_SRC: string
declare const STEPS: string[]
declare const REPORT_COLUMNS: { key: string; label: string }[]
declare const API_BASE: string
interface Window {
/** Contract entry point: one {event, data} envelope as JSON (desktop hosts, tests). */
__pyMsg: (json: string) => void
}
@@ -0,0 +1,75 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* ===== THEME TOKENS (swap these for a preset/custom palette) =====
The same two blocks as the no-AI page, so every preset in
rpa-dashboard-no-ai/templates/themes.md applies here unchanged. */
body{
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; --font-display:var(--font);
--header-bg:var(--glass-strong); --header-ink:var(--ink); --header-muted:var(--muted);
--primary-bg:linear-gradient(180deg,var(--accent-2),var(--accent));
--nav-active-bg:linear-gradient(135deg,var(--accent),var(--accent-dark));
--progress-bg:var(--ok); --blur:blur(14px);
}
body.dark{
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
}
/* ===== BAND COLOURS =====
Semantic only: swatches, the rule down a field, legend dots. Never on a button
or a heading. They say how clearly a value was printed, not whether it is right.
The same in every preset and both modes. */
body{ --clean:#51a68f; --verify:#e39b34; --review:#de5243; --absent:#6b6580; }
@theme inline {
--color-glass: var(--glass);
--color-glass-strong: var(--glass-strong);
--color-line: var(--border);
--color-ink: var(--ink);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-dark: var(--accent-dark);
--color-accent-soft: var(--accent-soft);
--color-accent-soft-2: var(--accent-soft-2);
--color-ok: var(--ok);
--color-ok-bg: var(--ok-bg);
--color-warn: var(--warn);
--color-warn-bg: var(--warn-bg);
--color-bad: var(--bad);
--color-bad-bg: var(--bad-bg);
--color-clean: var(--clean);
--color-verify: var(--verify);
--color-review: var(--review);
--color-absent: var(--absent);
--font-body: var(--font);
--font-head: var(--font-display);
}
html, body { height: 100%; overflow: hidden; }
body {
margin: 0;
font-family: var(--font);
color: var(--ink);
background: linear-gradient(180deg, var(--bg-top) 0%, var(--bg-mid) 45%, var(--bg-bottom) 100%);
}
/* Shared pieces, kept as classes because every screen uses them. */
@layer components {
.card { @apply mb-4 rounded-2xl border border-line bg-glass p-5 shadow-[0_10px_30px_rgba(20,50,70,.08)]; backdrop-filter: var(--blur); }
.card-title { @apply mb-3.5 font-head text-[15px] font-bold; }
.btn { @apply cursor-pointer rounded-[11px] border border-accent-soft-2 px-4 py-2 font-head text-[12.5px] font-semibold text-ink disabled:cursor-not-allowed disabled:opacity-45; background: linear-gradient(180deg, var(--accent-soft), var(--accent-soft-2)); }
.btn-primary { @apply border-accent text-white; background: var(--primary-bg); }
.btn-sm { @apply px-2.5 py-1 text-[11.5px]; }
.pill { @apply inline-block rounded-full px-3 py-1 text-[11px] font-bold text-white; }
.input { @apply rounded-[10px] border border-line bg-glass-strong px-2.5 py-2 text-[12.5px] text-ink; }
}
@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client'
import './index.css'
import { App } from './App'
import { startTransport } from './store'
startTransport()
createRoot(document.getElementById('root')!).render(<App />)
+139
View File
@@ -0,0 +1,139 @@
import { useSyncExternalStore } from 'react'
/**
* The no-AI contract, as on the no-AI page: envelopes in, actions out.
* Events reach handle() from window.__pyMsg, from polling, or from the sample data.
* The AI review screen talks to its own endpoints (see ai.ts).
*/
export const SAMPLE_DATA = true // no bot yet: the page answers its own data actions. Set false once wired.
export type LogType = 'info' | 'success' | 'warn' | 'error'
export type Batch = { batch_id: string | number; date: string; state: string; items_total: number; items_done: number }
export type ReportRow = Record<string, unknown> & { status?: string; bucket?: string }
export type State = {
status: { state: 'ready' | 'running' | 'done' | 'error'; msg?: string }
step: { label: string; pct: number } | null
log: { msg: string; type: LogType }[]
batches: Batch[]
reportRows: ReportRow[]
user: { username: string; role: string }
dark: boolean
}
const DARK_KEY = 'rpa-dashboard.dark'
let state: State = {
status: { state: 'ready', msg: 'System ready' },
step: null,
log: [],
batches: [],
reportRows: [],
user: { username: 'User', role: 'Operator' },
dark: false,
}
const subs = new Set<() => void>()
const set = (patch: Partial<State>) => {
state = { ...state, ...patch }
subs.forEach((f) => f())
}
export const useStore = () =>
useSyncExternalStore((cb) => (subs.add(cb), () => void subs.delete(cb)), () => state)
// ===== EVENT DISPATCH (see contract.md) =====
export function handle(event: string, d: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
switch (event) {
case 'status': set({ status: d }); break
case 'step': set({ step: d }); break
case 'log': set({ log: [...state.log, d] }); break
case 'dashboard_data': set({ batches: d.batches ?? [] }); break
case 'report_data': set({ reportRows: d.rows ?? [] }); break
case 'set_user': set({ user: d }); break
case 'apply_dark': setDark(!!d.on); break
}
}
window.__pyMsg = (json) => {
const m = JSON.parse(json)
handle(m.event, m.data)
}
/** Flip the theme now (not on the next render), so the page never shows a half-switched theme. */
export function setDark(on: boolean) {
document.body.classList.toggle('dark', on)
try { localStorage.setItem(DARK_KEY, on ? '1' : '') } catch { /* private window */ }
set({ dark: on })
}
export function toggleDark() {
const on = !state.dark
setDark(on)
act('set_dark', on)
}
export function clearRun() {
set({ log: [], step: STEPS.length ? { label: STEPS[0], pct: 0 } : null })
}
// ===== TRANSPORT: sample data, else HTTP polling =====
/** Page -> bot action: POST {API_BASE}/<slot-with-dashes>, body = the args. */
export function act(slot: string, ...args: unknown[]) {
if (SAMPLE_DATA && slot in SAMPLE) return SAMPLE[slot]()
fetch(`${API_BASE}/${slot.replace(/_/g, '-')}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
.then(() => SAMPLE_DATA || poll()) // the answer arrives as events: fetch them now
.catch(() => {})
}
let timer: ReturnType<typeof setTimeout> | undefined
async function poll() {
clearTimeout(timer)
try {
handle('dashboard_data', await (await fetch(`${API_BASE}/dashboard`)).json())
const s = await (await fetch(`${API_BASE}/run/status`)).json()
for (const e of s.events ?? []) handle(e.event, e.data)
timer = setTimeout(poll, s.running ? 2000 : 10000) // poll fast only while busy
} catch {
timer = setTimeout(poll, 10000)
}
}
export function startTransport() {
try { setDark(!!localStorage.getItem(DARK_KEY)) } catch { /* private window */ }
if (SAMPLE_DATA) act('load_dashboard')
else poll()
}
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
const SAMPLE: Record<string, () => void> = {
load_dashboard() {
const day = (n: number) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10)
handle('dashboard_data', { batches: [
{ batch_id: 1006, date: day(0), state: 'inprogress', items_total: 40, items_done: 18 },
{ batch_id: 1005, date: day(1), state: 'completed', items_total: 35, items_done: 35 },
{ batch_id: 1004, date: day(2), state: 'completed', items_total: 52, items_done: 52 },
{ batch_id: 1003, date: day(3), state: 'notcompleted', items_total: 20, items_done: 7 },
{ batch_id: 1002, date: day(4), state: 'completed', items_total: 28, items_done: 28 },
{ batch_id: 1001, date: day(6), state: 'cleared', items_total: 12, items_done: 0 },
] })
},
load_latest_report() {
const rows = [['Processed successfully', 'success'], ['Posted', 'success'], ['Needs review', 'attention'], ['Error', 'failed']]
.map(([status, bucket], i) => ({
...Object.fromEntries(REPORT_COLUMNS.map((c) => [c.key, `${c.label} ${i + 1}`])), status, bucket,
}))
handle('report_data', { rows })
},
start_automation() {
let i = 0
handle('status', { state: 'running', msg: 'Running (sample)' })
const t = setInterval(() => {
if (i < STEPS.length) {
handle('step', { label: STEPS[i], pct: Math.round(((i + 1) / STEPS.length) * 100) })
handle('log', { msg: `Sample step: ${STEPS[i++]}`, type: 'info' })
} else {
clearInterval(t)
handle('log', { msg: 'Sample run finished.', type: 'success' })
handle('status', { state: 'done', msg: 'Completed' })
}
}, 400)
},
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"]
}
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
base: './', // relative asset paths, so the built page works under any URL prefix
// `npm run dev`: the backend (or server_stub.py) answers /api on port 8000.
server: { proxy: { '/api': 'http://127.0.0.1:8000' } },
})
+12
View File
@@ -0,0 +1,12 @@
"""Shared by both page checks: one headless Chromium for the whole run
(Playwright's sync API allows only one instance per process)."""
import pytest
from playwright.sync_api import sync_playwright
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
b = p.chromium.launch()
yield b
b.close()
-9
View File
@@ -11,7 +11,6 @@ import re
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-no-ai" / "templates" / "dashboard.html"
@@ -44,14 +43,6 @@ def page_url(tmp_path_factory):
return out.as_uri()
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
b = p.chromium.launch()
yield b
b.close()
@pytest.fixture(params=list(MODES))
def page(request, browser, page_url):
width, height, dark = MODES[request.param]
+128
View File
@@ -0,0 +1,128 @@
"""Seam 1 page check for the AI web dashboard (React + Vite), served by its server stub.
Page under test: set PAGE_CHECK_AI_DIR to a generated dashboard folder (the one with
package.json, index.html and server_stub.py). Without it, the AI web starter template
is filled with TEST_FILL and built in a cache folder. Either way the folder is built
with npm, and its server_stub.py runs in this process, so tests can set run and
review state directly through the `stub` fixture.
Contract envelopes go in through window.__pyMsg, as in the no-AI check. Each action
the page POSTs to {API_BASE}/<slot> is recorded in window.__calls as [slot, ...args].
Every page test runs in light, dark and narrow mode.
"""
import hashlib
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TEMPLATE = ROOT / "rpa-dashboard-ai" / "templates" / "web"
TEST_FILL = {
"APP_NAME": "Test Extractor",
"APP_SUBTITLE": "Page check",
"LOGO_SRC": "",
"API_BASE": "/api",
"STEPS_JSON": json.dumps(["Initializing", "Login", "Process items", "Completed"]),
"REPORT_COLUMNS_JSON": json.dumps([{"key": "item", "label": "Item"}, {"key": "status", "label": "Status"}]),
}
RECORD_ACTIONS = """
window.__calls = [];
const realFetch = window.fetch;
window.fetch = (url, opts = {}) => {
const m = String(url).match(/\\/api\\/([a-z-]+)$/);
if (m && opts.method === 'POST') window.__calls.push([m[1].replace(/-/g, '_'), ...JSON.parse(opts.body || '[]')]);
return realFetch(url, opts);
};
"""
MODES = {"light": (1280, 820, False), "dark": (1280, 820, True), "narrow": (800, 900, False)}
SKIP_COPY = shutil.ignore_patterns("node_modules", "dist", "__pycache__")
@pytest.fixture(scope="session")
def site():
"""The dashboard folder under test: server_stub.py, index.html and the sources."""
if os.environ.get("PAGE_CHECK_AI_DIR"):
return Path(os.environ["PAGE_CHECK_AI_DIR"]).resolve()
pkg = (TEMPLATE / "package.json").read_bytes() + (TEMPLATE / "package-lock.json").read_bytes()
out = Path(tempfile.gettempdir()) / f"rpa-dashboard-ai-web-{hashlib.sha1(pkg).hexdigest()[:10]}"
shutil.rmtree(out / "src", ignore_errors=True) # node_modules is kept between runs
shutil.copytree(TEMPLATE, out, dirs_exist_ok=True, ignore=SKIP_COPY)
index = out / "index.html"
index.write_text(re.sub(r"\{\{(\w+)\}\}", lambda m: TEST_FILL[m[1]], index.read_text(encoding="utf-8")),
encoding="utf-8")
return out
@pytest.fixture(scope="session")
def built(site):
npm = shutil.which("npm") or pytest.skip("npm not on PATH")
if not (site / "node_modules").exists():
subprocess.run([npm, "ci" if (site / "package-lock.json").exists() else "install"], cwd=site, check=True)
r = subprocess.run([npm, "run", "build"], cwd=site, capture_output=True, text=True)
assert r.returncode == 0, r.stdout + r.stderr
return site
@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("server_stub", site / "server_stub.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="session")
def server(stub_module):
srv = stub_module.make_server("127.0.0.1", 0)
threading.Thread(target=srv.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{srv.server_address[1]}"
srv.shutdown()
@pytest.fixture
def stub(stub_module, server):
"""The running stub, reset to its sample data. Tests change stub.RUNS / stub.ROWS directly."""
stub_module.reset()
return stub_module
@pytest.fixture(scope="session")
def page_url(built, server):
return server + "/"
@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.add_init_script(RECORD_ACTIONS)
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
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):
"""push(event, data): send one contract envelope to the page, as the bot would."""
return lambda event, data: push_to(page, event, data)
+140
View File
@@ -0,0 +1,140 @@
"""AI contract cases: confidence bands, review drafts, the approve gate and run progress.
Run and review state is set on the server stub (the page's backend); what the page
shows is asserted through the browser. See rpa-dashboard-ai/templates/contract.md.
"""
import json
import re
import urllib.error
import urllib.request
from playwright.sync_api import expect
# (field, value, word scores, mandatory) -> the band each one must land in
FIELDS = [
("Invoice number", "INV-2207", [1.0], True), # green
("Vendor", "Northwind Traders", [0.99, 0.97], True), # green
("Invoice date", "2026-10-02", [0.93], True), # amber
("Total", "1,284.50", [0.98, 0.71], True), # red: worst word wins
("Due date", "", [1.0], True), # blank mandatory
("PO number", "PO-118", [], False), # unscored
]
EXPECTED = {"green": ["Invoice number", "Vendor"], "amber": ["Invoice date"], "red": ["Total"],
"blank": ["Due date"], "unscored": ["PO number"]}
FLAGGED = 4 # everything but green
def api(server, method, path, body=None):
req = urllib.request.Request(server + "/api" + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def test_server_refuses_approval_while_flagged_fields_are_unverified(stub, server):
"""The gate lives on the server: no request from any page can skip it."""
stub.put_run("job-gate", "gate.pdf", FIELDS)
status, body = api(server, "POST", "/runs/job-gate/approve")
assert status == 409 and str(FLAGGED) in body["detail"]
_, rows = api(server, "GET", "/runs/job-gate/review")
first_flagged = next(r for r in rows["rows"] if r["band"] != "green")
api(server, "PATCH", f"/review/{first_flagged['row_id']}", {"verified": True})
status, body = api(server, "POST", "/runs/job-gate/approve")
assert status == 409 and str(FLAGGED - 1) in body["detail"]
api(server, "POST", "/runs/job-gate/verify-remaining")
status, _ = api(server, "POST", "/runs/job-gate/approve")
assert status == 200
assert api(server, "GET", "/runs/job-gate")[1]["approved"] is True
def open_review(page, job_id):
page.get_by_role("navigation").get_by_role("button", name="Review", exact=True).click()
page.get_by_label("Document", exact=True).select_option(job_id)
def fields_list(page):
return page.get_by_role("list", name="Extracted fields").get_by_role("listitem")
LEGEND = {"green": "Clean", "amber": "Check", "red": "Review", "blank": "Missing", "unscored": "Unscored"}
def test_band_counts_and_legend_match(page, stub):
stub.put_run("job-t", "bands.pdf", FIELDS)
open_review(page, "job-t")
legend = page.get_by_role("list", name="Confidence bands").get_by_role("listitem")
expect(legend).to_have_count(len(LEGEND))
for i, (band, word) in enumerate(LEGEND.items()):
expect(legend.nth(i)).to_have_text(re.compile(rf"^\s*{word}\b.*· {len(EXPECTED[band])}\s*$"))
expect(page.get_by_text(re.compile("print clarity, not correctness", re.I))).to_be_visible()
def test_band_filters_show_the_right_rows(page, stub):
stub.put_run("job-t", "bands.pdf", FIELDS)
open_review(page, "job-t")
expect(fields_list(page)).to_have_count(len(FIELDS))
filters = page.get_by_role("group", name="Filter by band")
for name, band in [("Red", "red"), ("Amber", "amber"), ("Missing", "blank"), ("Unscored", "unscored")]:
filters.get_by_role("button", name=name, exact=True).click()
expect(fields_list(page)).to_have_count(len(EXPECTED[band]))
for field in EXPECTED[band]:
expect(page.get_by_role("textbox", name=field, exact=True)).to_be_visible()
filters.get_by_role("button", name="All", exact=True).click()
expect(fields_list(page)).to_have_count(len(FIELDS))
def row(stub, job_id, field):
return next(r for r in stub.ROWS[job_id] if r["field"] == field)
def test_draft_edit_is_not_saved_until_save(page, stub):
stub.put_run("job-t", "bands.pdf", FIELDS)
open_review(page, "job-t")
box = page.get_by_role("textbox", name="Vendor", exact=True)
box.fill("Northwind Traders Ltd")
expect(page.get_by_text("1 unsaved change", exact=True)).to_be_visible()
assert row(stub, "job-t", "Vendor")["corrected_value"] is None
asked = []
page.once("dialog", lambda d: (asked.append(d.message), d.dismiss()))
page.get_by_role("navigation").get_by_role("button", name="Report", exact=True).click()
expect(box).to_have_value("Northwind Traders Ltd") # leaving was refused, the draft is still there
assert asked and "unsaved" in asked[0].lower()
page.get_by_role("button", name="Save", exact=True).click()
expect(page.get_by_text("1 unsaved change", exact=True)).to_be_hidden()
saved = row(stub, "job-t", "Vendor")
assert saved["corrected_value"] == "Northwind Traders Ltd" and saved["verified"] is True
def test_approve_stays_blocked_while_rows_are_unverified(page, stub):
stub.put_run("job-t", "bands.pdf", FIELDS)
open_review(page, "job-t")
page.get_by_role("button", name="Verify Total", exact=True).click()
expect(page.get_by_role("button", name="Verify Total", exact=True)).to_have_attribute("aria-pressed", "true")
page.get_by_role("button", name="Approve", exact=True).click()
expect(page.get_by_role("alert")).to_contain_text(f"{FLAGGED - 1} flagged")
assert stub.RUNS["job-t"]["approved"] is False
page.get_by_role("button", name="Verify remaining", exact=True).click()
page.get_by_role("button", name="Approve", exact=True).click()
expect(page.get_by_text("✓ Approved", exact=True)).to_be_visible()
expect(page.get_by_role("alert")).to_be_hidden()
assert stub.RUNS["job-t"]["approved"] is True
def test_page_x_of_y_updates_while_running(page, stub):
stub.put_run("job-busy", "busy.pdf", FIELDS, status="running", pages_done=1, pages_total=4)
open_review(page, "job-busy")
expect(page.get_by_text("page 1 of 4", exact=True)).to_be_visible()
stub.RUNS["job-busy"]["pages_done"] = 3
expect(page.get_by_text("page 3 of 4", exact=True)).to_be_visible()
stub.RUNS["job-busy"].update(status="done", pages_done=4)
expect(fields_list(page)).to_have_count(len(FIELDS))
expect(page.get_by_text(re.compile(r"^page \d+ of \d+$"))).to_be_hidden()
+19
View File
@@ -0,0 +1,19 @@
"""Everything from the no-AI page: the no-AI contract cases, run unchanged against the AI page.
They use this folder's `page` and `push` fixtures, so they check the React page.
"""
import sys
from pathlib import Path
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,
)
+41
View File
@@ -0,0 +1,41 @@
"""Confidence bands: thresholds, the worst-word rule and the blank-mandatory flag (spec: Banding)."""
import importlib.util
from pathlib import Path
BANDS = Path(__file__).resolve().parents[1] / "rpa-dashboard-ai" / "templates" / "web" / "bands.py"
spec = importlib.util.spec_from_file_location("bands", BANDS)
bands = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bands)
def test_thresholds():
assert bands.band_of(1.0) == "green"
assert bands.band_of(0.95) == "green"
assert bands.band_of(0.9499) == "amber"
assert bands.band_of(0.90) == "amber"
assert bands.band_of(0.8999) == "red"
assert bands.band_of(0.0) == "red"
def test_field_takes_its_worst_word():
assert bands.field_band([0.99, 0.97, 0.98], "ACME Office Supply") == "green"
assert bands.field_band([0.99, 0.93, 0.98], "ACME Office Supply") == "amber"
assert bands.field_band([0.99, 0.97, 0.62], "ACME Office Supply") == "red"
def test_blank_mandatory_field_is_always_flagged():
assert bands.field_band([1.0], "", mandatory=True) == "blank"
assert bands.field_band([], " ", mandatory=True) == "blank"
assert bands.field_band(None, None, mandatory=True) == "blank"
assert bands.needs_review("blank")
# An optional blank is graded like any other field.
assert bands.field_band([1.0], "", mandatory=False) == "green"
def test_no_scores_means_unscored():
assert bands.field_band([], "INV-1001") == "unscored"
def test_only_green_skips_review():
assert not bands.needs_review("green")
assert all(bands.needs_review(b) for b in ["amber", "red", "blank", "unscored"])