← back to Norma
agents/instagram-agent/vision-classify.mjs
87 lines
#!/usr/bin/env node
/**
* vision-classify.mjs — binary Gemini 2.0 Flash classifier for TK-11367.
* For each local image, asks: is this a styled ROOM SETTING / interior scene,
* and/or a JEWELRY DISPLAY CASE? Returns strict JSON per image.
*
* node vision-classify.mjs <img1.jpg> [img2.jpg ...] # ad-hoc
* node vision-classify.mjs --manifest <file.jsonl> # batch (see below)
*
* Manifest lines: {"id": "...", "path": "/abs/img.jpg"} -> appends a result
* line {"id","room","jewelry","raw"} to <manifest>.results.jsonl, idempotent
* (skips ids already in the results file).
*/
import fs from 'fs';
import path from 'path';
const ENV = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
const KEY = (ENV.match(/^GEMINI_API_KEY=(.+)$/m) || [])[1]?.trim().replace(/"/g, '');
if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1); }
const MODEL = process.env.GEMINI_MODEL || 'gemini-3.6-flash';
const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
const PROMPT = `You are auditing a wallpaper/wallcovering brand's Instagram images.
Classify THIS image on two independent yes/no questions:
1. ROOM: Is this a styled ROOM SETTING / interior scene — i.e. the wallcovering
is shown installed on a wall as part of a decorated room or vignette, with
furniture, decor, lamps, plants, or architectural context (bedroom, living
room, lobby, office, dining, hallway, etc.)? A flat swatch, a rolled bolt,
a folded fabric drape, a close-up texture, a pattern tile, or a product-on-
white-background shot is NOT a room setting -> ROOM: no.
2. JEWELRY: Does this image show a JEWELRY DISPLAY CASE, jewelry counter, or
jewelry-store glass display presenting the product? -> yes/no.
Reply with ONLY a compact JSON object, no prose, no markdown fences:
{"room": true|false, "jewelry": true|false, "scene": "<3-6 word description>"}`;
async function classify(imgPath) {
const b64 = fs.readFileSync(imgPath).toString('base64');
const mime = imgPath.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const body = {
contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: mime, data: b64 } }] }],
generationConfig: { temperature: 0, maxOutputTokens: 120 },
};
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (res.status === 429 || res.status >= 500) { await new Promise(r => setTimeout(r, 1500 * (attempt + 1))); continue; }
const j = await res.json();
const txt = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
const m = txt.match(/\{[\s\S]*\}/);
if (!m) return { room: null, jewelry: null, scene: 'PARSE_FAIL', raw: txt.slice(0, 120) };
const parsed = JSON.parse(m[0]);
return { room: !!parsed.room, jewelry: !!parsed.jewelry, scene: parsed.scene || '' };
} catch (e) {
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)));
}
}
return { room: null, jewelry: null, scene: 'API_FAIL' };
}
const args = process.argv.slice(2);
if (args[0] === '--manifest') {
const manifest = args[1];
const outFile = manifest.replace(/\.jsonl$/, '') + '.results.jsonl';
const done = new Set(fs.existsSync(outFile)
? fs.readFileSync(outFile, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l).id; } catch { return null; } }).filter(Boolean)
: []);
const rows = fs.readFileSync(manifest, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(r => !done.has(r.id));
console.error(`manifest: ${rows.length} to classify (${done.size} already done) -> ${outFile}`);
let n = 0, hits = 0;
for (const r of rows) {
if (!fs.existsSync(r.path)) { fs.appendFileSync(outFile, JSON.stringify({ id: r.id, room: null, jewelry: null, scene: 'NO_FILE' }) + '\n'); continue; }
const v = await classify(r.path);
fs.appendFileSync(outFile, JSON.stringify({ id: r.id, ...v }) + '\n');
n++; if (v.room || v.jewelry) { hits++; console.error(` HIT[${v.room ? 'R' : ''}${v.jewelry ? 'J' : ''}] ${r.id} — ${v.scene}`); }
if (n % 100 === 0) console.error(` … ${n}/${rows.length} classified, ${hits} hits so far`);
}
console.error(`DONE — ${n} classified, ${hits} room/jewelry hits.`);
} else {
for (const p of args) {
const v = await classify(p);
console.log(path.basename(p), '->', JSON.stringify(v));
}
}