← back to Wallco Ai

scripts/auto-fix-composition.js

210 lines

#!/usr/bin/env node
// auto-fix-composition — iterate data/composition-scan-flagged.jsonl and
// fire POST /api/design/:id/smart-fix on each flagged id that's safe to
// auto-fix. Throttled, resumable, category-aware skip list.
//
// Safe-to-fix rules:
//   • Source PNG must exist on disk (smart-fix needs it to detect the motif).
//   • Category must NOT be a scenic mural (those bypass the gate entirely).
//   • Category must NOT be 'recolor' or 'bgswap' (downstream products — fixing
//     would lose the recolor/bg-swap intent; instead, re-recolor the upstream
//     fixed parent).
//   • ID must not already be in data/composition-fix-log.jsonl (resume).
//
// Usage:
//   node scripts/auto-fix-composition.js                  # process all flagged
//   node scripts/auto-fix-composition.js --limit 20       # smoke test 20
//   node scripts/auto-fix-composition.js --concurrency 2  # parallel curls (default 2)
//   node scripts/auto-fix-composition.js --port 9877      # server port (default 9877)
//   node scripts/auto-fix-composition.js --dry-run        # list what WOULD fire, no curl
//
// Output:
//   data/composition-fix-log.jsonl — one row per attempted fix:
//     { src_id, new_id, ok, attempts, elapsed_s, prompt_mode, composition,
//       verify, ts, src_category, error? }

const fs = require('fs');
const path = require('path');
const { SCENIC_CATEGORIES } = require('../lib/composition-detector');

const ARGS = process.argv.slice(2);
const arg = (n, d) => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i + 1] : d; };
const LIMIT       = parseInt(arg('limit', '0'), 10);
const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '2'), 10));
const PORT        = parseInt(arg('port', '9877'), 10);
const DRY_RUN     = ARGS.includes('--dry-run');
const VERBOSE     = ARGS.includes('--verbose');

const ROOT = path.join(__dirname, '..');
const FLAGGED_F = path.join(ROOT, 'data', 'composition-scan-flagged.jsonl');
const LOG_F     = path.join(ROOT, 'data', 'composition-fix-log.jsonl');

// Categories that are valid scan results but NOT safe for smart-fix.
// Scenic murals — bypass the gate. Recolor/bgswap — downstream products.
const SKIP_CATEGORIES = new Set([
  'recolor', 'bgswap', 'bg-swap',
]);

function isSkipCategory(category) {
  if (!category) return true;  // unknown → skip to be safe
  const base = category.split('·')[0].trim().toLowerCase();
  if (SCENIC_CATEGORIES.has(base)) return true;
  if (SKIP_CATEGORIES.has(base)) return true;
  return false;
}

function loadFlagged() {
  if (!fs.existsSync(FLAGGED_F)) return [];
  const lines = fs.readFileSync(FLAGGED_F, 'utf8').trim().split('\n');
  const items = [];
  const seen = new Set();
  for (const line of lines) {
    if (!line.trim()) continue;
    try {
      const r = JSON.parse(line);
      if (!r.id || seen.has(r.id)) continue;
      seen.add(r.id);
      items.push(r);
    } catch {}
  }
  return items;
}

function loadDone() {
  // Only count rows that ACTUALLY succeeded — failed attempts (server 500,
  // PIL crash, etc.) should be retried on the next run so a transient
  // server bug doesn't permanently skip an id.
  if (!fs.existsSync(LOG_F)) return new Set();
  const ids = new Set();
  for (const line of fs.readFileSync(LOG_F, 'utf8').split('\n')) {
    if (!line.trim()) continue;
    try {
      const r = JSON.parse(line);
      if (r.ok && r.new_id) ids.add(r.src_id);
    } catch {}
  }
  return ids;
}

// Look up local_path via the server's design endpoint — fast, in-process
// cache lookup, no extra psql shell-out per item.
async function srcPngExists(id) {
  // The /design/:id/manifest endpoint would tell us; simpler: just probe HEAD
  // on the by-id image URL. 200/302 → exists. 404 → missing.
  try {
    const r = await fetch(`http://127.0.0.1:${PORT}/designs/img/by-id/${id}`, { method: 'HEAD' });
    return r.ok;
  } catch {
    return false;
  }
}

async function fireSmartFix(id) {
  const t0 = Date.now();
  try {
    // ?legacy=1 selects the Gemini-regenerate code path (bypasses the
    // 2026-05-25 opacity-snap redirect). Inside that path, default is now V2
    // repeat-prompt (multi-instance) — V1 only triggered by ?promptv1=1, not
    // by ?legacy. So `?legacy=1` here gives us "Gemini + V2 prompt".
    const r = await fetch(`http://127.0.0.1:${PORT}/api/design/${id}/smart-fix?legacy=1`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      // No body required for smart-fix; auto-detects motif from source.
    });
    const text = await r.text();
    let j; try { j = JSON.parse(text); } catch { j = { raw: text.slice(0, 240) }; }
    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
    return { ...j, elapsed_local_s: elapsed, http_status: r.status };
  } catch (e) {
    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
    return { error: e.message, elapsed_local_s: elapsed };
  }
}

async function runPool(items, workers, onResult) {
  let cursor = 0;
  const next = () => (cursor < items.length ? items[cursor++] : null);
  async function workerLoop() {
    while (true) {
      const r = next();
      if (!r) return;
      const out = await onResult(r);
      // Small breath between calls per worker so we don't hammer Gemini
      // (smart-fix already takes ~10-30s each).
      await new Promise(res => setTimeout(res, 500));
    }
  }
  await Promise.all(Array.from({ length: workers }, workerLoop));
}

(async () => {
  console.log(`[auto-fix-composition] starting · limit=${LIMIT || 'all'} concurrency=${CONCURRENCY} port=${PORT} dry_run=${DRY_RUN}`);
  const flagged = loadFlagged();
  const done = loadDone();
  console.log(`[auto-fix-composition] flagged total: ${flagged.length} · already in fix-log: ${done.size}`);

  // Pre-filter: skip categories + already-done
  const candidates = [];
  let skipCat = 0, skipDone = 0;
  for (const f of flagged) {
    if (done.has(f.id)) { skipDone++; continue; }
    if (isSkipCategory(f.category)) { skipCat++; continue; }
    candidates.push(f);
  }
  // PNG-on-disk check — async per item, cheap (HEAD request to local server)
  process.stdout.write(`[auto-fix-composition] checking PNGs on disk for ${candidates.length} candidates... `);
  const alive = [];
  for (const c of candidates) {
    if (await srcPngExists(c.id)) alive.push(c);
  }
  console.log(`${alive.length} alive / ${candidates.length - alive.length} missing`);

  const todo = LIMIT > 0 ? alive.slice(0, LIMIT) : alive;
  console.log(`[auto-fix-composition] skipped: ${skipDone} done + ${skipCat} skip-cat + ${candidates.length - alive.length} missing-png · todo: ${todo.length}`);

  if (DRY_RUN) {
    console.log('\n[dry-run] would fire smart-fix on:');
    for (const c of todo.slice(0, 30)) {
      console.log(`  ${c.id.toString().padStart(5)} · ${c.category}`);
    }
    if (todo.length > 30) console.log(`  ... + ${todo.length - 30} more`);
    return;
  }

  let n = 0, ok = 0, errored = 0;
  const t0 = Date.now();
  await runPool(todo, CONCURRENCY, async (c) => {
    n++;
    const t1 = Date.now();
    const r = await fireSmartFix(c.id);
    const elapsed = ((Date.now() - t1) / 1000).toFixed(1);
    const row = {
      ts: new Date().toISOString(),
      src_id: c.id,
      src_category: c.category,
      new_id: r.new_id || null,
      ok: !!r.ok,
      attempts: r.attempts || null,
      elapsed_s: r.elapsed_s || elapsed,
      http_status: r.http_status,
      prompt_mode: r.prompt_mode,
      composition: r.composition || null,
      verify: r.verify || null,
      error: r.error || null,
    };
    fs.appendFileSync(LOG_F, JSON.stringify(row) + '\n');
    if (row.ok && row.new_id) ok++; else errored++;
    if (VERBOSE || n % 5 === 0 || n === todo.length) {
      const rate = n / ((Date.now() - t0) / 1000);
      const eta = ((todo.length - n) / Math.max(0.001, rate)).toFixed(0);
      const flag = row.ok ? '✓' : '✗';
      const compInfo = row.composition
        ? `n=${row.composition.instance_count} hero=${row.composition.hero_centered}`
        : (row.error ? `ERR: ${row.error.slice(0, 60)}` : 'no-comp');
      console.log(`[auto-fix] ${flag} ${n}/${todo.length} src=${c.id} → new=${row.new_id || '-'} (${row.elapsed_s}s, att=${row.attempts}, ${compInfo}) · rate=${rate.toFixed(2)}/s ETA=${eta}s`);
    }
  });
  console.log(`\n[auto-fix-composition] done · processed=${n} ok=${ok} errored=${errored}`);
  console.log(`[auto-fix-composition] log: ${LOG_F}`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });