[object Object]

← back to Whatsmystyle

yolo tick 14: couples invite/accept/exit API (flag-gated) + 24h drift cron + receipt-parser smoke (15/15)

ca023c63d272228d82c449e2c14abdb49681c9cd · 2026-05-12 06:56:26 -0700 · SteveStudio2

Files touched

Diff

commit ca023c63d272228d82c449e2c14abdb49681c9cd
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 06:56:26 2026 -0700

    yolo tick 14: couples invite/accept/exit API (flag-gated) + 24h drift cron + receipt-parser smoke (15/15)
---
 scripts/embed-drift-check.js |  24 ++++----
 scripts/test-receipts.js     | 130 +++++++++++++++++++++++++++++++++++++++++++
 server.js                    |  98 ++++++++++++++++++++++++++++++++
 3 files changed, 242 insertions(+), 10 deletions(-)

diff --git a/scripts/embed-drift-check.js b/scripts/embed-drift-check.js
index 5d881ae..8fce955 100644
--- a/scripts/embed-drift-check.js
+++ b/scripts/embed-drift-check.js
@@ -55,21 +55,20 @@ async function ollamaLlavaUp() {
   } catch { return false; }
 }
 
-async function main() {
-  const db = new Database(path.join(__dirname, '..', 'data', 'whatsmystyle.db'));
+async function runDriftSweep({ db, maxItems = MAX_ITEMS, threshold = THRESHOLD, offset = 0 } = {}) {
   if (!(await ollamaLlavaUp())) {
-    console.log(JSON.stringify({ ok: false, reason: 'ollama+llava not available locally; skipped' }));
-    return;
+    return { ok: false, reason: 'ollama+llava not available locally; skipped' };
   }
 
   // Only items that already have a non-null embedding worth comparing against.
+  // OFFSET so the cron can roll through the catalog instead of re-scanning the same head.
   const rows = db.prepare(
     `SELECT id, title, brand, category, color, pattern, material, image_url, embedding
        FROM items
       WHERE embedding IS NOT NULL AND embedding != ''
       ORDER BY id
-      LIMIT ?`
-  ).all(MAX_ITEMS);
+      LIMIT ? OFFSET ?`
+  ).all(maxItems, offset);
 
   const insert = db.prepare(
     `INSERT OR IGNORE INTO embedding_drifts (item_id, old_vec, new_vec, similarity, checked_at)
@@ -96,23 +95,28 @@ async function main() {
     }
 
     const sim = cosine(oldVec, newVec);
-    const drifted = sim < THRESHOLD;
+    const drifted = sim < threshold;
     if (drifted) {
       insert.run(row.id, JSON.stringify(oldVec), JSON.stringify(newVec), sim);
     }
     results.push({ id: row.id, sim: Number(sim.toFixed(4)), drifted });
   }
 
-  const summary = {
+  return {
     ok: true,
-    threshold: THRESHOLD,
+    threshold,
     scanned: results.length,
     drifted: results.filter(r => r.drifted).length,
     results,
   };
+}
+
+async function main() {
+  const db = new Database(path.join(__dirname, '..', 'data', 'whatsmystyle.db'));
+  const summary = await runDriftSweep({ db });
   console.log(JSON.stringify(summary, null, 2));
 }
 
 if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
 
-module.exports = { cosine };
+module.exports = { cosine, runDriftSweep, ollamaLlavaUp };
diff --git a/scripts/test-receipts.js b/scripts/test-receipts.js
new file mode 100644
index 0000000..873adfd
--- /dev/null
+++ b/scripts/test-receipts.js
@@ -0,0 +1,130 @@
+/**
+ * Receipt-parser smoke test (tick 14).
+ *
+ * Sanity-checks that scripts/parse-receipts.js routes each fixture to the
+ * right adapter and extracts brand + title + price. No real Gmail calls,
+ * no DB writes — pure function-call asserts against inline fixtures.
+ *
+ * Run:
+ *   node scripts/test-receipts.js
+ *
+ * Exit code is 0 on all-pass, 1 on any failure.
+ */
+const { parseEmail, pickAdapter } = require('./parse-receipts');
+
+const FIXTURES = [
+  {
+    name: 'nordstrom',
+    email: {
+      from: 'orders@emails.nordstrom.com',
+      subject: 'Your order has shipped',
+      body: [
+        'Hi Steve,',
+        'Your order has shipped. Items:',
+        '',
+        'Vince',
+        'Crew Neck Cashmere Sweater',
+        '$295.00',
+        'Size: M',
+        '',
+        'AGOLDE',
+        'Riley High Rise Straight Jean',
+        '$198.00',
+        'Size: 30',
+      ].join('\n'),
+    },
+    expect: {
+      adapter: 'nordstrom',
+      minItems: 2,
+      brands: ['Vince', 'AGOLDE'],
+      titles: ['Crew Neck Cashmere Sweater', 'Riley High Rise Straight Jean'],
+      priceCents: [29500, 19800],
+    },
+  },
+  {
+    name: 'netaporter',
+    email: {
+      from: 'orders@emails.net-a-porter.com',
+      subject: 'Your NET-A-PORTER order has been dispatched',
+      body: [
+        'Dear Customer,',
+        '',
+        'The Row ',
+        'Crewneck Cashmere Sweater ',
+        'USD 1,290.00',
+        '',
+        'Khaite ',
+        'Stretch Leather Bag ',
+        'USD 2,100.00',
+      ].join('\n'),
+    },
+    expect: {
+      adapter: 'netaporter',
+      minItems: 2,
+      brands: ['The Row', 'Khaite'],
+      titles: ['Crewneck Cashmere Sweater', 'Stretch Leather Bag'],
+      priceCents: [129000, 210000],
+    },
+  },
+  {
+    name: 'poshmark',
+    email: {
+      from: 'no-reply@poshmark.com',
+      subject: 'You bought it! Order confirmation',
+      body: [
+        'You bought it!',
+        '',
+        'Reformation',
+        'Juliette Linen Midi Dress',
+        '$118.00',
+        '',
+        'Acne Studios',
+        'Wool Crew Sweater Black',
+        '$240.00',
+      ].join('\n'),
+    },
+    expect: {
+      adapter: 'poshmark',
+      minItems: 2,
+      brands: ['Reformation', 'Acne Studios'],
+      titles: ['Juliette Linen Midi Dress', 'Wool Crew Sweater Black'],
+      priceCents: [11800, 24000],
+    },
+  },
+];
+
+let pass = 0, fail = 0;
+const failures = [];
+
+function check(name, condition, detail) {
+  if (condition) { pass++; }
+  else { fail++; failures.push(`[${name}] ${detail}`); }
+}
+
+for (const fx of FIXTURES) {
+  const picked = pickAdapter({ from: fx.email.from, subject: fx.email.subject });
+  check(fx.name, picked.name === fx.expect.adapter, `expected adapter=${fx.expect.adapter}, got ${picked.name}`);
+
+  const parsed = parseEmail(fx.email);
+  check(fx.name, parsed.adapter === fx.expect.adapter, `parsed.adapter mismatch: ${parsed.adapter}`);
+  check(fx.name, parsed.items.length >= fx.expect.minItems, `expected ≥${fx.expect.minItems} items, got ${parsed.items.length}`);
+
+  // Assert each expected brand/title/price appears in the parsed items.
+  for (let i = 0; i < fx.expect.brands.length; i++) {
+    const want = { brand: fx.expect.brands[i], title: fx.expect.titles[i], price_cents: fx.expect.priceCents[i] };
+    const hit = parsed.items.find(it =>
+      it.brand === want.brand &&
+      it.title === want.title &&
+      it.price_cents === want.price_cents);
+    check(fx.name, !!hit, `missing item: ${JSON.stringify(want)} — got ${JSON.stringify(parsed.items)}`);
+  }
+}
+
+const out = {
+  ok: fail === 0,
+  pass, fail,
+  failures: failures.slice(0, 20),
+  fixtures: FIXTURES.map(f => f.name),
+};
+console.log(JSON.stringify(out, null, 2));
+process.exit(fail === 0 ? 0 : 1);
diff --git a/server.js b/server.js
index f651e25..43b177a 100644
--- a/server.js
+++ b/server.js
@@ -560,6 +560,78 @@ app.post('/api/admin/debate', async (req, res) => {
   }
 });
 
+// ---- couples pilot (tick 14) ----------------------------------------------
+// Per the 2026-05-12 dream-team verdict (debate id 8), couples support stays
+// behind app_config.couples_pilot until the consent + breakup-data-reset
+// flows are vetted in pilot. While the flag is off, every endpoint returns
+// 503 {enabled:false} so the schema rows can't be written by accident.
+function couplesEnabled() { return configValue('couples_pilot', false) === true; }
+
+const crypto = require('crypto');
+function newInviteToken() { return crypto.randomBytes(16).toString('hex'); }
+
+app.post('/api/couple/invite', (req, res) => {
+  if (!couplesEnabled()) return res.status(503).json({ enabled: false, reason: 'couples_pilot flag off' });
+  const u = currentUser(req);
+  // Reuse an outstanding pending invite if partner_a already issued one.
+  const existing = db.prepare(
+    `SELECT id, partner_b_user_id FROM couples WHERE partner_a_user_id=? AND status='pending' ORDER BY id DESC LIMIT 1`
+  ).get(u.id);
+  if (existing) {
+    return res.json({ ok: true, reused: true, couple_id: existing.id, invite_token: existing.partner_b_user_id < 0 ? String(-existing.partner_b_user_id) : null });
+  }
+  // partner_b_user_id is unknown at invite time. We stash the negative of a
+  // token-fingerprint as the placeholder, swap to the real user_id on accept.
+  const token = newInviteToken();
+  const placeholder = -Number('0x' + token.slice(0, 8));  // negative numeric placeholder
+  const { lastInsertRowid } = db.prepare(
+    `INSERT INTO couples (partner_a_user_id, partner_b_user_id, status) VALUES (?, ?, 'pending')`
+  ).run(u.id, placeholder);
+  // Persist the real token in a side row keyed off the couple id so /accept can look it up.
+  db.prepare(
+    `INSERT INTO app_config (key, value) VALUES (?, ?)
+     ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`
+  ).run(`couple_invite_${lastInsertRowid}`, JSON.stringify(token));
+  res.json({ ok: true, couple_id: lastInsertRowid, invite_token: token });
+});
+
+app.post('/api/couple/accept/:token', (req, res) => {
+  if (!couplesEnabled()) return res.status(503).json({ enabled: false, reason: 'couples_pilot flag off' });
+  const u = currentUser(req);
+  const token = req.params.token;
+  // Walk pending invites to find the matching token (small N — fine).
+  const pending = db.prepare(`SELECT id, partner_a_user_id FROM couples WHERE status='pending'`).all();
+  let coupleId = null;
+  for (const row of pending) {
+    const cfg = db.prepare('SELECT value FROM app_config WHERE key=?').get(`couple_invite_${row.id}`);
+    if (cfg && JSON.parse(cfg.value) === token) {
+      if (row.partner_a_user_id === u.id) return res.status(400).json({ error: 'cannot accept your own invite' });
+      coupleId = row.id;
+      break;
+    }
+  }
+  if (!coupleId) return res.status(404).json({ error: 'invite not found or already accepted' });
+  db.prepare(`UPDATE couples SET partner_b_user_id=?, status='active', opt_in_at=datetime('now') WHERE id=?`).run(u.id, coupleId);
+  // Burn the token.
+  db.prepare(`DELETE FROM app_config WHERE key=?`).run(`couple_invite_${coupleId}`);
+  res.json({ ok: true, couple_id: coupleId, status: 'active' });
+});
+
+app.post('/api/couple/exit', (req, res) => {
+  if (!couplesEnabled()) return res.status(503).json({ enabled: false, reason: 'couples_pilot flag off' });
+  const u = currentUser(req);
+  const row = db.prepare(
+    `SELECT id FROM couples WHERE (partner_a_user_id=? OR partner_b_user_id=?) AND status='active' ORDER BY id DESC LIMIT 1`
+  ).get(u.id, u.id);
+  if (!row) return res.status(404).json({ error: 'no active couple' });
+  // Purge cross-twin training: any tryon_jobs that anchor on partner-B's avatar
+  // or closet item from partner-A's session (gift mode) get hard-deleted.
+  // For the scaffold we mark the couple exited; deeper purge wires up when
+  // gifts-mode ships.
+  db.prepare(`UPDATE couples SET status='exited', exit_at=datetime('now') WHERE id=?`).run(row.id);
+  res.json({ ok: true, couple_id: row.id, status: 'exited' });
+});
+
 // ---- duel-on-me pre-render --------------------------------------------
 // When the avatar is ready, we pre-render the duel items onto the user's
 // twin in the background so the next /api/duel call can serve "on me" URLs.
@@ -1204,6 +1276,32 @@ function drainAvatarBuild() {
 }
 setInterval(drainAvatarBuild, 30 * 1000);
 
+// Embedding drift sweep (tick 14): every 24h, scan a rolling window of 6
+// catalog items and write a row to embedding_drifts for any whose
+// llava-projected vector deviates from the stored one by > 20% cosine.
+// Rolls through the catalog via OFFSET stored in app_config.drift_offset.
+// Skips silently when Ollama llava isn't reachable.
+async function embedDriftSchedule() {
+  try {
+    const { runDriftSweep, ollamaLlavaUp } = require('./scripts/embed-drift-check');
+    if (!(await ollamaLlavaUp())) return;
+    const offset = configValue('drift_offset', 0);
+    const maxItems = 6;
+    const total = db.prepare("SELECT COUNT(*) c FROM items WHERE embedding IS NOT NULL AND embedding != ''").get().c || 0;
+    const nextOffset = total > 0 ? (offset + maxItems) % total : 0;
+    const summary = await runDriftSweep({ db, maxItems, offset });
+    db.prepare(`INSERT INTO app_config (key, value) VALUES ('drift_offset', ?)
+                ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`)
+      .run(JSON.stringify(nextOffset));
+    if (summary.drifted > 0) console.log(`[drift-sweep] ${summary.drifted}/${summary.scanned} drifted at offset ${offset}`);
+  } catch (e) {
+    console.error('[drift-sweep] error', e.message);
+  }
+}
+// First run 5 min after boot so we don't slow cold-start; subsequent runs every 24h.
+setTimeout(embedDriftSchedule, 5 * 60 * 1000);
+setInterval(embedDriftSchedule, 24 * 60 * 60 * 1000);
+
 // ---- start ----------------------------------------------------------------
 app.listen(PORT, '0.0.0.0', () => {
   console.log(`[whatsmystyle] listening on :${PORT}`);

← 30fe227 yolo tick 13: catalog re-embed drift checker (cosine vs stor  ·  back to Whatsmystyle  ·  ux(credits): shotonwhat-style item résumé — every try-on/out 4633077 →