← back to Tk 11331 Exec

d3c_enrich_scoped.mjs

66 lines

#!/usr/bin/env node
// TK-11331 D3b-C — LOCAL ($0) enrichment SCOPED to ONLY the ids staged this run.
// The shared momentum-feed/enrich.mjs is TABLE-WIDE (WHERE ai_color_name IS NULL) — forbidden here.
// This variant reads data/d3c-stage-restore.jsonl and enriches ONLY those ids. Resumable
// (WHERE ai_color_name IS NULL). Preserves the settlement-review tags (only fills tags when empty).
// qwen2.5vl:7b vision -> ai_color_name / description / tags. NEVER paid Gemini/Replicate. NEVER dw_sku.
// Run: NODE_PATH=.../AbramsOS/node_modules node d3c_enrich_scoped.mjs [--limit N]
import { createRequire } from 'module';
import fs from 'fs';
import path from 'path';
const require = createRequire(import.meta.url);
const { Pool } = require('pg');

const HERE = path.dirname(new URL(import.meta.url).pathname);
const RESTORE = path.join(HERE, 'data', 'd3c-stage-restore.jsonl');
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 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}`);
  return JSON.parse((await r.json()).response);
}

async function main() {
  const ids = fs.readFileSync(RESTORE, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l).id);
  const { rows } = await pool.query(
    `SELECT id, pattern_name, color_name, image_url FROM momentum_colorways
     WHERE id = ANY($1::int[]) AND ai_color_name IS NULL AND image_url IS NOT NULL AND image_url <> ''
     ORDER BY id ${argLimit ? `LIMIT ${argLimit}` : ''}`, [ids]);
  console.log(`[enrich-scoped] ${rows.length} of ${ids.length} staged ids need enrichment. 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 <= 8) console.error(`\n  [skip id=${r.id}] ${e.message}`); }
  }
  console.log(`\n[enrich-scoped] done — ${ok} enriched, ${fail} failed. $0 (local qwen2.5vl).`);
  await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });