[object Object]

← back to Enrich Local Hybrid

hybrid local enrich-ai-tags: ground-truth hex (Pillow) + qwen2.5vl semantics, Gemini fallback, off-by-default

4008570156c933821ad2ba91ac2e8ffc69550f35 · 2026-06-24 08:52:51 -0700 · Steve

Files touched

Diff

commit 4008570156c933821ad2ba91ac2e8ffc69550f35
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 08:52:51 2026 -0700

    hybrid local enrich-ai-tags: ground-truth hex (Pillow) + qwen2.5vl semantics, Gemini fallback, off-by-default
---
 .gitignore                |   1 +
 DEPLOY.md                 |  42 +++
 apply-patch.js            |  31 ++
 enrich-ai-tags.patched.js | 855 ++++++++++++++++++++++++++++++++++++++++++++++
 enrich-local.js           | 112 ++++++
 enrich-palette.py         |  32 ++
 test-local.js             |  11 +
 7 files changed, 1084 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e43b0f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.DS_Store
diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..cd7e8ec
--- /dev/null
+++ b/DEPLOY.md
@@ -0,0 +1,42 @@
+# enrich-ai-tags → local hybrid (build + you-deploy)
+
+**What:** swaps DW image enrichment from paid Gemini to a HYBRID local pipeline —
+exact hex+% from real pixels (Pillow, ground-truth, beats Gemini) + qwen2.5vl on
+Mac1 (via Tailscale from Kamatera) for color names / styles / patterns / material /
+imageType / dims / description. Same `aiData` output contract; ~$0.
+
+**Safety:** `ENRICH_PROVIDER` defaults to `gemini`, so dropping the files changes
+nothing until you set the env. Any local failure auto-falls-back to Gemini. Verified:
+Kamatera (100.107.67.67) reaches Mac1 Ollama (100.94.103.98:11434, qwen2.5vl present).
+
+## Files
+- `enrich-palette.py`     — Pillow ground-truth palette (exact hex + %)
+- `enrich-local.js`       — hybrid analyzer, exports localAnalyze() (geminiAnalyze shape)
+- `enrich-ai-tags.patched.js` — prod script + analyze() dispatcher (off by default)
+
+## Deploy (run from Mac2)
+```bash
+# 1. Back up the live prod script
+ssh my-server 'cp /root/DW-Agents/vendor-scrapers/enrich-ai-tags.js /root/DW-Agents/vendor-scrapers/enrich-ai-tags.js.bak-gemini-$(date +%Y%m%d)'
+
+# 2. Copy the 3 files up; swap the patched main into place
+scp enrich-local.js enrich-palette.py enrich-ai-tags.patched.js my-server:/root/DW-Agents/vendor-scrapers/
+ssh my-server 'cd /root/DW-Agents/vendor-scrapers && mv enrich-ai-tags.patched.js enrich-ai-tags.js'
+
+# 3. DRY-RUN with local provider (NO DB writes) — proves Kamatera→Mac1 + quality
+ssh my-server 'cd /root/DW-Agents/vendor-scrapers && ENRICH_PROVIDER=local node enrich-ai-tags.js thibaut --dry-run --limit 3'
+```
+
+## GO LIVE (the gated step — only after the dry-run looks good)
+Find the nightly cron that runs enrich and add `ENRICH_PROVIDER=local` to its env:
+```bash
+ssh my-server 'crontab -l | grep -i enrich'      # find the line
+# edit that cron entry / its wrapper to export ENRICH_PROVIDER=local before node enrich-ai-tags.js
+```
+**Rollback:** unset `ENRICH_PROVIDER` (or set `=gemini`) → instantly back on Gemini.
+Or restore `enrich-ai-tags.js.bak-gemini-*`.
+
+## Env knobs
+- `ENRICH_PROVIDER` = local | hybrid | gemini (default gemini)
+- `ENRICH_OLLAMA_URL` = http://100.94.103.98:11434 (Mac1 tailnet; default)
+- `ENRICH_VL_MODEL`   = qwen2.5vl:7b (default)
diff --git a/apply-patch.js b/apply-patch.js
new file mode 100644
index 0000000..ca55002
--- /dev/null
+++ b/apply-patch.js
@@ -0,0 +1,31 @@
+const fs = require('fs');
+let s = fs.readFileSync('/tmp/enrich-ai-tags.js', 'utf8');
+const orig = s;
+
+// 1) require the hybrid module — after the GEMINI_HOST const
+const anchor1 = "const GEMINI_HOST    = 'generativelanguage.googleapis.com';";
+s = s.replace(anchor1, anchor1 +
+  "\n\n// --- HYBRID LOCAL ENRICHMENT (off unless ENRICH_PROVIDER=local|hybrid) ---\n" +
+  "const { localAnalyze } = require('./enrich-local');\n" +
+  "const ENRICH_PROVIDER = process.env.ENRICH_PROVIDER || 'gemini';");
+
+// 2) dispatcher just before geminiAnalyze definition
+const anchor2 = "async function geminiAnalyze(imageUrl, mode, attempt = 0) {";
+s = s.replace(anchor2,
+  "// analyze(): try local hybrid first (if enabled); on ANY local failure fall back to Gemini.\n" +
+  "async function analyze(imageUrl, mode) {\n" +
+  "  if (ENRICH_PROVIDER === 'local' || ENRICH_PROVIDER === 'hybrid') {\n" +
+  "    try { return await localAnalyze(imageUrl, mode, fetchBuffer); }\n" +
+  "    catch (e) { console.log(`    local enrich failed (${e.message}) — Gemini fallback`); }\n" +
+  "  }\n" +
+  "  return geminiAnalyze(imageUrl, mode);\n" +
+  "}\n\n" + anchor2);
+
+// 3) main call site -> analyze()
+const anchor3 = "aiData = await geminiAnalyze([row.image_url, ...parseAllImages(row.all_images)], MODE);";
+s = s.replace(anchor3, "aiData = await analyze([row.image_url, ...parseAllImages(row.all_images)], MODE);");
+
+if (s === orig) { console.error('PATCH FAILED: no anchors matched'); process.exit(1); }
+const hits = [anchor1, "async function analyze", "await analyze("].filter(a => s.includes(a)).length;
+fs.writeFileSync('/tmp/enrich-ai-tags.patched.js', s);
+console.log(`applied; ${hits}/3 markers present`);
diff --git a/enrich-ai-tags.patched.js b/enrich-ai-tags.patched.js
new file mode 100644
index 0000000..248945a
--- /dev/null
+++ b/enrich-ai-tags.patched.js
@@ -0,0 +1,855 @@
+#!/usr/bin/env node
+/**
+ * enrich-ai-tags.js — Gemini AI Image Tagger for Vendor Catalog
+ *
+ * Enriches PostgreSQL vendor_catalog rows that have an image_url but are
+ * missing AI-generated tags. Sends the image to Gemini 2.0 Flash for
+ * analysis and writes results back to the catalog table.
+ *
+ * Two analysis modes:
+ *   RESIDENTIAL — decorative vendors (Thibaut, Romo, Cole & Son, etc.)
+ *   COMMERCIAL  — architectural vendors (Koroseal, Innovations, etc.)
+ *
+ * Usage:
+ *   node enrich-ai-tags.js <vendor_code> [--mode residential|commercial]
+ *                          [--dry-run] [--limit N] [--force]
+ *
+ * @module enrich-ai-tags
+ */
+
+'use strict';
+
+const https = require('https');
+const http  = require('http');
+const { Pool } = require('pg');
+const tracking = require('../full-monte/tracking');
+const { trackGeminiCall } = require('../shared/gemini-cost-tracker');
+
+// ── Configuration ──────────────────────────────────────────────────────────
+
+const DB_URL = 'postgresql://dw_admin@127.0.0.1:5432/dw_unified';
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';
+// Free-tier fallback chain: each model has a SEPARATE per-day free quota, so when one
+// hits its daily 429 cap we fail over to the next free model before giving up. Resets to
+// the first model each process run. Override via GEMINI_MODEL_CHAIN (comma-separated).
+const MODEL_CHAIN = (process.env.GEMINI_MODEL_CHAIN || 'gemini-2.5-flash-lite,gemini-2.5-flash,gemini-2.5-pro')
+  .split(',').map(function (m) { return m.trim(); }).filter(Boolean);
+let modelIdx = 0;
+let GEMINI_MODEL = MODEL_CHAIN[0];
+const GEMINI_HOST    = 'generativelanguage.googleapis.com';
+
+// --- HYBRID LOCAL ENRICHMENT (off unless ENRICH_PROVIDER=local|hybrid) ---
+const { localAnalyze } = require('./enrich-local');
+const ENRICH_PROVIDER = process.env.ENRICH_PROVIDER || 'gemini';
+
+/** Vendors that are always treated as commercial / architectural */
+const COMMERCIAL_VENDORS = new Set([
+  'koroseal', 'innovations', 'wolf_gordon', 'designtex',
+  'denovowall', 'newmor', 'versa_ds',
+]);
+
+/** Color → parent-color mapping for residential tagging */
+const COLOR_PARENTS = {
+  navy: 'Blue', cobalt: 'Blue', indigo: 'Blue', sky: 'Blue', teal: 'Blue',
+  aqua: 'Blue', cerulean: 'Blue', sapphire: 'Blue', 'baby blue': 'Blue',
+  sage: 'Green', olive: 'Green', emerald: 'Green', forest: 'Green',
+  mint: 'Green', chartreuse: 'Green', lime: 'Green', hunter: 'Green',
+  coral: 'Red', crimson: 'Red', scarlet: 'Red', burgundy: 'Red',
+  'wine red': 'Red', tomato: 'Red', brick: 'Red', maroon: 'Red',
+  rose: 'Pink', blush: 'Pink', fuchsia: 'Pink', mauve: 'Pink',
+  salmon: 'Pink', 'dusty rose': 'Pink', hot: 'Pink',
+  mustard: 'Yellow', lemon: 'Yellow', gold: 'Yellow', saffron: 'Yellow',
+  butter: 'Yellow', canary: 'Yellow',
+  lavender: 'Purple', violet: 'Purple', plum: 'Purple', lilac: 'Purple',
+  grape: 'Purple', amethyst: 'Purple',
+  tangerine: 'Orange', rust: 'Orange', amber: 'Orange', terracotta: 'Orange',
+  apricot: 'Orange', peach: 'Orange', burnt: 'Orange',
+  chocolate: 'Brown', espresso: 'Brown', caramel: 'Brown', mocha: 'Brown',
+  taupe: 'Brown', sienna: 'Brown', walnut: 'Brown', mahogany: 'Brown',
+  charcoal: 'Gray', slate: 'Gray', silver: 'Gray', pewter: 'Gray',
+  ash: 'Gray', dove: 'Gray', smoke: 'Gray', steel: 'Gray',
+  ivory: 'White', cream: 'White', linen: 'White', alabaster: 'White',
+  snow: 'White', bone: 'White', pearl: 'White', off: 'White',
+  ebony: 'Black', jet: 'Black', onyx: 'Black', coal: 'Black',
+};
+
+/** Animal keywords that trigger the Animal/Insects tag */
+const ANIMAL_KEYWORDS = [
+  'bird', 'butterfly', 'dragonfly', 'bee', 'beetle', 'crane', 'heron',
+  'peacock', 'horse', 'deer', 'fox', 'rabbit', 'tiger', 'leopard',
+  'zebra', 'elephant', 'fish', 'whale', 'octopus', 'snake', 'frog',
+  'insect', 'bug', 'cat', 'dog', 'lion', 'owl', 'parrot', 'flamingo',
+  'duck', 'swan', 'pheasant', 'monkey', 'cow',
+];
+
+// ── Argument parsing ───────────────────────────────────────────────────────
+
+const argv = process.argv.slice(2);
+
+if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
+  console.log([
+    '',
+    'Usage: node enrich-ai-tags.js <vendor_code> [options]',
+    '',
+    'Options:',
+    '  --mode residential|commercial  Override auto-detected mode',
+    '  --dry-run                      Print analysis results without writing to DB',
+    '  --limit N                      Process only N products',
+    '  --force                        Re-analyze products that already have ai_tags',
+    '                                 AND skip enrichment_tracking dedup check',
+    '  --budget N                     Daily dollar cap (default $25)',
+    '',
+    'Examples:',
+    '  node enrich-ai-tags.js thibaut --limit 50',
+    '  node enrich-ai-tags.js koroseal --dry-run',
+    '  node enrich-ai-tags.js brewster_york --mode residential --force --limit 10',
+    '  node enrich-ai-tags.js maya_romanoff --budget 10 --limit 100',
+    '',
+  ].join('\n'));
+  process.exit(0);
+}
+
+const vendorCode = argv[0];
+const DRY_RUN    = argv.includes('--dry-run');
+const FORCE      = argv.includes('--force');
+
+const modeArg = argv[argv.findIndex(a => a === '--mode') + 1];
+const LIMIT_N = parseInt(argv[argv.findIndex(a => a === '--limit') + 1]) || null;
+
+// Budget cap: daily dollar limit (default $25). Estimate ~$0.0006 per Gemini call.
+const COST_PER_CALL = 0.0006;
+const budgetIdx = argv.findIndex(a => a === '--budget');
+const BUDGET    = budgetIdx >= 0 ? parseFloat(argv[budgetIdx + 1]) || 25 : 25;
+
+const isCommercial = modeArg === 'commercial'
+  ? true
+  : modeArg === 'residential'
+    ? false
+    : COMMERCIAL_VENDORS.has(vendorCode);
+
+const MODE = isCommercial ? 'COMMERCIAL' : 'RESIDENTIAL';
+
+// ── Utilities ──────────────────────────────────────────────────────────────
+
+/** @param {number} ms */
+function sleep(ms) {
+  return new Promise(r => setTimeout(r, ms));
+}
+
+/**
+ * Title-case a string, with a list of minor words that stay lower-case.
+ * @param {string} str
+ * @returns {string}
+ */
+function toTitleCase(str) {
+  if (!str) return '';
+  const minor = new Set(['a', 'an', 'the', 'in', 'on', 'at', 'for', 'and', 'but', 'or', 'of', 'to']);
+  return str.split(' ').map((w, i) => {
+    if (i > 0 && minor.has(w.toLowerCase())) return w.toLowerCase();
+    return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
+  }).join(' ');
+}
+
+/**
+ * Given an array of color strings, expand each with its parent color.
+ * E.g. ["Navy"] → ["Navy", "Blue"]
+ * @param {string[]} colors
+ * @returns {string[]}
+ */
+function expandColorsWithParents(colors) {
+  const result = new Set();
+  for (const color of colors) {
+    const c = toTitleCase(color);
+    result.add(c);
+    const key = color.toLowerCase();
+    // Check each word in the color name against the parent map
+    for (const word of key.split(/\s+/)) {
+      if (COLOR_PARENTS[word]) result.add(COLOR_PARENTS[word]);
+    }
+    if (COLOR_PARENTS[key]) result.add(COLOR_PARENTS[key]);
+  }
+  return [...result];
+}
+
+/**
+ * Detect if any animal keywords appear in the AI description or pattern list.
+ * @param {object} aiData
+ * @returns {boolean}
+ */
+function detectAnimals(aiData) {
+  const haystack = [
+    (aiData.description || '').toLowerCase(),
+    (aiData.animalTypes || []).join(' ').toLowerCase(),
+    aiData.hasAnimals === true ? 'animal' : '',
+  ].join(' ');
+
+  return ANIMAL_KEYWORDS.some(kw => haystack.includes(kw));
+}
+
+// ── Image download (supports http + https, follows redirects) ──────────────
+
+/**
+ * Download an image URL to a Buffer, following up to 5 redirects.
+ * @param {string} url
+ * @param {number} [redirects]
+ * @returns {Promise<Buffer>}
+ */
+function fetchBuffer(url, redirects = 0) {
+  return new Promise((resolve, reject) => {
+    if (redirects > 5) return reject(new Error('Too many redirects'));
+    let parsed;
+    try { parsed = new URL(url); } catch (e) { return reject(new Error(`Bad URL: ${url}`)); }
+
+    const mod = parsed.protocol === 'https:' ? https : http;
+    const req = mod.get(url, {
+      headers: {
+        'User-Agent': 'Mozilla/5.0 (compatible; DWCatalogBot/1.0)',
+        'Accept': 'image/*',
+      },
+      timeout: 30000,
+    }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        const next = res.headers.location.startsWith('http')
+          ? res.headers.location
+          : `${parsed.protocol}//${parsed.host}${res.headers.location}`;
+        res.resume();
+        return fetchBuffer(next, redirects + 1).then(resolve, reject);
+      }
+      if (res.statusCode !== 200) {
+        res.resume();
+        return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+      }
+      const chunks = [];
+      res.on('data', c => chunks.push(c));
+      res.on('end', () => resolve(Buffer.concat(chunks)));
+      res.on('error', reject);
+    });
+    req.on('error', reject);
+    req.on('timeout', () => { req.destroy(); reject(new Error('Download timeout')); });
+  });
+}
+
+/**
+ * Guess MIME type from URL extension.
+ * @param {string} url
+ * @returns {string}
+ */
+function guessMime(url) {
+  const ext = url.split('?')[0].split('.').pop().toLowerCase();
+  const map = { png: 'image/png', gif: 'image/gif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
+  return map[ext] || 'image/jpeg';
+}
+
+// ── Gemini API ─────────────────────────────────────────────────────────────
+
+/**
+ * Build the Gemini prompt based on analysis mode.
+ * @param {'RESIDENTIAL'|'COMMERCIAL'} mode
+ * @returns {string}
+ */
+function buildPrompt(mode) {
+  const commonSchema = `{
+  "backgroundColor": "the single dominant background color name (the color behind any pattern — what the wall looks like from 10 feet away)",
+  "backgroundHex": "#XXXXXX hex code of the background color",
+  "colors": [
+    {"name": "color name", "hex": "#XXXXXX", "percentage": 45},
+    {"name": "color name", "hex": "#XXXXXX", "percentage": 30},
+    {"name": "color name", "hex": "#XXXXXX", "percentage": 15},
+    {"name": "color name", "hex": "#XXXXXX", "percentage": 10}
+  ],
+  "dominantHex": "#XXXXXX hex code of the single most dominant color overall",
+  "styles": ["1-2 from: Traditional, Contemporary, Art Deco, Mid-Century Modern, Victorian, Chinoiserie, Bohemian, Tropical, Coastal, Scandinavian, Industrial, Farmhouse, Minimalist"],
+  "patterns": ["1-2 from: Damask, Floral, Geometric, Botanical, Toile, Trellis, Abstract, Stripe, Solid, Textured, Chintz, Paisley, Ikat, Chevron, Herringbone"],
+  "material": "estimated material type (Vinyl, Paper, Grasscloth, Silk, Non-woven, etc.)",
+  "hasAnimals": false,
+  "animalTypes": [],
+  "commercialUse": ["suitable environments: office, hospitality, healthcare, residential"],
+  "description": "Exactly 3 sentences: design/pattern, mood/style, recommended use. Professional interior design tone.",
+  "imageType": "one of: scan_swatch, scan_flatbed, photo_full, photo_crop, render",
+  "physicalWidthInches": 0,
+  "physicalHeightInches": 0
+}
+
+COLOR RULES:
+- List ALL visible colors with hex codes and approximate percentage of image area each occupies
+- Percentages must sum to 100
+- "backgroundColor" is specifically the BACKGROUND — the base color behind any pattern, motif, or texture
+- "dominantHex" is the overall most prominent hex — may or may not be the background
+- Include minimum 2 colors, maximum 6
+- Use specific color names (not just "Blue" — use "Navy", "Cobalt", "Steel Blue", etc.)`;
+
+  const imageTypeNote = `
+Also classify this image source:
+- "imageType": one of "scan_swatch" (small swatch scan, typically 5"x5"), "scan_flatbed" (large flatbed scan, 8"x10"+), "photo_full" (full roll or sheet photograph), "photo_crop" (cropped photo detail), or "render" (3D/digital render).
+- "physicalWidthInches": estimated physical width of the depicted material in inches (not pixel size).
+- "physicalHeightInches": estimated physical height of the depicted material in inches (not pixel size).`;
+
+  const qualityGate = `
+IMAGE QUALITY GATE — CHECK FIRST:
+Before analyzing colors, check if this image is usable as a product image:
+- "image_rejected": true if the image contains ANY of: marketing text overlays, brand logos, watermarks, Korean/Chinese/Japanese text labels, product codes stamped on the image, furniture/accessories that obscure the pattern (handbags, vases, chairs covering >30% of image), promotional badges ("NEW", "SALE", etc.), or if image resolution is too low (<200px in either dimension)
+- "rejection_reason": short explanation if rejected (e.g. "Korean marketing text overlay", "brand logo covers pattern", "furniture obscures wallpaper")
+If image_rejected is true, still attempt color analysis but flag it. Products with rejected images should NOT go live.`;
+
+  if (mode === 'COMMERCIAL') {
+    return `Analyze this commercial wallcovering image. Use "Wallcovering" not "Wallpaper". Return ONLY valid JSON (no markdown):
+${commonSchema}
+${qualityGate}
+Note: For commercial/architectural products, include relevant tags like "Architectural", "Commercial", and "Class A Fire Rated" if the material appears to be vinyl or synthetic.
+${imageTypeNote}`;
+  }
+
+  return `Analyze this wallcovering image. Return ONLY valid JSON (no markdown):
+${commonSchema}
+${qualityGate}
+${imageTypeNote}`;
+}
+
+/**
+ * Send an image to Gemini 2.0 Flash for analysis.
+ * @param {string} imageUrl
+ * @param {'RESIDENTIAL'|'COMMERCIAL'} mode
+ * @returns {Promise<object|null>} Parsed AI response or null on failure
+ */
+// Keystone (2026-06-13): when a row's primary image_url is dead (404), fall back to a live
+// entry in all_images[] before skipping. Recovers rows whose stored CDN URL rotted but whose
+// gallery still resolves. parseAllImages handles jsonb arrays of strings or {url|src} objects,
+// JSON-string columns, and comma-joined text.
+function parseAllImages(v) {
+  if (!v) return [];
+  let arr = v;
+  if (typeof v === 'string') { try { arr = JSON.parse(v); } catch (_) { return v.split(',').map(x => x.trim()).filter(Boolean); } }
+  if (!Array.isArray(arr)) return [];
+  return arr.map(x => typeof x === 'string' ? x : (x && (x.url || x.src || x.image_url || x.image))).filter(Boolean);
+}
+
+// 429 circuit-breaker: bounded per-image retries + abort the whole run after a streak
+// of consecutive quota-exhausted images (free-tier daily cap) instead of spinning forever.
+const MAX_429_RETRIES = 3;
+const QUOTA_BREAK_STREAK = 5;
+let consecutive429 = 0;
+
+// analyze(): try local hybrid first (if enabled); on ANY local failure fall back to Gemini.
+async function analyze(imageUrl, mode) {
+  if (ENRICH_PROVIDER === 'local' || ENRICH_PROVIDER === 'hybrid') {
+    try { return await localAnalyze(imageUrl, mode, fetchBuffer); }
+    catch (e) { console.log(`    local enrich failed (${e.message}) — Gemini fallback`); }
+  }
+  return geminiAnalyze(imageUrl, mode);
+}
+
+async function geminiAnalyze(imageUrl, mode, attempt = 0) {
+  // Accept a single URL or a candidate list ([primary, ...all_images]); fetch the first live one.
+  const candidates = (Array.isArray(imageUrl) ? imageUrl : [imageUrl]).filter(Boolean);
+  let imageBuffer, resolvedUrl;
+  for (const cand of candidates) {
+    try { imageBuffer = await fetchBuffer(cand); resolvedUrl = cand; break; } catch (err) { /* try next candidate */ }
+  }
+  if (!imageBuffer) {
+    console.log(`    SKIP: Cannot fetch image — all ${candidates.length} candidate URL(s) failed`);
+    return null;
+  }
+  imageUrl = resolvedUrl; // from here on, use the URL that actually fetched
+
+  const mimeType   = guessMime(imageUrl);
+  const imageB64   = imageBuffer.toString('base64');
+  const promptText = buildPrompt(mode);
+
+  const requestBody = JSON.stringify({
+    contents: [{
+      parts: [
+        { inlineData: { mimeType, data: imageB64 } },
+        { text: promptText },
+      ],
+    }],
+    generationConfig: {
+      temperature: 0.1,
+      maxOutputTokens: 1024,
+    },
+  });
+
+  return new Promise((resolve, reject) => {
+    const path = `/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
+    const req = https.request({
+      hostname: GEMINI_HOST,
+      port: 443,
+      path,
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(requestBody),
+      },
+    }, res => {
+      let raw = '';
+      res.on('data', c => raw += c);
+      res.on('end', () => {
+        if (res.statusCode === 429) {
+          if (attempt >= MAX_429_RETRIES) {
+            if (modelIdx < MODEL_CHAIN.length - 1) {
+              modelIdx++;
+              GEMINI_MODEL = MODEL_CHAIN[modelIdx];
+              consecutive429 = 0;
+              console.log(`    Gemini 429 — failing over to free model #${modelIdx + 1}/${MODEL_CHAIN.length}: ${GEMINI_MODEL}`);
+              return geminiAnalyze(imageUrl, mode, 0).then(resolve).catch(reject);
+            }
+            consecutive429++;
+            console.log(`    Gemini 429 — all ${MODEL_CHAIN.length} free models exhausted; skip image (streak ${consecutive429}/${QUOTA_BREAK_STREAK})`);
+            return resolve(null);
+          }
+          console.log(`    Gemini 429 — waiting 5s, retry ${attempt + 1}/${MAX_429_RETRIES} on ${GEMINI_MODEL}...`);
+          setTimeout(() => geminiAnalyze(imageUrl, mode, attempt + 1).then(resolve).catch(reject), 5000);
+          return;
+        }
+        if (res.statusCode >= 400) {
+          console.log(`    Gemini API error ${res.statusCode}: ${raw.substring(0, 120)}`);
+          return resolve(null);
+        }
+        try {
+          const envelope = JSON.parse(raw);
+          const text = envelope?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+          // Strip optional markdown code fences: ```json ... ```
+          const stripped = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
+          const jsonMatch = stripped.match(/\{[\s\S]*\}/);
+          if (!jsonMatch) {
+            console.log(`    Gemini: no JSON found in response`);
+            return resolve(null);
+          }
+          // Track Gemini cost
+          const usage = envelope?.usageMetadata || {};
+          trackGeminiCall({
+            model: GEMINI_MODEL,
+            inputTokens: usage.promptTokenCount || 1000,
+            outputTokens: usage.candidatesTokenCount || 500,
+            task: `enrich-ai-tags ${vendorCode}`,
+          }).catch(() => {});
+          resolve(JSON.parse(jsonMatch[0]));
+        } catch (e) {
+          console.log(`    Gemini parse error: ${e.message}`);
+          resolve(null);
+        }
+      });
+    });
+    req.on('error', err => {
+      console.log(`    Gemini request error: ${err.message}`);
+      resolve(null);
+    });
+    req.setTimeout(60000, () => { req.destroy(); resolve(null); });
+    req.write(requestBody);
+    req.end();
+  });
+}
+
+// ── Tag building ───────────────────────────────────────────────────────────
+
+/**
+ * Convert raw Gemini output into the final set of DB columns.
+ * @param {object} aiData   Raw Gemini JSON response
+ * @param {'RESIDENTIAL'|'COMMERCIAL'} mode
+ * @returns {object} Columns ready for DB update
+ */
+function buildDbPayload(aiData, mode) {
+  // Image quality gate — check for rejected images
+  if (aiData.image_rejected) {
+    console.log(`    IMAGE REJECTED: ${aiData.rejection_reason || 'unknown reason'}`);
+  }
+
+  const bgColor  = toTitleCase(aiData.backgroundColor || '');
+  const bgHex    = (aiData.backgroundHex || '').toUpperCase();
+  const dominantHex = (aiData.dominantHex || '').toUpperCase();
+  const styles   = (aiData.styles   || []).map(s => toTitleCase(s));
+  const patterns = (aiData.patterns || []).map(p => toTitleCase(p));
+  const material = toTitleCase(aiData.material || '');
+  const desc     = (aiData.description || '').replace(/wallpaper/gi, 'Wallcovering');
+
+  // Handle new color format: [{name, hex, percentage}] or legacy ["color1", "color2"]
+  const rawColorsInput = aiData.colors || [];
+  let colorDetails = []; // Full objects with name/hex/percentage
+  let rawColors = [];     // Just color name strings for tags
+
+  if (rawColorsInput.length > 0 && typeof rawColorsInput[0] === 'object') {
+    // New format: [{name, hex, percentage}]
+    colorDetails = rawColorsInput.map(c => ({
+      name: toTitleCase(c.name || ''),
+      hex: (c.hex || '').toUpperCase(),
+      percentage: parseInt(c.percentage) || 0
+    })).filter(c => c.name);
+    rawColors = colorDetails.map(c => c.name);
+  } else {
+    // Legacy format: ["color1", "color2"]
+    rawColors = rawColorsInput.map(c => toTitleCase(c));
+    colorDetails = rawColors.map(name => ({ name, hex: '', percentage: 0 }));
+  }
+
+  // Residential: expand colors with parents
+  const colors = mode === 'RESIDENTIAL'
+    ? expandColorsWithParents(rawColors)
+    : rawColors;
+
+  // Assemble tags array — color NAMES only (no hex codes in tags)
+  // Hex codes stay in color_hex, dominant_color_hex, and ai_colors fields
+  const tagSet = new Set([...colors, ...styles, ...patterns]);
+
+  if (bgColor) tagSet.add(bgColor); // No "Background Color" prefix per MEMORY rules
+  if (material) tagSet.add(material);
+
+  if (mode === 'COMMERCIAL') {
+    tagSet.add('Architectural');
+    tagSet.add('Commercial');
+    const commercialUse = aiData.commercialUse || [];
+    commercialUse.forEach(u => tagSet.add(toTitleCase(u)));
+    // Only add fire rating tag if material looks synthetic
+    const syntheticMaterials = ['vinyl', 'type ii', 'type i', 'pvc', 'synthetic', 'polyester'];
+    if (syntheticMaterials.some(s => material.toLowerCase().includes(s))) {
+      tagSet.add('Class A Fire Rated');
+    }
+  } else {
+    // Residential: detect animals in description or explicit fields
+    if (detectAnimals(aiData)) {
+      tagSet.add('Animal');
+      tagSet.add('Insects');
+      (aiData.animalTypes || []).forEach(a => tagSet.add(toTitleCase(a)));
+    }
+  }
+
+  return {
+    ai_background_color: bgColor || null,
+    ai_colors:           JSON.stringify(colorDetails.length > 0 ? colorDetails : colors),
+    ai_styles:           JSON.stringify(styles),
+    ai_patterns:         JSON.stringify(patterns),
+    ai_tags:             JSON.stringify([...tagSet].filter(Boolean)),
+    ai_description:      desc || null,
+    color_hex:           dominantHex || bgHex || (colorDetails[0] ? colorDetails[0].hex : '') || null,
+    dominant_color_hex:  dominantHex || null,
+    image_rejected:      aiData.image_rejected ? true : false,
+    image_rejection_reason: aiData.image_rejected ? (aiData.rejection_reason || 'unknown') : null,
+  };
+}
+
+// ── Database ───────────────────────────────────────────────────────────────
+
+const pool = new Pool({ connectionString: DB_URL });
+
+/**
+ * Resolve the catalog table for a vendor from vendor_registry.
+ * Falls back to 'vendor_catalog' if not found.
+ * @param {string} vendorCode
+ * @returns {Promise<string>}
+ */
+async function resolveCatalogTable(vendorCode) {
+  const { rows } = await pool.query(
+    'SELECT catalog_table FROM vendor_registry WHERE vendor_code = $1 AND catalog_table IS NOT NULL LIMIT 1',
+    [vendorCode]
+  );
+  return rows[0]?.catalog_table || 'vendor_catalog';
+}
+
+/**
+ * Ensure the AI columns exist in the target catalog table.
+ * Runs CREATE COLUMN IF NOT EXISTS for each — safe to re-run.
+ * @param {string} tableName
+ * @returns {Promise<void>}
+ */
+async function ensureColumns(tableName) {
+  const columns = [
+    { name: 'ai_colors',              type: 'text' },
+    { name: 'ai_background_color',    type: 'text' },
+    { name: 'ai_styles',              type: 'text' },
+    { name: 'ai_patterns',            type: 'text' },
+    { name: 'ai_tags',                type: 'text' },
+    { name: 'ai_description',         type: 'text' },
+    { name: 'color_hex',              type: 'varchar(7)' },
+    { name: 'dominant_color_hex',     type: 'varchar(7)' },
+    { name: 'image_rejected',         type: 'boolean DEFAULT false' },
+    { name: 'image_rejection_reason', type: 'text' },
+  ];
+
+  for (const col of columns) {
+    await pool.query(
+      `ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`
+    );
+  }
+}
+
+/**
+ * Load the rows to process from the vendor's catalog table.
+ * @param {string} vendorCode
+ * @param {string} tableName  Resolved catalog table
+ * @param {boolean} force   If false, skip rows that already have ai_tags
+ * @param {number|null} limit
+ * @returns {Promise<Array<{id:number, mfr_sku:string, pattern_name:string, color_name:string, image_url:string}>>}
+ */
+async function loadProducts(vendorCode, tableName, force, limit) {
+  // Check which columns exist in this table (vendor tables have different schemas)
+  const { rows: colInfo } = await pool.query(`
+    SELECT column_name FROM information_schema.columns
+    WHERE table_name = $1 AND column_name IN ('vendor_code', 'pattern_name', 'product_name', 'color_name', 'colour_name', 'all_images')
+  `, [tableName]);
+  const colNames = new Set(colInfo.map(r => r.column_name));
+  const hasVendorCode = colNames.has('vendor_code');
+  const hasAllImages = colNames.has('all_images');
+  const nameCol = colNames.has('pattern_name') ? 'pattern_name' : colNames.has('product_name') ? 'product_name' : "'unknown'";
+  const colorCol = colNames.has('color_name') ? 'color_name' : colNames.has('colour_name') ? 'colour_name' : "''";
+
+  const conditions = [`image_url IS NOT NULL`, `image_url != ''`];
+  const params = [];
+
+  if (hasVendorCode) {
+    conditions.push(`vendor_code = $${params.length + 1}`);
+    params.push(vendorCode);
+  }
+
+  if (!force) {
+    // Handle both text and jsonb column types for ai_tags
+    conditions.push(`(ai_tags IS NULL OR ai_tags::text = '' OR ai_tags::text = '[]' OR ai_tags::text = 'null')`);
+  }
+
+  let sql = `
+    SELECT id, mfr_sku, ${nameCol} AS pattern_name, ${colorCol} AS color_name, image_url${hasAllImages ? ', all_images' : ''}
+    FROM ${tableName}
+    WHERE ${conditions.join(' AND ')}
+    ORDER BY id DESC
+  `;
+
+  if (limit) {
+    sql += ` LIMIT $${params.length + 1}`;
+    params.push(limit);
+  }
+
+  const { rows } = await pool.query(sql, params);
+  return rows;
+}
+
+/**
+ * Write the AI-generated columns back to a single catalog row.
+ * @param {string} tableName  Resolved catalog table
+ * @param {number} id
+ * @param {object} payload   Output from buildDbPayload()
+ * @returns {Promise<void>}
+ */
+async function updateRow(tableName, id, payload) {
+  const setClauses = [];
+  const values     = [];
+  let   idx        = 1;
+
+  for (const [key, val] of Object.entries(payload)) {
+    if (val !== null && val !== undefined) {
+      setClauses.push(`${key} = $${idx}`);
+      values.push(val);
+      idx++;
+    }
+  }
+
+  if (setClauses.length === 0) return;
+
+  values.push(id);
+  await pool.query(
+    `UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE id = $${idx}`,
+    values
+  );
+}
+
+/**
+ * Fetch the human-readable vendor name from vendor_registry.
+ * @param {string} code
+ * @returns {Promise<string>}
+ */
+async function getVendorName(code) {
+  const { rows } = await pool.query(
+    'SELECT vendor_name FROM vendor_registry WHERE vendor_code = $1 LIMIT 1',
+    [code]
+  );
+  return rows[0]?.vendor_name || code;
+}
+
+// ── Main ───────────────────────────────────────────────────────────────────
+
+async function main() {
+  console.log('======================================================================');
+  console.log('  ENRICH-AI-TAGS — Gemini Image Analyzer for Vendor Catalog');
+  console.log('======================================================================');
+  console.log(`  Vendor  : ${vendorCode}`);
+  console.log(`  Mode    : ${MODE}`);
+  console.log(`  Dry run : ${DRY_RUN}`);
+  console.log(`  Force   : ${FORCE}`);
+  console.log(`  Limit   : ${LIMIT_N || 'none'}`);
+  console.log(`  Budget  : $${BUDGET} (est. $${COST_PER_CALL}/call)`);
+  console.log(`  Tracking: enrichment_tracking phase 3 dedup${FORCE ? ' (BYPASSED — --force)' : ''}`);
+
+  // Resolve the catalog table for this vendor
+  const catalogTable = await resolveCatalogTable(vendorCode);
+  console.log(`  Table   : ${catalogTable}`);
+  console.log('======================================================================\n');
+
+  // Ensure AI columns exist — idempotent DDL, always runs (even in dry-run)
+  process.stdout.write(`Ensuring AI columns exist in ${catalogTable}... `);
+  await ensureColumns(catalogTable);
+  console.log('done\n');
+
+  const vendorName = await getVendorName(vendorCode);
+  const products   = await loadProducts(vendorCode, catalogTable, FORCE, LIMIT_N);
+
+  if (products.length === 0) {
+    console.log(`No products found for vendor "${vendorCode}" that need AI enrichment.`);
+    console.log('(Use --force to re-process already-tagged products)\n');
+    await pool.end();
+    return;
+  }
+
+  console.log(`Found ${products.length} products for ${vendorName} to analyze\n`);
+
+  let updated  = 0;
+  let skipped  = 0;
+  let deduped  = 0;
+  let errors   = 0;
+  let apiCalls = 0;
+  let spentDollars = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const row   = products[i];
+    const label = `[${i + 1}/${products.length}]`;
+    const sku   = row.mfr_sku || 'NO-SKU';
+    const name  = [row.pattern_name, row.color_name].filter(Boolean).join(' / ') || sku;
+
+    process.stdout.write(`${label} ${sku} — ${name.substring(0, 45)}`);
+
+    // ── Budget check ─────────────────────────────────────────────────
+    if (spentDollars >= BUDGET) {
+      console.log(`  → STOP (budget cap $${BUDGET} reached, spent ~$${spentDollars.toFixed(4)})`);
+      break;
+    }
+
+    // ── Dedup check via enrichment_tracking (phase 3) ────────────────
+    if (!FORCE && sku !== 'NO-SKU') {
+      try {
+        const already = await tracking.isProcessed(vendorCode, sku, 3);
+        if (already) {
+          console.log('  → SKIP (already tracked in phase 3)');
+          deduped++;
+          continue;
+        }
+      } catch (dedupErr) {
+        // If tracking table is unavailable, log and proceed without dedup
+        console.log(`  → WARN: dedup check failed: ${dedupErr.message}`);
+      }
+    }
+
+    // ── Call Gemini (1 req/s — well inside the free-tier 15 RPM) ─────
+    let aiData;
+    try {
+      aiData = await analyze([row.image_url, ...parseAllImages(row.all_images)], MODE);
+      apiCalls++;
+      spentDollars += COST_PER_CALL;
+    } catch (geminiErr) {
+      console.log(`  → ERROR: ${geminiErr.message}`);
+      errors++;
+      try {
+        await tracking.recordError(vendorCode, sku, catalogTable, geminiErr.message);
+      } catch (_) { /* tracking error is non-fatal */ }
+      continue;
+    }
+
+    // circuit-breaker: a success clears the streak; too many quota-429s in a row => stop cleanly
+    if (aiData) consecutive429 = 0;
+    else if (consecutive429 >= QUOTA_BREAK_STREAK) {
+      console.log(`\n  ⚠️  ${consecutive429} consecutive 429s — Gemini daily free quota exhausted. Stopping run cleanly.`);
+      break;
+    }
+
+    if (!aiData) {
+      console.log('  → SKIP (no AI data)');
+      skipped++;
+      // Record as error in tracking so we know it failed
+      try {
+        await tracking.recordError(vendorCode, sku, catalogTable, 'Gemini returned no data');
+      } catch (_) { /* tracking error is non-fatal */ }
+    } else {
+      // ── Extract image type metadata from Gemini response ───────────
+      const imageType      = aiData.imageType || null;
+      const physicalWidth  = parseFloat(aiData.physicalWidthInches) || null;
+      const physicalHeight = parseFloat(aiData.physicalHeightInches) || null;
+      // Estimate tokens: ~250 input (prompt) + ~150 output + image tokens
+      const estimatedTokens = 1000;
+
+      const payload = buildDbPayload(aiData, MODE);
+      const parsedColors = JSON.parse(payload.ai_colors);
+      const tagCount = JSON.parse(payload.ai_tags).length;
+
+      // ── Validate: colors MUST be extracted before marking complete ──
+      if (parsedColors.length === 0) {
+        console.log(`  → SKIP (Gemini returned valid response but no colors extracted)`);
+        skipped++;
+        try {
+          await tracking.recordError(vendorCode, sku, catalogTable, 'Gemini returned no colors');
+        } catch (_) { /* tracking error is non-fatal */ }
+        continue;
+      }
+
+      // ── Mark phase 3 complete in enrichment_tracking AFTER validation ─
+      if (sku !== 'NO-SKU') {
+        try {
+          await tracking.markComplete(vendorCode, sku, catalogTable, 3, {
+            model: GEMINI_MODEL,
+            tokens: estimatedTokens,
+            imageType,
+            physicalWidth,
+            physicalHeight,
+          });
+        } catch (trackErr) {
+          console.log(`    WARN: tracking.markComplete failed: ${trackErr.message}`);
+        }
+      }
+
+      const colorSummary = parsedColors.slice(0, 3).map(c => typeof c === 'object' ? `${c.name} ${c.hex} ${c.percentage}%` : c).join(', ');
+      console.log(`  → bg:${payload.ai_background_color || '?'} hex:${payload.color_hex || '?'} | ${colorSummary} | tags:${tagCount}${imageType ? ' | ' + imageType : ''}`);
+
+      if (DRY_RUN) {
+        console.log(`    DRY-RUN styles=${payload.ai_styles} patterns=${payload.ai_patterns}`);
+        console.log(`    DRY-RUN desc="${(payload.ai_description || '').substring(0, 80)}"`);
+        updated++;
+      } else {
+        try {
+          await updateRow(catalogTable, row.id, payload);
+          updated++;
+        } catch (err) {
+          console.log(`    DB error: ${err.message}`);
+          errors++;
+          try {
+            await tracking.recordError(vendorCode, sku, catalogTable, `DB update: ${err.message}`);
+          } catch (_) { /* tracking error is non-fatal */ }
+        }
+      }
+    }
+
+    // Progress milestone
+    if ((i + 1) % 10 === 0) {
+      console.log(`\n  --- Progress: ${i + 1}/${products.length} | ok=${updated} skip=${skipped} dedup=${deduped} fail=${errors} spent=~$${spentDollars.toFixed(4)} ---\n`);
+    }
+
+    // Rate-limit: 600ms between calls (~100 RPM)
+    if (i < products.length - 1) await sleep(200);
+  }
+
+  console.log('\n======================================================================');
+  console.log('  ENRICH-AI-TAGS COMPLETE');
+  console.log('======================================================================');
+  console.log(`  Vendor   : ${vendorName} (${vendorCode})`);
+  console.log(`  Mode     : ${MODE}`);
+  console.log(`  Total    : ${products.length}`);
+  console.log(`  Updated  : ${updated}${DRY_RUN ? ' (dry-run, not written)' : ''}`);
+  console.log(`  Deduped  : ${deduped} (skipped via enrichment_tracking)`);
+  console.log(`  Skipped  : ${skipped} (no AI data returned)`);
+  console.log(`  Errors   : ${errors}`);
+  console.log(`  API calls: ${apiCalls}`);
+  console.log(`  Est. cost: ~$${spentDollars.toFixed(4)} of $${BUDGET} budget`);
+  console.log('======================================================================\n');
+
+  await pool.end();
+  await tracking.close();
+}
+
+main().catch(err => {
+  console.error('\nFATAL:', err.message);
+  pool.end().catch(() => {});
+  tracking.close().catch(() => {});
+  process.exit(1);
+});
diff --git a/enrich-local.js b/enrich-local.js
new file mode 100644
index 0000000..d49082a
--- /dev/null
+++ b/enrich-local.js
@@ -0,0 +1,112 @@
+// enrich-local.js — HYBRID local enrichment, drop-in companion to enrich-ai-tags.js.
+// Ground-truth hex + percentages from real pixels (Pillow, more accurate than any VLM),
+// qwen2.5vl for the semantic fields (color names, styles, patterns, material, imageType,
+// dims, description, usability). Returns the SAME aiData shape as geminiAnalyze().
+//
+// Env:
+//   ENRICH_OLLAMA_URL  Ollama base (default Mac1 via tailnet for Kamatera)
+//   ENRICH_VL_MODEL    vision model (default qwen2.5vl:7b)
+//   ENRICH_PY          python3 path (default 'python3')
+const { spawnSync } = require('child_process');
+const http = require('http');
+const path = require('path');
+const fs   = require('fs');
+const os   = require('os');
+
+const OLLAMA_URL = process.env.ENRICH_OLLAMA_URL || 'http://100.94.103.98:11434'; // Mac1 tailnet
+const VL_MODEL   = process.env.ENRICH_VL_MODEL  || 'qwen2.5vl:7b';
+const PY         = process.env.ENRICH_PY        || 'python3';
+const PALETTE_PY = path.join(__dirname, 'enrich-palette.py');
+const MAX_COLORS = 6;
+
+function samplePalette(imgPath, k = MAX_COLORS) {
+  const r = spawnSync(PY, [PALETTE_PY, imgPath, String(k)], { encoding: 'utf8', timeout: 25000, maxBuffer: 1 << 20 });
+  if (r.status !== 0 || !r.stdout) throw new Error('palette failed: ' + (r.stderr || (r.error && r.error.message) || 'no output'));
+  return JSON.parse(r.stdout); // [{hex, percentage}]
+}
+
+function ollamaVL(imageB64, palette) {
+  const prompt =
+    `This wallcovering/fabric image was pixel-sampled into these EXACT colors (hex + area%): ` +
+    `${JSON.stringify(palette)} . In the SAME ORDER, give a designer color name for each hex. ` +
+    `Also: backgroundIndex (0-based index of the base/background color in that list), styles, ` +
+    `patterns, material, imageType (scan_swatch|scan_flatbed|photo_full|photo_crop|render), ` +
+    `physicalWidthInches (number), physicalHeightInches (number), usable (false only if blank/` +
+    `corrupt/not a product), rejectionReason (short, "" if usable), description (one sentence).`;
+  const schema = { type: 'object', properties: {
+    colorNames: { type: 'array', items: { type: 'string' } },
+    backgroundIndex: { type: 'integer' },
+    styles: { type: 'array', items: { type: 'string' } },
+    patterns: { type: 'array', items: { type: 'string' } },
+    material: { type: 'string' }, imageType: { type: 'string' },
+    physicalWidthInches: { type: 'number' }, physicalHeightInches: { type: 'number' },
+    usable: { type: 'boolean' }, rejectionReason: { type: 'string' }, description: { type: 'string' },
+  }, required: ['colorNames','backgroundIndex','styles','patterns','material','imageType','physicalWidthInches','description','usable'] };
+  const body = JSON.stringify({ model: VL_MODEL, prompt, images: [imageB64], stream: false, format: schema, options: { temperature: 0.1 } });
+  return new Promise((resolve, reject) => {
+    const u = new URL(OLLAMA_URL + '/api/generate');
+    const req = http.request(
+      { hostname: u.hostname, port: u.port || 11434, path: u.pathname, method: 'POST',
+        headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 120000 },
+      res => { let raw = ''; res.on('data', c => raw += c); res.on('end', () => {
+        try { resolve(JSON.parse(JSON.parse(raw).response)); } catch (e) { reject(new Error('VL parse: ' + e.message)); }
+      }); });
+    req.on('error', reject);
+    req.on('timeout', () => req.destroy(new Error('VL timeout')));
+    req.write(body); req.end();
+  });
+}
+
+// Drop junk values ("None"/"N/A"/empty), de-dup, cap to 4 — keeps tag sets clean.
+function cleanList(arr) {
+  if (!Array.isArray(arr)) return [];
+  const seen = new Set(), out = [];
+  for (const v of arr) {
+    const s = String(v || '').trim();
+    if (!s || /^(none|n\/?a|null|undefined)$/i.test(s)) continue;
+    const key = s.toLowerCase();
+    if (seen.has(key)) continue;
+    seen.add(key); out.push(s);
+    if (out.length >= 4) break;
+  }
+  return out;
+}
+
+// localAnalyze(imageUrlOrCandidates, mode, fetchBuffer) -> aiData (geminiAnalyze shape) | null
+async function localAnalyze(imageUrl, mode, fetchBuffer) {
+  const candidates = (Array.isArray(imageUrl) ? imageUrl : [imageUrl]).filter(Boolean);
+  let buf = null;
+  for (const c of candidates) { try { buf = await fetchBuffer(c); break; } catch (e) { /* next */ } }
+  if (!buf) return null; // signal caller to fall back to Gemini
+  const tmp = path.join(os.tmpdir(), `enrich-${process.pid}-${Date.now()}.img`);
+  fs.writeFileSync(tmp, buf);
+  try {
+    const palette = samplePalette(tmp, MAX_COLORS);          // ground-truth hex + %
+    const vl = await ollamaVL(buf.toString('base64'), palette);
+    if (vl.usable === false) {
+      return { image_rejected: true, rejection_reason: vl.rejectionReason || 'not a usable product image' };
+    }
+    const names = Array.isArray(vl.colorNames) ? vl.colorNames : [];
+    const colors = palette.map((c, i) => ({ name: (names[i] || '').trim(), hex: c.hex, percentage: c.percentage }));
+    const bgIdx = (Number.isInteger(vl.backgroundIndex) && vl.backgroundIndex >= 0 && vl.backgroundIndex < colors.length) ? vl.backgroundIndex : 0;
+    const fg = colors.filter((_, i) => i !== bgIdx);          // dominant = most prominent NON-background color
+    const dominant = fg[0] || colors[0] || { hex: '' };
+    return {
+      backgroundColor: colors[bgIdx] ? colors[bgIdx].name : '',
+      backgroundHex:   colors[bgIdx] ? colors[bgIdx].hex : '',
+      dominantHex:     dominant.hex,
+      colors,
+      styles:   cleanList(vl.styles),
+      patterns: cleanList(vl.patterns),
+      material: vl.material || '',
+      imageType: vl.imageType || null,
+      physicalWidthInches:  vl.physicalWidthInches  || null,
+      physicalHeightInches: vl.physicalHeightInches || null,
+      description: vl.description || '',
+      image_rejected: false,
+      _provider: 'local-hybrid',
+    };
+  } finally { try { fs.unlinkSync(tmp); } catch (e) { /* ignore */ } }
+}
+
+module.exports = { localAnalyze, samplePalette };
diff --git a/enrich-palette.py b/enrich-palette.py
new file mode 100644
index 0000000..2cc51a8
--- /dev/null
+++ b/enrich-palette.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+"""Ground-truth color palette: exact hex + area-percentage from real pixels.
+More accurate than any VLM for hex. Merges near-duplicate buckets. Usage: enrich-palette.py <image> [k]"""
+import sys, json
+from PIL import Image
+
+def _dist(a, b):
+    return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5
+
+def palette(path, k=6, merge_thresh=30):
+    im = Image.open(path).convert("RGB")
+    im.thumbnail((220, 220))
+    q = im.quantize(colors=16, method=Image.MEDIANCUT)   # over-quantize, then merge
+    pal = q.getpalette()
+    counts = sorted(q.getcolors(), reverse=True)
+    total = sum(c for c, _ in counts) or 1
+    buckets = []  # [{rgb:(r,g,b), pct:float}]
+    for count, idx in counts:
+        rgb = tuple(pal[idx*3:idx*3+3]); pct = count / total * 100
+        for b in buckets:
+            if _dist(rgb, b["rgb"]) <= merge_thresh:     # merge into nearest existing
+                b["pct"] += pct; break
+        else:
+            buckets.append({"rgb": rgb, "pct": pct})
+    buckets = [b for b in buckets if b["pct"] >= 2][:k]
+    out = [{"hex": "#%02X%02X%02X" % b["rgb"], "percentage": round(b["pct"])} for b in buckets]
+    diff = 100 - sum(c["percentage"] for c in out)
+    if out: out[0]["percentage"] += diff
+    return out
+
+if __name__ == "__main__":
+    print(json.dumps(palette(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 6)))
diff --git a/test-local.js b/test-local.js
new file mode 100644
index 0000000..3c75bbe
--- /dev/null
+++ b/test-local.js
@@ -0,0 +1,11 @@
+const fs = require('fs');
+process.env.ENRICH_OLLAMA_URL = process.env.ENRICH_OLLAMA_URL || 'http://127.0.0.1:11434'; // Mac2 local for test
+const { localAnalyze } = require('./enrich-local');
+// fetchBuffer stub: read a local file path as if it were a fetched image
+const fetchBuffer = async (p) => fs.readFileSync(p);
+(async () => {
+  const img = process.argv[2] || '/Users/stevestudio2/Desktop/ChinaSeas_MFR_clean_samples.png';
+  const t0 = Date.now();
+  const aiData = await localAnalyze([img], 'RESIDENTIAL', fetchBuffer);
+  console.log(`elapsed ${((Date.now()-t0)/1000).toFixed(1)}s\n` + JSON.stringify(aiData, null, 2));
+})().catch(e => { console.error('FAILED:', e.message); process.exit(1); });

(oldest)  ·  back to Enrich Local Hybrid  ·  DEPLOY.md: exact go-live = one line in full-monte/.env (Phas 168999c →