← 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); });