← back to Fromental Internal

scripts/enrich-fromental.js

129 lines

#!/usr/bin/env node
/**
 * enrich-fromental.js — LOCAL ($0) Ollama enrichment of fromental_catalog.
 *
 * Steve HARD RULE: all design/image enrichment runs on LOCAL models, never paid
 * Gemini/Claude-vision/Replicate. This uses Mac1 Ollama:
 *   - qwen2.5vl:7b  -> vision: colors (hex+name), color_hex, styles, tags,
 *                      and a clean description when body_html is thin.
 *
 * Resumable: skips rows where ai_accepted_at IS NOT NULL. Batches politely.
 *   node scripts/enrich-fromental.js [limit]
 *   LIMIT env or argv[2] caps rows this run (default 100). Pass "all" for全部.
 *
 * Cost: $0 (local Ollama, unmetered).
 */
const { Client } = require('pg');

const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
const VISION_MODEL = process.env.VISION_MODEL || 'qwen2.5vl:7b';
const argLimit = process.argv[2] || process.env.LIMIT || '100';
const LIMIT = argLimit === 'all' ? 1000000 : parseInt(argLimit, 10);

async function fetchImageB64(url) {
  // Fromental images can be huge; request a Shopify-resized variant to keep it small.
  let u = url;
  if (/cdn\.shopify\.com/.test(u) && !/[?&]width=/.test(u)) {
    u += (u.includes('?') ? '&' : '?') + 'width=600';
  }
  const res = await fetch(u, { headers: { 'User-Agent': 'DW-enrich/1.0' } });
  if (!res.ok) throw new Error(`img HTTP ${res.status}`);
  const buf = Buffer.from(await res.arrayBuffer());
  return buf.toString('base64');
}

async function ollamaVision(prompt, imgB64) {
  const res = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: VISION_MODEL,
      prompt,
      images: [imgB64],
      stream: false,
      format: 'json',
      options: { temperature: 0.2 },
    }),
  });
  if (!res.ok) throw new Error(`ollama HTTP ${res.status}`);
  const j = await res.json();
  return j.response;
}

function safeParse(s) {
  if (!s) return null;
  try { return JSON.parse(s); } catch {}
  const m = s.match(/\{[\s\S]*\}/);
  if (m) { try { return JSON.parse(m[0]); } catch {} }
  return null;
}

const PROMPT = (row) => `You are a luxury interior-design cataloguer describing a Fromental hand-painted / embroidered wallcovering or artwork image.
Pattern: "${row.pattern_name || ''}"  Colorway: "${row.color_name || ''}"  Type: "${row.product_type || ''}".
Look at the image and return STRICT JSON with these keys only:
{
 "colors": [{"name":"warm ivory","hex":"#efe7d8"}, ...],   // 2-5 dominant colors, real hex you SEE
 "dominant_hex": "#rrggbb",                                  // single most-dominant hex
 "styles": ["Chinoiserie","Scenic", ...],                   // 1-4 interior/design styles
 "tags": ["hand-painted","floral","birds", ...],            // 3-8 lowercase descriptive tags
 "description": "..."                                        // 1-2 elegant sentences, editorial tone, NO price, NO brand claims beyond Fromental
}
Only output the JSON object.`;

async function main() {
  const client = new Client({ connectionString: DB });
  await client.connect();

  const { rows } = await client.query(
    `SELECT id, mfr_sku, pattern_name, color_name, product_type, image_url, description
       FROM fromental_catalog
      WHERE ai_accepted_at IS NULL AND image_url IS NOT NULL
      ORDER BY (description IS NULL) DESC, id
      LIMIT $1`,
    [LIMIT]
  );
  console.log(`Enriching ${rows.length} rows (model ${VISION_MODEL}, LOCAL $0)...`);

  let ok = 0, fail = 0;
  for (const row of rows) {
    try {
      const b64 = await fetchImageB64(row.image_url);
      const raw = await ollamaVision(PROMPT(row), b64);
      const j = safeParse(raw);
      if (!j) { throw new Error('unparseable model output'); }

      const colors = Array.isArray(j.colors) ? j.colors : [];
      const domHex = typeof j.dominant_hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(j.dominant_hex) ? j.dominant_hex : null;
      const styles = Array.isArray(j.styles) ? j.styles : [];
      const tags = Array.isArray(j.tags) ? j.tags : [];
      const aiDesc = typeof j.description === 'string' ? j.description.trim() : null;
      // only fill ai_description into description if the scraped one is thin
      const useDesc = (!row.description || row.description.length < 40) ? aiDesc : null;

      await client.query(
        `UPDATE fromental_catalog SET
           ai_colors=$2, color_hex=$3, dominant_color_hex=$3, ai_styles=$4, ai_tags=$5,
           ai_description=$6,
           description=COALESCE($7, description),
           ai_accepted_at=NOW(), updated_at=NOW()
         WHERE id=$1`,
        [
          row.id, JSON.stringify(colors), domHex, JSON.stringify(styles),
          JSON.stringify(tags), aiDesc, useDesc,
        ]
      );
      ok++;
      if (ok % 10 === 0) process.stdout.write(`  ${ok} enriched...\n`);
    } catch (e) {
      fail++;
      process.stderr.write(`  FAIL ${row.mfr_sku}: ${e.message}\n`);
    }
    await new Promise((r) => setTimeout(r, 150)); // polite pacing
  }
  console.log(`Done. enriched=${ok} failed=${fail}. Cost: $0 (local Ollama).`);
  await client.end();
}

main().catch((e) => { console.error(e); process.exit(1); });