← 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
M scripts/embed-drift-check.jsA scripts/test-receipts.jsM server.js
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 →