- Role
- Recs + product systems
- Problem
- Teams collect clicks and purchases, then leave them in analytics while “for you” still means hand-tuned shelves.
- Constraint
- Any catalog shape, noisy/repeated client events, tenant isolation, explainable when results are wrong.
- Outcome
- Small API for items, interactions, recs, and search. Profiles rebuild from durable history; online indexes are derived.
I kept seeing products log useful behaviour and never feed it back into the product. Lumin is the service I wanted: ingest a catalog, accept interactions, return a personalised list.
Movies are the demo shelf. The engine is catalog-agnostic.
It works. It is not finished. Gaps stay visible — not rebranded as “AI platform.”
Source + architecture + sequences.
POST /api/catalogs/{catalogId}/items
→ register an item
POST /api/catalogs/{catalogId}/interactions
→ one weighted action, stable interaction id
GET /api/catalogs/{catalogId}/users/{userId}/recommendations
→ unseen items + strategy / evidence metadata
Everything is scoped to the catalog after the API key resolves the tenant.
Layer, not a movie app
First version could have been TMDB + vectors + “For you.” Nice demo, bad product boundary.
Items share a small core (title, description, tags, category, image, price) plus a validated attributes bag for domain fields — authors, venue, brand, whatever. New catalog through the API, not a deploy for a new schema.
One durable truth
Writes fan out: Gemini embeds, Upstash Vector for similarity, Tinybird for analytics, D1 for tenants/items/interactions, KV for short-lived rec responses.
They are not equal:
- D1 — source of truth
- Upstash — derived retrieval
- Tinybird — analysis
- KV — disposable cache
Rebuild from interaction history when an index or ranking rule changes. No pretence of a distributed transaction on ingest today: parallel side effects can partially fail.
Clicks are evidence, not love
Weights + time decay:
view +0.25 like +2.0
click +1.0 purchase +3.0
save +1.5 dismiss -1.0
complete +3.0 dislike -2.0
Profile = weighted sum of item embeddings, recompute anytime. Interaction IDs are accepted once per tenant/catalog so a retried HTTP call does not train the profile three times. Tinybird dedupes analytics separately. Boring until a stubborn webhook permanently skews genre.
Search ≠ recommendations
Search — query embed + vector neighbours + lexical D1 (helps when the item just landed and the vector is not ready).
Recs — taste profile, drop seen items, popularity cold-start when there is no signal. Empty shelf dressed as personalisation is still an empty shelf.
What I can prove
Controlled evaluator: fixed catalogs, deliberate taste profiles, checks that tastes separate, seen items stay out, retries do not double-count, ranking is stable for the same evidence.
That proves the loop is coherent in a lab. Not marketplace quality. One aggregate taste vector still mushs horror + family animation + sci‑fi.
Privacy: opaque user ids are enough for the engine, but we still store interactions and whatever metadata the customer sends. Retention/deletion controls are unfinished. “We store nothing” would sound better and be wrong.
Useful part: the learning loop is inspectable — what entered, how it was weighted, what is authoritative, how to rebuild.