← back to Dw Rotation Activator

lib/settlement-gate.js

283 lines

'use strict';
/*
 * settlement-gate.js — per-product SETTLEMENT gate for the rotation activator.
 *
 * The activator flips DRAFT→ACTIVE at ~500/day. WITHOUT this gate it would
 * publish tropical/botanical products (Gracie chinoiserie, Zuber scenics,
 * Fromental florals, Madagascar "Real Banana Leaf") unchecked — a hard-rule
 * violation of the Designer Wallcoverings Settlement Agreement. This module
 * closes that hole. It runs ALONGSIDE the existing 5-field gate: a product must
 * pass BOTH the 5-field gate AND settlement before it can activate.
 *
 * Binding rule (from ~/.claude/skills/settlement/binding/SETTLEMENT-BINDING-TEXT.md):
 *   A design is PROHIBITED iff BOTH
 *     Part A fully (ALL of: directional-variation foliage + open space between
 *       leaves + >1 ink color), AND
 *     Part B (≥1 of: bananas/banana-pods, grapes, birds, butterflies).
 *   The defendant-favorable reading is the only reading; BORDERLINE / UNKNOWN
 *   cases must be ruled BLOCK by default.
 *
 * Three outcomes per product:
 *   PASS  — settlement clear → activator may proceed to ACTIVE (if 5-field also ok).
 *   BLOCK — full Part A + a Part B element → leave DRAFT + hold metafield + log.
 *   HELD  — borderline / vision-unavailable / unknown → leave DRAFT + hold + log.
 *           (Treated identically to BLOCK by the caller: never activates.)
 *
 * Two tiers of check:
 *   (0) CHEAP AUTO-PASS ($0, no vision): a plain natural-texture material whose
 *       TITLE carries no foliage/motif keyword is settlement-MOOT (a solid
 *       grasscloth/cork/silk with no leaves can't satisfy Part A). This covers
 *       the entire tier-0 textures block (the first ~10 days of the rotation),
 *       so the loop is settlement-safe immediately at ~$0/run.
 *   (1) VISION GATE (Gemini 2.5-flash, ~$0.0006/image): everything else — every
 *       motif/print/botanical product that is NOT an auto-pass texture. Downloads
 *       the primary image and asks the model the Part A / Part B / Acceptable
 *       booleans, then applies the binding verdict locally.
 *
 * FAIL-CLOSED: lock.sh (settlement SHA verifier) runs once at construction. If it
 * fails, no motif/vision product may activate this run (they all return HELD);
 * plain auto-pass textures still pass (they never depended on the vision path).
 */
const fs = require('fs');
const os = require('os');
const path = require('path');
const https = require('https');
const { execFileSync } = require('child_process');

// ── texture material lexicon (auto-pass tier) ────────────────────────────────
// Prompt-specified plain-texture set. A product is a candidate auto-pass only if
// its material (metafield OR title-derived) is one of these solid naturals.
const TEXTURE_MATERIALS = [
  'silk', 'grasscloth', 'cork', 'sisal', 'raffia', 'abaca', 'jute', 'linen',
  'hemp', 'paperweave', 'grass', 'seagrass', 'leather', 'suede', 'wool', 'mohair',
];
const TEXTURE_MAT_RE = new RegExp('\\b(' + TEXTURE_MATERIALS.join('|') + ')\\b', 'i');
// 'grass' would match 'grasscloth' anyway; 'paperweave' also spelled 'paper weave'.
const TEXTURE_MAT_RE2 = /paper\s*weave/i;

// Motif / foliage keywords. If ANY appears in the title, the product is NOT a
// solid texture — it carries a motif and must be vision-gated (never auto-passed).
const MOTIF_RE =
  /floral|flower|leaf|leaves|palm|frond|banana|bird|butterfly|tropical|scenic|toile|chinoiserie|bloom|vine|jungle|garden|grape|peacock|foliage|botanic/i;

// ── orchestrator forensic log (shared with the settlement skill) ─────────────
const ORCH_LOG = path.join(os.homedir(),
  'Projects/wallco-ai/data/settlement-audit/orchestrator-log.jsonl');
function appendOrchestrator(entry) {
  try {
    fs.mkdirSync(path.dirname(ORCH_LOG), { recursive: true });
    fs.appendFileSync(ORCH_LOG, JSON.stringify(entry) + '\n');
  } catch (_) { /* forensic log best-effort; never block a run on it */ }
}

// ── cost logging (cost-tracker) ──────────────────────────────────────────────
const COST_LOG = path.join(os.homedir(), '.claude/skills/cost-tracker/scripts/log.js');
// Gemini 2.5-flash vision: a settlement image call is ~1 image (~258 vision
// tokens/tile) + a short prompt + a tiny JSON answer. Bill a flat conservative
// estimate so the ledger stays honest without token accounting per call.
const GEMINI_INPUT_TOKENS_PER_CALL = 800;   // prompt + ~258 image tokens, rounded up
const GEMINI_OUTPUT_TOKENS_PER_CALL = 120;  // small JSON verdict
function logVisionCost() {
  try {
    execFileSync('node', [COST_LOG,
      '--api', 'gemini_2_5_flash',
      '--units', `${GEMINI_INPUT_TOKENS_PER_CALL}:input_token,${GEMINI_OUTPUT_TOKENS_PER_CALL}:output_token`,
      '--app', 'dw-rotation-activator',
      '--note', 'settlement vision gate'], { encoding: 'utf8', stdio: 'ignore' });
  } catch (_) { /* ledger best-effort */ }
  // computed dollar value for inline display + return
  return GEMINI_INPUT_TOKENS_PER_CALL * 7.5e-8 + GEMINI_OUTPUT_TOKENS_PER_CALL * 3e-7;
}

// ── the gate ─────────────────────────────────────────────────────────────────
class SettlementGate {
  constructor() {
    // FAIL-CLOSED lock check — verify the settlement binding SHAs once per run.
    this.lockOk = false;
    this.lockMsg = '';
    try {
      execFileSync('bash', [path.join(os.homedir(), '.claude/skills/settlement/lock.sh')],
        { encoding: 'utf8', stdio: 'pipe' });
      this.lockOk = true;
    } catch (e) {
      this.lockMsg = (e.stdout || e.stderr || e.message || 'lock.sh failed').toString().slice(0, 200);
    }
    // Gemini keys — keep BOTH so a per-key quota cap (429 "monthly spending cap")
    // on one key falls through to the other instead of HELD-ing every tier-1
    // product. Order: generic GEMINI_API_KEY first (its own cap), then wallco.
    const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
    const clean = (v) => (v || '').replace(/['"]/g, '').trim();
    const kGen = clean((env.match(/^GEMINI_API_KEY=(.*)$/m) || [])[1]);
    const kWallco = clean((env.match(/^GEMINI_API_KEY_WALLCO=(.*)$/m) || [])[1]);
    this.geminiKeys = [kGen, kWallco].filter((k, i, a) => k && a.indexOf(k) === i);
    this.geminiKey = this.geminiKeys[0] || '';   // primary (back-compat)
    this.visionCalls = 0;
    this.visionCostTotal = 0;
    this.autoPassCount = 0;
  }

  lockStatus() { return { ok: this.lockOk, msg: this.lockMsg }; }
  stats() {
    return { visionCalls: this.visionCalls, visionCostTotal: this.visionCostTotal,
      autoPassCount: this.autoPassCount };
  }

  // Cheap auto-pass test: solid texture material + NO motif keyword in title.
  isAutoPassTexture(title, material) {
    const t = String(title || '');
    if (MOTIF_RE.test(t)) return false;               // any motif keyword → NOT solid texture
    const matStr = `${material || ''} ${t}`;          // material metafield OR title-derived
    return TEXTURE_MAT_RE.test(matStr) || TEXTURE_MAT_RE2.test(matStr);
  }

  // Fetch an image URL to a base64 buffer (follows one redirect).
  fetchImageB64(url, depth = 0) {
    return new Promise((resolve) => {
      if (!url || depth > 3) return resolve(null);
      https.get(url, (r) => {
        if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) {
          r.resume();
          return resolve(this.fetchImageB64(r.headers.location, depth + 1));
        }
        if (r.statusCode !== 200) { r.resume(); return resolve(null); }
        const chunks = [];
        r.on('data', (c) => chunks.push(c));
        r.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
      }).on('error', () => resolve(null));
    });
  }

  // Gemini 2.5-flash: return the settlement booleans for the produced image.
  // Iterates over available keys, falling through a 429 / quota-cap to the next.
  // Returns { ok:true, partA, partB, acceptable } or { ok:false, why }.
  async visionDetect(b64, mime) {
    const keys = this.geminiKeys && this.geminiKeys.length ? this.geminiKeys
      : (this.geminiKey ? [this.geminiKey] : []);
    if (!keys.length) return { ok: false, why: 'no-gemini-key' };
    let last = { ok: false, why: 'no-attempt' };
    for (const key of keys) {
      const r = await this._visionCall(b64, mime, key);
      if (r.ok) return r;
      last = r;
      // only fall through on rate/quota; a hard error (parse) shouldn't loop keys.
      if (!/^vision-http-429$/.test(r.why || '')) break;
    }
    return last;
  }

  async _visionCall(b64, mime, key) {
    const prompt =
      'You are a legal-compliance image classifier for a wallcovering catalog. Look ONLY at what is ' +
      'visibly depicted in this pattern image. Answer STRICTLY as JSON with these boolean keys:\n' +
      '"a1_directional_foliage": true if the design shows a REPEATING pattern of leaves, palm fronds, or ' +
      'similar foliage with DIRECTIONAL VARIATION (leaves point in more than one direction).\n' +
      '"a2_open_space": true if there is visible OPEN / negative space between the leaves (not edge-to-edge coverage).\n' +
      '"a3_multiple_ink_colors": true if the foliage/leaf layer uses MORE THAN ONE color (excluding the background).\n' +
      '"partB_element": true if ANY of these specific elements is visibly depicted: bananas or banana pods, ' +
      'grapes, birds (any species), or butterflies (any species).\n' +
      '"acceptable_element": true if the design shows tree trunks, clearly represented branches, OR fruit/animal ' +
      'elements OTHER THAN bananas/banana-pods/grapes/birds/butterflies.\n' +
      'Report only what is actually visible. Return ONLY the JSON object, no prose.';
    const body = {
      contents: [{ parts: [
        { inline_data: { mime_type: mime || 'image/jpeg', data: b64 } },
        { text: prompt },
      ] }],
      generationConfig: { temperature: 0, responseMimeType: 'application/json' },
    };
    const data = JSON.stringify(body);
    const res = await new Promise((resolve) => {
      const req = https.request({
        host: 'generativelanguage.googleapis.com',
        path: `/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`,
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
      }, (r) => { let d = ''; r.on('data', (c) => d += c);
        r.on('end', () => resolve({ status: r.statusCode, body: d })); });
      req.on('error', (e) => resolve({ status: 0, err: e.message }));
      req.write(data); req.end();
    });
    if (res.status !== 200) return { ok: false, why: `vision-http-${res.status}` };
    let j;
    try { j = JSON.parse(res.body); } catch { return { ok: false, why: 'vision-parse' }; }
    let obj;
    try { obj = JSON.parse(j.candidates?.[0]?.content?.parts?.[0]?.text || '{}'); }
    catch { return { ok: false, why: 'verdict-parse' }; }
    // require the 5 expected keys — a partial answer is UNKNOWN (fail-closed).
    const keys = ['a1_directional_foliage', 'a2_open_space', 'a3_multiple_ink_colors',
      'partB_element', 'acceptable_element'];
    if (!keys.every((k) => typeof obj[k] === 'boolean')) return { ok: false, why: 'incomplete-verdict', obj };
    const partA = obj.a1_directional_foliage && obj.a2_open_space && obj.a3_multiple_ink_colors;
    return { ok: true, partA, partB: obj.partB_element, acceptable: obj.acceptable_element, raw: obj };
  }

  // Apply the BINDING verdict rule to the vision booleans.
  // PROHIBITED iff (full Part A) AND (Part B). Everything else PASS.
  verdictFromVision(v) {
    if (v.partA && v.partB) {
      return { verdict: 'BLOCK', reason: `fullPartA+partB(${JSON.stringify(v.raw)})` };
    }
    return { verdict: 'PASS', reason: v.partA ? 'partA-but-no-partB' : 'partA-not-fully-satisfied' };
  }

  /*
   * evaluate(product) — the per-product entry point.
   * product = { shopify_id, title, vendor, dw_sku, material, imageUrl }
   * Returns { verdict:'PASS'|'BLOCK'|'HELD', tier:'auto-pass-texture'|'vision', reason, cost }.
   * Caller activates ONLY on verdict === 'PASS'.
   */
  async evaluate(product) {
    const { shopify_id, title, vendor, dw_sku, material, imageUrl } = product;

    // (0) CHEAP AUTO-PASS — solid texture, no foliage motif → settlement MOOT.
    if (this.isAutoPassTexture(title, material)) {
      this.autoPassCount++;
      return { verdict: 'PASS', tier: 'auto-pass-texture',
        reason: 'solid-texture-no-motif', cost: 0 };
    }

    // (1) VISION GATE for everything else.
    // Fail-closed on lock failure: no motif/vision product activates this run.
    if (!this.lockOk) {
      const rec = { verdict: 'HELD', tier: 'vision', reason: 'lock-failed:' + this.lockMsg, cost: 0 };
      appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
        pattern: title, verdict: 'NEEDS REVIEW', reason: 'settlement-lock-failed',
        source: 'dw-rotation-activator' });
      return rec;
    }

    const b64 = await this.fetchImageB64(imageUrl);
    if (!b64) {
      // No image to check → cannot clear settlement → HELD (fail-closed). (The
      // 5-field gate independently requires an image, so this is belt-and-braces.)
      appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
        pattern: title, verdict: 'NEEDS REVIEW', reason: 'no-image-for-settlement',
        source: 'dw-rotation-activator' });
      return { verdict: 'HELD', tier: 'vision', reason: 'no-image', cost: 0 };
    }
    const mime = /\.png(\?|$)/i.test(imageUrl || '') ? 'image/png' : 'image/jpeg';
    const v = await this.visionDetect(b64, mime);
    this.visionCalls++;
    const cost = logVisionCost();
    this.visionCostTotal += cost;

    if (!v.ok) {
      // vision unavailable / incomplete → BORDERLINE → HELD by default.
      appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
        pattern: title, verdict: 'NEEDS REVIEW', reason: 'vision-' + (v.why || 'error'),
        source: 'dw-rotation-activator' });
      return { verdict: 'HELD', tier: 'vision', reason: 'vision-' + (v.why || 'error'), cost };
    }

    const dec = this.verdictFromVision(v);
    if (dec.verdict === 'BLOCK') {
      appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
        pattern: title, verdict: 'BLOCK', reason: dec.reason, source: 'dw-rotation-activator' });
      return { verdict: 'BLOCK', tier: 'vision', reason: dec.reason, cost };
    }
    return { verdict: 'PASS', tier: 'vision', reason: dec.reason, cost };
  }
}

module.exports = { SettlementGate, TEXTURE_MATERIALS, MOTIF_RE };