← 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
M lib/composition-detector.jsA scripts/auto-fix-composition.jsM scripts/scan-composition.jsM server.js
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 →