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