← back to Wallco Ai
admin: ghost-review viewer + label-collection routes
6bd6333b68461b2f3ab90519a74ba7ca48faf1f9 · 2026-05-23 22:35:29 -0700 · Steve Abrams
- public/admin/ghost-review.html — grid of flagged items, click for detail
modal with drag-rect crop tool; keyboard F/T/S verdicts; category filter,
conf-sort, density slider; localStorage persistence for UI prefs
- server.js: /admin/ghost-review (page), /api/ghost-review/flagged (list),
/api/ghost-review/labels (read), /api/ghost-review/label (append)
- data/ghost-labels.jsonl — append-only label sink for training corpus
Purpose: Steve flagged that most of the 6,305 ghost-detector verdicts are
false positives. Need human labels to (a) measure the detector's precision
and (b) build training data for a sharper detector that runs upstream
during generation, not as a post-hoc audit.
Files touched
A data/ghost-labels.jsonlA public/admin/ghost-review.htmlM server.js
Diff
commit 6bd6333b68461b2f3ab90519a74ba7ca48faf1f9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 23 22:35:29 2026 -0700
admin: ghost-review viewer + label-collection routes
- public/admin/ghost-review.html — grid of flagged items, click for detail
modal with drag-rect crop tool; keyboard F/T/S verdicts; category filter,
conf-sort, density slider; localStorage persistence for UI prefs
- server.js: /admin/ghost-review (page), /api/ghost-review/flagged (list),
/api/ghost-review/labels (read), /api/ghost-review/label (append)
- data/ghost-labels.jsonl — append-only label sink for training corpus
Purpose: Steve flagged that most of the 6,305 ghost-detector verdicts are
false positives. Need human labels to (a) measure the detector's precision
and (b) build training data for a sharper detector that runs upstream
during generation, not as a post-hoc audit.
---
data/ghost-labels.jsonl | 0
public/admin/ghost-review.html | 555 +++++++++++++++++++++++++++++++++++++++++
server.js | 64 +++++
3 files changed, 619 insertions(+)
diff --git a/data/ghost-labels.jsonl b/data/ghost-labels.jsonl
new file mode 100644
index 0000000..e69de29
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
new file mode 100644
index 0000000..bd996b8
--- /dev/null
+++ b/public/admin/ghost-review.html
@@ -0,0 +1,555 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>Ghost Review — wallco.ai admin</title>
+<style>
+ :root {
+ --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+ --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+ --red:#b3261e; --green:#1f6a2c; --yellow:#a06c00;
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
+ --serif:'Playfair Display','Didot',Georgia,serif;
+ }
+ * { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
+ header {
+ padding:14px 22px; border-bottom:1px solid var(--line); background:#fff;
+ display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
+ }
+ header h1 { font:400 22px/1.2 var(--serif); margin:0; }
+ header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
+ header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
+ header .stats span b { color:var(--ink); }
+ header .stats .fp b { color:var(--green); }
+ header .stats .tp b { color:var(--red); }
+ header .stats .sk b { color:var(--faint); }
+
+ main { max-width:1500px; margin:0 auto; padding:18px 18px 80px; }
+
+ .controls {
+ display:flex; gap:16px; align-items:center; padding:10px 14px;
+ background:#fff; border:1px solid var(--line); border-radius:8px;
+ margin-bottom:14px; flex-wrap:wrap;
+ }
+ .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
+ text-transform:uppercase; color:var(--faint); }
+ .controls select { padding:6px 10px; border:1px solid var(--line);
+ border-radius:4px; background:#fff; font:13px var(--sans); }
+ .controls input[type=range] { vertical-align:middle; width:180px; }
+ .controls .density-val { font:600 12px ui-monospace,Menlo,monospace;
+ color:var(--faint); min-width:32px; display:inline-block; }
+ .controls .legend { margin-left:auto; font:11px var(--sans); color:var(--faint); }
+ .controls kbd {
+ background:#f3eee2; border:1px solid var(--line); border-radius:3px;
+ padding:1px 6px; font:600 10.5px ui-monospace,Menlo,monospace; color:var(--ink);
+ }
+
+ .grid {
+ display:grid;
+ grid-template-columns:repeat(auto-fill,minmax(var(--card-min,200px),1fr));
+ gap:10px;
+ }
+ .tile {
+ position:relative; aspect-ratio:1; background:#f3eee2;
+ border:1px solid var(--line); border-radius:6px; overflow:hidden;
+ cursor:pointer; transition:transform .12s, box-shadow .12s;
+ }
+ .tile:hover { transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.10); }
+ .tile img { width:100%; height:100%; object-fit:cover; display:block; }
+ .tile .badge {
+ position:absolute; top:6px; left:6px;
+ background:rgba(31,24,8,.78); color:#fff;
+ font:600 9.5px var(--sans); letter-spacing:.06em;
+ padding:3px 6px; border-radius:3px;
+ }
+ .tile .conf {
+ position:absolute; bottom:6px; right:6px;
+ background:rgba(255,255,255,.92); color:var(--ink);
+ font:600 10px ui-monospace,Menlo,monospace;
+ padding:2px 5px; border-radius:3px;
+ }
+ .tile.labeled-fp { outline:3px solid var(--green); outline-offset:-3px; }
+ .tile.labeled-fp::after {
+ content:'✓ CLEAN'; position:absolute; inset:auto 0 0 0;
+ background:var(--green); color:#fff; font:700 10px var(--sans);
+ letter-spacing:.1em; text-align:center; padding:4px 0;
+ }
+ .tile.labeled-tp { outline:3px solid var(--red); outline-offset:-3px; }
+ .tile.labeled-tp::after {
+ content:'⚠ GHOST'; position:absolute; inset:auto 0 0 0;
+ background:var(--red); color:#fff; font:700 10px var(--sans);
+ letter-spacing:.1em; text-align:center; padding:4px 0;
+ }
+ .tile.labeled-skip { opacity:.4; outline:2px dashed var(--faint); outline-offset:-2px; }
+
+ /* Detail modal */
+ .modal {
+ position:fixed; inset:0; background:rgba(20,15,5,.84);
+ display:none; align-items:stretch; justify-content:center; z-index:50;
+ }
+ .modal.open { display:flex; }
+ .modal-inner {
+ display:grid; grid-template-columns:1fr 360px; gap:0;
+ background:#fff; width:100%; max-width:1280px; margin:auto;
+ border-radius:10px; overflow:hidden; max-height:94vh;
+ }
+ .stage {
+ background:#000; position:relative; display:flex; align-items:center;
+ justify-content:center; overflow:hidden; min-height:520px;
+ }
+ .stage img.main {
+ max-width:100%; max-height:94vh; object-fit:contain; display:block;
+ user-select:none; -webkit-user-drag:none;
+ }
+ .stage .cropbox {
+ position:absolute; border:2px solid #ff5252; background:rgba(255,82,82,.18);
+ pointer-events:none;
+ }
+ .stage .cropbox::after {
+ content:'CROP'; position:absolute; top:-22px; left:0;
+ background:#ff5252; color:#fff; font:700 10px var(--sans);
+ letter-spacing:.1em; padding:2px 6px; border-radius:3px;
+ }
+ .stage .hint {
+ position:absolute; top:12px; left:12px;
+ background:rgba(0,0,0,.66); color:#fff; font:11px var(--sans);
+ padding:6px 10px; border-radius:4px; line-height:1.4;
+ }
+ .stage .navbtn {
+ position:absolute; top:50%; transform:translateY(-50%);
+ background:rgba(0,0,0,.5); color:#fff; border:none;
+ width:44px; height:60px; font-size:22px; cursor:pointer;
+ }
+ .stage .navbtn:hover { background:rgba(0,0,0,.8); }
+ .stage .navbtn.prev { left:0; border-radius:0 6px 6px 0; }
+ .stage .navbtn.next { right:0; border-radius:6px 0 0 6px; }
+
+ .panel { padding:18px 20px; display:flex; flex-direction:column; gap:14px; overflow-y:auto; }
+ .panel h3 { font:600 13px var(--sans); letter-spacing:.1em;
+ text-transform:uppercase; color:var(--faint); margin:0; }
+ .panel .id-row { font:600 16px ui-monospace,Menlo,monospace; }
+ .panel .cat { font:13px var(--sans); color:var(--faint); }
+ .panel .reason {
+ background:#fdf6e6; border-left:3px solid var(--gold);
+ padding:10px 12px; font:13px/1.5 var(--sans); color:var(--ink);
+ border-radius:0 4px 4px 0;
+ }
+ .panel .conf-bar {
+ display:flex; align-items:center; gap:8px;
+ font:11px ui-monospace,Menlo,monospace; color:var(--faint);
+ }
+ .panel .conf-bar .bar {
+ flex:1; height:8px; background:#f3eee2; border-radius:4px; overflow:hidden;
+ }
+ .panel .conf-bar .bar-fill { height:100%; background:var(--red); }
+ .panel .actions { display:flex; flex-direction:column; gap:8px; margin-top:6px; }
+ .panel .actions button {
+ padding:12px 14px; border:1px solid var(--line); border-radius:6px;
+ background:#fff; cursor:pointer; font:600 13px var(--sans);
+ display:flex; align-items:center; gap:10px; text-align:left;
+ }
+ .panel .actions button kbd {
+ background:#f3eee2; border:1px solid var(--line); border-radius:3px;
+ padding:1px 6px; font:600 11px ui-monospace,Menlo,monospace; margin-left:auto;
+ }
+ .panel .actions .b-fp { border-color:var(--green); }
+ .panel .actions .b-fp:hover { background:#eaf4ec; }
+ .panel .actions .b-tp { border-color:var(--red); }
+ .panel .actions .b-tp:hover { background:#fce8e6; }
+ .panel .actions .b-tp.armed { background:#ffeaea; border-width:2px; }
+ .panel .actions .b-skip { color:var(--faint); }
+ .panel .crop-readout {
+ font:11px ui-monospace,Menlo,monospace; color:var(--faint);
+ background:#f3eee2; padding:6px 8px; border-radius:4px;
+ }
+ .panel .clear-crop {
+ background:none; border:none; color:var(--faint); cursor:pointer;
+ font:11px var(--sans); text-decoration:underline; padding:0;
+ }
+ .panel .closex {
+ align-self:flex-end; background:none; border:none;
+ color:var(--faint); cursor:pointer; font-size:22px; line-height:1;
+ }
+
+ .empty {
+ text-align:center; padding:80px 20px; color:var(--faint);
+ font:14px var(--sans);
+ }
+ .empty h2 { font:400 28px var(--serif); color:var(--ink); margin-bottom:10px; }
+
+ .filter-bar { display:flex; gap:8px; flex-wrap:wrap; }
+ .filter-bar button {
+ border:1px solid var(--line); background:#fff; border-radius:14px;
+ padding:5px 11px; cursor:pointer; font:600 11px var(--sans);
+ color:var(--faint); letter-spacing:.04em;
+ }
+ .filter-bar button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
+</style>
+</head>
+<body>
+
+<header>
+ <h1>Ghost Review</h1>
+ <span class="meta">label false-positives to train the detector</span>
+ <div class="stats">
+ <span class="fp">CLEAN <b id="stat-fp">0</b></span>
+ <span class="tp">GHOST <b id="stat-tp">0</b></span>
+ <span class="sk">SKIP <b id="stat-skip">0</b></span>
+ <span>UNLABELED <b id="stat-todo">—</b></span>
+ <span>TOTAL <b id="stat-total">—</b></span>
+ </div>
+</header>
+
+<main>
+ <div class="controls">
+ <label>Category</label>
+ <select id="filter-cat"><option value="">all</option></select>
+ <label>Sort</label>
+ <select id="filter-sort">
+ <option value="conf-desc">Confidence ↓</option>
+ <option value="conf-asc">Confidence ↑</option>
+ <option value="id-desc">Newest</option>
+ <option value="id-asc">Oldest</option>
+ </select>
+ <label>Density</label>
+ <input type="range" id="density" min="120" max="360" step="20" value="200">
+ <span class="density-val" id="density-val">200px</span>
+ <div class="filter-bar">
+ <button class="active" data-filter="unlabeled">Unlabeled</button>
+ <button data-filter="fp">Clean</button>
+ <button data-filter="tp">Ghost</button>
+ <button data-filter="all">All</button>
+ </div>
+ <div class="legend">
+ <kbd>F</kbd> false-positive (clean)
+ <kbd>T</kbd> true-positive (ghost)
+ <kbd>S</kbd> skip
+ <kbd>←</kbd> <kbd>→</kbd> navigate
+ <kbd>Esc</kbd> close
+ </div>
+ </div>
+
+ <div id="grid" class="grid"></div>
+ <div id="empty" class="empty" style="display:none">
+ <h2>Nothing to review</h2>
+ <p>All flagged items in this filter have been labeled. Switch filter above.</p>
+ </div>
+</main>
+
+<div class="modal" id="modal">
+ <div class="modal-inner">
+ <div class="stage" id="stage">
+ <button class="navbtn prev" id="prev-btn">‹</button>
+ <img class="main" id="main-img" alt="">
+ <div class="cropbox" id="cropbox" style="display:none"></div>
+ <div class="hint" id="hint" style="display:none">Drag to mark the ghost region — release to confirm</div>
+ <button class="navbtn next" id="next-btn">›</button>
+ </div>
+ <div class="panel">
+ <button class="closex" id="close-btn" title="close (esc)">×</button>
+ <h3>Design</h3>
+ <div class="id-row">#<span id="d-id">—</span></div>
+ <div class="cat" id="d-cat">—</div>
+ <h3>Detector's reason</h3>
+ <div class="reason" id="d-reason">—</div>
+ <div class="conf-bar">
+ <span>conf</span>
+ <div class="bar"><div class="bar-fill" id="d-conf-fill" style="width:0%"></div></div>
+ <span id="d-conf-num">0.00</span>
+ </div>
+ <h3>Verdict</h3>
+ <div class="actions">
+ <button class="b-fp" id="btn-fp">✓ False positive — image is clean <kbd>F</kbd></button>
+ <button class="b-tp" id="btn-tp">⚠ True positive — drag crop, then save <kbd>T</kbd></button>
+ <button class="b-skip" id="btn-skip">– Skip for now <kbd>S</kbd></button>
+ </div>
+ <div class="crop-readout" id="crop-readout" style="display:none"></div>
+ <button class="clear-crop" id="clear-crop" style="display:none">clear crop</button>
+ </div>
+ </div>
+</div>
+
+<script>
+(() => {
+ const STAT = { fp:0, tp:0, skip:0, todo:0, total:0 };
+ const $ = (id) => document.getElementById(id);
+
+ // State
+ let ALL = []; // every flagged item from server
+ const LABELS = new Map(); // id -> { verdict, crop? }
+ let CURRENT = null; // currently-open item index in VIEW
+ let VIEW = []; // filtered + sorted view
+ let FILTER = 'unlabeled';
+ let CAT_FILTER = '';
+ let SORT = 'conf-desc';
+ let ARMED_TP = false; // true after T pressed, waiting for crop
+ let PENDING_CROP = null; // {x,y,w,h} normalized [0..1]
+
+ // Density slider
+ const density = $('density');
+ const densityVal = $('density-val');
+ density.value = localStorage.getItem('gr-density') || 200;
+ densityVal.textContent = density.value + 'px';
+ document.documentElement.style.setProperty('--card-min', density.value + 'px');
+ density.oninput = () => {
+ document.documentElement.style.setProperty('--card-min', density.value + 'px');
+ densityVal.textContent = density.value + 'px';
+ localStorage.setItem('gr-density', density.value);
+ };
+
+ // Sort + filter wiring
+ $('filter-sort').value = localStorage.getItem('gr-sort') || 'conf-desc';
+ $('filter-sort').onchange = (e) => { SORT = e.target.value; localStorage.setItem('gr-sort', SORT); recomputeView(); render(); };
+ $('filter-cat').onchange = (e) => { CAT_FILTER = e.target.value; recomputeView(); render(); };
+ document.querySelectorAll('.filter-bar button').forEach(b => {
+ b.onclick = () => {
+ document.querySelectorAll('.filter-bar button').forEach(x => x.classList.remove('active'));
+ b.classList.add('active');
+ FILTER = b.dataset.filter;
+ recomputeView();
+ render();
+ };
+ });
+
+ function recomputeView() {
+ VIEW = ALL.filter(item => {
+ if (CAT_FILTER && item.category !== CAT_FILTER) return false;
+ const lbl = LABELS.get(item.id);
+ if (FILTER === 'unlabeled') return !lbl;
+ if (FILTER === 'fp') return lbl && lbl.verdict === 'fp';
+ if (FILTER === 'tp') return lbl && lbl.verdict === 'tp';
+ return true; // all
+ });
+ if (SORT === 'conf-desc') VIEW.sort((a,b) => (b.confidence||0) - (a.confidence||0));
+ else if (SORT === 'conf-asc') VIEW.sort((a,b) => (a.confidence||0) - (b.confidence||0));
+ else if (SORT === 'id-desc') VIEW.sort((a,b) => b.id - a.id);
+ else if (SORT === 'id-asc') VIEW.sort((a,b) => a.id - b.id);
+ }
+
+ function updateStats() {
+ $('stat-fp').textContent = STAT.fp;
+ $('stat-tp').textContent = STAT.tp;
+ $('stat-skip').textContent = STAT.skip;
+ $('stat-total').textContent = STAT.total.toLocaleString();
+ $('stat-todo').textContent = (STAT.total - STAT.fp - STAT.tp - STAT.skip).toLocaleString();
+ }
+
+ function render() {
+ const grid = $('grid');
+ grid.innerHTML = '';
+ const empty = $('empty');
+ if (!VIEW.length) { empty.style.display = ''; return; }
+ empty.style.display = 'none';
+ // virtual-ish: render first 300 then scroll-load more
+ const slice = VIEW.slice(0, 300);
+ for (const item of slice) {
+ const tile = document.createElement('div');
+ tile.className = 'tile';
+ const lbl = LABELS.get(item.id);
+ if (lbl) tile.classList.add('labeled-' + lbl.verdict);
+ tile.innerHTML = `
+ <img loading="lazy" src="${item.image_url}" alt="${item.id}">
+ <div class="badge">${item.category || ''}</div>
+ <div class="conf">${(item.confidence || 0).toFixed(2)}</div>
+ `;
+ tile.onclick = () => open(VIEW.indexOf(item));
+ grid.appendChild(tile);
+ }
+ }
+
+ // --- Modal / detail view -----------------------------------------------
+ const modal = $('modal');
+ const mainImg = $('main-img');
+ const cropbox = $('cropbox');
+ const stage = $('stage');
+
+ function open(idx) {
+ if (idx < 0 || idx >= VIEW.length) return;
+ CURRENT = idx;
+ const item = VIEW[idx];
+ $('d-id').textContent = item.id;
+ $('d-cat').textContent = item.category || '—';
+ $('d-reason').textContent = item.reason || '—';
+ const c = (item.confidence || 0);
+ $('d-conf-fill').style.width = (c * 100) + '%';
+ $('d-conf-num').textContent = c.toFixed(2);
+ mainImg.src = item.image_url;
+ clearCrop();
+ modal.classList.add('open');
+ }
+
+ function close() {
+ modal.classList.remove('open');
+ CURRENT = null;
+ clearCrop();
+ }
+
+ function navigate(delta) {
+ if (CURRENT === null) return;
+ const next = CURRENT + delta;
+ if (next < 0 || next >= VIEW.length) return;
+ open(next);
+ }
+
+ $('close-btn').onclick = close;
+ $('prev-btn').onclick = () => navigate(-1);
+ $('next-btn').onclick = () => navigate(1);
+
+ // --- Crop drag-rect ----------------------------------------------------
+ let dragging = false, dragStart = null;
+ stage.onmousedown = (e) => {
+ if (e.target.classList.contains('navbtn')) return;
+ if (!ARMED_TP) return; // only allow drag after T pressed (or via button)
+ const r = mainImg.getBoundingClientRect();
+ // ignore if outside image
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return;
+ dragging = true;
+ dragStart = { x: e.clientX, y: e.clientY, rect: r };
+ cropbox.style.display = '';
+ cropbox.style.left = (e.clientX - stage.getBoundingClientRect().left) + 'px';
+ cropbox.style.top = (e.clientY - stage.getBoundingClientRect().top) + 'px';
+ cropbox.style.width = '0px';
+ cropbox.style.height = '0px';
+ e.preventDefault();
+ };
+ stage.onmousemove = (e) => {
+ if (!dragging) return;
+ const sr = stage.getBoundingClientRect();
+ const x = Math.min(e.clientX, dragStart.x);
+ const y = Math.min(e.clientY, dragStart.y);
+ const w = Math.abs(e.clientX - dragStart.x);
+ const h = Math.abs(e.clientY - dragStart.y);
+ cropbox.style.left = (x - sr.left) + 'px';
+ cropbox.style.top = (y - sr.top) + 'px';
+ cropbox.style.width = w + 'px';
+ cropbox.style.height = h + 'px';
+ };
+ stage.onmouseup = (e) => {
+ if (!dragging) return;
+ dragging = false;
+ const r = mainImg.getBoundingClientRect();
+ const x0 = Math.max(r.left, Math.min(e.clientX, dragStart.x));
+ const y0 = Math.max(r.top, Math.min(e.clientY, dragStart.y));
+ const x1 = Math.min(r.right, Math.max(e.clientX, dragStart.x));
+ const y1 = Math.min(r.bottom,Math.max(e.clientY, dragStart.y));
+ const nw = r.width, nh = r.height;
+ if ((x1 - x0) < 10 || (y1 - y0) < 10) { clearCrop(); return; }
+ PENDING_CROP = {
+ x: (x0 - r.left) / nw,
+ y: (y0 - r.top) / nh,
+ w: (x1 - x0) / nw,
+ h: (y1 - y0) / nh,
+ };
+ showCropReadout();
+ };
+
+ function showCropReadout() {
+ if (!PENDING_CROP) return;
+ const c = PENDING_CROP;
+ $('crop-readout').style.display = '';
+ $('crop-readout').textContent =
+ `crop [x=${c.x.toFixed(3)} y=${c.y.toFixed(3)} w=${c.w.toFixed(3)} h=${c.h.toFixed(3)}] — press T again to save`;
+ $('clear-crop').style.display = '';
+ }
+ function clearCrop() {
+ PENDING_CROP = null;
+ ARMED_TP = false;
+ cropbox.style.display = 'none';
+ $('hint').style.display = 'none';
+ $('crop-readout').style.display = 'none';
+ $('clear-crop').style.display = 'none';
+ $('btn-tp').classList.remove('armed');
+ }
+ $('clear-crop').onclick = clearCrop;
+
+ // --- Verdict actions ---------------------------------------------------
+ async function postLabel(verdict, crop) {
+ if (CURRENT === null) return;
+ const item = VIEW[CURRENT];
+ const body = { id: item.id, verdict, crop: crop || null };
+ try {
+ const r = await fetch('/api/ghost-review/label', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify(body)
+ });
+ if (!r.ok) throw new Error('http ' + r.status);
+ LABELS.set(item.id, { verdict, crop: crop || null });
+ STAT[verdict === 'skip' ? 'skip' : verdict]++;
+ updateStats();
+ const goingForward = (CURRENT < VIEW.length - 1);
+ // If on Unlabeled filter, the item leaves the view — index stays valid
+ if (FILTER === 'unlabeled') {
+ recomputeView();
+ render();
+ if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
+ if (VIEW.length === 0) { close(); return; }
+ open(CURRENT);
+ } else {
+ if (goingForward) navigate(1);
+ recomputeView(); render();
+ }
+ } catch (e) {
+ alert('Save failed: ' + e.message);
+ }
+ }
+
+ $('btn-fp').onclick = () => postLabel('fp');
+ $('btn-skip').onclick = () => postLabel('skip');
+ $('btn-tp').onclick = () => {
+ if (PENDING_CROP) { postLabel('tp', PENDING_CROP); return; }
+ if (!ARMED_TP) {
+ ARMED_TP = true;
+ $('hint').style.display = '';
+ $('btn-tp').classList.add('armed');
+ stage.style.cursor = 'crosshair';
+ }
+ };
+
+ // Keyboard
+ document.addEventListener('keydown', (e) => {
+ if (!modal.classList.contains('open')) return;
+ if (e.key === 'Escape') { close(); }
+ else if (e.key === 'ArrowLeft') { navigate(-1); }
+ else if (e.key === 'ArrowRight') { navigate(1); }
+ else if (e.key.toLowerCase() === 'f') { postLabel('fp'); }
+ else if (e.key.toLowerCase() === 's') { postLabel('skip'); }
+ else if (e.key.toLowerCase() === 't') {
+ if (PENDING_CROP) postLabel('tp', PENDING_CROP);
+ else $('btn-tp').click();
+ }
+ });
+
+ // --- Boot --------------------------------------------------------------
+ async function boot() {
+ const [flagged, labels] = await Promise.all([
+ fetch('/api/ghost-review/flagged').then(r => r.json()),
+ fetch('/api/ghost-review/labels').then(r => r.json()),
+ ]);
+ ALL = flagged.items;
+ STAT.total = ALL.length;
+ for (const lbl of labels.items) {
+ LABELS.set(lbl.id, { verdict: lbl.verdict, crop: lbl.crop });
+ if (lbl.verdict === 'fp') STAT.fp++;
+ else if (lbl.verdict === 'tp') STAT.tp++;
+ else if (lbl.verdict === 'skip') STAT.skip++;
+ }
+ // Populate category filter
+ const cats = [...new Set(ALL.map(i => i.category).filter(Boolean))].sort();
+ const sel = $('filter-cat');
+ for (const c of cats) {
+ const opt = document.createElement('option');
+ opt.value = c; opt.textContent = c;
+ sel.appendChild(opt);
+ }
+ updateStats();
+ recomputeView();
+ render();
+ }
+ boot().catch(e => { document.body.innerHTML = '<pre style="padding:40px;color:#b00">' + e.stack + '</pre>'; });
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 93ffce7..dee84f2 100644
--- a/server.js
+++ b/server.js
@@ -904,6 +904,70 @@ app.get('/api/design/:id/inspired-by-badge', (req, res) => {
}
});
+// ── Ghost Review (admin-only) ───────────────────────────────────────────
+// Viewer + label-collection for the ghost-detector training set. Reads
+// the flagged set from data/ghost-scan-flagged.jsonl; appends per-item
+// human verdicts to data/ghost-labels.jsonl (one line per label, never
+// rewritten). Re-labeling an id appends another line — last-wins on read.
+app.get('/admin/ghost-review', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+ res.sendFile(path.join(__dirname, 'public', 'admin', 'ghost-review.html'));
+});
+
+app.get('/api/ghost-review/flagged', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const f = path.join(__dirname, 'data', 'ghost-scan-flagged.jsonl');
+ if (!fs.existsSync(f)) return res.json({ ok: true, items: [] });
+ const items = [];
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const r = JSON.parse(line);
+ items.push({
+ id: r.id,
+ category: r.category,
+ confidence: r.confidence,
+ reason: r.reason,
+ image_url: r.image_url || ('/designs/img/by-id/' + r.id),
+ });
+ } catch {}
+ }
+ res.json({ ok: true, items });
+});
+
+app.get('/api/ghost-review/labels', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const f = path.join(__dirname, 'data', 'ghost-labels.jsonl');
+ if (!fs.existsSync(f)) return res.json({ ok: true, items: [] });
+ // Last-wins per id
+ const latest = new Map();
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const r = JSON.parse(line);
+ latest.set(r.id, r);
+ } catch {}
+ }
+ res.json({ ok: true, items: [...latest.values()] });
+});
+
+app.post('/api/ghost-review/label', express.json({ limit: '8kb' }), (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const { id, verdict, crop } = req.body || {};
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'id required' });
+ if (!['fp','tp','skip'].includes(verdict)) return res.status(400).json({ error: 'verdict must be fp|tp|skip' });
+ if (verdict === 'tp' && (!crop || typeof crop.x !== 'number'))
+ return res.status(400).json({ error: 'tp requires crop {x,y,w,h}' });
+ const f = path.join(__dirname, 'data', 'ghost-labels.jsonl');
+ const row = { id, verdict, crop: crop || null, ts: new Date().toISOString() };
+ try {
+ fs.appendFileSync(f, JSON.stringify(row) + '\n');
+ res.json({ ok: true });
+ } catch (e) {
+ res.status(500).json({ error: 'write failed', detail: e.message });
+ }
+});
+
// Trigger another generation from the same seed (admin-only). Spawns the
// generator as a detached child so the HTTP response returns fast.
app.post('/api/admin/inspirations/regenerate', express.json(), (req, res) => {
← 03cdc46 ghost-detector: 3x voting + maximal-determinism config
·
back to Wallco Ai
·
ghost-detector: 429 retry-with-backoff + triage-ghost-flagge 788de56 →