← 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
A .gitignoreA DEPLOY.mdA apply-patch.jsA enrich-ai-tags.patched.jsA enrich-local.jsA enrich-palette.pyA test-local.js
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 →