← back to Wallco Ai

scripts/classify-fuzzy.js

206 lines

#!/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); });