← back to Corkwallcovering
scripts/enrich-cork-ai.mjs
140 lines
#!/usr/bin/env node
/**
* Scoped Gemini enricher for the corkwallcovering microsite.
*
* Reads reports/cork-tag-diff.json to find the SKUs still missing a `style`
* and/or `pattern` facet (the ones the free/deterministic pass could NOT fill),
* sends each product's image_url to Gemini 2.5 Flash vision, and adds the
* missing facet tag(s) — CONSTRAINED to the exact vocab in server.js /
* stage-shopify-tags.mjs — back into data/products.json.
*
* Surgical by design: only touches the flagged SKUs, only ADDS the missing
* facet(s), never removes a tag, writes ONLY the local products.json (not
* dw_unified, not Shopify). Fully reversible via git.
*
* node scripts/enrich-cork-ai.mjs --dry-run # call Gemini, print, write nothing
* node scripts/enrich-cork-ai.mjs # apply to data/products.json
* node scripts/enrich-cork-ai.mjs --limit 5 --dry-run # first 5 only
*
* Cost: Gemini 2.5 Flash ~$0.0006 / product image. GEMINI_API_KEY sourced from
* the enrich-ai-tags skill .env (or process.env).
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
const DRY = process.argv.includes('--dry-run');
const limitIdx = process.argv.indexOf('--limit');
const LIMIT = limitIdx > -1 ? parseInt(process.argv[limitIdx + 1], 10) : Infinity;
const PER_IMG_COST = 0.0002; // Gemini 2.5 Flash vision: ~500 in-tok (image≈258 + prompt) × $0.30/1M ≈ $0.0002/img
const BUDGET = 1.00; // hard cap ($) — 31 imgs ≈ $0.006, so this never bites
// vocab MIRRORS stage-shopify-tags.mjs / server.js — Gemini must pick from these only
const STYLE_VOCAB = ['Traditional','Contemporary','Modern','Coastal','Tropical','Art Deco','Mid-Century Modern','Bohemian','Transitional','Victorian','Chinoiserie','Minimalist','Maximalist','Rustic','Industrial','Scandinavian','Hollywood Regency','Japandi','Farmhouse'];
const PATTERN_VOCAB = ['Botanical','Floral','Geometric','Damask','Palm','Trellis','Stripe','Abstract','Toile','Scenic','Paisley','Plaid','Animal Print','Medallion','Chevron','Herringbone','Solid','Textured','Faux','Mural'];
// ── key ────────────────────────────────────────────────────────────────────
async function loadKey() {
if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
const envPath = path.join(os.homedir(), '.claude/skills/enrich-ai-tags/.env');
try {
const txt = await fs.readFile(envPath, 'utf8');
const m = txt.match(/^GEMINI_API_KEY=(.+)$/m);
if (m) return m[1].trim().replace(/^["']|["']$/g, '');
} catch {}
throw new Error('GEMINI_API_KEY not found (env or enrich-ai-tags/.env)');
}
async function fetchImageB64(url) {
const r = await fetch(url);
if (!r.ok) throw new Error('img ' + r.status);
const buf = Buffer.from(await r.arrayBuffer());
const mime = r.headers.get('content-type') || 'image/jpeg';
return { data: buf.toString('base64'), mime: mime.split(';')[0] };
}
async function askGemini(key, img, need, title) {
const wantStyle = need.includes('style');
const wantPattern = need.includes('pattern');
const asks = [];
if (wantStyle) asks.push(`"style": ONE value from [${STYLE_VOCAB.join(', ')}]`);
if (wantPattern) asks.push(`"pattern": ONE value from [${PATTERN_VOCAB.join(', ')}]`);
const prompt =
`You are a senior interior-design cataloguer classifying a wallcovering swatch/product image ` +
`(title: "${title}"). Return STRICT JSON with exactly these keys: { ${asks.join(', ')} }. ` +
`Pick the single best-fitting value from each list — no other words, no explanation. ` +
`Most of these are cork / mica / natural-fiber textured wallcoverings.`;
// LOCAL-FIRST cost-saver (2026-07-03): free ollama qwen2.5vl before paid Gemini. ENRICH_GEMINI_ONLY=1 forces Gemini.
if (process.env.ENRICH_GEMINI_ONLY !== '1') {
try {
const lr = await fetch('http://127.0.0.1:11434/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b', prompt,
images: [img.data], format: 'json', stream: false, options: { temperature: 0 } }),
signal: AbortSignal.timeout(90000) });
if (lr.ok) { const lj = await lr.json();
let lout = {}; try { lout = JSON.parse((lj.response || '{}').replace(/^```json\s*|\s*```$/g, '').trim()); } catch (_) {}
const ladd = [];
if (wantStyle && STYLE_VOCAB.includes(lout.style)) ladd.push(lout.style);
if (wantPattern && PATTERN_VOCAB.includes(lout.pattern)) ladd.push(lout.pattern);
if (ladd.length) return ladd; // accept local only if it produced valid-vocab tags; else fall through to Gemini
}
} catch (_) { /* fall through to Gemini */ }
}
const body = {
contents: [{ parts: [ { text: prompt }, { inline_data: { mime_type: img.mime, data: img.data } } ] }],
generationConfig: { temperature: 0, responseMimeType: 'application/json' },
};
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`;
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) throw new Error('gemini ' + r.status + ' ' + (await r.text()).slice(0, 200));
const j = await r.json();
const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';
let out; try { out = JSON.parse(text); } catch { out = {}; }
const add = [];
if (wantStyle && STYLE_VOCAB.includes(out.style)) add.push(out.style);
if (wantPattern && PATTERN_VOCAB.includes(out.pattern)) add.push(out.pattern);
return add;
}
// ── main ────────────────────────────────────────────────────────────────────
const key = await loadKey();
const diff = JSON.parse(await fs.readFile('reports/cork-tag-diff.json', 'utf8'));
const products = JSON.parse(await fs.readFile('data/products.json', 'utf8'));
const bySku = new Map(products.map(p => [p.sku, p]));
const targets = diff.rows.filter(r => r.still_missing?.length).slice(0, LIMIT);
console.log(`Enriching ${targets.length} SKU(s) via Gemini 2.5 Flash${DRY ? ' [DRY-RUN]' : ''} — est $${(targets.length * PER_IMG_COST).toFixed(4)}`);
let spent = 0, changed = 0, failed = 0;
for (const row of targets) {
const p = bySku.get(row.sku);
if (!p || !p.image_url) { console.log(` ⚠ ${row.sku}: no product/image_url — skip`); failed++; continue; }
if (spent + PER_IMG_COST > BUDGET) { console.log(' ⛔ budget cap hit — stopping'); break; }
try {
const img = await fetchImageB64(p.image_url);
const add = await askGemini(key, img, row.still_missing, p.title || p.sku);
spent += PER_IMG_COST;
const have = new Set((p.tags || []).map(t => String(t).toLowerCase()));
const fresh = add.filter(a => !have.has(a.toLowerCase()));
if (fresh.length) {
if (!DRY) { p.tags = [...(p.tags || []), ...fresh].sort((a, b) => String(a).localeCompare(String(b))); }
changed++;
console.log(` ✓ ${row.sku} [need ${row.still_missing.join('+')}] → +${fresh.join(', ')}`);
} else {
console.log(` · ${row.sku}: Gemini returned nothing new (${add.join(',') || 'empty'})`);
}
} catch (e) {
failed++;
console.log(` ✗ ${row.sku}: ${e.message}`);
}
}
if (!DRY && changed) {
await fs.writeFile('data/products.json', JSON.stringify(products, null, 2) + '\n');
console.log(`\nWROTE data/products.json — ${changed} product(s) updated.`);
} else if (DRY) {
console.log(`\n[DRY-RUN] no file written — ${changed} would change.`);
}
console.log(`Done. changed=${changed} failed=${failed} · Gemini spend ≈ $${spent.toFixed(4)} (actual)`);