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