← back to Wallco Ai
Add cactus curator: ranked grid + density slider + 4-verdict admin actions
fbc35e722e6660fcb612fc41eb8207b26d04d9a8 · 2026-05-27 08:02:24 -0700 · Steve Abrams
- /admin/cactus-curator: grid over all 3,299 cactus designs, density slider
(--cols, localStorage), sort (composite rank / vision / seam / newest /
colorway), status filter, multi-select bulk bar, keyboard 1-4 verdicts.
- Ranking = composite of vision-quality (free local Ollama VL, pending) +
seam-quality (free 6-lens edges scan, ingested from on-disk TSV now).
- 4 actions on all_designs: bad (unpublish+user_removed+quarantine PNG),
digital (unpublish + digital_file_at date + tag), fix (needs_fixing_at date
+ tag), live (publish + web_viewer + clear flags). Single + bulk endpoints,
jsonl audit trail.
- New cols digital_file_at / needs_fixing_at / web_viewer; sidecar table
wallco_cactus_rank holds seam+vision scores out of the base table.
Files touched
A public/admin/cactus-curator.htmlA scripts/cactus-curator-setup.jsM server.js
Diff
commit fbc35e722e6660fcb612fc41eb8207b26d04d9a8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 27 08:02:24 2026 -0700
Add cactus curator: ranked grid + density slider + 4-verdict admin actions
- /admin/cactus-curator: grid over all 3,299 cactus designs, density slider
(--cols, localStorage), sort (composite rank / vision / seam / newest /
colorway), status filter, multi-select bulk bar, keyboard 1-4 verdicts.
- Ranking = composite of vision-quality (free local Ollama VL, pending) +
seam-quality (free 6-lens edges scan, ingested from on-disk TSV now).
- 4 actions on all_designs: bad (unpublish+user_removed+quarantine PNG),
digital (unpublish + digital_file_at date + tag), fix (needs_fixing_at date
+ tag), live (publish + web_viewer + clear flags). Single + bulk endpoints,
jsonl audit trail.
- New cols digital_file_at / needs_fixing_at / web_viewer; sidecar table
wallco_cactus_rank holds seam+vision scores out of the base table.
---
public/admin/cactus-curator.html | 311 +++++++++++++++++++++++++++++++++++++++
scripts/cactus-curator-setup.js | 109 ++++++++++++++
server.js | 2 +-
3 files changed, 421 insertions(+), 1 deletion(-)
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
new file mode 100644
index 0000000..e7a2673
--- /dev/null
+++ b/public/admin/cactus-curator.html
@@ -0,0 +1,311 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Cactus Curator — wallco.ai admin</title>
+<style>
+ :root { --cols: 5; --bg:#0e0f10; --panel:#17191b; --line:#272b2e; --ink:#e9ecef; --mut:#9aa3ab; --accent:#7bc96f; }
+ * { box-sizing: border-box; }
+ body { margin:0; font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ background:var(--bg); color:var(--ink); }
+ header { position:sticky; top:0; z-index:20; background:rgba(14,15,16,.96); backdrop-filter:blur(8px);
+ border-bottom:1px solid var(--line); padding:10px 16px; }
+ .row { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
+ h1 { font-size:16px; margin:0; font-weight:650; letter-spacing:.2px; }
+ h1 .leaf { color:var(--accent); }
+ .sub { color:var(--mut); font-size:12px; }
+ label.ctl { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--mut); }
+ select, input[type=range] { accent-color:var(--accent); }
+ select { background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 8px; font-size:13px; }
+ input[type=range] { width:130px; }
+ .pill { background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:12px; color:var(--mut); }
+ .pill b { color:var(--ink); }
+
+ .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:14px 16px 120px; }
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:10px; overflow:hidden;
+ display:flex; flex-direction:column; position:relative; transition:opacity .2s, transform .1s; }
+ .card.decided { opacity:.5; }
+ .card.sel { outline:2px solid #4aa3ff; outline-offset:-2px; }
+ .thumb { position:relative; aspect-ratio:1/1; background:#000; }
+ .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
+ .sel-box { position:absolute; top:6px; left:6px; width:20px; height:20px; border-radius:5px; border:2px solid #fff;
+ background:rgba(0,0,0,.45); cursor:pointer; z-index:3; }
+ .card.sel .sel-box { background:#4aa3ff; border-color:#4aa3ff; }
+ .badges { position:absolute; top:6px; right:6px; display:flex; flex-direction:column; gap:4px; align-items:flex-end; z-index:3; }
+ .b { font-size:10.5px; font-weight:700; padding:2px 6px; border-radius:6px; background:rgba(0,0,0,.62); color:#fff; line-height:1.3; }
+ .b.rank { background:rgba(123,201,111,.92); color:#0e0f10; }
+ .b.pub { background:#2e7d32; } .b.unpub { background:#5a5f63; }
+ .b.fix { background:#c98a00; } .b.dig { background:#7a5bd6; } .b.web { background:#1565c0; }
+ .b.pass { background:#2e7d32; } .b.warn { background:#b07a00; } .b.fail { background:#a13030; } .b.none{background:#555;}
+ .state { position:absolute; bottom:0; left:0; right:0; padding:18px 6px 5px; font-size:11px; font-weight:700; text-align:center;
+ color:#fff; background:linear-gradient(transparent,rgba(0,0,0,.8)); z-index:2; display:none; }
+ .meta { padding:7px 9px 4px; }
+ .cw { font-size:12px; color:var(--ink); font-weight:600; text-transform:capitalize; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+ .scores { display:flex; align-items:center; gap:8px; margin-top:3px; color:var(--mut); font-size:11px; }
+ .dot { width:11px; height:11px; border-radius:50%; border:1px solid rgba(255,255,255,.25); display:inline-block; vertical-align:-1px; }
+ .acts { display:grid; grid-template-columns:1fr 1fr; gap:4px; padding:6px 8px 9px; }
+ .acts button { font:600 11px/1.2 inherit; padding:7px 4px; border-radius:7px; border:1px solid var(--line);
+ background:#1d2023; color:var(--ink); cursor:pointer; transition:filter .1s; }
+ .acts button:hover { filter:brightness(1.25); }
+ .acts .bad { border-color:#5a2222; color:#ff9a9a; }
+ .acts .dig { border-color:#3d2f63; color:#cbb8ff; }
+ .acts .fix { border-color:#5a4410; color:#ffd98a; }
+ .acts .live { border-color:#1f4d2a; color:#9ff0a6; }
+ .acts button kbd { font:inherit; opacity:.55; }
+
+ /* sticky bulk bar */
+ #bulk { position:fixed; bottom:0; left:0; right:0; z-index:30; transform:translateY(110%); transition:transform .2s;
+ background:#16181a; border-top:1px solid var(--line); padding:12px 16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
+ #bulk.show { transform:none; }
+ #bulk b { color:var(--accent); }
+ #bulk button { font:600 13px inherit; padding:8px 14px; border-radius:8px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
+ #bulk .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;}
+ #bulk .clear{margin-left:auto;color:var(--mut);}
+ #empty,#err { padding:60px 16px; text-align:center; color:var(--mut); }
+ #err { color:#ff9a9a; }
+ .legend { font-size:11px; color:var(--mut); }
+ .legend code { background:var(--panel); padding:1px 5px; border-radius:4px; border:1px solid var(--line); color:var(--ink); }
+</style>
+</head>
+<body>
+<header>
+ <div class="row">
+ <h1><span class="leaf">❖</span> Cactus Curator</h1>
+ <span class="pill" id="stat">loading…</span>
+ <label class="ctl">Sort
+ <select id="sort">
+ <option value="rank">Composite rank ▼ (catalog-like)</option>
+ <option value="vision">Vision quality ▼</option>
+ <option value="seam">Seam quality ▼</option>
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="colorway">Colorway</option>
+ </select>
+ </label>
+ <label class="ctl">Show
+ <select id="filter">
+ <option value="undecided">Undecided</option>
+ <option value="all">All (live)</option>
+ <option value="published">Published</option>
+ <option value="unpublished">Unpublished</option>
+ <option value="web">Web-viewer</option>
+ <option value="fix">Needs fixing</option>
+ <option value="digital">Digital file</option>
+ <option value="removed">Removed</option>
+ </select>
+ </label>
+ <label class="ctl">Density
+ <input type="range" id="density" min="2" max="9" step="1" value="5">
+ </label>
+ <span class="legend">keys when hovering a card: <code>1</code> bad · <code>2</code> digital · <code>3</code> fix · <code>4</code> live · <code>x</code> select</span>
+ </div>
+</header>
+
+<div id="err" style="display:none"></div>
+<div id="empty" style="display:none">No designs match this filter.</div>
+<div class="grid" id="grid"></div>
+
+<div id="bulk">
+ <span><b id="bulkn">0</b> selected</span>
+ <button class="bad" data-a="bad">1 · Bad — remove all</button>
+ <button class="dig" data-a="digital">2 · Digital file</button>
+ <button class="fix" data-a="fix">3 · Needs fixing</button>
+ <button class="live" data-a="live">4 · Go live</button>
+ <button class="clear" id="bulkclear">clear selection</button>
+</div>
+
+<script>
+const ADMIN = new URLSearchParams(location.search).get('admin');
+const q = s => ADMIN ? (s + (s.includes('?')?'&':'?') + 'admin=' + encodeURIComponent(ADMIN)) : s;
+const $ = id => document.getElementById(id);
+const grid = $('grid');
+
+const LS = {
+ get d(){ return +(localStorage.getItem('cc_density')||5); }, set d(v){ localStorage.setItem('cc_density',v); },
+ get s(){ return localStorage.getItem('cc_sort')||'rank'; }, set s(v){ localStorage.setItem('cc_sort',v); },
+ get f(){ return localStorage.getItem('cc_filter')||'undecided'; }, set f(v){ localStorage.setItem('cc_filter',v); },
+};
+
+let ALL = []; // every design from the API
+const decided = new Map(); // id -> last action this session (optimistic)
+const selected = new Set();
+
+function applyDensity(v){ document.documentElement.style.setProperty('--cols', v); }
+$('density').value = LS.d; applyDensity(LS.d);
+$('sort').value = LS.s;
+$('filter').value = LS.f;
+
+$('density').addEventListener('input', e => { LS.d = e.target.value; applyDensity(e.target.value); });
+$('sort').addEventListener('change', e => { LS.s = e.target.value; render(); });
+$('filter').addEventListener('change', e => { LS.f = e.target.value; render(); });
+
+function num(v){ return v==null ? null : Number(v); }
+
+function sortFns(mode){
+ const r = d => num(d.rank_score) ?? -1, v = d => num(d.vision_score) ?? -1, s = d => num(d.seam_score) ?? -1;
+ switch(mode){
+ case 'vision': return (a,b)=> v(b)-v(a) || a.id-b.id;
+ case 'seam': return (a,b)=> s(b)-s(a) || a.id-b.id;
+ case 'newest': return (a,b)=> new Date(b.created_at)-new Date(a.created_at);
+ case 'oldest': return (a,b)=> new Date(a.created_at)-new Date(b.created_at);
+ case 'colorway': return (a,b)=> (a.category||'').localeCompare(b.category||'') || r(b)-r(a);
+ default: return (a,b)=> r(b)-r(a) || a.id-b.id; // composite rank
+ }
+}
+
+function passesFilter(d, f){
+ const act = decided.get(d.id);
+ switch(f){
+ case 'all': return true;
+ case 'published': return d.is_published && act!=='bad' && act!=='digital';
+ case 'unpublished': return !d.is_published || act==='digital';
+ case 'web': return d.web_viewer || act==='live';
+ case 'fix': return !!d.needs_fixing_at || act==='fix';
+ case 'digital': return !!d.digital_file_at || act==='digital';
+ case 'removed': return d.user_removed || act==='bad';
+ case 'undecided': // not yet touched in DB and not acted on this session
+ default: return !act && !d.user_removed && !d.needs_fixing_at && !d.digital_file_at && !d.web_viewer;
+ }
+}
+
+function colorway(cat){
+ if(!cat) return 'cactus';
+ return cat.includes('·') ? cat.split('·').slice(1).join('·').trim() : cat;
+}
+function verdictClass(v){ return v==='PASS'?'pass':v==='WARN'?'warn':v==='FAIL'?'fail':'none'; }
+
+function card(d){
+ const el = document.createElement('div');
+ el.className = 'card';
+ el.dataset.id = d.id;
+ const act = decided.get(d.id);
+ if(act) el.classList.add('decided');
+ if(selected.has(d.id)) el.classList.add('sel');
+
+ const rank = num(d.rank_score), vis = num(d.vision_score), seam = num(d.seam_score);
+ const stateLabel = act ? ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ LIVE'})[act]
+ : d.web_viewer ? '✓ LIVE' : d.needs_fixing_at ? '⚠ NEEDS FIXING'
+ : d.digital_file_at ? '⬇ DIGITAL FILE' : d.user_removed ? '✕ REMOVED' : '';
+
+ el.innerHTML = `
+ <div class="thumb">
+ <div class="sel-box" title="select (x)"></div>
+ <div class="badges">
+ <span class="b rank">${rank!=null? '#'+Math.round(rank) : '—'}</span>
+ <span class="b ${d.is_published?'pub':'unpub'}">${d.is_published?'PUB':'unpub'}</span>
+ ${d.web_viewer?'<span class="b web">WEB</span>':''}
+ <span class="b ${verdictClass(d.seam_verdict)}">${d.seam_verdict||'no scan'}</span>
+ </div>
+ <img loading="lazy" src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}">
+ <div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
+ </div>
+ <div class="meta">
+ <div class="cw" title="${d.category||''}">${colorway(d.category)} <span class="sub">#${d.id}</span></div>
+ <div class="scores">
+ <span class="dot" style="background:${d.dominant_hex||'#333'}"></span>
+ <span title="vision quality">V ${vis!=null?Math.round(vis):'·'}</span>
+ <span title="seam quality">S ${seam!=null?Math.round(seam):'·'}</span>
+ </div>
+ </div>
+ <div class="acts">
+ <button class="bad" data-a="bad" title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
+ <button class="dig" data-a="digital" title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
+ <button class="fix" data-a="fix" title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
+ <button class="live" data-a="live" title="Keep, go live in web viewer (4)">✓ Live <kbd>4</kbd></button>
+ </div>`;
+
+ el.querySelector('.sel-box').addEventListener('click', e => { e.stopPropagation(); toggleSel(d.id, el); });
+ el.querySelectorAll('.acts button').forEach(b =>
+ b.addEventListener('click', () => decide(d.id, b.dataset.a, el)));
+ el.addEventListener('mouseenter', () => hovered = d.id);
+ el.addEventListener('mouseleave', () => { if(hovered===d.id) hovered=null; });
+ return el;
+}
+
+let hovered = null;
+
+function render(){
+ const f = $('filter').value;
+ const list = ALL.filter(d => passesFilter(d, f)).sort(sortFns($('sort').value));
+ grid.innerHTML = '';
+ $('empty').style.display = list.length ? 'none' : 'block';
+ const frag = document.createDocumentFragment();
+ for(const d of list) frag.appendChild(card(d));
+ grid.appendChild(frag);
+ updateStat();
+}
+
+function updateStat(){
+ const total = ALL.length;
+ const done = ALL.filter(d => decided.get(d.id) || d.user_removed || d.needs_fixing_at || d.digital_file_at || d.web_viewer).length;
+ $('stat').innerHTML = `<b>${total}</b> cactus · <b>${done}</b> decided · <b>${total-done}</b> left`;
+}
+
+function toggleSel(id, el){
+ if(selected.has(id)){ selected.delete(id); el?.classList.remove('sel'); }
+ else { selected.add(id); el?.classList.add('sel'); }
+ $('bulkn').textContent = selected.size;
+ $('bulk').classList.toggle('show', selected.size>0);
+}
+
+async function decide(id, action, el){
+ el?.classList.add('decided');
+ const st = el?.querySelector('.state');
+ if(st){ st.textContent = ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ LIVE'})[action]; st.style.display='block'; }
+ decided.set(id, action);
+ // patch local model so filters/sorts stay consistent without a full reload
+ const d = ALL.find(x=>x.id===id);
+ if(d){
+ if(action==='bad'){ d.is_published=false; d.user_removed=true; d.web_viewer=false; }
+ else if(action==='digital'){ d.is_published=false; d.web_viewer=false; d.digital_file_at=today(); }
+ else if(action==='fix'){ d.needs_fixing_at=today(); }
+ else if(action==='live'){ d.is_published=true; d.web_viewer=true; d.user_removed=false; d.needs_fixing_at=null; d.digital_file_at=null; }
+ }
+ updateStat();
+ try {
+ const r = await fetch(q(`/api/cactus-decision/${id}`), {
+ method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action })
+ });
+ if(!r.ok){ const j = await r.json().catch(()=>({})); throw new Error(j.error||r.status); }
+ } catch(e){
+ el?.classList.remove('decided');
+ if(st) st.style.display='none';
+ decided.delete(id);
+ flash('Save failed for #'+id+': '+e.message);
+ }
+}
+
+async function bulk(action){
+ const ids = [...selected];
+ if(!ids.length) return;
+ if(action==='bad' && !confirm(`Remove ${ids.length} designs from all surfaces? (quarantines the PNGs)`)) return;
+ ids.forEach(id => { const el = grid.querySelector(`.card[data-id="${id}"]`); decide(id, action, el); });
+ selected.clear(); $('bulkn').textContent=0; $('bulk').classList.remove('show');
+}
+$('bulk').querySelectorAll('button[data-a]').forEach(b => b.addEventListener('click', ()=>bulk(b.dataset.a)));
+$('bulkclear').addEventListener('click', ()=>{ selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel')); selected.clear(); $('bulk').classList.remove('show'); });
+
+document.addEventListener('keydown', e => {
+ if(e.target.tagName==='SELECT'||e.target.tagName==='INPUT') return;
+ const map = {'1':'bad','2':'digital','3':'fix','4':'live'};
+ if(hovered && map[e.key]){ const el = grid.querySelector(`.card[data-id="${hovered}"]`); decide(hovered, map[e.key], el); }
+ else if(hovered && (e.key==='x'||e.key==='X')){ toggleSel(hovered, grid.querySelector(`.card[data-id="${hovered}"]`)); }
+});
+
+function today(){ return new Date().toISOString().slice(0,10); }
+function flash(msg){ const e=$('err'); e.textContent=msg; e.style.display='block'; setTimeout(()=>e.style.display='none', 4000); }
+
+async function load(){
+ try {
+ const r = await fetch(q('/api/admin/cactus/list?include=removed'));
+ if(!r.ok) throw new Error('list '+r.status+(r.status===404?' (admin gate — append ?admin=TOKEN)':''));
+ const j = await r.json();
+ ALL = j.items || [];
+ render();
+ } catch(e){ $('err').style.display='block'; $('err').textContent = 'Failed to load: '+e.message; }
+}
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/cactus-curator-setup.js b/scripts/cactus-curator-setup.js
new file mode 100644
index 0000000..3a00f29
--- /dev/null
+++ b/scripts/cactus-curator-setup.js
@@ -0,0 +1,109 @@
+#!/usr/bin/env node
+'use strict';
+/*
+ * cactus-curator-setup.js — one-time schema + seam-score ingest for the
+ * Cactus Curator (/admin/cactus-curator).
+ *
+ * 1. ALTER all_designs: add the 4-action date/flag columns
+ * digital_file_at (option 2 — "Sell as Digital File : create field with date")
+ * needs_fixing_at (option 3 — "Keep but needs fixing")
+ * web_viewer (option 4 — "Keep, go live in a web viewer")
+ * 2. CREATE wallco_cactus_rank sidecar (keeps cactus-only ranking data OUT
+ * of all_designs so the base table doesn't bloat).
+ * 3. Ingest the already-computed (free) seam scores from the on-disk edges
+ * scan into wallco_cactus_rank.seam_score. Vision scores land later via
+ * scripts/cactus-vision-score.js (free local Ollama vision).
+ *
+ * Idempotent — safe to re-run. Uses the same psql-shell path as server.js.
+ */
+const fs = require('fs');
+const path = require('path');
+const { psqlExecLocal, psqlQuery } = require('../lib/db');
+
+const ROOT = path.join(__dirname, '..');
+const EDGES_TSV = path.join(ROOT, 'data', 'edges-scan-full-2026-05-26.tsv');
+
+function sql(s) { return psqlExecLocal(s); }
+
+// ── 1 + 2. schema ──────────────────────────────────────────────────────────
+console.log('[setup] applying schema (columns + sidecar table)…');
+sql(`
+ALTER TABLE all_designs ADD COLUMN IF NOT EXISTS digital_file_at timestamptz;
+ALTER TABLE all_designs ADD COLUMN IF NOT EXISTS needs_fixing_at timestamptz;
+ALTER TABLE all_designs ADD COLUMN IF NOT EXISTS web_viewer boolean DEFAULT FALSE;
+
+CREATE TABLE IF NOT EXISTS wallco_cactus_rank (
+ design_id bigint PRIMARY KEY,
+ seam_score numeric, -- 0..100, higher = more seamless (print-ready)
+ seam_edge_raw numeric, -- raw worst edge-lens ΔE
+ seam_mid_raw numeric, -- raw worst mid-lens ΔE
+ seam_verdict text, -- PASS / WARN / FAIL from edges scan
+ vision_score numeric, -- 0..100, "looks like a real sellable wallcovering"
+ vision_raw text, -- raw model reply (audit)
+ vision_model text,
+ seam_scored_at timestamptz,
+ vision_scored_at timestamptz
+);
+`);
+console.log('[setup] schema OK');
+
+// ── 3. ingest seam scores from the edges TSV ────────────────────────────────
+// TSV columns: id | category | level | verdict | edge_score | mid_score | note
+if (!fs.existsSync(EDGES_TSV)) {
+ console.warn('[setup] edges TSV not found, skipping seam ingest:', EDGES_TSV);
+ process.exit(0);
+}
+
+// Which design_ids are cactus? Pull the set once so we only ingest cactus rows.
+const cactusIdsRaw = psqlQuery(
+ `SELECT string_agg(id::text, ',') FROM all_designs WHERE category ILIKE '%cactus%';`
+);
+const cactusIds = new Set((cactusIdsRaw || '').split(',').filter(Boolean));
+console.log(`[setup] ${cactusIds.size} cactus design ids`);
+
+const WARN_MAX = 12.0; // matches edges-scan.py WARN threshold
+function seamQuality(edge, mid) {
+ const worst = Math.max(Number(edge) || 0, Number(mid) || 0);
+ // 0 ΔE -> 100 ; WARN_MAX ΔE -> 0 ; beyond -> clamp 0
+ return Math.max(0, Math.min(100, Math.round((1 - worst / WARN_MAX) * 100)));
+}
+
+const lines = fs.readFileSync(EDGES_TSV, 'utf8').split('\n');
+const values = [];
+for (const line of lines) {
+ if (!line.trim()) continue;
+ const c = line.split('|');
+ if (c.length < 6) continue;
+ const id = c[0].trim();
+ if (!cactusIds.has(id)) continue;
+ const verdict = (c[3] || '').trim();
+ const edge = parseFloat(c[4]);
+ const mid = parseFloat(c[5]);
+ if (!Number.isFinite(edge) || !Number.isFinite(mid)) continue;
+ const sq = seamQuality(edge, mid);
+ values.push(`(${id}, ${sq}, ${edge}, ${mid}, '${verdict.replace(/'/g, "''")}', now())`);
+}
+console.log(`[setup] ${values.length} cactus seam rows to ingest`);
+
+// chunked upsert
+const CHUNK = 500;
+let done = 0;
+for (let i = 0; i < values.length; i += CHUNK) {
+ const batch = values.slice(i, i + CHUNK);
+ sql(`
+ INSERT INTO wallco_cactus_rank (design_id, seam_score, seam_edge_raw, seam_mid_raw, seam_verdict, seam_scored_at)
+ VALUES ${batch.join(',')}
+ ON CONFLICT (design_id) DO UPDATE SET
+ seam_score = EXCLUDED.seam_score,
+ seam_edge_raw = EXCLUDED.seam_edge_raw,
+ seam_mid_raw = EXCLUDED.seam_mid_raw,
+ seam_verdict = EXCLUDED.seam_verdict,
+ seam_scored_at= EXCLUDED.seam_scored_at;
+ `);
+ done += batch.length;
+ process.stdout.write(`\r[setup] ingested ${done}/${values.length}`);
+}
+console.log('\n[setup] seam ingest complete');
+
+const cov = psqlQuery(`SELECT count(*) FROM wallco_cactus_rank WHERE seam_score IS NOT NULL;`);
+console.log(`[setup] wallco_cactus_rank rows with seam_score: ${cov}`);
diff --git a/server.js b/server.js
index f6d3ac8..b05c354 100644
--- a/server.js
+++ b/server.js
@@ -1185,7 +1185,7 @@ function applyCactusDecision(id, action) {
psqlExecLocal(`UPDATE all_designs
SET is_published=TRUE, web_viewer=TRUE, user_removed=FALSE,
needs_fixing_at=NULL, digital_file_at=NULL,
- tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing')
+ tags = array_remove(array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing'), 'digital-file')
WHERE id=${id};`);
} else {
throw new Error('bad action');
← fdd35e6 Fix fuzzy edges + blurred center-cross in all 3 seam healers
·
back to Wallco Ai
·
promote gate: require fuzzy_seam_scan PASS in addition to ed 7d932a6 →