One Gemini-powered pipeline. Same engine, same results quality, for free and paid users. The Basic / Advanced toggle controls filter visibility, not algorithm quality.
Single entrypoint findRestaurantsAdvanced in services/geminiService.ts. No fallback paths, no flag branches.
QueryContext: cuisineTypes, dishMentions, vibeKeywords, occasion, dietary, priceHint, queryType, primaryFocus, sentimentWeights, and searchVariations (the 1–5 search strings Step 2 will use).locationBias.circle, then a baseline filter (≥ 50 reviews + within distance). Donut-ring offset sampling kicks in when maxDistance > 15 km. As of 2026-05-30 reviews fold in here: the field mask includes places.reviews + places.editorialSummary (Enterprise+Atmosphere SKU), so each Text Search call returns up to 5 reviews/place. Issued in both en + ar (languageCode biases the set) and deduped to up to 10 unique reviews/place. Prefers review.originalText over review.text. No separate /reviews places.get fan-out.dishMatchConfidence, dishSentiment, trend, best pro/con. Reviews + enriched data arrive inline from Step 2.
Reviews fold-in (2026-05-30): reviews now ride inline on the discover Text Search call (Enterprise+Atmosphere field mask), eliminating the per-place places.get fan-out that was ~89% of cold cost. Per-search cost dropped ~$1.4–1.6 cold to ~$0.19 (~88% cut) in the architecture, not the cache. The Supabase cache (Phase 1–4, shipped 2026-05-12) still wraps the searchText call by (geohash6, query, language) for 28 days and layers on top for repeat geohashes. See section 9.
Discover returns up to ~40 candidates. We display 9, but deeply analyze up to ~30 (18 top-rated + 12 far). We do NOT shrink this to match the 9 we show: the final score is dishSentiment-primary and dishSentiment is independent of Google rating, so a small rating-ranked pool would hide the specialist (a 4.3★ place with the best dish) that V4 exists to surface. Reviews are folded into discover and already paid for every candidate, so a big pool costs only Gemini. Pure top-by-rating filters out 4.0–4.4★ far places that the donut-ring sampling deliberately surfaced, so the seats are still split.
Sorted by Google rating descending. Pure quality picks regardless of distance.
TOP_RATED_SEATS = 9
Anything not already in Pool A, filtered to ≥ 3.8★ and ≥ 100 reviews, sorted by distance descending. Surfaces good places further away.
FAR_FLOOR_RATING = 3.8FAR_FLOOR_REVIEWS = 100
Run before scoring. Any restaurant that fails either gate is dropped — no weighted penalty, no second chance.
Hard floor. Anything below 3.0 stars is excluded regardless of dish quality. Reflects "this place has structural problems no good shawarma can rescue."
GOOGLE_RATING_FLOOR = 3.0
For queryType === 'dish', the place must demonstrate it actually serves the dish. Cuisine and vibe queries skip this gate.
DISH_CONFIDENCE_FLOOR = 5
Query-aware primary signal + three small additive modifiers. Total possible swing on top of primary-mapped raw: best +0.23, worst −0.40. Top picks stay top picks; the new factors break ties and surface near-misses.
| queryType | Primary |
|---|---|
| dish | dishSentiment |
| cuisine | dishSentiment |
| vibe | sentimentBreakdown.ambiance |
| multi-constraint | weighted blend |
| ambiguous | weighted blend |
"karaoke restaurant" is a vibe query, so its primary signal is the ambiance score, not how good the food is. Multi-constraint queries blend all four sentiment dimensions weighted by queryContext.sentimentWeights from /understand.
| Trend | Bonus |
|---|---|
| improving | + 0.08 |
| stable | 0 |
| declining | − 0.10 |
Asymmetric on purpose: a slipping place loses more than an improving place gains. Recent reviews matter.
| distanceKm vs maxDistance | Bonus |
|---|---|
| < 50% (close) | 0 |
| 50–80% (mid) | − 0.10 |
| ≥ 80% (far edge) | − 0.20 |
A Pro user picking 50km is opting in to far results, so a flat ">10km penalty" would undo their choice. Penalty kicks in past 50% of the user's chosen radius, deepens past 80%.
| googleRating | Base bonus |
|---|---|
| ≥ 4.5 | + 0.15 |
| 4.0 – 4.5 | + 0.05 |
| 3.5 – 4.0 | 0 |
| 3.0 – 3.5 | − 0.10 |
| < 3.0 | GATE — drop |
Confidence weighting: positive bonuses are multiplied by min(1, reviewCount / 500) — a 4.8★ place with 150 reviews gets only 30% of the EXCELLENT bonus (+0.045), while a 4.6★ place with 1500 reviews gets the full +0.15. Reflects the Law of Large Numbers — high-N ratings are statistically more reliable than low-N. The −0.10 penalty is NOT shrunk.
dishSentiment alone, which broke for vibe queries like "karaoke restaurant" — there is no "karaoke dish" to score. V4 picks the right primary per queryType:
dishSentiment stays primary (targeted: "how good is their shawarma")sentimentBreakdown.ambiance takes over (targeted: "how good is the karaoke vibe")sentimentWeightsdishMatchConfidence stays a hard gate (< 5 drops dish queries) — it's a binary "do they serve it" signal, useless for ranking.
The actual calculateV4Score from lib/scoring.ts, running in your browser. Slide the inputs to see the math.
Read top-to-bottom. Yellow boxes are decisions, red boxes are where a restaurant gets dropped. This is what runs for every single search.
QueryContext with cuisineTypes, dishMentions, vibeKeywords, occasion, dietary, priceHint (optional), maxPriceLocal (optional), primaryFocus, sentimentWeights (food/service/ambiance/value), queryType, and searchVariations — the 1–5 search strings Step 2 fans out into Places API calls.queryType === 'dish', and getMatchEvidence picks dishMatchConfidence for dish, cuisineMatchConfidence for cuisine, or the larger of the two for vibe / multi-constraint / ambiguous. (For non-dish queries, the picked match-evidence is currently unused — V3 score is dishSentiment-only.)searchText calls from the QueryContext searchVariations, capped at MAX_VARIATIONS = 2 and issued in both languages (en + ar): 2 variations × 2 languages = 4 calls at the 10 km default. The prompt at handlers/search/understand.ts:33 instructs Gemini to return 2–4 strings; the Zod schema at schemas.ts:28 permits 1–5. Each call is centered at the user with locationBias.circle radius = user's maxDistance (default 10 km). maxResultCount: 20 per call. After all calls return, results are merged by placeId (reviews accumulated across language calls), then filtered to userRatingCount ≥ 50 and within maxDistance.places.reviews + places.editorialSummary (Enterprise+Atmosphere SKU, $0.040/call), so each Text Search returns up to 5 reviews/place. languageCode biases the set, so the en + ar calls return disjoint reviews, merged and deduped by normalized text to up to 10 unique reviews/place. Prefers review.originalText over review.text. This eliminates the per-place places.get fan-out (~89% of old cold cost).maxDistance > 15 km): 2 additional calls at offset centers north + south of the user. Offset distance = min(maxDistance × 0.6, 30) km. Each offset call uses a fixed 15 km radius, the primary language only, and only the first capped variation.temperature: 0 + "USE ONE DECIMAL PLACE" + scale anchoring (10.0=extraordinary, 8.0=excellent, ...) for stable scoring.temperature=0 stability.dishMatchConfidence (0–10), dishSentiment (0–10 with decimals), cuisineMatchConfidence, sentimentBreakdown (foodQuality/service/ambiance/value, 1–10 each — V4 uses these for vibe + multi-constraint queries), trend (improving/stable/declining), bestPro, bestCon, and their translations.[ANALYZE-V2] EmptyCon: diagnostic logging (negativeReviews count) so audit trails confirm each empty is "honestly empty" and not "Gemini punted."
reviewRecencyMonths, reviewsAnalyzed, searchTermMentioned. These consume Gemini output tokens but feed nothing downstream.Each teal step is a Gemini call · coral is a Places API call · mint is pure client-side JS. Yellow diamonds are decisions, red boxes mean exclusion.
Badges are awarded after scoring, in priority order. A restaurant must score ≥ 3.5 to be eligible. Priority: Hidden Gem > Crowd Tested > Best Value.
Tiebreak: fewer reviews wins (more "hidden").
Tiebreak: more reviews wins (more proven).
Tiebreak: largest aiRating − googleRating gap.
Each row is something that used to live in code, and is now gone. The old marketing doc may still describe these.
| Replaced on 2026-05-09 (V3 → V4) | Why |
|---|---|
V3 scoring (calculateV3Score: dishSentiment alone + trend) | Broke for vibe queries — "karaoke restaurant" was scored on how good the food was, not how good the vibe was. V4 picks the primary signal per queryType: dishSentiment for dish/cuisine, ambiance for vibe, weighted blend for multi-constraint. |
Discarded data in analysis.sentimentBreakdown (foodQuality / service / ambiance / value) | Gemini was already computing these (Zod-required in RestaurantAnalysisSchema). V3 ignored them. V4 uses ambiance as the vibe-query primary and blends all four for multi-constraint queries. |
| googleRating as binary gate only | Promoted to a small tiered modifier (+0.15 / +0.05 / 0 / −0.10). Captures statistical confidence without re-introducing V2's "big chains beat specialists" — capped at +0.15 above the dishSentiment-driven raw. |
| Distance unused in scoring (filter + pool-split only) | Now a small additive modifier scaled to the user's chosen maxDistance. Penalty kicks in past 50% of their radius, deepens past 80%. Pro user picking 50km opted in to far results — a flat ">10km penalty" would have undone their choice. |
QueryType = 'dish' | 'cuisine' | 'vibe' | 'ambiguous' in lib/search/types.ts | Latent type drift — the Zod schema returned 5 values including 'multi-constraint', but the TypeScript type only had 4. SENTIMENT_WEIGHTS lookup silently returned undefined for multi-constraint queries. Fixed in the same V4 commit. |
| Removed on 2026-05-06 (V2/V3 cleanup) | Why |
V1 algorithm — client entrypoint findRestaurantsMultiStep | V2 was at 100% rollout for 22 days. The client function is gone; legacy V1 paths inside discover.ts and analyze.ts still exist but are unreachable from the client. |
| V1 scoring (Bayesian + 6 components) | Score = "how good is this restaurant" instead of "how good is their dish". Wrong question. |
V2 scoring (V1's Bayesian formula × dishMatchConfidence soft-gate multiplier 0.5–1.0, plus V1's trend bonuses) | Recovered from git show ccf9823^:lib/scoring.ts. Replaced by V3, which used dishSentiment alone — matchConfidence saturates at 10/10 for in-category restaurants and gives no ranking signal. |
SCORING_V3_ENABLED flag | V3 was hardcoded after rollout finished. |
ALGORITHM_V2_ROLLOUT_PCT flag | Same — no more gradual rollout. |
| Per-user feature-flag fetch | One pipeline, no branching. |
| Diversity dampening | Caused confusing UI: 5.0 score listed below a 4.6 score after dampening. The hard gate already filters irrelevant cuisines. |
trending, premium, local-favorite badges | Replaced by Hidden Gem / Crowd Tested / Best Value. |
| File | Role |
|---|---|
services/geminiService.ts | Client orchestrator. Single entrypoint findRestaurantsAdvanced. |
api/search/understand.ts | Gemini query intent → QueryContext. |
api/search/discover.ts | Places Text Search + donut-ring sampling; reviews + enriched folded in (dual-language). |
api/search/reviews.ts | Legacy places.get review fetch, no longer called by the v2 client path (2026-05-30). |
api/search/analyze.ts | Per-restaurant Gemini analysis. |
lib/scoring.ts | Client-side V4 query-aware multi-factor scoring + badge assignment. |
lib/search/types.ts | Shared types: QueryContext, RestaurantAnalysis, etc. |
lib/cache/supabase-cache.ts | Cache helpers: l2CacheGet/Set (place_cache, 28d TTL), searchTextCacheGet/Set (geohash6-keyed, 28d), inline geohashEncode. Fail-open: errors return null/silent. |
handlers/crons/refresh-place-cache.ts | Weekly rebake. Refreshes hot place_cache entries expiring in <7 days, evicts entries untouched in 60+ days. Runs Sun 03:00 UTC. |
handlers/crons/cache-health-check.ts | Daily sentinel. Cross-references search_analytics vs place_cache.created_at; emails admin if traffic happened but no cache writes. Runs daily 09:00 UTC. |
Three-table Supabase cache, shipped 2026-05-12 to cut the then-~$1.12 cold cost to ~$0.10 warm. Every cache layer is fail-open: if Supabase errors or env vars are missing, the live pipeline runs unaffected. Failures log with [ALERT] [CACHE-*] prefixes for grep-able alerts.
⚠️ Superseded as the primary cost lever by the reviews fold-in (2026-05-30). Cold cost is now ~$0.19 directly (reviews ride inline on the Text Search call), so the cache layers on top of an already-cheap baseline rather than being the main reducer. Knock-on: place_cache is now dormant — the v2 client path no longer calls /reviews, so nothing writes it; reviews are cached inside searchtext_cache instead. The rebake cron and cache-health-check sentinel below still target place_cache, so they are pending re-evaluation (the sentinel will report "traffic but no place_cache writes" daily until repointed at searchtext_cache).
| Layer | Key | TTL | What it stores |
|---|---|---|---|
place_cache | (place_id, language) | 28 days | Places places.get response — reviews + editorialSummary + priceLevel. Dormant since the 2026-05-30 fold-in: the v2 path no longer writes it. |
searchtext_cache | (geohash6, query, language) | 28 days | Places searchText response — array of place objects, now incl. folded-in reviews + editorialSummary (2026-05-30). Query key includes radius + field-mask version: ${q}|r${km}km|fm${v} (bump L2_FIELDMASK_VERSION on any mask change so stale rows miss). |
search_cache | cache_key | 2 hours | L1 full-search cache. Defined in schema; not wired into pipeline yet. |
Why 28 days, not 30: Google Maps Platform ToS §3.2.3 caps Places content caching at 30 days. 28 leaves a 2-day buffer for the weekly rebake to refresh hot entries before they tip past the limit.
Hit tracking: both place_cache and searchtext_cache have hit_count + last_hit_at columns. Reads fire-and-forget an atomic RPC (increment_place_cache_hit, increment_searchtext_cache_hit) so the rebake cron can distinguish hot vs cold without affecting read latency.
Rebake logic (handlers/crons/refresh-place-cache.ts, Sun 03:00 UTC):
place_cache rows where last_hit_at > now − 60d AND expires_at < now + 7d. Max 500 per run, parallelism 5.last_hit_at < now − 60d from BOTH place_cache AND searchtext_cache.Cache hit observability: failure logs use [ALERT] [CACHE-*] prefix. Free-tier alert: /api/crons/cache-health-check runs daily 09:00 UTC; emails admin if search_analytics shows traffic but no cache writes happened (catches the exact failure class — silent fail-open — that caused weeks of invisible breakage before 2026-05-12).
lib/scoring.ts, services/geminiService.ts, lib/search/types.ts, lib/search/schemas.ts, lib/api/ai-client.ts, all four endpoint files in api/search/ (reviews.ts now legacy, unused by the v2 path), the handler at handlers/search/understand.ts, and the architecture section of CLAUDE.md.
Pipeline currently runs on gemini-flash-lite-latest, an alias Google re-points without warning: it resolved to gemini-3.1-flash-lite ($0.25/M input + $1.50/M output) from 2026-05-09, and to gemini-3.5-flash-lite ($0.30/M input + $2.50/M output) since 2026-07-29. That flip raised output price ~67% with no deploy on our side, and also broke every Gemini call until thinkingBudget: 0 was replaced by thinkingLevel: 'minimal' (Gemini 3.x dropped thinking_budget). Measured on one real cold search (2026-07-29): the Gemini share is ~$0.035 ($0.0346 at 3.5 prices vs $0.0252 for the identical tokens at 3.1), a blended +38% rather than +67%, because analyze runs about 10:1 input-to-output so input is 54% of the bill. The Places share is unchanged and still dominates. Reviews fold-in (2026-05-30) cut per-search cost ~$1.4–1.6 cold to ~$0.19 (~88%) in the architecture: reviews ride inline on the discover Text Search call (Enterprise+Atmosphere $0.040/call), eliminating the per-place places.get fan-out. SKU note: places.reviews is an Atmosphere field, so a places.get carrying reviews was Enterprise+Atmosphere $0.025/call (not the plain Enterprise $0.020 previously cited). The Supabase cache (Phase 1–4, shipped 2026-05-12) still layers on top for repeat geohashes. See section 9.
Scoring is V4 (query-aware multi-factor) as of 2026-05-09 — replacing V3 (dishSentiment-only).
Review sampling is stratified (≤2 negative slots reserved) and bestCon uses a 3-level cascade (1-3★ primary → 4-5★ critique fragment fallback → empty), instrumented with per-restaurant EmptyCon diagnostics — landed 2026-05-09 after audit showed pure rating-desc sampling was chopping the negative tail before Gemini saw it.