← back to Wallco Ai
scripts/cull-viewer-server.js
276 lines
#!/usr/bin/env node
// cull-viewer-server.js — LIVE viewer for the fuzzy-design cull.
// Polls data/fuzzy-classifier.jsonl every 3s as the classifier writes,
// re-renders the Keepers/Rejects grid in real time. Open in a browser:
// http://127.0.0.1:9939
//
// API:
// GET / → the HTML viewer page
// GET /data → JSON: { crisp:[], fuzzy:[], errs:[], total, classifier_running, ts }
// GET /img/<f> → serves data/generated-web/<file>.png
// POST /decide → { id, verdict:'keep'|'reject'|null }, persisted to data/cull-decisions.json
// POST /export → returns decisions.json for download
"use strict";
const express = require("express");
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
const JSONL = path.join(ROOT, "data", "fuzzy-classifier.jsonl");
const WEB_DIR = path.join(ROOT, "data", "generated-web");
const DECISIONS = path.join(ROOT, "data", "cull-decisions.json");
const PORT = parseInt(process.env.CULL_VIEWER_PORT || "9939", 10);
function readDecisions() {
try { return JSON.parse(fs.readFileSync(DECISIONS, "utf8")); }
catch { return { decisions: {}, updated_at: null }; }
}
function writeDecisions(obj) {
fs.writeFileSync(DECISIONS + ".tmp", JSON.stringify(obj, null, 2));
fs.renameSync(DECISIONS + ".tmp", DECISIONS);
}
function classifierRunning() {
// Best-effort: ps for the classifier node process
try {
require("child_process").execSync("pgrep -fl 'node scripts/classify-fuzzy'", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
return true;
} catch { return false; }
}
function loadData() {
if (!fs.existsSync(JSONL)) return { crisp: [], fuzzy: [], errs: [], total: 0 };
const seen = new Map();
for (const line of fs.readFileSync(JSONL, "utf8").split("\n")) {
if (!line) continue;
try {
const r = JSON.parse(line);
if (r.file) seen.set(r.file, r);
} catch {}
}
const rows = Array.from(seen.values()).filter(r => fs.existsSync(path.join(WEB_DIR, r.file)));
const crisp = rows.filter(r => r.verdict === "CRISP").sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
const fuzzy = rows.filter(r => r.verdict === "FUZZY").sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
const errs = rows.filter(r => r.error || !r.verdict);
return { crisp, fuzzy, errs, total: rows.length };
}
const app = express();
app.use(express.json({ limit: "1mb" }));
app.get("/data", (req, res) => {
const d = loadData();
res.json({ ...d, classifier_running: classifierRunning(), decisions: readDecisions().decisions, ts: new Date().toISOString() });
});
app.get("/img/:file", (req, res) => {
const p = path.join(WEB_DIR, path.basename(req.params.file));
if (!fs.existsSync(p)) return res.status(404).end();
res.sendFile(p);
});
app.post("/decide", (req, res) => {
const { id, verdict } = req.body || {};
if (!Number.isFinite(id)) return res.status(400).json({ error: "bad_id" });
const cur = readDecisions();
if (verdict === "keep" || verdict === "reject") cur.decisions[id] = verdict;
else delete cur.decisions[id];
cur.updated_at = new Date().toISOString();
writeDecisions(cur);
res.json({ ok: true, n: Object.keys(cur.decisions).length });
});
app.post("/bulk-decide", (req, res) => {
const { ids = [], verdict } = req.body || {};
if (!Array.isArray(ids) || !["keep", "reject", null].includes(verdict)) return res.status(400).json({ error: "bad_input" });
const cur = readDecisions();
for (const id of ids) {
if (!Number.isFinite(id)) continue;
if (verdict) cur.decisions[id] = verdict;
else delete cur.decisions[id];
}
cur.updated_at = new Date().toISOString();
writeDecisions(cur);
res.json({ ok: true, n: Object.keys(cur.decisions).length });
});
app.get("/export", (req, res) => {
const cur = readDecisions();
res.setHeader("Content-Disposition", 'attachment; filename="fuzzy-cull-decisions.json"');
res.json(cur);
});
app.get("/", (req, res) => {
res.type("html").send(VIEWER_HTML);
});
const VIEWER_HTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Fuzzy-cull · live · wallco.ai</title>
<style>
:root { --ink:#1a1a1a; --line:#d8d0c0; --gold:#c9a14b; --bg:#fafaf6; --card:#fff; --reject:#c64a3a; --keep:#3a8a4f; }
* { box-sizing: border-box; }
body { margin:0; font:13px/1.5 -apple-system,BlinkMacSystemFont,sans-serif; background:var(--bg); color:var(--ink); }
header { position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid var(--line); padding:14px 22px; display:flex; align-items:center; gap:18px; flex-wrap:wrap; }
header h1 { margin:0; font:300 22px/1 Georgia,serif; }
header h1 .live { display:inline-block; width:8px; height:8px; border-radius:50%; background:#3a8a4f; margin-right:6px; vertical-align:middle; animation:pulse 1.4s ease-in-out infinite; }
header h1 .live.idle { background:#999; animation:none; }
@keyframes pulse { 0%,100% { opacity:.4 } 50% { opacity:1 } }
.tabs { display:flex; }
.tabs button { background:transparent; border:1px solid var(--line); border-right-width:0; padding:8px 16px; font:13px; cursor:pointer; color:var(--ink); }
.tabs button:last-child { border-right-width:1px; }
.tabs button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
.stats { font:12px/1 ui-monospace,monospace; color:#666; }
.stats b { color:var(--ink); }
.density { display:flex; align-items:center; gap:8px; font:11px; color:#666; }
main { padding:22px; }
.grid { display:grid; gap:14px; grid-template-columns: repeat(var(--cols, 6), 1fr); }
.card { background:var(--card); border:1px solid var(--line); border-radius:6px; overflow:hidden; }
.card.flagged-keep { border-color:var(--keep); box-shadow:inset 0 0 0 2px var(--keep); }
.card.flagged-reject { border-color:var(--reject); box-shadow:inset 0 0 0 2px var(--reject); opacity:.55; }
.thumb { display:block; width:100%; aspect-ratio:1/1; background:#eee; background-size:cover; background-position:center; }
.meta { padding:8px 10px; font-size:11.5px; }
.id { font-weight:600; }
.id a { color:var(--ink); text-decoration:none; }
.conf { float:right; color:#888; font:11px ui-monospace,monospace; }
.reason { color:#666; margin-top:3px; font-size:11px; line-height:1.3; max-height:30px; overflow:hidden; }
.btns { display:flex; gap:4px; padding:0 10px 10px; }
.btns button { flex:1; font:11px; padding:4px 6px; border:1px solid var(--line); background:#fff; border-radius:3px; cursor:pointer; }
.btns .keep:hover, .btns .keep.on { background:var(--keep); color:#fff; border-color:var(--keep); }
.btns .reject:hover, .btns .reject.on { background:var(--reject); color:#fff; border-color:var(--reject); }
.actions { padding:10px 22px; background:#fff; border-top:1px solid var(--line); position:sticky; bottom:0; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.actions button { font:13px; padding:8px 14px; border:1px solid var(--line); background:#fff; border-radius:4px; cursor:pointer; }
.actions .export-btn { background:var(--ink); color:#fff; border-color:var(--ink); }
.empty { padding:60px; text-align:center; color:#888; }
.classifier-progress { width:200px; height:4px; background:#eee; border-radius:2px; overflow:hidden; margin-left:8px; }
.classifier-progress span { display:block; height:100%; background:var(--gold); transition:width .5s; }
</style></head>
<body>
<header>
<h1><span class="live" id="live-dot"></span>Fuzzy-cull <span style="color:#888;font-size:13px">· live</span></h1>
<div class="stats" id="stats">loading…</div>
<div class="classifier-progress" title="classified / total"><span id="progress-bar" style="width:0%"></span></div>
<div class="density">Density <input type="range" id="density" min="3" max="9" step="1" value="6"></div>
<div class="tabs" style="margin-left:auto">
<button data-tab="crisp" class="active">Keepers <span id="t-crisp">0</span></button>
<button data-tab="fuzzy">Rejects <span id="t-fuzzy">0</span></button>
<button data-tab="errs">Errors <span id="t-errs">0</span></button>
</div>
</header>
<main><div class="grid" id="grid"><div class="empty">waiting for first records…</div></div></main>
<div class="actions">
<span id="decision-count">0 manual decisions</span>
<button id="apply-all-keepers" title="Mark every CRISP-flagged as Keep">✓ All Keepers</button>
<button id="apply-all-rejects" title="Mark every FUZZY-flagged as Reject">✗ All Rejects</button>
<button id="clear">Clear</button>
<a href="/export" class="export-btn" id="export" download style="display:inline-block;padding:8px 14px;border-radius:4px;text-decoration:none;background:var(--ink);color:#fff;font-size:13px;border:1px solid var(--ink)">Export decisions.json</a>
</div>
<script>
const dens = document.getElementById('density');
const grid = document.getElementById('grid');
const statsEl = document.getElementById('stats');
const tabs = document.querySelectorAll('.tabs button');
let activeTab = 'crisp';
let LIVE = { crisp: [], fuzzy: [], errs: [], total: 0, decisions: {} };
const KNOWN_TOTAL = 9446;
dens.value = localStorage.getItem('wallco.cull.density') || 6;
document.documentElement.style.setProperty('--cols', dens.value);
dens.addEventListener('input', () => {
document.documentElement.style.setProperty('--cols', dens.value);
localStorage.setItem('wallco.cull.density', dens.value);
});
async function poll() {
try {
const r = await fetch('/data');
LIVE = await r.json();
render();
} catch (e) {
statsEl.textContent = 'fetch failed: ' + e.message;
}
setTimeout(poll, LIVE.classifier_running ? 3000 : 6000);
}
function decisionClass(id) {
const d = LIVE.decisions[id];
if (d === 'keep') return 'flagged-keep';
if (d === 'reject') return 'flagged-reject';
return '';
}
function decisionBtn(id, kind) {
return LIVE.decisions[id] === kind ? 'on' : '';
}
function render() {
const list = LIVE[activeTab] || [];
document.getElementById('t-crisp').textContent = LIVE.crisp.length;
document.getElementById('t-fuzzy').textContent = LIVE.fuzzy.length;
document.getElementById('t-errs').textContent = LIVE.errs.length;
const dot = document.getElementById('live-dot');
if (LIVE.classifier_running) dot.classList.remove('idle'); else dot.classList.add('idle');
document.getElementById('progress-bar').style.width = Math.min(100, (LIVE.total / KNOWN_TOTAL) * 100).toFixed(1) + '%';
statsEl.innerHTML = 'Classified <b>' + LIVE.total + '</b> · Keepers <b>' + LIVE.crisp.length + '</b> · Rejects <b>' + LIVE.fuzzy.length + '</b> · ' + (LIVE.classifier_running ? '<span style="color:#3a8a4f">classifier running</span>' : '<span style="color:#999">classifier idle</span>');
document.getElementById('decision-count').textContent = Object.keys(LIVE.decisions).length + ' manual decisions';
if (!list.length) {
grid.innerHTML = '<div class="empty">' + (LIVE.total === 0 ? 'waiting for first records…' : 'No designs in this bucket.') + '</div>';
return;
}
grid.innerHTML = list.map(r => '\\
<div class="card ' + decisionClass(r.design_id) + '">\\
<a class="thumb-link" href="https://wallco.ai/design/' + r.design_id + '" target="_blank">\\
<div class="thumb" style="background-image:url(/img/' + r.file + ')"></div>\\
</a>\\
<div class="meta">\\
<span class="id"><a href="https://wallco.ai/design/' + r.design_id + '" target="_blank">#' + (r.design_id || '?') + '</a></span>\\
<span class="conf">' + (r.confidence != null ? r.confidence.toFixed(2) : '-') + '</span>\\
<div class="reason">' + (r.reason || r.error || '').replace(/</g,'<').slice(0,100) + '</div>\\
</div>\\
<div class="btns">\\
<button class="keep ' + decisionBtn(r.design_id,'keep') + '" data-id="' + r.design_id + '" data-v="keep">✓ Keep</button>\\
<button class="reject ' + decisionBtn(r.design_id,'reject') + '" data-id="' + r.design_id + '" data-v="reject">✗ Reject</button>\\
</div>\\
</div>').join('');
grid.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', async () => {
const id = parseInt(b.dataset.id, 10);
const wantedV = b.dataset.v;
const cur = LIVE.decisions[id];
const v = cur === wantedV ? null : wantedV;
LIVE.decisions[id] = v || undefined;
if (!v) delete LIVE.decisions[id];
render();
await fetch('/decide', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id, verdict: v}) });
}));
}
tabs.forEach(b => b.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
b.classList.add('active');
activeTab = b.dataset.tab;
render();
}));
document.getElementById('apply-all-keepers').onclick = async () => {
const ids = LIVE.crisp.map(r => r.design_id).filter(Boolean);
await fetch('/bulk-decide', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ids, verdict:'keep'}) });
poll();
};
document.getElementById('apply-all-rejects').onclick = async () => {
const ids = LIVE.fuzzy.map(r => r.design_id).filter(Boolean);
await fetch('/bulk-decide', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ids, verdict:'reject'}) });
poll();
};
document.getElementById('clear').onclick = async () => {
if (!confirm('Clear all manual decisions?')) return;
const ids = Object.keys(LIVE.decisions).map(Number);
await fetch('/bulk-decide', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ids, verdict:null}) });
poll();
};
poll();
</script>
</body></html>`;
// Bind to all interfaces so the LAN (e.g. Steve's MacBook over SSH to Mac2)
// can reach it. Mac2 is LAN-only — not internet-exposed — so this is safe.
const BIND = process.env.CULL_VIEWER_BIND || "0.0.0.0";
app.listen(PORT, BIND, () => {
console.log(`Cull viewer live at http://${BIND}:${PORT}`);
});