[object Object]

← back to Whatsmystyle

yolo tick 13: couples + embedding_drifts schema; closet.privacy col; /admin-config UI

477ba99210edeb3d10cd0c2344e70a4150728374 · 2026-05-12 01:34:28 -0700 · SteveStudio2

Files touched

Diff

commit 477ba99210edeb3d10cd0c2344e70a4150728374
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 01:34:28 2026 -0700

    yolo tick 13: couples + embedding_drifts schema; closet.privacy col; /admin-config UI
---
 public/admin-config.html | 105 +++++++++++++++++++++++++++++++++++++++++++++++
 server.js                |  43 ++++++++++++++++++-
 2 files changed, 146 insertions(+), 2 deletions(-)

diff --git a/public/admin-config.html b/public/admin-config.html
new file mode 100644
index 0000000..97e7e21
--- /dev/null
+++ b/public/admin-config.html
@@ -0,0 +1,105 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <meta name="robots" content="noindex,nofollow" />
+  <title>WhatsMyStyle — Admin Config</title>
+  <link rel="stylesheet" href="/css/app.css" />
+  <style>
+    body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #faf7f2; color: #1d1d1f; max-width: 720px; margin: 40px auto; padding: 0 24px; }
+    h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
+    .sub { color: #707070; margin-bottom: 32px; }
+    .pill-row { background: #fff; border: 1px solid #e6e1d8; border-radius: 24px; padding: 20px 24px; margin-bottom: 14px; display: flex; align-items: center; gap: 16px; }
+    .pill-row label { flex: 1; }
+    .pill-row .name { font-weight: 600; font-size: 16px; display: block; margin-bottom: 4px; }
+    .pill-row .hint { font-size: 13px; color: #707070; }
+    .pill-row input[type=number], .pill-row input[type=text] { font: inherit; padding: 10px 16px; border: 1px solid #d6d0c4; border-radius: 14px; width: 140px; text-align: right; background: #faf7f2; }
+    .pill-row input[type=checkbox] { width: 22px; height: 22px; }
+    button.save { font: inherit; font-weight: 600; background: #1d1d1f; color: #fff; border: 0; padding: 14px 28px; border-radius: 999px; cursor: pointer; margin-top: 8px; }
+    button.save:hover { background: #3a3a3c; }
+    .status { padding: 12px 16px; border-radius: 12px; margin-top: 16px; font-size: 14px; }
+    .status.ok { background: #e6f6ec; color: #14572a; }
+    .status.err { background: #fbe4e4; color: #7a1717; }
+    .nav { margin-bottom: 24px; }
+    .nav a { color: #707070; text-decoration: none; font-size: 14px; }
+    .nav a:hover { color: #1d1d1f; }
+  </style>
+</head>
+<body>
+  <div class="nav"><a href="/">← back to app</a></div>
+  <h1>Admin config</h1>
+  <p class="sub">Knobs the duel engine + budget guard read on every request. Saved values persist in <code>app_config</code>.</p>
+
+  <form id="cfg">
+    <div class="pill-row">
+      <label>
+        <span class="name">Cold-start cutoff</span>
+        <span class="hint">How many duels before we drop budget anchoring and go pure-random. Default 3.</span>
+      </label>
+      <input type="number" min="0" max="50" step="1" name="cold_start_cutoff" id="cold_start_cutoff" />
+    </div>
+
+    <div class="pill-row">
+      <label>
+        <span class="name">Outlier injection rate</span>
+        <span class="hint">Probability that during cold-start we swap one item for a random outlier. 0–1. Default 0.2.</span>
+      </label>
+      <input type="number" min="0" max="1" step="0.05" name="outlier_injection_rate" id="outlier_injection_rate" />
+    </div>
+
+    <div class="pill-row">
+      <label>
+        <span class="name">Per-user render budget (cents)</span>
+        <span class="hint">Hard cap on paid try-on calls per user. 500 = $5. Default 500.</span>
+      </label>
+      <input type="number" min="0" step="50" name="budget_cents_per_user" id="budget_cents_per_user" />
+    </div>
+
+    <div class="pill-row">
+      <label>
+        <span class="name">Couples pilot</span>
+        <span class="hint">Feature flag for the shared-closet pilot. DB scaffold ready; UI ships in a later tick. Default off.</span>
+      </label>
+      <input type="checkbox" name="couples_pilot" id="couples_pilot" />
+    </div>
+
+    <button class="save" type="submit">Save</button>
+    <div id="status"></div>
+  </form>
+
+  <script>
+    const $ = (id) => document.getElementById(id);
+    const isAdminQ = location.search.includes('admin=1') ? '?admin=1' : '';
+
+    async function load() {
+      const r = await fetch('/api/admin/config' + isAdminQ);
+      if (!r.ok) { $('status').className = 'status err'; $('status').textContent = 'admin gate failed'; return; }
+      const { config, defaults } = await r.json();
+      $('cold_start_cutoff').value      = config.cold_start_cutoff      ?? defaults.cold_start_cutoff;
+      $('outlier_injection_rate').value = config.outlier_injection_rate ?? defaults.outlier_injection_rate;
+      $('budget_cents_per_user').value  = config.budget_cents_per_user  ?? defaults.budget_cents_per_user;
+      $('couples_pilot').checked        = config.couples_pilot          ?? defaults.couples_pilot;
+    }
+
+    document.getElementById('cfg').addEventListener('submit', async (e) => {
+      e.preventDefault();
+      const body = {
+        cold_start_cutoff:      parseInt($('cold_start_cutoff').value, 10),
+        outlier_injection_rate: parseFloat($('outlier_injection_rate').value),
+        budget_cents_per_user:  parseInt($('budget_cents_per_user').value, 10),
+        couples_pilot:          $('couples_pilot').checked,
+      };
+      const r = await fetch('/api/admin/config' + isAdminQ, {
+        method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
+      });
+      const j = await r.json();
+      const s = $('status');
+      if (r.ok) { s.className = 'status ok'; s.textContent = 'Saved: ' + (j.written || []).join(', '); }
+      else      { s.className = 'status err'; s.textContent = j.error || 'save failed'; }
+    });
+
+    load();
+  </script>
+</body>
+</html>
diff --git a/server.js b/server.js
index b123946..f651e25 100644
--- a/server.js
+++ b/server.js
@@ -206,8 +206,40 @@ CREATE TABLE IF NOT EXISTS debates (
   panel TEXT,                     -- JSON array of {role, model, output}
   created_at TEXT DEFAULT (datetime('now'))
 );
+
+-- Tick 13: couples / shared-closets scaffold per debate id 8 verdict (HOLD - pilot).
+-- DB-only this tick; gated behind app_config.couples_pilot (default false).
+CREATE TABLE IF NOT EXISTS couples (
+  id INTEGER PRIMARY KEY,
+  partner_a_user_id INTEGER NOT NULL,
+  partner_b_user_id INTEGER NOT NULL,
+  status TEXT NOT NULL DEFAULT 'pending',  -- pending | active | exited
+  opt_in_at TEXT,                          -- both partners confirmed
+  exit_at TEXT,                            -- breakup flow; on set, cross-twin training is purged
+  created_at TEXT DEFAULT (datetime('now')),
+  UNIQUE(partner_a_user_id, partner_b_user_id)
+);
+CREATE INDEX IF NOT EXISTS couples_a_idx ON couples(partner_a_user_id);
+CREATE INDEX IF NOT EXISTS couples_b_idx ON couples(partner_b_user_id);
+
+-- Tick 13: catalog re-embed drift ledger. One row per (item, run) where
+-- new vs stored similarity < 0.80. checked_at is second-precision so the
+-- UNIQUE constraint blocks dupes within the same drift sweep second.
+CREATE TABLE IF NOT EXISTS embedding_drifts (
+  id INTEGER PRIMARY KEY,
+  item_id INTEGER NOT NULL,
+  old_vec TEXT NOT NULL,
+  new_vec TEXT NOT NULL,
+  similarity REAL NOT NULL,
+  checked_at TEXT NOT NULL DEFAULT (datetime('now')),
+  UNIQUE(item_id, checked_at)
+);
+CREATE INDEX IF NOT EXISTS embedding_drifts_item_idx ON embedding_drifts(item_id, id);
 `);
 
+// Tick 13: closet.privacy column (default 'shared'). Idempotent.
+safeAddColumn('closet', "privacy TEXT DEFAULT 'shared'");
+
 // ---------- app ------------------------------------------------------------
 const app = express();
 app.use(express.json({ limit: '5mb' }));
@@ -487,13 +519,20 @@ app.get('/api/admin/config', (req, res) => {
   rows.forEach(r => { try { cfg[r.key] = JSON.parse(r.value); } catch {} });
   res.json({
     config: cfg,
-    defaults: { cold_start_cutoff: 3, outlier_injection_rate: 0.2 },
+    defaults: { cold_start_cutoff: 3, outlier_injection_rate: 0.2, budget_cents_per_user: 500, couples_pilot: false },
   });
 });
+
+// ---- admin / config UI (tick 13) ------------------------------------------
+// Plain large-pill form. Same gate as /api/admin/config.
+app.get('/admin-config', (req, res) => {
+  if (!adminGate(req)) return res.status(403).send('admin only');
+  res.sendFile(path.join(__dirname, 'public', 'admin-config.html'));
+});
 app.put('/api/admin/config', express.json({ limit: '8kb' }), (req, res) => {
   if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
   const updates = req.body || {};
-  const ALLOWED = ['cold_start_cutoff', 'outlier_injection_rate', 'budget_cents_per_user'];
+  const ALLOWED = ['cold_start_cutoff', 'outlier_injection_rate', 'budget_cents_per_user', 'couples_pilot'];
   const stmt = db.prepare(`INSERT INTO app_config (key, value) VALUES (?, ?)
     ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`);
   const written = [];

← 543e924 yolo tick 12: couples-closets debate verdict (HOLD - pilot w  ·  back to Whatsmystyle  ·  yolo tick 13: catalog re-embed drift checker (cosine vs stor 30fe227 →