[object Object]

← back to Wallco Ai

cactus-curator: haiku-vision chips + Fix-now heal pipeline w/ rollback

f6e31885231fa19451221ec6f9dfd35707aef749 · 2026-05-29 12:33:12 -0700 · Steve Abrams

new in cactus-curator (Steve 2026-05-29 — built while the haiku-weekend
full sweep was finishing):

1. Haiku verdict chip on every card the weekend pass flagged. read from
   data/haiku-weekend-*.jsonl via new GET /api/admin/cactus/haiku-verdicts.
   per-defect color-coding (ghost=red, fuzzy_seam/tone_break=amber,
   mural_misclass=orange, ok=green, etc.). reason in hover-title.

2. Fix button now ALSO triggers the seam-heal pipeline in-place — not just
   stamp needs_fixing_at. POST /api/design/:id/fix runs scripts/fix-seam.py,
   promotes the healed PNG to canonical, backs up original alongside as
   __preseam.png. result lands in-card via cache-busted img refresh.

3. New POST /api/design/:id/restore-preseam — swaps __preseam.png backup
   back to canonical to roll back a heal that made things worse.

4. In-card 'chat panel' below each fixed card shows: what fix-seam did
   (fixed/already-seamless/no-change + duration), the haiku reason that
   triggered it, and a Restore button when a backup exists. honors the
   Round-1-sacred rule: in-place pixel surgery only, no SDXL re-roll.

dtd-ratified path A. local verified: endpoints respond correctly,
haiku-verdicts returns 847 entries from the completed weekend sweep,
HTML embeds new code chunks. NOT yet pushed to prod (will likely
auto-sync via same mechanism that propagated unpublish button earlier;
if not, surgical-port + scp).

Files touched

Diff

commit f6e31885231fa19451221ec6f9dfd35707aef749
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 12:33:12 2026 -0700

    cactus-curator: haiku-vision chips + Fix-now heal pipeline w/ rollback
    
    new in cactus-curator (Steve 2026-05-29 — built while the haiku-weekend
    full sweep was finishing):
    
    1. Haiku verdict chip on every card the weekend pass flagged. read from
       data/haiku-weekend-*.jsonl via new GET /api/admin/cactus/haiku-verdicts.
       per-defect color-coding (ghost=red, fuzzy_seam/tone_break=amber,
       mural_misclass=orange, ok=green, etc.). reason in hover-title.
    
    2. Fix button now ALSO triggers the seam-heal pipeline in-place — not just
       stamp needs_fixing_at. POST /api/design/:id/fix runs scripts/fix-seam.py,
       promotes the healed PNG to canonical, backs up original alongside as
       __preseam.png. result lands in-card via cache-busted img refresh.
    
    3. New POST /api/design/:id/restore-preseam — swaps __preseam.png backup
       back to canonical to roll back a heal that made things worse.
    
    4. In-card 'chat panel' below each fixed card shows: what fix-seam did
       (fixed/already-seamless/no-change + duration), the haiku reason that
       triggered it, and a Restore button when a backup exists. honors the
       Round-1-sacred rule: in-place pixel surgery only, no SDXL re-roll.
    
    dtd-ratified path A. local verified: endpoints respond correctly,
    haiku-verdicts returns 847 entries from the completed weekend sweep,
    HTML embeds new code chunks. NOT yet pushed to prod (will likely
    auto-sync via same mechanism that propagated unpublish button earlier;
    if not, surgical-port + scp).
---
 public/admin/cactus-curator.html | 122 ++++++++++++++++++++++++++++++++++++++-
 server.js                        |  80 +++++++++++++++++++++++++
 2 files changed, 199 insertions(+), 3 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index a91f998..6fa744b 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -107,6 +107,30 @@
   .acts .live  { border-color:#1f4d2a; color:#9ff0a6; }
   .acts .etsy  { border-color:#5a3f17; color:#ffb86b; }
   .acts .unpub { border-color:#3a3f44; color:#a8b3bc; }
+
+  /* Haiku-vision verdict chip — appears on cards where the haiku-weekend pass flagged a defect */
+  .b.haiku { background:#4a1f5a; color:#f2c8ff; font-weight:700; }
+  .b.haiku.ok            { background:#1f4d2a; color:#9ff0a6; }
+  .b.haiku.ghost         { background:#5a2a2a; color:#ff9a9a; }
+  .b.haiku.fuzzy_seam    { background:#5a4410; color:#ffd98a; }
+  .b.haiku.tone_break    { background:#5a4410; color:#ffd98a; }
+  .b.haiku.mural_misclass{ background:#5a3f17; color:#ffb86b; }
+  .b.haiku.vendor_leak   { background:#5a2a2a; color:#ff9a9a; }
+  .b.haiku.bleed         { background:#2a3f5a; color:#a8d8ff; }
+  .b.haiku.other         { background:#2a3f5a; color:#a8d8ff; }
+
+  /* In-card "chat" panel that appears after Fix runs — explains what the heal pipeline did + the haiku reason */
+  .fix-panel { margin-top:6px; padding:8px 10px; border:1px solid var(--line); border-radius:6px;
+               background:#14171a; font-size:12px; }
+  .fix-row { display:flex; gap:8px; padding:3px 0; align-items:flex-start; }
+  .fix-row .ico { flex:0 0 18px; font-size:14px; line-height:1.3; }
+  .fix-row .msg { flex:1; color:var(--ink); line-height:1.4; word-wrap:break-word; }
+  .fix-row .msg b { color:#9ff0a6; font-weight:700; }
+  .fix-row.haiku .msg b { color:#f2c8ff; }
+  .fix-row.err .msg, .fix-row.err .msg b { color:#ff9a9a; }
+  .restore-btn { margin-top:6px; padding:5px 11px; font:600 11px inherit;
+                 background:#1d2023; color:#a8b3bc; border:1px solid #3a3f44; border-radius:5px; cursor:pointer; }
+  .restore-btn:hover { filter:brightness(1.25); }
   .card.moved-to-etsy { opacity:0; transform:scale(.92); transition:opacity .3s ease, transform .3s ease; }
   .acts button kbd { font:inherit; opacity:.55; }
 
@@ -393,6 +417,9 @@ function card(d, idx){
         ${roomBadgeHTML(d)}
         <span class="b ${verdictClass(d.seam_verdict)}">${d.seam_verdict||'no scan'}</span>
         ${failReason(d).map(r=>`<span class="b reason" title="failed: ${r}">${r}</span>`).join('')}
+        ${(window.HAIKU_VERDICTS && window.HAIKU_VERDICTS[d.id]) ?
+          `<span class="b haiku ${(window.HAIKU_VERDICTS[d.id].verdict||'').toLowerCase()}" title="Haiku 4.5 vision verdict: ${(window.HAIKU_VERDICTS[d.id].reason||'').replace(/"/g,'&quot;')}">🤖 ${window.HAIKU_VERDICTS[d.id].verdict}</span>`
+          : ''}
       </div>
       <img loading="lazy" src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}">
       ${roomImgHTML(d)}
@@ -635,6 +662,12 @@ async function decide(id, action, el){
       };
       document.body.appendChild(t);
       setTimeout(()=>t.remove(), 10000);
+    } else if (action === 'fix' && el) {
+      // Steve 2026-05-29: the Fix button now ALSO runs the seam-heal pipeline
+      // in-place (not just stamp needs_fixing_at). Result chat panel + Restore
+      // button land inside the card. Original PNG is backed up alongside as
+      // __preseam.png — restore-preseam reverses the swap.
+      runFixAndDisplay(id, el);
     }
   } catch(e){
     el?.classList.remove('decided');
@@ -644,6 +677,79 @@ async function decide(id, action, el){
   }
 }
 
+// Run /api/design/:id/fix → shift-and-blend seam heal. On promotion (an actual
+// fix landed), refresh the card thumb with a cache-bust and append a chat-style
+// panel showing what the pipeline did + the Haiku verdict reason + a Restore
+// button that swaps the __preseam.png backup back into canonical.
+async function runFixAndDisplay(id, el){
+  if (!el) return;
+  let panel = el.querySelector('.fix-panel');
+  if (panel) panel.remove();
+  panel = document.createElement('div');
+  panel.className = 'fix-panel';
+  panel.innerHTML = `<div class="fix-row"><span class="ico">🔧</span><div class="msg">Running fix-seam… <span class="spin">⏳</span></div></div>`;
+  el.appendChild(panel);
+  try {
+    const r = await fetch(q(`/api/design/${id}/fix`), {
+      method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
+    });
+    const j = await r.json().catch(()=>({}));
+    if(!r.ok || j.ok===false) throw new Error(j.error || ('HTTP '+r.status));
+
+    if (j.promoted) {
+      const img = el.querySelector('img');
+      if (img) {
+        const u = new URL(img.src, location.origin);
+        u.searchParams.set('t', String(Date.now()));
+        img.src = u.toString();
+      }
+    }
+
+    const haiku = (window.HAIKU_VERDICTS||{})[id];
+    let html = '';
+    if (j.promoted) {
+      html += `<div class="fix-row"><span class="ico">🔧</span><div class="msg"><b>Seam-healed in place.</b> fixed=${j.fixed} · ${j.duration_ms}ms<br>${escapeHtml(j.note||'')}</div></div>`;
+    } else if (j.already_seamless > 0) {
+      html += `<div class="fix-row"><span class="ico">✓</span><div class="msg"><b>No seam defect detected.</b> already-seamless n=${j.already_seamless}.</div></div>`;
+    } else {
+      html += `<div class="fix-row"><span class="ico">·</span><div class="msg"><b>No change applied.</b> ${escapeHtml(j.note||'fix-seam reported no actionable seam.')}</div></div>`;
+    }
+    if (haiku) {
+      html += `<div class="fix-row haiku"><span class="ico">🤖</span><div class="msg"><b>Haiku ${haiku.verdict}:</b> ${escapeHtml(haiku.reason||'')}</div></div>`;
+    }
+    if (j.promoted) {
+      html += `<button class="restore-btn">↶ Restore original</button>`;
+    }
+    panel.innerHTML = html;
+    const rb = panel.querySelector('.restore-btn');
+    if (rb) rb.addEventListener('click', e => { e.stopPropagation(); runRestore(id, el); });
+  } catch(e){
+    panel.innerHTML = `<div class="fix-row err"><span class="ico">⚠</span><div class="msg"><b>Fix failed:</b> ${escapeHtml(e.message)}</div></div>`;
+  }
+}
+
+async function runRestore(id, el){
+  const panel = el.querySelector('.fix-panel');
+  try {
+    const r = await fetch(q(`/api/design/${id}/restore-preseam`), {
+      method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
+    });
+    const j = await r.json().catch(()=>({}));
+    if(!r.ok || j.ok===false) throw new Error(j.error || ('HTTP '+r.status));
+    const img = el.querySelector('img');
+    if (img) {
+      const u = new URL(img.src, location.origin);
+      u.searchParams.set('t', String(Date.now()));
+      img.src = u.toString();
+    }
+    if (panel) panel.innerHTML = `<div class="fix-row"><span class="ico">↶</span><div class="msg"><b>Restored original.</b> ${escapeHtml(j.note||'')}</div></div>`;
+  } catch(e){
+    if (panel) panel.innerHTML = `<div class="fix-row err"><span class="ico">⚠</span><div class="msg"><b>Restore failed:</b> ${escapeHtml(e.message)}</div></div>`;
+  }
+}
+
+function escapeHtml(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+
 async function bulk(action){
   const ids = [...selected];
   if(!ids.length) return;
@@ -866,10 +972,20 @@ function applySelection(next){
 
 async function load(){
   try {
-    const r = await fetch(q('/api/admin/cactus/list?include=removed&category=' + encodeURIComponent(CATEGORY)));
-    if(!r.ok) throw new Error('list '+r.status+(r.status===404?' (admin gate — append ?admin=TOKEN)':''));
-    const j = await r.json();
+    // Fan-out: pull the catalog list AND the Haiku-vision verdicts in parallel.
+    // Haiku verdicts populate window.HAIKU_VERDICTS so renderCard can show a
+    // "🤖 <verdict>" chip on each card the weekend scan flagged. Verdicts failure
+    // is non-fatal — chips just won't render.
+    const [listR, haikuR] = await Promise.all([
+      fetch(q('/api/admin/cactus/list?include=removed&category=' + encodeURIComponent(CATEGORY))),
+      fetch(q('/api/admin/cactus/haiku-verdicts')).catch(() => null),
+    ]);
+    if(!listR.ok) throw new Error('list '+listR.status+(listR.status===404?' (admin gate — append ?admin=TOKEN)':''));
+    const j = await listR.json();
     ALL = j.items || [];
+    if (haikuR && haikuR.ok) {
+      try { const hj = await haikuR.json(); if (hj.ok) window.HAIKU_VERDICTS = hj.verdicts || {}; } catch {}
+    }
     // Reflect the loaded collection in the title.
     const h = document.querySelector('h1'); if(h && CATEGORY!=='cactus') h.textContent = '❖ ' + CATEGORY + ' Curator';
     render();
diff --git a/server.js b/server.js
index 8f8b782..0c667b2 100644
--- a/server.js
+++ b/server.js
@@ -1823,6 +1823,44 @@ app.get('/api/admin/defect-registry/list', (req, res) => {
   }
 });
 
+// GET /api/admin/cactus/haiku-verdicts — return the latest Haiku-vision verdict
+// per design ID, aggregated across every data/haiku-weekend-*.jsonl file. Used
+// by the cactus-curator frontend to render a per-card defect chip ("🤖 GHOST",
+// "🤖 FUZZY_SEAM" etc.) with the model's one-sentence reason as the tooltip.
+// Cheap to compute (~35K rows max) — read once per curator page-load.
+app.get('/api/admin/cactus/haiku-verdicts', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  try {
+    const dataDir = path.join(__dirname, 'data');
+    if (!fs.existsSync(dataDir)) return res.json({ ok: true, total: 0, verdicts: {} });
+    const files = fs.readdirSync(dataDir).filter(f => /^haiku-weekend-.*\.jsonl$/.test(f));
+    const verdicts = {};   // id → { verdict, reason, bucket, ts, via }  (newest wins on ts)
+    for (const f of files) {
+      const fp = path.join(dataDir, f);
+      for (const line of fs.readFileSync(fp, 'utf8').split('\n')) {
+        if (!line.trim()) continue;
+        try {
+          const r = JSON.parse(line);
+          if (!r.id || !r.verdict) continue;
+          const existing = verdicts[r.id];
+          if (!existing || (r.ts && existing.ts && r.ts > existing.ts)) {
+            verdicts[r.id] = {
+              verdict: r.verdict,
+              reason: (r.reason || '').slice(0, 240),
+              bucket: r.bucket || null,
+              ts: r.ts || null,
+              via: r.via || 'api',
+            };
+          }
+        } catch {}
+      }
+    }
+    res.json({ ok: true, total: Object.keys(verdicts).length, verdicts });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
 // JSON list of every cactus design joined to its rank sidecar. rank_score is
 // computed in SQL so it always reflects the latest scores: 0.6*vision +
 // 0.4*seam when both present, else whichever exists. Default hides already-
@@ -8962,6 +9000,48 @@ app.post('/api/design/:id/fix', express.json({ limit: '4kb' }), (req, res) => {
   }
 });
 
+// POST /api/design/:id/restore-preseam — undo the most-recent seam-heal by
+// swapping the `__preseam.png` backup back to be the canonical PNG. Steve's
+// "↶ Restore original" button on the cactus-curator card after a Fix run.
+// Idempotent-ish: if no backup exists, 404. After restore the backup is gone
+// (renamed onto the canonical), so a subsequent fix re-creates a new backup.
+app.post('/api/design/:id/restore-preseam', express.json({ limit: '1kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    // Resolve canonical path the same way /api/design/:id/fix does (PG → DESIGNS cache fallback)
+    let lp = null;
+    try {
+      const pgRaw = psqlQuery(`SELECT local_path FROM spoon_all_designs WHERE id=${id} LIMIT 1;`);
+      if (pgRaw && pgRaw.trim()) lp = pgRaw.trim();
+    } catch {}
+    if (!lp) {
+      const cached = DESIGNS.find(x => x.id === id);
+      if (cached && cached.image_url && cached.image_url.startsWith('/designs/img/')) {
+        const fn = cached.image_url.replace(/^\/designs\/img\//, '');
+        const candidate = path.join(__dirname, 'data', 'generated', fn);
+        if (fs.existsSync(candidate)) lp = candidate;
+      }
+    }
+    if (!lp) return res.status(404).json({ ok: false, error: 'design not found' });
+
+    const backupPath = lp.replace(/\.png$/i, '__preseam.png');
+    if (!fs.existsSync(backupPath)) {
+      return res.status(404).json({ ok: false, error: 'no __preseam.png backup — nothing to restore' });
+    }
+    // Atomic-ish swap: delete current, rename backup onto canonical.
+    if (fs.existsSync(lp)) fs.unlinkSync(lp);
+    fs.renameSync(backupPath, lp);
+    res.json({
+      ok: true, id, restored: true,
+      note: 'Restored original from __preseam.png backup. Run Fix again to create a new backup.',
+    });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
 app.post('/api/design/:id/regenerate-with-comment', express.json({ limit: '8kb' }), (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const id = parseInt(req.params.id, 10);

← 2c77613 Add Theme Gallery link to admin-nav hamburger (sibling of Ro  ·  back to Wallco Ai  ·  harden(db): psqlExecLocal throws on psql error (ON_ERROR_STO fe9624d →