← back to Whatsmystyle

scripts/embed-items.js

188 lines

/**
 * llava-13b semantic embedder for catalog items.
 *
 * Replaces the hash-of-title stub with a real visual-semantic vector:
 *
 *   1. Fetch the product image (cached in data/cache/img/)
 *   2. Ask llava-13b for a strict JSON of structured style tags
 *   3. Project those tags onto a FIXED 32-d semantic basis where each
 *      dimension represents a single style axis (color family, formality,
 *      silhouette, era, etc.) — this is a hand-curated basis, not a
 *      learned model, so it stays deterministic across runs.
 *   4. Persist as the `embedding` JSON on the items row.
 *
 * The taste-vector update in /api/duel works because the 32 dims have
 * stable meaning across all items. Two items being "similar" in the
 * vector means they share style axes, not just title tokens.
 *
 * Why not CLIP/SBERT: llava is what we have locally on Mac2. A CLIP
 * pull is 1.5GB and a separate Ollama model; we can swap later.
 */
const Database = require('better-sqlite3');
const path     = require('path');
const fs       = require('fs');
const crypto   = require('crypto');
const fetch    = require('node-fetch');

const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const MODEL  = process.env.OLLAMA_VISION_MODEL || 'llava:latest';

const IMG_CACHE = path.join(__dirname, '..', 'data', 'cache', 'img');
fs.mkdirSync(IMG_CACHE, { recursive: true });

// ---- the 32-d semantic basis ---------------------------------------------
// Each dim is binary-ish 0/1 with 0.5 = ambiguous. Hand-chosen for coverage of
// the style axes that actually drive "do I want to wear this" decisions.
const BASIS = [
  // 0-4  color family (warm/cool/neutral/black-white/saturated)
  { key: 'color_warm',     match: t => /warm|earth|terra|rust|camel|cream|tan|gold|orange|red|brown/i.test(t) },
  { key: 'color_cool',     match: t => /cool|blue|indigo|navy|teal|green|sage|mint|grey|silver/i.test(t) },
  { key: 'color_neutral',  match: t => /neutral|beige|stone|ivory|sand|taupe|nude|oat/i.test(t) },
  { key: 'mono',           match: t => /black|white|monochrome|charcoal/i.test(t) },
  { key: 'saturated',      match: t => /vibrant|bold|electric|saturated|hot pink|red|cobalt|emerald/i.test(t) },
  // 5-9  pattern axes
  { key: 'solid',          match: t => /solid|plain/i.test(t) },
  { key: 'stripe',         match: t => /stripe|breton|pinstripe/i.test(t) },
  { key: 'floral',         match: t => /floral|ditzy|botanical|liberty/i.test(t) },
  { key: 'plaid',          match: t => /plaid|tartan|gingham|check/i.test(t) },
  { key: 'graphic',        match: t => /graphic|print|logo|monogram|abstract/i.test(t) },
  // 10-14  silhouette
  { key: 'fitted',         match: t => /fitted|tailor|bodycon|slim|skinny/i.test(t) },
  { key: 'oversized',      match: t => /oversized|loose|relaxed|boyfriend|baggy|slouch/i.test(t) },
  { key: 'flowy',          match: t => /flowy|drape|fluid|liquid|wrap|bias/i.test(t) },
  { key: 'structured',     match: t => /structured|boxy|sharp|architectural|crisp/i.test(t) },
  { key: 'cropped',        match: t => /cropped|short|mini|micro/i.test(t) },
  // 15-19  formality
  { key: 'workwear',       match: t => /work|office|professional|tailored|blazer|suit/i.test(t) },
  { key: 'evening',        match: t => /evening|cocktail|formal|gown|gala/i.test(t) },
  { key: 'casual',         match: t => /casual|everyday|easy|weekend/i.test(t) },
  { key: 'sport',          match: t => /sport|athleisure|activewear|gym|technical/i.test(t) },
  { key: 'loungewear',     match: t => /lounge|pajama|home|knit/i.test(t) },
  // 20-24  era / vibe
  { key: 'modern',         match: t => /modern|contemporary|minimal/i.test(t) },
  { key: 'vintage',        match: t => /vintage|retro|70s|80s|90s|y2k/i.test(t) },
  { key: 'romantic',       match: t => /romantic|feminine|delicate|lace|ruffle/i.test(t) },
  { key: 'edgy',           match: t => /edgy|punk|grunge|moto|leather|biker/i.test(t) },
  { key: 'preppy',         match: t => /preppy|collegiate|nautical|polo/i.test(t) },
  // 25-29  material
  { key: 'natural_fiber',  match: t => /cotton|linen|silk|wool|cashmere|hemp/i.test(t) },
  { key: 'synthetic',      match: t => /polyester|nylon|acrylic|spandex|elastane/i.test(t) },
  { key: 'leather',        match: t => /leather|suede|nubuck/i.test(t) },
  { key: 'denim',          match: t => /denim|jean/i.test(t) },
  { key: 'knit',           match: t => /knit|sweater|cashmere|cardigan|wool/i.test(t) },
  // 30-31  season-ish
  { key: 'warmweather',    match: t => /summer|warm weather|tropical|breezy|lightweight/i.test(t) },
  { key: 'coldweather',    match: t => /winter|cold|heavy|coat|outerwear|sweater/i.test(t) },
];

function projectTags(tagBag) {
  const flat = JSON.stringify(tagBag).toLowerCase();
  const v = BASIS.map(b => b.match(flat) ? 1 : 0);
  // L2 normalize so dot products in /api/recommend behave
  const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
  return v.map(x => x / n);
}

async function fetchImageCached(url) {
  const key = crypto.createHash('sha1').update(url).digest('hex');
  // Always .jpg — Unsplash and most CDNs return jpeg-encoded; llava doesn't care
  // about the extension on disk, only the bytes.
  const p = path.join(IMG_CACHE, `${key}.jpg`);
  if (fs.existsSync(p) && fs.statSync(p).size > 1024) return p;
  const r = await fetch(url, { headers: { 'user-agent': 'WhatsMyStyleBot/0.1 image-prefetch' }, timeout: 30000 });
  if (!r.ok) throw new Error(`image fetch ${url} → ${r.status}`);
  fs.writeFileSync(p, Buffer.from(await r.arrayBuffer()));
  return p;
}

async function describeOnce(imagePath) {
  const b64 = fs.readFileSync(imagePath).toString('base64');
  const prompt = `You see a single garment / accessory product photo. Return STRICT JSON only:
{"colors":["...","..."],"pattern":"solid|stripe|floral|plaid|graphic|other","silhouette":"fitted|oversized|flowy|structured|cropped|other","formality":"workwear|evening|casual|sport|loungewear","era":"modern|vintage|romantic|edgy|preppy","materials":["..."],"season":"warmweather|coldweather|allseason","style_tags":["...","..."]}
No prose. JSON only.`;
  const r = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ model: MODEL, prompt, images: [b64], stream: false, options: { temperature: 0.2 } }),
  });
  if (!r.ok) throw new Error(`ollama ${r.status}`);
  const j = await r.json();
  const m = (j.response || '').match(/\{[\s\S]*\}/);
  if (!m) return null;
  try { return JSON.parse(m[0]); } catch { return null; }
}

async function embedItem(db, row) {
  if (!row.image_url) return { id: row.id, skipped: 'no image' };
  let imgPath;
  try { imgPath = await fetchImageCached(row.image_url); }
  catch (e) { return { id: row.id, skipped: 'image fetch failed', err: e.message }; }
  const desc = await describeOnce(imgPath);
  if (!desc) return { id: row.id, skipped: 'llava returned non-json' };
  const tagBag = {
    ...desc,
    title: row.title, brand: row.brand, category: row.category, color: row.color, pattern: row.pattern, material: row.material,
  };
  const vec = projectTags(tagBag);
  db.prepare('UPDATE items SET embedding = ?, tags = ? WHERE id = ?')
    .run(JSON.stringify(vec),
         JSON.stringify([...new Set([...(desc.style_tags||[]), desc.formality, desc.era, desc.silhouette, desc.pattern].filter(Boolean))]),
         row.id);
  return { id: row.id, ok: true, dims_set: vec.filter(x => x > 0).length };
}

async function pingOllama() {
  try {
    const r = await fetch(`${OLLAMA}/api/tags`, { timeout: 4000 });
    if (!r.ok) return false;
    const j = await r.json();
    return (j.models || []).some(m => m.name?.startsWith('llava'));
  } catch { return false; }
}

// Tick 18: batch mode for the unembedded tail. With 1,200+ catalog rows we
// can't process them all in one pass — llava is slow (~3-5s/item). Run with
// `--batch <N>` to only process N items in this invocation; pair with a
// recurring caller (server cron) so the catalog fills incrementally.
//
// `--unembedded-only` picks rows where embedding IS NULL — what we want for
// the post-import backfill (existing real embeddings stay; only new items
// from the Shopify import get a vector).
function parseArgs(argv) {
  const args = { batch: null, unembeddedOnly: false, limit: null };
  for (let i = 2; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--batch') args.batch = Number(argv[++i]);
    else if (a === '--unembedded-only') args.unembeddedOnly = true;
    else if (!isNaN(Number(a))) args.limit = Number(a);  // legacy positional
  }
  return args;
}

async function main() {
  const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
  const db = new Database(dbPath);
  const ok = await pingOllama();
  if (!ok) {
    console.log(JSON.stringify({ ok: false, reason: 'ollama+llava not available locally; leaving stub embeddings in place', model: MODEL, url: OLLAMA }));
    return;
  }

  const { batch, unembeddedOnly, limit } = parseArgs(process.argv);
  const cap = batch || limit || 24;
  const sql = unembeddedOnly
    ? 'SELECT * FROM items WHERE embedding IS NULL ORDER BY id LIMIT ?'
    : 'SELECT * FROM items ORDER BY id LIMIT ?';
  const rows = db.prepare(sql).all(cap);

  const results = [];
  for (const r of rows) {
    try { results.push(await embedItem(db, r)); }
    catch (e) { results.push({ id: r.id, err: e.message }); }
  }
  console.log(JSON.stringify({ ok: true, processed: results.length, unembeddedOnly, results }, null, 2));
}

if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
module.exports = { embedItem, projectTags, BASIS };