← back to Settlement Prescan

scan.mjs

186 lines

#!/usr/bin/env node
/**
 * DW Settlement Gate — reversible PRE-SCAN over the 3 luxury wallcovering catalogs.
 *
 * Runs the settlement IMAGE-SIDE gate (settlement-post-gen-vision path) on each
 * SKU's primary image using LOCAL qwen2.5vl vision on Mac1 Ollama ($0), then
 * applies the settlement-verdict combinator logic and writes:
 *   - <table>.settlement_verdict  (OK | NEEDS_REVIEW | BLOCK)
 *   - <table>.settlement_reason   (one-line rationale + detector booleans)
 *
 * The five detectors mirror the SHA-locked settlement sub-skills:
 *   A1 settlement-part-a1-directional-leaves : directional variation amongst leaves/fronds
 *   A2 settlement-part-a2-open-space         : visible negative space between foliage motifs
 *   A3 settlement-part-a3-multiple-colors    : >1 ink color in the leaf/foliage layer
 *   B  settlement-part-b-prohibited-elements : bananas / banana pods / grapes / birds / butterflies
 *   AC settlement-acceptable-elements        : tree trunks / branches / non-prohibited fruit-or-animal
 *
 * Verdict (settlement-verdict binding, defendant-favorable, fail-closed):
 *   BLOCK        iff  A1 && A2 && A3 && B         (full Part A + Part B; Acceptable can't rescue when B present)
 *   NEEDS_REVIEW iff  any detector is "unknown"   (fail-closed — pre-scan staging, human eyes required)
 *                OR   image could not be fetched/analyzed
 *   OK           otherwise (Part A not fully satisfied, or Part B absent)
 *
 * NOTE ON NEEDS_REVIEW vs the verdict table: the live settlement-verdict table
 * collapses every "unknown" straight to BLOCK (hard publish gate). This is a
 * REVERSIBLE staging PRE-SCAN, not a publish gate — so "unknown"/unreadable is
 * surfaced as NEEDS_REVIEW (human review) rather than a hard BLOCK, matching the
 * OK/NEEDS_REVIEW/BLOCK tri-state Steve asked for. The settlement-clean subset =
 * OK only. No BLOCK/NEEDS_REVIEW row is ever eligible for publish from this scan.
 *
 * Zero npm deps: DB access via `psql` child_process (dw_unified is LOCAL).
 */

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

const OLLAMA = process.env.OLLAMA_HOST || 'http://192.168.1.133:11434';
const MODEL = process.env.SETTLEMENT_VLM || 'qwen2.5vl:7b';
const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
const TMP = path.join(process.cwd(), 'tmp');
fs.mkdirSync(TMP, { recursive: true });

const ALL_TABLES = ['fromental_catalog', 'gracie_catalog', 'zuber_catalog'];

const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
const LIMIT = parseInt(opt('--limit', '0'), 10) || 0;   // 0 = all NULL rows
const ONLY = opt('--table', null);
const tables = ONLY ? [ONLY] : ALL_TABLES;

function log(...a) { console.log(new Date().toISOString(), ...a); }

// ---- psql helpers ---------------------------------------------------------
function psql(sql) {
  const r = spawnSync('psql', [DB, '-t', '-A', '-F', '\t', '-c', sql],
    { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
  if (r.status !== 0) throw new Error('psql: ' + (r.stderr || r.stdout || 'unknown'));
  return r.stdout;
}
function sqlLit(s) { return "'" + String(s).replace(/'/g, "''") + "'"; }

function fetchRows(table) {
  const out = psql(
    `SELECT mfr_sku, image_url FROM ${table}
     WHERE settlement_verdict IS NULL AND image_url IS NOT NULL AND image_url <> ''
     ORDER BY mfr_sku ${LIMIT ? `LIMIT ${LIMIT}` : ''};`
  );
  return out.split('\n').filter(Boolean).map(line => {
    const tab = line.indexOf('\t');
    return { mfr_sku: line.slice(0, tab), image_url: line.slice(tab + 1) };
  });
}
function writeVerdict(table, sku, v, reason) {
  psql(`UPDATE ${table} SET settlement_verdict=${sqlLit(v)}, settlement_reason=${sqlLit(reason.slice(0, 1000))} WHERE mfr_sku=${sqlLit(sku)};`);
}

// ---- download image to base64 ---------------------------------------------
async function fetchImage(url) {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(45000) });
    if (!res.ok) return null;
    const buf = Buffer.from(await res.arrayBuffer());
    if (buf.length < 500) return null;
    return buf.toString('base64');
  } catch { return null; }
}

// ---- one vision call to qwen2.5vl on Mac1 Ollama ($0 local) ---------------
const VISION_PROMPT = `You are a legal image-compliance detector for a wallpaper Settlement Agreement. Look ONLY at what is visually present in this wallpaper/wallcovering image. Answer each of the five questions with exactly true, false, or unknown (use unknown ONLY if the image is unreadable/blank/too abstract to tell).

A1 (directional_leaves): Does the design contain a REPEATING pattern of leaves, palm fronds, or similar foliage where the leaves point in MORE THAN ONE direction/orientation (directional variation)? true only if foliage is clearly present AND directionally varied.
A2 (open_space): Is there visible OPEN NEGATIVE SPACE (background showing) BETWEEN the foliage/leaf motifs, rather than edge-to-edge dense coverage? Answer false if there is no foliage at all.
A3 (multiple_colors): Does the LEAF/FOLIAGE layer itself use MORE THAN ONE ink color (excluding the background)? Answer false if there is no foliage at all.
B (prohibited_elements): Does the image contain ANY of these specific elements: bananas, banana pods, grapes, birds, or butterflies? true if any are present.
AC (acceptable_elements): Does the image contain tree trunks, clearly-represented branches, OR fruit/animal elements OTHER than bananas/grapes/birds/butterflies (e.g. flowers alone do NOT count; but a deer, a fish, a pomegranate, a monkey would count)?

Respond with ONLY a compact JSON object, no prose:
{"A1":true|false|"unknown","A2":true|false|"unknown","A3":true|false|"unknown","B":true|false|"unknown","AC":true|false|"unknown","note":"<=12 word visual summary"}`;

async function runVision(b64) {
  const body = {
    model: MODEL, prompt: VISION_PROMPT, images: [b64],
    stream: false, format: 'json', keep_alive: '30m',
    options: { temperature: 0, num_ctx: 8192 },
  };
  const res = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body), signal: AbortSignal.timeout(180000),
  });
  if (!res.ok) throw new Error(`ollama ${res.status}`);
  const j = await res.json();
  let parsed;
  try { parsed = JSON.parse(j.response); }
  catch { throw new Error('bad-json:' + String(j.response).slice(0, 120)); }
  return parsed;
}

function norm(v) {
  if (v === true) return true;
  if (v === false) return false;
  const s = String(v).toLowerCase().trim();
  if (s === 'true' || s === 'yes') return true;
  if (s === 'false' || s === 'no') return false;
  return 'unknown';
}

// ---- settlement-verdict combinator (tri-state staging variant) ------------
function verdict(d) {
  const { A1, A2, A3, B, AC } = d;
  const anyUnknown = [A1, A2, A3, B, AC].some(x => x === 'unknown');
  const partA = A1 === true && A2 === true && A3 === true;
  const partB = B === true;
  if (partA && partB) {
    return { v: 'BLOCK', why: 'full settlement violation — Part A (directional leaves + open space + multiple colors) all satisfied AND Part B prohibited element present; Acceptable carve-out cannot apply when Part B is present' };
  }
  if (anyUnknown) {
    return { v: 'NEEDS_REVIEW', why: 'fail-closed — one or more detectors returned unknown (unreadable/ambiguous); requires human review before any publish' };
  }
  if (partA && !partB) {
    return { v: 'OK', why: 'Part A fully satisfied but Part B absent — no prohibited banana/grape/bird/butterfly element, permitted' };
  }
  return { v: 'OK', why: 'Part A not fully satisfied (foliage not directional/spaced/multicolor as required) — permitted regardless of other elements' };
}

async function scanTable(table) {
  const rows = fetchRows(table);
  log(`[${table}] ${rows.length} rows to scan${LIMIT ? ` (limit ${LIMIT})` : ''}`);
  let ok = 0, review = 0, block = 0;
  for (const row of rows) {
    const sku = row.mfr_sku;
    let res, reason;
    const b64 = await fetchImage(row.image_url);
    if (!b64) {
      res = { v: 'NEEDS_REVIEW', why: 'image could not be fetched/decoded — human review required' };
      reason = res.why;
    } else {
      try {
        const raw = await runVision(b64);
        const d = { A1: norm(raw.A1), A2: norm(raw.A2), A3: norm(raw.A3), B: norm(raw.B), AC: norm(raw.AC) };
        res = verdict(d);
        reason = `${res.why} [A1=${d.A1} A2=${d.A2} A3=${d.A3} B=${d.B} AC=${d.AC}]` +
                 (raw.note ? ` | ${String(raw.note).slice(0, 80)}` : '');
      } catch (e) {
        res = { v: 'NEEDS_REVIEW', why: 'vision analysis failed: ' + String(e.message).slice(0, 80) };
        reason = res.why;
      }
    }
    writeVerdict(table, sku, res.v, reason);
    if (res.v === 'OK') ok++; else if (res.v === 'NEEDS_REVIEW') review++; else block++;
    const done = ok + review + block;
    if (done % 20 === 0) log(`  [${table}] progress ${done}/${rows.length} ok=${ok} review=${review} block=${block}`);
  }
  log(`[${table}] DONE ok=${ok} needs_review=${review} block=${block}`);
  return { table, ok, review, block, scanned: rows.length };
}

async function main() {
  log(`settlement pre-scan start | model=${MODEL} @ ${OLLAMA} | tables=${tables.join(',')}`);
  const summary = [];
  for (const t of tables) summary.push(await scanTable(t));
  log('SUMMARY ' + JSON.stringify(summary));
}

main().catch(e => { log('FATAL', e); process.exit(1); });