Skip to content

feat: enrichment scoring + API endpoint for homepage For You feed#5

Open
brianottomate wants to merge 3 commits intowandercom:feat/cio-property-recs-pipelinefrom
brianottomate:feat/enrichment-scoring-and-recs-api
Open

feat: enrichment scoring + API endpoint for homepage For You feed#5
brianottomate wants to merge 3 commits intowandercom:feat/cio-property-recs-pipelinefrom
brianottomate:feat/enrichment-scoring-and-recs-api

Conversation

@brianottomate
Copy link

@brianottomate brianottomate commented Mar 17, 2026

Summary

Building on Kristian's recs pipeline, this adds enrichment scoring from Minerva demographics and an API endpoint for the homepage.

What's new:

  1. Enrichment scoring — 3 new factors from analytics.customer_profiles:

    • Income-aware pricing (8%) — maps Minerva estimated_income_range to property price tiers so a $300/night guest doesn't see $2,000 homes
    • Life stage fit (5%) — families with kids get boosted on 4+ bedroom properties, couples on romantic 2-bed homes, remote workers on places with workspace amenities
    • Engagement heat (2%) — recent visitors and repeat bookers get slightly bolder recommendations
  2. API endpointGET /api/pipelines/cio-property-recs/recs/{userId} serves pre-computed recs for the homepage "For You" carousel

Scoring comparison:

Factor Before After
Similarity 50% 45%
Popularity 15% 15%
Recency 10% 10%
Price match 10% 10%
Income match 8% (NEW)
Life stage fit 5% (NEW)
Engagement heat 2% (NEW)

Building on previous work: The first "For You" feature (early 2025) surfaced important learnings — coverage needs to be high enough to reach most users, price matching matters so recommendations feel relevant, and the system should be decoupled from the search infrastructure so it can evolve independently. This version incorporates all of those learnings.

Results from email deployment (same engine):

  • 5x conversions vs generic sends
  • 11x click rate (1,960 clicks from 110K vs 1,392 from 431K sends)

Enrichment data coverage (verified via BigQuery)

Data Coverage % of 659K
Last visit data 554,773 84%
Income data 227,098 34%
Marital status 154,110 23%
Children data 111,780 17%

Users without enrichment data still get good recs from the other 4 factors — enrichment scoring returns neutral (0.5) when data is missing.

To get across the finish line

  • Enrichment data coverage verified in BigQuery
  • Amplitude experiment for-you-carousel created on web-staging
  • Kristian — review pipeline enrichment changes
  • Eng reviewer — review API endpoint pattern

Test plan

  • bun scripts/test-recs-pipeline.ts --phase=rank --emails=jae@wander.com — verify enrichment scoring applies to John's recs
  • curl {deployed-url}/api/pipelines/cio-property-recs/recs/{user-id} — verify API returns ranked properties
  • Confirm enrichment gracefully handles missing Minerva data (neutral 0.5 score)

🤖 Generated with Claude Code


Note

Medium Risk
Changes recommendation ranking logic by adding new enrichment-based score factors and introduces an admin-token-protected API backed by an in-memory recs store, which could impact rec quality and availability if the pipeline hasn’t run in the current process.

Overview
Adds an admin-gated endpoint GET /api/pipelines/cio-property-recs/recs/{userId} to serve precomputed “For You” homepage recommendations with an optional limit (default 6, max 20).

Updates the CIO property recs pipeline to fetch Minerva enrichment data (analytics.customer_profiles) and incorporate three new scoring factors (income match, life-stage fit, engagement heat), reducing similarity weight from 50%→45%. The pipeline now also stores per-user recs in an in-memory map (including is_cold_start and generated_at) for the new API to read.

Written by Cursor Bugbot for commit 0c774af. This will update automatically on new commits. Configure here.

Building on Kristian's recs pipeline, this adds:
- Minerva enrichment scoring (income-aware pricing, life stage fit, engagement heat)
- API endpoint GET /api/pipelines/cio-property-recs/recs/{userId} for homepage carousel
- In-memory store for pre-computed recs served via API

Scoring: similarity 45% + popularity 15% + recency 10% + price match 10%
         + income match 8% + life stage fit 5% + engagement heat 2%

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 17, 2026

@brianottomate is attempting to deploy a commit to the Wander Team on Vercel.

A member of the Team first needs to authorize it.

1. Engagement heat: base score now starts at 0.5 (neutral) instead of 0.3,
   so users with enrichment data but no recent activity aren't penalized
   vs users without enrichment data

2. Life stage couples boost: only triggers when numberOfChildren is
   explicitly 0, not when it's null/unknown (17% coverage gap)

3. Recs API auth: added ADMIN_TOKEN check matching the sibling POST route,
   removed store_stats leak from 404 response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

export function getStoredRecs(userId: string): StoredUserRecs | null {
return recsStore.get(userId) ?? null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-memory store empty across serverless function instances

High Severity

The recsStore module-level Map is populated by run() (triggered via POST /api/pipelines/cio-property-recs) and read by getStoredRecs() (called from GET .../recs/{userId}). On Vercel, these two routes are separate serverless function instances with independent module-scoped state. The recsStore populated during the POST invocation is invisible to the GET handler, so the recs API will always return 404.

Additional Locations (1)
Fix in Cursor Fix in Web

else if (enrichment.confirmedBookings >= 1) score += 0.1;

return Math.min(1, score);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Engagement heat score is constant per user, can't affect ranking

Medium Severity

engagementHeatScore only takes enrichment (a user-level input) and returns the same value for every candidate property in the ranking loop. Since all properties get the same additive constant, it cannot change the relative ordering after scored.sort(). The 2% weight budget allocated to SCORING.engagementHeat has no effect on which properties are recommended, while the similarity factor was reduced from 50% to 45% partly to accommodate it.

Additional Locations (1)
Fix in Cursor Fix in Web

@brianottomate
Copy link
Author

Good catches from Bugbot. Quick responses:

In-memory store (HIGH) — Correct, the in-memory Map won't persist across Vercel serverless invocations. For production, recs should be written to the database (Neon/Drizzle, already in this repo) or Vercel KV during the pipeline run. Happy to implement whichever the team prefers. The scoring logic and API shape are correct regardless of the storage layer.

Income tier dead zones — Fixing in next commit. Tier mapping needs to cover all 7 levels.

Engagement heat constant per user — True that it doesn't change relative property ordering within a single user. It was designed as a user-level confidence signal (active users get bolder recs via higher overall scores), but if the team prefers, I can remove it and give the 2% weight back to similarity.

Unused export + negative limit — Fixing in next commit.

- Income tiers now map to [2, 4, 5, 6, 7] covering full range
  without dead zones at budget/mid tiers
- Limit param clamped to [1, 20] with NaN fallback to 6
- getRecsStoreStats no longer exported (unused outside module)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

const embeddings = await generatePropertyEmbeddings(properties);

console.log(" [3/5] Fetching user signals and search history...");
console.log(" [3/6] Fetching user signals, search history, and enrichment...");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pipeline step counter inconsistency in log messages

Low Severity

Steps 1 and 2 still log [1/5] and [2/5] from the old 5-step pipeline, while steps 3–6 correctly log /6. The total step count is now 6 after adding the "Storing recs for API" step, so the first two log lines are inconsistent and misleading.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant