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