← back to Wallco Ai

scripts/build-cull-viewer.js

187 lines

#!/usr/bin/env node
// build-cull-viewer.js — after classify-fuzzy.js completes, produce a
// self-contained HTML the user opens locally to batch-review keepers/rejects.
//
// Output: data/fuzzy-cull-viewer.html (open with `open <path>`)
// Images referenced via /designs/img/<file>.png (works when wallco-ai Mac2 is
// up at localhost:9878) AND via file://… fallback (works without a server).

"use strict";
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 OUT = path.join(ROOT, "data", "fuzzy-cull-viewer.html");

if (!fs.existsSync(JSONL)) { console.error("missing", JSONL); process.exit(1); }

const records = fs.readFileSync(JSONL, "utf8").trim().split("\n").map(l => {
  try { return JSON.parse(l); } catch { return null; }
}).filter(Boolean);

// dedup by file (keep last)
const seen = new Map();
for (const r of records) if (r.file) seen.set(r.file, r);
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));

const data = { crisp, fuzzy, errs, total: rows.length, generated_at: new Date().toISOString() };

const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Fuzzy-cull review · wallco.ai · ${rows.length} designs</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; }
header h1 { margin:0; font:300 22px/1 Georgia,serif; }
.tabs { display:flex; gap:0; margin-left:auto; }
.tabs button { background:transparent; border:1px solid var(--line); border-right-width:0; padding:8px 16px; font:13px var(--sans); 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 var(--sans); color:#666; }
main { padding:22px; }
.grid { display:grid; gap:14px; grid-template-columns: repeat(var(--cols, 5), 1fr); }
.card { background:var(--card); border:1px solid var(--line); border-radius:6px; overflow:hidden; transition:transform .08s, border-color .15s; cursor:pointer; }
.card:hover { transform:translateY(-2px); border-color:var(--gold); }
.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 { 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; color:var(--ink); }
.conf { float:right; color:#888; font:11px ui-monospace,monospace; }
.reason { color:#666; margin-top:3px; font-size:11px; line-height:1.3; }
.btns { display:flex; gap:4px; padding:0 10px 10px; }
.btns button { flex:1; font:11px var(--sans); padding:4px 6px; border:1px solid var(--line); background:#fff; border-radius:3px; cursor:pointer; }
.btns .keep:hover { background:var(--keep); color:#fff; border-color:var(--keep); }
.btns .reject:hover { background:var(--reject); color:#fff; border-color:var(--reject); }
.actions { margin-top:14px; padding:10px 22px; background:#fff; border-top:1px solid var(--line); position:sticky; bottom:0; display:flex; gap:10px; align-items:center; }
.actions button { font:13px var(--sans); 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; }
</style></head>
<body>
<header>
  <h1>Fuzzy-cull review</h1>
  <div class="stats" id="stats"></div>
  <div class="density">Density <input type="range" id="density" min="3" max="8" step="1" value="5"></div>
  <div class="tabs">
    <button data-tab="crisp" class="active">Keepers · ${crisp.length}</button>
    <button data-tab="fuzzy">Rejects · ${fuzzy.length}</button>
    <button data-tab="errs">Errors · ${errs.length}</button>
  </div>
</header>
<main>
  <div class="grid" id="grid"></div>
</main>
<div class="actions">
  <span id="decision-count">0 manual decisions</span>
  <button id="apply-all-keepers">Mark ALL Keepers ✓</button>
  <button id="apply-all-rejects">Mark ALL Rejects ✗</button>
  <button id="clear">Clear decisions</button>
  <button id="export" class="export-btn">Export decisions.json</button>
</div>
<script>
const DATA = ${JSON.stringify(data)};
const dens = document.getElementById('density');
const grid = document.getElementById('grid');
const stats = document.getElementById('stats');
const tabs = document.querySelectorAll('.tabs button');
let activeTab = 'crisp';
const decisions = JSON.parse(localStorage.getItem('wallco.fuzzy-cull') || '{}');

dens.value = localStorage.getItem('wallco.fuzzy-cull.density') || 5;
document.documentElement.style.setProperty('--cols', dens.value);
dens.addEventListener('input', () => {
  document.documentElement.style.setProperty('--cols', dens.value);
  localStorage.setItem('wallco.fuzzy-cull.density', dens.value);
});

function save() { localStorage.setItem('wallco.fuzzy-cull', JSON.stringify(decisions)); refreshCount(); }
function refreshCount() {
  const n = Object.keys(decisions).length;
  document.getElementById('decision-count').textContent = n + ' manual decision' + (n!==1?'s':'');
}
function decisionClass(id) {
  const d = decisions[id];
  if (d === 'keep') return 'flagged-keep';
  if (d === 'reject') return 'flagged-reject';
  return '';
}
function render() {
  const list = DATA[activeTab];
  if (!list || !list.length) {
    grid.innerHTML = '<div class="empty">No designs in this bucket.</div>';
  } else {
    grid.innerHTML = list.map(r => \`
      <div class="card \${decisionClass(r.design_id)}" data-id="\${r.design_id}">
        <a class="thumb-link" href="https://wallco.ai/design/\${r.design_id}" target="_blank">
          <div class="thumb" style="background-image:url('/designs/img/\${r.file}'), url('\${WEBPATH}/\${r.file}')"></div>
        </a>
        <div class="meta">
          <span class="id">#\${r.design_id || '?'}</span>
          <span class="conf">\${(r.confidence!=null?r.confidence.toFixed(2):'-')}</span>
          <div class="reason">\${(r.reason || r.error || '').slice(0,90)}</div>
        </div>
        <div class="btns">
          <button class="keep" onclick="event.stopPropagation();decide(\${r.design_id},'keep')">✓ Keep</button>
          <button class="reject" onclick="event.stopPropagation();decide(\${r.design_id},'reject')">✗ Reject</button>
        </div>
      </div>\`).join('');
  }
  stats.innerHTML = \`Total <b>\${DATA.total}</b> · Keepers <b>\${DATA.crisp.length}</b> · Rejects <b>\${DATA.fuzzy.length}</b> · Errs <b>\${DATA.errs.length}</b>\`;
}
function decide(id, v) {
  if (!id) return;
  if (decisions[id] === v) delete decisions[id]; else decisions[id] = v;
  save();
  render();
}
window.decide = decide;
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 = () => {
  DATA.crisp.forEach(r => { if (r.design_id) decisions[r.design_id] = 'keep'; });
  save(); render();
};
document.getElementById('apply-all-rejects').onclick = () => {
  DATA.fuzzy.forEach(r => { if (r.design_id) decisions[r.design_id] = 'reject'; });
  save(); render();
};
document.getElementById('clear').onclick = () => {
  if (!confirm('Clear all manual decisions?')) return;
  for (const k of Object.keys(decisions)) delete decisions[k];
  save(); render();
};
document.getElementById('export').onclick = () => {
  const out = { generated_at: new Date().toISOString(), classifier_total: DATA.total,
                keepers: DATA.crisp.length, rejects: DATA.fuzzy.length, decisions };
  const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'fuzzy-cull-decisions.json';
  a.click();
};
const WEBPATH = 'file://' + ${JSON.stringify(WEB_DIR)};
render();
refreshCount();
</script>
</body></html>
`;

fs.writeFileSync(OUT, html);
console.log("wrote", OUT);
console.log(`  total=${rows.length} crisp=${crisp.length} fuzzy=${fuzzy.length} errs=${errs.length}`);