← back to Wallco Ai

lib/subject-detector.js

271 lines

// subject-detector — verifies the subjects named in a generation prompt
// are actually visible in the produced image. Built to catch the broader
// class of failure where SDXL/Gemini silently drops the subject and ships
// only the background (e.g. the bullfrog-among-bluebonnets prompt that
// produced bluebonnets only, no frog — design #28066 → #41536 rescue).
//
// Sister to lib/composition-detector and lib/ghost-detector — same Gemini
// vision approach, different failure class.
//
// API:
//   analyzeSubjectPresence(input, { subjects: ['bullfrog', 'champagne bottle'] })
//     → { all_present, missing, present, confidence, what_you_see, cost_estimate }
//
//   analyzeSubjectPresenceStable(input, opts) — 3-vote majority variant
//
//   gateSubjectPresence(input, opts) — boolean gate { ok, result }
//
//   extractSubjectsFromPrompt(prompt)  — best-effort noun-phrase extractor
//     (regex-only, no LLM cost) for callers that have a raw generation
//     prompt but no curated subjects list
//
// Cost: ~$0.0005 per analyzeSubjectPresence call (one Gemini Flash vision
// call). Stable variant runs 3 in parallel, ~$0.0015.

const fs = require('fs');
const path = require('path');

// 2026-06-01: gemini-2.0-flash retired by Google (404) — migrate to 2.5-flash
// (matches settlement-post-gen-vision fix b25dd485). Override via SUBJECT_DETECTOR_MODEL.
const GEMINI_MODEL = process.env.SUBJECT_DETECTOR_MODEL || 'gemini-2.5-flash';
const GEMINI_BASE  = 'https://generativelanguage.googleapis.com/v1beta/models';

// Anti-prompt qualifiers we strip from extracted subject phrases — these
// are commonly attached to the noun in wallco generation prompts.
const QUALIFIER_STRIP = /\b(pink-cheeked|sleepy|tipsy|drunken|sloshed|wobbly|inebriated|starry-eyed|long-lashed|reticulated|plastered|stoned|silly|happy|sad|dignified|elegant|hand-painted|naturalist|cartoon|chibi)\b/gi;

function readKey() {
  if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
  try {
    const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
    const m = env.match(/^GEMINI_API_KEY=(.+)$/m);
    if (m) return m[1].trim();
  } catch {}
  throw new Error('GEMINI_API_KEY missing');
}

function toBase64(input) {
  if (Buffer.isBuffer(input)) return { b64: input.toString('base64'), mime: 'image/png' };
  if (typeof input !== 'string') throw new Error('subject-detector: input must be path|b64|buffer');
  // Only a short, on-disk string is a path. A JPEG base64 starts with "/9j/"
  // (looks like an abs path) — Nano Banana 3.1 returns C2PA-signed JPEG, which
  // the naive startsWith('/') check mis-read as a path. Length+exists guard fixes it.
  if (input.length < 4096 && fs.existsSync(input)) {
    const bytes = fs.readFileSync(input);
    const mime  = /\.png$/i.test(input) ? 'image/png' : 'image/jpeg';
    return { b64: bytes.toString('base64'), mime };
  }
  return { b64: input, mime: /^\/9j\//.test(input) ? 'image/jpeg' : 'image/png' };
}

// Best-effort PRIMARY-SUBJECT extraction. Returns up to N noun phrases,
// every one a real subject noun (animal/object) rather than a prepositional
// fragment or accessory. Callers that need precision should pass explicit
// subjects.
//
// The drunk-animals scan (2026-05-25) revealed the prior heuristic ("last
// 3 tokens of each comma-fragment") emitted noise like "over the rim",
// "of design #38491", "eyes crossed" — none real subjects. False-flag
// rate hit 94%. New heuristic: a curated allowlist of recurring DW
// subject nouns (animals + a few objects) — only fragments containing
// at least one allowlist token become subjects. If no fragment matches,
// fall back to the first fragment's last token.
const SUBJECT_NOUNS = new Set([
  // Mammals
  'frog','toad','bullfrog','sloth','lemur','orangutan','panda','elephant',
  'giraffe','peacock','macaw','parrot','octopus','squid','tiger','lion',
  'monkey','bear','rabbit','fox','wolf','deer','stag','horse','zebra',
  'cat','dog','owl','hedgehog','badger','otter','squirrel','goat','ram',
  'bat','seal','dolphin','whale','penguin','crane','heron','flamingo',
  'hippo','hippopotamus','rhino','rhinoceros','llama','alpaca','camel',
  'koala','kangaroo','wombat','possum','platypus','armadillo','sloth',
  'jaguar','leopard','cheetah','ocelot','lynx','panther','meerkat',
  // Insects/aquatic
  'butterfly','moth','beetle','dragonfly','bee','firefly','wasp','spider',
  'snake','lizard','turtle','tortoise','fish','seahorse','jellyfish',
  // Birds
  'bird','eagle','hawk','swan','duck','goose','swallow','sparrow','robin',
  'cardinal','bluebird','jay','magpie','crow','raven','toucan','cockatoo',
  // Recurring DW-specific objects
  'mushroom','cactus','palm','fern','orchid','rose','lily','tulip',
  'sunflower','poppy','daisy','peony','iris','dahlia','protea',
]);

// Prompts that REFERENCE a parent design rather than describing one
// directly. Caller should resolve to the parent's prompt before
// extracting subjects. Returns the parent design_id, else null.
function detectParentReference(prompt) {
  if (!prompt || typeof prompt !== 'string') return null;
  const m = prompt.match(/^\s*(?:auto-variation|auto-hue|hue[+\-]?\d*|rotate[+\-]?\d*|recolor|variant)\s+(?:hue[+\-]?\d+\s+)?of\s+design\s+#?(\d+)/i);
  return m ? parseInt(m[1], 10) : null;
}

function extractSubjectsFromPrompt(prompt, opts = {}) {
  if (!prompt || typeof prompt !== 'string') return [];
  const maxN = Math.max(1, parseInt(opts.max, 10) || 3);
  // Strip "Smart-fix of #XXX (...): " / "BG-swap of #XXX (...): " /
  // "Fix-design of #XXX (...): " / "Luxe regen of #XXX (...): " meta
  // wrappers that prefix many derived-design prompts in PG.
  const cleanedPrompt = prompt
    .replace(/^\s*(?:Smart-fix|BG-swap|Fix-design|Luxe regen)\s+of\s+#\d+\s*\([^)]*\)\s*[:\-—]\s*/i, '')
    .trim();
  const parts = cleanedPrompt.split(',').map(p => p.trim()).filter(Boolean);
  const subjects = [];
  for (const part of parts) {
    if (subjects.length >= maxN) break;
    const cleaned = part.replace(QUALIFIER_STRIP, '').replace(/\s+/g, ' ').trim();
    if (!cleaned || cleaned.length < 3) continue;
    const tokens = cleaned.toLowerCase().split(/[\s\-]+/).filter(t => /^[a-z]/.test(t));
    if (!tokens.length) continue;
    // Find the first allowlist noun in this fragment — that's the subject
    const hit = tokens.find(t => SUBJECT_NOUNS.has(t));
    if (hit) {
      if (!subjects.includes(hit)) subjects.push(hit);
    }
  }
  // Fallback: if NOTHING matched, take the first fragment's last
  // alphabetic token (last-token heuristic, only when no real subject found)
  if (!subjects.length && parts[0]) {
    const tokens = parts[0].replace(QUALIFIER_STRIP, '').toLowerCase()
      .split(/\s+/).filter(t => t.length > 2 && /^[a-z]+$/.test(t));
    if (tokens.length) subjects.push(tokens[tokens.length - 1]);
  }
  return subjects.filter(s => !/^#?\d+$/.test(s) && s.length >= 3);
}

function buildPrompt(subjects) {
  const list = subjects.map((s, i) => `${i + 1}. "${s}"`).join('\n');
  return `You are inspecting a WALLPAPER TILE image. Check whether each of the following SUBJECTS is visibly present in the image — not implied, not described, VISIBLY DRAWN/RENDERED.

Subjects to check:
${list}

For each subject, mark TRUE if you can point to it in the image (even if rendered abstractly, as a silhouette, or as a small repeating element). Mark FALSE if it's missing entirely or replaced by a different motif.

Reply ONLY in this JSON shape:
{
  "subjects": [
    {"name": "<subject name>", "present": true|false, "notes": "<one short phrase, max 12 words>"}
  ],
  "what_you_see": "<one sentence describing the image, max 30 words>",
  "confidence": <0.0-1.0>
}

Be strict — if a subject is named but the image shows only the BACKGROUND or a DIFFERENT subject, mark it FALSE. The image is a 24" wallpaper tile from a designer wallcovering catalog.`;
}

async function analyzeSubjectPresence(input, opts = {}) {
  const subjects = Array.isArray(opts.subjects) ? opts.subjects.filter(s => s && typeof s === 'string').slice(0, 8) : [];
  if (!subjects.length) {
    return {
      all_present: true,
      present: [],
      missing: [],
      confidence: 1.0,
      what_you_see: 'No subjects supplied — vacuously passing.',
      cost_estimate: 0,
      no_subjects: true,
    };
  }
  const KEY = readKey();
  const { b64, mime } = toBase64(input);
  const url = `${GEMINI_BASE}/${GEMINI_MODEL}:generateContent?key=${KEY}`;
  const body = {
    contents: [{ parts: [
      { text: buildPrompt(subjects) },
      { inline_data: { mime_type: mime, data: b64 } },
    ]}],
    generationConfig: {
      temperature: 0.0,
      topP: 1.0,
      topK: 1,
      candidateCount: 1,
      response_mime_type: 'application/json',
    },
  };
  const r = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!r.ok) {
    const txt = await r.text().catch(() => '');
    throw new Error(`gemini ${r.status}: ${txt.slice(0, 200)}`);
  }
  const j = await r.json();
  const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
  let parsed;
  try { parsed = JSON.parse(text); }
  catch { throw new Error(`gemini returned non-JSON: ${text.slice(0, 200)}`); }

  const items = Array.isArray(parsed.subjects) ? parsed.subjects : [];
  const present = [];
  const missing = [];
  // Match Gemini's per-subject answers back to the input list — fall back
  // to "missing" if Gemini didn't return a row for a subject we asked about
  for (const s of subjects) {
    const row = items.find(r => r && typeof r.name === 'string' && r.name.toLowerCase().includes(s.toLowerCase().slice(0, 12)));
    if (row && row.present === true) present.push(s);
    else missing.push(s);
  }
  return {
    all_present:   missing.length === 0,
    present,
    missing,
    confidence:    Number(parsed.confidence) || 0,
    what_you_see:  String(parsed.what_you_see || '').slice(0, 250),
    cost_estimate: 0.0005,
    raw_per_subject: items,
  };
}

// 3-vote stable variant — majority on per-subject present flag.
async function analyzeSubjectPresenceStable(input, opts = {}) {
  const N = 3;
  const settled = await Promise.allSettled(
    Array.from({ length: N }, () => analyzeSubjectPresence(input, opts))
  );
  const votes = settled.filter(s => s.status === 'fulfilled').map(s => s.value);
  if (!votes.length) {
    throw new Error('all 3 vision calls failed: ' + settled.map(s => s.reason?.message || '?').join(' / '));
  }
  // Per-subject majority
  const subjects = Array.isArray(opts.subjects) ? opts.subjects : [];
  const present = [];
  const missing = [];
  for (const s of subjects) {
    const yes = votes.filter(v => v.present.includes(s)).length;
    if (yes >= Math.ceil(votes.length / 2)) present.push(s);
    else missing.push(s);
  }
  const avgConf = votes.reduce((a, v) => a + (v.confidence || 0), 0) / votes.length;
  return {
    all_present: missing.length === 0,
    present, missing,
    confidence: avgConf,
    consensus:  votes.every(v => v.missing.length === missing.length && v.present.length === present.length) ? 1.0 : (votes.length - 1) / votes.length,
    votes_count: votes.length,
    errored: N - votes.length,
    what_you_see: votes[0].what_you_see,
    cost_estimate: votes.length * 0.0005,
  };
}

// Convenience gate — returns { ok, result } so callers can pattern-match
// the same way as gateComposition / gateGhost.
async function gateSubjectPresence(input, opts = {}) {
  const stable = opts.stable === true;
  const result = await (stable
    ? analyzeSubjectPresenceStable(input, opts)
    : analyzeSubjectPresence(input, opts));
  return { ok: result.all_present, result };
}

module.exports = {
  analyzeSubjectPresence,
  analyzeSubjectPresenceStable,
  gateSubjectPresence,
  extractSubjectsFromPrompt,
  detectParentReference,
};