Skip to content

Commit e38c037

Browse files
committed
Add basic POI search.
Signed-off-by: Katharine Berry <ktbry@google.com>
1 parent ab79a80 commit e38c037

File tree

3 files changed

+199
-6
lines changed

3 files changed

+199
-6
lines changed

service/assistant/functions/poi.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package functions
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"github.com/honeycombio/beeline-go"
21+
"github.com/pebble-dev/bobby-assistant/service/assistant/query"
22+
"github.com/pebble-dev/bobby-assistant/service/assistant/quota"
23+
"github.com/pebble-dev/bobby-assistant/service/assistant/util/mapbox"
24+
"google.golang.org/genai"
25+
"log"
26+
"net/url"
27+
)
28+
29+
type POIQuery struct {
30+
Location string
31+
Query string
32+
}
33+
34+
type POI struct {
35+
Name string
36+
Address string
37+
Categories []string
38+
OpeningHours map[string]string
39+
}
40+
41+
type POIResponse struct {
42+
Results []POI
43+
}
44+
45+
func init() {
46+
registerFunction(Registration{
47+
Definition: genai.FunctionDeclaration{
48+
Name: "poi",
49+
Description: "Look up points of interest near the user's location (or another named location).",
50+
Parameters: &genai.Schema{
51+
Type: genai.TypeObject,
52+
Nullable: false,
53+
Properties: map[string]*genai.Schema{
54+
"query": {
55+
Type: genai.TypeString,
56+
Description: "The search query to use to find points of interest. Could be a name (e.g. \"McDonald's\"), a category (e.g. \"restaurant\" or \"pizza\"), or another search term.",
57+
Nullable: false,
58+
},
59+
"location": {
60+
Type: genai.TypeString,
61+
Description: "The name of the location to search near. If not provided, the user's current location will be used. Assume that no location should be provided unless explicitly requested: not providing one results in more accurate answers.",
62+
Nullable: true,
63+
},
64+
},
65+
Required: []string{"query"},
66+
},
67+
},
68+
Fn: searchPoi,
69+
Thought: searchPoiThought,
70+
InputType: POIQuery{},
71+
})
72+
}
73+
74+
func searchPoiThought(args interface{}) string {
75+
return "Looking around..."
76+
}
77+
78+
func searchPoi(ctx context.Context, quotaTracker *quota.Tracker, args interface{}) interface{} {
79+
ctx, span := beeline.StartSpan(ctx, "search_poi")
80+
defer span.Send()
81+
poiQuery := args.(*POIQuery)
82+
span.AddField("query", poiQuery.Query)
83+
qs := url.Values{}
84+
location := query.LocationFromContext(ctx)
85+
if poiQuery.Location != "" {
86+
vals := url.Values{}
87+
vals.Set("types", "place,district,region,country,locality,neighborhood")
88+
if location != nil {
89+
vals.Set("proximity", fmt.Sprintf("%f,%f", location.Lon, location.Lat))
90+
}
91+
collection, err := mapbox.GeocodingRequest(ctx, poiQuery.Location, vals)
92+
if err != nil {
93+
return Error{Error: fmt.Sprintf("Failed to geocode %q", poiQuery.Location)}
94+
}
95+
if len(collection.Features) == 0 {
96+
return Error{Error: fmt.Sprintf("No location found for %q", poiQuery.Location)}
97+
}
98+
location = &query.Location{
99+
Lon: collection.Features[0].Center[0],
100+
Lat: collection.Features[0].Center[1],
101+
}
102+
}
103+
qs.Set("q", poiQuery.Query)
104+
qs.Set("proximity", fmt.Sprintf("%f,%f", location.Lon, location.Lat))
105+
106+
log.Printf("Searching for POIs matching %q", poiQuery.Query)
107+
_ = quotaTracker.ChargeCredits(ctx, quota.PoiSearchCredits)
108+
results, err := mapbox.SearchBoxRequest(ctx, qs)
109+
if err != nil {
110+
span.AddField("error", err)
111+
log.Printf("Failed to search for POIs: %v", err)
112+
return Error{Error: err.Error()}
113+
}
114+
log.Printf("Found %d POIs", len(results.Features))
115+
116+
var pois []POI
117+
for _, feature := range results.Features {
118+
poi := POI{
119+
Name: feature.Properties.Name,
120+
Address: feature.Properties.Address,
121+
Categories: feature.Properties.POICategory,
122+
OpeningHours: make(map[string]string),
123+
}
124+
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
125+
for _, period := range feature.Properties.Metadata.OpenHours.Periods {
126+
poi.OpeningHours[days[period.Open.Day]] = fmt.Sprintf("%s - %s", period.Open.Time, period.Close.Time)
127+
}
128+
for _, day := range days {
129+
if _, ok := poi.OpeningHours[day]; !ok {
130+
poi.OpeningHours[day] = "Closed"
131+
}
132+
}
133+
pois = append(pois, poi)
134+
}
135+
136+
return &POIResponse{
137+
Results: pois,
138+
}
139+
}

service/assistant/quota/quota.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import (
2828
const InputTokenCredits = 1
2929
const OutputTokenCredits = 4
3030
const WeatherQueryCredits = 5_250
31+
const PoiSearchCredits = 17_000
32+
const RouteCalculationCredits = 20_000
3133
const MonthlyQuotaCredits = 20_000_000
3234

3335
type Tracker struct {

service/assistant/util/mapbox/mapbox.go

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,41 @@ type FeatureCollection struct {
1414
}
1515

1616
type Feature struct {
17-
ID string `json:"id"`
18-
PlaceType []string `json:"place_type"`
19-
Text string `json:"text"`
20-
Relevance float64 `json:"relevance"`
21-
PlaceName string `json:"place_name"`
22-
Center []float64 `json:"center"`
17+
ID string `json:"id"`
18+
PlaceType []string `json:"place_type"`
19+
Text string `json:"text"`
20+
Relevance float64 `json:"relevance"`
21+
PlaceName string `json:"place_name"`
22+
Center []float64 `json:"center"`
23+
Properties Properties `json:"properties"`
24+
}
25+
26+
type Properties struct {
27+
Name string `json:"name"`
28+
Address string `json:"address"`
29+
POICategory []string `json:"poi_category"`
30+
Metadata Metadata `json:"metadata"`
31+
Distance int `json:"distance"`
32+
}
33+
34+
type Metadata struct {
35+
Phone string `json:"phone"`
36+
Website string `json:"website"`
37+
OpenHours OpenHours `json:"open_hours"`
38+
}
39+
40+
type OpenHours struct {
41+
Periods []Period `json:"periods"`
42+
}
43+
44+
type Period struct {
45+
Open TimePoint `json:"open"`
46+
Close TimePoint `json:"close"`
47+
}
48+
49+
type TimePoint struct {
50+
Day int `json:"day"`
51+
Time string `json:"time"`
2352
}
2453

2554
func GeocodingRequest(ctx context.Context, search string, params url.Values) (*FeatureCollection, error) {
@@ -47,3 +76,26 @@ func GeocodingRequest(ctx context.Context, search string, params url.Values) (*F
4776
}
4877
return &collection, nil
4978
}
79+
80+
func SearchBoxRequest(ctx context.Context, params url.Values) (*FeatureCollection, error) {
81+
ctx, span := beeline.StartSpan(ctx, "mapbox.searchbox")
82+
defer span.Send()
83+
params.Set("access_token", config.GetConfig().MapboxKey)
84+
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.mapbox.com/search/searchbox/v1/forward?"+params.Encode(), nil)
85+
if err != nil {
86+
span.AddField("error", err)
87+
return nil, err
88+
}
89+
resp, err := http.DefaultClient.Do(req)
90+
if err != nil {
91+
span.AddField("error", err)
92+
return nil, err
93+
}
94+
defer resp.Body.Close()
95+
var collection FeatureCollection
96+
if err := json.NewDecoder(resp.Body).Decode(&collection); err != nil {
97+
span.AddField("error", err)
98+
return nil, err
99+
}
100+
return &collection, nil
101+
}

0 commit comments

Comments
 (0)