[object Object]

← back to Wallco Ai

Concierge search reads in-memory designs.json cache, not Postgres

3a7458b3ea4a2076d54f38bbf2f0111289100c36 · 2026-06-23 11:43:31 -0700 · Steve

toolSearchDesigns now searches the same DESIGNS array the public /api/designs
grid renders (data/designs.json snapshot) instead of querying spoon_all_designs.
On prod the dw_unified DB is near-empty (~350 rows) and is NOT the catalog
source — the ~27k-row designs.json snapshot is — so the PG-query concierge
returned near-empty on prod while the grid showed the full catalog. This makes
search return the same catalog the user can see, identically on local and prod.

- server.js: pass { getDesigns: () => DESIGNS } into chat.mount (matches the
  existing review.js/library.js getter convention; reflects /admin/reload-designs).
- src/chat.js: mount(app, opts) captures getDesigns; toolSearchDesigns filters
  the cached array (is_published && !user_removed), soft category/motif keyword
  match over category+title+motifs+tags (arrays or strings), hue/hex_near color
  filters with empty-cone keyword fallback, uses each record's own image_url,
  same {id,title,category,hex,image_url} return shape. GENERATE path + the
  /api/chat/design/:id PG lookup left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3a7458b3ea4a2076d54f38bbf2f0111289100c36
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 23 11:43:31 2026 -0700

    Concierge search reads in-memory designs.json cache, not Postgres
    
    toolSearchDesigns now searches the same DESIGNS array the public /api/designs
    grid renders (data/designs.json snapshot) instead of querying spoon_all_designs.
    On prod the dw_unified DB is near-empty (~350 rows) and is NOT the catalog
    source — the ~27k-row designs.json snapshot is — so the PG-query concierge
    returned near-empty on prod while the grid showed the full catalog. This makes
    search return the same catalog the user can see, identically on local and prod.
    
    - server.js: pass { getDesigns: () => DESIGNS } into chat.mount (matches the
      existing review.js/library.js getter convention; reflects /admin/reload-designs).
    - src/chat.js: mount(app, opts) captures getDesigns; toolSearchDesigns filters
      the cached array (is_published && !user_removed), soft category/motif keyword
      match over category+title+motifs+tags (arrays or strings), hue/hex_near color
      filters with empty-cone keyword fallback, uses each record's own image_url,
      same {id,title,category,hex,image_url} return shape. GENERATE path + the
      /api/chat/design/:id PG lookup left untouched.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js   |  2 +-
 src/chat.js | 84 +++++++++++++++++++++++++++++++++++--------------------------
 2 files changed, 50 insertions(+), 36 deletions(-)

diff --git a/server.js b/server.js
index a1fbc99..b2ece7e 100644
--- a/server.js
+++ b/server.js
@@ -110,7 +110,7 @@ app.use(express.json({ limit: '25mb' }));  // 25mb to accept full-res baked PNGs
 app.use(express.urlencoded({ extended: false }));
 
 // ── Chat layer: catalog + per-design variation
-try { require('./src/chat').mount(app); console.log('  Chat layer mounted'); } catch (e) { console.error('Chat mount failed:', e.message); }
+try { require('./src/chat').mount(app, { getDesigns: () => DESIGNS }); console.log('  Chat layer mounted'); } catch (e) { console.error('Chat mount failed:', e.message); }
 
 // ── Review layer: sliders + chips + chip-chat on /designs (admin-gated by hostname/query)
 try { require('./src/review').mount(app, () => DESIGNS); } catch (e) { console.error('Review mount failed:', e.message); }
diff --git a/src/chat.js b/src/chat.js
index 2aecb34..826ef74 100644
--- a/src/chat.js
+++ b/src/chat.js
@@ -102,36 +102,52 @@ function extractToolCall(text) {
 
 // -------- Tool implementations --------
 
+// `getDesigns` is injected by server.js mount() — a getter returning the live
+// in-memory DESIGNS array (loaded from data/designs.json, hot-reloadable via
+// /admin/reload-designs). Default to an empty array if the mount didn't wire it.
+let _getDesigns = () => [];
+
+// Build a lowercase haystack of the searchable text fields for a cached design.
+// designs.json has NO `prompt` column — the searchable text is category + title
+// + motifs + tags (motifs/tags may be arrays OR strings, so coerce both).
+function designText(d) {
+  const parts = [d.category, d.title];
+  for (const k of ['motifs', 'tags']) {
+    const v = d[k];
+    if (Array.isArray(v)) parts.push(v.join(' '));
+    else if (typeof v === 'string') parts.push(v);
+  }
+  return parts.filter(Boolean).join(' ').toLowerCase();
+}
+
 function toolSearchDesigns({ category, hue, hex_near, motif_kw, limit = 12 } = {}) {
-  // Scope to the LIVE wallco.ai catalog only — the same brand + publish gate the
-  // public /api/designs grid uses. The previous `generator='replicate'` filter
-  // was the primary bug: it excluded ~84% of the published catalog (incl. every
-  // 'comfy' / 'gemini-*' / 'wallpapersback.com' row and EVERY damask row), so the
-  // concierge returned 0 results for every query while still answering HTTP 200.
-  const where = [`brand='wallco.ai'`, `is_published = TRUE`];
-  // The concierge's category vocabulary (floral|geometric|damask|mixed) does NOT
-  // match the catalog's real taxonomy (drunk-animals, chinoiserie, stripe, …), so
-  // an exact `category=` match also always yielded 0. Treat category as a SOFT
-  // keyword: match the category column OR the prompt text.
-  if (category) where.push(`(category ILIKE ${esc('%' + category + '%')} OR prompt ILIKE ${esc('%' + category + '%')})`);
-  if (motif_kw) where.push(`prompt ILIKE ${esc('%' + motif_kw + '%')}`);
+  // Search the SAME in-memory catalog the public /api/designs grid renders
+  // (data/designs.json → server.js DESIGNS), NOT Postgres. On prod the
+  // dw_unified DB is near-empty (~350 rows) and is NOT the catalog source —
+  // the ~27k-row designs.json snapshot is. Querying PG made the concierge
+  // near-empty on prod even though the grid showed the full catalog.
+  const all = (_getDesigns() || []).filter(d => d && d.is_published !== false && !d.user_removed);
+
+  // category + motif_kw are SOFT keywords matched over category/title/motifs/tags
+  // (the concierge's floral|geometric|damask vocabulary doesn't line up with the
+  // real taxonomy drunk-animals/chinoiserie/stripe, so an exact match yields 0).
+  let rows = all;
+  if (category) { const q = String(category).toLowerCase(); rows = rows.filter(d => designText(d).includes(q)); }
+  if (motif_kw) { const q = String(motif_kw).toLowerCase(); rows = rows.filter(d => designText(d).includes(q)); }
+
   const lim = Math.min(parseInt(limit, 10) || 12, 30);
-  // A hue / hex_near filter is applied in JS AFTER the query, so pull a larger
-  // candidate pool first — otherwise the color filter starves on the newest N
-  // rows and silently returns nothing.
-  const poolLim = (hue || hex_near) ? 400 : lim;
-  const sql = `SELECT COALESCE(json_agg(t),'[]'::json) FROM (
-    SELECT id, kind, category, dominant_hex, prompt
-    FROM spoon_all_designs WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ${poolLim}) t;`;
-  const rows = JSON.parse(psql(sql) || '[]');
-  // Hex-near filter (post-query): Lab distance in JS is overkill; use Euclidean in RGB
+
+  // Color filters run over dominant_hex. Hex-near uses Euclidean RGB distance;
+  // hue uses the hue-cone test. Both fall back to the keyword matches when the
+  // color cone is empty so the user still sees relevant designs.
   let filtered = rows;
   if (hex_near && /^#[0-9a-fA-F]{6}$/.test(hex_near)) {
     const target = hexToRgb(hex_near);
     filtered = rows
-      .map(r => ({ ...r, _dist: r.dominant_hex ? rgbDist(target, hexToRgb(r.dominant_hex)) : 9999 }))
-      .filter(r => r._dist < 80)
-      .sort((a, b) => a._dist - b._dist);
+      .map(r => ({ r, _dist: r.dominant_hex ? rgbDist(target, hexToRgb(r.dominant_hex)) : 9999 }))
+      .filter(x => x._dist < 80)
+      .sort((a, b) => a._dist - b._dist)
+      .map(x => x.r);
   } else if (hue) {
     const huedeg = hueNameToDeg(hue);
     // huedeg === null → unknown color name; skip the hue filter rather than
@@ -145,21 +161,15 @@ function toolSearchDesigns({ category, hue, hex_near, motif_kw, limit = 12 } = {
   }
   return filtered.slice(0, lim).map(r => ({
     id: r.id,
-    title: titleFromPrompt(r.prompt),
+    title: r.title || '',
     category: r.category,
     hex: r.dominant_hex,
-    // Resolve via the by-id route. local_path basenames are NOT reliably served
-    // on every host (they 404'd here), but /designs/img/by-id/:id always resolves.
-    image_url: '/designs/img/by-id/' + r.id
+    // image_url is already in each cached record (same URL the grid serves);
+    // use it directly. Fall back to the by-id route if a record somehow lacks one.
+    image_url: r.image_url || ('/designs/img/by-id/' + r.id)
   }));
 }
 
-function titleFromPrompt(p) {
-  if (!p) return '';
-  const first = p.split(',')[0].trim();
-  return first.length > 70 ? first.slice(0, 67) + '…' : first;
-}
-
 function hexToRgb(h) {
   h = h.replace('#', '');
   return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
@@ -337,7 +347,11 @@ Stay faithful to the underlying motif (peony, lattice, damask, etc).`;
 
 // -------- Public Express factory --------
 
-function mount(app) {
+function mount(app, opts = {}) {
+  // server.js injects { getDesigns } — a getter for the live in-memory DESIGNS
+  // cache (data/designs.json). The SEARCH path reads from this so the concierge
+  // returns the same catalog the grid shows, identically on local and prod.
+  if (typeof opts.getDesigns === 'function') _getDesigns = opts.getDesigns;
   // Catalog chat
   app.post('/api/chat/catalog', async (req, res) => {
     try {

← 126ee4f Fix design concierge returning 0 results  ·  back to Wallco Ai  ·  auto-save: 2026-06-23T14:52:21 (1 files) — data/logs/gate-re f76ac12 →