[object Object]

← 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

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) &nbsp;
+      <kbd>T</kbd> true-positive (ghost) &nbsp;
+      <kbd>S</kbd> skip &nbsp;
+      <kbd>←</kbd> <kbd>→</kbd> navigate &nbsp;
+      <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 →