← back to Patterndesignlab
Add settlement post-gen vision pass (Gemini 2.5-flash, one call/image, cost-ceiling + 429 hard-stop) and settlement_status columns
7136fea5147816c37efdbec45c2552ca49e56212 · 2026-07-04 13:37:58 -0700 · Steve
Files touched
A scripts/settlement-vision-pass.js
Diff
commit 7136fea5147816c37efdbec45c2552ca49e56212
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jul 4 13:37:58 2026 -0700
Add settlement post-gen vision pass (Gemini 2.5-flash, one call/image, cost-ceiling + 429 hard-stop) and settlement_status columns
---
scripts/settlement-vision-pass.js | 228 ++++++++++++++++++++++++++++++++++++++
1 file changed, 228 insertions(+)
diff --git a/scripts/settlement-vision-pass.js b/scripts/settlement-vision-pass.js
new file mode 100644
index 0000000..89056f2
--- /dev/null
+++ b/scripts/settlement-vision-pass.js
@@ -0,0 +1,228 @@
+#!/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();
+ const { rows } = await pg.query(
+ "SELECT id FROM designs WHERE content_class='credible' AND title ~ 'No\\.[0-9]+$' AND 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); });
← c2f385a Pull 1451 owned credible masters from prod (chinoiserie/stri
·
back to Patterndesignlab
·
Exclude settlement vision_blocked/vision_review designs from df1617f →