mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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`
|
||||
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
|
||||
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
|
||||
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
|
||||
pass over a point, and those overhead now), public cameras (including a
|
||||
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
|
||||
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).
|
||||
|
||||
+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
|
||||
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
|
||||
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`;
|
||||
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
|
||||
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 |
|
||||
| `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_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/places.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/places.js",
|
||||
"src/tools/queries/mobility.js",
|
||||
"src/tools/queries/environment.js",
|
||||
"src/tools/queries/brief.js",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/sources/tle.js",
|
||||
"src/data/satellitePass.js"
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js';
|
||||
import { createBikeshareSource } from '../../src/layers/bikeshare/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 { createFirmsSource } from '../../src/layers/firms/source.js';
|
||||
import { createInstallationSource } from '../../src/layers/installations/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 { createSatelliteSource } from '../../src/layers/satellites/source.js';
|
||||
import { createTransitSource } from '../../src/layers/transit/source.js';
|
||||
import { createApplicationRequestServices } from '../../src/services/requests.js';
|
||||
import {
|
||||
createAdsbLolSource,
|
||||
createOpenSkySource,
|
||||
@@ -44,6 +48,7 @@ export function createApiFetch({
|
||||
/** Construct every service Core's tools read, backed by the local server. */
|
||||
export function createLocalToolServices(options = {}) {
|
||||
const fetchImpl = createApiFetch(options);
|
||||
const requests = createApplicationRequestServices({ fetchImpl });
|
||||
return {
|
||||
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
|
||||
fires: createFirmsSource({ fetchImpl }),
|
||||
@@ -60,6 +65,17 @@ export function createLocalToolServices(options = {}) {
|
||||
getStations: createBikeshareSource({ fetchImpl }).getStations,
|
||||
},
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
getAircraftInfo,
|
||||
getAircraftTrack,
|
||||
} 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 { getBikeShare, getTransitVehicles } from './queries/mobility.js';
|
||||
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
|
||||
@@ -63,4 +73,13 @@ export const coreTools = Object.freeze([
|
||||
planRoute,
|
||||
getBikeShare,
|
||||
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 { createApiFetch } from '../../server/mcp/services.js';
|
||||
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
|
||||
import { coreTools } from './index.js';
|
||||
|
||||
const usgs = {
|
||||
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(byId.get(1).result.serverInfo.name, 'gods-eye-view');
|
||||
assert.equal(byId.get(null).error.code, -32700);
|
||||
// Every Core tool's services are composed locally.
|
||||
assert.deepEqual(
|
||||
byId.get(2).result.tools.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',
|
||||
],
|
||||
coreTools.map((tool) => tool.name),
|
||||
);
|
||||
assert.equal(
|
||||
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