← back to Wallco Ai

scripts/bleed_guard.js

288 lines

#!/usr/bin/env node
/**
 * bleed_guard.js — kills MOTIF BLEED / ghost-layer artifacts forever.
 *
 * Steve directive 2026-05-20: "fix this issue forever with an agent."
 *
 * "Bleed" here is NOT banana-leaves-where-they-shouldn't-be (that's the
 * legal settlement gate). It is the AESTHETIC bleed:
 *   • Ghost / echo / duplicate of the motif visible behind the main motif
 *   • Halftone / ben-day dots / dot screen behind motif
 *   • Atmospheric haze / watercolor wash / airbrush behind sharp motif
 *   • Sepia gradient / soft-focus background layer
 *   • Crosshatching or interior shading visible inside the motif silhouette
 *   • Layered-bleed double-exposure look
 *
 * Visual signature: motif appears doubled up — one crisp layer in front,
 * a faded/blurred duplicate offset behind it, as if a screen-print
 * registration is off. SDXL+Replicate trips this constantly.
 *
 * Usage:
 *   node scripts/bleed_guard.js                 # tick mode — last 5 min
 *   node scripts/bleed_guard.js --since="1 hour"
 *   node scripts/bleed_guard.js --last-n=50
 *   node scripts/bleed_guard.js --id=10386
 *   node scripts/bleed_guard.js --recheck       # ignore prior verdict, recheck
 *
 * Scheduled via cron every 5 min — see install-bleed-guard-cron.sh.
 */
'use strict';
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs   = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const ROOT = path.join(__dirname, '..');
const KEY  = process.env.GEMINI_API_KEY;
if (!KEY) { console.error('[bleed_guard] GEMINI_API_KEY missing — exit'); process.exit(1); }

const PSQL = (process.platform === 'linux')
  ? 'sudo -n -u postgres psql dw_unified -At -q'
  : 'psql dw_unified -At -q';
function psql(sql) {
  return execSync(PSQL, { input: sql, encoding: 'utf8' }).trim();
}

const LOG_FILE = path.join(ROOT, 'data', 'logs', 'bleed_guard.log');
const DO_NOT_WANT = path.join(ROOT, 'data', 'do-not-want.jsonl');
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });

function log(event) {
  const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
  fs.appendFileSync(LOG_FILE, line + '\n');
  console.log(line);
}

// ── CLI ──────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
function arg(flag) {
  const hit = argv.find(a => a.startsWith(`${flag}=`));
  return hit ? hit.slice(flag.length + 1) : null;
}
const ID_ONE  = arg('--id');
const LAST_N  = parseInt(arg('--last-n') || '0', 10);
const SINCE   = arg('--since') || '5 minutes';
const CATEGORY = arg('--category'); // substring match on category, e.g. drunk|monkey|stoned
const RECHECK = argv.includes('--recheck');
const INCLUDE_REMOVED = argv.includes('--include-removed');
const REVIEW_ONLY = argv.includes('--review-only'); // don't write verdicts, just report

// ── candidates ──────────────────────────────────────────────────────────
function candidates() {
  const skipPrior = RECHECK
    ? ''
    : `AND (settlement_verdict IS NULL OR settlement_verdict NOT IN ('BLEED_GHOST_BLOCK','BLEED_GHOST_OK'))`;
  if (ID_ONE) {
    return parseRows(psql(`
      SELECT id, category, COALESCE(dig_number,''), local_path, image_url
        FROM spoon_all_designs WHERE id=${parseInt(ID_ONE, 10)} LIMIT 1;
    `));
  }
  if (LAST_N > 0) {
    return parseRows(psql(`
      SELECT id, category, COALESCE(dig_number,''), local_path, image_url
        FROM spoon_all_designs
       WHERE 1=1 ${INCLUDE_REMOVED ? '' : 'AND is_published = TRUE AND user_removed = FALSE'}
         ${CATEGORY ? `AND category ~* '${CATEGORY.replace(/'/g, "''")}'` : ''}
         AND (generator IS NULL OR generator NOT LIKE 'pil-%')
         ${skipPrior}
       ORDER BY id DESC LIMIT ${LAST_N};
    `));
  }
  // 2026-05-26 exemption — PIL-derived rows (pil-mid-heal, pil-fix-design,
  // pil-fix, pil-opacity-snap) are already vetted by a deterministic
  // pipeline and DON'T look like bleed/ghost in the original sense — the
  // band-blur smoothing strip can trip Gemini's "halftone_layer" detector
  // and re-block a perfectly clean heal. Skip them at candidate time.
  return parseRows(psql(`
    SELECT id, category, COALESCE(dig_number,''), local_path, image_url
      FROM spoon_all_designs
     WHERE is_published = TRUE AND user_removed = FALSE
       AND created_at > NOW() - INTERVAL '${SINCE.replace(/'/g, "''")}'
       AND (generator IS NULL OR generator NOT LIKE 'pil-%')
       ${skipPrior}
     ORDER BY id DESC LIMIT 60;
  `));
}
function parseRows(raw) {
  return raw.split('\n').filter(Boolean).map(line => {
    const [id, category, dig, local_path, image_url] = line.split('|');
    return { id: parseInt(id, 10), category, dig, local_path, image_url };
  });
}

// ── Gemini vision: ghost-layer / motif-bleed detector ───────────────────
async function visionCheck(imgPath, category, dig) {
  const ext  = imgPath.toLowerCase().split('.').pop();
  const mime = ext === 'png' ? 'image/png' : (ext === 'webp' ? 'image/webp' : 'image/jpeg');
  const b64  = fs.readFileSync(imgPath).toString('base64');

  const prompt = `You are reviewing a wallpaper pattern for a quality defect called MOTIF BLEED.

What MOTIF BLEED looks like:
- The main motif is repeated as a faded ghost / duplicate / echo offset behind itself, like a screen-print with the registration off.
- A halftone, ben-day-dot, dot-screen, or blurred haze layer sits behind the sharp motif.
- Atmospheric haze, watercolor wash, airbrush, or sepia gradient blurs the background.
- Crosshatching or interior shading shows up INSIDE the motif silhouette that should be flat.
- A "double exposure" look — you can see two layers of the same motif at once.

What is NOT bleed (these are FINE — do not flag):
- A different motif or filler ornament in the background (e.g. small flowers behind a damask).
- A solid colored background.
- Texture that is consistent across the WHOLE image, not specifically a duplicate of the foreground motif.
- Color palette of 2-4 inks (totally fine).

Category: "${category}"
Identifier: "${dig || '(none)'}"

Return STRICT JSON only:
{
  "has_motif_ghost": <true|false — is there a faded duplicate / echo of the main motif behind itself?>,
  "has_halftone_layer": <true|false — halftone, ben-day, dot screen, or blur layer behind motif?>,
  "has_atmospheric_haze": <true|false — soft haze / wash / airbrush / sepia gradient behind motif?>,
  "has_interior_shading_bleed": <true|false — crosshatching or shading inside the silhouette that should be flat?>,
  "bleed_severity": <"none" | "subtle" | "obvious">,
  "confidence": <0.0 to 1.0>,
  "one_line_reason": "<short single sentence>"
}`;

  const body = {
    contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: mime, data: b64 } }] }],
    generationConfig: { temperature: 0, response_mime_type: 'application/json' },
  };
  const r = await fetch(
    `https://generativelanguage.googleapis.com/v1beta/models/${process.env.GEMINI_VISION_MODEL || 'gemini-2.5-flash'}:generateContent?key=${KEY}`,
    { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
  );
  if (!r.ok) {
    const txt = await r.text();
    throw new Error(`gemini ${r.status}: ${txt.slice(0, 200)}`);
  }
  const j = await r.json();
  const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';
  return JSON.parse(text);
}

// ── decision ─────────────────────────────────────────────────────────────
function decide(v) {
  const flags = [];
  if (v.has_motif_ghost)            flags.push('motif_ghost');
  if (v.has_halftone_layer)         flags.push('halftone_layer');
  if (v.has_atmospheric_haze)       flags.push('atmospheric_haze');
  if (v.has_interior_shading_bleed) flags.push('interior_shading_bleed');
  // BLOCK if severity is obvious AND at least one flag tripped,
  // OR motif_ghost is true at any subtle/obvious severity (this is the
  // exact bleed Steve called out on /design/10386 and /design/10425).
  if (v.has_motif_ghost && v.bleed_severity !== 'none') {
    return { block: true, reason: `motif ghost layer (${v.bleed_severity})`, flags };
  }
  if (v.bleed_severity === 'obvious' && flags.length > 0) {
    return { block: true, reason: `obvious bleed: ${flags.join(', ')}`, flags };
  }
  return { block: false, reason: 'clean', flags };
}

// Steve directive 2026-05-20: drunk-monkey / drunk-zoo / drunk-animals /
// stoned-animals concepts are LOVED — don't hard-delete on bleed. Instead
// unpublish + mark for regen so the prompt-system can rebuild them with
// anti-ghost language. All other categories: hard-delete as before.
const KEEP_FOR_REGEN_CATEGORIES = [
  'drunk-animals', 'drunk-animals-designer', 'drunk-zoo-36', 'stoned-animals',
];
function isKeepForRegen(category) {
  if (KEEP_FOR_REGEN_CATEGORIES.includes(category)) return true;
  const lc = String(category || '').toLowerCase();
  return lc.includes('monkey') || lc.includes('drunk') || lc.includes('stoned');
}

// ── unpublish + stamp (delete vs regen branch) ──────────────────────────
function hardDelete(designId, category, reason, verdict) {
  const keep = isKeepForRegen(category);
  const tag  = keep ? 'BLEED_GHOST_REGEN' : 'BLEED_GHOST_BLOCK';
  const safe = String(reason).replace(/'/g, "''").slice(0, 380);
  if (keep) {
    // Unpublish + flag for regen — keep the row + image around as reference.
    psql(`
      UPDATE spoon_all_designs
         SET is_published = FALSE,
             settlement_verdict = '${tag}',
             notes = COALESCE(notes, '') || ' | BLEED_GHOST_REGEN: ${safe}'
       WHERE id = ${designId};
    `);
  } else {
    // Hard delete — unpublish + user_removed + do-not-want log.
    psql(`
      UPDATE spoon_all_designs
         SET is_published = FALSE,
             settlement_verdict = '${tag}',
             user_removed = TRUE,
             notes = COALESCE(notes, '') || ' | BLEED_GHOST: ${safe}'
       WHERE id = ${designId};
    `);
    fs.appendFileSync(DO_NOT_WANT, JSON.stringify({
      ts: new Date().toISOString(),
      design_id: designId,
      category,
      reason,
      verdict,
      source: 'bleed_guard.ghost-layer',
    }) + '\n');
  }
}
function stampOk(designId) {
  psql(`UPDATE spoon_all_designs SET settlement_verdict='BLEED_GHOST_OK' WHERE id=${designId};`);
}

function resolveImg(row) {
  if (row.local_path && fs.existsSync(row.local_path)) return row.local_path;
  if (row.image_url && row.image_url.startsWith('/designs/img/')) {
    const f = path.join(ROOT, 'data', 'generated', row.image_url.replace('/designs/img/', ''));
    if (fs.existsSync(f)) return f;
  }
  return null;
}

// ── main ────────────────────────────────────────────────────────────────
(async () => {
  const rows = candidates();
  if (!rows.length) { log({ event: 'tick_done', checked: 0, blocked: 0, note: 'no candidates' }); return; }
  log({ event: 'tick_start', candidates: rows.length, mode: ID_ONE ? 'one' : (LAST_N ? `last-${LAST_N}` : `since-${SINCE}`), recheck: RECHECK });

  let checked = 0, blocked = 0, errored = 0, skipped = 0;
  for (const row of rows) {
    const img = resolveImg(row);
    if (!img) { skipped++; log({ event: 'skip', id: row.id, reason: 'image not found on disk' }); continue; }
    try {
      const verdict = await visionCheck(img, row.category, row.dig);
      const decision = decide(verdict);
      checked++;
      if (decision.block) {
        if (!REVIEW_ONLY) hardDelete(row.id, row.category, decision.reason, verdict);
        blocked++;
        const keep = isKeepForRegen(row.category);
        log({ event: REVIEW_ONLY ? 'would_block' : (keep ? 'regen_marked' : 'block'), id: row.id, category: row.category, reason: decision.reason, flags: decision.flags, severity: verdict.bleed_severity, conf: verdict.confidence });
      } else {
        if (!REVIEW_ONLY) stampOk(row.id);
        log({ event: 'ok', id: row.id, category: row.category, severity: verdict.bleed_severity });
      }
    } catch (e) {
      errored++;
      log({ event: 'error', id: row.id, err: e.message });
    }
    await new Promise(r => setTimeout(r, 250));
  }
  log({ event: 'tick_done', checked, blocked, errored, skipped });

  if (blocked > 0) {
    try {
      execSync(`python3 ${path.join(ROOT, 'scripts/refresh_designs_snapshot.py')}`, { cwd: ROOT });
      log({ event: 'designs_json_refreshed' });
    } catch (e) {
      log({ event: 'refresh_failed', err: e.message });
    }
  }
})().catch(e => {
  log({ event: 'fatal', err: e.message });
  process.exit(2);
});