← back to Hollywood Import

momentum-feed/enrich.mjs

65 lines

#!/usr/bin/env node
// Momentum → Hollywood LOCAL enrichment ($0). Fills ai_color_name / description / tags
// for momentum_colorways rows missing them, using local Ollama qwen2.5vl:7b vision.
// Resumable (only touches rows WHERE ai_color_name IS NULL AND image_url IS NOT NULL).
// Does NOT write ai_color_hex — per dw-local-color-enrichment rule, hex comes from pixel
// analysis (LLMs hallucinate hex), left to the PIL enricher. NEVER touches dw_sku/shopify_*.
// Run:  NODE_PATH=.../AbramsOS/node_modules node enrich.mjs [--limit N] [--category Acoustic]
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { Pool } = require('pg');

const OLLAMA = process.env.OLLAMA_HOST || 'http://localhost:11434';
const MODEL = process.env.OLLAMA_VL || 'qwen2.5vl:7b';
const pool = new Pool({ connectionString: 'postgresql://dw_admin:DW2024!@127.0.0.1:5432/dw_unified' });
const argLimit = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i + 1], 10) : null; })();
const argCat = (() => { const i = process.argv.indexOf('--category'); return i > -1 ? process.argv[i + 1] : null; })();

const PROMPT = `You are a contract interior-design cataloger. Look at this commercial wallcovering/acoustic-panel swatch image and return ONLY compact JSON, no prose:
{"color_name":"<designer color name, e.g. Harvest Tan, Alabaster, Celadon>","description":"<one commercial sentence about the texture/finish/use, NO brand names>","tags":["<3-5 style/color tags>"]}`;

async function imgB64(url) {
  const r = await fetch(url, { signal: AbortSignal.timeout(20000) });
  if (!r.ok) throw new Error(`img ${r.status}`);
  return Buffer.from(await r.arrayBuffer()).toString('base64');
}
async function vision(b64) {
  const r = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: MODEL, prompt: PROMPT, images: [b64], stream: false, format: 'json',
      options: { temperature: 0.2 } }),
    signal: AbortSignal.timeout(120000),
  });
  if (!r.ok) throw new Error(`ollama ${r.status}`);
  const j = await r.json();
  return JSON.parse(j.response);
}

async function main() {
  const where = ['ai_color_name IS NULL', "image_url IS NOT NULL", "image_url <> ''"];
  if (argCat) where.push(`category = '${argCat.replace(/'/g, "''")}'`);
  const q = `SELECT id, pattern_name, color_name, image_url FROM momentum_colorways
             WHERE ${where.join(' AND ')} ORDER BY category, id ${argLimit ? `LIMIT ${argLimit}` : ''}`;
  const { rows } = await pool.query(q);
  console.log(`[enrich] ${rows.length} rows to enrich${argCat ? ` (category=${argCat})` : ''}. model=${MODEL}  $0 local`);
  let ok = 0, fail = 0;
  for (const r of rows) {
    try {
      const out = await vision(await imgB64(r.image_url));
      const name = (out.color_name || '').toString().slice(0, 80) || null;
      const desc = (out.description || '').toString().slice(0, 500) || null;
      const tags = Array.isArray(out.tags) ? out.tags.slice(0, 6).map(String) : [];
      await pool.query(
        `UPDATE momentum_colorways SET ai_color_name=$1, description=COALESCE(description,$2),
           tags = CASE WHEN (tags IS NULL OR tags='[]' OR tags='') THEN $3 ELSE tags END, updated_at=now()
         WHERE id=$4 AND ai_color_name IS NULL`,
        [name, desc, JSON.stringify(tags), r.id]);
      ok++;
      if (ok % 25 === 0) process.stdout.write(`\r  enriched ${ok} ok / ${fail} fail   `);
    } catch (e) { fail++; if (fail <= 5) console.error(`\n  [skip id=${r.id}] ${e.message}`); }
  }
  console.log(`\n[enrich] done — ${ok} enriched, ${fail} failed. $0 (local qwen2.5vl).`);
  await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });