[object Object]

← back to Corkwallcovering

auto-save: 2026-07-01T09:41:09 (1 files) — scripts/enrich-cork-ai.mjs

6bbcce9f7a1e3c618209e224ab6f55bea841a350 · 2026-07-01 09:41:10 -0700 · Steve Abrams

Files touched

Diff

commit 6bbcce9f7a1e3c618209e224ab6f55bea841a350
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 1 09:41:10 2026 -0700

    auto-save: 2026-07-01T09:41:09 (1 files) — scripts/enrich-cork-ai.mjs
---
 scripts/enrich-cork-ai.mjs | 121 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 121 insertions(+)

diff --git a/scripts/enrich-cork-ai.mjs b/scripts/enrich-cork-ai.mjs
new file mode 100644
index 0000000..9e98592
--- /dev/null
+++ b/scripts/enrich-cork-ai.mjs
@@ -0,0 +1,121 @@
+#!/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.0 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.0 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 LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i + 1], 10) : Infinity; })();
+const PER_IMG_COST = 0.0006;
+const BUDGET = 1.00; // hard cap ($) — 31 imgs ≈ $0.02, 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.`;
+  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 === Infinity ? undefined : LIMIT);
+console.log(`Enriching ${targets.length} SKU(s) via Gemini 2.0 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)`);

← 8d52027 Re-host 23 logo-cropped cork images as live Shopify product  ·  back to Corkwallcovering  ·  cork: Gemini 2.5 Flash style/pattern enrichment for 30/31 fa ee5ed29 →