← back to Norma
agents/instagram-agent/jewelry-gate.js
190 lines
/**
* jewelry-gate.js — DURABLE jewelry-counter exclusion gate for the IG post path.
*
* Steve, 2026-09-22: "stop creating any instagrams with jewelry counters."
* Scope = JEWELRY ONLY. Room-setting carousels stay — this gate NEVER drops a
* room:true image, only jewelry:true (and anything it can't decide).
*
* Any image about to be published is classified at SELECTION time. An image the
* classifier flags jewelry:true — OR that it CANNOT decide (backend down / parse
* fail / missing file) — is treated as NOT postable and excluded. Fail-CLOSED:
* an un-measured image is never "safe to post" (CLAUDE.md TK-11431 amendment 1).
*
* Results are cached (data/jewelry-cache.json) keyed by the image's own identity
* (URL, or localpath+mtime+size) so vision is called at most once per image, not
* on every cadence run. Only DECIDED verdicts are cached — a transient null is
* never persisted, so a later run re-measures it.
*
* Detector: the same binary vision question as vision-classify.mjs /
* vision-sweep-local.mjs. Backend order (all local/free-first):
* 1. ollama qwen2.5vl on OLLAMA_HOSTS (default 127.0.0.1,192.168.1.133) — $0
* 2. Gemini (GEMINI_API_KEY) as a fallback
* If every backend fails -> verdict null -> image is BLOCKED (fail-closed).
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const HERE = __dirname;
const CACHE_FILE = path.join(HERE, 'data', 'jewelry-cache.json');
const OLLAMA_HOSTS = (process.env.OLLAMA_HOSTS || '127.0.0.1,192.168.1.133').split(',').map((s) => s.trim()).filter(Boolean);
const VL_MODEL = process.env.VL_MODEL || 'qwen2.5vl:7b';
const CALL_TIMEOUT = Number(process.env.JEWELRY_CALL_TIMEOUT_MS || 60000);
// The jewelry question, phrased identically to the existing detectors so the
// verdict is consistent across the sweep and the live gate.
const PROMPT = `Classify this wallpaper/wallcovering brand Instagram image on two independent yes/no questions.
ROOM = the wallcovering is shown INSTALLED in a decorated room or styled vignette, with furniture, decor, lamps, plants, rugs, or architectural context. A flat swatch, rolled bolt, folded fabric drape, close-up texture, pattern tile, or product-on-white-background is NOT a room -> room:false.
JEWELRY = the image shows a JEWELRY DISPLAY CASE, jewelry counter, or jewelry-store glass display presenting the product (rings, necklaces on busts, earrings on stands in a glass/lit case).
Reply with ONLY a compact JSON object, no prose: {"room":true|false,"jewelry":true|false,"scene":"3-6 word description"}`;
function loadCache() {
try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }
}
function saveCache(c) {
try {
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
} catch { /* cache is best-effort; a write failure must never post an unclassified image */ }
}
/** ollama qwen2.5vl — reads the file in Node (no shell arg-length limit). */
async function classifyOllama(host, buf) {
const b64 = buf.toString('base64');
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), CALL_TIMEOUT);
try {
const res = await fetch(`http://${host}:11434/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({ model: VL_MODEL, prompt: PROMPT, images: [b64], stream: false, format: 'json', options: { temperature: 0, num_predict: 80 } }),
});
clearTimeout(to);
if (!res.ok) return null;
const j = await res.json();
const m = String(j.response || '').match(/\{[\s\S]*\}/);
if (!m) return null;
const p = JSON.parse(m[0]);
return { room: !!p.room, jewelry: !!p.jewelry, scene: String(p.scene || '').slice(0, 60), backend: `ollama:${host}` };
} catch { clearTimeout(to); return null; }
}
/** Gemini fallback (GEMINI_API_KEY from secrets-manager/.env). */
let GEMINI_KEY = null;
function geminiKey() {
if (GEMINI_KEY !== null) return GEMINI_KEY;
try {
const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
GEMINI_KEY = (env.match(/^GEMINI_API_KEY=(.+)$/m) || [])[1]?.trim().replace(/"/g, '') || '';
} catch { GEMINI_KEY = ''; }
return GEMINI_KEY;
}
async function classifyGemini(buf, mime) {
const key = geminiKey();
if (!key) return null;
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
const ep = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), CALL_TIMEOUT);
try {
const res = await fetch(ep, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({
contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: mime, data: buf.toString('base64') } }] }],
generationConfig: { temperature: 0, maxOutputTokens: 200 },
}),
});
clearTimeout(to);
if (!res.ok) return null;
const j = await res.json();
const m = String(j?.candidates?.[0]?.content?.parts?.[0]?.text || '').match(/\{[\s\S]*\}/);
if (!m) return null;
const p = JSON.parse(m[0]);
return { room: !!p.room, jewelry: !!p.jewelry, scene: String(p.scene || '').slice(0, 60), backend: `gemini:${model}` };
} catch { clearTimeout(to); return null; }
}
/** Classify a Buffer through the backend chain. Returns a verdict or null (undecidable). */
async function classifyBuffer(buf, mime) {
for (const host of OLLAMA_HOSTS) {
const v = await classifyOllama(host, buf);
if (v) return v;
}
const g = await classifyGemini(buf, mime || 'image/jpeg');
if (g) return g;
return null;
}
const mimeFor = (p) => (/\.png($|\?)/i.test(p) ? 'image/png' : 'image/jpeg');
/**
* Classify a LOCAL image file. Cache key = abspath|mtime|size (re-measures if the
* file changes). Returns { jewelry, room, scene, decided, backend }.
*/
async function classifyFile(filePath) {
let st;
try { st = fs.statSync(filePath); } catch {
return { jewelry: null, room: null, scene: 'NO_FILE', decided: false };
}
const key = `file:${path.resolve(filePath)}|${st.mtimeMs}|${st.size}`;
const cache = loadCache();
if (cache[key] && cache[key].decided) return cache[key];
const v = await classifyBuffer(fs.readFileSync(filePath), mimeFor(filePath));
const out = v
? { jewelry: v.jewelry, room: v.room, scene: v.scene, decided: true, backend: v.backend, at: new Date().toISOString() }
: { jewelry: null, room: null, scene: 'UNDECIDED', decided: false };
if (out.decided) { cache[key] = out; saveCache(cache); }
return out;
}
/** Download a URL to a temp file, classify, delete the temp. Cache key = URL. */
async function classifyUrl(url) {
const key = `url:${url}`;
const cache = loadCache();
if (cache[key] && cache[key].decided) return cache[key];
let buf, mime = 'image/jpeg';
try {
const u = url.startsWith('//') ? 'https:' + url : url;
const r = await fetch(u, { headers: { 'User-Agent': 'dw-jewelry-gate' } });
if (!r.ok) return { jewelry: null, room: null, scene: `FETCH_${r.status}`, decided: false };
buf = Buffer.from(await r.arrayBuffer());
mime = r.headers.get('content-type') || mimeFor(url);
} catch { return { jewelry: null, room: null, scene: 'FETCH_FAIL', decided: false }; }
const v = await classifyBuffer(buf, mime);
const out = v
? { jewelry: v.jewelry, room: v.room, scene: v.scene, decided: true, backend: v.backend, at: new Date().toISOString() }
: { jewelry: null, room: null, scene: 'UNDECIDED', decided: false };
if (out.decided) { cache[key] = out; saveCache(cache); }
return out;
}
/**
* Gate a list of image URLs. Returns { postable[], blocked[] } where blocked
* carries the reason. An image is postable ONLY if decided AND jewelry===false.
* jewelry===true -> blocked('jewelry'); undecidable -> blocked('undecided').
*/
async function gateUrls(urls) {
const postable = [], blocked = [];
for (const url of urls) {
const v = await classifyUrl(url);
if (v.decided && v.jewelry === false) postable.push(url);
else blocked.push({ url, reason: v.jewelry === true ? 'jewelry' : 'undecided', scene: v.scene });
}
return { postable, blocked };
}
module.exports = { classifyFile, classifyUrl, classifyBuffer, gateUrls, PROMPT };
// CLI: `node jewelry-gate.js <file-or-url> [...]` — prints one verdict per line.
if (require.main === module) {
(async () => {
const args = process.argv.slice(2);
if (!args.length) { console.error('usage: node jewelry-gate.js <file-or-url> [...]'); process.exit(1); }
for (const a of args) {
const v = /^https?:\/\//.test(a) || a.startsWith('//') ? await classifyUrl(a) : await classifyFile(a);
const verdict = !v.decided ? 'BLOCK(undecided)' : v.jewelry ? 'BLOCK(jewelry)' : 'PASS';
console.log(`${verdict} ${a} -> ${JSON.stringify({ jewelry: v.jewelry, room: v.room, scene: v.scene, backend: v.backend })}`);
}
})();
}