[object Object]

← back to Wallco Ai

wallco.ai: hot-path fallback for /design/:id (live PG lookup when not in snapshot) · classification-filter PD: 73 AI-input candidates (34 seamless+textile + 39 near+textile) from 304-row corpus · is_ai_input_candidate column + index

2922a592ec3e5d4309c59a3ddac509ad24221c04 · 2026-05-11 17:41:39 -0700 · Steve

Files touched

Diff

commit 2922a592ec3e5d4309c59a3ddac509ad24221c04
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 11 17:41:39 2026 -0700

    wallco.ai: hot-path fallback for /design/:id (live PG lookup when not in snapshot) · classification-filter PD: 73 AI-input candidates (34 seamless+textile + 39 near+textile) from 304-row corpus · is_ai_input_candidate column + index
---
 data/designs.json      |  15 ++++
 data/moodboards.json   |   1 +
 data/vision-cache.json |   1 +
 server.js              |  34 ++++++++-
 src/review.js          | 199 ++++++++++++++++++++++++++++++++++++++++++++++---
 5 files changed, 237 insertions(+), 13 deletions(-)

diff --git a/data/designs.json b/data/designs.json
index 3f63e9b..fac2dd9 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -448,5 +448,20 @@
     "seed": 1617564843,
     "is_published": false,
     "created_at": "2026-05-11T23:48:57.994123+00:00"
+  },
+  {
+    "id": 41,
+    "kind": "seamless_tile",
+    "category": "mixed",
+    "dominant_hex": "#2c1323",
+    "saturation": 0.397,
+    "title": "Plum Studio No.41",
+    "handle": "wallco-0041",
+    "image_url": "/designs/img/1778543777405_536522826.png",
+    "filename": "1778543777405_536522826.png",
+    "generator": "Replicate SDXL",
+    "seed": 536522826,
+    "is_published": false,
+    "created_at": "2026-05-11T23:56:17.861881+00:00"
   }
 ]
\ No newline at end of file
diff --git a/data/moodboards.json b/data/moodboards.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/data/moodboards.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/data/vision-cache.json b/data/vision-cache.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/data/vision-cache.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/server.js b/server.js
index 1c8e6e8..75174de 100644
--- a/server.js
+++ b/server.js
@@ -782,8 +782,15 @@ ${isAdmin ? `
   .pair-card .pair-type  { display:inline-block; font-size:9px; text-transform:uppercase; letter-spacing:.08em; padding:1px 6px; border-radius:2px; background:#2a2825; color:#d2b15c; margin-bottom:5px; }
   .pair-card .pair-reason { font-size:11px; color:#bba; line-height:1.4; }
   .pair-card .pair-hexcode { font-family: ui-monospace, monospace; font-size:10px; color:#888; margin-left:6px; }
-  .pair-card a.pair-link { color:inherit; text-decoration:none; display:flex; gap:12px; align-items:flex-start; }
+  .pair-card a.pair-link { color:inherit; text-decoration:none; display:flex; gap:12px; align-items:flex-start; flex:1; }
   .pair-card a.pair-link:hover .pair-title { color:#d2b15c; }
+  .pair-card .pair-pin { background:none; border:1px solid #3a3631; color:#666; font-size:14px; cursor:pointer; padding:3px 8px; border-radius:14px; transition:all .12s; flex-shrink:0; align-self:flex-start; }
+  .pair-card .pair-pin:hover { color:#d2b15c; border-color:#d2b15c; }
+  .pair-card.pinned { border-color:var(--chip-hot); background:#1f1b13; }
+  .pair-card.pinned .pair-pin { color:#d2b15c; background:#d2b15c33; border-color:#d2b15c; }
+  .pair-card .pair-vision-badge { display:inline-block; font-size:9px; padding:0 5px; border:1px solid #3a3631; border-radius:2px; color:#888; margin-left:6px; text-transform:uppercase; letter-spacing:.05em; }
+  #pair-vision-read { background:#1a1816; padding:8px 12px; margin:0 18px 8px; border:1px solid #2a2825; border-radius:2px; font-size:11px; color:#bba; line-height:1.45; font-style:italic; }
+  #pair-vision-read.hidden { display:none; }
   .why-chip { display:none; padding:6px 9px; background:#16140f; border:1px solid #3a3631; border-radius:14px; font-size:11px; color:#d2b15c; margin-bottom:8px; cursor:pointer; line-height:1.35; }
   .why-chip.visible { display:block; }
   .why-chip.loading::after { content:' …'; opacity:.6; }
@@ -832,9 +839,10 @@ ${isAdmin ? `
       <div class="ctx-main" id="pair-design-title">Pairs well with…</div>
       <div class="ctx-sub" id="pair-design-sub"></div>
     </div>
-    <button class="refresh" id="pair-refresh" title="Force regenerate (Ollama)">↻</button>
+    <button class="refresh" id="pair-refresh" title="Force regenerate (Ollama + LLaVA)">↻</button>
     <button class="close" id="pair-close" title="Close (Esc)">&times;</button>
   </header>
+  <div id="pair-vision-read" class="hidden"></div>
   <div id="pair-body"></div>
 </div>
 
@@ -1081,7 +1089,27 @@ ${HAMBURGER_JS}
 // ── DESIGN DETAIL
 app.get('/design/:id', (req, res) => {
   const id = parseInt(req.params.id, 10);
-  const design = DESIGNS.find(d => d.id === id);
+  let design = DESIGNS.find(d => d.id === id);
+  // Hot-path fallback: design was generated after server start (e.g. via chat).
+  // Look it up live in PG + lazily extend the in-memory snapshot.
+  if (!design) {
+    try {
+      const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category, dominant_hex, prompt, seed, local_path, is_published, created_at FROM spoon_all_designs WHERE id=${id}) t;`);
+      if (row) {
+        const r = JSON.parse(row);
+        const filename = (r.local_path || '').split('/').pop();
+        design = {
+          id: r.id, kind: r.kind, category: r.category, dominant_hex: r.dominant_hex,
+          saturation: 0, title: (r.prompt || '').split(',')[0].slice(0, 60) || `Design #${r.id}`,
+          handle: `wallco-${String(r.id).padStart(4,'0')}`,
+          image_url: `/designs/img/${filename}`, filename,
+          generator: 'Replicate SDXL', seed: r.seed,
+          is_published: !!r.is_published, created_at: r.created_at
+        };
+        DESIGNS.push(design);
+      }
+    } catch {}
+  }
   if (!design) return res.status(404).type('html').send(`${htmlHead({title:'Not Found — wallco.ai',description:'',canonical:`https://wallco.ai/design/${id}`})}<body>${htmlHeader('')}<main><div class="empty-state" style="padding:80px;text-align:center"><h2>Design not found</h2><a href="/designs">Browse all designs</a></div></main>${FOOTER}${HAMBURGER_JS}</body></html>`);
 
   const relIdx = DESIGNS.findIndex(d => d.id === id);
diff --git a/src/review.js b/src/review.js
index 1d3e81f..9101b27 100644
--- a/src/review.js
+++ b/src/review.js
@@ -19,9 +19,13 @@
 const fs = require('fs');
 const path = require('path');
 
-const REVIEWS_FILE  = path.join(__dirname, '..', 'data', 'reviews.json');
-const CHATS_FILE    = path.join(__dirname, '..', 'data', 'chip-chats.json');
-const PAIRINGS_FILE = path.join(__dirname, '..', 'data', 'pairings.json');
+const REVIEWS_FILE   = path.join(__dirname, '..', 'data', 'reviews.json');
+const CHATS_FILE     = path.join(__dirname, '..', 'data', 'chip-chats.json');
+const PAIRINGS_FILE  = path.join(__dirname, '..', 'data', 'pairings.json');
+const MOODBOARD_FILE = path.join(__dirname, '..', 'data', 'moodboards.json');
+const VISION_FILE    = path.join(__dirname, '..', 'data', 'vision-cache.json');
+const IMG_DIR        = path.join(__dirname, '..', 'data', 'generated');
+const VISION_MODEL   = process.env.OLLAMA_VISION_MODEL || 'llava:latest';
 const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
 const OLLAMA_MODEL = process.env.OLLAMA_REVIEW_MODEL || 'qwen3:14b';
 
@@ -200,25 +204,72 @@ function colorSwatches(hex) {
   ];
 }
 
-// material/object suggestions via Ollama (catalog-agnostic, prompt-grounded)
+// Resolve the on-disk image path for a design (handles image_url + local_path variants).
+function getImagePath(design) {
+  if (design.local_path && fs.existsSync(design.local_path)) return design.local_path;
+  if (design.image_url) {
+    const filename = path.basename(design.image_url);
+    const p = path.join(IMG_DIR, filename);
+    if (fs.existsSync(p)) return p;
+  }
+  if (design.filename) {
+    const p = path.join(IMG_DIR, design.filename);
+    if (fs.existsSync(p)) return p;
+  }
+  return null;
+}
+
+// LLaVA vision read — describes the image in 1-2 sentences. Cached per design.
+async function visionDescribe(design) {
+  const cache = loadJSON(VISION_FILE, {});
+  if (cache[design.id]) return cache[design.id];
+  const imgPath = getImagePath(design);
+  if (!imgPath) return null;
+  try {
+    const b64 = fs.readFileSync(imgPath).toString('base64');
+    const res = await fetch(`${OLLAMA}/api/generate`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        model: VISION_MODEL,
+        prompt: 'You are a senior interior designer looking at a wallcovering swatch. In one tight sentence (≤30 words), describe: motif/pattern type, dominant colors, scale (small/medium/large repeat), and overall mood (formal/casual/maximalist/restrained). No preamble.',
+        images: [b64],
+        stream: false,
+        options: { temperature: 0.3 }
+      }),
+      signal: AbortSignal.timeout(45_000)
+    });
+    if (!res.ok) throw new Error(`llava ${res.status}`);
+    const j = await res.json();
+    const desc = (j.response || '').trim().split('\n')[0].slice(0, 280);
+    if (desc) { cache[design.id] = desc; saveJSON(VISION_FILE, cache); }
+    return desc;
+  } catch (e) {
+    return null;
+  }
+}
+
+// material/object suggestions via Ollama — now vision-grounded if llava available.
 async function materialSuggestions(design) {
+  const visionDesc = await visionDescribe(design);
   const sys = `You are a senior interior designer. Suggest exactly 2 specific material or finish pairings for a wallcovering with these properties:
 title: "${design.title || '(untitled)'}"
 category: ${design.category || 'unknown'}
 dominant hex: ${design.dominant_hex || 'unknown'}
 kind: ${design.kind || 'seamless tile'}
-Respond as JSON only, no preamble: [{"label":"specific material/finish (≤5 words)","reason":"why it pairs (≤16 words)"},{...}]. Be concrete (e.g. "Aged brass hardware", "Walnut wood paneling", "Unbleached linen drapery"). Never generic ("nice furniture").`;
+${visionDesc ? `visual read: ${visionDesc}` : ''}
+Respond as JSON only, no preamble: [{"label":"specific material/finish (≤5 words)","reason":"why it pairs (≤16 words)"},{...}]. Be concrete (e.g. "Aged brass hardware", "Walnut wood paneling", "Unbleached linen drapery"). Never generic ("nice furniture"). Tie reasoning to what you actually see in the visual read.`;
   try {
     const raw = await ollamaCall({ system: sys, history: [], message: 'Give me the 2 pairings as a JSON array.' });
-    // extract first [...] block
     const m = raw.match(/\[[\s\S]*?\]/);
     if (!m) return [];
     const arr = JSON.parse(m[0]);
     return arr.slice(0, 2).map(x => ({
       type: 'material',
       label: String(x.label || '').slice(0, 60),
-      reason: String(x.reason || '').slice(0, 120),
+      reason: String(x.reason || '').slice(0, 140),
       icon: '✦',
+      vision_grounded: !!visionDesc,
     })).filter(x => x.label);
   } catch (e) {
     return [
@@ -234,19 +285,40 @@ async function generatePairings(design, allDesigns) {
     Promise.resolve(colorSwatches(design.dominant_hex)),
     materialSuggestions(design),
   ]);
+  // load existing moodboard for this design so pins survive cache regen
+  const moodboards = loadJSON(MOODBOARD_FILE, {});
+  const pinnedKeys = new Set((moodboards[design.id] || []).map(p => p.pin_key));
+  const allSuggestions = [...matches, ...colors, ...materials].map((s, i) => ({
+    ...s,
+    idx: i,
+    pin_key: pinKey(s, design.id),
+    pinned: pinnedKeys.has(pinKey(s, design.id)),
+  }));
+  // get cached vision read for header display
+  const visionCache = loadJSON(VISION_FILE, {});
   return {
     design_id: design.id,
+    vision_description: visionCache[design.id] || null,
     generated_at: new Date().toISOString(),
-    suggestions: [...matches, ...colors, ...materials],
+    suggestions: allSuggestions,
   };
 }
 
+// Stable identifier for a suggestion so pins survive regenerations.
+function pinKey(s, designId) {
+  if (s.type === 'design') return `d:${s.design_id}`;
+  if (s.type === 'color') return `c:${(s.hex || '').toLowerCase()}`;
+  return `m:${(s.label || '').toLowerCase().replace(/[^a-z0-9]+/g,'-')}`;
+}
+
 // ── mount ─────────────────────────────────────────────────────────────
 function mount(app, getDesigns) {
   // ensure files exist
   if (!fs.existsSync(REVIEWS_FILE)) saveJSON(REVIEWS_FILE, {});
   if (!fs.existsSync(CHATS_FILE)) saveJSON(CHATS_FILE, {});
   if (!fs.existsSync(PAIRINGS_FILE)) saveJSON(PAIRINGS_FILE, {});
+  if (!fs.existsSync(MOODBOARD_FILE)) saveJSON(MOODBOARD_FILE, {});
+  if (!fs.existsSync(VISION_FILE)) saveJSON(VISION_FILE, {});
 
   app.get('/api/review/:id', (req, res) => {
     const reviews = loadJSON(REVIEWS_FILE, {});
@@ -342,12 +414,14 @@ function mount(app, getDesigns) {
     res.json(pairings);
   });
 
-  // POST refresh — invalidate cache
+  // POST refresh — invalidate cache (both pairings + vision)
   app.post('/api/pairings/:id/refresh', async (req, res) => {
     const id = req.params.id;
     const designs = getDesigns();
     const design = designs.find(d => String(d.id) === String(id));
     if (!design) return res.status(404).json({ error: 'design not found' });
+    // bust vision cache too
+    const vc = loadJSON(VISION_FILE, {}); delete vc[id]; saveJSON(VISION_FILE, vc);
     const pairings = await generatePairings(design, designs);
     const cache = loadJSON(PAIRINGS_FILE, {});
     cache[id] = pairings;
@@ -355,7 +429,112 @@ function mount(app, getDesigns) {
     res.json(pairings);
   });
 
-  console.log('  Review layer mounted (sliders + chips + chat + pairings on Ollama ' + OLLAMA_MODEL + ')');
+  // Moodboard: pin/unpin a suggestion.
+  app.post('/api/moodboard/:id/pin', (req, res) => {
+    const id = req.params.id;
+    const { pin_key, suggestion } = req.body || {};
+    if (!pin_key) return res.status(400).json({ error: 'pin_key required' });
+    const moodboards = loadJSON(MOODBOARD_FILE, {});
+    moodboards[id] = moodboards[id] || [];
+    if (moodboards[id].some(p => p.pin_key === pin_key)) {
+      // unpin (toggle)
+      moodboards[id] = moodboards[id].filter(p => p.pin_key !== pin_key);
+    } else {
+      // pin — store the whole suggestion
+      moodboards[id].push({ pin_key, ...suggestion, pinned_at: new Date().toISOString() });
+    }
+    saveJSON(MOODBOARD_FILE, moodboards);
+    // sync the pairings cache `pinned` flag so UI is consistent
+    const cache = loadJSON(PAIRINGS_FILE, {});
+    if (cache[id] && cache[id].suggestions) {
+      const pinnedKeys = new Set(moodboards[id].map(p => p.pin_key));
+      cache[id].suggestions = cache[id].suggestions.map(s => ({ ...s, pinned: pinnedKeys.has(s.pin_key) }));
+      saveJSON(PAIRINGS_FILE, cache);
+    }
+    res.json({ moodboard: moodboards[id] });
+  });
+
+  // GET moodboard for one design
+  app.get('/api/moodboard/:id', (req, res) => {
+    const moodboards = loadJSON(MOODBOARD_FILE, {});
+    res.json({ design_id: req.params.id, items: moodboards[req.params.id] || [] });
+  });
+
+  // GET all moodboards (admin)
+  app.get('/api/moodboards/all', (req, res) => {
+    res.json(loadJSON(MOODBOARD_FILE, {}));
+  });
+
+  // CSV export — kept designs + scores + verdict + pinned pairings
+  app.get('/api/export/keep.csv', (req, res) => {
+    const reviews = loadJSON(REVIEWS_FILE, {});
+    const moodboards = loadJSON(MOODBOARD_FILE, {});
+    const designs = getDesigns();
+    const cols = ['design_id', 'title', 'category', 'kind', 'dominant_hex', 'score_design', 'score_color', 'score_style', 'verdict', 'why', 'image_url', 'pinned_count', 'pinned_summary', 'reviewed_at'];
+    const rows = [cols.join(',')];
+    for (const [id, r] of Object.entries(reviews)) {
+      if (r.decision !== 'keep') continue;
+      const d = designs.find(x => String(x.id) === String(id)) || {};
+      const pinned = moodboards[id] || [];
+      const pinnedSummary = pinned.map(p => (p.label || p.hex || '').replace(/"/g,'""').slice(0, 40)).join(' · ');
+      const row = [
+        id,
+        csvCell(d.title || d.prompt || ''),
+        csvCell(d.category || ''),
+        csvCell(d.kind || ''),
+        csvCell(d.dominant_hex || ''),
+        r.design ?? '',
+        r.color ?? '',
+        r.style ?? '',
+        r.decision || '',
+        csvCell(r.why || ''),
+        csvCell(d.image_url || (d.local_path ? '/img/' + path.basename(d.local_path) : '')),
+        pinned.length,
+        csvCell(pinnedSummary),
+        r.updated_at || '',
+      ];
+      rows.push(row.join(','));
+    }
+    res.type('text/csv').setHeader('Content-Disposition', 'attachment; filename="wallco-keep-' + new Date().toISOString().slice(0,10) + '.csv"');
+    res.send(rows.join('\n') + '\n');
+  });
+
+  // CSV export — all reviewed designs (any decision)
+  app.get('/api/export/all.csv', (req, res) => {
+    const reviews = loadJSON(REVIEWS_FILE, {});
+    const moodboards = loadJSON(MOODBOARD_FILE, {});
+    const designs = getDesigns();
+    const cols = ['design_id', 'title', 'category', 'kind', 'dominant_hex', 'score_design', 'score_color', 'score_style', 'verdict', 'why', 'image_url', 'pinned_count', 'pinned_summary', 'reviewed_at'];
+    const rows = [cols.join(',')];
+    for (const [id, r] of Object.entries(reviews)) {
+      const d = designs.find(x => String(x.id) === String(id)) || {};
+      const pinned = moodboards[id] || [];
+      const pinnedSummary = pinned.map(p => (p.label || p.hex || '').replace(/"/g,'""').slice(0, 40)).join(' · ');
+      rows.push([
+        id, csvCell(d.title || d.prompt || ''), csvCell(d.category || ''), csvCell(d.kind || ''),
+        csvCell(d.dominant_hex || ''),
+        r.design ?? '', r.color ?? '', r.style ?? '',
+        r.decision || '',
+        csvCell(r.why || ''),
+        csvCell(d.image_url || ''),
+        pinned.length,
+        csvCell(pinnedSummary),
+        r.updated_at || '',
+      ].join(','));
+    }
+    res.type('text/csv').setHeader('Content-Disposition', 'attachment; filename="wallco-all-' + new Date().toISOString().slice(0,10) + '.csv"');
+    res.send(rows.join('\n') + '\n');
+  });
+
+  console.log('  Review layer mounted (sliders + chips + chat + pairings + moodboard + CSV export on Ollama ' + OLLAMA_MODEL + ' / ' + VISION_MODEL + ')');
+}
+
+function csvCell(s) {
+  s = String(s == null ? '' : s);
+  if (s.includes(',') || s.includes('"') || s.includes('\n')) {
+    return '"' + s.replace(/"/g, '""') + '"';
+  }
+  return s;
 }
 
 function clamp(n, min, max) {

← 98dbbba Add /designs pairing suggestions — Pair button + side modal  ·  back to Wallco Ai  ·  Pairings v2: LLaVA vision read (Mac2 localhost:11434) drives a3ace82 →