mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat: add ship tools
Add vessels_in_area, find_vessel and get_vessel_track over the live vessel source, and a ships section in situation_brief. Without an AIS key the source's own message reaches clients as an unavailable feed.
This commit is contained in:
1 parent
0a74e36ce8
commit
e6cbcc31ad
10 files changed
+393
-5
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
- 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, ship, satellite, camera, radio,
|
||||
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
|
||||
|
||||
@@ -7,7 +7,8 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
||||
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
||||
reading from a running app's `/api` routes (default `http://localhost:4173`).
|
||||
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), ships (in an area,
|
||||
by identifier, and tracks), 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, transit vehicles, weather, regional briefs, tropical cyclones, fire
|
||||
|
||||
+4
-1
@@ -99,6 +99,9 @@ public feeds the sources already use.
|
||||
| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration |
|
||||
| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points |
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility |
|
||||
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first |
|
||||
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
||||
@@ -116,5 +119,5 @@ public feeds the sources already use.
|
||||
| `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 |
|
||||
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
|
||||
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
|
||||
@@ -876,5 +876,6 @@
|
||||
"src/tools/queries/places.test.mjs",
|
||||
"src/layers/transit/source.test.mjs",
|
||||
"src/tools/queries/mobility.test.mjs",
|
||||
"src/tools/queries/environment.test.mjs"
|
||||
"src/tools/queries/environment.test.mjs",
|
||||
"src/tools/queries/maritime.test.mjs"
|
||||
]
|
||||
@@ -3096,6 +3096,7 @@
|
||||
"src/tools/queries/mobility.js",
|
||||
"src/tools/queries/environment.js",
|
||||
"src/tools/queries/brief.js",
|
||||
"src/tools/queries/maritime.js",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/sources/tle.js",
|
||||
"src/data/satellitePass.js"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createTransitSource } from '../../src/layers/transit/source.js';
|
||||
import { createApplicationRequestServices } from '../../src/services/requests.js';
|
||||
import {
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createOpenSkySource,
|
||||
} from '../../src/sources/live/standalone.js';
|
||||
import {
|
||||
@@ -55,6 +56,10 @@ export function createLocalToolServices(options = {}) {
|
||||
launches: createLaunchSource({ fetchImpl }),
|
||||
aircraft: createOpenSkySource({ fetchImpl }),
|
||||
military: createAdsbLolSource({ fetchImpl }),
|
||||
vessels: createAisStreamSource({
|
||||
fetchImpl,
|
||||
origin: () => new URL(options.apiBase ?? DEFAULT_API_BASE).origin,
|
||||
}),
|
||||
satellites: createSatelliteSource({ fetchImpl }),
|
||||
cctv: createCctvSource({ fetchImpl }),
|
||||
radio: createRadioSource({ fetchImpl }),
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getWeather,
|
||||
} from './queries/environment.js';
|
||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||
import {
|
||||
findVessel,
|
||||
getVesselTrack,
|
||||
vesselsInArea,
|
||||
} from './queries/maritime.js';
|
||||
import { getBikeShare, getTransitVehicles } from './queries/mobility.js';
|
||||
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
|
||||
import {
|
||||
@@ -63,6 +68,9 @@ export const coreTools = Object.freeze([
|
||||
findAircraft,
|
||||
getAircraftTrack,
|
||||
getAircraftInfo,
|
||||
vesselsInArea,
|
||||
findVessel,
|
||||
getVesselTrack,
|
||||
nextSatellitePass,
|
||||
satellitesOverhead,
|
||||
findCctvCameras,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import { vesselsInArea } from './maritime.js';
|
||||
|
||||
const SECTION_LIMIT = 5;
|
||||
|
||||
@@ -32,6 +33,11 @@ const SECTIONS = [
|
||||
tool: aircraftInArea,
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'vessels',
|
||||
tool: vesselsInArea,
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'cyclones',
|
||||
tool: getCyclones,
|
||||
@@ -91,7 +97,7 @@ export const situationBrief = defineTool({
|
||||
title: 'Situation brief',
|
||||
description:
|
||||
'One overview of an area: current weather, recent earthquakes, active ' +
|
||||
'fires, aircraft overhead and tropical cyclones, each summarized with ' +
|
||||
'fires, aircraft overhead, ships and tropical cyclones, each summarized with ' +
|
||||
'its top items. Sections that are unavailable are marked as such.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/** Maritime queries over the live vessel source (src/sources/live). */
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
|
||||
const MMSI_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
pattern: '^\\d{5,10}$',
|
||||
description: 'Maritime Mobile Service Identity, such as "366999712".',
|
||||
});
|
||||
const MAX_TRACK_POINTS = 200;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||
|
||||
function vesselRow(record, from) {
|
||||
const point = { lat: record.latitude, lon: record.longitude };
|
||||
return {
|
||||
mmsi: record.id,
|
||||
name: text(record.name),
|
||||
imo: text(record.imo),
|
||||
type: text(record.type),
|
||||
destination: text(record.destination),
|
||||
lat: round(record.latitude, 5),
|
||||
lon: round(record.longitude, 5),
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
course_deg: round(record.courseDeg, 0),
|
||||
heading_deg: round(record.headingDeg, 0),
|
||||
observed_at: isoTime(record.observedAtMs),
|
||||
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotInfo(snapshot) {
|
||||
return {
|
||||
source: snapshot.source ?? null,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
observed_at: isoTime(snapshot.observedAtMs),
|
||||
freshness: snapshot.freshness ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
export const vesselsInArea = defineTool({
|
||||
name: 'vessels_in_area',
|
||||
title: 'Vessels in an area',
|
||||
description:
|
||||
'Ships currently reported by AIS inside an area, nearest the center ' +
|
||||
'first, optionally filtered by vessel type.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
type: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 40,
|
||||
description: 'Vessel type to match, such as "tanker" or "cargo".',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['vessels'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const snapshot = await services.vessels.getSnapshot({}, { signal });
|
||||
const wanted = args.type?.toLowerCase();
|
||||
const rows = snapshot.records
|
||||
.filter(
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
(!wanted || String(record.type).toLowerCase().includes(wanted)),
|
||||
)
|
||||
.map((record) => vesselRow(record, center))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const kind = args.type ? `${args.type} vessel` : 'vessel';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, kind)} in ${area.label}` +
|
||||
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
|
||||
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const findVessel = defineTool({
|
||||
name: 'find_vessel',
|
||||
title: 'Find a vessel',
|
||||
description:
|
||||
'Find ships currently reported anywhere by MMSI, IMO number or name. ' +
|
||||
'Names match when they contain the given text.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mmsi: MMSI_SCHEMA,
|
||||
imo: { type: 'string', pattern: '^\\d{7}$' },
|
||||
name: { type: 'string', minLength: 2, maxLength: 60 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['vessels'],
|
||||
async run(args, { services, signal }) {
|
||||
const given = ['mmsi', 'imo', 'name'].filter((key) => args[key] != null);
|
||||
if (given.length !== 1)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'Give exactly one of mmsi, imo or name',
|
||||
);
|
||||
const [key] = given;
|
||||
const wanted = args[key].trim().toUpperCase();
|
||||
const matches = {
|
||||
mmsi: (record) => record.id === wanted,
|
||||
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
|
||||
name: (record) => String(record.name).toUpperCase().includes(wanted),
|
||||
}[key];
|
||||
const snapshot = await services.vessels.getSnapshot({}, { signal });
|
||||
const rows = snapshot.records
|
||||
.filter(matches)
|
||||
.map((record) => vesselRow(record));
|
||||
return {
|
||||
summary: rows.length
|
||||
? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.`
|
||||
: `No vessel with ${key} ${args[key]} is currently reported.`,
|
||||
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getVesselTrack = defineTool({
|
||||
name: 'get_vessel_track',
|
||||
title: 'Vessel track',
|
||||
description:
|
||||
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
|
||||
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { mmsi: MMSI_SCHEMA },
|
||||
required: ['mmsi'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['vessels'],
|
||||
async run(args, { services, signal }) {
|
||||
const track = await services.vessels.getTrack(args.mmsi, { signal });
|
||||
const points = track.records
|
||||
.filter((point) => Number.isFinite(point.observedAtMs))
|
||||
.sort((a, b) => a.observedAtMs - b.observedAtMs);
|
||||
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
||||
return {
|
||||
summary: points.length
|
||||
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
|
||||
: `No recent track is available for ${args.mmsi}.`,
|
||||
data: {
|
||||
mmsi: args.mmsi,
|
||||
total: points.length,
|
||||
returned: kept.length,
|
||||
thinned: kept.length < points.length,
|
||||
points: kept.map((point) => ({
|
||||
time: isoTime(point.observedAtMs),
|
||||
lat: round(point.latitude, 5),
|
||||
lon: round(point.longitude, 5),
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
|
||||
const vessel = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
reference: id,
|
||||
latitude,
|
||||
longitude,
|
||||
name: `SHIP ${id}`,
|
||||
imo: '',
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speedMps: 6.17,
|
||||
courseDeg: 181.2,
|
||||
headingDeg: 180,
|
||||
observedAtMs: Date.UTC(2026, 0, 1),
|
||||
altitudeDatum: 'sea-surface',
|
||||
...extra,
|
||||
});
|
||||
const records = [
|
||||
vessel('366999712', 37.81, -122.41, {
|
||||
name: 'GOLDEN BEAR',
|
||||
imo: '9123456',
|
||||
type: 'Passenger',
|
||||
}),
|
||||
vessel('538001234', 37.75, -122.3, {
|
||||
name: 'PACIFIC TRADER',
|
||||
imo: 'IMO7654321',
|
||||
type: 'Tanker',
|
||||
destination: 'OAKLAND',
|
||||
}),
|
||||
vessel('235000001', 51.5, 1.5),
|
||||
];
|
||||
const vessels = (extra = {}) => ({
|
||||
async getSnapshot() {
|
||||
return {
|
||||
records,
|
||||
source: 'AISStream',
|
||||
coverage: 'received AIS positions',
|
||||
observedAtMs: Date.UTC(2026, 0, 1),
|
||||
freshness: 'current',
|
||||
...extra,
|
||||
};
|
||||
},
|
||||
async getTrack(mmsi) {
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
latitude: 37.8,
|
||||
longitude: -122.4,
|
||||
observedAtMs: Date.UTC(2026, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
latitude: 37.7,
|
||||
longitude: -122.5,
|
||||
observedAtMs: Date.UTC(2026, 0, 1, 0),
|
||||
},
|
||||
{ latitude: 37.6, longitude: -122.6, observedAtMs: null },
|
||||
].filter(() => mmsi === '366999712'),
|
||||
complete: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
const bay = { lat: 37.78, lon: -122.4, radius_km: 20 };
|
||||
|
||||
test('vessels in an area are nearest first and filterable by type', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
const all = await catalog.call('vessels_in_area', { area: bay });
|
||||
assert.equal(all.summary, '2 vessels in 20 km around 37.780, -122.400.');
|
||||
assert.deepEqual(
|
||||
all.data.rows.map((row) => row.mmsi),
|
||||
['366999712', '538001234'],
|
||||
);
|
||||
assert.deepEqual(all.data.rows[0], {
|
||||
mmsi: '366999712',
|
||||
name: 'GOLDEN BEAR',
|
||||
imo: '9123456',
|
||||
type: 'Passenger',
|
||||
destination: null,
|
||||
lat: 37.81,
|
||||
lon: -122.41,
|
||||
speed_mps: 6.2,
|
||||
course_deg: 181,
|
||||
heading_deg: 180,
|
||||
observed_at: '2026-01-01T00:00:00.000Z',
|
||||
distance_km: 3.4,
|
||||
});
|
||||
assert.equal(all.data.source, 'AISStream');
|
||||
const tankers = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
type: 'tanker',
|
||||
});
|
||||
assert.equal(
|
||||
tankers.summary,
|
||||
'1 tanker vessel in 20 km around 37.780, -122.400.',
|
||||
);
|
||||
const stale = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels({ freshness: 'stale' }) },
|
||||
});
|
||||
assert.match(
|
||||
(await stale.call('vessels_in_area', { area: bay })).summary,
|
||||
/\(data may be stale\)\.$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('vessels are found by exactly one identifier', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await catalog.call('find_vessel', { mmsi: '366999712' })).data.rows.map(
|
||||
(row) => row.name,
|
||||
),
|
||||
['GOLDEN BEAR'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await catalog.call('find_vessel', { imo: '7654321' })).data.rows.map(
|
||||
(row) => row.mmsi,
|
||||
),
|
||||
['538001234'],
|
||||
);
|
||||
const byName = await catalog.call('find_vessel', { name: 'trader' });
|
||||
assert.equal(byName.summary, 'Found 1 vessel with name trader.');
|
||||
assert.equal(
|
||||
(await catalog.call('find_vessel', { mmsi: '99999' })).summary,
|
||||
'No vessel with mmsi 99999 is currently reported.',
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_vessel', { mmsi: '366999712', name: 'xy' }),
|
||||
/exactly one/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_vessel', { mmsi: 'abc' }),
|
||||
/must match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel tracks are ordered and drop untimed positions', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
const track = await catalog.call('get_vessel_track', { mmsi: '366999712' });
|
||||
assert.equal(
|
||||
track.summary,
|
||||
'2 positions for 366999712 from 2026-01-01T00:00:00.000Z to 2026-01-01T01:00:00.000Z.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
track.data.points.map((point) => point.lat),
|
||||
[37.7, 37.8],
|
||||
);
|
||||
assert.equal(
|
||||
(await catalog.call('get_vessel_track', { mmsi: '235000001' })).summary,
|
||||
'No recent track is available for 235000001.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a missing AIS key surfaces as an unavailable feed', async () => {
|
||||
const keyless = {
|
||||
getSnapshot: async () => {
|
||||
throw new LiveSourceError('unavailable', 'AISSTREAM_API_KEY not set');
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: keyless },
|
||||
});
|
||||
await assert.rejects(
|
||||
catalog.call('vessels_in_area', { area: bay }),
|
||||
(error) =>
|
||||
error.code === 'unavailable' &&
|
||||
error.message === 'AISSTREAM_API_KEY not set',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user