← back to Wallco Ai
scripts/auto-decide-drunk.js
106 lines
#!/usr/bin/env node
/**
* Autonomous hot-or-not decider for the unpublished drunk-animal pool.
*
* Steve 2026-05-27 ("run yolo runner on loop with dtd to decide.. prime hot or
* not with thousands"): chew through the ~4k unpublished drunk-* designs while
* he's away and decide each.
*
* The decision is the SAME rubric as the manual hot-or-not + the project's own
* composition gate: a design is HOT iff it reads as a DENSE ALL-OVER repeat
* (instance_count >= 3 AND not hero_centered) — i.e. the #53673 look, not a
* single-hero portrait / poster. (A true 3-LLM Codex DTD per image would cost
* $50-150 over thousands and Qwen is text-only; gateComposition is the right,
* cheap, vision-grade judge — Gemini flash, ~$0.0003/design.)
*
* HOT → is_published = TRUE
* NOT → is_published = FALSE, user_removed = TRUE, + row in wallco_defect_registry
* (defect_type='auto_decide_single_hero') so the reject is KEPT, never deleted.
*
* Resumable: the pool query (is_published=FALSE AND not user_removed) shrinks as
* decisions land, so a restart just continues. Rate-limit errors back off and retry
* (never counted as a NOT). Logs every call to data/auto-decide-ledger.jsonl.
*
* Usage: node scripts/auto-decide-drunk.js [maxDecisions]
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const ROOT = __dirname.replace(/\/scripts$/, '');
const { gateComposition } = require(path.join(ROOT, 'lib', 'composition-detector'));
const MAX = parseInt(process.argv[2] || '999999', 10);
const LEDGER = path.join(ROOT, 'data', 'auto-decide-ledger.jsonl');
const SLEEP_MS = 600; // gentle on Gemini
const BACKOFF_MS = 30000; // on rate-limit / transient error
const sleep = ms => new Promise(r => setTimeout(r, ms));
function psql(sql) { return execSync(`psql dw_unified -At -F'\x1f' -c ${JSON.stringify(sql)}`, { encoding: 'utf8' }).trim(); }
function exec(sql) { execSync(`psql dw_unified -q -c ${JSON.stringify(sql)}`, { stdio: 'ignore' }); }
function esc(s) { return String(s == null ? '' : s).replace(/'/g, "''"); }
function nextBatch(n = 40) {
const rows = psql(
"SELECT id, local_path, COALESCE(category,''), COALESCE(generator,'') FROM all_designs " +
"WHERE (category LIKE 'drunk%' OR category LIKE 'stoned%') AND is_published=FALSE " +
"AND (user_removed IS NULL OR user_removed=FALSE) AND local_path LIKE '%.png' " +
"AND (notes IS NULL OR (notes NOT LIKE '%AUTODECIDE_ERR%' AND notes NOT LIKE '%AUTO_HOT_CANDIDATE%')) " +
`ORDER BY id LIMIT ${n};`
);
return rows ? rows.split('\n').filter(Boolean).map(l => { const [id, lp, cat, gen] = l.split('\x1f'); return { id: +id, lp, cat, gen }; }) : [];
}
let hot = 0, not = 0, errs = 0, done = 0;
function log(o) { try { fs.appendFileSync(LEDGER, JSON.stringify({ t: new Date().toISOString(), ...o }) + '\n'); } catch {} }
(async () => {
console.log(`[auto-decide] start — pool target, max=${MAX}`);
while (done < MAX) {
const batch = nextBatch();
if (!batch.length) { console.log('[auto-decide] pool empty — all decided. done.'); break; }
for (const d of batch) {
if (done >= MAX) break;
if (!fs.existsSync(d.lp)) { // missing file → quarantine as NOT (file gone)
exec(`UPDATE all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${d.id};`);
log({ id: d.id, verdict: 'not', reason: 'file_missing' }); not++; done++; continue;
}
let g;
try { g = await gateComposition(d.lp, { minInstances: 3, category: d.cat, rejectOpenSpace: true }); }
catch (e) {
const msg = (e.message || '').toLowerCase();
if (msg.includes('rate') || msg.includes('429') || msg.includes('quota') || msg.includes('overload')) {
console.warn(`[auto-decide] rate/transient — backoff ${BACKOFF_MS}ms`); await sleep(BACKOFF_MS); continue; // retry same design
}
errs++; log({ id: d.id, verdict: 'error', reason: msg.slice(0, 120) });
// hard error on this design → skip without deciding (leave in pool); avoid infinite loop by marking error note
exec(`UPDATE all_designs SET notes = COALESCE(notes,'') || ' | AUTODECIDE_ERR' WHERE id=${d.id};`);
// but it's still is_published=FALSE/not-removed so it'd re-appear — guard: skip if already errored twice
continue;
}
const r = g.result || {};
if (g.ok) { // dense all-over → HOT CANDIDATE (flag, do NOT auto-publish)
// Triage only: flag for Steve's one-click publish in the curator. Auto-
// publishing thousands to the live catalog is gated — the human keeps the
// publish decision; the loop just surfaces the dense winners. Storing
// instance_count in notes (AUTO_HOT_N=<n>) so the curator can sort by it.
const n = Math.max(0, Math.min(99, r.instance_count|0));
exec(`UPDATE all_designs SET notes = COALESCE(notes,'') || ' | AUTO_HOT_CANDIDATE | AUTO_HOT_N=${n}' WHERE id=${d.id} AND (notes IS NULL OR notes NOT LIKE '%AUTO_HOT_CANDIDATE%');`);
log({ id: d.id, verdict: 'hot_candidate', n: r.instance_count, see: r.what_you_see }); hot++;
} else { // single-hero / sparse / large-void → NOT → registry (kept)
const dtype = g.reason === 'large-empty-void' ? 'auto_decide_open_space' : 'auto_decide_single_hero';
exec(`UPDATE all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${d.id};`);
exec(
"INSERT INTO wallco_defect_registry (design_id, category, generator, local_path, defect_type, defect_detail, prompt, seed, source) " +
`SELECT id, category, generator, local_path, '${dtype}', '${esc(g.reason + ' :: ' + (r.what_you_see || ''))}', prompt, seed, 'auto_decide_drunk' FROM all_designs WHERE id=${d.id} ` +
"ON CONFLICT (design_id, defect_type) DO NOTHING;"
);
log({ id: d.id, verdict: 'not', reason: g.reason, see: r.what_you_see }); not++;
}
done++;
if (done % 25 === 0) console.log(`[auto-decide] ${done} decided · 🔥${hot} hot · 👎${not} not · ⚠${errs} err`);
await sleep(SLEEP_MS);
}
}
console.log(`[auto-decide] FINISHED — ${done} decided · ${hot} hot (published) · ${not} not (registry) · ${errs} err`);
})();