Skip to content

Add "Based on your browsing" rail driven by cookied product IDs + new TS Layer client #7

Description

@hev

Goal

Show shoppers a "Based on your browsing" rail at the bottom of /product/{asin} that uses the visitor's recent product views (kept in a cookie) to drive a multi-seed vector query against amazon-products. Fills a gap in the current showcase: today we only demonstrate single-seed similarity (title re-embed in indexer/app/main.py:303). Multi-ASIN history is a more realistic storefront use case and exercises a Layer pattern — server-side query by document ID — that we don't have a demo for yet.

Also introduces a small TypeScript Layer client at web/lib/layer-client.ts, mirroring the relevant subset of indexer/app/layer_client.py. Strategic goal #2 in CLAUDE.md is to provide clean example code for forks; the Python client covers the indexer-tier story, and a TS counterpart covers the storefront/edge-tier story.

Required layer-gateway features

This issue is blocked on layer-gateway adding the following. Capture these in the layer-gateway repo before starting hev-shop work.

1. Query by document ID (required)

Today POST /v2/namespaces/{ns}/query accepts a vector and returns nearest neighbors. To do "nearest to this product" without re-embedding the title (current hack in backendSimilar/search), we either need to fetch the stored vector first and round-trip it back, or — much cleaner — ask the gateway to look up the stored vector server-side:

POST /v2/namespaces/amazon-products/query
{
  "id": "B00FI7TCGI",
  "top_k": 20,
  "include_attributes": ["asin", "title", "image_url", "category"],
  "filters": ["id", "NotIn", ["B00FI7TCGI"]]
}

Server-side: fetch the doc's vector, run the existing ANN query, return as today. Same response shape ({results, stable_as_of}). One round-trip per seed instead of two. Also avoids exposing raw stored vectors to clients, which is a property worth preserving as namespaces get larger.

This is the load-bearing feature for the rail. Without it, the storefront has to fetch vectors out of the gateway (extra payload, extra hop) just to pass them right back.

2. Batch query by IDs (nice to have)

Same as #1 but pass a list of IDs and get N result lists back in one round-trip:

POST /v2/namespaces/amazon-products/query
{
  "ids": ["B00FI7TCGI", "B07Y2HZ1X3", "B09KMNQ4XL"],
  "top_k": 20,
  "include_attributes": [...]
}
→ { "results_per_id": [[...], [...], [...]], "stable_as_of": ... }

Cuts the cookie's N seeds from N sequential queries to one. Storefront-tier latency win. Not required for v1 — we can fan out N parallel queries against #1 — but worth scoping at the same time since the data path is nearly identical.

3. (Not needed) Exclude filter

The standard filters: ["id", "NotIn", [...]] should already cover "don't return the ASINs the user already viewed". Confirm during layer-gateway scoping, but no new feature expected here.

Design (hev-shop side, after layer-gateway lands #1)

Cookie

  • Name: hev_recent
  • Value: comma-separated ASINs, most-recent-first
  • Capped at 12, deduped on append
  • Max-Age=7776000 (90 days), SameSite=Lax, not HttpOnly (client may want to read it too)

Recording views

POST /api/recent with { asin }. Called once from /product/[asin] on mount via a tiny client component. Reads the existing cookie via next/headers, prepends, writes back with Set-Cookie. Returns the trimmed list (debug aid).

Generating recommendations

GET /api/because?exclude={current_asin}&limit=8:

  1. Read hev_recent cookie. Empty → { products: [] }.
  2. For each seed ASIN (up to ~6 most recent), call layer-client queryById(PRODUCT_NS, asin, top_k=20, filters=NotIn(viewed ∪ exclude), include_attributes=[…]). Uses layer-gateway feature Barbell review classification: only classify 1★/5★ #1.
  3. Reciprocal-rank-fuse the N result lists (k=60), dedupe by ASIN, take top limit.
  4. Hydrate title/image_url/etc. from the attributes already returned by step 2 — no extra fetch.
  5. Return { products, perf: { per_seed: [latency_ms…], total_ms }, stable_as_of: min(…) }.

Once layer-gateway #2 is available, collapse step 2 into one call.

Render

New client component web/components/RecentRail.tsx. Fires POST /api/recent then GET /api/because on mount. Renders below the existing "Visually similar" section on web/app/product/[asin]/page.tsx. Skeleton while loading; hidden when 0 results.

TS Layer client (web/lib/layer-client.ts)

Minimal storefront-tier subset of indexer/app/layer_client.py:

Method HTTP Used by
fetchDocument(ns, id, includeAttributes?) GET /v2/namespaces/{ns}/documents/{id} future read paths
fetchManyDocuments(ns, ids, includeAttributes?) POST /v2/namespaces/{ns}/documents hydration
queryById(ns, id, opts) POST /v2/namespaces/{ns}/query (with id) the rail — needs layer-gateway #1
warmNamespace(ns) already exists in web/lib/layer.ts — refactor into the new client

Each returns { body, perf: { latency_ms, cache_status } } matching the Python LayerPerf shape. Uses the existing LAYER_GATEWAY_URL / LAYER_GATEWAY_API_KEY env vars. Per the project's layer-only-client constraint (CLAUDE.md), no direct turbopuffer SDK usage.

Files

  • web/lib/layer-client.ts — new TS client (above)
  • web/lib/recent.ts — pure cookie helpers (readRecent, appendRecent)
  • web/app/api/recent/route.tsPOST view-recorder
  • web/app/api/because/route.tsGET recommender, calls layer-client
  • web/components/RecentRail.tsx — new client component
  • web/app/product/[asin]/page.tsx — append <RecentRail excludeAsin={asin} /> below the existing similar section
  • web/lib/layer.ts — refactor warmOne into layer-client.ts, keep warmOnce() as the call site

Acceptance

  • curl -X POST -d '{"asin":"B00FI7TCGI"}' https://hev-shop.com/api/recent sets a hev_recent cookie containing B00FI7TCGI.
  • After 2–3 product views, curl --cookie 'hev_recent=...' https://hev-shop.com/api/because?exclude=... returns a non-empty product list whose ASINs are not in the cookie.
  • https://hev-shop.com/product/B00FI7TCGI renders the "Based on your browsing" rail beneath "Visually similar" once the cookie has ≥ 1 entry.
  • Rail is fully hidden on a first visit (empty cookie) — no empty-state placeholder.
  • Per-seed layer_perf surfaces in the response payload for the existing LayerPerfBadge to consume (badge wiring may follow in a separate PR; the data needs to be there).

Out of scope (v1)

  • Homepage rail and search-page rail — defer; the product-detail placement is the highest-signal spot.
  • Cross-device history — cookie-only, no account-bound storage.
  • Signed/tamper-resistant cookie — this is a recommendation hint, not a security boundary.
  • Per-seed weighting (recency decay, dwell time). RRF with equal weight is the v1 fuse.
  • Server-side RRF (layer-gateway feature). Client-side fuse is simple and fast for N≤12.

Risk to watch

  • Doc cache behavior on the new query-by-id path. Confirm with layer-gateway that the `stable_as_of` watermark is still surfaced and that `x-layer-cache` is set consistently. If the server-side vector lookup goes through the doc cache, the rail will demonstrate cache hit/miss on a query for the first time in the app — worth surfacing in the UI later.
  • PII in the cookie. ASINs are public catalog IDs, not user data. Still, keep `HttpOnly` off only because we may want to read on client; if no client read materializes during build, flip it on.
  • Tight coupling to namespace name. `LAYER_PRODUCT_NAMESPACE` already exists in `layer.ts` — reuse, don't hard-code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions