← back to Sanderson Onboard
scripts/settlement_vision.mjs
71 lines
// settlement_vision.mjs — the IMAGE-side settlement gate the binding text requires (post-gen vision).
// The name-signal gate (settlement_gate.mjs) is necessary but NOT sufficient for a LEGAL gate: a design
// whose IMAGE shows a bird/butterfly on directional foliage with an innocuous NAME sails past text.
// This runs a LOCAL vision model (qwen2.5vl:7b, $0) on each item's actual product image and asks the
// binding combined-gate question. BLOCK removes the item from the manifest (defendant-favorable on doubt).
// Runs on the batch manifest/queue BEFORE activation. Only images already HTTP-200 verified are fetched.
import fs from 'node:fs';
const OLLAMA = process.env.OLLAMA_URL_VISION || 'http://localhost:11434'; // qwen2.5vl:7b lives on Mac2
const MODEL = 'qwen2.5vl:7b';
const CONCURRENCY = 2;
// The binding combined-gate question, defendant-favorable. We ask for the two prohibited-together facts.
const PROMPT = `You are a legal compliance checker for a wallcovering/fabric catalog. Look at this pattern image and answer STRICTLY in JSON.
A design is PROHIBITED only if BOTH are true together:
A) it shows leaves / palm fronds / directional foliage with visible open space between the foliage, in more than one color, AND
B) it shows any of: bananas, banana pods, grapes, birds (any species), or butterflies (any species).
Answer JSON: {"foliage_directional_multicolor": true|false, "bird_or_butterfly_or_banana_or_grape": true|false, "notes": "<=8 words"}. Only JSON, nothing else.`;
async function fetchImageB64(url) {
const r = await fetch(url);
if (!r.ok) return null;
const buf = Buffer.from(await r.arrayBuffer());
return buf.toString('base64');
}
async function visionCheck(url) {
try {
const b64 = await fetchImageB64(url);
if (!b64) return { verdict: 'UNKNOWN', reason: 'image-fetch-failed' };
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, prompt: PROMPT, images: [b64], stream: false, options: { temperature: 0 } }),
});
const j = await res.json();
let txt = String(j.response || '').replace(/```json|```/g, '').trim();
const m = txt.match(/\{[\s\S]*\}/);
if (!m) return { verdict: 'UNKNOWN', reason: 'no-json', raw: txt.slice(0, 80) };
const p = JSON.parse(m[0]);
const partA = !!p.foliage_directional_multicolor;
const partB = !!p.bird_or_butterfly_or_banana_or_grape;
// combined gate: BOTH -> BLOCK. defendant-favorable: if UNKNOWN we don't auto-block (name gate already ran) but flag.
if (partA && partB) return { verdict: 'BLOCK', reason: `vision: foliage+partB (${p.notes || ''})` };
return { verdict: 'OK', reason: '', a: partA, b: partB };
} catch (e) { return { verdict: 'UNKNOWN', reason: e.message.slice(0, 60) }; }
}
async function run(file) {
const items = JSON.parse(fs.readFileSync(file, 'utf8'));
const kept = [], blocked = [], unknown = [];
let i = 0;
async function worker() {
while (i < items.length) {
const it = items[i++];
const c = await visionCheck(it.image);
it.settlement_vision = c;
if (c.verdict === 'BLOCK') { blocked.push({ dw_sku: it.dw_sku, name: `${it.pattern} ${it.color}`, reason: c.reason }); }
else { kept.push(it); if (c.verdict === 'UNKNOWN') unknown.push(it.dw_sku); }
if ((i % 25) === 0) process.stderr.write(` vision ${i}/${items.length}\r`);
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
fs.writeFileSync(file, JSON.stringify(kept, null, 2));
fs.writeFileSync(file.replace(/\.json$/, '-vision-blocked.json'), JSON.stringify(blocked, null, 2));
console.log(`\n${file}: vision-clean=${kept.length} BLOCKED=${blocked.length} UNKNOWN(kept)=${unknown.length}`);
if (blocked.length) blocked.forEach(b => console.log(` BLOCK ${b.dw_sku} ${b.name} — ${b.reason}`));
}
const file = (process.argv.find(a => a.startsWith('--file=')) || '').split('=')[1] || 'pilot/queue-today.json';
run(new URL('../' + file, import.meta.url).pathname);