mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
One provenanceLine() builds STATE | text | LAYERS for the HUD and get_hud_caption. hasWord is one lookaround regex, capAtWord one grapheme pass. Feed-state helpers are imported from layerSnapshot.js directly, and the kept-whole sanitizer cases are one table.
324 lines
9.4 KiB
JavaScript
324 lines
9.4 KiB
JavaScript
/** Composite queries that combine other tools' answers for one area. */
|
|
|
|
import { suggestView } from '../views.js';
|
|
import {
|
|
feedProvenanceEnvelope,
|
|
hudSummaryMatchesProvenance,
|
|
provenanceLine,
|
|
provenanceTag,
|
|
} from '../../data/layerSnapshot.js';
|
|
import { defineTool, ToolError } from '../catalog.js';
|
|
import {
|
|
AREA_SCHEMA,
|
|
POINT_SCHEMA,
|
|
areaCenter,
|
|
resolveArea,
|
|
resolvePoint,
|
|
} from '../area.js';
|
|
|
|
const SECTION_LIMIT = 5;
|
|
const AWARENESS_LIMIT = 10;
|
|
const AWARENESS_RADIUS_KM = 250;
|
|
|
|
/** Brief sections: the tool each one calls and the arguments it passes. */
|
|
const SECTIONS = [
|
|
{
|
|
key: 'weather',
|
|
label: 'Weather',
|
|
tool: 'get_weather',
|
|
args: (area, center) => ({
|
|
location: area.argument.place
|
|
? { place: area.argument.place }
|
|
: { lat: center.lat, lon: center.lon },
|
|
}),
|
|
},
|
|
{
|
|
key: 'earthquakes',
|
|
layer: 'earthquakes',
|
|
label: 'Earthquakes',
|
|
tool: 'get_earthquakes',
|
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
|
},
|
|
{
|
|
key: 'fires',
|
|
layer: 'local-firms',
|
|
label: 'Active fires',
|
|
tool: 'get_active_fires',
|
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
|
},
|
|
{
|
|
key: 'aircraft',
|
|
layer: 'flights',
|
|
label: 'Aircraft',
|
|
tool: 'aircraft_in_area',
|
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
|
},
|
|
{
|
|
key: 'vessels',
|
|
layer: 'ais-live-vessels',
|
|
label: 'Ships',
|
|
tool: 'vessels_in_area',
|
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
|
},
|
|
{
|
|
key: 'cyclones',
|
|
layer: 'weather-cyclones',
|
|
label: 'Tropical cyclones',
|
|
tool: 'get_cyclones',
|
|
args: (area) => ({ area: area.argument }),
|
|
},
|
|
];
|
|
|
|
/** Military contacts around a point, as the app's military awareness panel. */
|
|
const AWARENESS_SECTIONS = [
|
|
{
|
|
key: 'military_aircraft',
|
|
tool: 'aircraft_in_area',
|
|
args: (area) => ({
|
|
area: area.argument,
|
|
military: true,
|
|
limit: AWARENESS_LIMIT,
|
|
}),
|
|
},
|
|
{
|
|
key: 'aircraft',
|
|
tool: 'aircraft_in_area',
|
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
|
},
|
|
{
|
|
key: 'vessels',
|
|
tool: 'vessels_in_area',
|
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
|
},
|
|
{
|
|
key: 'installations',
|
|
tool: 'find_military_installations',
|
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
|
},
|
|
];
|
|
|
|
/** A section's feed state for the caption, from what its result reported. */
|
|
function sectionFeedState(section) {
|
|
if (section.unavailable) return 'unavailable';
|
|
const data = section.data || {};
|
|
if (data.freshness === 'stale' || data.stale === true) return 'stale';
|
|
// Answered, but some of the feeds or satellites behind it did not.
|
|
if (data.missing_sources?.length || data.unavailable_feeds?.length)
|
|
return 'degraded';
|
|
return 'nominal';
|
|
}
|
|
|
|
/**
|
|
* The resolved area, plus the argument sections receive. A named place is
|
|
* passed by name, so sections resolve it to the same point and label as a
|
|
* direct query does (place lookups are cached).
|
|
*/
|
|
function sectionArea(resolved, original) {
|
|
return {
|
|
...resolved,
|
|
argument: original?.place
|
|
? { place: original.place }
|
|
: resolved.center
|
|
? {
|
|
lat: resolved.center.lat,
|
|
lon: resolved.center.lon,
|
|
radius_km: resolved.center.radiusKm,
|
|
}
|
|
: {
|
|
bbox: [
|
|
resolved.west,
|
|
resolved.south,
|
|
resolved.east,
|
|
resolved.north,
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run every section whose tool the catalog offers, through the catalog so
|
|
* replaced tools and interceptors apply. A failing section is reported as
|
|
* unavailable instead of failing the whole answer.
|
|
*/
|
|
async function runSections(all, area, { tools, signal }) {
|
|
const center = areaCenter(area);
|
|
const sections = all.filter(({ tool }) => tools.has(tool));
|
|
const results = await Promise.allSettled(
|
|
sections.map(({ tool, args: build }) =>
|
|
tools.call(tool, build(area, center)),
|
|
),
|
|
);
|
|
signal?.throwIfAborted();
|
|
const answers = {};
|
|
const lines = [];
|
|
sections.forEach(({ key }, index) => {
|
|
const result = results[index];
|
|
if (result.status === 'fulfilled') {
|
|
answers[key] = { summary: result.value.summary, data: result.value.data };
|
|
lines.push(result.value.summary);
|
|
} else {
|
|
const known = result.reason instanceof ToolError;
|
|
answers[key] = {
|
|
unavailable: true,
|
|
reason: known ? result.reason.message : 'unavailable right now',
|
|
};
|
|
}
|
|
});
|
|
return { center, answers, lines };
|
|
}
|
|
|
|
async function buildBrief(args, { services, signal, tools }) {
|
|
// Sections receive the resolved box so a place name is looked up once.
|
|
const area = sectionArea(
|
|
await resolveArea(args.area, { services, signal }),
|
|
args.area,
|
|
);
|
|
const { center, answers, lines } = await runSections(SECTIONS, area, {
|
|
tools,
|
|
signal,
|
|
});
|
|
return { area, center, brief: answers, 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, ships 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: {
|
|
view: suggestView(context.services, {
|
|
area,
|
|
layers: SECTIONS.filter(({ key }) => brief[key])
|
|
.map(({ layer }) => layer)
|
|
.filter(Boolean),
|
|
}),
|
|
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, brief, lines } = await buildBrief(args, context);
|
|
// The app's caption context: place labels and each section as a layer
|
|
// with the feed state its result reported, failures included.
|
|
const layers = SECTIONS.filter(({ key }) => brief[key]).map(
|
|
({ key, label }) => ({
|
|
id: key,
|
|
name: label,
|
|
enabled: true,
|
|
feedState: sectionFeedState(brief[key]),
|
|
}),
|
|
);
|
|
const feedProvenance = feedProvenanceEnvelope(layers);
|
|
const response = await context.services.summary.summarize(
|
|
{
|
|
placeLabels: [area.label],
|
|
location: area.label,
|
|
center,
|
|
observations: lines,
|
|
enabledLayerLabels: layers.map((layer) => layer.name),
|
|
enabledLayers: layers.map(({ id, name, feedState }) => ({
|
|
id,
|
|
name,
|
|
feedState,
|
|
})),
|
|
feedProvenance,
|
|
},
|
|
{ signal: context.signal },
|
|
);
|
|
const words = response?.data?.summary;
|
|
if (!response?.ok || typeof words !== 'string' || !words)
|
|
throw new ToolError('unavailable', 'The caption service did not answer');
|
|
// As on the HUD: the app's own state leads and its layers trail, and a
|
|
// reply that names another state than the app's keeps only the place.
|
|
const caption = provenanceLine(
|
|
hudSummaryMatchesProvenance(response.data, feedProvenance)
|
|
? words
|
|
: area.label,
|
|
provenanceTag(feedProvenance),
|
|
);
|
|
return {
|
|
summary: caption,
|
|
data: {
|
|
area: area.label,
|
|
caption,
|
|
feedState: feedProvenance.overall,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
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, tools }) {
|
|
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, {
|
|
tools,
|
|
signal,
|
|
});
|
|
return {
|
|
summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`,
|
|
data: {
|
|
view: suggestView(services, {
|
|
area,
|
|
layers: ['military', 'ais-live-vessels', 'military-installations'],
|
|
}),
|
|
location: point,
|
|
radius_km: radiusKm,
|
|
sections: answers,
|
|
},
|
|
};
|
|
},
|
|
});
|