← back to Wallco Ai
Fuzzy-design cull pipeline: Gemini classifier + viewer + apply
48f6b40aebe2299711898e421ab63b03560cc505 · 2026-05-19 14:23:59 -0700 · SteveStudio2
- classify-fuzzy.js: Gemini 2.5-flash multimodal pass, thinkingBudget=0,
JSONL output (file,design_id,verdict,confidence,reason); --sample N
for validation, --files for surgical re-runs; dedup-on-resume
- build-cull-viewer.js: self-contained HTML with Keepers/Rejects tabs,
per-card keep/reject buttons, localStorage persistence, export to
decisions.json. Strict-screenprint calibration matches Steve's bar
- apply-cull-decisions.js: takes a decisions.json and executes quarantines local files (not delete) → generated-rejected/, is_published=FALSE + user_removed=TRUE on Mac2 spoon_all_designs, removes from Mac2 + prod designs.json, restarts prod wallco-ai, appends to data/cull-audit.jsonl. Supports --dry-run.
Files touched
A scripts/apply-cull-decisions.jsA scripts/build-cull-viewer.jsA scripts/classify-fuzzy.js
Diff
commit 48f6b40aebe2299711898e421ab63b03560cc505
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue May 19 14:23:59 2026 -0700
Fuzzy-design cull pipeline: Gemini classifier + viewer + apply
- classify-fuzzy.js: Gemini 2.5-flash multimodal pass, thinkingBudget=0,
JSONL output (file,design_id,verdict,confidence,reason); --sample N
for validation, --files for surgical re-runs; dedup-on-resume
- build-cull-viewer.js: self-contained HTML with Keepers/Rejects tabs,
per-card keep/reject buttons, localStorage persistence, export to
decisions.json. Strict-screenprint calibration matches Steve's bar
- apply-cull-decisions.js: takes a decisions.json and executes quarantines local files (not delete) → generated-rejected/, is_published=FALSE + user_removed=TRUE on Mac2 spoon_all_designs, removes from Mac2 + prod designs.json, restarts prod wallco-ai, appends to data/cull-audit.jsonl. Supports --dry-run.
---
scripts/apply-cull-decisions.js | 113 ++++++++++++++++++++++
scripts/build-cull-viewer.js | 186 ++++++++++++++++++++++++++++++++++++
scripts/classify-fuzzy.js | 205 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 504 insertions(+)
diff --git a/scripts/apply-cull-decisions.js b/scripts/apply-cull-decisions.js
new file mode 100644
index 0000000..a5b0fca
--- /dev/null
+++ b/scripts/apply-cull-decisions.js
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+// apply-cull-decisions.js — read fuzzy-cull-decisions.json (from the viewer)
+// and execute the user's decisions against Mac2 and prod.
+//
+// REJECT decision per design_id:
+// 1. Set is_published=FALSE in spoon_all_designs (Mac2).
+// 2. Move the original from data/generated/ → data/generated-rejected/
+// (archive-not-delete; the watermarked thumb stays until prod-removed).
+// 3. Move the watermarked from data/generated-web/ → data/generated-web-rejected/.
+// 4. Remove the entry from designs.json on Mac2.
+// 5. ssh prod: rm /root/public-projects/wallco-ai/data/generated/<file>.png
+// 6. ssh prod: remove from prod designs.json + pm2 restart wallco-ai.
+// 7. Append to data/cull-audit.jsonl.
+//
+// KEEP decision per design_id: no-op (these are already in the catalog).
+//
+// Usage: node apply-cull-decisions.js path/to/fuzzy-cull-decisions.json [--dry-run]
+
+"use strict";
+const fs = require("fs");
+const path = require("path");
+const { execSync } = require("child_process");
+
+const ROOT = path.join(__dirname, "..");
+const DESIGNS_JSON = path.join(ROOT, "data", "designs.json");
+const GEN_DIR = path.join(ROOT, "data", "generated");
+const WEB_DIR = path.join(ROOT, "data", "generated-web");
+const REJ_GEN = path.join(ROOT, "data", "generated-rejected");
+const REJ_WEB = path.join(ROOT, "data", "generated-web-rejected");
+const AUDIT = path.join(ROOT, "data", "cull-audit.jsonl");
+
+const args = process.argv.slice(2);
+const decisionsPath = args.find(a => !a.startsWith("--"));
+const DRY = args.includes("--dry-run");
+if (!decisionsPath) {
+ console.error("Usage: node apply-cull-decisions.js <decisions.json> [--dry-run]");
+ process.exit(2);
+}
+
+const dec = JSON.parse(fs.readFileSync(decisionsPath, "utf8")).decisions || {};
+const rejectIds = Object.entries(dec).filter(([, v]) => v === "reject").map(([k]) => parseInt(k, 10));
+const keepIds = Object.entries(dec).filter(([, v]) => v === "keep").map(([k]) => parseInt(k, 10));
+console.log(`Decisions: reject=${rejectIds.length} keep=${keepIds.length} ${DRY ? "(DRY RUN)" : ""}`);
+if (rejectIds.length === 0) { console.log("Nothing to reject. Exiting."); process.exit(0); }
+
+[REJ_GEN, REJ_WEB].forEach(d => { if (!DRY) try { fs.mkdirSync(d, { recursive: true }); } catch {} });
+
+function psql(sql) {
+ return execSync(`psql dw_unified -At -q`, { input: sql, encoding: "utf8" }).trim();
+}
+
+// 1. fetch local_path basenames for the reject IDs
+const inList = rejectIds.join(",");
+const fileMap = new Map();
+const rows = psql(`SELECT id, regexp_replace(local_path,'^.*/','') FROM spoon_all_designs WHERE id IN (${inList});`);
+for (const line of rows.split("\n").filter(Boolean)) {
+ const [id, fn] = line.split("|");
+ fileMap.set(parseInt(id, 10), fn);
+}
+console.log(`Resolved ${fileMap.size}/${rejectIds.length} filenames from DB`);
+
+// 2. quarantine local files
+for (const [id, fn] of fileMap) {
+ for (const [src, dst] of [[GEN_DIR, REJ_GEN], [WEB_DIR, REJ_WEB]]) {
+ const from = path.join(src, fn), to = path.join(dst, fn);
+ if (fs.existsSync(from)) {
+ if (!DRY) fs.renameSync(from, to);
+ console.log(` mv ${path.basename(src)}/${fn} → ${path.basename(dst)}/`);
+ }
+ }
+}
+
+// 3. Mac2 spoon_all_designs is_published=FALSE
+if (!DRY) {
+ psql(`UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE WHERE id IN (${inList});`);
+ console.log(`Mac2 DB: is_published=FALSE + user_removed=TRUE on ${rejectIds.length} rows`);
+}
+
+// 4. Mac2 designs.json — drop the entries
+if (fs.existsSync(DESIGNS_JSON) && !DRY) {
+ const cur = JSON.parse(fs.readFileSync(DESIGNS_JSON, "utf8"));
+ const before = cur.length;
+ const out = cur.filter(d => !rejectIds.includes(d.id));
+ fs.writeFileSync(DESIGNS_JSON + ".tmp", JSON.stringify(out, null, 2));
+ fs.renameSync(DESIGNS_JSON + ".tmp", DESIGNS_JSON);
+ console.log(`Mac2 designs.json: ${before} → ${out.length}`);
+}
+
+// 5. Prod: rm watermarked + remove from prod designs.json + restart
+if (!DRY && fileMap.size > 0) {
+ const files = Array.from(fileMap.values()).map(f => `data/generated/${f}`).join(" ");
+ execSync(`ssh -o ConnectTimeout=15 root@45.61.58.125 'cd /root/public-projects/wallco-ai && rm -f ${files}'`, { stdio: "inherit" });
+ const tmpIds = `/tmp/cull-ids-${Date.now()}.json`;
+ fs.writeFileSync(tmpIds, JSON.stringify(rejectIds));
+ execSync(`scp ${tmpIds} root@45.61.58.125:/tmp/cull-ids.json`, { stdio: "inherit" });
+ execSync(`ssh root@45.61.58.125 'node -e "
+const fs=require(\\"fs\\"); const ids=new Set(JSON.parse(fs.readFileSync(\\"/tmp/cull-ids.json\\",\\"utf8\\")));
+const F=\\"/root/public-projects/wallco-ai/data/designs.json\\";
+const cur=JSON.parse(fs.readFileSync(F,\\"utf8\\"));
+const out=cur.filter(d=>!ids.has(d.id));
+fs.writeFileSync(F+\\".tmp\\",JSON.stringify(out,null,2));
+fs.renameSync(F+\\".tmp\\",F);
+console.log(\\"prod designs.json: \\"+cur.length+\\" → \\"+out.length);
+" && pm2 restart wallco-ai --update-env >/dev/null && echo "wallco-ai restarted"'`, { stdio: "inherit" });
+}
+
+// 6. audit log
+if (!DRY) {
+ const ts = new Date().toISOString();
+ fs.appendFileSync(AUDIT, JSON.stringify({ ts, action: "cull", rejected: rejectIds, kept: keepIds, classifier_source: decisionsPath }) + "\n");
+ console.log(`Audit logged → ${AUDIT}`);
+}
+console.log(DRY ? "\nDRY RUN — nothing changed." : "\nCull complete.");
diff --git a/scripts/build-cull-viewer.js b/scripts/build-cull-viewer.js
new file mode 100644
index 0000000..fc7a6e9
--- /dev/null
+++ b/scripts/build-cull-viewer.js
@@ -0,0 +1,186 @@
+#!/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:9792) 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}`);
diff --git a/scripts/classify-fuzzy.js b/scripts/classify-fuzzy.js
new file mode 100644
index 0000000..7d4abe0
--- /dev/null
+++ b/scripts/classify-fuzzy.js
@@ -0,0 +1,205 @@
+#!/usr/bin/env node
+// classify-fuzzy.js — vision-QA pass over wallco design images, labelling each
+// CRISP vs FUZZY with a confidence score and short reason. Writes JSONL.
+//
+// Driven by Steve's standing rule (2026-05-19): every design must be crisp
+// like a screenprint — fuzzy / layer-over-layer / washed-out designs are
+// quality rejects. This script SURFACES the flagged list; it never deletes.
+//
+// Usage:
+// node classify-fuzzy.js # full pass (every .png in data/generated-web)
+// node classify-fuzzy.js --sample 30 # sample N random + 4 known cases (9317/18/19/23)
+// node classify-fuzzy.js --files a.png,b.png,c.png
+//
+// Env:
+// GEMINI_API_KEY — required (read from env or ~/Projects/secrets-manager/.env)
+// CLASSIFIER_CONCURRENCY=10 — parallel Gemini calls (default 10)
+// CLASSIFIER_MODEL=gemini-2.5-flash — multimodal text-out model
+//
+// Output: data/fuzzy-classifier.jsonl (append-only, dedup by file)
+
+"use strict";
+const fs = require("fs");
+const path = require("path");
+const { execSync, spawnSync } = require("child_process");
+
+const ROOT = path.join(__dirname, "..");
+const WEB_DIR = path.join(ROOT, "data", "generated-web");
+const OUT = path.join(ROOT, "data", "fuzzy-classifier.jsonl");
+
+function loadGeminiKey() {
+ if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
+ try {
+ const txt = fs.readFileSync(
+ path.join(process.env.HOME, "Projects/secrets-manager/.env"),
+ "utf8",
+ );
+ const m = txt.match(/^GEMINI_API_KEY=(.+)$/m);
+ if (m) return m[1].replace(/^["']|["']$/g, "").trim();
+ } catch {}
+ return null;
+}
+const KEY = loadGeminiKey();
+if (!KEY) {
+ console.error("GEMINI_API_KEY not found in env or ~/Projects/secrets-manager/.env");
+ process.exit(2);
+}
+
+const MODEL = process.env.CLASSIFIER_MODEL || "gemini-2.5-flash";
+const CONCURRENCY = parseInt(process.env.CLASSIFIER_CONCURRENCY || "10", 10);
+
+const PROMPT = `You are a wallpaper-quality auditor. Look at this generated wallpaper design and judge whether it meets the "crisp screenprint" standard.
+
+CRISP = sharp clean edges, opaque richly-saturated ink, distinct motifs you can name, flat or properly-rendered color, no smudgy blur, no washed-out gradients, no see-through layering.
+
+FUZZY = soft layered look, washed-out / low-contrast pastel haze, indistinct or smudgy motifs, gradient bleed, layer-over-layer ghosting, halftone/dot-screen breakdown, blurred edges.
+
+Return ONLY a JSON object on one line (no prose, no code fence):
+{"verdict":"CRISP"|"FUZZY","confidence":0.0-1.0,"reason":"<<=80 chars>"}
+
+If borderline, lean CRISP only when motifs are clearly legible AND color is opaque.`;
+
+async function classify(file) {
+ const buf = fs.readFileSync(file);
+ const b64 = buf.toString("base64");
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+ const body = {
+ contents: [{
+ role: "user",
+ parts: [
+ { text: PROMPT },
+ { inline_data: { mime_type: "image/png", data: b64 } },
+ ],
+ }],
+ generationConfig: {
+ temperature: 0.1, maxOutputTokens: 400,
+ responseMimeType: "application/json",
+ thinkingConfig: { thinkingBudget: 0 }, // disable 2.5 thinking — otherwise eats all tokens
+ },
+ };
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 30000);
+ try {
+ const r = await fetch(url, {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body), signal: ctrl.signal,
+ });
+ clearTimeout(t);
+ const j = await r.json();
+ const text = j.candidates?.[0]?.content?.parts?.[0]?.text || "";
+ let parsed = null;
+ try { parsed = JSON.parse(text); } catch {
+ const m = text.match(/\{[\s\S]*?\}/);
+ if (m) try { parsed = JSON.parse(m[0]); } catch {}
+ }
+ if (!parsed || !parsed.verdict) return { error: "parse_fail", raw: text.slice(0, 200) };
+ return parsed;
+ } catch (e) {
+ clearTimeout(t);
+ return { error: String(e.message || e).slice(0, 200) };
+ }
+}
+
+// Map filename → design_id via spoon_all_designs.local_path basename
+function buildFilenameMap() {
+ try {
+ const out = execSync(
+ `psql dw_unified -At -q -c "SELECT regexp_replace(local_path,'^.*/',''), id FROM spoon_all_designs WHERE local_path LIKE '%generated/%';"`,
+ { encoding: "utf8" },
+ );
+ const map = new Map();
+ for (const line of out.trim().split("\n")) {
+ const [fn, id] = line.split("|");
+ if (fn && id) map.set(fn, parseInt(id, 10));
+ }
+ return map;
+ } catch (e) {
+ console.error("filename→id map failed:", e.message);
+ return new Map();
+ }
+}
+
+function loadAlreadyDone() {
+ if (!fs.existsSync(OUT)) return new Set();
+ const seen = new Set();
+ for (const line of fs.readFileSync(OUT, "utf8").trim().split("\n")) {
+ try {
+ const j = JSON.parse(line);
+ if (j.file) seen.add(j.file);
+ } catch {}
+ }
+ return seen;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ let files = [];
+
+ const sampleIdx = args.indexOf("--sample");
+ const filesIdx = args.indexOf("--files");
+ if (sampleIdx >= 0) {
+ const n = parseInt(args[sampleIdx + 1], 10) || 30;
+ const all = fs.readdirSync(WEB_DIR).filter(f => f.endsWith(".png"));
+ // Always include known cases
+ const known = ["1779224403913_1106085464.png", "1779224412957_1415526741.png",
+ "1779224440803_787371989.png", "1779224536695_1788929927.png"]
+ .filter(f => all.includes(f));
+ const rest = all.filter(f => !known.includes(f));
+ // shuffle
+ for (let i = rest.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [rest[i], rest[j]] = [rest[j], rest[i]];
+ }
+ files = known.concat(rest.slice(0, Math.max(0, n - known.length)));
+ } else if (filesIdx >= 0) {
+ files = args[filesIdx + 1].split(",").map(f => f.trim()).filter(Boolean);
+ } else {
+ files = fs.readdirSync(WEB_DIR).filter(f => f.endsWith(".png"));
+ }
+
+ const done = loadAlreadyDone();
+ files = files.filter(f => !done.has(f));
+ console.log(`Classifying ${files.length} files (model=${MODEL}, parallel=${CONCURRENCY})`);
+ if (done.size) console.log(` (skipping ${done.size} already classified)`);
+
+ const fmap = buildFilenameMap();
+ const out = fs.createWriteStream(OUT, { flags: "a" });
+ let done_n = 0, crisp = 0, fuzzy = 0, errs = 0;
+ const t0 = Date.now();
+
+ async function worker(slice) {
+ for (const fn of slice) {
+ const full = path.join(WEB_DIR, fn);
+ if (!fs.existsSync(full)) { errs++; continue; }
+ const result = await classify(full);
+ const rec = {
+ ts: new Date().toISOString(),
+ file: fn,
+ design_id: fmap.get(fn) || null,
+ ...result,
+ };
+ out.write(JSON.stringify(rec) + "\n");
+ done_n++;
+ if (result.error) errs++;
+ else if (result.verdict === "CRISP") crisp++;
+ else if (result.verdict === "FUZZY") fuzzy++;
+ if (done_n % 25 === 0) {
+ const rate = done_n / ((Date.now() - t0) / 1000);
+ const eta = (files.length - done_n) / rate;
+ console.log(` ${done_n}/${files.length} · crisp=${crisp} fuzzy=${fuzzy} err=${errs} · ${rate.toFixed(1)}/s · eta ${Math.round(eta)}s`);
+ }
+ }
+ }
+
+ // shard files into CONCURRENCY slices
+ const slices = Array.from({ length: CONCURRENCY }, () => []);
+ files.forEach((f, i) => slices[i % CONCURRENCY].push(f));
+ await Promise.all(slices.map(worker));
+ out.end();
+
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
+ console.log(`\nDone in ${dt}s — crisp=${crisp} fuzzy=${fuzzy} errs=${errs}`);
+ console.log(`Output: ${OUT}`);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
← bed0442 wallco /designs: hide failed-generation blank designs from a
·
back to Wallco Ai
·
admin/inspiration/generate: actionable 503 when Ollama is un 7761399 →