← 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,
};