[object Object]

← back to Wallco Ai

feat(scripts): add cull-viewer-server.js

2e90f4f9b0f1e2b1a74b701401ca06754ccbbf09 · 2026-05-19 15:09:43 -0700 · Steve

Files touched

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,'&lt;').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 →