← back to Wallco Ai
feat(scripts): add cull-viewer-server.js
2e90f4f9b0f1e2b1a74b701401ca06754ccbbf09 · 2026-05-19 15:09:43 -0700 · Steve
Files touched
A scripts/cull-viewer-server.js
Diff
commit 2e90f4f9b0f1e2b1a74b701401ca06754ccbbf09
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 19 15:09:43 2026 -0700
feat(scripts): add cull-viewer-server.js
---
scripts/cull-viewer-server.js | 275 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 275 insertions(+)
diff --git a/scripts/cull-viewer-server.js b/scripts/cull-viewer-server.js
new file mode 100644
index 0000000..38d7c06
--- /dev/null
+++ b/scripts/cull-viewer-server.js
@@ -0,0 +1,275 @@
+#!/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}`);
+});
← d12476d admin/inspiration: two-step flow + Export decisions.json
·
back to Wallco Ai
·
data: refresh designs.json ca3c568 →