From a747b8a4e02e6bb1c781c1a470bc35400a2203b7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:41:30 -0700 Subject: [PATCH] feat: add a military awareness tool --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 3 +- src/tools/index.js | 7 +- src/tools/queries/brief.js | 126 +++++++++++++++++++++---- src/tools/queries/environment.test.mjs | 78 +++++++++++++++ 5 files changed, 197 insertions(+), 19 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df6793080..d6673919e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -17,7 +17,7 @@ stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an area, submarine cables, datacenters and dams, regional briefs, tropical cyclones, fire perimeters, terrain height, military installations and map features, plus a -combined situation brief, the app's heads-up display caption, and a link that +combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. 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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 811a47022..8186f11ed 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -53,7 +53,7 @@ relative `/api/...` paths through an injected `fetchImpl`, so the same tool code runs wherever an application routes those paths. 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 +`situation_brief` and `military_awareness` run each section whose services are supplied and mark the others unavailable. `app` is `{ baseUrl }`, the address links open. 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 @@ -140,5 +140,6 @@ public feeds the sources already use. | `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, ships and cyclones for an area, by section | +| `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section | | `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | | `open_in_gods_eye_view` | `app` | A share link looking straight down on an area, with chosen layers on | diff --git a/src/tools/index.js b/src/tools/index.js index 1c35bdcc6..bedf234de 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -11,7 +11,11 @@ import { } from './queries/aviation.js'; import { getWeatherMap, getWind } from './queries/atmosphere.js'; import { findSubmarineCables } from './queries/cables.js'; -import { getHudCaption, situationBrief } from './queries/brief.js'; +import { + getHudCaption, + militaryAwareness, + situationBrief, +} from './queries/brief.js'; import { findMilitaryInstallations, getCyclones, @@ -106,6 +110,7 @@ export const coreTools = Object.freeze([ findMilitaryInstallations, getMapFeatures, situationBrief, + militaryAwareness, getHudCaption, openInGodsEyeView, ]); diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 9c51f36a3..44dcbaf38 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -1,13 +1,25 @@ /** 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 { + AREA_SCHEMA, + POINT_SCHEMA, + areaCenter, + resolveArea, + resolvePoint, +} from '../area.js'; import { aircraftInArea } from './aviation.js'; -import { getCyclones, getWeather } from './environment.js'; +import { + findMilitaryInstallations, + getCyclones, + getWeather, +} from './environment.js'; import { getActiveFires, getEarthquakes } from './hazards.js'; import { vesselsInArea } from './maritime.js'; const SECTION_LIMIT = 5; +const AWARENESS_LIMIT = 10; +const AWARENESS_RADIUS_KM = 250; /** Brief sections: the tool each one reuses and the services it needs. */ const SECTIONS = [ @@ -45,14 +57,37 @@ const SECTIONS = [ }, ]; -/** - * 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 = { +/** Military contacts around a point, as the app's military awareness panel. */ +const AWARENESS_SECTIONS = [ + { + key: 'military_aircraft', + tool: aircraftInArea, + args: (area) => ({ + area: area.argument, + military: true, + limit: AWARENESS_LIMIT, + }), + }, + { + key: 'aircraft', + tool: aircraftInArea, + args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), + }, + { + key: 'vessels', + tool: vesselsInArea, + args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), + }, + { + key: 'installations', + tool: findMilitaryInstallations, + args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }), + }, +]; + +/** The resolved area, plus the argument sections receive in its place. */ +function sectionArea(resolved) { + return { ...resolved, argument: resolved.center ? { @@ -64,8 +99,15 @@ async function buildBrief(args, { services, signal }) { bbox: [resolved.west, resolved.south, resolved.east, resolved.north], }, }; - const center = areaCenter(resolved); - const sections = SECTIONS.filter(({ tool }) => +} + +/** + * Run every section whose services are supplied. A failing section is + * reported as unavailable instead of failing the whole answer. + */ +async function runSections(all, area, { services, signal }) { + const center = areaCenter(area); + const sections = all.filter(({ tool }) => tool.requires.every((key) => services[key] != null), ); const results = await Promise.allSettled( @@ -74,22 +116,32 @@ async function buildBrief(args, { services, signal }) { ), ); signal?.throwIfAborted(); - const brief = {}; + const answers = {}; const lines = []; sections.forEach(({ key }, index) => { const result = results[index]; if (result.status === 'fulfilled') { - brief[key] = { summary: result.value.summary, data: result.value.data }; + answers[key] = { summary: result.value.summary, data: result.value.data }; lines.push(result.value.summary); } else { const known = result.reason instanceof ToolError; - brief[key] = { + answers[key] = { unavailable: true, reason: known ? result.reason.message : 'unavailable right now', }; } }); - return { area: resolved, center, brief, lines }; + return { center, answers, lines }; +} + +async function buildBrief(args, { services, signal }) { + // Sections receive the resolved box so a place name is looked up once. + const area = sectionArea(await resolveArea(args.area, { services, signal })); + const { center, answers, lines } = await runSections(SECTIONS, area, { + services, + signal, + }); + return { area, center, brief: answers, lines }; } export const situationBrief = defineTool({ @@ -143,3 +195,45 @@ export const getHudCaption = defineTool({ }; }, }); + +export const militaryAwareness = defineTool({ + name: 'military_awareness', + title: 'Military awareness', + description: + 'Military contacts around a location, as the military awareness panel ' + + 'shows them: military aircraft, other aircraft, ships and mapped ' + + `military installations within ${AWARENESS_RADIUS_KM} km by default, ` + + 'each nearest first. Sections that are unavailable are marked as such.', + inputSchema: { + type: 'object', + properties: { + location: POINT_SCHEMA, + radius_km: { type: 'number', minimum: 1, maximum: AWARENESS_RADIUS_KM }, + }, + required: ['location'], + additionalProperties: false, + }, + requires: ['military'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM; + const area = sectionArea( + await resolveArea( + { lat: point.lat, lon: point.lon, radius_km: radiusKm }, + { services, signal }, + ), + ); + const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, { + services, + signal, + }); + return { + summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`, + data: { + location: point, + radius_km: radiusKm, + sections: answers, + }, + }; + }, +}); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 4c9337a96..bd4f48bd3 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -453,3 +453,81 @@ test('the HUD caption summarizes the brief through the summary service', async ( (error) => error instanceof ToolError && error.code === 'unavailable', ); }); + +test('military awareness gathers contacts around a point and marks failures', async () => { + const contact = (id, latitude, longitude) => ({ + id, + reference: id, + latitude, + longitude, + callsign: 'RCH123', + baroAltitudeM: 9000, + onGround: false, + positionTimeMs: Date.UTC(2026, 0, 1), + }); + const military = { + getSnapshot: async () => ({ + records: [contact('ae1234', 32.8, -117.1), contact('ae9999', 40, -100)], + complete: true, + source: 'Military feed', + freshness: 'current', + }), + }; + const aircraft = { + getSnapshot: async () => { + throw new Error('upstream down'); + }, + }; + const installations = { + getMappedSites: async () => ({ + source: 'OpenStreetMap', + records: [ + { + id: 'osm:1', + kind: 'installation', + class: 'base', + name: 'Naval Base Point Loma', + latitude: 32.6941, + longitude: -117.2494, + }, + ], + }), + }; + const catalog = catalogWith({ military, aircraft, installations }); + const result = await catalog.call('military_awareness', { + location: { lat: 32.7, lon: -117.2 }, + radius_km: 100, + }); + assert.equal( + result.summary, + 'Military awareness within 100 km of 32.7000, -117.2000: ' + + '1 military aircraft in 100 km around 32.700, -117.200. ' + + '1 mapped military installation in 100 km around 32.700, -117.200.', + ); + assert.deepEqual(Object.keys(result.data.sections), [ + 'military_aircraft', + 'aircraft', + 'installations', + ]); + assert.deepEqual( + result.data.sections.military_aircraft.data.rows.map((row) => row.id), + ['ae1234'], + ); + assert.deepEqual(result.data.sections.aircraft, { + unavailable: true, + reason: 'unavailable right now', + }); + assert.equal(result.data.radius_km, 100); + assert.equal( + (await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } })) + .data.radius_km, + 250, + ); + await assert.rejects( + catalog.call('military_awareness', { + location: { lat: 0, lon: 0 }, + radius_km: 500, + }), + /radius_km/, + ); +});