← 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
A shopify/scripts/anna-french-enrich.js
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 →