← 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 →