[object Object]

← 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

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 →