mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat: add weather, hazard, terrain and situation tools
Add get_weather, get_regional_brief, get_cyclones, get_fire_perimeters, get_terrain_height, find_military_installations and get_map_features over the application request services and the layers' sources. situation_brief combines weather, earthquakes, fires, aircraft and cyclones for an area by reusing those tools, marking sections that are unavailable instead of failing. get_hud_caption returns the app's heads-up display caption for the same overview.
This commit is contained in:
1 parent
8c97fed92d
commit
0a74e36ce8
11 files changed
+1093
-23
No files matched your search
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
||||||
serves earthquake, active-fire, launch, aircraft, satellite, camera, radio,
|
serves earthquake, active-fire, launch, aircraft, satellite, camera, radio,
|
||||||
place, routing, bike-share and transit queries over stdio to clients such as Claude Code, reading
|
place, routing, bike-share, transit, weather, cyclone, fire-perimeter,
|
||||||
|
terrain, installation and map-feature queries, plus a situation brief, over
|
||||||
|
stdio to clients such as Claude Code, reading
|
||||||
from a running app. Tools are defined
|
from a running app. Tools are defined
|
||||||
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
|
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
|
||||||
exposed through the protocol adapter in `gods-eye-view/tools/mcp`.
|
exposed through the protocol adapter in `gods-eye-view/tools/mcp`.
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ Queries cover earthquakes, active fires, recent launches, aircraft (in an
|
|||||||
area, by identifier, tracks, and type and route lookups), satellites (next
|
area, by identifier, tracks, and type and route lookups), satellites (next
|
||||||
pass over a point, and those overhead now), public cameras (including a
|
pass over a point, and those overhead now), public cameras (including a
|
||||||
camera's current image), radio stations, place search, routing, bike-share
|
camera's current image), radio stations, place search, routing, bike-share
|
||||||
stations and transit vehicles. Tools reuse the layers' portable source factories, take a
|
stations, transit vehicles, weather, regional briefs, tropical cyclones, fire
|
||||||
|
perimeters, terrain height, military installations and map features, plus a
|
||||||
|
combined situation brief and the app's heads-up display caption. Tools reuse the layers' portable source factories, take a
|
||||||
shared `area` argument (place name, bounding box, or point and radius) and cap
|
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||||
lists at 25 rows by default. The app does not import the tools, so its runtime
|
lists at 25 rows by default. The app does not import the tools, so its runtime
|
||||||
behavior is unchanged. See [tools and the MCP server](TOOLS.md).
|
behavior is unchanged. See [tools and the MCP server](TOOLS.md).
|
||||||
|
|||||||
+13
-1
@@ -49,7 +49,10 @@ Services are the portable source factories the layers already use, such as
|
|||||||
plus a `places` service with `resolve(name, { signal })`. Sources request
|
plus a `places` service with `resolve(name, { signal })`. Sources request
|
||||||
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
|
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
|
||||||
code runs wherever an application routes those paths.
|
code runs wherever an application routes those paths.
|
||||||
The `bikeshare` service is `{ systems, getStations }`: the system registry and
|
The `weather`, `regional`, `terrain`, `summary` and `features` services are the
|
||||||
|
application request services from `gods-eye-view/application/requests`.
|
||||||
|
`situation_brief` runs each section whose services are supplied and marks the
|
||||||
|
others unavailable. The `bikeshare` service is `{ systems, getStations }`: the system registry and
|
||||||
the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`;
|
the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`;
|
||||||
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
|
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
|
||||||
is configured; `createRouteService` plans routes through `/api/route`.
|
is configured; `createRouteService` plans routes through `/api/route`.
|
||||||
@@ -106,3 +109,12 @@ public feeds the sources already use.
|
|||||||
| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path |
|
| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path |
|
||||||
| `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available |
|
| `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available |
|
||||||
| `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route |
|
| `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route |
|
||||||
|
| `get_weather` | `weather` | Current conditions at a place or point |
|
||||||
|
| `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines |
|
||||||
|
| `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area |
|
||||||
|
| `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first |
|
||||||
|
| `get_terrain_height` | `terrain` | Ground, geoid and ellipsoid heights at up to 20 points |
|
||||||
|
| `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side |
|
||||||
|
| `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) |
|
||||||
|
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft and cyclones for an area, by section |
|
||||||
|
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
|
||||||
@@ -875,5 +875,6 @@
|
|||||||
"src/tools/queries/media.test.mjs",
|
"src/tools/queries/media.test.mjs",
|
||||||
"src/tools/queries/places.test.mjs",
|
"src/tools/queries/places.test.mjs",
|
||||||
"src/layers/transit/source.test.mjs",
|
"src/layers/transit/source.test.mjs",
|
||||||
"src/tools/queries/mobility.test.mjs"
|
"src/tools/queries/mobility.test.mjs",
|
||||||
|
"src/tools/queries/environment.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -3094,6 +3094,8 @@
|
|||||||
"src/tools/queries/media.js",
|
"src/tools/queries/media.js",
|
||||||
"src/tools/queries/places.js",
|
"src/tools/queries/places.js",
|
||||||
"src/tools/queries/mobility.js",
|
"src/tools/queries/mobility.js",
|
||||||
|
"src/tools/queries/environment.js",
|
||||||
|
"src/tools/queries/brief.js",
|
||||||
"src/sources/gbfsStations.js",
|
"src/sources/gbfsStations.js",
|
||||||
"src/sources/tle.js",
|
"src/sources/tle.js",
|
||||||
"src/data/satellitePass.js"
|
"src/data/satellitePass.js"
|
||||||
|
|||||||
@@ -6,12 +6,16 @@
|
|||||||
import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js';
|
import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js';
|
||||||
import { createBikeshareSource } from '../../src/layers/bikeshare/source.js';
|
import { createBikeshareSource } from '../../src/layers/bikeshare/source.js';
|
||||||
import { createCctvSource } from '../../src/layers/cctv/source.js';
|
import { createCctvSource } from '../../src/layers/cctv/source.js';
|
||||||
|
import { createCycloneSource } from '../../src/layers/cyclones/source.js';
|
||||||
import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js';
|
import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js';
|
||||||
import { createFirmsSource } from '../../src/layers/firms/source.js';
|
import { createFirmsSource } from '../../src/layers/firms/source.js';
|
||||||
|
import { createInstallationSource } from '../../src/layers/installations/source.js';
|
||||||
import { createLaunchSource } from '../../src/layers/launches/source.js';
|
import { createLaunchSource } from '../../src/layers/launches/source.js';
|
||||||
|
import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js';
|
||||||
import { createRadioSource } from '../../src/layers/radio/source.js';
|
import { createRadioSource } from '../../src/layers/radio/source.js';
|
||||||
import { createSatelliteSource } from '../../src/layers/satellites/source.js';
|
import { createSatelliteSource } from '../../src/layers/satellites/source.js';
|
||||||
import { createTransitSource } from '../../src/layers/transit/source.js';
|
import { createTransitSource } from '../../src/layers/transit/source.js';
|
||||||
|
import { createApplicationRequestServices } from '../../src/services/requests.js';
|
||||||
import {
|
import {
|
||||||
createAdsbLolSource,
|
createAdsbLolSource,
|
||||||
createOpenSkySource,
|
createOpenSkySource,
|
||||||
@@ -44,6 +48,7 @@ export function createApiFetch({
|
|||||||
/** Construct every service Core's tools read, backed by the local server. */
|
/** Construct every service Core's tools read, backed by the local server. */
|
||||||
export function createLocalToolServices(options = {}) {
|
export function createLocalToolServices(options = {}) {
|
||||||
const fetchImpl = createApiFetch(options);
|
const fetchImpl = createApiFetch(options);
|
||||||
|
const requests = createApplicationRequestServices({ fetchImpl });
|
||||||
return {
|
return {
|
||||||
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
|
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
|
||||||
fires: createFirmsSource({ fetchImpl }),
|
fires: createFirmsSource({ fetchImpl }),
|
||||||
@@ -60,6 +65,17 @@ export function createLocalToolServices(options = {}) {
|
|||||||
getStations: createBikeshareSource({ fetchImpl }).getStations,
|
getStations: createBikeshareSource({ fetchImpl }).getStations,
|
||||||
},
|
},
|
||||||
transit: createTransitSource({ fetchImpl }),
|
transit: createTransitSource({ fetchImpl }),
|
||||||
|
weather: requests.weather,
|
||||||
|
regional: requests.regional,
|
||||||
|
terrain: requests.terrain,
|
||||||
|
summary: requests.summary,
|
||||||
|
features: requests.features,
|
||||||
|
cyclones: createCycloneSource({ fetchImpl }),
|
||||||
|
perimeters: createWfigsPerimeterSource({ fetchImpl }),
|
||||||
|
installations: createInstallationSource({
|
||||||
|
fetchImpl,
|
||||||
|
tileFetchImpl: fetchImpl,
|
||||||
|
}),
|
||||||
places: createGeocodePlaceService({ fetchImpl }),
|
places: createGeocodePlaceService({ fetchImpl }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,16 @@ import {
|
|||||||
getAircraftInfo,
|
getAircraftInfo,
|
||||||
getAircraftTrack,
|
getAircraftTrack,
|
||||||
} from './queries/aviation.js';
|
} from './queries/aviation.js';
|
||||||
|
import { getHudCaption, situationBrief } from './queries/brief.js';
|
||||||
|
import {
|
||||||
|
findMilitaryInstallations,
|
||||||
|
getCyclones,
|
||||||
|
getFirePerimeters,
|
||||||
|
getMapFeatures,
|
||||||
|
getRegionalBrief,
|
||||||
|
getTerrainHeight,
|
||||||
|
getWeather,
|
||||||
|
} from './queries/environment.js';
|
||||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||||
import { getBikeShare, getTransitVehicles } from './queries/mobility.js';
|
import { getBikeShare, getTransitVehicles } from './queries/mobility.js';
|
||||||
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
|
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
|
||||||
@@ -63,4 +73,13 @@ export const coreTools = Object.freeze([
|
|||||||
planRoute,
|
planRoute,
|
||||||
getBikeShare,
|
getBikeShare,
|
||||||
getTransitVehicles,
|
getTransitVehicles,
|
||||||
|
getWeather,
|
||||||
|
getRegionalBrief,
|
||||||
|
getCyclones,
|
||||||
|
getFirePerimeters,
|
||||||
|
getTerrainHeight,
|
||||||
|
findMilitaryInstallations,
|
||||||
|
getMapFeatures,
|
||||||
|
situationBrief,
|
||||||
|
getHudCaption,
|
||||||
]);
|
]);
|
||||||
@@ -4,6 +4,7 @@ import { PassThrough } from 'node:stream';
|
|||||||
import { createLocalMcpServer } from '../../server/mcp/server.js';
|
import { createLocalMcpServer } from '../../server/mcp/server.js';
|
||||||
import { createApiFetch } from '../../server/mcp/services.js';
|
import { createApiFetch } from '../../server/mcp/services.js';
|
||||||
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
|
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
|
||||||
|
import { coreTools } from './index.js';
|
||||||
|
|
||||||
const usgs = {
|
const usgs = {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
@@ -66,27 +67,10 @@ test('the stdio server answers newline-delimited requests using only its data so
|
|||||||
assert.equal(responses.length, 4);
|
assert.equal(responses.length, 4);
|
||||||
assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view');
|
assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view');
|
||||||
assert.equal(byId.get(null).error.code, -32700);
|
assert.equal(byId.get(null).error.code, -32700);
|
||||||
|
// Every Core tool's services are composed locally.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
byId.get(2).result.tools.map((tool) => tool.name),
|
byId.get(2).result.tools.map((tool) => tool.name),
|
||||||
[
|
coreTools.map((tool) => tool.name),
|
||||||
'get_earthquakes',
|
|
||||||
'get_active_fires',
|
|
||||||
'get_recent_launches',
|
|
||||||
'aircraft_in_area',
|
|
||||||
'find_aircraft',
|
|
||||||
'get_aircraft_track',
|
|
||||||
'get_aircraft_info',
|
|
||||||
'next_satellite_pass',
|
|
||||||
'satellites_overhead',
|
|
||||||
'find_cctv_cameras',
|
|
||||||
'get_cctv_snapshot',
|
|
||||||
'find_radio_stations',
|
|
||||||
'search_places',
|
|
||||||
'places_nearby',
|
|
||||||
'plan_route',
|
|
||||||
'get_bike_share',
|
|
||||||
'get_transit_vehicles',
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
byId.get(3).result.content[0].text,
|
byId.get(3).result.content[0].text,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/** Composite queries that combine other tools' answers for one area. */
|
||||||
|
|
||||||
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
|
import { AREA_SCHEMA, areaCenter, resolveArea } from '../area.js';
|
||||||
|
import { aircraftInArea } from './aviation.js';
|
||||||
|
import { getCyclones, getWeather } from './environment.js';
|
||||||
|
import { getActiveFires, getEarthquakes } from './hazards.js';
|
||||||
|
|
||||||
|
const SECTION_LIMIT = 5;
|
||||||
|
|
||||||
|
/** Brief sections: the tool each one reuses and the services it needs. */
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
key: 'weather',
|
||||||
|
tool: getWeather,
|
||||||
|
args: (area, center) => ({
|
||||||
|
location: { lat: center.lat, lon: center.lon },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'earthquakes',
|
||||||
|
tool: getEarthquakes,
|
||||||
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fires',
|
||||||
|
tool: getActiveFires,
|
||||||
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aircraft',
|
||||||
|
tool: aircraftInArea,
|
||||||
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cyclones',
|
||||||
|
tool: getCyclones,
|
||||||
|
args: (area) => ({ area: area.argument }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run every section whose services are supplied. A failing section is
|
||||||
|
* reported as unavailable instead of failing the brief.
|
||||||
|
*/
|
||||||
|
async function buildBrief(args, { services, signal }) {
|
||||||
|
const resolved = await resolveArea(args.area, { services, signal });
|
||||||
|
// Sections receive the resolved box so a place name is looked up once.
|
||||||
|
const area = {
|
||||||
|
...resolved,
|
||||||
|
argument: resolved.center
|
||||||
|
? {
|
||||||
|
lat: resolved.center.lat,
|
||||||
|
lon: resolved.center.lon,
|
||||||
|
radius_km: resolved.center.radiusKm,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
bbox: [resolved.west, resolved.south, resolved.east, resolved.north],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const center = areaCenter(resolved);
|
||||||
|
const sections = SECTIONS.filter(({ tool }) =>
|
||||||
|
tool.requires.every((key) => services[key] != null),
|
||||||
|
);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
sections.map(({ tool, args: build }) =>
|
||||||
|
tool.run(build(area, center), { services, signal }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const brief = {};
|
||||||
|
const lines = [];
|
||||||
|
sections.forEach(({ key }, index) => {
|
||||||
|
const result = results[index];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
brief[key] = { summary: result.value.summary, data: result.value.data };
|
||||||
|
lines.push(result.value.summary);
|
||||||
|
} else {
|
||||||
|
const known = result.reason instanceof ToolError;
|
||||||
|
brief[key] = {
|
||||||
|
unavailable: true,
|
||||||
|
reason: known ? result.reason.message : 'unavailable right now',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { area: resolved, center, brief, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const situationBrief = defineTool({
|
||||||
|
name: 'situation_brief',
|
||||||
|
title: 'Situation brief',
|
||||||
|
description:
|
||||||
|
'One overview of an area: current weather, recent earthquakes, active ' +
|
||||||
|
'fires, aircraft overhead and tropical cyclones, each summarized with ' +
|
||||||
|
'its top items. Sections that are unavailable are marked as such.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { area: AREA_SCHEMA },
|
||||||
|
required: ['area'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['weather'],
|
||||||
|
async run(args, context) {
|
||||||
|
const { area, brief, lines } = await buildBrief(args, context);
|
||||||
|
return {
|
||||||
|
summary: `Situation in ${area.label}: ${lines.join(' ')}`,
|
||||||
|
data: { area: area.label, sections: brief },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getHudCaption = defineTool({
|
||||||
|
name: 'get_hud_caption',
|
||||||
|
title: 'Heads-up display caption',
|
||||||
|
description:
|
||||||
|
"The short heads-up display caption God's Eye View would show for an " +
|
||||||
|
'area, written by the app from the same overview situation_brief gives.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { area: AREA_SCHEMA },
|
||||||
|
required: ['area'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['weather', 'summary'],
|
||||||
|
async run(args, context) {
|
||||||
|
const { area, center, lines } = await buildBrief(args, context);
|
||||||
|
const response = await context.services.summary.summarize(
|
||||||
|
{ location: area.label, center, observations: lines },
|
||||||
|
{ signal: context.signal },
|
||||||
|
);
|
||||||
|
const caption = response?.data?.summary;
|
||||||
|
if (!response?.ok || typeof caption !== 'string' || !caption)
|
||||||
|
throw new ToolError('unavailable', 'The caption service did not answer');
|
||||||
|
return {
|
||||||
|
summary: caption,
|
||||||
|
data: { area: area.label, caption },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* Environment queries: weather, regional briefs, cyclones, fire perimeters,
|
||||||
|
* terrain height, mapped military sites and map features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
|
import {
|
||||||
|
AREA_SCHEMA,
|
||||||
|
POINT_SCHEMA,
|
||||||
|
areaCenter,
|
||||||
|
areaContains,
|
||||||
|
distanceKm,
|
||||||
|
resolveArea,
|
||||||
|
resolvePoint,
|
||||||
|
} from '../area.js';
|
||||||
|
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||||
|
|
||||||
|
const MAX_TERRAIN_POINTS = 20;
|
||||||
|
const MAX_SITE_BOX_DEGREES = 10;
|
||||||
|
const FEATURE_KINDS = {
|
||||||
|
administrative_areas: 'getAdministrativeAreas',
|
||||||
|
named_places: 'getEnclosingAreas',
|
||||||
|
monuments: 'getMonuments',
|
||||||
|
};
|
||||||
|
|
||||||
|
const round = (value, digits) =>
|
||||||
|
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||||
|
|
||||||
|
// WMO weather interpretation codes used by the weather provider.
|
||||||
|
const WEATHER_CODES = {
|
||||||
|
0: 'clear sky',
|
||||||
|
1: 'mainly clear',
|
||||||
|
2: 'partly cloudy',
|
||||||
|
3: 'overcast',
|
||||||
|
45: 'fog',
|
||||||
|
48: 'depositing rime fog',
|
||||||
|
51: 'light drizzle',
|
||||||
|
53: 'drizzle',
|
||||||
|
55: 'dense drizzle',
|
||||||
|
56: 'light freezing drizzle',
|
||||||
|
57: 'freezing drizzle',
|
||||||
|
61: 'light rain',
|
||||||
|
63: 'rain',
|
||||||
|
65: 'heavy rain',
|
||||||
|
66: 'light freezing rain',
|
||||||
|
67: 'freezing rain',
|
||||||
|
71: 'light snow',
|
||||||
|
73: 'snow',
|
||||||
|
75: 'heavy snow',
|
||||||
|
77: 'snow grains',
|
||||||
|
80: 'light rain showers',
|
||||||
|
81: 'rain showers',
|
||||||
|
82: 'violent rain showers',
|
||||||
|
85: 'light snow showers',
|
||||||
|
86: 'snow showers',
|
||||||
|
95: 'thunderstorm',
|
||||||
|
96: 'thunderstorm with light hail',
|
||||||
|
99: 'thunderstorm with heavy hail',
|
||||||
|
};
|
||||||
|
|
||||||
|
function weatherRow(weather) {
|
||||||
|
if (!weather) return null;
|
||||||
|
return {
|
||||||
|
observed_at: weather.observedAt ?? null,
|
||||||
|
conditions: WEATHER_CODES[weather.weatherCode] ?? null,
|
||||||
|
weather_code: weather.weatherCode ?? null,
|
||||||
|
temperature_c: weather.temperatureC ?? null,
|
||||||
|
feels_like_c: weather.apparentTemperatureC ?? null,
|
||||||
|
precipitation_mm: weather.precipitationMm ?? null,
|
||||||
|
cloud_cover_pct: weather.cloudCoverPct ?? null,
|
||||||
|
wind_kph: weather.windKph ?? null,
|
||||||
|
wind_direction_deg: weather.windDirectionDeg ?? null,
|
||||||
|
visibility_m: weather.visibilityM ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeWeather(row) {
|
||||||
|
if (!row) return 'no current weather';
|
||||||
|
const parts = [
|
||||||
|
row.conditions,
|
||||||
|
Number.isFinite(row.temperature_c) && `${Math.round(row.temperature_c)}°C`,
|
||||||
|
Number.isFinite(row.wind_kph) && `wind ${Math.round(row.wind_kph)} km/h`,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(', ') || 'conditions unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWeather = defineTool({
|
||||||
|
name: 'get_weather',
|
||||||
|
title: 'Current weather',
|
||||||
|
description:
|
||||||
|
'Current weather at a location: conditions, temperature, feels-like, ' +
|
||||||
|
'precipitation, cloud cover, wind and visibility.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { location: POINT_SCHEMA },
|
||||||
|
required: ['location'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['weather'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const point = await resolvePoint(args.location, { services, signal });
|
||||||
|
const payload = await services.weather.getConditions(point.lat, point.lon, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const row = weatherRow(payload?.weather);
|
||||||
|
return {
|
||||||
|
summary: `Weather at ${point.label}: ${describeWeather(row)}.`,
|
||||||
|
data: { location: point, weather: row },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getRegionalBrief = defineTool({
|
||||||
|
name: 'get_regional_brief',
|
||||||
|
title: 'Regional brief',
|
||||||
|
description:
|
||||||
|
'What and where a location is, its current weather, and recent news ' +
|
||||||
|
'headlines for the region.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { location: POINT_SCHEMA },
|
||||||
|
required: ['location'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['regional'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const point = await resolvePoint(args.location, { services, signal });
|
||||||
|
const brief = await services.regional.getBrief(point.lat, point.lon, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const place = brief?.place ?? null;
|
||||||
|
const articles = (brief?.articles || []).slice(0, 10).map((article) => ({
|
||||||
|
title: article.title ?? null,
|
||||||
|
url: article.url ?? null,
|
||||||
|
source: article.domain ?? null,
|
||||||
|
published_at: article.publishedAt ?? null,
|
||||||
|
}));
|
||||||
|
const weather = weatherRow(brief?.weather);
|
||||||
|
const label = place?.label || point.label;
|
||||||
|
return {
|
||||||
|
summary: `${label}: ${describeWeather(weather)}; ${countNoun(articles.length, 'recent headline')}.`,
|
||||||
|
data: {
|
||||||
|
location: point,
|
||||||
|
place: place && {
|
||||||
|
label: place.label ?? null,
|
||||||
|
locality: place.locality ?? null,
|
||||||
|
region: place.region ?? null,
|
||||||
|
country: place.country ?? null,
|
||||||
|
kind: place.kind ?? null,
|
||||||
|
},
|
||||||
|
weather,
|
||||||
|
news_source: brief?.newsSource ?? null,
|
||||||
|
articles,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getCyclones = defineTool({
|
||||||
|
name: 'get_cyclones',
|
||||||
|
title: 'Tropical cyclones',
|
||||||
|
description:
|
||||||
|
'Active tropical cyclones from the NOAA National Hurricane Center and ' +
|
||||||
|
'Central Pacific Hurricane Center, optionally limited to an area.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { area: AREA_SCHEMA },
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['cyclones'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const area = args.area
|
||||||
|
? await resolveArea(args.area, { services, signal })
|
||||||
|
: null;
|
||||||
|
const snapshot = await services.cyclones.getSnapshot({ signal });
|
||||||
|
if (snapshot.unavailable)
|
||||||
|
throw new ToolError(
|
||||||
|
'unavailable',
|
||||||
|
'Cyclone advisories are unavailable right now',
|
||||||
|
);
|
||||||
|
const storms = snapshot.storms
|
||||||
|
.filter(
|
||||||
|
(storm) =>
|
||||||
|
!area ||
|
||||||
|
areaContains(area, {
|
||||||
|
lat: storm.position.latitude,
|
||||||
|
lon: storm.position.longitude,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map((storm) => ({
|
||||||
|
id: storm.id,
|
||||||
|
name: storm.name,
|
||||||
|
classification: storm.classification,
|
||||||
|
basin: storm.basin,
|
||||||
|
lat: storm.position.latitude,
|
||||||
|
lon: storm.position.longitude,
|
||||||
|
position_at: storm.positionAt,
|
||||||
|
wind_kt: storm.windKt,
|
||||||
|
pressure_hpa: storm.pressureHpa,
|
||||||
|
movement: storm.movement ?? null,
|
||||||
|
advisory: storm.advisoryNumber,
|
||||||
|
issued_at: storm.issuedAt,
|
||||||
|
advisory_url: storm.advisoryUrl,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.wind_kt ?? 0) - (a.wind_kt ?? 0));
|
||||||
|
const where = area ? ` in ${area.label}` : '';
|
||||||
|
return {
|
||||||
|
summary:
|
||||||
|
`${countNoun(storms.length, 'active tropical cyclone')}${where}` +
|
||||||
|
(storms[0]
|
||||||
|
? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.`
|
||||||
|
: '.'),
|
||||||
|
data: {
|
||||||
|
storms,
|
||||||
|
coverage: snapshot.coverage ?? null,
|
||||||
|
source: snapshot.source ?? null,
|
||||||
|
attribution: snapshot.attribution ?? null,
|
||||||
|
stale: snapshot.stale === true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getFirePerimeters = defineTool({
|
||||||
|
name: 'get_fire_perimeters',
|
||||||
|
title: 'Wildfire perimeters',
|
||||||
|
description:
|
||||||
|
'Mapped wildfire perimeters from the US WFIGS service in an area, ' +
|
||||||
|
'largest first, with size, containment, cause and personnel.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||||
|
required: ['area'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['perimeters'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const area = await resolveArea(args.area, { services, signal });
|
||||||
|
const rows = (await services.perimeters.getSnapshot({ signal }))
|
||||||
|
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
||||||
|
.filter(({ point }) => point && areaContains(area, point))
|
||||||
|
.map(({ fire, point }) => ({
|
||||||
|
id: fire.stableId,
|
||||||
|
name: fire.name,
|
||||||
|
acres: round(fire.acres, 0),
|
||||||
|
contained_pct: fire.containedPct,
|
||||||
|
state: fire.state,
|
||||||
|
county: fire.county,
|
||||||
|
category: fire.category,
|
||||||
|
cause: fire.cause,
|
||||||
|
behavior: fire.behavior,
|
||||||
|
personnel: fire.personnel,
|
||||||
|
discovered: isoTime(fire.discoveredTime),
|
||||||
|
updated: isoTime(fire.updatedTime),
|
||||||
|
lat: round(point.lat, 5),
|
||||||
|
lon: round(point.lon, 5),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
|
||||||
|
return {
|
||||||
|
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
|
||||||
|
data: capRows(rows, args.limit),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
|
||||||
|
function polygonCenter(polygons) {
|
||||||
|
let lat = 0;
|
||||||
|
let lon = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const polygon of polygons || []) {
|
||||||
|
for (const [x, y] of polygon?.[0] || []) {
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||||
|
lon += x;
|
||||||
|
lat += y;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count ? { lat: lat / count, lon: lon / count } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTerrainHeight = defineTool({
|
||||||
|
name: 'get_terrain_height',
|
||||||
|
title: 'Terrain height',
|
||||||
|
description:
|
||||||
|
'Ground elevation above sea level at up to 20 points, with the geoid ' +
|
||||||
|
'and ellipsoid heights.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
points: {
|
||||||
|
type: 'array',
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: MAX_TERRAIN_POINTS,
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||||||
|
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||||||
|
},
|
||||||
|
required: ['lat', 'lon'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['points'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['terrain'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const results = await services.terrain.getHeights(args.points, { signal });
|
||||||
|
if (!Array.isArray(results) || results.length !== args.points.length)
|
||||||
|
throw new ToolError('malformed', 'Terrain heights were incomplete');
|
||||||
|
const rows = args.points.map((point, index) => ({
|
||||||
|
lat: point.lat,
|
||||||
|
lon: point.lon,
|
||||||
|
elevation_m: round(results[index]?.elevation, 1),
|
||||||
|
geoid_m: round(results[index]?.geoid, 1),
|
||||||
|
ellipsoid_m: round(results[index]?.ellipsoid, 1),
|
||||||
|
}));
|
||||||
|
const first = rows[0];
|
||||||
|
return {
|
||||||
|
summary:
|
||||||
|
rows.length === 1
|
||||||
|
? `Ground elevation is ${first.elevation_m ?? 'unknown'} m at ${first.lat}, ${first.lon}.`
|
||||||
|
: `Ground elevation at ${countNoun(rows.length, 'point')}.`,
|
||||||
|
data: { points: rows },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findMilitaryInstallations = defineTool({
|
||||||
|
name: 'find_military_installations',
|
||||||
|
title: 'Military installations',
|
||||||
|
description:
|
||||||
|
'Military installations mapped in OpenStreetMap within an area of at ' +
|
||||||
|
`most ${MAX_SITE_BOX_DEGREES}° on each side, nearest the center first.`,
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||||
|
required: ['area'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['installations'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const area = await resolveArea(args.area, { services, signal });
|
||||||
|
if (
|
||||||
|
area.west > area.east ||
|
||||||
|
area.north - area.south > MAX_SITE_BOX_DEGREES ||
|
||||||
|
area.east - area.west > MAX_SITE_BOX_DEGREES
|
||||||
|
)
|
||||||
|
throw new ToolError(
|
||||||
|
'invalid_arguments',
|
||||||
|
`The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`,
|
||||||
|
);
|
||||||
|
const center = areaCenter(area);
|
||||||
|
const result = await services.installations.getMappedSites(area, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const rows = (result.records || [])
|
||||||
|
.map((site) => ({
|
||||||
|
site,
|
||||||
|
point: { lat: site.latitude, lon: site.longitude },
|
||||||
|
}))
|
||||||
|
.filter(({ point }) => areaContains(area, point))
|
||||||
|
.map(({ site, point }) => ({
|
||||||
|
id: site.id,
|
||||||
|
name: site.name ?? null,
|
||||||
|
kind: site.kind ?? null,
|
||||||
|
class: site.class ?? null,
|
||||||
|
lat: round(point.lat, 5),
|
||||||
|
lon: round(point.lon, 5),
|
||||||
|
distance_km: round(distanceKm(center, point), 1),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.distance_km - b.distance_km);
|
||||||
|
return {
|
||||||
|
summary: `${countNoun(rows.length, 'mapped military installation')} in ${area.label}.`,
|
||||||
|
data: { ...capRows(rows, args.limit), source: result.source ?? null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getMapFeatures = defineTool({
|
||||||
|
name: 'get_map_features',
|
||||||
|
title: 'Map features',
|
||||||
|
description:
|
||||||
|
'Named OpenStreetMap features at a location: the administrative areas ' +
|
||||||
|
'containing it, named parks, land uses and water nearby, or monuments ' +
|
||||||
|
'and memorials within 2.5 km.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
location: POINT_SCHEMA,
|
||||||
|
kind: { type: 'string', enum: Object.keys(FEATURE_KINDS) },
|
||||||
|
limit: LIMIT_SCHEMA,
|
||||||
|
},
|
||||||
|
required: ['location', 'kind'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
requires: ['features'],
|
||||||
|
async run(args, { services, signal }) {
|
||||||
|
const point = await resolvePoint(args.location, { services, signal });
|
||||||
|
const features = await services.features[FEATURE_KINDS[args.kind]](
|
||||||
|
{ lat: point.lat, lon: point.lon },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
if (features?.unavailable)
|
||||||
|
throw new ToolError(
|
||||||
|
'unavailable',
|
||||||
|
'Map feature lookups need an Overpass server configured for this app',
|
||||||
|
);
|
||||||
|
if (!Array.isArray(features))
|
||||||
|
throw new ToolError(
|
||||||
|
'retry_later',
|
||||||
|
'The map feature service did not answer',
|
||||||
|
);
|
||||||
|
const rows = features
|
||||||
|
.map((feature) => ({
|
||||||
|
id: feature.id,
|
||||||
|
name:
|
||||||
|
feature.names?.english ||
|
||||||
|
feature.names?.primary ||
|
||||||
|
feature.names?.official ||
|
||||||
|
null,
|
||||||
|
category: feature.category,
|
||||||
|
admin_level: feature.level === 99 ? null : feature.level,
|
||||||
|
lat: round(feature.point?.lat, 5),
|
||||||
|
lon: round(feature.point?.lon, 5),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.name)
|
||||||
|
.sort((a, b) => (a.admin_level ?? 99) - (b.admin_level ?? 99));
|
||||||
|
const kind = args.kind.replace('_', ' ');
|
||||||
|
return {
|
||||||
|
summary: `${countNoun(rows.length, 'named feature')} (${kind}) at ${point.label}.`,
|
||||||
|
data: { location: point, ...capRows(rows, args.limit) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeCatalog, coreTools, ToolError } from '../index.js';
|
||||||
|
|
||||||
|
const conditions = {
|
||||||
|
observedAt: '2026-10-01T19:15:00.000Z',
|
||||||
|
temperatureC: 26.3,
|
||||||
|
apparentTemperatureC: 32,
|
||||||
|
precipitationMm: 0,
|
||||||
|
cloudCoverPct: 100,
|
||||||
|
windKph: 5.4,
|
||||||
|
windDirectionDeg: 318,
|
||||||
|
visibilityM: 15400,
|
||||||
|
weatherCode: 3,
|
||||||
|
};
|
||||||
|
const weather = {
|
||||||
|
calls: [],
|
||||||
|
async getConditions(latitude, longitude) {
|
||||||
|
this.calls.push([latitude, longitude]);
|
||||||
|
return { status: 'ready', weather: conditions };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const places = {
|
||||||
|
resolve: async (name) =>
|
||||||
|
name === 'Austin'
|
||||||
|
? {
|
||||||
|
name: 'Austin, Texas',
|
||||||
|
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const catalogWith = (services) =>
|
||||||
|
composeCatalog({ tools: coreTools, services });
|
||||||
|
|
||||||
|
test('weather describes current conditions at a point or place', async () => {
|
||||||
|
const catalog = catalogWith({ weather, places });
|
||||||
|
const result = await catalog.call('get_weather', {
|
||||||
|
location: { place: 'Austin' },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'Weather at Austin, Texas: overcast, 26°C, wind 5 km/h.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.data.weather, {
|
||||||
|
observed_at: '2026-10-01T19:15:00.000Z',
|
||||||
|
conditions: 'overcast',
|
||||||
|
weather_code: 3,
|
||||||
|
temperature_c: 26.3,
|
||||||
|
feels_like_c: 32,
|
||||||
|
precipitation_mm: 0,
|
||||||
|
cloud_cover_pct: 100,
|
||||||
|
wind_kph: 5.4,
|
||||||
|
wind_direction_deg: 318,
|
||||||
|
visibility_m: 15400,
|
||||||
|
});
|
||||||
|
const [latitude, longitude] = weather.calls.at(-1);
|
||||||
|
assert.ok(Math.abs(latitude - 30.31) < 1e-9 && longitude === -97.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the regional brief returns place, weather and at most ten headlines', async () => {
|
||||||
|
const regional = {
|
||||||
|
async getBrief() {
|
||||||
|
return {
|
||||||
|
place: {
|
||||||
|
label: 'Edwards Plateau',
|
||||||
|
locality: null,
|
||||||
|
region: 'Edwards Plateau',
|
||||||
|
country: null,
|
||||||
|
kind: 'natural',
|
||||||
|
},
|
||||||
|
weather: conditions,
|
||||||
|
newsSource: 'Google News RSS',
|
||||||
|
articles: Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
title: `Story ${index}`,
|
||||||
|
url: `https://news.example/${index}`,
|
||||||
|
domain: 'news.example',
|
||||||
|
publishedAt: '2026-10-01T00:00:00Z',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await catalogWith({ regional }).call('get_regional_brief', {
|
||||||
|
location: { lat: 30.27, lon: -97.74 },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'Edwards Plateau: overcast, 26°C, wind 5 km/h; 10 recent headlines.',
|
||||||
|
);
|
||||||
|
assert.equal(result.data.articles.length, 10);
|
||||||
|
assert.deepEqual(result.data.articles[0], {
|
||||||
|
title: 'Story 0',
|
||||||
|
url: 'https://news.example/0',
|
||||||
|
source: 'news.example',
|
||||||
|
published_at: '2026-10-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const storm = (id, name, latitude, longitude, windKt) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
classification: 'Hurricane',
|
||||||
|
basin: 'Atlantic',
|
||||||
|
position: { latitude, longitude },
|
||||||
|
positionAt: '2026-10-01T18:00:00.000Z',
|
||||||
|
advisoryNumber: '12',
|
||||||
|
issuedAt: '2026-10-01T15:00:00.000Z',
|
||||||
|
windKt,
|
||||||
|
pressureHpa: 980,
|
||||||
|
movement: { towardDeg: 300, speedKt: 10 },
|
||||||
|
advisoryUrl: 'https://www.nhc.noaa.gov/text/X.shtml',
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cyclones are listed strongest first and can be limited to an area', async () => {
|
||||||
|
const cyclones = {
|
||||||
|
async getSnapshot() {
|
||||||
|
return {
|
||||||
|
source: 'NOAA NHC / CPHC',
|
||||||
|
coverage: 'Atlantic and eastern/central North Pacific',
|
||||||
|
stale: false,
|
||||||
|
unavailable: false,
|
||||||
|
storms: [
|
||||||
|
storm('a', 'Alpha', 25, -70, 65),
|
||||||
|
storm('b', 'Beta', 15, -110, 120),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const all = await catalogWith({ cyclones }).call('get_cyclones', {});
|
||||||
|
assert.equal(
|
||||||
|
all.summary,
|
||||||
|
'2 active tropical cyclones; strongest Beta at 120 kt.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
all.data.storms.map((row) => row.id),
|
||||||
|
['b', 'a'],
|
||||||
|
);
|
||||||
|
const atlantic = await catalogWith({ cyclones }).call('get_cyclones', {
|
||||||
|
area: { bbox: [-100, 0, -10, 50] },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
atlantic.summary,
|
||||||
|
'1 active tropical cyclone in the requested box; strongest Alpha at 65 kt.',
|
||||||
|
);
|
||||||
|
const down = { getSnapshot: async () => ({ unavailable: true, storms: [] }) };
|
||||||
|
await assert.rejects(
|
||||||
|
catalogWith({ cyclones: down }).call('get_cyclones', {}),
|
||||||
|
(error) => error.code === 'unavailable',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fire perimeters are placed by their outline and sorted by size', async () => {
|
||||||
|
const ring = (lon, lat) => [
|
||||||
|
[
|
||||||
|
[lon, lat],
|
||||||
|
[lon + 0.1, lat],
|
||||||
|
[lon + 0.1, lat + 0.1],
|
||||||
|
[lon, lat + 0.1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const perimeters = {
|
||||||
|
async getSnapshot() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
stableId: 'small',
|
||||||
|
name: 'Small Fire',
|
||||||
|
acres: 120.4,
|
||||||
|
containedPct: 80,
|
||||||
|
state: 'US-CA',
|
||||||
|
county: 'Kern',
|
||||||
|
category: 'WF',
|
||||||
|
cause: 'Human',
|
||||||
|
behavior: null,
|
||||||
|
personnel: 20,
|
||||||
|
discoveredTime: Date.UTC(2026, 8, 1),
|
||||||
|
updatedTime: null,
|
||||||
|
polygons: [ring(-118.5, 35.2)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stableId: 'big',
|
||||||
|
name: 'Big Fire',
|
||||||
|
acres: 5000,
|
||||||
|
containedPct: 10,
|
||||||
|
polygons: [ring(-118.8, 35.4)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stableId: 'far',
|
||||||
|
name: 'Far Fire',
|
||||||
|
acres: 9000,
|
||||||
|
polygons: [ring(-80, 30)],
|
||||||
|
},
|
||||||
|
{ stableId: 'empty', name: 'No Outline', acres: 1, polygons: [] },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||||
|
area: { bbox: [-120, 34, -117, 36] },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'2 mapped wildfire perimeters in the requested box.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.rows.map((row) => row.id),
|
||||||
|
['big', 'small'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.data.rows[1], {
|
||||||
|
id: 'small',
|
||||||
|
name: 'Small Fire',
|
||||||
|
acres: 120,
|
||||||
|
contained_pct: 80,
|
||||||
|
state: 'US-CA',
|
||||||
|
county: 'Kern',
|
||||||
|
category: 'WF',
|
||||||
|
cause: 'Human',
|
||||||
|
behavior: null,
|
||||||
|
personnel: 20,
|
||||||
|
discovered: '2026-09-01T00:00:00.000Z',
|
||||||
|
updated: null,
|
||||||
|
lat: 35.25,
|
||||||
|
lon: -118.45,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terrain heights are returned in request order', async () => {
|
||||||
|
const terrain = {
|
||||||
|
async getHeights(points) {
|
||||||
|
return points.map((point) => ({
|
||||||
|
...point,
|
||||||
|
elevation: point.lat * 100,
|
||||||
|
geoid: -30.04,
|
||||||
|
ellipsoid: point.lat * 100 - 30.04,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const one = await catalogWith({ terrain }).call('get_terrain_height', {
|
||||||
|
points: [{ lat: 27.9881, lon: 86.925 }],
|
||||||
|
});
|
||||||
|
assert.equal(one.summary, 'Ground elevation is 2798.8 m at 27.9881, 86.925.');
|
||||||
|
const many = await catalogWith({ terrain }).call('get_terrain_height', {
|
||||||
|
points: [
|
||||||
|
{ lat: 1, lon: 1 },
|
||||||
|
{ lat: 2, lon: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
many.data.points.map((point) => point.elevation_m),
|
||||||
|
[100, 200],
|
||||||
|
);
|
||||||
|
const short = { getHeights: async () => [] };
|
||||||
|
await assert.rejects(
|
||||||
|
catalogWith({ terrain: short }).call('get_terrain_height', {
|
||||||
|
points: [{ lat: 1, lon: 1 }],
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'malformed',
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
catalogWith({ terrain }).call('get_terrain_height', {
|
||||||
|
points: Array(21).fill({ lat: 0, lon: 0 }),
|
||||||
|
}),
|
||||||
|
/at most 20 items/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('military installations need a bounded area and are nearest first', async () => {
|
||||||
|
const requested = [];
|
||||||
|
const installations = {
|
||||||
|
async getMappedSites(box) {
|
||||||
|
requested.push(box);
|
||||||
|
return {
|
||||||
|
source: 'OpenStreetMap',
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 'osm:1',
|
||||||
|
kind: 'installation',
|
||||||
|
class: 'base',
|
||||||
|
name: 'Naval Base Point Loma',
|
||||||
|
latitude: 32.6941,
|
||||||
|
longitude: -117.2494,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'osm:2',
|
||||||
|
kind: 'installation',
|
||||||
|
class: 'airfield',
|
||||||
|
name: 'North Island',
|
||||||
|
latitude: 32.7,
|
||||||
|
longitude: -117.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const catalog = catalogWith({ installations });
|
||||||
|
const result = await catalog.call('find_military_installations', {
|
||||||
|
area: { lat: 32.7, lon: -117.2, radius_km: 20 },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'2 mapped military installations in 20 km around 32.700, -117.200.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.rows.map((row) => row.id),
|
||||||
|
['osm:2', 'osm:1'],
|
||||||
|
);
|
||||||
|
assert.equal(result.data.source, 'OpenStreetMap');
|
||||||
|
assert.ok(requested[0].north - requested[0].south < 1);
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('find_military_installations', {
|
||||||
|
area: { bbox: [-130, 20, -100, 50] },
|
||||||
|
}),
|
||||||
|
/at most 10° on each side/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('map features name what is at a location and report an unconfigured server', async () => {
|
||||||
|
const features = {
|
||||||
|
async getAdministrativeAreas() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
category: 'administrative',
|
||||||
|
names: { primary: 'Texas' },
|
||||||
|
level: 4,
|
||||||
|
point: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
category: 'administrative',
|
||||||
|
names: { primary: 'Travis County', english: 'Travis County' },
|
||||||
|
level: 6,
|
||||||
|
point: null,
|
||||||
|
},
|
||||||
|
{ id: 3, category: 'administrative', names: {}, level: 8, point: null },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
getEnclosingAreas: async () => null,
|
||||||
|
getMonuments: async () => ({
|
||||||
|
unavailable: true,
|
||||||
|
code: 'OVERPASS_NOT_CONFIGURED',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const catalog = catalogWith({ features });
|
||||||
|
const result = await catalog.call('get_map_features', {
|
||||||
|
location: { lat: 30.27, lon: -97.74 },
|
||||||
|
kind: 'administrative_areas',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'2 named features (administrative areas) at 30.2700, -97.7400.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.rows.map((row) => [row.name, row.admin_level]),
|
||||||
|
[
|
||||||
|
['Texas', 4],
|
||||||
|
['Travis County', 6],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('get_map_features', {
|
||||||
|
location: { lat: 30.27, lon: -97.74 },
|
||||||
|
kind: 'monuments',
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'unavailable' && /Overpass/.test(error.message),
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
catalog.call('get_map_features', {
|
||||||
|
location: { lat: 30.27, lon: -97.74 },
|
||||||
|
kind: 'named_places',
|
||||||
|
}),
|
||||||
|
(error) => error.code === 'retry_later',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the situation brief combines available sections and marks failures', async () => {
|
||||||
|
const services = {
|
||||||
|
weather,
|
||||||
|
earthquakes: {
|
||||||
|
getSnapshot: async () => [
|
||||||
|
{
|
||||||
|
stableId: 'q',
|
||||||
|
usgsId: 'us1',
|
||||||
|
lat: 30.3,
|
||||||
|
lon: -97.7,
|
||||||
|
mag: 3.1,
|
||||||
|
place: 'near Austin',
|
||||||
|
time: 0,
|
||||||
|
depthKm: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fires: { getSnapshot: async () => ({ keyRequired: true }) },
|
||||||
|
cyclones: {
|
||||||
|
getSnapshot: async () => {
|
||||||
|
throw new Error('upstream down');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
places,
|
||||||
|
};
|
||||||
|
const result = await catalogWith(services).call('situation_brief', {
|
||||||
|
area: { place: 'Austin' },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.summary,
|
||||||
|
'Situation in Austin, Texas: Weather at 30.3100, -97.7500: overcast, 26°C, wind 5 km/h. ' +
|
||||||
|
'1 earthquake of M2.5+ in the last 24 hours in the requested box; strongest M3.1 near Austin.',
|
||||||
|
);
|
||||||
|
assert.deepEqual(Object.keys(result.data.sections), [
|
||||||
|
'weather',
|
||||||
|
'earthquakes',
|
||||||
|
'fires',
|
||||||
|
'cyclones',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(result.data.sections.fires, {
|
||||||
|
unavailable: true,
|
||||||
|
reason: 'Fire detections need a NASA FIRMS key configured for this server',
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.data.sections.cyclones, {
|
||||||
|
unavailable: true,
|
||||||
|
reason: 'unavailable right now',
|
||||||
|
});
|
||||||
|
assert.equal(result.data.sections.aircraft, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HUD caption summarizes the brief through the summary service', async () => {
|
||||||
|
const sent = [];
|
||||||
|
const summary = {
|
||||||
|
async summarize(context) {
|
||||||
|
sent.push(context);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { summary: 'Overcast skies over Austin', error: null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await catalogWith({ weather, summary, places }).call(
|
||||||
|
'get_hud_caption',
|
||||||
|
{ area: { place: 'Austin' } },
|
||||||
|
);
|
||||||
|
assert.equal(result.summary, 'Overcast skies over Austin');
|
||||||
|
assert.equal(sent[0].location, 'Austin, Texas');
|
||||||
|
assert.match(sent[0].observations[0], /^Weather at /);
|
||||||
|
const failing = {
|
||||||
|
summarize: async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 502,
|
||||||
|
data: { summary: null },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
catalogWith({ weather, summary: failing, places }).call('get_hud_caption', {
|
||||||
|
area: { place: 'Austin' },
|
||||||
|
}),
|
||||||
|
(error) => error instanceof ToolError && error.code === 'unavailable',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user