← 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)`);