[object Object]

← back to Designer Wallcoverings

Anna French STEP2+3: local Full-Monte enrichment + interior-designer lexicon normalizer (staging-only, $0 local)

785b097722e8946d28c4fd45b3e3eb562c8f9d62 · 2026-06-25 08:33:15 -0700 · Steve

Files touched

Diff

commit 785b097722e8946d28c4fd45b3e3eb562c8f9d62
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 25 08:33:15 2026 -0700

    Anna French STEP2+3: local Full-Monte enrichment + interior-designer lexicon normalizer (staging-only, $0 local)
---
 shopify/scripts/anna-french-enrich.js | 215 ++++++++++++++++++++++++++++++++++
 1 file changed, 215 insertions(+)

diff --git a/shopify/scripts/anna-french-enrich.js b/shopify/scripts/anna-french-enrich.js
new file mode 100644
index 00000000..c167af1d
--- /dev/null
+++ b/shopify/scripts/anna-french-enrich.js
@@ -0,0 +1,215 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Anna French — STEP 2 + STEP 3: local Full-Monte enrichment + interior-designer
+ * lexicon normalization. STAGING ONLY (anna_french_catalog). No Shopify writes.
+ *
+ * STEP 2 (fm_*): image-clean + color/hex (Pillow ground truth) + styles/patterns/
+ *   material/imageType + a one-line VLM description — all via the LOCAL enrich
+ *   hybrid (~/Projects/enrich-local-hybrid/enrich-local.js: Pillow + qwen2.5vl on
+ *   Mac1). COST = $0 (local). No paid Gemini.
+ * STEP 3: normalize color_name / ai_styles / ai_patterns to the interior-designer
+ *   trade lexicon (Oatmeal/Greige/Celadon…, damask/toile/ikat…, Art Deco/MCM…) and
+ *   compose a clean, on-brand luxury description. No "Unknown", no banned word.
+ *
+ * Writes: color_hex, dominant_color_hex, ai_background_color, ai_colors, ai_styles,
+ *   ai_patterns, ai_tags, ai_description (enriched), image_rejected/reason.
+ *
+ * Usage:
+ *   node anna-french-enrich.js --limit 5      # small batch
+ *   node anna-french-enrich.js --all          # whole staging table
+ *   node anna-french-enrich.js --all --no-vision   # lexicon-normalize only (skip VLM)
+ */
+const { Pool } = require('pg');
+const https = require('https');
+const { localAnalyze } = require(require('os').homedir() + '/Projects/enrich-local-hybrid/enrich-local.js');
+
+const pool = process.env.DATABASE_URL
+  ? new Pool({ connectionString: process.env.DATABASE_URL })
+  : new Pool({ host: '/tmp', database: 'dw_unified', user: process.env.PGUSER || process.env.USER });
+
+const argv = process.argv.slice(2);
+const ALL = argv.includes('--all');
+const NO_VISION = argv.includes('--no-vision');
+const li = argv.indexOf('--limit');
+const LIMIT = li >= 0 ? Math.max(1, parseInt(argv[li + 1]) || 25) : 25;
+const CONCURRENCY = 3;
+
+// ---- interior-designer trade lexicon (mirrored from the skill SKILL.md) ----
+const COLORWAYS = [
+  // whites/off-whites
+  'Chalk','Alabaster','Cotton','Marshmallow','Swiss Coffee','Ivory','Eggshell','Bone','Cream','Linen','Porcelain','Dove White','Antique White',
+  // neutrals/greige
+  'Oatmeal','Flax','String','Greige','Mushroom','Putty','Stone','Fawn','Wheat','Sand','Biscuit','Almond','Taupe','Pebble',
+  // tans/browns
+  'Buff','Chamois','Honey','Tan','Camel','Khaki','Caramel','Pecan','Cognac','Chestnut','Tobacco','Walnut','Mocha','Espresso',
+  // greys
+  'Dove','Silver','Ash','Fog','Smoke','Pewter','Nickel','Flannel','Slate','Zinc','Graphite','Gunmetal','Charcoal',
+  // blacks
+  'Ink','Noir','Onyx','Ebony','Jet','Obsidian','Coal',
+  // greens
+  'Celadon','Pistachio','Sage','Eucalyptus','Sea Glass','Moss','Fern','Olive','Loden','Juniper','Hunter','Forest','Emerald','Verdigris','Bottle Green',
+  // blues
+  'Powder','Sky','Wedgwood','Cornflower','French Blue','Cerulean','Denim','Slate Blue','Teal','Peacock','Prussian','Indigo','Navy','Midnight',
+  // yellows/golds
+  'Buttercream','Champagne','Maize','Citrine','Saffron','Amber','Ochre','Mustard','Gold','Brass',
+  // oranges/earths
+  'Apricot','Peach','Clay','Terracotta','Persimmon','Pumpkin','Marmalade','Copper','Rust','Sienna',
+  // reds/pinks
+  'Blush','Rose','Dusty Rose','Coral','Cinnabar','Brick','Oxblood','Claret','Garnet','Burgundy','Crimson',
+  // purples
+  'Lavender','Lilac','Wisteria','Orchid','Heather','Mauve','Amethyst','Plum','Aubergine',
+  // metallics
+  'Platinum','Gilt','Bronze',
+];
+const STYLES = [
+  'Traditional','Neoclassical','Georgian','Regency','Federal','Colonial','Victorian','Edwardian','Baroque','Rococo','Gothic','Renaissance','Empire','Palladian',
+  'English Country','French Country','Tuscan','Mediterranean','Cottage','Cottagecore','Farmhouse','Modern Farmhouse','Rustic','Lodge','Southwestern',
+  'Modern','Contemporary','Mid-Century Modern','Bauhaus','Minimalist','Scandinavian','Japandi','Organic Modern','Brutalist','Postmodern',
+  'Art Deco','Art Nouveau','Hollywood Regency','Glam','Maximalist','Grandmillennial','Chinoiserie','Moroccan','Bohemian','Eclectic','Shabby Chic','Parisian',
+  'Industrial','Transitional','Coastal','Hamptons','Nautical','Wabi-Sabi','Zen','Biophilic','Vintage','Retro',
+];
+const PATTERNS = [
+  'Damask','Medallion','Ogee','Arabesque','Scroll','Acanthus','Fleur-de-lis','Quatrefoil','Trefoil',
+  'Toile','Chinoiserie','Grisaille','Mural','Scenic','Trompe l\'oeil','Frieze','Border',
+  'Floral','Botanical','Chintz','Jacobean','Crewel','William Morris','Tropical','Palm','Ditsy','Sprig','Trailing Vine',
+  'Ikat','Suzani','Paisley','Batik','Block Print','Kuba','Mudcloth','Otomi',
+  'Greek Key','Fretwork','Lattice','Trellis','Diamond','Harlequin','Honeycomb','Hexagon','Chevron','Herringbone','Basketweave','Polka Dot','Abstract','Geometric',
+  'Stripe','Plaid','Tartan','Gingham','Buffalo Check','Windowpane','Houndstooth','Tattersall',
+  'Grasscloth','Strie','Moire','Faux Bois','Marbleized','Tortoiseshell','Shagreen','Plaster','Linen-Look',
+];
+
+function closest(term, lexicon) {
+  if (!term) return null;
+  const t = String(term).toLowerCase().trim();
+  // exact / substring match against lexicon
+  for (const L of lexicon) { const l = L.toLowerCase(); if (l === t || t === l) return L; }
+  for (const L of lexicon) { const l = L.toLowerCase(); if (t.includes(l) || l.includes(t)) return L; }
+  return null;
+}
+function normalizeList(arr, lexicon, cap = 4) {
+  const out = [], seen = new Set();
+  for (const x of (Array.isArray(arr) ? arr : [])) {
+    const hit = closest(x, lexicon) || (String(x || '').trim() ? titleCase(String(x).trim()) : null);
+    if (hit && !seen.has(hit.toLowerCase())) { seen.add(hit.toLowerCase()); out.push(hit); }
+    if (out.length >= cap) break;
+  }
+  return out;
+}
+function titleCase(s) {
+  return String(s || '').toLowerCase().split(/\s+/).filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+}
+const cleanWallpaper = (s) => !s ? s : String(s).replace(/\bWalls Wallpaper\b/gi, 'Wallcoverings').replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering');
+
+function fetchBuffer(url) {
+  return new Promise((resolve, reject) => {
+    const to = setTimeout(() => { req.destroy(); reject(new Error('timeout')); }, 20000);
+    const req = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
+      if (res.statusCode !== 200) { clearTimeout(to); res.resume(); return reject(new Error('HTTP ' + res.statusCode)); }
+      const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => { clearTimeout(to); resolve(Buffer.concat(chunks)); });
+    });
+    req.on('error', (e) => { clearTimeout(to); reject(e); });
+  });
+}
+
+// compose a clean luxury description from real spec + normalized semantics
+function composeDescription(row, normColor, styles, patterns, vlmDesc) {
+  const pat = titleCase(row.pattern_name || '');
+  const col = normColor || row.color_name || '';
+  const typeWord = (row.product_type === 'Fabric') ? 'fabric' : 'wallcovering';
+  const bits = [];
+  if (pat) {
+    const lead = patterns[0] ? `${pat} is a ${patterns[0].toLowerCase()} ${typeWord}` : `${pat} is a refined ${typeWord}`;
+    bits.push(`${lead}${col ? ` in ${col}` : ''} from the Anna French ${titleCase(row.collection || '')} collection`.replace(/\s+collection$/i, row.collection ? ' collection' : '').trim() + '.');
+  }
+  if (styles.length) bits.push(`Its ${styles.slice(0, 2).map(s => s).join(' / ')} character suits a curated, design-led interior.`);
+  const spec = [];
+  if (row.material) spec.push(`crafted from ${String(row.material).toLowerCase()}`);
+  if (row.width) spec.push(`${String(row.width).toLowerCase()} wide`);
+  if (row.repeat_v) spec.push(`with a ${row.repeat_v}" vertical repeat`);
+  if (row.match_type) spec.push(String(row.match_type).toLowerCase());
+  if (spec.length) bits.push(titleCase(spec[0].split(' ')[0]) + spec[0].slice(spec[0].indexOf(' ')) + (spec.length > 1 ? ', ' + spec.slice(1).join(', ') : '') + '.');
+  let txt = cleanWallpaper(bits.join(' ').replace(/\s+/g, ' ').trim());
+  if (!txt || txt.length < 40) txt = cleanWallpaper(vlmDesc || `${pat || 'Anna French'} ${typeWord}${col ? ` in ${col}` : ''}, a design-trade quality piece from Anna French.`);
+  return txt;
+}
+
+async function enrichRow(row) {
+  const imgs = (() => { try { return JSON.parse(row.all_images || '[]'); } catch { return []; } })();
+  const candidates = [row.image_url, ...imgs].filter(Boolean);
+  let vl = null;
+  if (!NO_VISION && candidates.length) {
+    try { vl = await localAnalyze(candidates, 'product', fetchBuffer); }
+    catch (e) { vl = null; /* fall through to spec-only normalize */ }
+  }
+  if (vl && vl.image_rejected) {
+    await pool.query(`UPDATE anna_french_catalog SET image_rejected=TRUE, image_rejection_reason=$2, updated_at=NOW() WHERE id=$1`, [row.id, vl.rejection_reason || 'rejected']);
+    return { id: row.id, rejected: true };
+  }
+  // STEP 3 normalization
+  const styles = normalizeList(vl ? vl.styles : [], STYLES);
+  const patterns = normalizeList((vl ? vl.patterns : []).concat(row.application ? [row.application] : []), PATTERNS);
+  // color_name: keep the REAL manufacturer color (never AI-replace per DW rules); only
+  // map a normalized colorway as an ADDITIONAL tag, never overwrite the scraped name.
+  const realColor = row.color_name;
+  const normColorway = vl && Array.isArray(vl.colors) && vl.colors[0] ? closest(vl.colors[0].name, COLORWAYS) : null;
+  const colors = vl ? vl.colors : [];
+  const tags = normalizeList(
+    [].concat(styles, patterns, normColorway ? [normColorway] : [], colors.map(c => c.name).filter(Boolean)),
+    [...STYLES, ...PATTERNS, ...COLORWAYS], 12
+  );
+  const desc = composeDescription(row, realColor, styles, patterns, vl ? vl.description : '');
+
+  await pool.query(
+    `UPDATE anna_french_catalog SET
+       color_hex=$2, dominant_color_hex=$3, ai_background_color=$4,
+       ai_colors=$5, ai_styles=$6, ai_patterns=$7, ai_tags=$8,
+       ai_description=$9, image_rejected=FALSE, updated_at=NOW()
+     WHERE id=$1`,
+    [
+      row.id,
+      (colors[0] && colors[0].hex) || null,
+      (vl && vl.dominantHex) || (colors[0] && colors[0].hex) || null,
+      (vl && vl.backgroundColor) || null,
+      JSON.stringify(colors),
+      JSON.stringify(styles),
+      JSON.stringify(patterns),
+      JSON.stringify(tags),
+      desc,
+    ]
+  );
+  return { id: row.id, provider: vl ? vl._provider : 'spec-only', styles, patterns, colorways: normColorway, hex: colors[0] && colors[0].hex, descLen: desc.length };
+}
+
+async function run() {
+  const t0 = Date.now();
+  const lim = ALL ? 100000 : LIMIT;
+  const { rows } = await pool.query(
+    `SELECT id, mfr_sku, pattern_name, color_name, collection, product_type, material,
+            width, repeat_v, match_type, application, image_url, all_images
+       FROM anna_french_catalog
+      WHERE mfr_sku IS NOT NULL AND (discontinued IS NOT TRUE)
+      ORDER BY id LIMIT $1`, [lim]
+  );
+  console.log(`Anna French enrich — ${rows.length} row(s)${NO_VISION ? ' [no-vision]' : ' [local qwen2.5vl + Pillow, $0]'}`);
+  const stats = { done: 0, rejected: 0, failed: 0 };
+  const samples = [];
+  for (let i = 0; i < rows.length; i += CONCURRENCY) {
+    const batch = rows.slice(i, i + CONCURRENCY);
+    const res = await Promise.allSettled(batch.map(enrichRow));
+    for (const r of res) {
+      if (r.status === 'fulfilled') {
+        if (r.value.rejected) stats.rejected++; else stats.done++;
+        if (samples.length < 6 && !r.value.rejected) samples.push(r.value);
+      } else { stats.failed++; }
+    }
+    if ((i / CONCURRENCY) % 20 === 0) console.log(`  ...${i + batch.length}/${rows.length} (done:${stats.done} rej:${stats.rejected} fail:${stats.failed})`);
+  }
+  console.log(`\n=== ENRICH RESULTS (${Math.round((Date.now() - t0) / 1000)}s) ===`);
+  console.log(JSON.stringify(stats));
+  console.log('COST: $0 (local — Pillow + qwen2.5vl on Mac1; no paid Gemini calls)');
+  console.log('\n=== SAMPLES ===');
+  for (const s of samples) console.log(JSON.stringify(s));
+  await pool.end();
+}
+run().catch(e => { console.error('FATAL', e); process.exit(1); });

← 64278b92 Scrub residual Brewster/York from 44 product descriptions (b  ·  back to Designer Wallcoverings  ·  Anna French STEP4+5: onboarding gate (validateBeforeActivate 30bafd58 →