← back to Patterndesignlab
scripts/settlement-vision-pass.js
231 lines
#!/usr/bin/env node
/*
* Settlement post-gen VISION pass for patterndesignlab.com credible auto-title designs.
*
* Faithfully implements the settlement gate (binding text SHA-locked in
* ~/.claude/skills/settlement/binding/SETTLEMENT-BINDING-TEXT.md):
* Part A (A1 directional leaves, A2 open space, A3 >1 ink color) — all three needed
* Part B (bananas/banana-pods/grapes/birds/butterflies) — any one
* Acceptable carve-out (tree trunks / branches / non-prohibited fruit-or-animal)
* PROHIBITED iff (A1 && A2 && A3) && B. Acceptable only rescues when B is absent.
* Unknown / ambiguous inputs on a tropical-ish design -> fail-closed to REVIEW (excluded from serve).
*
* ONE Gemini vision call per image (returns all 5 booleans as JSON) to respect the cost ceiling.
* Logs every paid call to the cost-tracker ledger and HARD-STOPS at the ceiling or on 429/quota.
*
* Usage:
* node scripts/settlement-vision-pass.js --test <id> # single image, no DB write
* node scripts/settlement-vision-pass.js --run [--ceiling 1.58] [--limit N]
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { Client } = require('pg');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const CEILING = (() => {
const i = process.argv.indexOf('--ceiling');
return i >= 0 ? parseFloat(process.argv[i + 1]) : 1.58;
})();
const LIMIT = (() => {
const i = process.argv.indexOf('--limit');
return i >= 0 ? parseInt(process.argv[i + 1], 10) : Infinity;
})();
// ---- Gemini key (classic AIza key that works with generativelanguage endpoint) ----
function loadKey() {
const envFile = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
const txt = fs.readFileSync(envFile, 'utf8');
const pick = (name) => {
const m = txt.match(new RegExp('^' + name + '=(.+)$', 'm'));
return m ? m[1].trim() : null;
};
// GEMINI_API_KEY (AQ.A… account) verified live 2026-07-04; GEMINI_API_KEY_WALLCO
// (AIza… account) hit its monthly billing spend cap (429 RESOURCE_EXHAUSTED).
// Prefer the working account, fall back to the other.
const main = pick('GEMINI_API_KEY');
const wallco = pick('GEMINI_API_KEY_WALLCO');
const key = main || wallco;
if (!key) throw new Error('No Gemini API key found in secrets-manager/.env');
return key;
}
// Cost rates match cost-tracker pricing.json api "gemini_2_5_flash" exactly so the
// running total == the ledger sum. input $0.075/1M, output $0.30/1M tokens.
const PRICE_IN_PER_TOK = 7.5e-8;
const PRICE_OUT_PER_TOK = 3e-7;
const MODEL = 'gemini-2.5-flash';
const COST_API = 'gemini_2_5_flash';
const PROMPT = `You are a strict legal image-compliance detector for a wallpaper/wallcovering settlement gate.
Look ONLY at the actual pixels of this repeating pattern image. Answer 5 questions with true/false/unknown.
Return STRICT JSON only, no prose, exactly this shape:
{"a1":<bool|"unknown">,"a2":<bool|"unknown">,"a3":<bool|"unknown">,"b":<bool|"unknown">,"acceptable":<bool|"unknown">,"b_elements":[...],"notes":"<=12 words"}
Definitions (be literal):
- a1 = repeating patterns with DIRECTIONAL VARIATION amongst leaves, palm fronds, or similar foliage (leaves pointing in multiple orientations). If there is essentially no foliage, a1=false.
- a2 = visible OPEN/negative SPACE between the leaves/foliage motifs (not edge-to-edge coverage). If no foliage, a2=false.
- a3 = MORE THAN ONE ink/color used in the leaf/foliage layer itself (exclude the background). If no foliage, a3=false.
- b = does ANY of these specific elements appear anywhere: bananas or banana pods, grapes, BIRDS (any species incl. cranes/pheasants/parrots), BUTTERFLIES (any species). List which in b_elements.
- acceptable = does at least one appear: tree trunks, clearly represented branches, OR fruit/animal elements OTHER THAN bananas/banana-pods/grapes/birds/butterflies (e.g. monkeys, deer, florals-with-fruit).
Use "unknown" only when the image is genuinely too ambiguous to tell.`;
function callGemini(key, imgPath) {
const b64 = fs.readFileSync(imgPath).toString('base64');
const ext = path.extname(imgPath).toLowerCase();
const mime = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
const body = JSON.stringify({
contents: [{ parts: [ { text: PROMPT }, { inline_data: { mime_type: mime, data: b64 } } ] }],
generationConfig: { temperature: 0, responseMimeType: 'application/json', thinkingConfig: { thinkingBudget: 0 } }
});
const opts = {
hostname: 'generativelanguage.googleapis.com',
path: `/v1beta/models/${MODEL}:generateContent?key=${key}`,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
};
return new Promise((resolve, reject) => {
const req = https.request(opts, (res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => resolve({ status: res.statusCode, data }));
});
req.on('error', reject);
req.write(body);
req.end();
});
}
function verdictFrom({ a1, a2, a3, b, acceptable }) {
const isUnknown = (v) => v === 'unknown' || v === null || v === undefined;
const anyUnknown = [a1, a2, a3, b, acceptable].some(isUnknown);
const partAfull = a1 === true && a2 === true && a3 === true;
// strict biconditional: PROHIBITED iff Part A fully satisfied AND Part B present
if (partAfull && b === true) return { verdict: 'BLOCK', status: 'vision_blocked' };
// fail-closed: any unknown on a foliage/tropical-ish design -> REVIEW (excluded from serve)
if (anyUnknown) {
// only escalate to review if there is a plausible tropical/foliage or Part-B signal
const foliageSignal = a1 === true || a2 === true || a3 === true || b === true || isUnknown(b);
if (foliageSignal && (b === true || isUnknown(b) || (partAfull))) {
return { verdict: 'NEEDS REVIEW', status: 'vision_review' };
}
}
return { verdict: 'OK', status: 'vision_ok' };
}
function parseGemini(resp) {
if (resp.status === 429) throw Object.assign(new Error('RATE_LIMIT_429'), { rate: true });
if (resp.status >= 400) {
const low = resp.data.toLowerCase();
if (low.includes('quota') || low.includes('resource_exhausted') || low.includes('rate')) {
throw Object.assign(new Error('QUOTA/RATE: ' + resp.data.slice(0, 200)), { rate: true });
}
throw new Error(`Gemini HTTP ${resp.status}: ${resp.data.slice(0, 300)}`);
}
const j = JSON.parse(resp.data);
const usage = j.usageMetadata || {};
const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
let obj;
try { obj = JSON.parse(text); }
catch (e) {
const m = text.match(/\{[\s\S]*\}/);
if (!m) throw new Error('No JSON in Gemini response: ' + text.slice(0, 200));
obj = JSON.parse(m[0]);
}
const inTok = usage.promptTokenCount || 0;
const outTok = (usage.candidatesTokenCount || 0) + (usage.thoughtsTokenCount || 0);
const cost = inTok * PRICE_IN_PER_TOK + outTok * PRICE_OUT_PER_TOK;
return { obj, usage, cost, inTok, outTok };
}
function logCost(inTok, outTok, note) {
try {
execFileSync('node', [
path.join(process.env.HOME, '.claude/skills/cost-tracker/scripts/log.js'),
'--api', COST_API,
'--units', `${inTok}:input_token,${outTok}:output_token`,
'--app', 'patterndesignlab-settlement-vision', '--note', note
], { stdio: 'ignore' });
} catch (e) { /* ledger best-effort; do not abort the pass */ }
}
async function screenOne(key, id) {
const imgPath = path.join(ROOT, 'public/assets/patterns', `pdl-${id}.png`);
if (!fs.existsSync(imgPath)) return { id, skipped: 'missing-file' };
const resp = await callGemini(key, imgPath);
const { obj, usage, cost, inTok, outTok } = parseGemini(resp);
const v = verdictFrom(obj);
return { id, ...v, dets: obj, cost, usage, inTok, outTok };
}
async function main() {
const key = loadKey();
const mode = process.argv.includes('--test') ? 'test' : (process.argv.includes('--run') ? 'run' : null);
if (!mode) { console.error('specify --test <id> or --run'); process.exit(2); }
if (mode === 'test') {
const id = process.argv[process.argv.indexOf('--test') + 1];
const r = await screenOne(key, id);
console.log(JSON.stringify(r, null, 2));
if (!r.skipped) logCost(r.inTok, r.outTok, `test id=${id}`);
return;
}
// ---- run mode ----
const pg = new Client({ database: 'patterndesignlab' });
await pg.connect();
// Pre-public go-live: screen EVERY still-unscreened design (novelty + non-auto-title
// credibles included) so nothing goes public unscreened. Was previously credible auto-title only.
const { rows } = await pg.query(
"SELECT id FROM designs WHERE settlement_status IS NULL ORDER BY id"
);
const ids = rows.map((r) => r.id).slice(0, LIMIT === Infinity ? undefined : LIMIT);
console.log(`[run] ${ids.length} designs to screen (ceiling $${CEILING})`);
let total = 0, done = 0, blocked = 0, review = 0, ok = 0, skipped = 0;
const blockedExamples = [];
const started = Date.now();
for (const id of ids) {
// pre-flight ceiling guard (estimate next call ~ $0.0007)
if (total + 0.0007 > CEILING) {
console.log(`\n[HARD-STOP] ceiling $${CEILING} would be exceeded (running $${total.toFixed(4)}). Stopping.`);
break;
}
let r;
try {
r = await screenOne(key, id);
} catch (e) {
if (e.rate) {
console.log(`\n[HARD-STOP] rate/quota error at id=${id}: ${e.message}. Stopping.`);
break;
}
console.log(`[err] id=${id}: ${e.message}`);
continue;
}
if (r.skipped) { skipped++; continue; }
total += r.cost || 0;
done++;
await pg.query('UPDATE designs SET settlement_status=$1, settlement_checked_at=now() WHERE id=$2', [r.status, id]);
if (r.status === 'vision_blocked') { blocked++; if (blockedExamples.length < 8) blockedExamples.push({ id, dets: r.dets }); }
else if (r.status === 'vision_review') review++;
else ok++;
logCost(r.inTok, r.outTok, `id=${id} ${r.status}`);
if (done % 25 === 0) {
console.log(`[progress] done=${done} ok=${ok} review=${review} blocked=${blocked} | running $${total.toFixed(4)} (avg $${(total/done).toFixed(5)}/img)`);
}
}
const elapsed = ((Date.now() - started) / 1000).toFixed(0);
console.log('\n===== SETTLEMENT VISION PASS SUMMARY =====');
console.log(`screened(this run): ${done} ok: ${ok} review: ${review} blocked: ${blocked} skipped: ${skipped}`);
console.log(`running cost: $${total.toFixed(4)} avg: $${done? (total/done).toFixed(5):'0'}/img elapsed: ${elapsed}s`);
const remaining = ids.length - done - skipped;
console.log(`remaining unscreened in this batch: ${remaining}`);
console.log('blocked examples:');
for (const b of blockedExamples) console.log(` #${b.id}: b_elements=${JSON.stringify(b.dets.b_elements)} a1/a2/a3/b/acc=${b.dets.a1}/${b.dets.a2}/${b.dets.a3}/${b.dets.b}/${b.dets.acceptable} :: ${b.dets.notes}`);
await pg.end();
}
main().catch((e) => { console.error(e); process.exit(1); });