feat: add place search and routing tools

Add search_places and places_nearby over the app's Google Places routes,
and plan_route over its OpenStreetMap routing. The place search service
reports when no search key is configured, so an unconfigured server is
not mistaken for an empty result, and rate limits become retry_later.
Route endpoints accept a place name or coordinates.
This commit is contained in:
Sameh Khamis committed 2026-10-01 12:16:01 -07:00
1 parent 85fee59da1
commit e680a59bf5
14 files changed
+596 -23

No files matched your search

+8 -12
View File
@@ -8,7 +8,13 @@ import {
distanceKm,
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import {
LIMIT_SCHEMA,
capRows,
countNoun,
isoTime,
thinEvenly,
} from '../results.js';
const ICAO24_SCHEMA = Object.freeze({
type: 'string',
@@ -200,7 +206,7 @@ export const getAircraftTrack = defineTool({
const points = [...track.records].sort(
(a, b) => a.observedAtMs - b.observedAtMs,
);
const kept = thin(points, MAX_TRACK_POINTS);
const kept = thinEvenly(points, MAX_TRACK_POINTS);
const first = points[0];
const last = points.at(-1);
return {
@@ -224,16 +230,6 @@ export const getAircraftTrack = defineTool({
},
});
/** Keep the first and last items and evenly spaced items between them. */
function thin(items, max) {
if (items.length <= max) return items;
const step = (items.length - 1) / (max - 1);
return Array.from(
{ length: max },
(_, index) => items[Math.round(index * step)],
);
}
export const getAircraftInfo = defineTool({
name: 'get_aircraft_info',
title: 'Aircraft type and route',
+158
View File
@@ -0,0 +1,158 @@
/** Place search and routing queries. */
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
POINT_SCHEMA,
areaCenter,
areaContains,
areaRadiusKm,
resolveArea,
resolvePoint,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, thinEvenly } from '../results.js';
const MODES = { walk: 'foot', drive: 'car', bike: 'bike' };
const MAX_ROUTE_POINTS = 100;
const NOT_CONFIGURED =
'Place search needs a Google Places key configured for this server';
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
function placeRow(place) {
return {
id: place.id ?? null,
name: place.name ?? null,
address: place.address ?? null,
type: place.primaryType ?? null,
lat: round(place.latitude, 6),
lon: round(place.longitude, 6),
distance_m: Number.isFinite(place.distanceM) ? place.distanceM : null,
};
}
export const searchPlaces = defineTool({
name: 'search_places',
title: 'Search places',
description:
'Find businesses, landmarks and other points of interest matching a ' +
'query within an area, using Google Places.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', minLength: 1, maxLength: 200 },
area: AREA_SCHEMA,
limit: LIMIT_SCHEMA,
},
required: ['query', 'area'],
additionalProperties: false,
},
requires: ['placeSearch'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area);
const radiusM = Math.round(
Math.min(50000, Math.max(500, areaRadiusKm(area) * 1000)),
);
const result = await services.placeSearch.search(
args.query,
{ latitude: center.lat, longitude: center.lon, radiusM },
{ signal },
);
if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED);
const rows = result.places
.filter((place) =>
areaContains(area, { lat: place.latitude, lon: place.longitude }),
)
.map(placeRow);
return {
summary: `${countNoun(rows.length, 'place')} matching "${args.query}" in ${area.label}.`,
data: capRows(rows, args.limit),
};
},
});
export const placesNearby = defineTool({
name: 'places_nearby',
title: 'Places nearby',
description:
'Notable places around a point within a radius, most notable first, ' +
'using Google Places.',
inputSchema: {
type: 'object',
properties: {
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
radius_m: { type: 'number', minimum: 10, maximum: 5000 },
limit: LIMIT_SCHEMA,
},
required: ['lat', 'lon'],
additionalProperties: false,
},
requires: ['placeSearch'],
async run(args, { services, signal }) {
const radiusM = Math.round(args.radius_m ?? 250);
const result = await services.placeSearch.nearby(
{ latitude: args.lat, longitude: args.lon, radiusM },
{ signal },
);
if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED);
const rows = result.places.map(placeRow);
return {
summary: `${countNoun(rows.length, 'place')} within ${radiusM} m.`,
data: capRows(rows, args.limit),
};
},
});
export const planRoute = defineTool({
name: 'plan_route',
title: 'Plan a route',
description:
'Walking, driving or cycling route between two locations over ' +
'OpenStreetMap, with distance, duration and a simplified path.',
inputSchema: {
type: 'object',
properties: {
from: POINT_SCHEMA,
to: POINT_SCHEMA,
mode: { type: 'string', enum: Object.keys(MODES) },
},
required: ['from', 'to'],
additionalProperties: false,
},
requires: ['routing'],
async run(args, { services, signal }) {
const [from, to] = await Promise.all([
resolvePoint(args.from, { services, signal }),
resolvePoint(args.to, { services, signal }),
]);
const mode = args.mode ?? 'walk';
const route = await services.routing.route([from, to], MODES[mode], {
signal,
});
if (!route?.ok)
throw new ToolError(
'invalid_arguments',
`No ${mode} route from ${from.label} to ${to.label}: ${route?.error || 'no route found'}`,
);
const path = thinEvenly(route.geometry, MAX_ROUTE_POINTS).map(
([lon, lat]) => [round(lon, 5), round(lat, 5)],
);
const km = route.distanceM / 1000;
const minutes = Math.round(route.durationS / 60);
return {
summary: `${mode[0].toUpperCase()}${mode.slice(1)} from ${from.label} to ${to.label}: ${km.toFixed(1)} km, about ${minutes} minutes.`,
data: {
mode,
from,
to,
distance_m: route.distanceM,
duration_s: route.durationS,
path_lon_lat: path,
path_points: route.geometry.length,
},
};
},
});
+251
View File
@@ -0,0 +1,251 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { createPlaceSearchService, createRouteService } from '../places.js';
const place = (id, latitude, longitude, extra = {}) => ({
id,
name: `Place ${id}`,
address: `${id} Main St`,
latitude,
longitude,
distanceM: 120,
primaryType: 'cafe',
types: ['cafe'],
...extra,
});
const placeSearch = (result) => ({
calls: [],
async search(query, near) {
this.calls.push({ query, ...near });
return result;
},
async nearby(near) {
this.calls.push(near);
return result;
},
});
const places = {
resolve: async (name) =>
({
Austin: {
name: 'Austin, Texas',
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
},
Dallas: {
name: 'Dallas, Texas',
bounds: { west: -96.99, south: 32.62, east: -96.46, north: 33.02 },
},
})[name] ?? null,
};
test('place search is centered on the area, kept inside it, and reports a missing key', async () => {
const search = placeSearch({
configured: true,
places: [place('a', 30.27, -97.74), place('far', 40.7, -74)],
});
const catalog = composeCatalog({
tools: coreTools,
services: { placeSearch: search, places },
});
const result = await catalog.call('search_places', {
query: 'coffee',
area: { place: 'Austin' },
});
assert.equal(result.summary, '1 place matching "coffee" in Austin, Texas.');
assert.deepEqual(result.data.rows[0], {
id: 'a',
name: 'Place a',
address: 'a Main St',
type: 'cafe',
lat: 30.27,
lon: -97.74,
distance_m: 120,
});
assert.equal(search.calls[0].query, 'coffee');
assert.ok(Math.abs(search.calls[0].latitude - 30.31) < 1e-9);
assert.equal(search.calls[0].radiusM, 29642);
const unconfigured = composeCatalog({
tools: coreTools,
services: {
placeSearch: placeSearch({ configured: false, places: [] }),
places,
},
});
await assert.rejects(
unconfigured.call('search_places', {
query: 'coffee',
area: { place: 'Austin' },
}),
(error) =>
error.code === 'unavailable' && /Google Places key/.test(error.message),
);
});
test('nearby places use the given radius', async () => {
const search = placeSearch({
configured: true,
places: [place('a', 30.27, -97.74)],
});
const catalog = composeCatalog({
tools: coreTools,
services: { placeSearch: search },
});
const result = await catalog.call('places_nearby', {
lat: 30.27,
lon: -97.74,
radius_m: 400,
});
assert.equal(result.summary, '1 place within 400 m.');
assert.deepEqual(search.calls[0], {
latitude: 30.27,
longitude: -97.74,
radiusM: 400,
});
});
test('routes resolve endpoints, map modes and thin long paths', async () => {
const requests = [];
const geometry = Array.from({ length: 250 }, (_, index) => [
-97.74 + index / 1000,
30.27,
]);
const routing = {
async route(points, profile) {
requests.push({ points, profile });
return {
ok: true,
profile,
distanceM: 312400,
durationS: 11220,
geometry,
};
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { routing, places },
});
const result = await catalog.call('plan_route', {
from: { place: 'Austin' },
to: { lat: 32.78, lon: -96.8 },
mode: 'drive',
});
assert.equal(
result.summary,
'Drive from Austin, Texas to 32.7800, -96.8000: 312.4 km, about 187 minutes.',
);
assert.equal(requests[0].profile, 'car');
assert.deepEqual(requests[0].points[1], {
label: '32.7800, -96.8000',
lat: 32.78,
lon: -96.8,
});
assert.equal(result.data.path_points, 250);
assert.equal(result.data.path_lon_lat.length, 100);
assert.deepEqual(result.data.path_lon_lat.at(-1), [-97.491, 30.27]);
const failing = composeCatalog({
tools: coreTools,
services: {
routing: {
route: async () => ({ ok: false, error: 'route leg too long' }),
},
places,
},
});
await assert.rejects(
failing.call('plan_route', {
from: { place: 'Austin' },
to: { place: 'Dallas' },
}),
/No walk route from Austin, Texas to Dallas, Texas: route leg too long/,
);
await assert.rejects(
catalog.call('plan_route', {
from: { place: 'Austin', lat: 1 },
to: { lat: 1, lon: 2 },
}),
/either place, or lat and lon/,
);
});
test('the place and route services read the app routes and translate failures', async () => {
const requested = [];
const respond = (body, status = 200, headers = {}) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers },
});
const search = createPlaceSearchService({
fetchImpl: async (url) => {
requested.push(url);
return url.includes('nearby')
? respond({ configured: false, error: null, places: [] })
: respond({ places: [place('a', 1, 2)] });
},
});
assert.deepEqual(
await search.search('tea', { latitude: 1, longitude: 2, radiusM: 300 }),
{
configured: true,
places: [place('a', 1, 2)],
},
);
assert.deepEqual(
await search.nearby({ latitude: 1, longitude: 2, radiusM: 50 }),
{ configured: false, places: [] },
);
assert.deepEqual(requested, [
'/api/google/text-search?q=tea&lat=1&lon=2&radiusM=300',
'/api/google/nearby-places?lat=1&lon=2&radiusM=50',
]);
const limited = createPlaceSearchService({
fetchImpl: async () =>
respond({ error: 'Rate limit exceeded', places: [] }, 429, {
'Retry-After': '30',
}),
});
await assert.rejects(
limited.nearby({ latitude: 1, longitude: 2, radiusM: 50 }),
(error) => error.code === 'retry_later' && error.retryAfterSeconds === 30,
);
const routes = [];
const routing = createRouteService({
fetchImpl: async (url) => {
routes.push(url);
if (routes.length === 1)
return respond({
ok: true,
distanceM: 10,
durationS: 5,
geometry: [[2, 1]],
});
if (routes.length === 2)
return respond({ ok: false, error: 'need 2-12 coordinates' }, 400);
return respond({ ok: false, error: 'rate limited' }, 429, {
'Retry-After': '5',
});
},
});
const points = [
{ lat: 1, lon: 2 },
{ lat: 1.5, lon: 2.5 },
];
assert.equal((await routing.route(points, 'foot')).distanceM, 10);
assert.equal(
routes[0],
'/api/route?profile=foot&coords=2.000000%2C1.000000%3B2.500000%2C1.500000',
);
assert.deepEqual(await routing.route(points, 'foot'), {
ok: false,
error: 'need 2-12 coordinates',
});
await assert.rejects(
routing.route(points, 'foot'),
(error) => error.code === 'retry_later' && error.retryAfterSeconds === 5,
);
});