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