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