From b28a678c4cfa3b455bd795c66df0071e8f52b701 Mon Sep 17 00:00:00 2001 From: tim-eternos Date: Tue, 11 Aug 2026 22:33:52 -0700 Subject: [PATCH] Derive LocationType from the primary Google type for keyword searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyword (brand) searches query Google with LocationTypeAny, so parsePlacesSearchResponse stamped every result with LocationType "". The write path is a blind upsert into the shared placeDetails:{id} records that category reads hydrate from, so each brand search wiped the type off previously typed records — a McDonald's cached by an Eatery search was then served with no type, and downstream consumers (OfferBee best-card) scored it at base rate instead of dining. Two changes, both gap-filling only: - parsePlacesSearchResponse: when the searched type is LocationTypeAny, derive LocationType from POI.PrimaryLocationType(place.Types) — the same machinery ReclassifyForCategory already uses on category reads. Typed searches keep their searched-type stamp. - restoreCachedDetails: restore the stored LocationType when the rebuilt record has none, same "fill a gap, never overwrite" rule as URL/Summary/Address/Hours. Existing blank-typed records stay blank until a fresh search rewrites them; this stops new wipes and types new keyword results at the source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011BmBJ2x6b7V36vywSMrYDy --- iowrappers/nearby_search.go | 11 +++++ iowrappers/nearby_search_test.go | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/iowrappers/nearby_search.go b/iowrappers/nearby_search.go index 8c619446..8c80e8ab 100644 --- a/iowrappers/nearby_search.go +++ b/iowrappers/nearby_search.go @@ -364,6 +364,9 @@ func restoreCachedDetails(places []POI.Place, cached map[string]POI.Place) { if places[i].Address == (POI.Address{}) { places[i].Address = stored.Address } + if places[i].LocationType == POI.LocationTypeAny { + places[i].LocationType = stored.LocationType + } if !places[i].HasRealOpeningHours() && stored.HasRealOpeningHours() { places[i].Hours = stored.Hours } @@ -501,6 +504,14 @@ func parsePlacesSearchResponse(resp maps.PlacesSearchResponse, locationType POI. // Preserve Google's actual feature types so callers can classify the place by // its primary function, not the type this search happened to query for. place.Types = res.Types + // Keyword (brand) searches query with LocationTypeAny, so the searched-type + // stamp above is blank — and the blind-upsert write path would cache (and + // overwrite typed records with) LocationType "". Derive the real type from + // the place's primary Google feature type instead. Typed searches keep their + // stamp: re-tagging those is ReclassifyForCategory's job on the category paths. + if locationType == POI.LocationTypeAny { + place.LocationType = POI.PrimaryLocationType(place.Types) + } places = append(places, place) } return diff --git a/iowrappers/nearby_search_test.go b/iowrappers/nearby_search_test.go index e04f09a8..92659165 100644 --- a/iowrappers/nearby_search_test.go +++ b/iowrappers/nearby_search_test.go @@ -164,6 +164,78 @@ func TestSelectPlacesForDetailsSkipsCachedPlaces(t *testing.T) { }) } +// TestParsePlacesSearchResponseKeywordDerivesLocationType covers the keyword (brand) search +// path: it queries Google with LocationTypeAny, so stamping results with the searched type +// caches LocationType "" — and the blind-upsert write path then wipes the type off any +// previously typed record (a McDonald's cached by an Eatery search served with no type). +// The parser must fall back to the place's primary Google feature type instead. +func TestParsePlacesSearchResponseKeywordDerivesLocationType(t *testing.T) { + resp := maps.PlacesSearchResponse{ + Results: []maps.PlacesSearchResult{ + { + Name: "McDonald's", + PlaceID: "mcd", + Geometry: maps.AddressGeometry{Location: maps.LatLng{Lat: 37.32, Lng: -122.03}}, + Types: []string{"meal_takeaway", "food", "point_of_interest", "establishment"}, + UserRatingsTotal: 100, + }, + { // umbrella types only: no meaningful primary type to derive + Name: "Mystery Venue", + PlaceID: "untyped", + Geometry: maps.AddressGeometry{Location: maps.LatLng{Lat: 37.33, Lng: -122.04}}, + Types: []string{"point_of_interest", "establishment"}, + UserRatingsTotal: 5, + }, + }, + } + + t.Run("a keyword search derives the type from the place's primary Google type", func(t *testing.T) { + places := parsePlacesSearchResponse(resp, POI.LocationTypeAny, nil, map[string]bool{}, nil, nil) + if len(places) != 2 { + t.Fatalf("expect 2 places parsed, got %d", len(places)) + } + if places[0].LocationType != POI.LocationTypeMealTakeaway { + t.Errorf("LocationType = %q, want %q derived from Types", places[0].LocationType, POI.LocationTypeMealTakeaway) + } + if places[1].LocationType != POI.LocationTypeAny { + t.Errorf("LocationType = %q, want empty when Types has no meaningful primary", places[1].LocationType) + } + }) + + t.Run("a typed search keeps its searched-type stamp", func(t *testing.T) { + // Re-tagging typed searches is ReclassifyForCategory's job on the category + // paths; the parser must not start second-guessing it here. + places := parsePlacesSearchResponse(resp, POI.LocationTypeRestaurant, nil, map[string]bool{}, nil, nil) + if places[0].LocationType != POI.LocationTypeRestaurant { + t.Errorf("LocationType = %q, want the searched type %q", places[0].LocationType, POI.LocationTypeRestaurant) + } + }) +} + +// TestRestoreCachedDetailsPreservesLocationType covers the other half of the same bug: a +// keyword result that still ends up untyped (no meaningful Types) must not blank out the +// LocationType a category search already stored — restore only ever fills a gap. +func TestRestoreCachedDetailsPreservesLocationType(t *testing.T) { + stored := storedPlace("near", time.Now()) + stored.SetType(POI.LocationTypeRestaurant) + + t.Run("an untyped rebuilt place recovers the stored type", func(t *testing.T) { + places := []POI.Place{{ID: "near"}} + restoreCachedDetails(places, map[string]POI.Place{"near": stored}) + if places[0].LocationType != POI.LocationTypeRestaurant { + t.Errorf("LocationType = %q, want stored %q", places[0].LocationType, POI.LocationTypeRestaurant) + } + }) + + t.Run("a typed rebuilt place keeps its fresh type", func(t *testing.T) { + places := []POI.Place{{ID: "near", LocationType: POI.LocationTypeMealTakeaway}} + restoreCachedDetails(places, map[string]POI.Place{"near": stored}) + if places[0].LocationType != POI.LocationTypeMealTakeaway { + t.Errorf("LocationType = %q, want fresh %q kept", places[0].LocationType, POI.LocationTypeMealTakeaway) + } + }) +} + // TestRestoreCachedDetails pins the half of the optimisation that protects the data: the write // path is a blind upsert, so a place whose Details call was skipped must not be written back // stripped of the fields it was skipped because of.