[object Object]

← back to Wallco Ai

Stage 4 followup: scenic exemption + auto-fix-composition.js

0e7e4fff99ce9f90b1c0712b71a18030492db23e · 2026-05-24 22:06:53 -0700 · Steve Abrams

lib/composition-detector.js — adds SCENIC_CATEGORIES exemption mirroring
lib/ghost-detector.js. Mural categories (monterey-mural, cactus-11ft-mural,
tree-mural, designer-scenic, cactus-pine-scenic, theatrical-circus,
saguaro-arms, etc.) bypass the repeating-field gate because they're single
panoramic scenes BY DESIGN — not a defect. Without this, the catalog scan
flagged 358 valid scenic murals (~47% of the flagged list) as defects.

analyzeComposition / analyzeCompositionStable / gateComposition all accept
an optional opts.category. When the category is scenic, return a synthetic
"scenic-exempt" pass without calling Gemini ($0).

scripts/scan-composition.js — passes row.category through to the detector
so future scan passes correctly exempt scenics.

server.js — smart-fix's gateComposition call now passes d.category so the
retry loop also benefits from the exemption (won't fire 3 retries trying
to make a mural into a tile repeat).

scripts/auto-fix-composition.js — drives the fix pipeline against
data/composition-scan-flagged.jsonl. Resumable (skips ids already in
data/composition-fix-log.jsonl). Filters out:
  • scenic categories (would be no-op)
  • recolor / bgswap (downstream products — smart-fix would lose the
    color/bg-swap intent; instead, re-recolor the upstream fixed parent)
  • already-done ids from the log
  • dead source PNGs (smart-fix endpoint will 404 — just logged, no Gemini)
Throttled to concurrency=2 by default so it shares Gemini bandwidth with
the in-progress catalog scan. Each fix call goes through the live server,
which means it benefits from the V2 repeat-prompt + composition retry gate.

Logs every attempt to data/composition-fix-log.jsonl with src_id, new_id,
composition gate result, and elapsed time — so a follow-up audit can
measure what fraction of fixes converged.

Files touched

Diff

commit 0e7e4fff99ce9f90b1c0712b71a18030492db23e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 22:06:53 2026 -0700

    Stage 4 followup: scenic exemption + auto-fix-composition.js
    
    lib/composition-detector.js — adds SCENIC_CATEGORIES exemption mirroring
    lib/ghost-detector.js. Mural categories (monterey-mural, cactus-11ft-mural,
    tree-mural, designer-scenic, cactus-pine-scenic, theatrical-circus,
    saguaro-arms, etc.) bypass the repeating-field gate because they're single
    panoramic scenes BY DESIGN — not a defect. Without this, the catalog scan
    flagged 358 valid scenic murals (~47% of the flagged list) as defects.
    
    analyzeComposition / analyzeCompositionStable / gateComposition all accept
    an optional opts.category. When the category is scenic, return a synthetic
    "scenic-exempt" pass without calling Gemini ($0).
    
    scripts/scan-composition.js — passes row.category through to the detector
    so future scan passes correctly exempt scenics.
    
    server.js — smart-fix's gateComposition call now passes d.category so the
    retry loop also benefits from the exemption (won't fire 3 retries trying
    to make a mural into a tile repeat).
    
    scripts/auto-fix-composition.js — drives the fix pipeline against
    data/composition-scan-flagged.jsonl. Resumable (skips ids already in
    data/composition-fix-log.jsonl). Filters out:
      • scenic categories (would be no-op)
      • recolor / bgswap (downstream products — smart-fix would lose the
        color/bg-swap intent; instead, re-recolor the upstream fixed parent)
      • already-done ids from the log
      • dead source PNGs (smart-fix endpoint will 404 — just logged, no Gemini)
    Throttled to concurrency=2 by default so it shares Gemini bandwidth with
    the in-progress catalog scan. Each fix call goes through the live server,
    which means it benefits from the V2 repeat-prompt + composition retry gate.
    
    Logs every attempt to data/composition-fix-log.jsonl with src_id, new_id,
    composition gate result, and elapsed time — so a follow-up audit can
    measure what fraction of fixes converged.
---
 lib/composition-detector.js     |  62 ++++++++++++-
 scripts/auto-fix-composition.js | 199 ++++++++++++++++++++++++++++++++++++++++
 scripts/scan-composition.js     |   4 +-
 server.js                       |   2 +-
 4 files changed, 259 insertions(+), 8 deletions(-)

diff --git a/lib/composition-detector.js b/lib/composition-detector.js
index 13e72e1..e36d239 100644
--- a/lib/composition-detector.js
+++ b/lib/composition-detector.js
@@ -23,6 +23,26 @@ const path = require('path');
 const GEMINI_MODEL = process.env.COMPOSITION_DETECTOR_MODEL || 'gemini-2.0-flash';
 const GEMINI_BASE  = 'https://generativelanguage.googleapis.com/v1beta/models';
 
+// Scenic categories — these are MURAL designs (single panoramic scenes spanning
+// multiple panels), NOT seamless tiles. By design they show one large scene, not
+// multiple instances of a motif. Treating them as repeating-field defects produces
+// false positives. The composition-detector returns a synthetic "scenic pass"
+// for these categories without calling Gemini. Mirrors SCENIC_CATEGORIES in
+// lib/ghost-detector.js.
+const SCENIC_CATEGORIES = new Set([
+  'cactus-pine-scenic', 'cactus-11ft-mural', 'tree-mural',
+  'mural', 'scenic', 'panoramic', 'desert-flora', 'mural-animal',
+  'mural-scenic', 'designer-scenic', 'monterey-mural',
+  'theatrical-circus', 'saguaro-arms',
+]);
+
+function isScenicCategory(category) {
+  if (!category) return false;
+  // Strip "· colorway" suffix used in color-variant rows
+  const base = category.split('·')[0].trim().toLowerCase();
+  return SCENIC_CATEGORIES.has(base);
+}
+
 const PROMPT = `You are inspecting a WALLPAPER TILE image (a single tile of a repeating pattern).
 
 Distinguish between two compositions:
@@ -72,7 +92,21 @@ function toBase64(input) {
   return { b64: input, mime: 'image/png' };
 }
 
-async function analyzeComposition(input) {
+async function analyzeComposition(input, opts = {}) {
+  // Scenic murals bypass — single-scene panoramic designs are exempt from
+  // the repeating-field requirement. Caller passes `category` (e.g.
+  // 'monterey-mural', 'cactus-11ft-mural') to enable this exemption.
+  if (opts.category && isScenicCategory(opts.category)) {
+    return {
+      is_repeating_field: true,
+      instance_count:     1,
+      hero_centered:      false,
+      confidence:         1.0,
+      what_you_see:       `Scenic-category exemption (${opts.category}) — single-scene mural, not a tile defect.`,
+      cost_estimate:      0,
+      scenic_exempt:      true,
+    };
+  }
   const KEY = readKey();
   const { b64, mime } = toBase64(input);
   const url = `${GEMINI_BASE}/${GEMINI_MODEL}:generateContent?key=${KEY}`;
@@ -120,10 +154,26 @@ async function analyzeComposition(input) {
 
 // Stable variant — 3 parallel votes, majority on the binary is_repeating_field flag.
 // Returns the winner with median instance_count, mode hero_centered, mean confidence.
-async function analyzeCompositionStable(input) {
+async function analyzeCompositionStable(input, opts = {}) {
+  // Scenic exemption — same shortcut as the single-call variant
+  if (opts.category && isScenicCategory(opts.category)) {
+    return {
+      is_repeating_field: true,
+      instance_count:     1,
+      hero_centered:      false,
+      confidence:         1.0,
+      consensus:          1.0,
+      unanimous:          true,
+      votes:              [],
+      errored:            0,
+      what_you_see:       `Scenic-category exemption (${opts.category}) — single-scene mural, not a tile defect.`,
+      cost_estimate:      0,
+      scenic_exempt:      true,
+    };
+  }
   const N = 3;
   const settled = await Promise.allSettled(
-    Array.from({ length: N }, () => analyzeComposition(input))
+    Array.from({ length: N }, () => analyzeComposition(input, opts))
   );
   const votes = settled.filter(s => s.status === 'fulfilled').map(s => s.value);
   const errored = N - votes.length;
@@ -165,9 +215,11 @@ async function analyzeCompositionStable(input) {
 async function gateComposition(input, opts = {}) {
   const minInstances = opts.minInstances ?? 3;
   const stable = opts.stable === true;
-  const r = stable ? await analyzeCompositionStable(input) : await analyzeComposition(input);
+  const r = stable ? await analyzeCompositionStable(input, opts) : await analyzeComposition(input, opts);
+  // Scenic exemption always passes the gate
+  if (r.scenic_exempt) return { ok: true, result: r, reason: 'scenic-exempt' };
   const ok = !r.hero_centered && r.instance_count >= minInstances;
   return { ok, result: r, reason: ok ? 'pass' : (r.hero_centered ? `centered-hero (n=${r.instance_count})` : `too-few-instances (n=${r.instance_count}<${minInstances})`) };
 }
 
-module.exports = { analyzeComposition, analyzeCompositionStable, gateComposition, GEMINI_MODEL };
+module.exports = { analyzeComposition, analyzeCompositionStable, gateComposition, isScenicCategory, SCENIC_CATEGORIES, GEMINI_MODEL };
diff --git a/scripts/auto-fix-composition.js b/scripts/auto-fix-composition.js
new file mode 100644
index 0000000..ee64e19
--- /dev/null
+++ b/scripts/auto-fix-composition.js
@@ -0,0 +1,199 @@
+#!/usr/bin/env node
+// auto-fix-composition — iterate data/composition-scan-flagged.jsonl and
+// fire POST /api/design/:id/smart-fix on each flagged id that's safe to
+// auto-fix. Throttled, resumable, category-aware skip list.
+//
+// Safe-to-fix rules:
+//   • Source PNG must exist on disk (smart-fix needs it to detect the motif).
+//   • Category must NOT be a scenic mural (those bypass the gate entirely).
+//   • Category must NOT be 'recolor' or 'bgswap' (downstream products — fixing
+//     would lose the recolor/bg-swap intent; instead, re-recolor the upstream
+//     fixed parent).
+//   • ID must not already be in data/composition-fix-log.jsonl (resume).
+//
+// Usage:
+//   node scripts/auto-fix-composition.js                  # process all flagged
+//   node scripts/auto-fix-composition.js --limit 20       # smoke test 20
+//   node scripts/auto-fix-composition.js --concurrency 2  # parallel curls (default 2)
+//   node scripts/auto-fix-composition.js --port 9877      # server port (default 9877)
+//   node scripts/auto-fix-composition.js --dry-run        # list what WOULD fire, no curl
+//
+// Output:
+//   data/composition-fix-log.jsonl — one row per attempted fix:
+//     { src_id, new_id, ok, attempts, elapsed_s, prompt_mode, composition,
+//       verify, ts, src_category, error? }
+
+const fs = require('fs');
+const path = require('path');
+const { SCENIC_CATEGORIES } = require('../lib/composition-detector');
+
+const ARGS = process.argv.slice(2);
+const arg = (n, d) => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i + 1] : d; };
+const LIMIT       = parseInt(arg('limit', '0'), 10);
+const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '2'), 10));
+const PORT        = parseInt(arg('port', '9877'), 10);
+const DRY_RUN     = ARGS.includes('--dry-run');
+const VERBOSE     = ARGS.includes('--verbose');
+
+const ROOT = path.join(__dirname, '..');
+const FLAGGED_F = path.join(ROOT, 'data', 'composition-scan-flagged.jsonl');
+const LOG_F     = path.join(ROOT, 'data', 'composition-fix-log.jsonl');
+
+// Categories that are valid scan results but NOT safe for smart-fix.
+// Scenic murals — bypass the gate. Recolor/bgswap — downstream products.
+const SKIP_CATEGORIES = new Set([
+  'recolor', 'bgswap', 'bg-swap',
+]);
+
+function isSkipCategory(category) {
+  if (!category) return true;  // unknown → skip to be safe
+  const base = category.split('·')[0].trim().toLowerCase();
+  if (SCENIC_CATEGORIES.has(base)) return true;
+  if (SKIP_CATEGORIES.has(base)) return true;
+  return false;
+}
+
+function loadFlagged() {
+  if (!fs.existsSync(FLAGGED_F)) return [];
+  const lines = fs.readFileSync(FLAGGED_F, 'utf8').trim().split('\n');
+  const items = [];
+  const seen = new Set();
+  for (const line of lines) {
+    if (!line.trim()) continue;
+    try {
+      const r = JSON.parse(line);
+      if (!r.id || seen.has(r.id)) continue;
+      seen.add(r.id);
+      items.push(r);
+    } catch {}
+  }
+  return items;
+}
+
+function loadDone() {
+  if (!fs.existsSync(LOG_F)) return new Set();
+  const ids = new Set();
+  for (const line of fs.readFileSync(LOG_F, 'utf8').split('\n')) {
+    if (!line.trim()) continue;
+    try { ids.add(JSON.parse(line).src_id); } catch {}
+  }
+  return ids;
+}
+
+// Look up local_path via the server's design endpoint — fast, in-process
+// cache lookup, no extra psql shell-out per item.
+async function srcPngExists(id) {
+  // The /design/:id/manifest endpoint would tell us; simpler: just probe HEAD
+  // on the by-id image URL. 200/302 → exists. 404 → missing.
+  try {
+    const r = await fetch(`http://127.0.0.1:${PORT}/designs/img/by-id/${id}`, { method: 'HEAD' });
+    return r.ok;
+  } catch {
+    return false;
+  }
+}
+
+async function fireSmartFix(id) {
+  const t0 = Date.now();
+  try {
+    const r = await fetch(`http://127.0.0.1:${PORT}/api/design/${id}/smart-fix`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      // No body required for smart-fix; auto-detects motif from source.
+    });
+    const text = await r.text();
+    let j; try { j = JSON.parse(text); } catch { j = { raw: text.slice(0, 240) }; }
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    return { ...j, elapsed_local_s: elapsed, http_status: r.status };
+  } catch (e) {
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    return { error: e.message, elapsed_local_s: elapsed };
+  }
+}
+
+async function runPool(items, workers, onResult) {
+  let cursor = 0;
+  const next = () => (cursor < items.length ? items[cursor++] : null);
+  async function workerLoop() {
+    while (true) {
+      const r = next();
+      if (!r) return;
+      const out = await onResult(r);
+      // Small breath between calls per worker so we don't hammer Gemini
+      // (smart-fix already takes ~10-30s each).
+      await new Promise(res => setTimeout(res, 500));
+    }
+  }
+  await Promise.all(Array.from({ length: workers }, workerLoop));
+}
+
+(async () => {
+  console.log(`[auto-fix-composition] starting · limit=${LIMIT || 'all'} concurrency=${CONCURRENCY} port=${PORT} dry_run=${DRY_RUN}`);
+  const flagged = loadFlagged();
+  const done = loadDone();
+  console.log(`[auto-fix-composition] flagged total: ${flagged.length} · already in fix-log: ${done.size}`);
+
+  // Pre-filter: skip categories + already-done
+  const candidates = [];
+  let skipCat = 0, skipDone = 0;
+  for (const f of flagged) {
+    if (done.has(f.id)) { skipDone++; continue; }
+    if (isSkipCategory(f.category)) { skipCat++; continue; }
+    candidates.push(f);
+  }
+  // PNG-on-disk check — async per item, cheap (HEAD request to local server)
+  process.stdout.write(`[auto-fix-composition] checking PNGs on disk for ${candidates.length} candidates... `);
+  const alive = [];
+  for (const c of candidates) {
+    if (await srcPngExists(c.id)) alive.push(c);
+  }
+  console.log(`${alive.length} alive / ${candidates.length - alive.length} missing`);
+
+  const todo = LIMIT > 0 ? alive.slice(0, LIMIT) : alive;
+  console.log(`[auto-fix-composition] skipped: ${skipDone} done + ${skipCat} skip-cat + ${candidates.length - alive.length} missing-png · todo: ${todo.length}`);
+
+  if (DRY_RUN) {
+    console.log('\n[dry-run] would fire smart-fix on:');
+    for (const c of todo.slice(0, 30)) {
+      console.log(`  ${c.id.toString().padStart(5)} · ${c.category}`);
+    }
+    if (todo.length > 30) console.log(`  ... + ${todo.length - 30} more`);
+    return;
+  }
+
+  let n = 0, ok = 0, errored = 0;
+  const t0 = Date.now();
+  await runPool(todo, CONCURRENCY, async (c) => {
+    n++;
+    const t1 = Date.now();
+    const r = await fireSmartFix(c.id);
+    const elapsed = ((Date.now() - t1) / 1000).toFixed(1);
+    const row = {
+      ts: new Date().toISOString(),
+      src_id: c.id,
+      src_category: c.category,
+      new_id: r.new_id || null,
+      ok: !!r.ok,
+      attempts: r.attempts || null,
+      elapsed_s: r.elapsed_s || elapsed,
+      http_status: r.http_status,
+      prompt_mode: r.prompt_mode,
+      composition: r.composition || null,
+      verify: r.verify || null,
+      error: r.error || null,
+    };
+    fs.appendFileSync(LOG_F, JSON.stringify(row) + '\n');
+    if (row.ok && row.new_id) ok++; else errored++;
+    if (VERBOSE || n % 5 === 0 || n === todo.length) {
+      const rate = n / ((Date.now() - t0) / 1000);
+      const eta = ((todo.length - n) / Math.max(0.001, rate)).toFixed(0);
+      const flag = row.ok ? '✓' : '✗';
+      const compInfo = row.composition
+        ? `n=${row.composition.instance_count} hero=${row.composition.hero_centered}`
+        : (row.error ? `ERR: ${row.error.slice(0, 60)}` : 'no-comp');
+      console.log(`[auto-fix] ${flag} ${n}/${todo.length} src=${c.id} → new=${row.new_id || '-'} (${row.elapsed_s}s, att=${row.attempts}, ${compInfo}) · rate=${rate.toFixed(2)}/s ETA=${eta}s`);
+    }
+  });
+  console.log(`\n[auto-fix-composition] done · processed=${n} ok=${ok} errored=${errored}`);
+  console.log(`[auto-fix-composition] log: ${LOG_F}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/scan-composition.js b/scripts/scan-composition.js
index 6cd12cd..56c2753 100644
--- a/scripts/scan-composition.js
+++ b/scripts/scan-composition.js
@@ -145,8 +145,8 @@ async function processOne(row) {
   }
   try {
     const r = STABLE
-      ? await analyzeCompositionStable(row.local_path)
-      : await analyzeComposition(row.local_path);
+      ? await analyzeCompositionStable(row.local_path, { category: row.category })
+      : await analyzeComposition(row.local_path, { category: row.category });
     return {
       id: row.id, ts: new Date().toISOString(),
       is_repeating_field: r.is_repeating_field,
diff --git a/server.js b/server.js
index 5875dd1..2e88a13 100644
--- a/server.js
+++ b/server.js
@@ -7800,7 +7800,7 @@ RETURNING id`);
       let compositionOk = true;
       if (opacityOk && COMP_GATE_ENABLED) {
         try {
-          const g = await gateComposition(candidate, { minInstances: 3 });
+          const g = await gateComposition(candidate, { minInstances: 3, category: d.category });
           lastComposition = g.result;
           compositionOk = g.ok;
           if (!g.ok) console.warn(`[smart-fix] composition fail att${attempts}: ${g.reason} — retrying`);

← 5138c6e remove broken /admin/fixes-feed viewer  ·  back to Wallco Ai  ·  auto-fix: decouple ?legacy routing from prompt-version; fix 352e9ce →