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:
Sameh Khamis committed 2026-10-01 12:28:34 -07:00
1 parent 8c97fed92d
commit 0a74e36ce8
11 files changed
+1093 -23

No files matched your search

+139
View File
@@ -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 },
};
},
});
+438
View File
@@ -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) },
};
},
});
+455
View File
@@ -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',
);
});