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