← 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 };