Skip to content

Commit a96807a

Browse files
authored
Merge pull request #449 from timwangmusic/feat/place-text-search
feat: place text-search with durable cache insert + unified type classification
2 parents 836ec2b + 2a61462 commit a96807a

13 files changed

Lines changed: 2357 additions & 81 deletions

POI/categories.go

Lines changed: 166 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package POI
33
import (
44
"fmt"
55
"math"
6+
"sort"
67
"strings"
78
)
89

@@ -33,34 +34,137 @@ type LocationType string
3334

3435
const (
3536
// LocationTypeAny leaves the Google Maps place type unset, used by keyword (brand) searches
36-
LocationTypeAny = LocationType("")
37-
LocationTypeCafe = LocationType("cafe")
38-
LocationTypeRestaurant = LocationType("restaurant")
39-
LocationTypeBar = LocationType("bar")
40-
LocationTypeBakery = LocationType("bakery")
41-
LocationTypeMealTakeaway = LocationType("meal_takeaway")
42-
LocationTypeMuseum = LocationType("museum")
43-
LocationTypeGallery = LocationType("art_gallery")
44-
LocationTypeAmusementPark = LocationType("amusement_park")
45-
LocationTypePark = LocationType("park")
37+
LocationTypeAny = LocationType("")
38+
// Eatery place types
39+
LocationTypeCafe = LocationType("cafe")
40+
LocationTypeRestaurant = LocationType("restaurant")
41+
LocationTypeBar = LocationType("bar")
42+
LocationTypeBakery = LocationType("bakery")
43+
LocationTypeMealTakeaway = LocationType("meal_takeaway")
44+
LocationTypeMealDelivery = LocationType("meal_delivery")
45+
LocationTypeNightClub = LocationType("night_club")
46+
// Visit place types
47+
LocationTypeMuseum = LocationType("museum")
48+
LocationTypeGallery = LocationType("art_gallery")
49+
LocationTypeAmusementPark = LocationType("amusement_park")
50+
LocationTypePark = LocationType("park")
51+
LocationTypeTouristAttraction = LocationType("tourist_attraction")
52+
LocationTypeZoo = LocationType("zoo")
53+
LocationTypeAquarium = LocationType("aquarium")
54+
LocationTypeMovieTheater = LocationType("movie_theater")
55+
LocationTypeStadium = LocationType("stadium")
56+
LocationTypeBowlingAlley = LocationType("bowling_alley")
4657
// Shopping place types
4758
LocationTypeShoppingMall = LocationType("shopping_mall")
4859
LocationTypeDepartmentStore = LocationType("department_store")
4960
LocationTypeSupermarket = LocationType("supermarket")
5061
LocationTypeClothingStore = LocationType("clothing_store")
5162
LocationTypeStore = LocationType("store")
63+
// LocationTypeGroceryOrSupermarket is a types[]-only value: it appears in a place's
64+
// Types list but is NOT a legal ?type= value for the legacy Nearby Search
65+
// (maps.ParsePlaceType rejects it). Never pass it to CreateMapSearchRequest or add it to
66+
// GetPlaceTypes; it is matched only via PrimaryLocationType/GetPlaceCategory.
67+
LocationTypeGroceryOrSupermarket = LocationType("grocery_or_supermarket")
68+
// Any type that is a strict specialization of the already-mapped `store` goes to Shopping.
69+
LocationTypeConvenienceStore = LocationType("convenience_store")
70+
LocationTypeHardwareStore = LocationType("hardware_store")
71+
LocationTypeHomeGoodsStore = LocationType("home_goods_store")
72+
LocationTypeElectronicsStore = LocationType("electronics_store")
73+
LocationTypeFurnitureStore = LocationType("furniture_store")
74+
LocationTypeBookStore = LocationType("book_store")
75+
LocationTypeShoeStore = LocationType("shoe_store")
76+
LocationTypeJewelryStore = LocationType("jewelry_store")
77+
LocationTypePetStore = LocationType("pet_store")
78+
LocationTypeBicycleStore = LocationType("bicycle_store")
79+
LocationTypeFlorist = LocationType("florist")
80+
LocationTypeLiquorStore = LocationType("liquor_store")
81+
LocationTypeGasStation = LocationType("gas_station")
5282
// Lodging place types
5383
LocationTypeLodging = LocationType("lodging")
5484
// Wellness place types
55-
LocationTypeGym = LocationType("gym")
56-
LocationTypeSpa = LocationType("spa")
57-
LocationTypePharmacy = LocationType("pharmacy")
85+
LocationTypeGym = LocationType("gym")
86+
LocationTypeSpa = LocationType("spa")
87+
LocationTypePharmacy = LocationType("pharmacy")
88+
LocationTypeDrugstore = LocationType("drugstore")
89+
LocationTypeBeautySalon = LocationType("beauty_salon")
90+
LocationTypeHairCare = LocationType("hair_care")
5891
)
5992

93+
// placeTypeToCategory is the reverse map from a Google place type to its category. It backs
94+
// both GetPlaceCategory (the write path / classification rule) and ReclassifyForCategory (the
95+
// read filter) below, so there is exactly one table that decides "what category does this
96+
// Google type belong to" anywhere in the service.
97+
//
98+
// It is a SUPERSET of GetPlaceTypes' inverse: it covers every Google primary type (see
99+
// PrimaryLocationType) this service knows how to classify, not only the types the nearby-search
100+
// endpoints actively query for (GetPlaceTypes' 18 searched types are all present here too).
101+
// Widening this map only ever makes ReclassifyForCategory keep MORE places, never fewer — see
102+
// TestReclassifyForCategoryKeepsAllFormerlySearchedTypes.
103+
var placeTypeToCategory = map[LocationType]PlaceCategory{
104+
// Eatery
105+
LocationTypeCafe: PlaceCategoryEatery,
106+
LocationTypeRestaurant: PlaceCategoryEatery,
107+
LocationTypeBar: PlaceCategoryEatery,
108+
LocationTypeBakery: PlaceCategoryEatery,
109+
LocationTypeMealTakeaway: PlaceCategoryEatery,
110+
LocationTypeMealDelivery: PlaceCategoryEatery,
111+
LocationTypeNightClub: PlaceCategoryEatery,
112+
113+
// Visit
114+
LocationTypePark: PlaceCategoryVisit,
115+
LocationTypeAmusementPark: PlaceCategoryVisit,
116+
LocationTypeGallery: PlaceCategoryVisit,
117+
LocationTypeMuseum: PlaceCategoryVisit,
118+
LocationTypeTouristAttraction: PlaceCategoryVisit,
119+
LocationTypeZoo: PlaceCategoryVisit,
120+
LocationTypeAquarium: PlaceCategoryVisit,
121+
LocationTypeMovieTheater: PlaceCategoryVisit,
122+
LocationTypeStadium: PlaceCategoryVisit,
123+
LocationTypeBowlingAlley: PlaceCategoryVisit,
124+
125+
// Shopping. Any type that is a strict specialization of the already-mapped `store` goes
126+
// to Shopping.
127+
LocationTypeShoppingMall: PlaceCategoryShopping,
128+
LocationTypeDepartmentStore: PlaceCategoryShopping,
129+
LocationTypeSupermarket: PlaceCategoryShopping,
130+
LocationTypeClothingStore: PlaceCategoryShopping,
131+
LocationTypeStore: PlaceCategoryShopping,
132+
LocationTypeGroceryOrSupermarket: PlaceCategoryShopping,
133+
LocationTypeConvenienceStore: PlaceCategoryShopping,
134+
LocationTypeHardwareStore: PlaceCategoryShopping,
135+
LocationTypeHomeGoodsStore: PlaceCategoryShopping,
136+
LocationTypeElectronicsStore: PlaceCategoryShopping,
137+
LocationTypeFurnitureStore: PlaceCategoryShopping,
138+
LocationTypeBookStore: PlaceCategoryShopping,
139+
LocationTypeShoeStore: PlaceCategoryShopping,
140+
LocationTypeJewelryStore: PlaceCategoryShopping,
141+
LocationTypePetStore: PlaceCategoryShopping,
142+
LocationTypeBicycleStore: PlaceCategoryShopping,
143+
LocationTypeFlorist: PlaceCategoryShopping,
144+
LocationTypeLiquorStore: PlaceCategoryShopping,
145+
LocationTypeGasStation: PlaceCategoryShopping,
146+
147+
// Lodging
148+
LocationTypeLodging: PlaceCategoryLodging,
149+
150+
// Wellness
151+
LocationTypeGym: PlaceCategoryWellness,
152+
LocationTypeSpa: PlaceCategoryWellness,
153+
LocationTypePharmacy: PlaceCategoryWellness,
154+
LocationTypeDrugstore: PlaceCategoryWellness,
155+
LocationTypeBeautySalon: PlaceCategoryWellness,
156+
LocationTypeHairCare: PlaceCategoryWellness,
157+
158+
// LocationTypeAny ("") is deliberately NOT a key: GetPlaceCategory("") must stay
159+
// ("", false), the same as any other unmapped type.
160+
}
161+
60162
// GetPlaceCategory maps a Google Maps place type back to its category, reporting whether
61-
// the type is mapped at all. It is the inverse of GetPlaceTypes and MUST stay consistent
62-
// with it: the nearby-search cache writes each place under
63-
// EncodeNearbySearchRedisKey(GetPlaceCategory(place.LocationType), ...), so a type that
163+
// the type is mapped at all. placeTypeToCategory (above) is now a SUPERSET of GetPlaceTypes'
164+
// inverse, not its exact inverse — it classifies every primary type this service recognizes,
165+
// while GetPlaceTypes still only lists the subset each category's Nearby Search issues as
166+
// ?type=. The write path relies on the shared subset: the nearby-search cache writes each place
167+
// under EncodeNearbySearchRedisKey(GetPlaceCategory(place.LocationType), ...), so a type that
64168
// resolves to a different category than the one it was searched under would never cache-hit.
65169
//
66170
// It deliberately has NO default category. An earlier version defaulted to Eatery, which
@@ -69,24 +173,34 @@ const (
69173
// GetPlaceTypes(Eatery), Google ignored the unenforceable filter, and prominence-ranked
70174
// hotels were written into the eatery geo buckets. Returning ok=false forces every caller
71175
// to decide what an unmapped type means, and makes TestPlaceCategoryRoundTrip able to fail.
176+
//
177+
// DO NOT ADD: fast_food_restaurant, food_court. Both are Places API (New)-only values that
178+
// never appear in a legacy Nearby Search result's types[], so adding them re-opens the exact
179+
// guard class TestGetPlaceCategoryRejectsUnknownTypes exists for.
72180
func GetPlaceCategory(placeType LocationType) (PlaceCategory, bool) {
73-
switch placeType {
74-
case LocationTypePark, LocationTypeAmusementPark, LocationTypeGallery, LocationTypeMuseum:
75-
return PlaceCategoryVisit, true
76-
case LocationTypeCafe, LocationTypeRestaurant, LocationTypeBar, LocationTypeBakery, LocationTypeMealTakeaway:
77-
return PlaceCategoryEatery, true
78-
case LocationTypeShoppingMall, LocationTypeDepartmentStore, LocationTypeSupermarket, LocationTypeClothingStore, LocationTypeStore:
79-
return PlaceCategoryShopping, true
80-
case LocationTypeLodging:
81-
return PlaceCategoryLodging, true
82-
case LocationTypeGym, LocationTypeSpa, LocationTypePharmacy:
83-
return PlaceCategoryWellness, true
84-
default:
85-
return PlaceCategory(""), false
181+
cat, ok := placeTypeToCategory[placeType]
182+
return cat, ok
183+
}
184+
185+
// MappedLocationTypes returns every LocationType classified by GetPlaceCategory, sorted for
186+
// deterministic iteration. Exported for tests (e.g. TestGetPlaceCategoryKeysAreGoogleTypes),
187+
// which need to walk the full map without depending on Go's randomized map iteration order.
188+
func MappedLocationTypes() []LocationType {
189+
keys := make([]LocationType, 0, len(placeTypeToCategory))
190+
for t := range placeTypeToCategory {
191+
keys = append(keys, t)
86192
}
193+
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
194+
return keys
87195
}
88196

89-
// GetPlaceTypes returns a set of types defined in Google Maps API given a location type
197+
// GetPlaceTypes returns a set of types defined in Google Maps API given a location type. This
198+
// is the SEARCHED subset: the exact place types each category's Nearby Search issues as ?type=.
199+
// It is intentionally unchanged by the classification-map expansion above — widening it changes
200+
// the outbound Google query for every existing search, not just how a result gets classified,
201+
// and is how the fast_food_restaurant incident happened (a type the legacy API doesn't
202+
// understand, silently ignored by Google, poisoning the eatery cache). Add new types to
203+
// placeTypeToCategory / GetPlaceCategory instead of here.
90204
func GetPlaceTypes(placeCat PlaceCategory) (placeTypes []LocationType) {
91205
switch placeCat {
92206
case PlaceCategoryVisit:
@@ -161,25 +275,35 @@ func PrimaryLocationType(types []string) LocationType {
161275
return LocationType("")
162276
}
163277

164-
// ReclassifyForCategory decides whether a place belongs in cat based on its
165-
// PRIMARY function, and returns the place re-tagged with that primary type.
278+
// ReclassifyForCategory decides whether a place belongs in cat based on its PRIMARY function,
279+
// and returns the place re-tagged with that primary type. It now keys on the same
280+
// placeTypeToCategory map as GetPlaceCategory — the same map the nearby-search write path
281+
// (SetPlacesAddGeoLocations) and the bucket-cleanup migration
282+
// (RemoveMisclassifiedPlacesFromCategoryBuckets) key on — so there is one rule everywhere for
283+
// "does this place belong in this category":
166284
//
167-
// - primary type is one of cat's search types → keep, LocationType := primary
285+
// - primary type maps to cat → keep, LocationType := primary
168286
// (e.g. a "cafe"-searched result that is really a restaurant is re-tagged).
169-
// - primary type is known but NOT in cat → drop (keep=false): its main
287+
// - primary type maps to a DIFFERENT category → drop (keep=false): its main
170288
// function is something else (a supermarket the food search returned).
171-
// - no Types on the place (older cached records) → keep unchanged, so coverage
172-
// never regresses on data written before Types was captured.
289+
// - no Types at all (empty primary) → keep unchanged (older cached records), so
290+
// coverage never regresses on data written before Types was captured.
291+
// - primary type is present but unmapped → drop (keep=false), same as the old rule:
292+
// an unmapped primary was never a member of GetPlaceTypes(cat) either, so this is not a
293+
// behavior change from before the placeTypeToCategory unification.
294+
//
295+
// Because placeTypeToCategory is a strict superset of GetPlaceTypes' searched types (see
296+
// GetPlaceCategory's docstring), this keeps every place the old primary-in-GetPlaceTypes(cat)
297+
// rule kept, plus more — never fewer. TestReclassifyForCategoryKeepsAllFormerlySearchedTypes
298+
// pins that monotonicity.
173299
func ReclassifyForCategory(place Place, cat PlaceCategory) (Place, bool) {
174300
primary := PrimaryLocationType(place.Types)
175301
if primary == LocationType("") {
176-
return place, true
302+
return place, true // records with no Types stay kept (older cache entries)
177303
}
178-
for _, t := range GetPlaceTypes(cat) {
179-
if t == primary {
180-
place.LocationType = primary
181-
return place, true
182-
}
304+
if c, ok := GetPlaceCategory(primary); ok && c == cat {
305+
place.LocationType = primary // re-tag with the true type
306+
return place, true
183307
}
184308
return place, false
185309
}

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,128 @@ will incorporate personalized recommendations.
2020
* View trip details
2121
* Make a plan yourself by creating a template
2222

23+
## Place Search API
24+
25+
Two endpoints let a caller add a place to the shared cache by name, as an alternative to the
26+
category-based nearby-places search: search by free text, then confirm one result into the
27+
cache.
28+
29+
### `POST /v1/place-search`
30+
31+
Runs a free-text Google Places search around a coordinate and returns every result as a
32+
confirmable candidate; nothing is written to the shared cache by this call. Every result
33+
(insertable or not) is stashed server-side under its Google place ID for 30 minutes so a
34+
subsequent confirm call can resolve it by ID alone rather than trusting place data an HTTP
35+
caller might send back.
36+
37+
Request:
38+
39+
```json
40+
{
41+
"query": "Joe's Pizza",
42+
"location": {"latitude": 40.7309, "longitude": -74.0021},
43+
"radius": 5000,
44+
"limit": 10
45+
}
46+
```
47+
48+
`query` is required (2-120 characters). `location` (`latitude`/`longitude`) is required, with
49+
no default — same as the nearby-places endpoints' own zero-location rejection — because an
50+
unbiased text query like "konjoe" can resolve to the wrong continent without a coordinate to
51+
anchor it. `radius` is in meters and is clamped to the service's max search radius (16,000 m /
52+
~10 miles) when zero or larger than that maximum. `limit` defaults to 10 and is capped at 20.
53+
54+
Response (`200`, fields elided for brevity):
55+
56+
```json
57+
{
58+
"results": [
59+
{
60+
"place": {
61+
"ID": "ChIJd8BlQ2BZwokRAFUEcm_qrcA",
62+
"Name": "Joe's Pizza",
63+
"Status": "OPERATIONAL",
64+
"LocationType": "restaurant",
65+
"Types": ["restaurant", "food", "point_of_interest", "establishment"],
66+
"FormattedAddress": "7 Carmine St, New York, NY 10014",
67+
"Location": {"latitude": 40.7309, "longitude": -74.0021, "city": "", "adminAreaLevelOne": "", "country": ""},
68+
"PriceLevel": 1,
69+
"Rating": 4.5,
70+
"UserRatingsTotal": 3200
71+
},
72+
"category": "Eatery",
73+
"insertable": true
74+
}
75+
]
76+
}
77+
```
78+
79+
`category` and `insertable` are always derived server-side from the place's own Google types.
80+
A candidate whose primary type does not map to a known category is still returned (so the
81+
caller can see it), but with `category: ""` and `insertable: false`.
82+
83+
### `POST /v1/place-search/confirm`
84+
85+
Inserts a previously returned candidate into the shared cache — `placeIDs:<category>` plus a
86+
`place_details:place_ID:*` record — making one Place Details call to fill in hours, address,
87+
URL, summary, and (as a gap-fill only, never overwriting an existing photo) photo before
88+
writing.
89+
90+
Request:
91+
92+
```json
93+
{"placeId": "ChIJd8BlQ2BZwokRAFUEcm_qrcA"}
94+
```
95+
96+
Response (`200`):
97+
98+
```json
99+
{
100+
"place": { "ID": "ChIJd8BlQ2BZwokRAFUEcm_qrcA", "...": "same shape as place-search's place object, now enriched with hours/URL/summary" },
101+
"category": "Eatery",
102+
"alreadyCached": false
103+
}
104+
```
105+
106+
Error responses:
107+
108+
* `404` `{"error": "...", "code": "candidate_expired"}` — the place ID was never searched, or
109+
its 30-minute stash entry expired.
110+
* `422` `{"error": "...", "code": "unsupported_place_type", "placeType": "<google type>"}`
111+
the candidate's primary Google type does not map to any category; nothing is written.
112+
113+
#### Visibility
114+
115+
Confirming a place writes it into the shared Redis cache immediately — you can verify this
116+
directly, without waiting on any other endpoint: the confirm response itself echoes the
117+
written place, and `ZSCORE placeIDs:<category> <placeID>` against Redis returns a score right
118+
away.
119+
120+
Whether the place then shows up in `/v1/nearby-places-by-category` depends on that cell's
121+
search freshness, not on the write above. In a **warm** cell (one whose `MapsLastSearchTime`
122+
marker is still fresh), the confirmed place appears on the very next read. In a **cold or
123+
stale** cell, the next read triggers a background Google search whose results replace — not
124+
merge with — the cached bucket in that response, so the newly confirmed place is briefly
125+
missing from that one response and only appears from the following read onward. When
126+
verifying a confirm in a cell you are not sure is warm, check `ZSCORE` first and expect to
127+
need up to two reads of `/v1/nearby-places-by-category` before the place shows up.
128+
129+
Never manually stamp a cell's `MapsLastSearchTime` to force this — doing so marks the cell
130+
"searched" for 14 days and would suppress a real cold search the cell still needs.
131+
132+
### Authentication
133+
134+
Both endpoints require the same authentication as the other `/v1` endpoints: a Personal
135+
Access Token via `Authorization: Bearer <token>`, or a JWT session cookie as a browser
136+
fallback.
137+
138+
### Safety design
139+
140+
The category a place lands under is always computed server-side from Google's own primary
141+
type on the place, never accepted from the caller, and a primary type that maps to no known
142+
category is refused outright rather than defaulted into some bucket — so nothing
143+
client-supplied ever reaches the shared place cache unclassified.
144+
23145
## Installation (Mac)
24146

25147
* git clone the repository

0 commit comments

Comments
 (0)