← back to Dw Settlement Audit

verify-candidates.js

172 lines

#!/usr/bin/env node
/* DW settlement audit — stage 2: vision verify.
 *
 * Reads candidates.jsonl (stage-1 keyword hits), fetches each product's primary
 * image, and runs it through Gemini vision against the settlement gate.
 *
 * GATE FAITHFULNESS (best-practices fix 2026-05-18):
 *   - The settlement constraint set is NOT paraphrased here. It is read VERBATIM
 *     at runtime from the SHA-locked binding vault
 *     (~/.claude/skills/settlement/binding/SETTLEMENT-BINDING-TEXT.md) and the
 *     BINDING-START..BINDING-END block is embedded directly in the prompt.
 *   - Gemini reports only the 5 ATOMIC detector booleans (a1,a2,a3,partB,acceptable)
 *     plus an `uncertain` flag. It does NOT decide the verdict. The verdict is
 *     computed deterministically in JS by the combinator (combine()), matching
 *     the canonical 5-detector + settlement-verdict architecture.
 *   - Each run is SHA-stamped to the vault version it used (run-manifest.json).
 *
 * Gemini vision is the canonically-correct model for image-side detection — the
 * settlement-post-gen-vision skill uses Gemini for exactly this.
 *
 * NEVER deletes or unpublishes anything. Output is a report for Steve's sign-off.
 *
 *   SHOPIFY_ADMIN_TOKEN=... GEMINI_API_KEY=... node verify-candidates.js
 */
'use strict';
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const STOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const GKEY = process.env.GEMINI_API_KEY;
if (!STOKEN || !GKEY) { console.error('need SHOPIFY_ADMIN_TOKEN + GEMINI_API_KEY'); process.exit(1); }

const CAND = 'candidates.jsonl';
const VERD = 'verdicts.jsonl';
const PROG = 'progress2.txt';
const VAULT = os.homedir() + '/.claude/skills/settlement/binding/SETTLEMENT-BINDING-TEXT.md';

// --- pull the binding text VERBATIM from the SHA-locked vault ---------------
const vaultRaw = fs.readFileSync(VAULT, 'utf8');
const m = vaultRaw.match(/<!-- BINDING-START[\s\S]*?BINDING-END[^>]*-->/);
if (!m) { console.error('could not locate BINDING block in vault — aborting'); process.exit(1); }
const BINDING = m[0];
const BINDING_SHA = crypto.createHash('sha256').update(BINDING).digest('hex');
fs.writeFileSync('run-manifest.json', JSON.stringify({
  startedAt: new Date().toISOString(),
  vault: VAULT,
  bindingSha256: BINDING_SHA,
  note: 'verdicts.jsonl was produced against this exact binding-vault version',
}, null, 2) + '\n');

// The detector prompt embeds the binding text verbatim. Gemini reports atomic
// observations only — it must NOT decide the verdict (combine() does that).
const PROMPT = `You are auditing a wallcovering product image against a binding legal settlement.
The settlement constraint set is reproduced VERBATIM below, between the BINDING markers.
Do not reinterpret, soften, or "improve" it. Read it literally and narrowly.

${BINDING}

Evaluate the image against each ATOMIC condition independently. Report ONLY what you
observe — do NOT decide the final PROHIBITED/PERMITTED verdict, that is computed elsewhere.

Notes for accurate observation:
- Part B is a CLOSED list: bananas/banana pods, grapes, birds, butterflies. NOTHING else
  satisfies Part B. Monkeys and all other animals, other fruit, flowers, and insects that
  are not butterflies are NOT Part B elements — for those, partB is false.
- "acceptable" is true if the image shows tree trunks, clearly represented branches, OR a
  fruit/animal element that is NOT one of the four Part B elements.

Reply with ONLY a compact JSON object, no prose, no markdown fence:
{"a1":bool,"a2":bool,"a3":bool,"partB":bool,"partBElements":["..."],"acceptable":bool,"uncertain":bool,"reason":"<=30 words"}`;

const sleep = ms => new Promise(r => setTimeout(r, ms));
const readLines = f => { try { return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean); } catch { return []; } };

// --- deterministic verdict combinator (canonical defendant-favorable reading) ---
function combine(o) {
  if (o.uncertain) return { verdict: 'NEEDS_REVIEW', partA: null };
  const partA = !!(o.a1 && o.a2 && o.a3);
  if (partA && o.partB && !o.acceptable) return { verdict: 'PROHIBITED', partA };
  if (partA && o.partB && o.acceptable) return { verdict: 'NEEDS_REVIEW', partA }; // A∧B∧Acceptable
  return { verdict: 'PERMITTED', partA };
}

async function shopify(path) {
  for (let a = 0; a < 5; a++) {
    const r = await fetch(`https://${SHOP}/admin/api/2024-01/${path}`,
      { headers: { 'X-Shopify-Access-Token': STOKEN } });
    if (r.status === 429) { await sleep(2000); continue; }
    if (!r.ok) return null;
    return r.json();
  }
  return null;
}

async function geminiObserve(imgB64, mime, ctx) {
  const body = {
    contents: [{ parts: [
      { text: PROMPT + '\n\nProduct title: ' + ctx },
      { inline_data: { mime_type: mime, data: imgB64 } },
    ] }],
    generationConfig: {
      temperature: 0, maxOutputTokens: 600,
      thinkingConfig: { thinkingBudget: 0 }, // 2.5-flash thinks by default — eats the budget
    },
  };
  for (let a = 0; a < 4; a++) {
    const r = await fetch(
      'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=' + GKEY,
      { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    if (r.status === 429 || r.status >= 500) { await sleep(3000); continue; }
    const j = await r.json();
    const txt = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
    const mm = txt.match(/\{[\s\S]*\}/);
    if (mm) { try { return JSON.parse(mm[0]); } catch {} }
    return { _err: 'unparseable: ' + txt.slice(0, 80) };
  }
  return { _err: 'gemini unavailable' };
}

(async () => {
  const cands = readLines(CAND).map(l => JSON.parse(l));
  const done = new Set(readLines(VERD).map(l => { try { return JSON.parse(l).id; } catch { return null; } }));
  const todo = cands.filter(c => !done.has(c.id));
  let n = 0, prohibited = 0, needsReview = 0, err = 0;
  const out = fs.createWriteStream(VERD, { flags: 'a' });

  for (const c of todo) {
    n++;
    let rec = { id: c.id, handle: c.handle, title: c.title, status: c.status,
      published: c.published, match: c.match, bindingSha256: BINDING_SHA };
    try {
      const imgs = await shopify(`products/${c.id}/images.json`);
      const all = imgs?.images || [];
      const src = all[0]?.src;
      rec.imageCount = all.length;          // panel flagged: only primary image is checked
      if (!src) { rec.verdict = 'NO_IMAGE'; rec.reason = 'product has no image'; }
      else {
        const ir = await fetch(src);
        const buf = Buffer.from(await ir.arrayBuffer());
        const mime = (ir.headers.get('content-type') || 'image/jpeg').split(';')[0];
        const o = await geminiObserve(buf.toString('base64'), mime, c.title || '');
        rec.imageUrl = src;
        if (o._err) { rec.verdict = 'ERROR'; rec.reason = o._err; }
        else {
          const v = combine(o);
          rec = Object.assign(rec, {
            a1: !!o.a1, a2: !!o.a2, a3: !!o.a3, partA: v.partA,
            partB: !!o.partB, partBElements: o.partBElements || [],
            acceptable: !!o.acceptable, uncertain: !!o.uncertain,
            verdict: v.verdict, reason: o.reason || '',
          });
        }
      }
    } catch (e) { rec.verdict = 'ERROR'; rec.reason = e.message; }
    if (rec.verdict === 'PROHIBITED') prohibited++;
    if (rec.verdict === 'NEEDS_REVIEW') needsReview++;
    if (rec.verdict === 'ERROR') err++;
    out.write(JSON.stringify(rec) + '\n');
    if (n % 10 === 0 || n === todo.length) {
      fs.writeFileSync(PROG, `verified ${n}/${todo.length} (of ${cands.length} total) | ` +
        `PROHIBITED ${prohibited} | NEEDS_REVIEW ${needsReview} | ERROR ${err} | ${new Date().toISOString()}\n`);
    }
    await sleep(700); // pace Shopify + Gemini
  }
  out.end();
  fs.writeFileSync(PROG, `DONE | verified ${n}/${todo.length} | PROHIBITED ${prohibited} | ` +
    `NEEDS_REVIEW ${needsReview} | ERROR ${err} | ${new Date().toISOString()}\n`);
  console.log(`stage 2 done — ${n} verified, ${prohibited} prohibited, ${needsReview} needs-review, ${err} errors`);
})();