[object Object]

← back to Wallco Ai

wallco.ai · /api/designs/search now emits did_you_mean[] on 0-hit · Levenshtein over the motif+category vocab (cap=2, early-exit on row min) — only kicks in when count===0, returns top-5 candidates sorted by distance · autocomplete dropdown wires it through window._didYouMean and renders a 'Did you mean' chip row beneath the no-matches message · verified: roze→rose(d=1), damaks→damask(d=2), valid 'rose' (53 hits) → did_you_mean:[]

4739bcb4c002011bac1c995ddff18e0284afed46 · 2026-05-12 08:32:23 -0700 · SteveStudio2

Files touched

Diff

commit 4739bcb4c002011bac1c995ddff18e0284afed46
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 08:32:23 2026 -0700

    wallco.ai · /api/designs/search now emits did_you_mean[] on 0-hit · Levenshtein over the motif+category vocab (cap=2, early-exit on row min) — only kicks in when count===0, returns top-5 candidates sorted by distance · autocomplete dropdown wires it through window._didYouMean and renders a 'Did you mean' chip row beneath the no-matches message · verified: roze→rose(d=1), damaks→damask(d=2), valid 'rose' (53 hits) → did_you_mean:[]
---
 server.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 63 insertions(+), 2 deletions(-)

diff --git a/server.js b/server.js
index e642501..07294bb 100644
--- a/server.js
+++ b/server.js
@@ -216,10 +216,34 @@ function maybe304(req, res, lastModifiedDate) {
   return false;
 }
 
+// ── Levenshtein distance (early-exit at cap). Used for did-you-mean suggestions.
+function _lev(a, b, cap) {
+  if (a === b) return 0;
+  if (!a.length) return b.length;
+  if (!b.length) return a.length;
+  if (Math.abs(a.length - b.length) > cap) return cap + 1;
+  let prev = new Array(b.length + 1);
+  for (let j = 0; j <= b.length; j++) prev[j] = j;
+  for (let i = 1; i <= a.length; i++) {
+    let cur = [i];
+    let rowMin = i;
+    for (let j = 1; j <= b.length; j++) {
+      const cost = a[i-1] === b[j-1] ? 0 : 1;
+      const v = Math.min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + cost);
+      cur.push(v);
+      if (v < rowMin) rowMin = v;
+    }
+    if (rowMin > cap) return cap + 1;
+    prev = cur;
+  }
+  return prev[b.length];
+}
+
 // ── Lightweight in-memory search across the catalog.
 // /api/designs/search?q=<term>&limit=20
 // Scores each design on motif (3), title (2), category/handle (1) substring match.
 // Returns top-N by score then by saturation. Pure read-only, no DB.
+// On 0 hits, suggests near-matches via Levenshtein over motif + category vocab.
 app.get('/api/designs/search', (req, res) => {
   const qRaw = String(req.query.q || '').toLowerCase().trim();
   const limit = Math.max(1, Math.min(50, parseInt(req.query.limit || '20', 10)));
@@ -260,6 +284,33 @@ app.get('/api/designs/search', (req, res) => {
       });
     }
   }
+  // Did-you-mean: if no hits, search the vocabulary for near-matches to each term.
+  // Cap=2 edit distance; cheapest 5 unique candidates.
+  let didYouMean = [];
+  if (out.length === 0) {
+    const vocab = new Set();
+    for (const d of DESIGNS) {
+      (d.motifs || []).forEach(m => { const v = String(m).toLowerCase().trim(); if (v.length >= 3) vocab.add(v); });
+      if (d.category) vocab.add(String(d.category).toLowerCase().trim());
+    }
+    const candidates = new Map();
+    const cap = 2;
+    for (const t of terms) {
+      if (t.length < 3) continue; // too short — Levenshtein noise
+      for (const w of vocab) {
+        const d = _lev(t, w, cap);
+        if (d <= cap && w !== t) {
+          const prev = candidates.get(w);
+          if (prev === undefined || d < prev) candidates.set(w, d);
+        }
+      }
+    }
+    didYouMean = Array.from(candidates.entries())
+      .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
+      .slice(0, 5)
+      .map(([word, distance]) => ({ word, distance }));
+  }
+
   out.sort((a, b) => b.score - a.score || b.saturation - a.saturation);
   // Facet counts — rolled up from the matched designs.
   // Lets a UI show "Refine: rose (12) · peony (8) · scrollwork (5)" alongside results.
@@ -291,6 +342,7 @@ app.get('/api/designs/search', (req, res) => {
       motifs: topMotifs,
       categories: topCategories,
     },
+    did_you_mean: didYouMean,
   });
 });
 
@@ -1498,7 +1550,16 @@ ${FOOTER}
     var hide = function(){ sBox.style.display='none'; sIn.setAttribute('aria-expanded','false'); sIdx=-1; };
     var render = function(results, qStr){
       if (!results || !results.length) {
-        sBox.innerHTML = '<div style="padding:14px 16px;font:13px var(--sans,system-ui);color:var(--ink-faint,#888)">No matches for &ldquo;'+esc(qStr)+'&rdquo;</div>';
+        var dym = (window._didYouMean || []);
+        var dymRow = '';
+        if (dym.length) {
+          dymRow = '<div style="padding:10px 14px 12px;border-top:1px solid var(--line,#f0eee9);background:#fafaf7"><div style="font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);margin-bottom:6px">Did you mean</div><div style="display:flex;flex-wrap:wrap;gap:5px">'
+            + dym.slice(0,5).map(function(s){
+              return '<a href="/designs?q='+encodeURIComponent(s.word)+'" style="display:inline-flex;align-items:center;padding:4px 10px;font:11px var(--sans,system-ui);background:var(--bg,#fff);color:var(--ink,#111);border:1px solid var(--line,#e6e2d8);border-radius:999px;text-decoration:none">'+esc(s.word)+'</a>';
+            }).join('')
+            + '</div></div>';
+        }
+        sBox.innerHTML = '<div style="padding:14px 16px;font:13px var(--sans,system-ui);color:var(--ink-faint,#888)">No matches for &ldquo;'+esc(qStr)+'&rdquo;</div>' + dymRow;
         sBox.style.display='block'; sIn.setAttribute('aria-expanded','true');
         sItems = []; sIdx = -1;
         return;
@@ -1534,7 +1595,7 @@ ${FOOTER}
       sCtl = new AbortController();
       fetch('/api/designs/search?q='+encodeURIComponent(qStr)+'&limit=24', { signal: sCtl.signal })
         .then(function(r){ return r.json(); })
-        .then(function(j){ window._facets = j.facets || null; render(j.results||[], qStr); })
+        .then(function(j){ window._facets = j.facets || null; window._didYouMean = j.did_you_mean || []; render(j.results||[], qStr); })
         .catch(function(){ /* aborted or network — ignore */ });
     };
     sIn.addEventListener('input', function(){

← 0b4eba7 studio: show rendered image + 3 room settings (bedroom/livin  ·  back to Wallco Ai  ·  wallco.ai · /murals search box parity with /designs — server 4295177 →