[object Object]

← back to Whatsmystyle

yolo tick 22: SPA selector calibrator (BB fetch + cheerio probe + paste-ready output) + paid-API budget pill on embed-chip (color-coded vs budget_cents_per_user) + /admin/drifts viewer (recent events + most-drifted items)

c0bf68a032168f06e5c39a162690d5f9a33b524b · 2026-05-12 12:03:52 -0700 · SteveStudio2

Files touched

Diff

commit c0bf68a032168f06e5c39a162690d5f9a33b524b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 12:03:52 2026 -0700

    yolo tick 22: SPA selector calibrator (BB fetch + cheerio probe + paste-ready output) + paid-API budget pill on embed-chip (color-coded vs budget_cents_per_user) + /admin/drifts viewer (recent events + most-drifted items)
---
 public/admin-drifts.html               |  98 +++++++++++++++++++++++++++
 public/css/app.css                     |  12 ++++
 public/index.html                      |   4 ++
 public/js/app.js                       |  13 ++++
 scripts/clothing-apis/calibrate-spa.js | 120 +++++++++++++++++++++++++++++++++
 server.js                              |  43 ++++++++++++
 6 files changed, 290 insertions(+)

diff --git a/public/admin-drifts.html b/public/admin-drifts.html
new file mode 100644
index 0000000..3636488
--- /dev/null
+++ b/public/admin-drifts.html
@@ -0,0 +1,98 @@
+<!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 — Embedding-drift ledger</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: 1100px; 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; font-size: 14px; }
+    .card { background: #fff; border: 1px solid #e6e1d8; border-radius: 16px; padding: 18px 22px; margin: 14px 0; }
+    table { width: 100%; border-collapse: collapse; font-size: 13px; }
+    th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #f0eadf; }
+    th { font-weight: 600; color: #707070; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
+    .nav { margin-bottom: 24px; }
+    .nav a { color: #707070; text-decoration: none; font-size: 14px; }
+    .nav a:hover { color: #1d1d1f; }
+    .thumb { width: 36px; height: 44px; object-fit: cover; border-radius: 6px; border: 1px solid #e6e1d8; }
+    .sim { font-variant-numeric: tabular-nums; }
+    .sim-low { color: #b87a13; font-weight: 600; }
+    .sim-very-low { color: #b91c1c; font-weight: 700; }
+    .muted { color: #707070; }
+    .pill { display: inline-block; background: #faf7f2; border: 1px solid #e6e1d8; padding: 3px 10px; border-radius: 999px; font-size: 11px; color: #555; }
+    .empty { text-align: center; padding: 60px 20px; color: #707070; font-style: italic; }
+    h2 { font-size: 18px; font-weight: 600; margin: 0 0 14px; letter-spacing: -0.01em; }
+  </style>
+</head>
+<body>
+  <div class="nav"><a href="/admin-config">← admin config</a> · <a href="/">app home</a></div>
+  <h1>Embedding-drift ledger</h1>
+  <p class="sub">Every catalog item whose llava-projected vector deviated &gt;20% cosine from its stored value. Populated by the 24h drift cron + manual drift sweeps.</p>
+
+  <div class="card">
+    <h2>Summary</h2>
+    <p><strong id="total">0</strong> drift events recorded · most-drifted item: <span id="top-item" class="muted">none yet</span></p>
+  </div>
+
+  <div class="card">
+    <h2>Most-drifted items (top 50 by drift count)</h2>
+    <table id="per-item-tbl">
+      <thead><tr><th></th><th>Item</th><th>Drift count</th><th>Min similarity</th><th>Last seen</th></tr></thead>
+      <tbody></tbody>
+    </table>
+  </div>
+
+  <div class="card">
+    <h2>Recent events (last 500)</h2>
+    <table id="recent-tbl">
+      <thead><tr><th></th><th>Item</th><th>Similarity</th><th>Checked at</th></tr></thead>
+      <tbody></tbody>
+    </table>
+  </div>
+
+  <script>
+    const $ = (s) => document.querySelector(s);
+    function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
+    function simClass(s) {
+      if (s == null) return '';
+      if (s < 0.5) return 'sim-very-low';
+      if (s < 0.7) return 'sim-low';
+      return '';
+    }
+    async function load() {
+      const r = await fetch('/api/admin/drifts');
+      if (!r.ok) { document.body.innerHTML = '<p class="empty">admin gate failed</p>'; return; }
+      const j = await r.json();
+      $('#total').textContent = j.total;
+      if (j.total === 0) {
+        $('#per-item-tbl tbody').innerHTML = '<tr><td colspan="5" class="empty">No drift events yet — the 24h cron writes rows when similarity drops below 0.80.</td></tr>';
+        $('#recent-tbl tbody').innerHTML = '';
+        return;
+      }
+      $('#top-item').innerHTML = j.per_item.length ? `item #${j.per_item[0].item_id} (${j.per_item[0].c} drifts)` : 'none';
+      $('#per-item-tbl tbody').innerHTML = j.per_item.map(r => `
+        <tr>
+          <td></td>
+          <td><strong>#${r.item_id}</strong></td>
+          <td>${r.c}</td>
+          <td class="sim ${simClass(r.min_sim)}">${(r.min_sim ?? 0).toFixed(3)}</td>
+          <td class="muted">${escapeHtml(r.last_seen)}</td>
+        </tr>
+      `).join('');
+      $('#recent-tbl tbody').innerHTML = j.recent.map(r => `
+        <tr>
+          <td>${r.image_url ? `<img class="thumb" src="${escapeHtml(r.image_url)}" loading="lazy" alt="">` : ''}</td>
+          <td>${r.product_url ? `<a href="${escapeHtml(r.product_url)}" target="_blank" rel="noopener">${escapeHtml(r.title || ('item #' + r.item_id))}</a>` : escapeHtml(r.title || ('item #' + r.item_id))}<br><span class="pill">${escapeHtml(r.brand || r.source || '')}</span></td>
+          <td class="sim ${simClass(r.similarity)}">${(r.similarity ?? 0).toFixed(3)}</td>
+          <td class="muted">${escapeHtml(r.checked_at)}</td>
+        </tr>
+      `).join('');
+    }
+    load();
+    setInterval(load, 60 * 1000);
+  </script>
+</body>
+</html>
diff --git a/public/css/app.css b/public/css/app.css
index 6f66216..9516130 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -778,6 +778,18 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
   width: 0%;
   transition: width 0.6s ease-out;
 }
+.embed-chip-budget {
+  margin-top: 6px;
+  padding-top: 6px;
+  border-top: 1px solid rgba(255,255,255,0.08);
+  font-size: 11px;
+  color: #c7c2b8;
+}
+.embed-chip-budget .muted-sm { color: #707070; }
+.embed-chip-budget.budget-warn { color: #f59e0b; }
+.embed-chip-budget.budget-warn .muted-sm { color: #b87a13; }
+.embed-chip-budget.budget-over { color: #ef4444; font-weight: 600; }
+.embed-chip-budget.budget-over .muted-sm { color: #ef4444; }
 
 /* ---- Production credit badge (tick 19) ---- */
 .card { position: relative; }   /* anchor for the absolute-positioned badge */
diff --git a/public/index.html b/public/index.html
index dbcd20f..84d2c09 100644
--- a/public/index.html
+++ b/public/index.html
@@ -584,6 +584,10 @@
     <span class="embed-chip-mac1" id="embed-chip-mac1">Mac1: …</span>
   </div>
   <div class="embed-chip-track"><div class="embed-chip-fill" id="embed-chip-fill"></div></div>
+  <!-- Tick 22: paid-API burn vs budget. Color goes amber > 75%, red > 100%. -->
+  <div class="embed-chip-budget" id="embed-chip-budget" title="Paid-API burn this month">
+    <span id="embed-chip-spent">$0.00</span> <span class="muted-sm">of $<span id="embed-chip-budget-cap">5.00</span> · </span><span id="embed-chip-burn-by"></span>
+  </div>
 </div>
 
 <script src="/js/app.js"></script>
diff --git a/public/js/app.js b/public/js/app.js
index e6d687e..b3c353d 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -164,6 +164,19 @@ async function refreshEmbedChip() {
     if (!j.drainer_enabled) {
       mac1.textContent = label + ' · paused';
     }
+    // Tick 22: paid-API budget row. Color-code on burn percentage.
+    if (j.budget) {
+      const spent = (j.budget.month_cents / 100);
+      const cap = (j.budget.budget_cents / 100);
+      const pct = cap > 0 ? (spent / cap) : 0;
+      $('#embed-chip-spent').textContent = '$' + spent.toFixed(2);
+      $('#embed-chip-budget-cap').textContent = cap.toFixed(2);
+      const budgetEl = $('#embed-chip-budget');
+      budgetEl.classList.toggle('budget-warn', pct >= 0.75 && pct < 1);
+      budgetEl.classList.toggle('budget-over', pct >= 1);
+      const top = j.budget.by_provider?.[0];
+      $('#embed-chip-burn-by').textContent = top ? `${top.provider} $${(top.c/100).toFixed(2)}` : 'no burn yet';
+    }
   } catch {
     chip.hidden = true;
   }
diff --git a/scripts/clothing-apis/calibrate-spa.js b/scripts/clothing-apis/calibrate-spa.js
new file mode 100644
index 0000000..7335785
--- /dev/null
+++ b/scripts/clothing-apis/calibrate-spa.js
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+/**
+ * SPA selector calibrator.
+ *
+ * Usage:
+ *   node scripts/clothing-apis/calibrate-spa.js <brand-id>
+ *
+ * Steps:
+ *   1. Load the brand entry from brands.json (must exist under headless_spa[]).
+ *   2. Open the products page via Browserbase, wait for network idle, pull
+ *      the rendered HTML, cache to /tmp/spa-<id>.html.
+ *   3. Probe a battery of common card selectors. Print the count for each.
+ *   4. For the best-scoring card selector, probe likely title/price/image/link
+ *      sub-selectors inside the first card.
+ *   5. Print a ready-to-paste `selectors` block.
+ *
+ * No interactive prompt — pure batch mode so it can run from CI / cron.
+ * Once selectors look good, paste them into brands.json + flip `disabled: false`.
+ *
+ * Idempotent: re-running uses the cached HTML if it's < 1 hour old.
+ */
+const fs = require('fs');
+const path = require('path');
+const { fetchRenderedHtml, available } = require('./headless-spa');
+
+const BRANDS = JSON.parse(fs.readFileSync(path.join(__dirname, 'brands.json'), 'utf8'));
+
+// Common card selectors across the e-commerce SPA ecosystem.
+const CARD_SELECTORS = [
+  '.product-card', '.product', '.ProductCard', '.product-tile', '.product-item',
+  '[data-test-id="product-card"]', '[data-testid="product-card"]', '[data-test="product-card"]',
+  '[data-product-card]', '[data-product]', 'article.product',
+  '.grid__item--product', '.collection-product', '.collection-product-card',
+  'li.product', '.product-grid-item', '.shop-item', '.shop-card',
+];
+
+// Within a card, candidate selectors per field.
+const TITLE_SELECTORS = ['.product-card__title', '.product__title', 'h2', 'h3', '.title', '[data-test*="title"]', '.name', '.product-name'];
+const PRICE_SELECTORS = ['.product-card__price', '.product__price', '.price', '[data-test*="price"]', '.amount', '.product-price'];
+const IMAGE_SELECTORS = ['img'];
+const LINK_SELECTORS = ['a'];
+
+async function main() {
+  const brandId = process.argv[2];
+  if (!brandId) {
+    console.error('usage: node scripts/clothing-apis/calibrate-spa.js <brand-id>');
+    console.error('available: ' + (BRANDS.headless_spa || []).map(b => b.id).join(', '));
+    process.exit(1);
+  }
+  const brand = (BRANDS.headless_spa || []).find(b => b.id === brandId);
+  if (!brand) {
+    console.error(`brand ${brandId} not in brands.json headless_spa`);
+    process.exit(1);
+  }
+  if (!available()) {
+    console.error('BROWSERBASE_API_KEY / BROWSERBASE_PROJECT_ID not set in env or skill .env');
+    process.exit(1);
+  }
+
+  const cacheFile = `/tmp/spa-${brandId}.html`;
+  const cacheAgeMs = fs.existsSync(cacheFile) ? Date.now() - fs.statSync(cacheFile).mtimeMs : Infinity;
+  let html;
+  if (cacheAgeMs < 60 * 60 * 1000) {
+    console.log(`[cache hit] reusing ${cacheFile} (age ${Math.round(cacheAgeMs/1000)}s)`);
+    html = fs.readFileSync(cacheFile, 'utf8');
+  } else {
+    const url = `https://${brand.domain}${brand.products_path || '/products'}`;
+    console.log(`[fetch] ${url} via Browserbase (this takes ~10-30s)`);
+    html = await fetchRenderedHtml(url, { waitMs: 5000 });
+    fs.writeFileSync(cacheFile, html);
+    console.log(`[cache] wrote ${cacheFile} (${(html.length / 1024).toFixed(0)} KB)`);
+  }
+
+  let cheerio;
+  try { cheerio = require('cheerio'); }
+  catch { console.error('cheerio not installed — run `npm i cheerio`'); process.exit(1); }
+  const $ = cheerio.load(html);
+
+  // Probe 1 — which card selector matches a sensible number of cards?
+  console.log('\n=== card selector candidates ===');
+  const cardScores = CARD_SELECTORS.map(s => ({ s, n: $(s).length }));
+  cardScores.sort((a, b) => b.n - a.n);
+  cardScores.slice(0, 8).forEach(c => console.log(`  ${String(c.n).padStart(4)} × ${c.s}`));
+  const best = cardScores.find(c => c.n >= 4 && c.n <= 500) || cardScores[0];
+  if (!best || best.n === 0) {
+    console.log('\nNo card selector matched. The page may be lazy-rendered (try a longer waitMs in headless-spa.js fetchRenderedHtml) or use a fully custom DOM structure (open /tmp/spa-' + brandId + '.html and craft selectors by hand).');
+    process.exit(2);
+  }
+  console.log(`\n[pick] card = "${best.s}" (${best.n} matches)`);
+
+  // Probe 2 — inside the first card, which sub-selectors hit?
+  const firstCard = $(best.s).first();
+  function probe(name, candidates) {
+    const hits = candidates.map(c => ({ c, txt: firstCard.find(c).first().text().trim().slice(0, 60), exists: firstCard.find(c).length > 0 })).filter(x => x.exists);
+    console.log(`\n=== ${name} candidates inside first card ===`);
+    hits.forEach(h => console.log(`  ${h.c.padEnd(35)} → ${JSON.stringify(h.txt)}`));
+    return hits[0]?.c || candidates[0];
+  }
+  const titleSel = probe('title', TITLE_SELECTORS);
+  const priceSel = probe('price', PRICE_SELECTORS);
+
+  // image + link probes (different — they want attr not text)
+  const imgEl = firstCard.find('img').first();
+  const imgSrc = imgEl.attr('src') || imgEl.attr('data-src') || imgEl.attr('srcset')?.split(',')[0]?.trim().split(' ')[0];
+  const linkEl = firstCard.find('a').first();
+  const linkHref = linkEl.attr('href');
+  console.log(`\n=== image inside first card ===`);
+  console.log(`  ${imgSrc ? 'FOUND: ' + (imgSrc.slice(0, 90)) : 'no <img> with src/data-src/srcset'}`);
+  console.log(`\n=== link inside first card ===`);
+  console.log(`  ${linkHref ? 'FOUND: ' + linkHref.slice(0, 90) : 'no <a href>'}`);
+
+  // Final paste-ready block
+  console.log(`\n=== READY-TO-PASTE selectors for brands.json → headless_spa[${brandId}] ===`);
+  console.log(JSON.stringify({
+    selectors: { card: best.s, title: titleSel, price: priceSel, image: 'img', link: 'a' }
+  }, null, 2));
+  console.log(`\nIf the values above look right, flip "disabled": false in brands.json and run:\n  curl -sS -X POST -H 'content-type: application/json' -d '{"kind":"headless","id":"${brandId}"}' http://127.0.0.1:9777/api/admin/import-catalog`);
+}
+
+if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index f024496..ca83ba7 100644
--- a/server.js
+++ b/server.js
@@ -816,6 +816,33 @@ app.get('/admin-config', (req, res) => {
 app.get('/privacy-policy', (_req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'privacy-policy.html'));
 });
+
+// Tick 22: drift-ledger viewer — surfaces embedding_drifts so admin can audit
+// which catalog items have shifted in their llava-projected vector.
+app.get('/admin/drifts', (req, res) => {
+  if (!adminGate(req)) return res.status(403).send('admin only');
+  res.sendFile(path.join(__dirname, 'public', 'admin-drifts.html'));
+});
+
+app.get('/api/admin/drifts', (req, res) => {
+  if (!adminGate(req)) return res.status(403).json({ error: 'admin only' });
+  // Recent drift events, joined to items for context.
+  const rows = db.prepare(`
+    SELECT d.id, d.item_id, d.similarity, d.checked_at,
+           i.title, i.brand, i.source, i.image_url, i.product_url
+      FROM embedding_drifts d
+      LEFT JOIN items i ON i.id = d.item_id
+     ORDER BY d.checked_at DESC, d.id DESC
+     LIMIT 500
+  `).all();
+  // Roll-up: drift count per item.
+  const perItem = db.prepare(`
+    SELECT item_id, COUNT(*) c, MIN(similarity) min_sim, MAX(checked_at) last_seen
+      FROM embedding_drifts GROUP BY item_id ORDER BY c DESC LIMIT 50
+  `).all();
+  const total = db.prepare('SELECT COUNT(*) c FROM embedding_drifts').get().c;
+  res.json({ total, recent: rows, per_item: perItem });
+});
 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 || {};
@@ -868,10 +895,26 @@ app.get('/api/admin/catalog-status', (req, res) => {
     const r = db.prepare("SELECT value FROM app_config WHERE key='mac1_status'").get();
     if (r) mac1 = JSON.parse(r.value);
   } catch {}
+  // Tick 22: paid-API budget rollup. Sum provider_costs by month + by provider.
+  // Floating chip displays running monthly burn against budget_cents_per_user.
+  const monthStart = new Date();
+  monthStart.setUTCDate(1);
+  monthStart.setUTCHours(0, 0, 0, 0);
+  const monthCents = db.prepare(
+    `SELECT COALESCE(SUM(cost_cents), 0) c FROM provider_costs WHERE ts >= ?`
+  ).get(monthStart.toISOString()).c || 0;
+  const byProvider = db.prepare(
+    `SELECT provider, COALESCE(SUM(cost_cents), 0) c FROM provider_costs WHERE ts >= ? GROUP BY provider ORDER BY c DESC`
+  ).all(monthStart.toISOString());
   res.json({
     total, embedded, unembedded: total - embedded, by_source: bySource,
     drainer_enabled: configValue('embed_drainer_enabled', false),
     mac1,
+    budget: {
+      month_cents: monthCents,
+      budget_cents: configValue('budget_cents_per_user', 500),
+      by_provider: byProvider,
+    },
   });
 });
 

← 380a952 yolo tick 21: Browserbase headless-SPA adapter (Reformation/  ·  back to Whatsmystyle  ·  yolo tick 23: color+material facet pills on /recs (server-si fcfd422 →