[object Object]

← back to Letsbegin

Add Claude-vision enrich-write helper (color+specs+tags→metafields, bypass Gemini cap)

f5b11c2ed86f684b5561a523818c5818ce769e94 · 2026-06-11 11:36:46 -0700 · Steve Abrams

Files touched

Diff

commit f5b11c2ed86f684b5561a523818c5818ce769e94
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 11:36:46 2026 -0700

    Add Claude-vision enrich-write helper (color+specs+tags→metafields, bypass Gemini cap)
---
 enrich-write.js | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 332 insertions(+)

diff --git a/enrich-write.js b/enrich-write.js
new file mode 100644
index 0000000..797d73a
--- /dev/null
+++ b/enrich-write.js
@@ -0,0 +1,332 @@
+#!/usr/bin/env node
+/**
+ * enrich-write.js — WRITE-ONLY Claude-vision enrichment helper.
+ *
+ * Vision is done by the CALLING AGENT (Claude looks at the product image and
+ * derives color + tags). This script does NO vision — it just writes the
+ * agent's findings to Shopify metafields + image alt tags + normalizes the
+ * title + stamps the dw_unified.shopify_products row. Idempotent (safe re-run).
+ *
+ * Why: Gemini is spend-capped, so Claude/agents are the vision analyzer instead
+ * of last20day_full_monty.js's inline Gemini call. This is the write half of
+ * that script, with the Gemini phase ripped out.
+ *
+ * Usage:
+ *   node enrich-write.js <shopify_id> <json-payload-file>
+ *
+ * <shopify_id> may be a bare numeric id or a full gid://shopify/Product/<id>.
+ *
+ * payload JSON:
+ *   {
+ *     "colorName":    "Olive Green",            // required — dominant color name
+ *     "hex":          "#6B7A3A",                // required — dominant hex
+ *     "colorDetails": [ {"name":"Olive Green","hex":"#6B7A3A","percentage":55}, ... ]
+ *                     // or { "colors":[...], "background":"White", ... } — stored as-is as JSON
+ *     "specs":        { "material":"Non-woven", "finish":"Matte", ... },  // optional → custom.<key>
+ *     "styleTags":    ["botanical","mural","dark"],   // optional — added as Shopify product tags
+ *     "title":        "Palm Jungle - Olive Green | Cole & Son"  // null/absent → auto-normalize from current title
+ *   }
+ *
+ * Env: SHOPIFY_ADMIN_TOKEN (or SHOPIFY_PRODUCT_TOKEN). PG* optional (defaults
+ *      user stevestudio2, db dw_unified, local socket).
+ *
+ * Exit non-zero on any Shopify userError or fatal.
+ */
+
+const https = require('https');
+const fs = require('fs');
+const { Client } = require('pg');
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN (or SHOPIFY_PRODUCT_TOKEN) env var required'); process.exit(1); }
+
+// ---------- args ----------
+const rawId = process.argv[2];
+const payloadFile = process.argv[3];
+if (!rawId || !payloadFile) {
+  console.error('Usage: node enrich-write.js <shopify_id> <json-payload-file>');
+  process.exit(2);
+}
+const PID = /^gid:\/\//.test(rawId) ? rawId : `gid://shopify/Product/${String(rawId).replace(/\D/g, '')}`;
+
+let payload;
+try { payload = JSON.parse(fs.readFileSync(payloadFile, 'utf8')); }
+catch (e) { console.error(`FATAL: cannot read/parse payload file: ${e.message}`); process.exit(2); }
+
+const colorName = (payload.colorName || '').trim();
+const hex = (payload.hex || '').trim();
+if (!colorName || !hex) { console.error('FATAL: payload requires colorName + hex'); process.exit(2); }
+const colorDetails = payload.colorDetails != null ? payload.colorDetails : null;
+const specs = payload.specs && typeof payload.specs === 'object' ? payload.specs : {};
+const styleTags = Array.isArray(payload.styleTags) ? payload.styleTags.map(t => String(t).trim()).filter(Boolean) : [];
+const providedTitle = payload.title != null && String(payload.title).trim() ? String(payload.title).trim() : null;
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---------- Shopify GraphQL (from last20day_full_monty.js) ----------
+function gqlRaw(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: API, method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
+    req.on('error', reject);
+    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data); req.end();
+  });
+}
+async function gql(body, retries = 4) {
+  for (let attempt = 1; attempt <= retries; attempt++) {
+    try {
+      const result = await gqlRaw(body);
+      const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+      const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
+      if (throttled) { await sleep(3000); continue; }
+      if (lowBudget) await sleep(1500);
+      return result;
+    } catch (e) {
+      if (attempt < retries) { await sleep(attempt * 2000); continue; }
+      throw e;
+    }
+  }
+}
+
+// ---------- title helpers (verbatim from last20day_full_monty.js) ----------
+function stripVendorSuffix(title) {
+  const pi = title.indexOf('|');
+  return (pi >= 0 ? title.slice(0, pi) : title).trim();
+}
+function extractPatternAndColor(title) {
+  let cleaned = stripVendorSuffix(title);
+  cleaned = cleaned.replace(/\(tm\)/gi, '').replace(/\bwallcoverings?\b/gi, '').replace(/\bwall\s*coverings?\b/gi, '').replace(/\bwall\s*papers?\b/gi, '').replace(/\s+/g, ' ').trim();
+  const commaIdx = cleaned.search(/,\s/);
+  const dashIdx = cleaned.search(/\s[-–]\s/);
+  function lastSep(re) { let m, last = -1; const r = new RegExp(re, 'g'); while ((m = r.exec(cleaned))) last = m.index; return last; }
+  const lastComma = lastSep(',\\s');
+  const lastDash = lastSep('\\s[-–]\\s');
+  if (commaIdx === -1 && dashIdx === -1) return { pattern: cleaned.trim(), color: null };
+  if (lastDash >= 0 && lastDash >= lastComma) {
+    const m = cleaned.slice(0, lastDash).trim();
+    const c = cleaned.slice(lastDash).replace(/^\s[-–]\s/, '').trim();
+    return { pattern: m, color: c };
+  }
+  if (lastComma >= 0) {
+    const m = cleaned.slice(0, lastComma).trim();
+    const c = cleaned.slice(lastComma + 1).trim();
+    return { pattern: m, color: c };
+  }
+  return { pattern: cleaned.trim(), color: null };
+}
+function toTitleCase(s) { if (!s) return ''; return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()); }
+function buildTitle(pattern, color, vendor) {
+  const tc = toTitleCase(color);
+  return tc ? `${pattern} - ${tc} | ${vendor}` : `${pattern} | ${vendor}`;
+}
+
+// ---------- spec key/type map (same conventions as last20day FIELD_MAP) ----------
+const MULTILINE_KEYS = new Set(['material', 'description', 'care_instructions']);
+function specType(key) { return MULTILINE_KEYS.has(key) ? 'multi_line_text_field' : 'single_line_text_field'; }
+
+// Look up the existing PRODUCT metafield definition type for custom.<key>.
+// Returns the type name (e.g. 'product_reference', 'single_line_text_field')
+// or null if no definition exists (free text accepted). Cached per process.
+const _defCache = new Map();
+async function definitionType(key) {
+  if (_defCache.has(key)) return _defCache.get(key);
+  let t = null;
+  try {
+    const r = await gql({ query: `{ metafieldDefinitions(first:1, ownerType: PRODUCT, namespace:"custom", key:"${key}"){ edges { node { type { name } } } } }` });
+    const edges = r?.data?.metafieldDefinitions?.edges || [];
+    if (edges.length) t = edges[0].node.type.name;
+  } catch { t = null; }
+  _defCache.set(key, t);
+  return t;
+}
+
+const PRODUCT_QUERY = (id) => ({ query: `{
+  product(id:"${id}") {
+    id title vendor tags
+    images(first:15){ edges{ node{ id url } } }
+    media(first:15){ edges{ node{ ... on MediaImage { id } } } }
+    variants(first:5){ edges{ node{ id sku } } }
+    metafields(first:100){ edges{ node{ namespace key value type } } }
+  }
+}` });
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+const PRODUCT_UPDATE = 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message field } } }';
+
+async function main() {
+  const errors = [];
+
+  // ---- fetch product ----
+  const fr = await gql(PRODUCT_QUERY(PID));
+  const p = fr?.data?.product;
+  if (!p) { console.error(`FATAL: product ${PID} not found on Shopify (resp: ${JSON.stringify(fr).slice(0, 200)})`); process.exit(1); }
+  const origTitle = p.title || '';
+  const vendor = p.vendor || 'Designer Wallcoverings';
+  const firstVariant = p.variants?.edges?.find(e => e.node?.sku && !/-sample$/i.test(e.node.sku))?.node || p.variants?.edges?.[0]?.node;
+  const sku = (firstVariant?.sku || '').replace(/-sample$/i, '').trim();
+  const existingTags = Array.isArray(p.tags) ? p.tags : [];
+
+  console.log(`[enrich-write] ${PID}`);
+  console.log(`  vendor=${vendor} sku=${sku || '(none)'}`);
+  console.log(`  origTitle="${origTitle}"`);
+
+  // ---- 1a. COLOR metafields (always written in their own batch so a bad spec
+  //         key can't poison the color write — see pilot note re: legacy
+  //         product_reference definitions on this sandbox store) ----
+  const colorMf = [
+    { ownerId: PID, namespace: 'custom', key: 'dominant_color', value: colorName, type: 'single_line_text_field' },
+    { ownerId: PID, namespace: 'custom', key: 'dominant_hex', value: hex, type: 'single_line_text_field' },
+    // mirror to the keys last20day_full_monty.js wrote so existing readers stay happy
+    { ownerId: PID, namespace: 'custom', key: 'color_name', value: colorName, type: 'single_line_text_field' },
+    { ownerId: PID, namespace: 'custom', key: 'color_hex', value: hex, type: 'single_line_text_field' },
+  ];
+  if (colorDetails != null) {
+    colorMf.push({ ownerId: PID, namespace: 'custom', key: 'color_details', value: JSON.stringify(colorDetails), type: 'json' });
+  }
+  {
+    const r = await gql({ query: MF_MUTATION, variables: { m: colorMf } });
+    const errs = r?.data?.metafieldsSet?.userErrors || [];
+    if (errs.length) errors.push(`Color metafields: ${errs.map(e => e.message).join('; ')}`);
+    else console.log(`  color metafields set: ${colorMf.map(m => m.key).join(', ')}`);
+    await sleep(400);
+  }
+
+  // ---- 1b. SPEC metafields (separate batch). This store carries legacy
+  //         custom.* metafield DEFINITIONS typed as product_reference (from an
+  //         old CSV import — e.g. custom.finish, custom.material's siblings).
+  //         A single type-incompatible key poisons the whole metafieldsSet
+  //         batch, so we pre-check each spec key's existing definition type and
+  //         SKIP any whose definition is incompatible with the text type we'd
+  //         write — reported as a non-fatal warning, never blocks the row. ----
+  const specEntries = Object.entries(specs).filter(([, v]) => v != null && String(v).trim() !== '');
+  if (specEntries.length) {
+    const specMf = [];
+    for (const [k, v] of specEntries) {
+      const wantType = specType(k);
+      const defType = await definitionType(k);   // null = no definition (free text OK)
+      if (defType && defType !== wantType) {
+        errors.push(`Spec ${k} skipped: existing definition is '${defType}', not '${wantType}'`);
+        console.log(`    SKIP custom.${k}: existing definition is '${defType}' (incompatible)`);
+        continue;
+      }
+      // if a definition exists and matches, write with its type; else use wantType
+      specMf.push({ ownerId: PID, namespace: 'custom', key: k, value: String(v), type: defType || wantType });
+    }
+    if (specMf.length) {
+      const r = await gql({ query: MF_MUTATION, variables: { m: specMf } });
+      const errs = r?.data?.metafieldsSet?.userErrors || [];
+      if (errs.length) errors.push(`Spec metafields: ${errs.map(e => e.message).join('; ')}`);
+      else console.log(`  spec metafields set: ${specMf.map(m => m.key).join(', ')}`);
+      await sleep(400);
+    }
+  }
+
+  // ---- 2. title normalization + style tags ----
+  let titleColor, pattern;
+  if (providedTitle) {
+    // caller gave an already-normalized title; still parse pattern/color for alt tag
+    const parsed = extractPatternAndColor(providedTitle);
+    pattern = parsed.pattern; titleColor = parsed.color || colorName;
+  } else {
+    const parsed = extractPatternAndColor(origTitle);
+    pattern = parsed.pattern;
+    titleColor = parsed.color || colorName;
+  }
+  const newTitle = providedTitle || buildTitle(pattern, titleColor, vendor);
+
+  // merge style tags + color into product tags (dedupe, case-insensitive)
+  const tagSet = new Map();
+  for (const t of existingTags) tagSet.set(t.toLowerCase(), t);
+  const wantTags = [colorName, ...styleTags].map(t => String(t).trim()).filter(Boolean);
+  for (const t of wantTags) if (!tagSet.has(t.toLowerCase())) tagSet.set(t.toLowerCase(), t);
+  const mergedTags = [...tagSet.values()];
+
+  const titleChanged = newTitle && newTitle !== origTitle;
+  const tagsChanged = mergedTags.length !== existingTags.length;
+  if (titleChanged || tagsChanged) {
+    const input = { id: PID };
+    if (titleChanged) input.title = newTitle;
+    if (tagsChanged) input.tags = mergedTags;
+    const r = await gql({ query: PRODUCT_UPDATE, variables: { i: input } });
+    const errs = r?.data?.productUpdate?.userErrors || [];
+    if (errs.length) errors.push(`Title/Tags: ${errs.map(e => e.message).join('; ')}`);
+    else console.log(`  title${titleChanged ? `="${newTitle}"` : ' unchanged'}${tagsChanged ? `  tags=[${mergedTags.join(', ')}]` : ''}`);
+    await sleep(400);
+  } else { console.log('  title + tags already current'); }
+
+  // ---- 3. image alt tags: "{SKU} {Pattern} {Color} designer-wallcoverings-los-angeles" ----
+  const mediaEdges = p.media?.edges || [];
+  const altBase = `${sku} ${pattern} ${titleColor} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
+  if (mediaEdges.length > 0) {
+    const updates = [];
+    for (let mi = 0; mi < mediaEdges.length; mi++) {
+      const node = mediaEdges[mi].node;
+      if (!node?.id) continue;
+      updates.push({ id: node.id, alt: mi === 0 ? altBase : `${altBase} ${mi + 1}` });
+    }
+    if (updates.length) {
+      const mediaJson = updates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"}`).join(', ');
+      const r = await gql({ query: `mutation { productUpdateMedia(productId: "${PID}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }` });
+      const errs = r?.data?.productUpdateMedia?.mediaUserErrors || [];
+      if (errs.length) errors.push(`Alt: ${errs.map(e => e.message).join('; ')}`);
+      else console.log(`  alt tags set on ${updates.length} image(s): "${altBase}"`);
+      await sleep(400);
+    }
+  } else { console.log('  no media to alt-tag'); }
+
+  // ---- 4. stamp dw_unified.shopify_products (idempotent) ----
+  // NOTE: full_monty_tags is an INTEGER column (a count, per existing schema) —
+  // the color + style tag STRINGS live on the Shopify product tags above. We
+  // stamp the count of tags applied so the column stays integer-valid.
+  const pg = new Client({ user: process.env.PGUSER || 'stevestudio2', database: process.env.PGDATABASE || 'dw_unified', host: process.env.PGHOST || '/tmp' });
+  let dbStamped = false;
+  try {
+    await pg.connect();
+    const tagCount = wantTags.length;
+    const res = await pg.query(
+      `UPDATE shopify_products
+         SET full_monty_at = now(),
+             fm_metafields_verified = 1,
+             fm_specs_at = now(),
+             fm_tags_at = now(),
+             full_monty_tags = $2
+       WHERE shopify_id = $1`,
+      [PID, tagCount]
+    );
+    if (res.rowCount > 0) { dbStamped = true; console.log(`  DB stamped: full_monty_at=now, fm_metafields_verified=1, fm_specs_at=now, fm_tags_at=now, full_monty_tags=${tagCount} (rows=${res.rowCount})`); }
+    else { errors.push(`DB: no shopify_products row matched ${PID}`); console.log(`  DB: WARNING no row matched ${PID}`); }
+  } catch (e) { errors.push(`DB: ${e.message}`); console.log(`  DB: ERROR ${e.message}`); }
+  finally { try { await pg.end(); } catch {} }
+
+  // ---- 5. re-query to VERIFY metafields landed ----
+  await sleep(300);
+  const vr = await gql({ query: `{ product(id:"${PID}") { metafields(first:100){ edges{ node{ namespace key value } } } } }` });
+  const vmf = {};
+  for (const e of (vr?.data?.product?.metafields?.edges || [])) {
+    const { namespace, key, value } = e.node;
+    if (namespace === 'custom') vmf[key] = value;
+  }
+  const gotColor = vmf['dominant_color'];
+  const gotDetails = vmf['color_details'];
+  const colorOk = gotColor === colorName;
+  const detailsOk = colorDetails == null ? '(none provided)' : (gotDetails ? 'present' : 'MISSING');
+  console.log(`VERIFY ${PID} | custom.dominant_color="${gotColor || ''}" (${colorOk ? 'OK' : 'MISMATCH'}) | custom.dominant_hex="${vmf['dominant_hex'] || ''}" | custom.color_details=${detailsOk} | db_stamped=${dbStamped}`);
+
+  // Separate non-fatal warnings (spec keys skipped due to legacy product_reference
+  // definitions on this store) from real, blocking errors.
+  const warnings = errors.filter(e => /^Spec .* skipped/.test(e));
+  const fatal = errors.filter(e => !/^Spec .* skipped/.test(e));
+  if (warnings.length) console.log(`WARNINGS: ${warnings.join(' | ')}`);
+  if (fatal.length) { console.error(`USERERRORS: ${fatal.join(' | ')}`); process.exit(1); }
+  if (!colorOk) { console.error('FATAL: dominant_color verify mismatch'); process.exit(1); }
+  if (colorDetails != null && !gotDetails) { console.error('FATAL: color_details did not land'); process.exit(1); }
+  console.log('OK');
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });

← a780b03 Add last-20-day Full Monty backfill runner (1003 items, meta  ·  back to Letsbegin  ·  Add color_family controlled-vocab metafield from vendor moti 9b884d7 →