← back to Wallco Ai

scripts/audit-drunk-animals-abstraction.js

196 lines

#!/usr/bin/env node
/**
 * audit-drunk-animals-abstraction.js — Gemini vision pass over the live
 * drunk-animals cohort flagging "too abstract" designs (AI-stretched
 * subjects, unrecognizable animals/bottles, abstract-only composition).
 *
 * Steve 2026-05-31: "review all drunk animals. Most images have nothing
 * to do with animals or bottles." Triggered by design #54916 which read
 * as brown smears not orangutans.
 *
 * Output: /tmp/drunk-animals-audit-results.jsonl (append-only, resumable)
 *         /tmp/drunk-animals-audit-kill-list.json  (KILL ids only)
 *         /tmp/drunk-animals-audit-keep-list.json  (KEEP ids only)
 *
 * NO writes to DB. NO deploy. Surface-only — Steve confirms the kill-list
 * before any unpublish.
 *
 * Model: gemini-2.5-flash (input $0.10/M, output $0.40/M) — ~$0.04 total
 * for 854 images at ~340 tokens/req.
 */
'use strict';

require('dotenv').config();
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');

const API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!API_KEY) {
  console.error('GEMINI_API_KEY not set in .env — abort.');
  process.exit(1);
}

const MODEL = process.env.AUDIT_MODEL || 'gemini-2.5-flash';
const SERVER = process.env.AUDIT_SERVER || 'http://127.0.0.1:9905';
const OUT_JSONL = '/tmp/drunk-animals-audit-results.jsonl';
const KILL_OUT = '/tmp/drunk-animals-audit-kill-list.json';
const KEEP_OUT = '/tmp/drunk-animals-audit-keep-list.json';
const CONCURRENCY = parseInt(process.env.AUDIT_CONCURRENCY, 10) || 4;

const PROMPT = `Describe what you see in this wallpaper pattern. The collection's intent is "animal holding/near a drink".

Answer in EXACTLY this format on three lines, nothing else:
ANIMAL: name the animal species you see, or "indistinct" if it's just smeared/stretched shapes you can't identify without being told. Be honest — if the body looks AI-stretched, dismembered, elongated, or amorphous, say "indistinct".
DRINK: name the drink object (bottle, glass, cocktail, coupe, etc.), or "none" if there's no drink-shaped object, or "indistinct" if there's just a tube/blob/stick/ribbon that's not clearly a bottle or glass.
VERDICT: KEEP if both ANIMAL and DRINK are clear and specific. KILL if either is "indistinct" or "none", OR if the composition reads as abstract pattern rather than illustration of an animal-with-drink scene.`;

// ── load the audit queue ────────────────────────────────────────────────────
function loadQueue() {
  const snap = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'designs.json'), 'utf8'));
  const queue = snap.filter(d =>
    d.category === 'drunk-animals' &&
    d.is_published !== false &&
    !d.user_removed
  ).map(d => ({ id: d.id, title: d.title || '', created_at: d.created_at || null }));
  return queue;
}

function loadDone() {
  if (!fs.existsSync(OUT_JSONL)) return new Set();
  const lines = fs.readFileSync(OUT_JSONL, 'utf8').split('\n').filter(Boolean);
  const done = new Set();
  for (const l of lines) {
    try { const r = JSON.parse(l); if (r.id) done.add(r.id); } catch {}
  }
  return done;
}

// ── HTTP helpers ────────────────────────────────────────────────────────────
function fetchImage(id) {
  return new Promise((resolve, reject) => {
    const req = http.get(`${SERVER}/designs/img/by-id/${id}`, res => {
      if (res.statusCode !== 200) {
        res.resume();
        return reject(new Error(`img HTTP ${res.statusCode}`));
      }
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => resolve({ buf: Buffer.concat(chunks), mime: res.headers['content-type'] || 'image/png' }));
      res.on('error', reject);
    });
    req.on('error', reject);
    req.setTimeout(15000, () => { req.destroy(new Error('img timeout')); });
  });
}

function callGemini(imgB64, mime) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({
      contents: [{
        parts: [
          { inline_data: { mime_type: mime.split(';')[0].trim(), data: imgB64 } },
          { text: PROMPT },
        ],
      }],
      generationConfig: { temperature: 0.1, maxOutputTokens: 80 },
    });
    const req = https.request({
      hostname: 'generativelanguage.googleapis.com',
      path: `/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`,
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
    }, res => {
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => {
        const raw = Buffer.concat(chunks).toString('utf8');
        if (res.statusCode !== 200) return reject(new Error(`gemini HTTP ${res.statusCode}: ${raw.slice(0, 200)}`));
        try {
          const j = JSON.parse(raw);
          const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
          resolve({ text, usage: j?.usageMetadata });
        } catch (e) { reject(new Error('gemini parse: ' + e.message)); }
      });
    });
    req.on('error', reject);
    req.setTimeout(45000, () => { req.destroy(new Error('gemini timeout')); });
    req.write(body); req.end();
  });
}

function parseVerdict(text) {
  const m = (text || '').match(/VERDICT:\s*(KEEP|KILL)/i);
  const verdict = m ? m[1].toUpperCase() : 'UNKNOWN';
  const animal = (text.match(/ANIMAL:\s*([^\n]+)/i) || [, ''])[1].trim().slice(0, 80);
  const drink  = (text.match(/DRINK:\s*([^\n]+)/i)  || [, ''])[1].trim().slice(0, 80);
  return { verdict, animal, drink };
}

async function auditOne(item) {
  const t0 = Date.now();
  try {
    const { buf, mime } = await fetchImage(item.id);
    const b64 = buf.toString('base64');
    const { text, usage } = await callGemini(b64, mime);
    const v = parseVerdict(text);
    return { id: item.id, title: item.title, ...v, raw: text.slice(0, 200), tokens_in: usage?.promptTokenCount || 0, tokens_out: usage?.candidatesTokenCount || 0, ms: Date.now() - t0 };
  } catch (e) {
    return { id: item.id, title: item.title, verdict: 'ERROR', error: String(e.message || e).slice(0, 160), ms: Date.now() - t0 };
  }
}

// ── concurrent runner w/ pool ───────────────────────────────────────────────
async function main() {
  const queue = loadQueue();
  const done = loadDone();
  const work = queue.filter(it => !done.has(it.id));
  console.log(`queue: ${queue.length} total, ${done.size} already done, ${work.length} to process`);
  console.log(`model: ${MODEL}, concurrency: ${CONCURRENCY}, server: ${SERVER}`);

  const out = fs.createWriteStream(OUT_JSONL, { flags: 'a' });
  let idx = 0, processed = 0;
  const total = work.length;
  const tally = { KEEP: 0, KILL: 0, ERROR: 0, UNKNOWN: 0 };
  let tokIn = 0, tokOut = 0;
  const t0 = Date.now();

  async function worker() {
    while (true) {
      const i = idx++;
      if (i >= work.length) break;
      const item = work[i];
      const r = await auditOne(item);
      tally[r.verdict] = (tally[r.verdict] || 0) + 1;
      tokIn += r.tokens_in || 0; tokOut += r.tokens_out || 0;
      out.write(JSON.stringify(r) + '\n');
      processed++;
      if (processed % 25 === 0 || processed === total) {
        const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
        const rate = (processed / Math.max(1, (Date.now() - t0) / 1000)).toFixed(2);
        // gemini-2.0-flash: $0.10/M in, $0.40/M out
        const cost = (tokIn * 0.10 / 1e6) + (tokOut * 0.40 / 1e6);
        console.log(`  [${processed}/${total}] ${elapsed}s @ ${rate}/s — KEEP=${tally.KEEP} KILL=${tally.KILL} ERR=${tally.ERROR} | $${cost.toFixed(4)} (in=${tokIn} out=${tokOut})`);
      }
    }
  }
  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
  out.end();

  // Re-read full results (incl prior) for output lists
  const allRows = fs.readFileSync(OUT_JSONL, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
  const kill = allRows.filter(r => r.verdict === 'KILL');
  const keep = allRows.filter(r => r.verdict === 'KEEP');
  fs.writeFileSync(KILL_OUT, JSON.stringify(kill, null, 2));
  fs.writeFileSync(KEEP_OUT, JSON.stringify(keep, null, 2));
  console.log(`\n=== DONE ===`);
  console.log(`results: ${OUT_JSONL}`);
  console.log(`kill list: ${KILL_OUT} — ${kill.length} ids`);
  console.log(`keep list: ${KEEP_OUT} — ${keep.length} ids`);
  console.log(`errors: ${allRows.filter(r => r.verdict === 'ERROR').length}`);
  console.log(`total cost: $${((tokIn * 0.10 / 1e6) + (tokOut * 0.40 / 1e6)).toFixed(4)} (this run only)`);
}

main().catch(e => { console.error('FATAL:', e); process.exit(1); });