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