← 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, '"')}"</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}`);
});