[object Object]

← back to Wallco Ai

Inspiration pool: Replicate backend + settlement gate + placeholder thumbs

4a89b0ed6b0cf0d5dc4a962eeb8facd74e6f2fc8 · 2026-05-19 13:56:43 -0700 · SteveStudio2

- generate route forces GEN_BACKEND=replicate (DTD verdict B) so it runs
  synchronously from prod, which has no local ComfyUI
- spawnSync arg-array replaces shell-string execSync (no injection from
  LLM-authored prompt text)
- settlementScreen() backstop + settlement-aware Ollama system prompt:
  birds/butterflies/bananas/grapes/monstera/palm fronds blocked pre-gen
- response now reports blocked_by_settlement count
- pool cards with empty image_url render hsl-gradient placeholder + initial

Files touched

Diff

commit 4a89b0ed6b0cf0d5dc4a962eeb8facd74e6f2fc8
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 19 13:56:43 2026 -0700

    Inspiration pool: Replicate backend + settlement gate + placeholder thumbs
    
    - generate route forces GEN_BACKEND=replicate (DTD verdict B) so it runs
      synchronously from prod, which has no local ComfyUI
    - spawnSync arg-array replaces shell-string execSync (no injection from
      LLM-authored prompt text)
    - settlementScreen() backstop + settlement-aware Ollama system prompt:
      birds/butterflies/bananas/grapes/monstera/palm fronds blocked pre-gen
    - response now reports blocked_by_settlement count
    - pool cards with empty image_url render hsl-gradient placeholder + initial
---
 public/js/admin.js |   9 ++--
 src/admin.js       | 119 ++++++++++++++++++++++++++++++++++++++---------------
 2 files changed, 92 insertions(+), 36 deletions(-)

diff --git a/public/js/admin.js b/public/js/admin.js
index 782efe1..1b22205 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -65,12 +65,15 @@
         gen.disabled = false; gen.textContent = orig; return;
       }
       const designs = j.designs || [];
+      const made = designs.filter(d => d.id);
+      const blocked = j.blocked_by_settlement || 0;
       // Render results above grid
       const out = document.createElement('div');
       out.style.cssText = 'margin:20px 0;padding:18px;background:var(--card-bg);border:1px solid var(--gold);border-radius:8px';
-      out.innerHTML = `<h2 style="font:300 22px/1 var(--serif);margin:0 0 10px;color:var(--ink)">${designs.length} new wallco originals from your pool</h2>
+      out.innerHTML = `<h2 style="font:300 22px/1 var(--serif);margin:0 0 10px;color:var(--ink)">${made.length} new wallco originals from your pool</h2>
+        ${blocked ? `<p style="margin:0 0 10px;font-size:13px;color:#9a3b2e">⚖ ${blocked} prompt${blocked > 1 ? 's' : ''} blocked by the settlement gate (birds / foliage / prohibited motifs) — click again to retry.</p>` : ''}
         <div class="p-grid" style="margin-top:10px">
-        ${designs.filter(d => !d.error).map(d => `
+        ${made.map(d => `
           <a href="/design/${d.id}" class="p-card" style="text-decoration:none;color:inherit">
             <div class="p-img" style="background-image:url('${d.image_url}')"></div>
             <div class="p-meta">
@@ -80,7 +83,7 @@
           </a>`).join('')}
         </div>`;
       gen.after(out);
-      gen.textContent = `✓ Generated ${designs.length} · click to generate 3 more`;
+      gen.textContent = `✓ Generated ${made.length} · click to generate 3 more`;
       gen.disabled = false;
     } catch (e) {
       alert('Generation error: ' + e.message);
diff --git a/src/admin.js b/src/admin.js
index f09270a..b0ccbc8 100644
--- a/src/admin.js
+++ b/src/admin.js
@@ -17,7 +17,7 @@
  *   GET  /api/admin/vendor/:code    → JSON: that vendor's products (paginated, filterable)
  */
 'use strict';
-const { execSync } = require('child_process');
+const { execSync, spawnSync } = require('child_process');
 
 const PSQL_CMD = (process.platform === 'linux')
   ? `sudo -n -u postgres psql dw_unified -At -q`
@@ -274,15 +274,25 @@ function mount(app) {
     const items = inspirationFor(req.admin.email);
     const cards = items.length === 0
       ? '<div class="empty-state" style="padding:60px;text-align:center;color:var(--ink-faint)">No inspiration items yet. Browse vendors and click + Inspiration.</div>'
-      : items.map(i => `
+      : items.map(i => {
+          const label = i.pattern_name || i.mfr_sku || '—';
+          const hasImg = i.image_url && /^(https?:|\/)/.test(i.image_url);
+          // Deterministic hsl placeholder when the pool row has no usable image_url.
+          let hue = 0; for (const c of label) hue = (hue + c.charCodeAt(0)) % 360;
+          const imgStyle = hasImg
+            ? `background-image:url('${i.image_url}')`
+            : `background:linear-gradient(135deg,hsl(${hue} 42% 86%),hsl(${(hue + 40) % 360} 38% 72%))`;
+          const initial = (label.replace(/[^A-Za-z0-9]/g, '').charAt(0) || '·').toUpperCase();
+          return `
         <div class="p-card" data-id="${i.id}">
-          <div class="p-img" style="background-image:url('${i.image_url}')"></div>
+          <div class="p-img" style="${imgStyle}">${hasImg ? '' : `<span style="display:flex;align-items:center;justify-content:center;height:100%;font:300 38px/1 Georgia,serif;color:rgba(0,0,0,.32)">${initial}</span>`}</div>
           <div class="p-meta">
-            <div class="p-name">${i.pattern_name || i.mfr_sku || '—'}</div>
-            <div class="p-coll">${(i.vendor_code||'').replace(/_/g,' ')}</div>
+            <div class="p-name">${label}</div>
+            <div class="p-coll">${(i.vendor_code || '').replace(/_/g, ' ')}</div>
             <button class="p-remove" type="button" data-id="${i.id}">Remove</button>
           </div>
-        </div>`).join('');
+        </div>`;
+        }).join('');
     res.type('html').send(pageShell('Inspiration', `
       ${adminHeader('/admin/inspiration').replace('{email}', req.admin.email)}
       <main class="admin-main">
@@ -359,17 +369,47 @@ function mount(app) {
     catch (e) { res.status(500).json({ error: e.message }); }
   });
 
+  // ── Settlement gate (server-side backstop) ────────────────────────────
+  // Defendant-favorable keyword screen for the binding settlement's prohibited
+  // elements. The Ollama prompt-engineer step (below) is instructed to avoid
+  // these up front; this catches anything that slips through before an image
+  // is ever generated. Source of truth: ~/.claude/skills/settlement binding text.
+  const SETTLEMENT_FORBIDDEN =
+    /\b(bananas?|banana\s*(?:leaf|leaves|pods?)|grapes?|birds?|butterfl(?:y|ies)|monstera|palm\s*fronds?|toucans?|parrots?|hummingbirds?)\b/i;
+  function settlementScreen(text) {
+    const m = String(text || '').match(SETTLEMENT_FORBIDDEN);
+    return m ? { verdict: 'BLOCK', term: m[0] } : { verdict: 'OK' };
+  }
+
   // ── Generate 3 wallco-originals from the admin's inspiration pool.
-  // STRICT NO-LEAK: only pattern_name + collection name are sent to the LLM and Replicate.
-  // Vendor names are NEVER included in the prompt that's stored or sent to Replicate.
+  // STRICT NO-LEAK: only pattern_name is sent to the LLM and Replicate.
+  // Vendor names are NEVER included in the prompt that's stored or sent out.
+  // SETTLEMENT GATE: every prompt is screened pre-generation (settlementScreen).
+  // BACKEND: forced to Replicate so the route runs from prod (no local ComfyUI).
   app.post('/api/admin/inspiration/generate', gate, async (req, res) => {
     try {
       const items = inspirationFor(req.admin.email);
       if (items.length === 0) return res.status(400).json({ error: 'pool_empty' });
 
-      // 1) Ask Ollama qwen3:14b to translate the items into 3 SDXL prompts (scrubbed)
+      // 1) Ask Ollama qwen3:14b to translate the items into 3 settlement-safe SDXL prompts.
       const scrubbedList = items.slice(0, 8).map(i => `- ${i.pattern_name || 'untitled pattern'}`).join('\n');
-      const sys = `You are a wallpaper design prompt engineer. Given a short list of pattern names (vendor-anonymous), write 3 distinct SDXL prompts for *new* wallpaper designs that share the same aesthetic family but are NOT copies. Output ONLY a JSON array of 3 strings — each prompt is one paragraph ≤ 200 chars, describing motif + palette + mood + texture. Mention "seamless tile, 24-inch repeat, archival quality". Do NOT mention any vendor name or copy specific patterns.`;
+      const sys = [
+        'You are a wallpaper design prompt engineer for wallco.ai.',
+        'Given a short list of pattern names (vendor-anonymous), write 3 distinct SDXL prompts',
+        'for NEW wallpaper designs that share the aesthetic family but are NOT copies.',
+        '',
+        'HARD LEGAL CONSTRAINT — a binding settlement prohibits these; every prompt MUST avoid them:',
+        'birds, butterflies, bananas, banana leaves, banana pods, grapes, monstera leaves, palm fronds,',
+        'and directional variation among leaves/foliage. If an inspiration name implies them',
+        '(e.g. "scenic", "toile", "chinoiserie"), REINTERPRET with settlement-safe motifs instead:',
+        'peony & chrysanthemum florals, pagodas, lattice / fretwork / key-fret geometry, ginger jars',
+        '& vases, stylized clouds and mountains, edge-to-edge coverage, one dominant ink family.',
+        'Crisp opaque screen-print color — never fuzzy or layer-over-layer.',
+        '',
+        'Output ONLY a JSON array of 3 strings — each prompt one paragraph <= 220 chars,',
+        'describing motif + palette + mood + texture. Include "seamless tile, 24-inch repeat,',
+        'archival quality". Never mention a vendor name.'
+      ].join('\n');
       const userMsg = `Aesthetic inspiration (names only):\n${scrubbedList}\n\nReturn a JSON array of 3 prompts.`;
 
       const ollamaUrl = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
@@ -393,36 +433,49 @@ function mount(app) {
       }
       prompts = prompts.slice(0, 3).filter(p => typeof p === 'string' && p.length > 20);
 
-      // 2) Fire SDXL via the wallco chat tool (re-uses Replicate path + palette extract + insert)
-      const { toolGenerateDesign } = (() => {
-        // chat.js exports mount(); not the tools. Inline the path here via psql + the same generate_designs.js stub.
-        return {};
-      })();
-      // Simpler: shell out to scripts/generate_designs.js with --prompts flag for each
+      // 2) Generate via Replicate SDXL (DTD verdict B — runs from prod, no local
+      //    ComfyUI dependency). spawnSync with an arg array = no shell injection
+      //    from the LLM-authored prompt text.
+      const genScript = require('path').join(__dirname, '..', 'scripts', 'generate_designs.js');
       const results = [];
       for (const prompt of prompts) {
+        // SETTLEMENT GATE — screen before spending a Replicate call.
+        const screen = settlementScreen(prompt);
+        if (screen.verdict === 'BLOCK') {
+          results.push({ error: 'blocked_by_settlement', term: screen.term, prompt: prompt.slice(0, 140) });
+          continue;
+        }
         try {
-          const out = execSync(`node ${require('path').join(__dirname, '..', 'scripts/generate_designs.js')} --n 1 --kind seamless_tile --category mixed --prompts ${JSON.stringify(prompt)}`,
-            { encoding: 'utf8', timeout: 180_000 });
-          const idMatch = out.match(/IDs?:\s*(\d+)/);
-          if (idMatch) {
-            const id = parseInt(idMatch[1], 10);
-            const r = psql(`SELECT row_to_json(t) FROM (SELECT id, prompt, dominant_hex, local_path FROM spoon_all_designs WHERE id=${id}) t;`);
-            const row = JSON.parse(r);
-            // Attach lineage marker (admin pool inspiration). Note: prompts are scrubbed already.
-            psql(`UPDATE spoon_all_designs SET request_text='admin-pool: ' || ${esc(req.admin.email)} WHERE id=${id};`);
-            results.push({
-              id: row.id,
-              dominant_hex: row.dominant_hex,
-              prompt: row.prompt.slice(0, 200),
-              image_url: '/designs/img/' + (row.local_path || '').split('/').pop()
-            });
+          const gen = spawnSync('node',
+            [genScript, '--n', '1', '--kind', 'seamless_tile', '--category', 'mixed', '--prompts', prompt],
+            { encoding: 'utf8', timeout: 180_000, env: { ...process.env, GEN_BACKEND: 'replicate' } });
+          if (gen.status !== 0) {
+            results.push({ error: (gen.stderr || gen.stdout || 'generate_designs.js failed').slice(-200) });
+            continue;
+          }
+          const idMatch = String(gen.stdout || '').match(/IDs?:\s*(\d+)/);
+          if (!idMatch) {
+            results.push({ error: 'no_design_id_in_output' });
+            continue;
           }
+          const id = parseInt(idMatch[1], 10);
+          const r = psql(`SELECT row_to_json(t) FROM (SELECT id, prompt, dominant_hex, local_path FROM spoon_all_designs WHERE id=${id}) t;`);
+          const row = JSON.parse(r);
+          // Lineage marker — these prompts are vendor-scrubbed and settlement-screened.
+          psql(`UPDATE spoon_all_designs SET request_text='admin-pool: ' || ${esc(req.admin.email)} WHERE id=${id};`);
+          results.push({
+            id: row.id,
+            dominant_hex: row.dominant_hex,
+            prompt: (row.prompt || '').slice(0, 200),
+            image_url: '/designs/img/' + (row.local_path || '').split('/').pop()
+          });
         } catch (e) {
-          results.push({ error: e.message.slice(0, 200) });
+          results.push({ error: String(e.message || e).slice(0, 200) });
         }
       }
-      res.json({ ok: true, generated: results.length, designs: results });
+      const ok = results.filter(r => r.id).length;
+      const blocked = results.filter(r => r.error === 'blocked_by_settlement').length;
+      res.json({ ok: true, generated: ok, blocked_by_settlement: blocked, designs: results });
     } catch (e) {
       res.status(500).json({ error: e.message });
     }

← d36364c refiner-compare: all 3 columns complete — 50 ComfyUI + 25 Re  ·  back to Wallco Ai  ·  refiner-compare: dual-method new-item composer — drag-a-hous 6fa0c16 →