feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small Fastify + SQLite service in the monorepo (shares the payload contract and the class vocabulary via @parking/shared), delivered to art-docker-station by its own stack so nothing booth-side lands there and nothing of it on a booth. - POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row. - /review + /api/*: the reviewer's screen served by the process (Basic auth, one login): one pending crop at a time, operator's pick and camera's pick beside it, one button/key per vocabulary class + unusable + skip; stats per booth and per hashed operator (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's category). - GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host. - Booth payload now carries operatorCategory.classes so the comparison needs no site setup. - Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one secret per booth referenced from both the collector's token list and the booth's own stack (park-2 lines templated, commented, DNS name for the URL). - Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { VEHICLE_CLASSES } from "@parking/shared";
|
||||
|
||||
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
|
||||
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
|
||||
// itself (no build step, no framework) — this is deliberately the whole UI.
|
||||
|
||||
export function reviewPage(): string {
|
||||
const classes = JSON.stringify(VEHICLE_CLASSES);
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wash review</title>
|
||||
<style>
|
||||
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
|
||||
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
|
||||
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
|
||||
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
|
||||
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
|
||||
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
|
||||
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
|
||||
dt { color:var(--muted); }
|
||||
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
|
||||
button:hover { border-color:var(--amber); }
|
||||
button.mono { font-family:ui-monospace, monospace; }
|
||||
button.hint { border-color:var(--amber); }
|
||||
button.unusable { color:var(--red); }
|
||||
button.skip { color:var(--muted); }
|
||||
.muted { color:var(--muted); }
|
||||
.warn { color:var(--amber); }
|
||||
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
|
||||
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
|
||||
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
|
||||
<main>
|
||||
<section class="card" id="item">
|
||||
<p class="muted">Loading…</p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
|
||||
</section>
|
||||
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
|
||||
</main>
|
||||
<script>
|
||||
const CLASSES = ${classes};
|
||||
const skipped = new Set();
|
||||
let current = null;
|
||||
|
||||
async function api(path, init) {
|
||||
const r = await fetch(path, init);
|
||||
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
|
||||
async function loadStats() {
|
||||
const s = await api('/api/stats');
|
||||
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
|
||||
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
|
||||
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
|
||||
const tb = document.querySelector('#stats tbody');
|
||||
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
|
||||
}
|
||||
|
||||
async function next() {
|
||||
const { items } = await api('/api/items?status=pending&limit=25');
|
||||
current = items.find(i => !skipped.has(i.id)) || null;
|
||||
const el = document.getElementById('item');
|
||||
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
|
||||
const it = current;
|
||||
const opClasses = JSON.parse(it.operatorClasses || '[]');
|
||||
el.innerHTML =
|
||||
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
|
||||
'<dl style="margin-top:.8rem">' +
|
||||
'<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>' +
|
||||
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
|
||||
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
|
||||
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>' +
|
||||
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
|
||||
'</dl>' +
|
||||
'<div class="buttons" style="margin-top:.8rem">' +
|
||||
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
|
||||
'<button class="unusable" data-label="unusable">unusable</button>' +
|
||||
'<button class="skip" data-skip="1">skip</button>' +
|
||||
'</div>';
|
||||
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
|
||||
el.querySelector('button[data-skip]').addEventListener('click', skip);
|
||||
}
|
||||
|
||||
async function verdict(label) {
|
||||
if (!current) return;
|
||||
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
|
||||
await Promise.all([next(), loadStats()]);
|
||||
}
|
||||
function skip() { if (current) { skipped.add(current.id); next(); } }
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
if (e.key === 'u') verdict('unusable');
|
||||
else if (e.key === 's') skip();
|
||||
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
|
||||
});
|
||||
|
||||
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
|
||||
loadStats().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user