← back to Wallco Ai
T2: subject-mismatch scan + gallery — flags dropped-subject failures
e7bc7e13938161fb04a6f86057a61bb11004bc4b · 2026-05-25 02:34:18 -0700 · Steve Abrams
scripts/scan-subject-mismatch.js — vision-scans a random sample of root
designs in a category for "primary subject visibly absent" failures.
Walks meta-prompt references ("auto-variation hue+60 of design #N") up
to 4 levels via detectParentReference so the scan asks about the real
subject, not the wrapper.
lib/subject-detector.js refinements (codifying the 94% → 9% iteration):
- SUBJECT_NOUNS allowlist: curated set of recurring DW subject nouns
(animals + a few core objects). Replaces "last 3 tokens" heuristic
that emitted noise like "over the rim", "of design #N", "eyes
crossed". Pure regex, no LLM cost.
- Strips "Smart-fix/BG-swap/Fix-design/Luxe regen of #N (...): "
meta wrappers before extraction.
- detectParentReference() — caller-owned walk for hue+/recolor/auto-
variation prompts. Returns parent ID for SQL resolve.
- Defense-in-depth: extracted subjects filtered to drop pure-digit
"#N" tokens + tokens shorter than 3 chars.
Scan flagging logic — PRIMARY subject only:
Flag fires ONLY when the first-extracted subject (the animal) is
missing. Accessory items (rose, palm, bat) missing while the animal is
present = "compositional simplification", logged but not flagged.
Validated on 100 drunk-animals roots (seed=42, $0.05 spend):
✓ 73 all subjects present
~ 18 animal present, accessory missing (logged, not flagged)
⚠ 9 animal completely dropped (flagged — including the known
bullfrog failure #28066 that started this whole thread)
/api/subject-mismatch + public/subject-mismatch.html — gallery showing
every flagged design with the vision detector's "what you see" caption
side-by-side with the prompt. No publish-state changes — purely
diagnostic; Steve picks what to do with each flag.
Files touched
M lib/subject-detector.jsA public/subject-mismatch.htmlA scripts/scan-subject-mismatch.jsM server.js
Diff
commit e7bc7e13938161fb04a6f86057a61bb11004bc4b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 25 02:34:18 2026 -0700
T2: subject-mismatch scan + gallery — flags dropped-subject failures
scripts/scan-subject-mismatch.js — vision-scans a random sample of root
designs in a category for "primary subject visibly absent" failures.
Walks meta-prompt references ("auto-variation hue+60 of design #N") up
to 4 levels via detectParentReference so the scan asks about the real
subject, not the wrapper.
lib/subject-detector.js refinements (codifying the 94% → 9% iteration):
- SUBJECT_NOUNS allowlist: curated set of recurring DW subject nouns
(animals + a few core objects). Replaces "last 3 tokens" heuristic
that emitted noise like "over the rim", "of design #N", "eyes
crossed". Pure regex, no LLM cost.
- Strips "Smart-fix/BG-swap/Fix-design/Luxe regen of #N (...): "
meta wrappers before extraction.
- detectParentReference() — caller-owned walk for hue+/recolor/auto-
variation prompts. Returns parent ID for SQL resolve.
- Defense-in-depth: extracted subjects filtered to drop pure-digit
"#N" tokens + tokens shorter than 3 chars.
Scan flagging logic — PRIMARY subject only:
Flag fires ONLY when the first-extracted subject (the animal) is
missing. Accessory items (rose, palm, bat) missing while the animal is
present = "compositional simplification", logged but not flagged.
Validated on 100 drunk-animals roots (seed=42, $0.05 spend):
✓ 73 all subjects present
~ 18 animal present, accessory missing (logged, not flagged)
⚠ 9 animal completely dropped (flagged — including the known
bullfrog failure #28066 that started this whole thread)
/api/subject-mismatch + public/subject-mismatch.html — gallery showing
every flagged design with the vision detector's "what you see" caption
side-by-side with the prompt. No publish-state changes — purely
diagnostic; Steve picks what to do with each flag.
---
lib/subject-detector.js | 85 ++++++++++++++++-----
public/subject-mismatch.html | 80 ++++++++++++++++++++
scripts/scan-subject-mismatch.js | 158 +++++++++++++++++++++++++++++++++++++++
server.js | 38 ++++++++++
4 files changed, 343 insertions(+), 18 deletions(-)
diff --git a/lib/subject-detector.js b/lib/subject-detector.js
index 937a90a..1629703 100644
--- a/lib/subject-detector.js
+++ b/lib/subject-detector.js
@@ -53,31 +53,79 @@ function toBase64(input) {
return { b64: input, mime: 'image/png' };
}
-// Best-effort subject extraction from a free-text generation prompt.
-// Returns up to 3 noun phrases — the main subject(s) most likely to have
-// been dropped. Doesn't try to be perfect; callers that need precision
-// should pass an explicit subjects array.
+// 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.
//
-// Heuristic: take the first comma-delimited fragment (usually the subject
-// in wallco prompts: "pink-cheeked drunken bullfrog, champagne bottle,
-// ..."), strip qualifier adjectives, split on " and ", take the last 1-3
-// noun-ish tokens.
-function extractSubjectsFromPrompt(prompt) {
+// 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 parts = prompt.split(',').map(p => p.trim()).filter(Boolean);
+ 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.slice(0, 3)) {
+ 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;
- // Take the last 1-3 tokens (the noun phrase head)
- const tokens = cleaned.split(/\s+/).filter(t => t.length > 1);
+ const tokens = cleaned.toLowerCase().split(/[\s\-]+/).filter(t => /^[a-z]/.test(t));
if (!tokens.length) continue;
- const head = tokens.slice(-3).join(' ').toLowerCase();
- if (head.length < 3 || head.length > 60) continue;
- if (!subjects.includes(head)) subjects.push(head);
- if (subjects.length >= 3) break;
+ // 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;
+ return subjects.filter(s => !/^#?\d+$/.test(s) && s.length >= 3);
}
function buildPrompt(subjects) {
@@ -213,4 +261,5 @@ module.exports = {
analyzeSubjectPresenceStable,
gateSubjectPresence,
extractSubjectsFromPrompt,
+ detectParentReference,
};
diff --git a/public/subject-mismatch.html b/public/subject-mismatch.html
new file mode 100644
index 0000000..79e1b6d
--- /dev/null
+++ b/public/subject-mismatch.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Subject-Mismatch Gallery — dropped-subject failures</title>
+<style>
+ :root { --bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff; --border:#e8e6e1; --accent:#b3261e; --warn:#c98a00; }
+ * { box-sizing: border-box; }
+ body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; }
+ header { padding:22px 28px 16px; border-bottom:1px solid var(--border); background:var(--card); position:sticky; top:0; z-index:10; }
+ header h1 { margin:0 0 4px; font-size:20px; font-weight:600; letter-spacing:-0.01em; }
+ header h1 .badge { display:inline-block; padding:2px 8px; font-size:11px; font-weight:600; border-radius:10px; background:var(--accent); color:#fff; margin-left:8px; vertical-align:middle; }
+ header .stats { color:var(--muted); font-size:13px; }
+ header .stats b { color:var(--fg); font-weight:600; }
+ header .meta { color:var(--muted); font-size:12px; margin-top:6px; }
+ main { padding:22px 28px 64px; }
+ .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(360px, 1fr)); gap:20px; }
+ .card { background:var(--card); border:1px solid var(--border); border-radius:8px; overflow:hidden; position:relative; }
+ .card .img { aspect-ratio:1; background:#f0eee8; overflow:hidden; position:relative; }
+ .card .img img { width:100%; height:100%; object-fit:cover; display:block; }
+ .card .label { position:absolute; top:6px; left:6px; padding:3px 9px; background:var(--accent); color:#fff; font-size:11px; letter-spacing:0.06em; text-transform:uppercase; font-weight:700; border-radius:2px; }
+ .card .meta { padding:12px 14px; }
+ .card .meta .row1 { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px; }
+ .card .meta .row1 .id a { color:var(--accent); text-decoration:none; font-weight:600; font-size:13px; }
+ .card .meta .row1 .id a:hover { text-decoration:underline; }
+ .card .meta .row1 .subject { font-weight:600; color:var(--fg); font-size:13px; }
+ .card .meta .row1 .subject .missing { color:var(--accent); }
+ .card .meta .prompt { color:var(--muted); font-size:11.5px; line-height:1.4; margin:6px 0 4px; font-style:italic; }
+ .card .meta .saw { color:var(--fg); font-size:11.5px; line-height:1.4; padding:6px 8px; background:#fbf6e8; border-left:3px solid var(--warn); border-radius:0 4px 4px 0; }
+ .card .meta .saw b { color:var(--warn); }
+ .empty { padding:60px; text-align:center; color:var(--muted); }
+</style>
+</head>
+<body>
+<header>
+ <h1>Subject-Mismatch Gallery <span class="badge" id="count">…</span></h1>
+ <div class="stats">Roots flagged as <b>primary subject dropped</b> by the post-gen vision detector
+ (<code>lib/subject-detector</code>). Image shows only background/foliage — the animal/object the prompt requested never landed.</div>
+ <div class="meta">Scanned <b id="scanned">…</b> drunk-animals roots · use <code>node scripts/scan-subject-mismatch.js --limit=N --seed=S</code> to rescan.</div>
+</header>
+<main>
+ <div class="grid" id="grid"><div class="empty">Loading…</div></div>
+</main>
+<script>
+async function load() {
+ try {
+ const r = await fetch('/api/subject-mismatch');
+ const d = await r.json();
+ document.getElementById('count').textContent = d.count || 0;
+ document.getElementById('scanned').textContent = d.scanned || 0;
+ const grid = document.getElementById('grid');
+ if (!d.items || !d.items.length) {
+ grid.innerHTML = '<div class="empty">No flagged designs yet. Run <code>node scripts/scan-subject-mismatch.js</code>.</div>';
+ return;
+ }
+ grid.innerHTML = d.items.map(p => `
+ <div class="card">
+ <div class="img">
+ <span class="label">${p.primary_subject || '?'} MISSING</span>
+ <img loading="lazy" src="/designs/img/by-id/${p.root_id}?asis=1" alt="root ${p.root_id}">
+ </div>
+ <div class="meta">
+ <div class="row1">
+ <div class="id"><a href="/design/${p.root_id}?asis=1">root #${p.root_id}</a></div>
+ <div class="subject">prompt asked: <span class="missing">${(p.subjects || []).join(', ')}</span></div>
+ </div>
+ <div class="prompt">"${p.prompt_head || ''}"</div>
+ <div class="saw"><b>Vision saw:</b> ${p.what_you_see || '—'}</div>
+ </div>
+ </div>
+ `).join('');
+ } catch (e) {
+ document.getElementById('grid').innerHTML = '<div class="empty">Failed: ' + e.message + '</div>';
+ }
+}
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/scan-subject-mismatch.js b/scripts/scan-subject-mismatch.js
new file mode 100644
index 0000000..d0750ee
--- /dev/null
+++ b/scripts/scan-subject-mismatch.js
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+// scan-subject-mismatch — scan a sample of root designs for the
+// "missing subject" failure class. Uses lib/subject-detector to vision-
+// verify that every subject extracted from the generation prompt is
+// actually visible in the produced image. Append-only to
+// data/subject-mismatch.jsonl.
+//
+// Usage:
+// node scripts/scan-subject-mismatch.js [--category=drunk-animals]
+// [--limit=100] [--concurrency=4]
+// [--seed=42]
+//
+// Cost: ~$0.0005 per scan. 100 designs → $0.05.
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { analyzeSubjectPresence, extractSubjectsFromPrompt, detectParentReference } = require('../lib/subject-detector');
+
+const ARG = (k, dflt) => {
+ const a = process.argv.find(x => x.startsWith(`--${k}=`));
+ return a ? a.split('=')[1] : dflt;
+};
+
+const CATEGORY = ARG('category', 'drunk-animals');
+const LIMIT = parseInt(ARG('limit', '100'), 10);
+const CONCURRENCY = parseInt(ARG('concurrency', '4'), 10);
+const SEED = parseInt(ARG('seed', '42'), 10);
+
+const ROOT = path.join(__dirname, '..');
+const LOG = path.join(ROOT, 'data', 'subject-mismatch.jsonl');
+
+function psql(sql) {
+ const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-F|'], { input: sql, encoding: 'utf8' });
+ if (r.status !== 0) throw new Error(r.stderr || 'psql failed');
+ return r.stdout.trim();
+}
+
+// Pull <LIMIT> root designs (depth-deepest published rows) from the category
+// with a deterministic seeded shuffle so re-runs hit the same sample.
+function pickRoots() {
+ const sql = `
+ SET LOCAL seed TO ${(SEED % 1000) / 1000};
+ WITH RECURSIVE chain AS (
+ SELECT id, parent_design_id, prompt, generator, id AS leaf_id, 0 AS depth
+ FROM spoon_all_designs
+ WHERE category = '${CATEGORY.replace(/'/g, "''")}'
+ AND brand = 'wallco.ai'
+ AND is_published = TRUE
+ AND local_path IS NOT NULL
+ AND generator NOT LIKE '%luxe%'
+ UNION ALL
+ SELECT s.id, s.parent_design_id, s.prompt, s.generator, c.leaf_id, c.depth + 1
+ FROM spoon_all_designs s
+ JOIN chain c ON s.id = c.parent_design_id
+ WHERE c.depth < 10
+ ),
+ roots AS (
+ SELECT DISTINCT ON (leaf_id) leaf_id, id AS root_id, prompt AS root_prompt
+ FROM chain ORDER BY leaf_id, depth DESC
+ ),
+ deduped AS (SELECT DISTINCT root_id, root_prompt FROM roots)
+ SELECT d.root_id, s.local_path,
+ regexp_replace(d.root_prompt, E'\\n|\\r', ' ', 'g')
+ FROM deduped d
+ JOIN spoon_all_designs s ON s.id = d.root_id
+ WHERE s.local_path IS NOT NULL
+ ORDER BY random()
+ LIMIT ${LIMIT}
+ `;
+ const raw = psql(sql);
+ return raw.split('\n').filter(Boolean).map(line => {
+ const [rootId, localPath, ...promptParts] = line.split('|');
+ return {
+ rootId: parseInt(rootId, 10),
+ localPath,
+ prompt: promptParts.join('|'),
+ };
+ });
+}
+
+function logResult(entry) {
+ fs.appendFileSync(LOG, JSON.stringify(entry) + '\n');
+}
+
+(async () => {
+ const roots = pickRoots();
+ console.log(`[scan-subject-mismatch] category=${CATEGORY} sample=${roots.length} seed=${SEED} concurrency=${CONCURRENCY}`);
+ const startedAt = Date.now();
+ let i = 0, okCount = 0, errCount = 0, flagCount = 0;
+ const q = [...roots];
+
+ // Walk meta-prompt references ("auto-variation hue+60 of design #N") up
+ // to MAX_DEPTH levels to find a prompt that actually describes the subject.
+ function resolvePrompt(rootId, prompt, depth = 0) {
+ if (depth >= 4) return prompt;
+ const parent = detectParentReference(prompt);
+ if (!parent) return prompt;
+ const out = psql(`SELECT prompt FROM spoon_all_designs WHERE id=${parent}`);
+ if (!out) return prompt;
+ return resolvePrompt(parent, out, depth + 1);
+ }
+
+ async function worker(wid) {
+ while (q.length) {
+ const r = q.shift();
+ if (!r) return;
+ const idx = ++i;
+ const resolvedPrompt = resolvePrompt(r.rootId, r.prompt);
+ const subjects = extractSubjectsFromPrompt(resolvedPrompt);
+ if (!subjects.length) {
+ logResult({ ts: new Date().toISOString(), root_id: r.rootId, ok: false, err: 'no_subjects_extractable', prompt_head: (r.prompt||'').slice(0, 80) });
+ errCount++;
+ continue;
+ }
+ let result;
+ try {
+ result = await analyzeSubjectPresence(r.localPath, { subjects });
+ } catch (e) {
+ logResult({ ts: new Date().toISOString(), root_id: r.rootId, ok: false, err: e.message, subjects });
+ errCount++;
+ console.log(`[w${wid}] ${idx}/${roots.length} root=${r.rootId} ✗ ${e.message}`);
+ continue;
+ }
+ // Flag ONLY when the PRIMARY subject (first extracted) is missing.
+ // Accessory items (rose, palm, bat) missing while the animal is
+ // present = "compositional simplification", not a dropped-subject
+ // failure. Caller can still see all missing items via missing[].
+ const primarySubject = subjects[0];
+ const primaryMissing = result.missing.includes(primarySubject);
+ const flagged = primaryMissing;
+ if (flagged) flagCount++;
+ okCount++;
+ logResult({
+ ts: new Date().toISOString(),
+ root_id: r.rootId,
+ ok: true,
+ flagged,
+ primary_subject: primarySubject,
+ primary_missing: primaryMissing,
+ subjects,
+ present: result.present,
+ missing: result.missing,
+ what_you_see: result.what_you_see,
+ confidence: result.confidence,
+ prompt_head: (r.prompt||'').slice(0, 120),
+ });
+ const tag = flagged
+ ? `⚠ PRIMARY ${primarySubject} MISSING`
+ : (result.missing.length ? `~ ${primarySubject} present, accessory missing [${result.missing.join(', ')}]` : `✓ all ${subjects.length} present`);
+ console.log(`[w${wid}] ${idx}/${roots.length} root=${r.rootId} ${tag}`);
+ }
+ }
+
+ await Promise.all(Array.from({ length: CONCURRENCY }, (_, k) => worker(k + 1)));
+ const totalSec = ((Date.now() - startedAt) / 1000).toFixed(1);
+ console.log(`\n[scan-subject-mismatch] done · ok=${okCount} err=${errCount} flagged=${flagCount} (${(100*flagCount/Math.max(okCount,1)).toFixed(1)}%) · ${totalSec}s wall · cost≈$${(okCount * 0.0005).toFixed(3)} · log=${LOG}`);
+})().catch(e => { console.error('FATAL', e.stack || e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 60a8d2d..5452e31 100644
--- a/server.js
+++ b/server.js
@@ -15938,6 +15938,44 @@ function designHasImage(d) {
return false;
}
+// /api/subject-mismatch — backing data for /subject-mismatch.html.
+// Reads data/subject-mismatch.jsonl from scripts/scan-subject-mismatch.js
+// Returns only the flagged rows (primary subject visibly absent in image).
+app.get('/api/subject-mismatch', (req, res) => {
+ try {
+ const logF = path.join(__dirname, 'data', 'subject-mismatch.jsonl');
+ if (!fs.existsSync(logF)) return res.json({ count: 0, items: [], scanned: 0 });
+ const flagged = [];
+ let scanned = 0;
+ const seen = new Set();
+ for (const line of fs.readFileSync(logF, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const r = JSON.parse(line);
+ if (!r.ok) continue;
+ scanned++;
+ if (!r.flagged) continue;
+ if (seen.has(r.root_id)) continue;
+ seen.add(r.root_id);
+ flagged.push({
+ root_id: r.root_id,
+ primary_subject: r.primary_subject,
+ subjects: r.subjects,
+ present: r.present,
+ missing: r.missing,
+ what_you_see: r.what_you_see,
+ confidence: r.confidence,
+ prompt_head: r.prompt_head,
+ ts: r.ts,
+ });
+ } catch {}
+ }
+ res.json({ count: flagged.length, scanned, items: flagged });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// /api/luxe-curator — backing data for /luxe-curator.html. Reads
// data/luxe-curator-queue.jsonl + the curator_choices table to surface
// every root that has variants queued AND no decision yet. Each row =
← caabe86 lib/subject-detector — Gemini vision gate for "is the prompt
·
back to Wallco Ai
·
T3: regenerate-THIS-variant button on /luxe-curator.html 05d4109 →