bethel-nz
/Lumin
/project2026
/project2026

2026

Lumin

A service that helps apps recommend things based on what people like and interact with.

Visit project ↗
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.