← back to Wallco Ai

scripts/audit-ghost-server.js

259 lines

#!/usr/bin/env node
// audit-ghost-server — tiny localhost UI to eyeball-verify ghost-layer flags.
//
// Reads data/ghost-audit-sample.json (created by audit-ghost-sample.js),
// renders one card per sample with thumbnail + reason + buttons. Verdicts
// stream to data/ghost-audit-verdicts.jsonl (append-only, one row per click).
//
// Usage:
//   node scripts/audit-ghost-server.js                # :9779
//   node scripts/audit-ghost-server.js --port 9787

const fs   = require('fs');
const path = require('path');
const http = require('http');

const ARGS = process.argv.slice(2);
function arg(name, def) {
  const i = ARGS.indexOf('--' + name);
  return i >= 0 ? ARGS[i + 1] : def;
}
const PORT = parseInt(arg('port', '9779'), 10);

const ROOT     = path.join(__dirname, '..');
const SAMPLE   = path.join(ROOT, 'data', 'ghost-audit-sample.json');
const VERDICTS = path.join(ROOT, 'data', 'ghost-audit-verdicts.jsonl');

if (!fs.existsSync(SAMPLE)) {
  console.error(`missing ${SAMPLE} — run: node scripts/audit-ghost-sample.js`);
  process.exit(1);
}

const SAMPLES = JSON.parse(fs.readFileSync(SAMPLE, 'utf8'));
const BY_ID   = new Map(SAMPLES.map(s => [String(s.id), s]));

function loadVerdicts() {
  if (!fs.existsSync(VERDICTS)) return new Map();
  const m = new Map();
  for (const line of fs.readFileSync(VERDICTS, 'utf8').split('\n')) {
    if (!line.trim()) continue;
    try { const v = JSON.parse(line); m.set(String(v.id), v.verdict); } catch {}
  }
  return m;
}

function summary() {
  const v = loadVerdicts();
  let tp = 0, fp = 0, skip = 0;
  for (const [, val] of v) {
    if (val === 'tp') tp++;
    else if (val === 'fp') fp++;
    else if (val === 'skip') skip++;
  }
  const total = SAMPLES.length;
  const done  = v.size;
  const decided = tp + fp;
  const fpRate = decided > 0 ? fp / decided : 0;
  const projectedFp = Math.round(fpRate * 6305);
  return { total, done, tp, fp, skip, decided, fpRate, projectedFp };
}

const HTML = `<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Ghost-Layer Audit · ${SAMPLES.length} samples</title>
<style>
  :root { color-scheme: dark; }
  body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0d0d10; color: #e8e8ec; margin: 0; padding: 24px; }
  header { position: sticky; top: 0; background: #0d0d10ee; backdrop-filter: blur(8px); padding: 12px 0; margin: -24px -24px 16px; padding: 16px 24px; border-bottom: 1px solid #2a2a30; z-index: 10; }
  h1 { margin: 0 0 6px; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; }
  .stats { font-size: 13px; color: #9a9aa6; display: flex; gap: 18px; flex-wrap: wrap; }
  .stats b { color: #e8e8ec; }
  .stats .tp { color: #6ee7b7; } .stats .fp { color: #fda4af; }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 18px; }
  .card { background: #16161c; border: 1px solid #2a2a30; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; transition: opacity .2s, border-color .2s; }
  .card.decided { opacity: 0.5; }
  .card.tp { border-color: #34d399; }
  .card.fp { border-color: #fb7185; }
  .card.skip { border-color: #6b7280; }
  .thumb { width: 100%; aspect-ratio: 1; background: #000; object-fit: cover; cursor: zoom-in; }
  .meta { padding: 10px 12px; font-size: 12px; line-height: 1.5; }
  .meta .id { font-family: ui-monospace, monospace; color: #9a9aa6; font-size: 11px; }
  .meta .cat { background: #2a2a30; padding: 1px 8px; border-radius: 4px; font-size: 11px; display: inline-block; margin: 2px 0; }
  .meta .src { font-size: 10px; color: #6b7280; margin-left: 4px; }
  .meta .reason { color: #d4d4d8; margin-top: 6px; font-style: italic; }
  .meta .conf { color: #fbbf24; font-weight: 600; }
  .btns { display: flex; gap: 0; border-top: 1px solid #2a2a30; }
  .btns button { flex: 1; background: transparent; color: inherit; border: 0; padding: 12px; font-size: 13px; font-weight: 600; cursor: pointer; border-right: 1px solid #2a2a30; transition: background .15s; }
  .btns button:last-child { border-right: 0; }
  .btns button:hover { background: #2a2a30; }
  .btns .tp { color: #6ee7b7; }
  .btns .fp { color: #fda4af; }
  .btns .skip { color: #9a9aa6; }
  .lightbox { position: fixed; inset: 0; background: rgba(0,0,0,0.92); display: none; align-items: center; justify-content: center; z-index: 100; cursor: zoom-out; }
  .lightbox.on { display: flex; }
  .lightbox img { max-width: 95vw; max-height: 95vh; object-fit: contain; }
  kbd { font-family: ui-monospace, monospace; background: #2a2a30; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
</style></head>
<body>
<header>
  <h1>Ghost-Layer Audit · <span id="stats-done">0</span>/<span id="stats-total">${SAMPLES.length}</span></h1>
  <div class="stats">
    <span><b class="tp" id="stats-tp">0</b> true positive (real ghost)</span>
    <span><b class="fp" id="stats-fp">0</b> false positive (no ghost)</span>
    <span><b id="stats-skip">0</b> skipped</span>
    <span>FP rate: <b id="stats-rate">—</b></span>
    <span>Projected FP across 6,305 flagged: <b id="stats-proj">—</b></span>
    <span style="margin-left:auto;color:#6b7280">click thumbnail to zoom · <kbd>T</kbd> TP · <kbd>F</kbd> FP · <kbd>S</kbd> skip</span>
  </div>
</header>
<div class="grid" id="grid"></div>
<div class="lightbox" id="lb"><img id="lbimg" alt=""></div>
<script>
const SAMPLES = ${JSON.stringify(SAMPLES.map(s => ({ id: s.id, category: s.category, source: s.source, confidence: s.confidence, reason: s.reason })))};

let verdicts = {};
async function refreshStats() {
  const r = await fetch('/summary').then(r => r.json());
  document.getElementById('stats-done').textContent  = r.done;
  document.getElementById('stats-tp').textContent    = r.tp;
  document.getElementById('stats-fp').textContent    = r.fp;
  document.getElementById('stats-skip').textContent  = r.skip;
  document.getElementById('stats-rate').textContent  = r.decided > 0 ? (r.fpRate * 100).toFixed(0) + '%' : '—';
  document.getElementById('stats-proj').textContent  = r.decided > 0 ? r.projectedFp.toLocaleString() : '—';
}

async function vote(id, verdict, cardEl) {
  await fetch('/verdict', {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ id, verdict }),
  });
  verdicts[id] = verdict;
  cardEl.classList.remove('tp', 'fp', 'skip', 'decided');
  cardEl.classList.add(verdict, 'decided');
  refreshStats();
  // jump focus to next undecided
  const next = cardEl.nextElementSibling;
  if (next) next.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

function card(s) {
  const el = document.createElement('div');
  el.className = 'card';
  el.dataset.id = s.id;
  el.innerHTML = \`
    <img class="thumb" src="/img/\${s.id}" loading="lazy" alt="design \${s.id}">
    <div class="meta">
      <div><span class="id">#\${s.id}</span> <span class="cat">\${s.category || '—'}</span><span class="src">[\${s.source}]</span></div>
      <div class="reason">"\${(s.reason || '').replace(/"/g, '&quot;')}"</div>
      <div>detector confidence: <span class="conf">\${(s.confidence * 100).toFixed(0)}%</span></div>
    </div>
    <div class="btns">
      <button class="tp" data-v="tp">✓ Real ghost</button>
      <button class="fp" data-v="fp">✗ False alarm</button>
      <button class="skip" data-v="skip">skip</button>
    </div>\`;
  el.querySelector('.thumb').onclick = () => {
    document.getElementById('lbimg').src = '/img/' + s.id;
    document.getElementById('lb').classList.add('on');
  };
  for (const b of el.querySelectorAll('.btns button')) {
    b.onclick = () => vote(s.id, b.dataset.v, el);
  }
  return el;
}

const grid = document.getElementById('grid');
SAMPLES.forEach(s => grid.appendChild(card(s)));

document.getElementById('lb').onclick = () => document.getElementById('lb').classList.remove('on');

// Keyboard nav — operate on the card most centered in viewport
function focusedCard() {
  const cards = [...document.querySelectorAll('.card')];
  const center = window.innerHeight / 2;
  let best = null, bestDist = Infinity;
  for (const c of cards) {
    const r = c.getBoundingClientRect();
    const cc = r.top + r.height / 2;
    const d = Math.abs(cc - center);
    if (d < bestDist) { bestDist = d; best = c; }
  }
  return best;
}
window.addEventListener('keydown', (e) => {
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
  if (document.getElementById('lb').classList.contains('on')) {
    if (e.key === 'Escape') document.getElementById('lb').classList.remove('on');
    return;
  }
  const c = focusedCard();
  if (!c) return;
  const id = parseInt(c.dataset.id, 10);
  if (e.key === 't' || e.key === 'T') vote(id, 'tp', c);
  else if (e.key === 'f' || e.key === 'F') vote(id, 'fp', c);
  else if (e.key === 's' || e.key === 'S') vote(id, 'skip', c);
});

// hydrate any prior verdicts on load
fetch('/verdicts').then(r => r.json()).then(v => {
  for (const [id, verdict] of Object.entries(v)) {
    const c = document.querySelector('.card[data-id="' + id + '"]');
    if (c) c.classList.add(verdict, 'decided');
  }
  refreshStats();
});
</script>
</body></html>`;

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const send = (status, type, body) => {
    res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
    res.end(body);
  };

  if (url.pathname === '/') return send(200, 'text/html; charset=utf-8', HTML);

  if (url.pathname === '/summary') return send(200, 'application/json', JSON.stringify(summary()));

  if (url.pathname === '/verdicts') {
    const v = loadVerdicts();
    return send(200, 'application/json', JSON.stringify(Object.fromEntries(v)));
  }

  if (url.pathname.startsWith('/img/')) {
    const id = url.pathname.slice(5);
    const s = BY_ID.get(id);
    if (!s || !s.local_path) return send(404, 'text/plain', 'no image');
    try {
      const buf = fs.readFileSync(s.local_path);
      const mime = s.local_path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
      res.writeHead(200, { 'content-type': mime, 'cache-control': 'public, max-age=3600' });
      return res.end(buf);
    } catch (e) { return send(500, 'text/plain', e.message); }
  }

  if (url.pathname === '/verdict' && req.method === 'POST') {
    let body = '';
    req.on('data', c => body += c);
    req.on('end', () => {
      try {
        const { id, verdict } = JSON.parse(body);
        if (!['tp', 'fp', 'skip'].includes(verdict)) return send(400, 'text/plain', 'bad verdict');
        const row = { id, verdict, ts: new Date().toISOString() };
        fs.appendFileSync(VERDICTS, JSON.stringify(row) + '\n');
        send(200, 'application/json', JSON.stringify({ ok: true }));
      } catch (e) { send(500, 'text/plain', e.message); }
    });
    return;
  }

  send(404, 'text/plain', 'not found');
});

server.listen(PORT, '127.0.0.1', () => {
  console.log(`ghost-audit UI → http://127.0.0.1:${PORT}/`);
  console.log(`samples: ${SAMPLES.length} · verdicts log: ${VERDICTS}`);
});