[object Object]

← 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

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 →