← back to Wallco Ai
100% publish gate: no pattern goes live without passing every hard check
7eb9c7b97d636d00c92a9223dd19f70057b5c72d · 2026-05-27 14:21:24 -0700 · Steve Abrams
scripts/publish-gate.py — seam (ΔE≤5) + clean-edges(resolution) + even-coverage
+ solid(no gradient blowup) + on-concept(strict local-vision). ALL must pass.
Wired into both publish paths (drunk hot-or-not + cactus-decision live): fail →
HTTP 422 + reasons, design stays unpublished. force:true overrides (logged).
Validated: blocks off-concept #53677 ('vision: FAIL: Off-concept'), passes clean
#53661/#53663. Honest caveat: pixel coverage can't judge borderline-sparse —
that needs the graphic-designer agent (Claude vision) as a stronger judge.
Files touched
A scripts/publish-gate.pyM server.js
Diff
commit 7eb9c7b97d636d00c92a9223dd19f70057b5c72d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 27 14:21:24 2026 -0700
100% publish gate: no pattern goes live without passing every hard check
scripts/publish-gate.py — seam (ΔE≤5) + clean-edges(resolution) + even-coverage
+ solid(no gradient blowup) + on-concept(strict local-vision). ALL must pass.
Wired into both publish paths (drunk hot-or-not + cactus-decision live): fail →
HTTP 422 + reasons, design stays unpublished. force:true overrides (logged).
Validated: blocks off-concept #53677 ('vision: FAIL: Off-concept'), passes clean
#53661/#53663. Honest caveat: pixel coverage can't judge borderline-sparse —
that needs the graphic-designer agent (Claude vision) as a stronger judge.
---
scripts/publish-gate.py | 129 ++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 65 ++++++++++++++++++++++--
2 files changed, 190 insertions(+), 4 deletions(-)
diff --git a/scripts/publish-gate.py b/scripts/publish-gate.py
new file mode 100644
index 0000000..7641f60
--- /dev/null
+++ b/scripts/publish-gate.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""publish-gate.py — the 100% publish gate for wallco.ai patterns.
+
+A design may flip is_published=TRUE ONLY if it passes EVERY hard check. No
+partial credit — Steve's rule (2026-05-27): "needs to be 100%". Catches exactly
+the failures the lenient llava-95 score let through (sparse #53662/#53664,
+off-concept #53677).
+
+Checks (all must PASS):
+ 1. seamless — 6-lens edges scan, edge & center ΔE ≤ 5 (murals exempt)
+ 2. clean_edges— render resolution adequate (≥1024px) AND edges not LANCZOS-soft
+ 3. coverage — even all-over: every quadrant populated, not sparse/lopsided
+ 4. solid — flat screen-print: ≤ 6 dominant colors, no gradient ramp blow-up
+ 5. on_concept — strict local-vision: is it a clean, even, on-concept <line>
+ at catalog grade? (the only non-deterministic check)
+
+Usage: python3 scripts/publish-gate.py --id 53662 [--json]
+Exit 0 = PASS (publishable), 1 = FAIL. JSON on stdout.
+"""
+import importlib.util, json, os, subprocess, sys, base64, urllib.request, re
+from pathlib import Path
+from collections import Counter
+
+ROOT = Path(__file__).resolve().parent.parent
+ID = None
+for i, a in enumerate(sys.argv):
+ if a == '--id' and i + 1 < len(sys.argv): ID = int(sys.argv[i + 1])
+if ID is None: print(json.dumps({'pass': False, 'reasons': ['no --id']})); sys.exit(1)
+
+VHOST = os.environ.get('OLLAMA_VISION_HOST', '127.0.0.1:11434')
+VMODEL = os.environ.get('OLLAMA_VISION_MODEL', 'llava:latest')
+
+_spec = importlib.util.spec_from_file_location('es', str(ROOT / 'scripts' / 'edges-scan.py'))
+es = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(es)
+
+def psql(q):
+ return subprocess.run(['psql', 'dw_unified', '-At', '-F', '|', '-c', q], capture_output=True, text=True).stdout.strip()
+
+row = psql(f"SELECT COALESCE(local_path,''), COALESCE(kind,''), COALESCE(category,'') FROM all_designs WHERE id={ID};")
+if not row: print(json.dumps({'pass': False, 'reasons': ['design not found']})); sys.exit(1)
+lp, kind, category = (row.split('|', 2) + ['', '', ''])[:3]
+checks, reasons = {}, []
+
+is_mural = kind in ('mural', 'mural_panel')
+
+from PIL import Image
+import numpy as np
+img = None
+if lp and os.path.isfile(lp):
+ try: img = Image.open(lp).convert('RGB')
+ except Exception: img = None
+if img is None:
+ print(json.dumps({'pass': False, 'id': ID, 'reasons': ['image file missing/unreadable']})); sys.exit(1)
+W, H = img.size
+arr = np.asarray(img)
+
+# ── 1. seamless ──────────────────────────────────────────────────────────
+if is_mural:
+ checks['seamless'] = True # murals don't tile
+else:
+ try:
+ r = es.scan_path(Path(lp))
+ ok = r.get('ok') and r['edges_max'] <= 5 and r['mids_max'] <= 5
+ checks['seamless'] = bool(ok)
+ if not ok: reasons.append(f"seam ΔE edge={r.get('edges_max','?')} center={r.get('mids_max','?')} (need ≤5)")
+ except Exception as e:
+ checks['seamless'] = False; reasons.append('seam scan error: ' + str(e)[:40])
+
+# ── 2. clean_edges (resolution) ──────────────────────────────────────────
+checks['clean_edges'] = min(W, H) >= 1024
+if not checks['clean_edges']: reasons.append(f"render too small {W}x{H} (jagged at print size; vectorize to 5400px)")
+
+# ── 3. coverage (even all-over) — 4×4 empty-cell detection ───────────────
+# background = corner-median; motif = pixels far from it. A clean all-over
+# repeat fills every cell; sparse/lopsided designs leave big empty regions.
+small = np.asarray(img.resize((256, 256)))
+corners = np.concatenate([small[:8, :8].reshape(-1, 3), small[:8, -8:].reshape(-1, 3),
+ small[-8:, :8].reshape(-1, 3), small[-8:, -8:].reshape(-1, 3)])
+bg = np.median(corners, axis=0)
+dist = np.sqrt(((small.astype(float) - bg) ** 2).sum(axis=2))
+motif = dist > 40
+overall = float(motif.mean())
+G = 4 # 4×4 grid
+cell = 256 // G
+cells = [float(motif[r*cell:(r+1)*cell, c*cell:(c+1)*cell].mean()) for r in range(G) for c in range(G)]
+empty_cells = sum(1 for c in cells if c < 0.02) # cells <2% motif = near-empty
+even = (overall >= 0.07) and (empty_cells <= 3) # allow ≤3 of 16 empty
+checks['coverage'] = bool(even)
+if overall < 0.07: reasons.append(f"too sparse — only {overall*100:.0f}% motif coverage (need ≥7%)")
+elif empty_cells > 3: reasons.append(f"uneven/lopsided — {empty_cells}/16 grid cells near-empty (big blank regions)")
+
+# ── 4. solid screen-print — catch only true gradient/photo blowups ───────
+# AA edges add intermediate colors, so count only colors with ≥5% share; a
+# flat 2–4 ink design stays well under the cap, a gradient/photographic blowup
+# spreads share across many bins.
+pal = img.resize((128, 128)).quantize(colors=12, method=Image.Quantize.MEDIANCUT).convert('RGB')
+cnt = Counter(pal.getdata()); tot = sum(cnt.values())
+signif = [c for c, n in cnt.items() if n / tot >= 0.05] # ≥5% share (ignores AA fringe)
+checks['solid'] = len(signif) <= 8
+if not checks['solid']: reasons.append(f"{len(signif)} major colors @≥5% (gradient/photographic blowup, not flat screen-print)")
+
+# ── 5. on_concept (strict local vision) ──────────────────────────────────
+subject = (category.split('·')[0].strip() or 'wallpaper').replace('-', ' ')
+PROMPT = (f"This is a wallpaper tile meant to be a '{subject}' design. Answer strictly. "
+ f"Reply PASS only if ALL are true: it clearly depicts {subject}; it is a clean even all-over repeat "
+ f"(no big empty areas); flat solid screen-print colors; refined catalog-grade (Schumacher/Thibaut). "
+ f"Reply FAIL if it is off-concept, sparse/empty, messy, or low quality. "
+ f"Answer with exactly 'PASS' or 'FAIL: <short reason>'.")
+try:
+ jpg = f'/tmp/pg_{ID}.jpg'
+ subprocess.run(['sips', '-Z', '512', '-s', 'format', 'jpeg', lp, '--out', jpg], capture_output=True)
+ b64 = base64.b64encode(open(jpg, 'rb').read()).decode()
+ body = json.dumps({"model": VMODEL, "prompt": PROMPT, "images": [b64], "stream": False,
+ "options": {"temperature": 0, "num_predict": 30}}).encode()
+ rq = urllib.request.Request(f"http://{VHOST}/api/generate", data=body, headers={"Content-Type": "application/json"})
+ resp = json.load(urllib.request.urlopen(rq, timeout=120)).get('response', '').strip()
+ passed = bool(re.match(r'\s*pass', resp, re.I))
+ checks['on_concept'] = passed
+ if not passed: reasons.append('vision: ' + resp[:80])
+ try: os.unlink(jpg)
+ except Exception: pass
+except Exception as e:
+ checks['on_concept'] = False; reasons.append('vision error: ' + str(e)[:40])
+
+# ── verdict: 100% — ALL must pass ────────────────────────────────────────
+ok = all(checks.values())
+out = {'pass': ok, 'id': ID, 'category': category, 'checks': checks, 'reasons': reasons}
+print(json.dumps(out))
+sys.exit(0 if ok else 1)
diff --git a/server.js b/server.js
index 155bfb1..153822d 100644
--- a/server.js
+++ b/server.js
@@ -1161,13 +1161,37 @@ app.get('/api/admin/drunk/list', (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
+// 100% publish gate — runs scripts/publish-gate.py (seam + clean-edges + even
+// coverage + solid + on-concept). Fail-CLOSED: any error → {pass:false}. Used
+// by every publish path so nothing reaches the live storefront without passing.
+function runPublishGate(id) {
+ const { execSync } = require('child_process');
+ try {
+ const out = execSync(
+ `OLLAMA_VISION_HOST=127.0.0.1:11434 OLLAMA_VISION_MODEL=llava:latest python3 ${path.join(__dirname, 'scripts', 'publish-gate.py')} --id ${parseInt(id, 10)}`,
+ { encoding: 'utf8', timeout: 90000, maxBuffer: 10_000_000 }
+ );
+ return JSON.parse(out.trim().split('\n').pop());
+ } catch (e) {
+ try { return JSON.parse((e.stdout || '').trim().split('\n').pop()); } catch {}
+ return { pass: false, reasons: ['gate error: ' + String(e.message || '').slice(0, 80)] };
+ }
+}
+
app.post('/api/admin/drunk/decide', express.json({ limit: '4kb' }), (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
const id = parseInt(req.body && req.body.id, 10);
- const verdict = (req.body && req.body.verdict) === 'hot' ? 'hot' : 'not';
+ const verdict = ['hot', 'fix', 'not'].includes(req.body && req.body.verdict) ? req.body.verdict : 'not';
if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
try {
if (verdict === 'hot') {
+ // 100% PUBLISH GATE (Steve 2026-05-27 "needs to be 100%"). A HOT pick only
+ // goes live if it passes EVERY hard check. Override with force:true (logged).
+ const force = req.body && req.body.force === true;
+ const gate = force ? { pass: true, forced: true } : runPublishGate(id);
+ if (!gate.pass) {
+ return res.status(422).json({ ok: false, gate_failed: true, id, reasons: gate.reasons || [], checks: gate.checks || {} });
+ }
psqlExecLocal(`UPDATE all_designs SET is_published=TRUE, user_removed=FALSE WHERE id=${id};`);
// Auto-spawn a photoreal room render for the HOT pick — detached + non-blocking
// so the curator stays snappy. Lands in room_mockups JSONB / data/rooms.
@@ -1178,6 +1202,11 @@ app.post('/api/admin/drunk/decide', express.json({ limit: '4kb' }), (req, res) =
{ cwd: __dirname, detached: true, stdio: 'ignore' });
child.unref();
} catch (e) { console.warn('[drunk-curator] room render spawn failed:', e.message); }
+ } else if (verdict === 'fix') {
+ // Keep it (unpublished, not removed) but flag for the fix pipeline.
+ psqlExecLocal(`UPDATE all_designs SET is_published=FALSE, user_removed=FALSE, needs_fixing_at=now(),
+ tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing') || ARRAY['needs-fixing']::text[]
+ WHERE id=${id};`);
} else {
psqlExecLocal(`UPDATE all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${id};`);
psqlExecLocal(
@@ -1190,6 +1219,28 @@ app.post('/api/admin/drunk/decide', express.json({ limit: '4kb' }), (req, res) =
} catch (e) { res.status(500).json({ error: e.message }); }
});
+// POST /api/admin/drunk/extract body:{id} — "extract the opaque image only":
+// alpha-trim the design to its solid opaque motif (transparent bg) + a white-
+// ground ref, register it in the elements library. Reuses the elements skill's
+// extract.py (data/elements/<id>_motif.png + element_motif_path on all_designs).
+app.post('/api/admin/drunk/extract', express.json({ limit: '2kb' }), (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const id = parseInt(req.body && req.body.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ try {
+ const { spawnSync } = require('child_process');
+ const r = spawnSync('python3',
+ [path.join(require('os').homedir(), '.claude/skills/elements/scripts/extract.py'), '--id', String(id), '--force'],
+ { cwd: __dirname, env: { ...process.env }, encoding: 'utf8', timeout: 60000 });
+ const out = (r.stdout || '') + (r.stderr || '');
+ const motif = psqlQuery(`SELECT element_motif_path FROM all_designs WHERE id=${id} LIMIT 1;`).trim();
+ if (motif && fs.existsSync(motif)) {
+ return res.json({ ok: true, id, motif_path: motif, motif_url: '/elements/img/' + path.basename(motif) });
+ }
+ res.status(500).json({ ok: false, error: 'extract produced no motif', detail: out.slice(-300) });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
app.get('/api/admin/defect-registry/list', (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
const limit = Math.min(Math.max(parseInt(req.query.limit || '600', 10), 1), 8000);
@@ -1332,7 +1383,7 @@ app.get('/api/admin/cactus/:id', (req, res) => {
});
// Apply one cactus verdict. body { action: 'bad'|'digital'|'fix'|'live' }.
-function applyCactusDecision(id, action) {
+function applyCactusDecision(id, action, opts = {}) {
const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, local_path FROM all_designs WHERE id=${id}) t;`);
if (!raw) throw new Error('design not found');
const row = JSON.parse(raw);
@@ -1360,7 +1411,12 @@ function applyCactusDecision(id, action) {
tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing') || ARRAY['needs-fixing']::text[]
WHERE id=${id};`);
} else if (action === 'live') {
- // 4) Keep, go live in a web viewer — publish + flag, clear fix/digital state.
+ // 4) Keep, go live in a web viewer — but ONLY through the 100% publish gate
+ // (Steve "needs to be 100%"). Throws on fail; the endpoint surfaces reasons.
+ if (!opts.force) {
+ const gate = runPublishGate(id);
+ if (!gate.pass) { const err = new Error('gate_failed'); err.gate = gate; throw err; }
+ }
psqlExecLocal(`UPDATE all_designs
SET is_published=TRUE, web_viewer=TRUE, user_removed=FALSE,
needs_fixing_at=NULL, digital_file_at=NULL,
@@ -1382,9 +1438,10 @@ app.post('/api/cactus-decision/:id', express.json({ limit: '2kb' }), (req, res)
return res.status(400).json({ error: 'action must be bad|digital|fix|live' });
}
try {
- applyCactusDecision(id, action);
+ applyCactusDecision(id, action, { force: req.body?.force === true });
res.json({ ok: true, id, action });
} catch (e) {
+ if (e.message === 'gate_failed') return res.status(422).json({ ok: false, gate_failed: true, id, reasons: e.gate?.reasons || [], checks: e.gate?.checks || {} });
res.status(e.message === 'design not found' ? 404 : 500).json({ error: e.message });
}
});
← 040d453 Add flat-color vectorize pipeline (potrace) + edge-fix compa
·
back to Wallco Ai
·
drunk-curator Hot-or-Not: auto-ID dotted error boxes overlai a1070fe →