[object Object]

← back to Wallco Ai

stoned-animals title-drift fix — re-title 303 designs with actual species

5203dd4c5998a8881decd26c5a435b4f80ba2312 · 2026-05-31 19:54:41 -0700 · Steve Abrams

Steve 2026-05-31: 'fix the title drifts' (after the 227-design quality
purge, the audit had flagged 450 title-vs-image mismatches; 147 of those
were already KILLed in the quality purge, leaving 303 KEEPs to retitle).

The drift had two shapes:
  - 279 'color-only' (e.g. 'Olive Drift No.2645') — the auto-titler
    punted to color+verb+id when species detection failed during
    generation. Now: 'Wolves Drift in Olive'.
  -  24 'species-mismatch' (e.g. 'Deer Daydream in Prussian' but image
    is iguana) — title claimed wrong species. Now: 'Iguanas Daydream
    in Prussian'.

Top retitled species (Gemini-identified): koala 47, dog 40, llama 27,
sloth 27, iguana 25, bear 23, fox 22, wolf 17, raccoon 16, deer 15.

Mechanism:
  1. scripts/generate-stoned-title-rewrites.js — pure Node, parses
     existing title format (color-first vs species-first), pluralizes
     Gemini's animal call (handles wolf→wolves, fox→foxes, deer→deer,
     etc. via small irregular-plural dict), emits proposed renames
     as /tmp/stoned-animals-title-rewrites.json.
  2. server.js +44 lines: POST /api/admin/titles/bulk endpoint —
     accepts {updates: [{id, title}]}, builds one CASE-WHEN UPDATE
     against spoon_all_designs.ai_title, patches DESIGNS in-memory
     so live grid reflects changes immediately. isAdmin-gated.
  3. scripts/refresh_designs_snapshot.py +5 lines: snapshot now
     prefers ai_title (admin-set) over the algorithmic title_for()
     default. Backward-compatible: rows with ai_title=NULL still get
     the algorithmic title.

Execution:
  1. POST 303 renames → applied=303 errored=0 patched_in_memory=303 (0.6s)
  2. snapshot rebuild → 35,025 rows, 5/5 spot-checks ✓
  3. ./deploy-kamatera.sh → HTTP 200, prod /api/design/:id returns
     new titles for all 5 spot-checks, grid JSON-LD too.

Reversible: /tmp/stoned-animals-title-rewrites.json carries every
old_title; reverse via POST /api/admin/titles/bulk with {id: old_title}.

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

Files touched

Diff

commit 5203dd4c5998a8881decd26c5a435b4f80ba2312
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 31 19:54:41 2026 -0700

    stoned-animals title-drift fix — re-title 303 designs with actual species
    
    Steve 2026-05-31: 'fix the title drifts' (after the 227-design quality
    purge, the audit had flagged 450 title-vs-image mismatches; 147 of those
    were already KILLed in the quality purge, leaving 303 KEEPs to retitle).
    
    The drift had two shapes:
      - 279 'color-only' (e.g. 'Olive Drift No.2645') — the auto-titler
        punted to color+verb+id when species detection failed during
        generation. Now: 'Wolves Drift in Olive'.
      -  24 'species-mismatch' (e.g. 'Deer Daydream in Prussian' but image
        is iguana) — title claimed wrong species. Now: 'Iguanas Daydream
        in Prussian'.
    
    Top retitled species (Gemini-identified): koala 47, dog 40, llama 27,
    sloth 27, iguana 25, bear 23, fox 22, wolf 17, raccoon 16, deer 15.
    
    Mechanism:
      1. scripts/generate-stoned-title-rewrites.js — pure Node, parses
         existing title format (color-first vs species-first), pluralizes
         Gemini's animal call (handles wolf→wolves, fox→foxes, deer→deer,
         etc. via small irregular-plural dict), emits proposed renames
         as /tmp/stoned-animals-title-rewrites.json.
      2. server.js +44 lines: POST /api/admin/titles/bulk endpoint —
         accepts {updates: [{id, title}]}, builds one CASE-WHEN UPDATE
         against spoon_all_designs.ai_title, patches DESIGNS in-memory
         so live grid reflects changes immediately. isAdmin-gated.
      3. scripts/refresh_designs_snapshot.py +5 lines: snapshot now
         prefers ai_title (admin-set) over the algorithmic title_for()
         default. Backward-compatible: rows with ai_title=NULL still get
         the algorithmic title.
    
    Execution:
      1. POST 303 renames → applied=303 errored=0 patched_in_memory=303 (0.6s)
      2. snapshot rebuild → 35,025 rows, 5/5 spot-checks ✓
      3. ./deploy-kamatera.sh → HTTP 200, prod /api/design/:id returns
         new titles for all 5 spot-checks, grid JSON-LD too.
    
    Reversible: /tmp/stoned-animals-title-rewrites.json carries every
    old_title; reverse via POST /api/admin/titles/bulk with {id: old_title}.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 scripts/generate-stoned-title-rewrites.js | 181 ++++++++++++++++++++++++++++++
 scripts/refresh_designs_snapshot.py       |   8 +-
 server.js                                 |  44 ++++++++
 3 files changed, 231 insertions(+), 2 deletions(-)

diff --git a/scripts/generate-stoned-title-rewrites.js b/scripts/generate-stoned-title-rewrites.js
new file mode 100644
index 0000000..ed37fc2
--- /dev/null
+++ b/scripts/generate-stoned-title-rewrites.js
@@ -0,0 +1,181 @@
+#!/usr/bin/env node
+/**
+ * generate-stoned-title-rewrites.js — Read the stoned-animals audit results,
+ * compute proposed new titles for the 303 drifted KEEPs, output as JSON for
+ * spot-check + downstream batch UPDATE.
+ *
+ * 2026-05-31. KILLs are excluded (they're unpublished anyway). Color-only
+ * titles ("Olive Drift No.X") get a species INJECTED; species-mismatch
+ * titles ("Deer Daydream in Prussian" → iguana) get the species REPLACED.
+ *
+ * Target format: `[ActualSpeciesPlural] [Verb] in [Color]`
+ *
+ * Output: /tmp/stoned-animals-title-rewrites.json
+ *   [{id, old_title, new_title, animal, verb, color, kind: 'color-only'|'species-mismatch'}, ...]
+ *
+ * NO writes. Read-only. Pure title generation.
+ */
+'use strict';
+
+const fs = require('fs');
+
+const RESULTS = '/tmp/stoned-animals-audit-results.jsonl';
+const OUT = '/tmp/stoned-animals-title-rewrites.json';
+
+// Verb vocabulary from the auto-titler — these are mood/state words attached
+// to the collection. Always appear capitalized in second position.
+const VERBS = new Set([
+  'Slumber', 'Idyll', 'Daydream', 'Haze', 'Repose', 'Drift', 'Lullaby',
+  'Reverie', 'Halcyon', 'Vespers', 'Nightcap', 'Drowse', 'Doze',
+  'Soirée', 'Soiree', 'Folly', 'Spree', 'Toast', 'Saunter', 'Revel',
+  'Bacchanal', 'Sundrift', 'Folio',
+]);
+
+// Colors that appear in titles — single-word + multi-word. Order matters
+// for multi-word matches (longer first).
+const MULTI_COLORS = [
+  'Bottle Green', 'Antique Brass', 'Antique Tan', 'Bone Ivory',
+  'Bordeaux Velvet', 'Champagne Linen', 'Deep Plum', 'Forest Loden',
+  'Greenhouse Glass', 'Indigo Library', 'Manor Saddle', 'Midnight Navy',
+  'Saddle Mocha', 'Slate Mist', 'Smoke Ash', 'Stone Pewter',
+];
+
+// Irregular plurals — anything not in here gets the standard +s rule.
+const PLURAL_EXCEPTIONS = {
+  fox: 'foxes', wolf: 'wolves', mouse: 'mice', ox: 'oxen',
+  deer: 'deer', sheep: 'sheep', moose: 'moose', fish: 'fish',
+  goose: 'geese', child: 'children', octopus: 'octopuses',
+  // Single-word two-syllable irregulars from Gemini's vocab so far
+  bunny: 'bunnies', puppy: 'puppies', kitty: 'kitties',
+};
+
+function pluralize(s) {
+  s = (s || '').trim().toLowerCase();
+  if (!s) return '';
+  if (PLURAL_EXCEPTIONS[s]) return PLURAL_EXCEPTIONS[s];
+  if (s.endsWith('s') || s.endsWith('x') || s.endsWith('z') ||
+      s.endsWith('ch') || s.endsWith('sh')) return s + 'es';
+  if (s.endsWith('y') && !'aeiou'.includes(s[s.length - 2])) return s.slice(0, -1) + 'ies';
+  if (s.endsWith('f')) return s.slice(0, -1) + 'ves';
+  if (s.endsWith('fe')) return s.slice(0, -2) + 'ves';
+  return s + 's';
+}
+
+function titleCase(s) { return s.replace(/\b\w/g, c => c.toUpperCase()); }
+
+// Extract verb + color from an existing title. Two formats supported:
+//   A. "[Color] [Verb] No.[ID]"      → color-only title
+//   B. "[Speciess] [Verb] in [Color]" → species-first title
+// Returns null if format doesn't match.
+function parseTitle(title) {
+  if (!title) return null;
+  const t = title.trim();
+
+  // Format B: "X Verb in Color"
+  const mB = t.match(/^(\S+)\s+(\S+)\s+in\s+(.+)$/);
+  if (mB) {
+    const [, first, verb, colorRaw] = mB;
+    if (VERBS.has(verb)) {
+      return { kind: 'species-mismatch', old_animal_plural: first, verb, color: colorRaw.trim() };
+    }
+  }
+
+  // Format A: "Color Verb No.ID"
+  // Color can be multi-word — match longest first.
+  for (const mc of MULTI_COLORS) {
+    if (t.startsWith(mc + ' ')) {
+      const rest = t.slice(mc.length + 1);
+      const mA = rest.match(/^(\S+)\s+No\.\d+/);
+      if (mA && VERBS.has(mA[1])) {
+        return { kind: 'color-only', color: mc, verb: mA[1] };
+      }
+    }
+  }
+  // Single-word color
+  const mAs = t.match(/^(\S+)\s+(\S+)\s+No\.\d+/);
+  if (mAs && VERBS.has(mAs[2])) {
+    return { kind: 'color-only', color: mAs[1], verb: mAs[2] };
+  }
+
+  // Format C (catch): "Speciess Verb" without "in Color" — rare
+  const mC = t.match(/^(\S+)\s+(\S+)$/);
+  if (mC && VERBS.has(mC[2])) {
+    return { kind: 'species-bare', old_animal_plural: mC[1], verb: mC[2], color: null };
+  }
+  return null;
+}
+
+function cleanAnimal(s) {
+  // Strip parenthetical, comma-separated lists, leading "a/an/the", etc.
+  s = (s || '').toLowerCase().trim();
+  if (!s || s === 'indistinct' || s === 'none') return '';
+  s = s.replace(/^(a|an|the)\s+/, '');
+  s = s.split(/[,/]|\sand\s/)[0].trim();
+  // Strip descriptors like "sleeping fox" → "fox", "red panda" → "red panda" (keep)
+  // Keep multi-word species like "red panda", "polar bear", "snow leopard"
+  const MULTI_WORD_OK = ['red panda', 'polar bear', 'snow leopard', 'sea otter', 'tree frog', 'flying fox'];
+  if (MULTI_WORD_OK.includes(s)) return s;
+  // Otherwise take the last word (often the head noun for "stylized fox" or "small dog")
+  const parts = s.split(/\s+/);
+  return parts[parts.length - 1];
+}
+
+function main() {
+  const rows = fs.readFileSync(RESULTS, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
+  const drifted = rows.filter(r => r.verdict === 'KEEP' && r.title_drift);
+  console.log(`drifted KEEPs: ${drifted.length}`);
+
+  const rewrites = [];
+  const skipped = [];
+  for (const r of drifted) {
+    const parsed = parseTitle(r.title);
+    if (!parsed) { skipped.push({ id: r.id, reason: 'unparseable title', title: r.title }); continue; }
+    const animal = cleanAnimal(r.animal);
+    if (!animal) { skipped.push({ id: r.id, reason: 'animal blank/indistinct', animal: r.animal }); continue; }
+    const plural = titleCase(pluralize(animal));
+    if (parsed.kind === 'color-only') {
+      const newTitle = `${plural} ${parsed.verb} in ${parsed.color}`;
+      rewrites.push({ id: r.id, old_title: r.title, new_title: newTitle, animal, verb: parsed.verb, color: parsed.color, kind: 'color-only' });
+    } else if (parsed.kind === 'species-mismatch') {
+      const newTitle = `${plural} ${parsed.verb} in ${parsed.color}`;
+      if (newTitle === r.title) continue;  // no-op (e.g. Gemini agreed with title-claim post-clean)
+      rewrites.push({ id: r.id, old_title: r.title, new_title: newTitle, animal, verb: parsed.verb, color: parsed.color, kind: 'species-mismatch' });
+    } else if (parsed.kind === 'species-bare') {
+      const newTitle = parsed.color ? `${plural} ${parsed.verb} in ${parsed.color}` : `${plural} ${parsed.verb}`;
+      if (newTitle === r.title) continue;
+      rewrites.push({ id: r.id, old_title: r.title, new_title: newTitle, animal, verb: parsed.verb, color: parsed.color || '', kind: 'species-bare' });
+    }
+  }
+
+  console.log(`generated rewrites: ${rewrites.length}`);
+  console.log(`skipped: ${skipped.length}`);
+  if (skipped.length) {
+    const bySkipReason = {};
+    skipped.forEach(s => bySkipReason[s.reason] = (bySkipReason[s.reason] || 0) + 1);
+    console.log('skip reasons:');
+    for (const [k, v] of Object.entries(bySkipReason)) console.log(`  ${v}× ${k}`);
+  }
+
+  // Output
+  fs.writeFileSync(OUT, JSON.stringify(rewrites, null, 2));
+  console.log(`\nsaved → ${OUT}`);
+  console.log('\n=== 12 SAMPLE REWRITES ===');
+  rewrites.slice(0, 6).forEach(r =>
+    console.log(`  #${r.id.toString().padStart(5)}  '${r.old_title}'\n           → '${r.new_title}'  [${r.kind}]`));
+  console.log('  ...');
+  rewrites.slice(-6).forEach(r =>
+    console.log(`  #${r.id.toString().padStart(5)}  '${r.old_title}'\n           → '${r.new_title}'  [${r.kind}]`));
+
+  // Distribution
+  const kinds = {};
+  rewrites.forEach(r => kinds[r.kind] = (kinds[r.kind] || 0) + 1);
+  console.log(`\n=== KIND DISTRIBUTION ===`);
+  Object.entries(kinds).forEach(([k, v]) => console.log(`  ${k}: ${v}`));
+
+  const species = {};
+  rewrites.forEach(r => species[r.animal] = (species[r.animal] || 0) + 1);
+  console.log(`\n=== TOP SPECIES (post-rewrite) ===`);
+  Object.entries(species).sort((a, b) => b[1] - a[1]).slice(0, 12).forEach(([k, v]) => console.log(`  ${v.toString().padStart(3)}× ${k}`));
+}
+
+main();
diff --git a/scripts/refresh_designs_snapshot.py b/scripts/refresh_designs_snapshot.py
index 2febb01..6378899 100644
--- a/scripts/refresh_designs_snapshot.py
+++ b/scripts/refresh_designs_snapshot.py
@@ -184,7 +184,10 @@ rows = psql_json(
     "SELECT COALESCE(json_agg(t),'[]'::json) FROM ("
     "SELECT id, kind, category, dominant_hex, prompt, generator, seed, local_path, "
     "is_published, created_at, motifs, room_mockups, notes, tags, "
-    "settlement_verdict, "
+    "settlement_verdict, ai_title, "
+    # 2026-05-31 — added ai_title so admin-set titles (curator rewrites,
+    # title-drift fixes) override the algorithmic title_for() default.
+    # Set via /api/admin/titles/bulk; if non-null, the snapshot uses it as-is.
     "user_removed, user_color_good, user_style_good, user_scale_good, user_stars "
     # 2026-05-22 — added 'comfy' so Mac1 ComfyUI-generated designs (drunk-monkeys-v2 etc)
     # land in the snapshot. Previously these were silently excluded, making the live
@@ -249,7 +252,8 @@ for r in rows:
         "category": r['category'],
         "dominant_hex": r['dominant_hex'],
         "saturation": round(s, 3),
-        "title": title_for(r['category'], r['dominant_hex'] or '', r['id'], r.get('prompt')),
+        # Prefer admin-set ai_title (curator title fixes); fall back to algorithmic.
+        "title": (r.get('ai_title') or '').strip() or title_for(r['category'], r['dominant_hex'] or '', r['id'], r.get('prompt')),
         "handle": f"wallco-{r['id']:04d}",
         "image_url": f"/designs/img/{filename}",
         "filename": filename,
diff --git a/server.js b/server.js
index ccc5a08..5edec7f 100644
--- a/server.js
+++ b/server.js
@@ -2468,6 +2468,50 @@ app.post('/api/cactus-decision/bulk', express.json({ limit: '256kb' }), (req, re
   }
 });
 
+// POST /api/admin/titles/bulk — bulk rename design titles.
+//   body: { updates: [ { id: <int>, title: <string> }, ... ] }
+// Used 2026-05-31 to fix the 303 stoned-animals title drifts (auto-titler had
+// punted to "[Color] [Verb] No.[ID]" when species detection failed, OR claimed
+// a wrong species). Patches Mac2 PG via one CASE-WHEN UPDATE + patches the
+// in-memory DESIGNS array so the live grid reflects the new titles immediately.
+// Safe + reversible — title-only, no publish/quarantine side effects.
+app.post('/api/admin/titles/bulk', express.json({ limit: '1mb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const updates = Array.isArray(req.body && req.body.updates) ? req.body.updates : [];
+  if (!updates.length) return res.status(400).json({ error: 'updates[] required' });
+  // Sanitize: every entry must have an int id + a non-empty string title (cap at 200 chars
+  // matching typical product-title length).
+  const clean = [];
+  for (const u of updates) {
+    const id = parseInt(u && u.id, 10);
+    const title = String((u && u.title) || '').trim().slice(0, 200);
+    if (!Number.isFinite(id) || id < 1 || !title) continue;
+    clean.push({ id, title });
+  }
+  if (!clean.length) return res.status(400).json({ error: 'no valid updates after sanitize' });
+  // Build the CASE WHEN. Apostrophes are the only SQL-special we need to escape
+  // for a string literal in single quotes — '' inside '' is the standard PG form.
+  const esc = (s) => "'" + String(s).replace(/'/g, "''") + "'";
+  const cases = clean.map(u => `WHEN ${u.id} THEN ${esc(u.title)}`).join(' ');
+  const idList = clean.map(u => u.id).join(',');
+  // Column is `ai_title` on `spoon_all_designs` — see server.js:20260 + 21573.
+  // The snapshot rebuilder reads ai_title and emits it as `title` in designs.json.
+  const sql = `UPDATE spoon_all_designs SET ai_title = CASE id ${cases} END WHERE id IN (${idList});`;
+  try {
+    psqlExecLocal(sql);
+    // Patch in-memory DESIGNS so the live grid + /api/designs see the new
+    // titles right away (no full reload needed).
+    let patched = 0;
+    for (const u of clean) {
+      const d = DESIGNS.find(x => x.id === u.id);
+      if (d) { d.title = u.title; patched++; }
+    }
+    res.json({ ok: true, total: updates.length, applied: clean.length, patched_in_memory: patched });
+  } catch (e) {
+    res.status(500).json({ error: e.message, sample_sql: sql.slice(0, 400) });
+  }
+});
+
 // POST /api/admin/cactus/:id/annotations  body: { boxes:[{x,y,w,h,label}] }
 // Manual shift-drag "bad area" boxes from the curator. Stored on bad_examples
 // (boxes col) so a boxed design becomes a labeled anti-pattern with its exact

← 674cae4 curator: add Bake button — render HSB+contrast adjustment to  ·  back to Wallco Ai  ·  gen-luxe --color-family=<gated family> targets ground colorw cc6dafb →