← back to Enrich Local Hybrid

enrich-local.js

168 lines

// enrich-local.js — HYBRID local enrichment, drop-in companion to enrich-ai-tags.js.
// Ground-truth hex + percentages from real pixels (Pillow, more accurate than any VLM),
// a vision LLM for the semantic fields (color names, styles, patterns, material, imageType,
// dims, description, usability). Returns the SAME aiData shape as geminiAnalyze().
//
// TK-12090 Lane E (2026-09-23): this used to hit a direct Ollama /api/generate on a Mac
// tailnet host. Ollama is RETIRED (a zombie — the port accepts connections but ZERO models
// are loaded, so every generate call failed and silently fell through to the Gemini
// fallback below, at full per-image cost, since the day Ollama died). Now routed through
// ~/Projects/_shared/lib/exo-vision.mjs: exo ring primary ($0 local), Gemini fallback
// (cost-ledgered) if the ring is unreachable or has no live vision instance right now.
//
// Env (canonical exo-vision-lib names — shared with every other Lane in TK-12090):
//   VISION_URL       exo ring base (default 127.0.0.1:52415 — correct for local Mac
//                     testing via test-local.js). On the KAMATERA deploy this MUST be
//                     overridden to a tailnet/LAN address that can reach a Mac in the
//                     ring — see DEPLOY.md "Env knobs" for the verified reachable host.
//   VISION_MODEL      vision model id (default from the shared lib:
//                     mlx-community/Qwen3-VL-4B-Instruct-4bit). ENRICH_VL_MODEL accepted
//                     as a legacy alias.
//   VISION_FALLBACK   gemini (default) | none
//   ENRICH_PY         python3 path (default 'python3')
//   EXO_TIMEOUT_MS    exo-only attempt budget (default 15000). Short on purpose: a loaded
//                     ring must fail fast to the fallback, not stall the batch.
//   VISION_TIMEOUT_MS fallback (Gemini) budget (default 60000).
//   EXO_BREAKER_N     consecutive exo failures before the ring is skipped for the rest of
//                     the run (default 5).
const { spawnSync } = require('child_process');
const path = require('path');
const fs   = require('fs');
const os   = require('os');
const { pathToFileURL } = require('url');

const EXO_VISION_LIB = process.env.EXO_VISION_LIB
  || path.join(__dirname, '..', '_shared', 'lib', 'exo-vision.mjs');
const PY         = process.env.ENRICH_PY || 'python3';
const PALETTE_PY = path.join(__dirname, 'enrich-palette.py');
const MAX_COLORS = 6;

// TK-12090: batch throughput guards. The exo attempt gets its OWN short timeout so a
// saturated/cold ring fails fast to the fallback instead of burning the shared 120s per
// image (at --limit 1500 that was ~50h/cron, i.e. never finishing, and the tiers stack).
// After EXO_BREAKER_N consecutive exo failures the ring is skipped for the rest of the
// process, bounding total wasted wait to N * EXO_TIMEOUT_MS rather than 1500 * timeout.
const VISION_TIMEOUT_MS = Number(process.env.VISION_TIMEOUT_MS) || 60000;  // fallback (Gemini) budget
const EXO_TIMEOUT_MS    = Number(process.env.EXO_TIMEOUT_MS)    || 45000;  // exo-only budget (warm real-image ≈14s; headroom for load, still «120s)
const EXO_BREAKER_N     = Number(process.env.EXO_BREAKER_N)     || 5;
let _exoFailStreak = 0;
let _exoBreakerTripped = false;

let _libPromise = null;
function exoLib() {
  if (!_libPromise) {
    _libPromise = import(pathToFileURL(EXO_VISION_LIB).href)
      .catch(e => { _libPromise = null; throw new Error(`exo-vision lib unavailable (${EXO_VISION_LIB}): ${e.message}`); });
  }
  return _libPromise;
}

function samplePalette(imgPath, k = MAX_COLORS) {
  const r = spawnSync(PY, [PALETTE_PY, imgPath, String(k)], { encoding: 'utf8', timeout: 25000, maxBuffer: 1 << 20 });
  if (r.status !== 0 || !r.stdout) throw new Error('palette failed: ' + (r.stderr || (r.error && r.error.message) || 'no output'));
  return JSON.parse(r.stdout); // [{hex, percentage}]
}

function extractJson(text) {
  const m = String(text || '').match(/\{[\s\S]*\}/);
  if (!m) throw new Error(`VL: no JSON object found in response: ${String(text || '').slice(0, 200)}`);
  return JSON.parse(m[0]);
}

// Was ollamaVL() using Ollama's grammar-constrained `format: <schema>` — the shared lib
// has no equivalent (it's a plain chat-completions passthrough to exo/Gemini), so the
// schema is now spelled out in the prompt and the response is parsed leniently.
async function visionAnalyze(imageB64, palette) {
  const prompt =
    `This wallcovering/fabric image was pixel-sampled into these EXACT colors (hex + area%): ` +
    `${JSON.stringify(palette)} . In the SAME ORDER, give a designer color name for each hex. ` +
    `Also: backgroundIndex (0-based index of the base/background color in that list), styles, ` +
    `patterns, material, imageType (scan_swatch|scan_flatbed|photo_full|photo_crop|render), ` +
    `physicalWidthInches (number), physicalHeightInches (number), usable (false only if blank/` +
    `corrupt/not a product), rejectionReason (short, "" if usable), description (one sentence).\n\n` +
    `Respond with ONLY a single raw JSON object — no code fences, no commentary — with exactly ` +
    `these keys: colorNames (array of strings, same order/length as the color list above), ` +
    `backgroundIndex (integer), styles (array of strings), patterns (array of strings), ` +
    `material (string), imageType (string), physicalWidthInches (number), physicalHeightInches ` +
    `(number), usable (boolean), rejectionReason (string), description (string).`;

  const lib = await exoLib();
  const r = await lib.visionChat({
    prompt,
    image: { b64: imageB64, mime: 'image/jpeg' },
    model: process.env.VISION_MODEL || process.env.ENRICH_VL_MODEL || undefined,
    timeoutMs: VISION_TIMEOUT_MS,
    exoTimeoutMs: EXO_TIMEOUT_MS,
    skipExo: _exoBreakerTripped,
    maxTokens: 800,
  });
  if (r.ok && r.provider === 'exo') {
    _exoFailStreak = 0;                       // a success clears the streak
  } else if (!_exoBreakerTripped) {
    // Either exo failed (fell back / not measured) or it was skipped. Count only real attempts.
    if (++_exoFailStreak >= EXO_BREAKER_N) {
      _exoBreakerTripped = true;
      console.log(`  → exo circuit breaker OPEN after ${_exoFailStreak} consecutive failures ` +
                  `— remaining images this run go straight to the fallback (saves ${EXO_TIMEOUT_MS}ms/image)`);
    }
  }
  if (!r.ok) throw new Error(r.error || 'vision call not measured (exo ring + Gemini fallback both unavailable)');
  return extractJson(r.text);
}

// Drop junk values ("None"/"N/A"/empty), de-dup, cap to 4 — keeps tag sets clean.
function cleanList(arr) {
  if (!Array.isArray(arr)) return [];
  const seen = new Set(), out = [];
  for (const v of arr) {
    const s = String(v || '').trim();
    if (!s || /^(none|n\/?a|null|undefined)$/i.test(s)) continue;
    const key = s.toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key); out.push(s);
    if (out.length >= 4) break;
  }
  return out;
}

// localAnalyze(imageUrlOrCandidates, mode, fetchBuffer) -> aiData (geminiAnalyze shape) | null
async function localAnalyze(imageUrl, mode, fetchBuffer) {
  const candidates = (Array.isArray(imageUrl) ? imageUrl : [imageUrl]).filter(Boolean);
  let buf = null;
  for (const c of candidates) { try { buf = await fetchBuffer(c); break; } catch (e) { /* next */ } }
  if (!buf) return null; // signal caller to fall back to Gemini
  const tmp = path.join(os.tmpdir(), `enrich-${process.pid}-${Date.now()}.img`);
  fs.writeFileSync(tmp, buf);
  try {
    const sampled = samplePalette(tmp, MAX_COLORS);          // {palette, image_b64 (normalized JPEG)}
    const palette = sampled.palette || [];
    if (!sampled.image_b64) throw new Error('palette: no normalized image');
    const vl = await visionAnalyze(sampled.image_b64, palette);
    if (vl.usable === false) {
      return { image_rejected: true, rejection_reason: vl.rejectionReason || 'not a usable product image' };
    }
    const names = Array.isArray(vl.colorNames) ? vl.colorNames : [];
    const colors = palette.map((c, i) => ({ name: (names[i] || '').trim(), hex: c.hex, percentage: c.percentage }));
    const bgIdx = (Number.isInteger(vl.backgroundIndex) && vl.backgroundIndex >= 0 && vl.backgroundIndex < colors.length) ? vl.backgroundIndex : 0;
    const fg = colors.filter((_, i) => i !== bgIdx);          // dominant = most prominent NON-background color
    const dominant = fg[0] || colors[0] || { hex: '' };
    return {
      backgroundColor: colors[bgIdx] ? colors[bgIdx].name : '',
      backgroundHex:   colors[bgIdx] ? colors[bgIdx].hex : '',
      dominantHex:     dominant.hex,
      colors,
      styles:   cleanList(vl.styles),
      patterns: cleanList(vl.patterns),
      material: vl.material || '',
      imageType: vl.imageType || null,
      physicalWidthInches:  vl.physicalWidthInches  || null,
      physicalHeightInches: vl.physicalHeightInches || null,
      description: vl.description || '',
      image_rejected: false,
      _provider: 'local-hybrid',
    };
  } finally { try { fs.unlinkSync(tmp); } catch (e) { /* ignore */ } }
}

module.exports = { localAnalyze, samplePalette };