← back to Marketing Command Center

lib/settlement-gate.js

160 lines

'use strict';
// Server-side Settlement gate for the #vendors "Make it ours" creative system.
//
// This is a faithful JS port of the CANONICAL image-side settlement checker
//   ~/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/lib/settlement-checker.ts
//   (checkSettlementCompliance) — the reference implementation the
//   `settlement-post-gen-vision` skill defers to. We do NOT reinvent the rule;
//   we run the SAME post-gen VISION check the skill prescribes, so any
//   AI-generated OR composited DW-original asset is verified against the executed
//   Settlement Agreement (Part A all-three AND Part B, no acceptable carve-out)
//   BEFORE it can be staged/shown as final.
//
// MODEL RE-ROUTE (2026-08-25, TK-10845): the vision call now runs on a LOCAL,
//   $0 vision model (ollama qwen2.5vl:7b on 127.0.0.1:11434) instead of a paid
//   API. History: it was Gemini, then re-routed to Claude vision per Steve's
//   "use other model unless for vision" — but BOTH paid keys (Gemini image AND
//   the box's ANTHROPIC_API_KEY) turned out to be out of credits, which blocked
//   this legal gate (so even the $0 branded card couldn't SAVE) and the room
//   render, and was also killing the sub-agents. Steve's standing directive is
//   $0 / no more paying, so the genuine vision task now runs on the local model
//   Steve already hosts — zero credits, zero rate-limits. Overridable via env
//   (SETTLEMENT_VISION_URL / SETTLEMENT_VISION_MODEL) so Steve can repoint it at
//   a funded frontier vision API later for maximum legal-gate accuracy.
//
// HARD RULES honored from settlement-post-gen-vision/SKILL.md (UNCHANGED — only
// the model TRANSPORT changed; the prompt text, combination logic, return shape,
// and fail-CLOSED behavior are IDENTICAL to the prior Gemini/Claude ports):
//   - Vision is GROUND TRUTH (text anti-prompts don't bind a generator).
//   - FAIL-SAFE / fail-CLOSED: any fetch/parse error or uncertainty returns
//     NEEDS_REVIEW, never an auto-pass. This is a LEGAL compliance gate.
//   - Verdict vocabulary is BLOCK / NEEDS_REVIEW / OK.
//
// COST: $0 (local ollama vision). A local 7B vision model is weaker than a
// frontier model at nuanced motif detection, but the gate stays fail-CLOSED
// (uncertainty -> NEEDS_REVIEW), so the conservative bias is preserved. A plain
// branded-card composite (Treatment 1) is not AI-generated, but we still
// post-gen-vision-check it per the task spec ("run it past settlement post-gen
// vision to be safe").

// Local vision endpoint (ollama /api/generate) + model. Both env-overridable so
// the gate can be repointed at Mac1's ollama or a funded frontier API later.
const VISION_URL = process.env.SETTLEMENT_VISION_URL || 'http://127.0.0.1:11434/api/generate';
const VISION_MODEL = process.env.SETTLEMENT_VISION_MODEL || 'qwen2.5vl:7b';
const VISION_COST = 0; // local, $0
// Back-compat aliases (some callers reference the old names for cost labels).
const CLAUDE_VISION_MODEL = VISION_MODEL;
const CLAUDE_VISION_COST = VISION_COST;

// Retained for back-compat export; no longer required by the local path.
const ANTHROPIC_KEY_NAMES = ['ANTHROPIC_API_KEY'];
function anthropicKey() {
  for (const n of ANTHROPIC_KEY_NAMES) if (process.env[n]) return process.env[n];
  try {
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const p = path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
    const s = fs.readFileSync(p, 'utf8');
    for (const n of ANTHROPIC_KEY_NAMES) {
      const m = s.match(new RegExp('^' + n + '=(.*)$', 'm'));
      if (m) return m[1].replace(/^["']|["']$/g, '').trim();
    }
  } catch { /* ignore */ }
  return '';
}

// Run the post-gen vision gate on a raw image buffer.
// @param {Buffer} buf   the produced image bytes (branded card PNG, or room JPEG)
// @param {string} mime  image mime (image/png | image/jpeg)   [kept for signature compat]
// @param {string} title short label for the prompt (vendor/pattern name)
// @returns {Promise<{verdict:'OK'|'NEEDS_REVIEW'|'BLOCK', reason:string, cost:number, detail:object}>}
async function settlementGateBuffer(buf, mime, title) {
  const failSafe = (why) => ({
    verdict: 'NEEDS_REVIEW',
    reason: `Vision check unavailable (${why}) — flagged for review rather than auto-passed.`,
    cost: 0,
    detail: { failSafe: true },
  });

  if (!Buffer.isBuffer(buf) || !buf.length) return failSafe('empty image buffer');

  const b64 = buf.toString('base64');
  // CANONICAL PROMPT TEXT — preserved VERBATIM across every re-route so the legal
  // gate's semantics are byte-identical; only the transport (local model) changed.
  const prompt = `You are performing a strict LEGAL compliance check on a wallcovering design image for "${String(title || '').slice(0, 120)}".
Answer ONLY with valid JSON, no markdown:
{
  "a1_directional_leaves": <true if the design shows repeating patterns with DIRECTIONAL VARIATION amongst leaves, palm fronds, or similar foliage>,
  "a2_open_space": <true if there is visible OPEN/NEGATIVE SPACE between the leaves (not edge-to-edge foliage coverage)>,
  "a3_multiple_colors": <true if the leaf/foliage layer uses MORE THAN ONE ink color (ignore the background color)>,
  "b_prohibited_elements": <true if the design contains ANY of: bananas, banana pods, grapes, birds, or butterflies>,
  "b_elements_found": [<list which of banana/banana pod/grape/bird/butterfly appear, empty if none>],
  "acceptable_carveouts": <true if the design clearly shows tree trunks, OR clearly represented branches, OR fruit/animal elements OTHER than bananas/grapes/birds/butterflies>,
  "is_tropical_foliage_design": <true if this is a tropical/botanical foliage design at all>,
  "visual_summary": "<one sentence describing what the design depicts>"
}
Be precise and conservative: only answer true when the visual evidence is clear.`;

  let json;
  try {
    // ollama /api/generate with an image + forced JSON output. format:'json'
    // makes the local model return a single JSON object we can parse reliably.
    const res = await fetch(VISION_URL, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        model: VISION_MODEL,
        prompt,
        images: [b64],
        stream: false,
        format: 'json',
        options: { temperature: 0 },
      }),
    });
    json = await res.json();
  } catch (e) {
    return failSafe('local vision fetch ' + String((e && e.message) || e).slice(0, 100));
  }
  if (!json || json.error) {
    return failSafe('local vision: ' + String((json && json.error) || 'no response').slice(0, 120));
  }

  // ollama /api/generate returns { response: "<the JSON text>" }.
  const text = String(json.response || '');
  let v;
  try {
    v = JSON.parse(text.replace(/```json\n?|```\n?/g, '').trim());
  } catch {
    return failSafe('unparseable vision verdict');
  }

  // Defendant-favorable combination (IDENTICAL to the canonical checker):
  // VIOLATION requires FULL Part A (all three) AND Part B, no acceptable carve-out.
  const partA = !!(v.a1_directional_leaves && v.a2_open_space && v.a3_multiple_colors);
  const partB = !!v.b_prohibited_elements;
  const carveout = !!v.acceptable_carveouts;
  const violates = partA && partB && !carveout;
  const needsReview = !violates && ((partA && !carveout) || (partB && !!v.is_tropical_foliage_design));

  const reason =
    `Local vision (${VISION_MODEL}): A1=${!!v.a1_directional_leaves}, A2=${!!v.a2_open_space}, A3=${!!v.a3_multiple_colors} ` +
    `(Part A ${partA ? 'MET' : 'not met'}); Part B=${partB}` +
    `${(v.b_elements_found || []).length ? ` [${(v.b_elements_found || []).join(', ')}]` : ''}; ` +
    `carve-out=${carveout}. ${v.visual_summary || ''}`.trim();

  const verdict = violates ? 'BLOCK' : (needsReview ? 'NEEDS_REVIEW' : 'OK');
  return { verdict, reason, cost: VISION_COST, detail: { partA, partB, carveout, raw: v } };
}

module.exports = {
  settlementGateBuffer,
  anthropicKey,
  VISION_MODEL,
  VISION_COST,
  VISION_URL,
  // back-compat aliases
  CLAUDE_VISION_MODEL,
  CLAUDE_VISION_COST,
};