← 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
A scripts/generate-stoned-title-rewrites.jsM scripts/refresh_designs_snapshot.pyM server.js
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 →