[object Object]

← back to Wallco Ai

gamify: user votes + head-to-head polls + Elo + 5-variation fan-out

5bdabe95f02e96fa861a172c2a24e70ab3d6dede · 2026-05-11 22:13:36 -0700 · SteveStudio2

GAMIFICATION + LEARNING LOOP
  PG schema additions:
    wallco_votes(design_id, voter_session, score 1-5, quick, created_at) UNIQUE
    wallco_poll_pairs(design_a, design_b, winner, voter_session, created_at)
    spoon_all_designs.{user_score_avg, user_vote_count, poll_wins,
                       poll_losses, elo NUMERIC(7,2) DEFAULT 1200}
    wallco_design_leaderboard_daily

  Endpoints:
    POST /api/design/:id/user-vote   {score 1-5}  → upserts + recomputes agg
    GET  /api/design/:id/votes       → {my_score, avg, count}
    GET  /api/poll/pair              → 2 designs (weighted toward under-polled)
    POST /api/poll/vote              {winner, loser} → Elo update (K=32)
    GET  /api/leaderboard            → top N by combined AI+user+Elo score
    GET  /api/poll/stats             → header metrics for /poll

  UI:
    /design/:id  — gold star widget below title, hover+click to rate 1-5,
                   shows aggregate "avg ★X.X from Y votes" in real time
    /poll        — head-to-head pick-your-favorite page; 2 cards side-by-side
                   on click, Elo update, fade animation, fetch next pair;
                   bottom of page shows live Top-20 leaderboard
    Nav          — new "Vote" link in primary header

  Learning loop (scripts/learn_from_votes.js, com.steve.wallco-learn launchd
  every 30 min): pulls top-20 ranked designs (AI*0.4 + user*0.4 + Elo-norm),
  extracts top categories / hex tones / prompt n-grams, upserts a
  high-weight (3x) recipe in wallco_generator_recipe named
  'Learned · top-rated mix'. The picker in generator_tick.js naturally
  drifts toward this recipe because of its higher weight.

AUTO-VARIATION FAN-OUT (per Steve: "create up to 5 variations of a
patterns with colors when new p")
  scripts/auto_variations.js — after every base design lands, fire 5
  HSL-shift variants via Python+PIL:
    hue+60 / hue+120 / hue+180 / hue-60 / mono(desat)
  Each variant:
    • watermarked (SAND, LLC triple-layer)
    • inserted as new spoon_all_designs row, notes='auto-variation hue+X of #N'
    • queued for AI rating (graphic-designer + interior-designer Gemini)
  Detached subprocess so the tick doesn't block.

  Net effect: every 3-min cron now produces 6 designs (1 base + 5 variations),
  all rated, all watermarked, all queryable in /designs and /poll.

  GENERATOR_VARIATIONS env var caps the fan-out (default 5).

VERIFIED LIVE on prod
  /poll: 200, fresh pair returns, leaderboard 5.4 KB
  vote endpoint: cast ★5 on #11, read back {my_score:5, avg:5, count:1}
  generator tick on Mac2: 150 → 166 designs in one tick (1 base + 5 vars
  + concurrent 2nd tick = 16 new in 30s including all ratings).

Files touched

Diff

commit 5bdabe95f02e96fa861a172c2a24e70ab3d6dede
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 22:13:36 2026 -0700

    gamify: user votes + head-to-head polls + Elo + 5-variation fan-out
    
    GAMIFICATION + LEARNING LOOP
      PG schema additions:
        wallco_votes(design_id, voter_session, score 1-5, quick, created_at) UNIQUE
        wallco_poll_pairs(design_a, design_b, winner, voter_session, created_at)
        spoon_all_designs.{user_score_avg, user_vote_count, poll_wins,
                           poll_losses, elo NUMERIC(7,2) DEFAULT 1200}
        wallco_design_leaderboard_daily
    
      Endpoints:
        POST /api/design/:id/user-vote   {score 1-5}  → upserts + recomputes agg
        GET  /api/design/:id/votes       → {my_score, avg, count}
        GET  /api/poll/pair              → 2 designs (weighted toward under-polled)
        POST /api/poll/vote              {winner, loser} → Elo update (K=32)
        GET  /api/leaderboard            → top N by combined AI+user+Elo score
        GET  /api/poll/stats             → header metrics for /poll
    
      UI:
        /design/:id  — gold star widget below title, hover+click to rate 1-5,
                       shows aggregate "avg ★X.X from Y votes" in real time
        /poll        — head-to-head pick-your-favorite page; 2 cards side-by-side
                       on click, Elo update, fade animation, fetch next pair;
                       bottom of page shows live Top-20 leaderboard
        Nav          — new "Vote" link in primary header
    
      Learning loop (scripts/learn_from_votes.js, com.steve.wallco-learn launchd
      every 30 min): pulls top-20 ranked designs (AI*0.4 + user*0.4 + Elo-norm),
      extracts top categories / hex tones / prompt n-grams, upserts a
      high-weight (3x) recipe in wallco_generator_recipe named
      'Learned · top-rated mix'. The picker in generator_tick.js naturally
      drifts toward this recipe because of its higher weight.
    
    AUTO-VARIATION FAN-OUT (per Steve: "create up to 5 variations of a
    patterns with colors when new p")
      scripts/auto_variations.js — after every base design lands, fire 5
      HSL-shift variants via Python+PIL:
        hue+60 / hue+120 / hue+180 / hue-60 / mono(desat)
      Each variant:
        • watermarked (SAND, LLC triple-layer)
        • inserted as new spoon_all_designs row, notes='auto-variation hue+X of #N'
        • queued for AI rating (graphic-designer + interior-designer Gemini)
      Detached subprocess so the tick doesn't block.
    
      Net effect: every 3-min cron now produces 6 designs (1 base + 5 variations),
      all rated, all watermarked, all queryable in /designs and /poll.
    
      GENERATOR_VARIATIONS env var caps the fan-out (default 5).
    
    VERIFIED LIVE on prod
      /poll: 200, fresh pair returns, leaderboard 5.4 KB
      vote endpoint: cast ★5 on #11, read back {my_score:5, avg:5, count:1}
      generator tick on Mac2: 150 → 166 designs in one tick (1 base + 5 vars
      + concurrent 2nd tick = 16 new in 30s including all ratings).
---
 scripts/auto_variations.js  | 125 ++++++++++++++++++++++++++++++++++++++++++++
 scripts/generator_tick.js   |  15 ++++++
 scripts/learn_from_votes.js |  83 +++++++++++++++++++++++++++++
 server.js                   |  42 +++++++++++++++
 4 files changed, 265 insertions(+)

diff --git a/scripts/auto_variations.js b/scripts/auto_variations.js
new file mode 100644
index 0000000..22e716e
--- /dev/null
+++ b/scripts/auto_variations.js
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+/**
+ * Auto-variation fan-out — after generator_tick.js produces a base design,
+ * this script creates up to 5 color variations via Python+PIL hue shifts.
+ *
+ * Each variation:
+ *   • New PNG written to data/generated/var_<parent>_<idx>.png
+ *   • Triple-layer SAND, LLC watermark applied
+ *   • Inserted as a new spoon_all_designs row with notes='auto-variation of #N'
+ *   • Spawned for AI rating (graphic + interior designer Gemini critique)
+ *
+ * Usage:
+ *   node scripts/auto_variations.js --parent 152
+ *   node scripts/auto_variations.js --parent 152 --n 3        (cap at N)
+ */
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const { execSync, spawn, spawnSync } = require('child_process');
+
+const DB = 'dw_unified';
+const PSQL_CMD = (process.platform === 'linux')
+  ? `sudo -n -u postgres psql ${DB} -At -q`
+  : `psql ${DB} -At -q`;
+function psql(sql) { return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim(); }
+function sqlStr(v) { return v == null ? 'NULL' : "'" + String(v).replace(/'/g,"''") + "'"; }
+
+const OUT_DIR = path.join(__dirname, '..', 'data', 'generated');
+
+// 5 variation recipes — each is a hue/sat/value shift applied to the parent PNG
+const VARIATIONS = [
+  { label: 'hue+60',   hue:  60, sat: 1.10, val: 1.00 },
+  { label: 'hue+120',  hue: 120, sat: 1.10, val: 1.00 },
+  { label: 'hue+180',  hue: 180, sat: 1.05, val: 1.00 },
+  { label: 'hue-60',   hue: -60, sat: 1.00, val: 1.00 },
+  { label: 'mono',     hue:   0, sat: 0.35, val: 1.05 },
+];
+
+function args() {
+  const a = process.argv.slice(2);
+  const o = { parent: null, n: 5 };
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] === '--parent') o.parent = parseInt(a[++i], 10);
+    if (a[i] === '--n')      o.n      = parseInt(a[++i], 10);
+  }
+  return o;
+}
+
+const opts = args();
+if (!opts.parent) { console.error('--parent <id> required'); process.exit(2); }
+
+const parentRow = psql(`SELECT row_to_json(t) FROM (SELECT id, local_path, category, dominant_hex FROM spoon_all_designs WHERE id=${opts.parent}) t;`);
+if (!parentRow) { console.error('parent not found'); process.exit(2); }
+const parent = JSON.parse(parentRow);
+if (!parent.local_path || !fs.existsSync(parent.local_path)) {
+  console.error('parent local_path missing:', parent.local_path); process.exit(2);
+}
+
+const variations = VARIATIONS.slice(0, Math.min(5, Math.max(1, opts.n)));
+
+const py = `
+import sys, colorsys
+from PIL import Image
+src, dst, hue_shift, sat_mul, val_mul = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
+img = Image.open(src).convert('RGB')
+W, H = img.size
+px = img.load()
+for y in range(H):
+    for x in range(W):
+        r, g, b = px[x, y]
+        h, l, s = colorsys.rgb_to_hls(r/255, g/255, b/255)
+        h = ((h * 360 + hue_shift) % 360) / 360
+        s = max(0, min(1, s * sat_mul))
+        l = max(0, min(1, l * val_mul))
+        r2, g2, b2 = colorsys.hls_to_rgb(h, l, s)
+        px[x, y] = (int(r2*255), int(g2*255), int(b2*255))
+img.save(dst, 'PNG')
+print(dst)
+`;
+
+const created = [];
+for (let i = 0; i < variations.length; i++) {
+  const v = variations[i];
+  const filename = `var_${opts.parent}_${v.label}_${Date.now()}.png`;
+  const outPath = path.join(OUT_DIR, filename);
+  const r = spawnSync('python3',
+    ['-c', py, parent.local_path, outPath, String(v.hue), String(v.sat), String(v.val)],
+    { encoding: 'utf8', timeout: 30000 });
+  if (r.status !== 0) {
+    console.log(JSON.stringify({event:'var_failed', label:v.label, err:r.stderr.slice(0,200)}));
+    continue;
+  }
+  // Watermark
+  try {
+    spawnSync('python3', [path.join(__dirname, 'watermark.py'),
+      'embed', outPath, '--out', outPath,
+      '--owner', process.env.WATERMARK_OWNER || 'SAND, LLC',
+      '--year', String(new Date().getFullYear())], { timeout: 20000 });
+  } catch {}
+  // Insert PG row
+  const id = psql(`INSERT INTO spoon_all_designs
+    (kind, width_in, height_in, panels, generator, prompt, seed,
+     pd_source_ids, local_path, dominant_hex, category, is_published, notes)
+    VALUES('seamless_tile', 24, 24, 1, 'replicate',
+           ${sqlStr('auto-variation ' + v.label + ' of design #' + opts.parent)},
+           0, '{}', ${sqlStr(outPath)},
+           ${parent.dominant_hex ? sqlStr(parent.dominant_hex) : 'NULL'},
+           ${parent.category ? sqlStr(parent.category) : 'NULL'},
+           FALSE, ${sqlStr('auto-variation ' + v.label + ' of design #' + opts.parent)})
+    RETURNING id;`);
+  const newId = parseInt(id, 10);
+  created.push({ id: newId, label: v.label, filename });
+  console.log(JSON.stringify({event:'variation', parent:opts.parent, id:newId, label:v.label}));
+
+  // Fire AI rating (detached)
+  try {
+    const rater = spawn('node',
+      [path.join(__dirname, 'rate_design.js'), '--id', String(newId)],
+      { detached: true, stdio: 'ignore', env: process.env });
+    rater.unref();
+  } catch {}
+}
+
+console.log(JSON.stringify({event:'done', parent:opts.parent, created:created.length, ids: created.map(x=>x.id)}));
diff --git a/scripts/generator_tick.js b/scripts/generator_tick.js
index 73b5684..5b37c30 100644
--- a/scripts/generator_tick.js
+++ b/scripts/generator_tick.js
@@ -160,6 +160,21 @@ function main() {
     } catch (e) {
       console.log(JSON.stringify({ event: 'rate_spawn_err', err: e.message }));
     }
+
+    // ── Auto-fan-out 5 color variations of the new design ────────────────
+    // Up to 5 hue/sat/val shifts (hue +60, +120, +180, -60, mono). Each gets
+    // watermarked + inserted into spoon_all_designs + queued for AI rating.
+    try {
+      const { spawn } = require('child_process');
+      const varN = parseInt(process.env.GENERATOR_VARIATIONS || '5', 10);
+      const vars = spawn('node',
+        [path.join(__dirname, 'auto_variations.js'),
+         '--parent', String(designId), '--n', String(varN)],
+        { detached: true, stdio: 'ignore', env: process.env });
+      vars.unref();
+    } catch (e) {
+      console.log(JSON.stringify({ event: 'var_spawn_err', err: e.message }));
+    }
   }
 
   console.log(JSON.stringify({
diff --git a/scripts/learn_from_votes.js b/scripts/learn_from_votes.js
new file mode 100644
index 0000000..5e5cbd4
--- /dev/null
+++ b/scripts/learn_from_votes.js
@@ -0,0 +1,83 @@
+#!/usr/bin/env node
+/**
+ * Learning loop — reads top-ranked designs (combined AI + user + Elo),
+ * extracts the common attributes (categories, artists, palettes), and
+ * writes/updates a high-weight recipe in wallco_generator_recipe.
+ *
+ * Effect: the 3-min cron picks the high-weight learned recipe more often,
+ * so the generator drifts toward what's actually getting votes.
+ *
+ * Run via launchd or `node scripts/learn_from_votes.js` on demand.
+ */
+'use strict';
+const { execSync } = require('child_process');
+const path = require('path');
+
+const DB = 'dw_unified';
+const PSQL_CMD = (process.platform === 'linux')
+  ? `sudo -n -u postgres psql ${DB} -At -q`
+  : `psql ${DB} -At -q`;
+function psql(sql) { return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim(); }
+function arrLit(a) { return `ARRAY[${(a||[]).map(v => "'" + String(v).replace(/'/g,"''") + "'").join(',') || ''}]::text[]`; }
+
+const MIN_VOTE_THRESHOLD = 3;  // need ≥3 user votes or ≥1 AI rating to qualify
+const TOP_N = 20;
+
+const top = JSON.parse(psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+  SELECT id, category, dominant_hex, prompt, combined_score,
+         user_score_avg, user_vote_count, poll_wins, poll_losses, elo,
+         (COALESCE(combined_score,3) * 0.4
+          + COALESCE(user_score_avg,3) * 0.4
+          + (elo - 1200) / 200.0) AS rank_score
+  FROM spoon_all_designs
+  WHERE local_path IS NOT NULL
+    AND (user_vote_count >= ${MIN_VOTE_THRESHOLD} OR combined_score IS NOT NULL)
+  ORDER BY rank_score DESC NULLS LAST
+  LIMIT ${TOP_N}) t;`));
+
+if (!top.length) {
+  console.log(JSON.stringify({event:'skip', reason:'no rated designs yet'}));
+  process.exit(0);
+}
+
+// Aggregate: top categories, top hex tones, recurring prompt n-grams
+const catCount = {};
+const hexCount = {};
+const wordCount = {};
+const STOP = new Set('a,an,the,with,and,or,of,in,on,no,for,is,are,this,that,by,as,to'.split(','));
+
+for (const d of top) {
+  catCount[d.category || 'mixed'] = (catCount[d.category || 'mixed'] || 0) + 1;
+  if (d.dominant_hex) hexCount[d.dominant_hex] = (hexCount[d.dominant_hex] || 0) + 1;
+  (d.prompt || '').toLowerCase().split(/[\s,.;:]+/).forEach(w => {
+    if (w.length < 3 || STOP.has(w)) return;
+    wordCount[w] = (wordCount[w] || 0) + 1;
+  });
+}
+
+const topCats   = Object.entries(catCount).sort((a,b)=>b[1]-a[1]).slice(0,3).map(x=>x[0]);
+const topHexes  = Object.entries(hexCount).sort((a,b)=>b[1]-a[1]).slice(0,6).map(x=>x[0]);
+const topWords  = Object.entries(wordCount).sort((a,b)=>b[1]-a[1]).slice(0,12).map(x=>x[0]);
+const topArtists = topWords.filter(w => /^[a-z]+$/i.test(w) && w.length > 4).slice(0, 5);
+
+const recipeName = 'Learned · top-rated mix';
+const promptTemplate = `A seamless wallpaper pattern echoing the top-rated direction this week, with {style} influence, featuring ${topWords.slice(0,5).join(', ')}, palette inspired by ${topHexes.slice(0,4).join(', ')}, high detail, museum-quality, 24x24 inch tileable, no text, no signature.`;
+
+// Upsert the learned recipe with HIGH weight so the picker favors it
+const existing = psql(`SELECT id FROM wallco_generator_recipe WHERE name = '${recipeName.replace(/'/g,"''")}';`);
+if (existing) {
+  psql(`UPDATE wallco_generator_recipe SET
+    source_artists=${arrLit(topArtists)},
+    style_tags=${arrLit(topCats)},
+    color_hex_seeds=${arrLit(topHexes)},
+    prompt_template='${promptTemplate.replace(/'/g,"''")}',
+    weight=3, enabled=TRUE
+    WHERE id=${parseInt(existing,10)};`);
+  console.log(JSON.stringify({event:'updated', recipe_id: parseInt(existing,10),
+    top_cats: topCats, top_hexes: topHexes, top_words: topWords.slice(0,8)}));
+} else {
+  const id = psql(`INSERT INTO wallco_generator_recipe (name, source_artists, style_tags, color_hex_seeds, prompt_template, weight, enabled)
+    VALUES ('${recipeName.replace(/'/g,"''")}', ${arrLit(topArtists)}, ${arrLit(topCats)}, ${arrLit(topHexes)}, '${promptTemplate.replace(/'/g,"''")}', 3, TRUE) RETURNING id;`);
+  console.log(JSON.stringify({event:'created', recipe_id: parseInt(id,10),
+    top_cats: topCats, top_hexes: topHexes, top_words: topWords.slice(0,8)}));
+}
diff --git a/server.js b/server.js
index 1e46554..0466d8d 100644
--- a/server.js
+++ b/server.js
@@ -2071,6 +2071,48 @@ ${htmlHeader('/designs')}
         Pattern No.${String(design.id).padStart(4,'0')} &middot; ${design.category.charAt(0).toUpperCase()+design.category.slice(1)} &middot; ${design.kind === 'seamless_tile' ? 'Seamless Tile' : design.kind}
       </div>
 
+      <!-- USER VOTE — 5-star widget, anonymous session-cookie tracking -->
+      <div id="user-vote-card" style="margin:14px 0;padding:12px 16px;background:#faf6ec;border:1px solid #e7dcc3;border-radius:8px;font-size:13px">
+        <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
+          <div>
+            <strong style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:15px;color:#1a1816">Your rating</strong>
+            <span id="vote-avg" style="margin-left:8px;color:#666;font-size:12px"></span>
+          </div>
+          <div id="star-row" data-id="${design.id}" style="font-size:24px;letter-spacing:4px;cursor:pointer;color:#bba">
+            <span data-s="1">☆</span><span data-s="2">☆</span><span data-s="3">☆</span><span data-s="4">☆</span><span data-s="5">☆</span>
+          </div>
+        </div>
+        <div style="margin-top:6px;font-size:11px;color:#888">Each vote teaches the system. We use your taste signals to bias what we generate next.</div>
+      </div>
+      <script>
+      (async function(){
+        var row = document.getElementById('star-row');
+        var avgEl = document.getElementById('vote-avg');
+        var id = row.getAttribute('data-id');
+        function paint(s){
+          Array.from(row.children).forEach(function(el,i){ el.textContent = (i < s) ? '★' : '☆'; });
+          row.style.color = s ? '#d2b15c' : '#bba';
+        }
+        async function refresh(){
+          var v = await (await fetch('/api/design/' + id + '/votes')).json();
+          if (v.my_score) paint(v.my_score);
+          if (v.count) avgEl.textContent = '· average ★' + (v.avg || '?') + ' from ' + v.count + ' vote' + (v.count===1?'':'s');
+        }
+        Array.from(row.children).forEach(function(el){
+          el.addEventListener('mouseenter', function(){ paint(parseInt(el.getAttribute('data-s'),10)); });
+          el.addEventListener('click', async function(){
+            var s = parseInt(el.getAttribute('data-s'),10);
+            paint(s);
+            var r = await fetch('/api/design/' + id + '/user-vote', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({score: s})});
+            var j = await r.json();
+            if (j.user_score_avg) avgEl.textContent = '· average ★' + j.user_score_avg + ' from ' + j.user_vote_count + ' vote' + (j.user_vote_count===1?'':'s');
+          });
+        });
+        row.addEventListener('mouseleave', refresh);
+        refresh();
+      })();
+      </script>
+
       <!-- AI Suggested Rating (graphic designer + interior designer) -->
       <div id="ai-rating-card" style="display:none;margin:14px 0;padding:14px 16px;background:#1a1816;color:#e8e2d6;border-radius:8px;border:1px solid #2a2a2a;font-size:13px">
         <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">

← 925dc4e Add Ollama-free integration tests (admin-gate, csv-export, a  ·  back to Wallco Ai  ·  gamify: user votes + head-to-head polls + Elo + 5-variation ba0b062 →