mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat(voice): attach feed-state provenance to analyst and HUD answers
Share one layer snapshot envelope so analyst_query, get_current_view_state, and HUD summaries narrate the same chip feed-state instead of treating stale or degraded feeds as live. Co-authored-by: Matt Van Horn <[email protected]>
This commit is contained in:
1 parent
aacfa06a31
commit
cfb0d6e247
15 files changed
+881
-118
No files matched your search
@@ -13,6 +13,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Attach honest layer feed-state provenance (nominal/loading/degraded/stale/
|
||||||
|
fallback/unavailable) to `analyst_query`, `get_current_view_state`, and HUD
|
||||||
|
summary context so voice and HUD answers cannot narrate a stale or degraded
|
||||||
|
feed as live.
|
||||||
|
|
||||||
- Separate terrain, traffic, FIRMS and GBFS middleware into focused provider
|
- Separate terrain, traffic, FIRMS and GBFS middleware into focused provider
|
||||||
modules, preserving local configuration, routes and cache/error behavior.
|
modules, preserving local configuration, routes and cache/error behavior.
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -1,5 +1,20 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
## Voice and HUD feed-state provenance
|
||||||
|
|
||||||
|
Voice and HUD answers share one layer snapshot envelope (`src/data/layerSnapshot.js`)
|
||||||
|
so they cannot narrate a stale, degraded, fallback, loading, or unavailable feed as
|
||||||
|
live. `layerFeedState` — the same classifier the Data Layers chips use — stamps
|
||||||
|
each queried or enabled layer with `nominal` / `loading` / `degraded` / `stale` /
|
||||||
|
`fallback` / `unavailable`, or `off` when the layer is disabled. `analyst_query`
|
||||||
|
and `get_current_view_state` carry `feedProvenance` (`overall`, per-layer rows,
|
||||||
|
and a mechanical `note`). The HUD summary context sends the same snapshots; the
|
||||||
|
five-word AI line must include a non-nominal feedState token, and the
|
||||||
|
deterministic telemetry fallback appends e.g. `STALE LIVE FLIGHTS`. Confirmations
|
||||||
|
still report only what the tool result returned.
|
||||||
|
|
||||||
|
## Terrain, traffic, fire and bike-share provider modules
|
||||||
|
|
||||||
## Terrain, traffic, fire and bike-share provider modules
|
## Terrain, traffic, fire and bike-share provider modules
|
||||||
|
|
||||||
Local composition now imports separate Node modules for Re:Earth heights,
|
Local composition now imports separate Node modules for Re:Earth heights,
|
||||||
@@ -2426,13 +2441,14 @@ silently demoting every later lookup for the session.
|
|||||||
2. **Contacts OFF** → "nearby" means **in view**; "near \<place\>" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera.
|
2. **Contacts OFF** → "nearby" means **in view**; "near \<place\>" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera.
|
||||||
3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction.
|
3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction.
|
||||||
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
||||||
|
5. **Feed-state provenance rides with every count**: `feedProvenance` is the same `layerFeedState` the chips show. A non-nominal `overall` must be spoken with the count ("12 flights in view, STALE"); an unavailable feed is not a confident zero; fallback is valid data and must be named as fallback. Mechanism: `src/data/layerSnapshot.js`, attached by `analystEngine` / `get_current_view_state` / HUD summary context.
|
||||||
- **Degradation**: without `OPENAI_API_KEY`, `/api/realtime/token` returns 503 and the mic button surfaces the error; the rest of the app is unaffected.
|
- **Degradation**: without `OPENAI_API_KEY`, `/api/realtime/token` returns 503 and the mic button surfaces the error; the rest of the app is unaffected.
|
||||||
|
|
||||||
### AI HUD Summary (June 2026)
|
### AI HUD Summary (June 2026)
|
||||||
|
|
||||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers with `feedState`) — the model is instructed not to infer from coordinates or invent a feed-state. A non-nominal enabled layer requires that feedState token in the five words.
|
||||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort), which itself appends a provenance tag (`STALE LIVE FLIGHTS`) when an enabled feed is not nominal; typewriter animation on update.
|
||||||
|
|
||||||
### Map Stack Switcher (June 2026)
|
### Map Stack Switcher (June 2026)
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import {
|
|||||||
normalizeRegionalPlace,
|
normalizeRegionalPlace,
|
||||||
normalizeRegionalWeather,
|
normalizeRegionalWeather,
|
||||||
} from '../../src/data/regionalBrief.js';
|
} from '../../src/data/regionalBrief.js';
|
||||||
import { keylessHudSummaryResponse } from '../../src/hudSummaryResponse.js';
|
import { HUD_SUMMARY_INSTRUCTIONS, keylessHudSummaryResponse } from '../../src/hudSummaryResponse.js';
|
||||||
import { parseEnv as parseDotenvText } from 'node:util';
|
import { parseEnv as parseDotenvText } from 'node:util';
|
||||||
import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs';
|
import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -2767,13 +2767,7 @@ export function openAiRealtimeProxy() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||||
instructions: [
|
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
||||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
|
||||||
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
|
|
||||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
|
||||||
'Do not infer from coordinates or invent a place.',
|
|
||||||
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
|
||||||
].join(' '),
|
|
||||||
input: JSON.stringify(context),
|
input: JSON.stringify(context),
|
||||||
reasoning: { effort: 'minimal' },
|
reasoning: { effort: 'minimal' },
|
||||||
max_output_tokens: 100,
|
max_output_tokens: 100,
|
||||||
@@ -2905,8 +2899,9 @@ export function openAiRealtimeProxy() {
|
|||||||
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
||||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||||
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
|
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note and feedProvenance honestly: the answer covers data loaded by enabled layers, not the whole world, and a non-nominal feedState is not live. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
|
||||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
||||||
|
'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.',
|
||||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
||||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||||
'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.',
|
'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.',
|
||||||
@@ -3314,7 +3309,7 @@ const GEV_REALTIME_TOOLS = [
|
|||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
name: 'get_current_view_state',
|
name: 'get_current_view_state',
|
||||||
description: 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.',
|
description: 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action. Each layer includes feedState (nominal/loading/degraded/stale/fallback/unavailable/off) plus feedProvenance; narrate layer counts only with that provenance.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -3608,7 +3603,7 @@ const GEV_REALTIME_TOOLS = [
|
|||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
name: 'analyst_query',
|
name: 'analyst_query',
|
||||||
description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. Results include feedProvenance (the same feedState the Data Layers chips show). Narrate counts only with that provenance; never describe a stale, degraded, loading, fallback, or unavailable feed as live. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|||||||
@@ -18,14 +18,21 @@
|
|||||||
*
|
*
|
||||||
* Providers (injected — keeps the engine pure and node-testable):
|
* Providers (injected — keeps the engine pure and node-testable):
|
||||||
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
||||||
|
* getLayerSnapshot?(layerKey) → snapshot|null (honest feed-state envelope)
|
||||||
* resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary)
|
* resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary)
|
||||||
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||||
*
|
*
|
||||||
* @module data/analystEngine
|
* @module data/analystEngine
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { feedProvenanceEnvelope } from './layerSnapshot.js';
|
||||||
import { pointInRing } from './naturalEarthRegions.js';
|
import { pointInRing } from './naturalEarthRegions.js';
|
||||||
|
|
||||||
|
function withFeedProvenance(coverage, snapshots) {
|
||||||
|
if (!Array.isArray(snapshots) || snapshots.length === 0) return coverage;
|
||||||
|
return { ...coverage, feedProvenance: feedProvenanceEnvelope(snapshots) };
|
||||||
|
}
|
||||||
|
|
||||||
/** Layers the engine understands, with the fields queries may reference. */
|
/** Layers the engine understands, with the fields queries may reference. */
|
||||||
export const ANALYST_LAYERS = {
|
export const ANALYST_LAYERS = {
|
||||||
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
|
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
|
||||||
@@ -115,12 +122,15 @@ export function createAnalystEngine(providers) {
|
|||||||
// 1) Source records
|
// 1) Source records
|
||||||
let records;
|
let records;
|
||||||
let layersQueried;
|
let layersQueried;
|
||||||
|
let queriedSnapshots;
|
||||||
if (layers === null) {
|
if (layers === null) {
|
||||||
records = lastResult.items.slice();
|
records = lastResult.items.slice();
|
||||||
layersQueried = lastResult.coverage.layersQueried;
|
layersQueried = lastResult.coverage.layersQueried;
|
||||||
|
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
|
||||||
} else {
|
} else {
|
||||||
records = [];
|
records = [];
|
||||||
layersQueried = [];
|
layersQueried = [];
|
||||||
|
queriedSnapshots = [];
|
||||||
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
|
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
|
||||||
if (unknown.length) {
|
if (unknown.length) {
|
||||||
return {
|
return {
|
||||||
@@ -132,7 +142,21 @@ export function createAnalystEngine(providers) {
|
|||||||
for (const key of layers) {
|
for (const key of layers) {
|
||||||
if (!ANALYST_LAYERS[key]) continue;
|
if (!ANALYST_LAYERS[key]) continue;
|
||||||
const rows = providers.getRecords(key) || [];
|
const rows = providers.getRecords(key) || [];
|
||||||
layersQueried.push({ layerKey: key, records: rows.length });
|
const snapshot = typeof providers.getLayerSnapshot === 'function'
|
||||||
|
? providers.getLayerSnapshot(key)
|
||||||
|
: null;
|
||||||
|
if (snapshot) queriedSnapshots.push(snapshot);
|
||||||
|
layersQueried.push({
|
||||||
|
layerKey: key,
|
||||||
|
records: rows.length,
|
||||||
|
...(snapshot ? {
|
||||||
|
feedState: snapshot.feedState,
|
||||||
|
source: snapshot.source,
|
||||||
|
lastUpdate: snapshot.lastUpdate,
|
||||||
|
enabled: snapshot.enabled,
|
||||||
|
error: snapshot.error,
|
||||||
|
} : {}),
|
||||||
|
});
|
||||||
for (const row of rows) records.push({ layerKey: key, ...row });
|
for (const row of rows) records.push({ layerKey: key, ...row });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +175,10 @@ export function createAnalystEngine(providers) {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `I couldn't resolve a boundary for "${scope.name}" — try a state, country, or a named natural region.`,
|
error: `I couldn't resolve a boundary for "${scope.name}" — try a state, country, or a named natural region.`,
|
||||||
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
|
coverage: withFeedProvenance({
|
||||||
|
layersQueried,
|
||||||
|
scope: `region:${scope.name}:unresolved`,
|
||||||
|
}, queriedSnapshots),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
resolvedScope = region;
|
resolvedScope = region;
|
||||||
@@ -225,12 +252,12 @@ export function createAnalystEngine(providers) {
|
|||||||
truncated: items.length > top.length,
|
truncated: items.length > top.length,
|
||||||
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
|
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
|
||||||
scopeLabel,
|
scopeLabel,
|
||||||
coverage: {
|
coverage: withFeedProvenance({
|
||||||
layersQueried,
|
layersQueried,
|
||||||
scope: scopeNote,
|
scope: scopeNote,
|
||||||
followUp: Boolean(spec.followUp && lastResult),
|
followUp: Boolean(spec.followUp && lastResult),
|
||||||
note: 'client-side data only — answers cover what the enabled layers currently hold',
|
note: 'client-side data only — answers cover what the enabled layers currently hold',
|
||||||
},
|
}, queriedSnapshots),
|
||||||
// Surfaced so the narration can name the centre it measured from rather
|
// Surfaced so the narration can name the centre it measured from rather
|
||||||
// than implying a view-centred answer.
|
// than implying a view-centred answer.
|
||||||
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
|
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||||
|
import { layerSnapshot } from './layerSnapshot.js';
|
||||||
|
|
||||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||||
@@ -20,18 +21,31 @@ const FIRES = [
|
|||||||
{ id: 'FIRE-3', lat: 51.9, lon: -121.9, frp: 2400 },
|
{ id: 'FIRE-3', lat: 51.9, lon: -121.9, frp: 2400 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function makeEngine() {
|
function snapshotFor(key, rows, extraStats = {}) {
|
||||||
|
return layerSnapshot({
|
||||||
|
id: key,
|
||||||
|
name: key,
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: rows.length, lastUpdate: 1, ...extraStats },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEngine(statOverrides = {}) {
|
||||||
|
const records = { flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES };
|
||||||
return createAnalystEngine({
|
return createAnalystEngine({
|
||||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []),
|
getRecords: (key) => records[key] || [],
|
||||||
|
getLayerSnapshot: (key) => snapshotFor(key, records[key] || [], statOverrides[key]),
|
||||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same world, but Contacts is up with a subject far from the parked camera. */
|
/** Same world, but Contacts is up with a subject far from the parked camera. */
|
||||||
function makeContactsEngine(subject) {
|
function makeContactsEngine(subject, statOverrides = {}) {
|
||||||
|
const records = { flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES };
|
||||||
return createAnalystEngine({
|
return createAnalystEngine({
|
||||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []),
|
getRecords: (key) => records[key] || [],
|
||||||
|
getLayerSnapshot: (key) => snapshotFor(key, records[key] || [], statOverrides[key]),
|
||||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||||
// Parked far away, as a high-altitude camera often is.
|
// Parked far away, as a high-altitude camera often is.
|
||||||
getViewContext: () => ({ lat: 45.0, lon: -122.0, viewRadiusKm: 150 }),
|
getViewContext: () => ({ lat: 45.0, lon: -122.0, viewRadiusKm: 150 }),
|
||||||
@@ -201,3 +215,33 @@ test('helpers: haversine sanity + scope radius', () => {
|
|||||||
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
|
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
|
||||||
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
|
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('analyst: a stale feed stamps provenance so the count cannot be narrated as live', async () => {
|
||||||
|
const r = await makeEngine({ flights: { stale: true } }).query({
|
||||||
|
layers: ['flights'], scope: { kind: 'view' }, limit: 50,
|
||||||
|
});
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
assert.equal(r.coverage.layersQueried[0].feedState, 'stale');
|
||||||
|
assert.equal(r.coverage.feedProvenance.overall, 'stale');
|
||||||
|
assert.match(r.coverage.feedProvenance.note, /STALE/);
|
||||||
|
assert.match(r.coverage.feedProvenance.note, /do not describe this as live/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst: a degraded feed outranks a nominal neighbour in overall provenance', async () => {
|
||||||
|
const r = await makeEngine({
|
||||||
|
'local-firms': { error: 'FIRMS timeout', count: 2 },
|
||||||
|
}).query({
|
||||||
|
layers: ['flights', 'local-firms'], scope: { kind: 'anywhere' }, limit: 50,
|
||||||
|
});
|
||||||
|
assert.equal(r.coverage.feedProvenance.overall, 'degraded');
|
||||||
|
const fires = r.coverage.layersQueried.find((row) => row.layerKey === 'local-firms');
|
||||||
|
assert.equal(fires.feedState, 'degraded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst: follow-up keeps the original feed-state snapshot', async () => {
|
||||||
|
const eng = makeEngine({ flights: { stale: true } });
|
||||||
|
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||||
|
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
|
||||||
|
assert.equal(r.coverage.followUp, true);
|
||||||
|
assert.equal(r.coverage.feedProvenance.overall, 'stale');
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Shared layer snapshot envelope for voice and HUD answers.
|
||||||
|
*
|
||||||
|
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
|
||||||
|
* summary must all name the same feed-state. This module is the one envelope:
|
||||||
|
* it classifies `getStats()` through `layerFeedState` and stamps the result
|
||||||
|
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
|
||||||
|
* HUD summary proxy can import it from Node.
|
||||||
|
*
|
||||||
|
* @module data/layerSnapshot
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Feed states the Data Layers chips and voice/HUD provenance share. */
|
||||||
|
export const LAYER_FEED_STATES = Object.freeze([
|
||||||
|
'nominal',
|
||||||
|
'loading',
|
||||||
|
'degraded',
|
||||||
|
'stale',
|
||||||
|
'fallback',
|
||||||
|
'unavailable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
|
||||||
|
export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
||||||
|
nominal: 'ON',
|
||||||
|
loading: 'LOADING',
|
||||||
|
degraded: 'DEGRADED',
|
||||||
|
stale: 'STALE',
|
||||||
|
fallback: 'FALLBACK',
|
||||||
|
unavailable: 'UNAVAILABLE',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Snapshot feed-state including a disabled layer. */
|
||||||
|
export const SNAPSHOT_FEED_STATES = Object.freeze([...LAYER_FEED_STATES, 'off']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
|
||||||
|
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
|
||||||
|
* neighbour.
|
||||||
|
*/
|
||||||
|
export const FEED_STATE_SEVERITY = Object.freeze({
|
||||||
|
unavailable: 0,
|
||||||
|
loading: 1,
|
||||||
|
degraded: 2,
|
||||||
|
stale: 3,
|
||||||
|
fallback: 4,
|
||||||
|
nominal: 5,
|
||||||
|
off: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize heterogeneous layer stats into one honest control-chip state.
|
||||||
|
* @param {object|null} stats Layer getStats() result.
|
||||||
|
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
||||||
|
*/
|
||||||
|
export function layerFeedState(stats = {}) {
|
||||||
|
const state = stats || {};
|
||||||
|
const status = typeof state.status === 'string' ? state.status.toLowerCase() : '';
|
||||||
|
const source = `${state.source || ''} ${state.coverage || ''}`;
|
||||||
|
const hasExplicitFallback = typeof state.fallback === 'boolean';
|
||||||
|
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
|
||||||
|
const presentedError = state.error || state.lastError || state.managerRefreshError;
|
||||||
|
if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable';
|
||||||
|
if (
|
||||||
|
(presentedError || state.unavailable === true || state.available === false)
|
||||||
|
&& !hasPriorData
|
||||||
|
&& !['zoom-in', 'empty', 'idle'].includes(status)
|
||||||
|
) {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
if (state.loading) return 'loading';
|
||||||
|
// Guidance states ask the user to act (zoom in, run a search) — normal
|
||||||
|
// operation, not feed faults. One honesty carve-out: layers keep their
|
||||||
|
// rendered records through the guidance state, so a genuinely stale cache
|
||||||
|
// still reads STALE; a guidance prompt alone never reads DEGRADED.
|
||||||
|
if (['zoom-in', 'empty', 'idle'].includes(status)) {
|
||||||
|
return state.stale ? 'stale' : 'nominal';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.fallback === true
|
||||||
|
|| status === 'fallback'
|
||||||
|
|| state.mode === 'sim'
|
||||||
|
|| /\bfallback\b/i.test(source)
|
||||||
|
|| (!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
|
||||||
|
) {
|
||||||
|
return 'fallback';
|
||||||
|
}
|
||||||
|
if (state.stale || status === 'stale') return 'stale';
|
||||||
|
if (
|
||||||
|
state.degraded
|
||||||
|
|| presentedError
|
||||||
|
|| state.unavailable === true
|
||||||
|
|| state.available === false
|
||||||
|
) return 'degraded';
|
||||||
|
return 'nominal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotError(stats) {
|
||||||
|
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
|
||||||
|
if (err == null || err === '') return null;
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Age of a lastUpdate timestamp in whole seconds.
|
||||||
|
* @param {number|null|undefined} lastUpdate Epoch ms.
|
||||||
|
* @param {number} [now=Date.now()] Clock for tests.
|
||||||
|
* @returns {number|null} Non-negative seconds, or null when unknown.
|
||||||
|
*/
|
||||||
|
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
|
||||||
|
const stamp = Number(lastUpdate);
|
||||||
|
if (!Number.isFinite(stamp) || stamp <= 0) return null;
|
||||||
|
return Math.max(0, Math.floor((now - stamp) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact age label matching the Data Layers meta line.
|
||||||
|
* @param {number|null|undefined} lastUpdate Epoch ms.
|
||||||
|
* @param {number} [now=Date.now()] Clock for tests.
|
||||||
|
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
|
||||||
|
*/
|
||||||
|
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
|
||||||
|
const sec = snapshotAgeSec(lastUpdate, now);
|
||||||
|
if (sec === null) return null;
|
||||||
|
if (sec < 5) return 'just now';
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||||
|
return `${Math.floor(sec / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the most severe feed-state from a list.
|
||||||
|
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
||||||
|
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
|
||||||
|
*/
|
||||||
|
export function worstFeedState(states) {
|
||||||
|
let worst = null;
|
||||||
|
let worstRank = Number.POSITIVE_INFINITY;
|
||||||
|
for (const state of states || []) {
|
||||||
|
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
|
||||||
|
const rank = FEED_STATE_SEVERITY[state];
|
||||||
|
if (rank < worstRank) {
|
||||||
|
worst = state;
|
||||||
|
worstRank = rank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One honest layer snapshot for voice/HUD narration.
|
||||||
|
*
|
||||||
|
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
|
||||||
|
* even if leftover stats would otherwise classify as stale. Counts and errors
|
||||||
|
* still travel so a disabled layer with a last-known error is inspectable.
|
||||||
|
*
|
||||||
|
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
|
||||||
|
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
|
||||||
|
* @param {{ now?: number }} [options]
|
||||||
|
* @returns {{
|
||||||
|
* id: string|null,
|
||||||
|
* name: string|null,
|
||||||
|
* enabled: boolean,
|
||||||
|
* feedState: string,
|
||||||
|
* source: string|null,
|
||||||
|
* count: number,
|
||||||
|
* lastUpdate: number|null,
|
||||||
|
* ageSec: number|null,
|
||||||
|
* ageLabel: string|null,
|
||||||
|
* error: string|null,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
|
||||||
|
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
|
||||||
|
const source = String(stats.source || layer.source || '').trim() || null;
|
||||||
|
const enabled = Boolean(layer.enabled);
|
||||||
|
const countRaw = Number(stats.count);
|
||||||
|
const count = Number.isFinite(countRaw) ? countRaw : 0;
|
||||||
|
const lastUpdate = Number.isFinite(Number(stats.lastUpdate)) ? Number(stats.lastUpdate) : null;
|
||||||
|
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
|
||||||
|
return {
|
||||||
|
id: layer.id || layer.layerKey || null,
|
||||||
|
name: layer.name || layer.label || layer.id || layer.layerKey || null,
|
||||||
|
enabled,
|
||||||
|
feedState,
|
||||||
|
source,
|
||||||
|
count,
|
||||||
|
lastUpdate,
|
||||||
|
ageSec: snapshotAgeSec(lastUpdate, now),
|
||||||
|
ageLabel: snapshotAgeLabel(lastUpdate, now),
|
||||||
|
error: snapshotError(stats),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot every layer in a roster.
|
||||||
|
* @param {Array<object>} [layers]
|
||||||
|
* @param {{ now?: number }} [options]
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function layerSnapshots(layers = [], options) {
|
||||||
|
if (!Array.isArray(layers)) return [];
|
||||||
|
return layers.map((layer) => layerSnapshot(layer, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function asSnapshot(entry, now) {
|
||||||
|
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
|
||||||
|
if (typeof entry.feedState === 'string') return entry;
|
||||||
|
return layerSnapshot(entry, { now });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mechanical narration note so the model does not have to infer honesty.
|
||||||
|
* @param {Array<object>} layers Snapshots.
|
||||||
|
* @param {string|null} overall Worst considered feed-state.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function feedProvenanceNote(layers = [], overall = null) {
|
||||||
|
if (!overall) {
|
||||||
|
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
|
||||||
|
}
|
||||||
|
if (overall === 'nominal') {
|
||||||
|
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
|
||||||
|
}
|
||||||
|
if (overall === 'off') {
|
||||||
|
const names = layers.map((s) => s.name || s.id).filter(Boolean).join(', ') || 'the requested layer';
|
||||||
|
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
|
||||||
|
}
|
||||||
|
const flagged = layers.filter((s) => (
|
||||||
|
s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off'
|
||||||
|
));
|
||||||
|
const bits = flagged.map((s) => {
|
||||||
|
const extras = [s.source, s.ageLabel || snapshotAgeLabel(s.lastUpdate), s.error]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
||||||
|
});
|
||||||
|
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
||||||
|
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
|
||||||
|
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
|
||||||
|
* @param {{ now?: number }} [options]
|
||||||
|
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
|
||||||
|
*/
|
||||||
|
export function feedProvenanceEnvelope(snapshots = [], { now = Date.now() } = {}) {
|
||||||
|
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) => asSnapshot(entry, now));
|
||||||
|
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
|
||||||
|
const overall = worstFeedState(active.map((s) => s.feedState))
|
||||||
|
|| (layers.length ? 'off' : null);
|
||||||
|
return {
|
||||||
|
overall,
|
||||||
|
layers,
|
||||||
|
note: feedProvenanceNote(layers, overall),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact voice-tool layer row: identity + the fields a model must read.
|
||||||
|
* @param {object} layer Raw `getAll()` row or snapshot.
|
||||||
|
* @param {{ now?: number }} [options]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function viewStateLayerRow(layer, options) {
|
||||||
|
const snap = layerSnapshot(layer, options);
|
||||||
|
return {
|
||||||
|
id: snap.id,
|
||||||
|
name: snap.name,
|
||||||
|
enabled: snap.enabled,
|
||||||
|
count: snap.count,
|
||||||
|
error: snap.error,
|
||||||
|
feedState: snap.feedState,
|
||||||
|
source: snap.source,
|
||||||
|
lastUpdate: snap.lastUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
FEED_STATE_SEVERITY,
|
||||||
|
LAYER_FEED_STATE_LABELS,
|
||||||
|
LAYER_FEED_STATES,
|
||||||
|
SNAPSHOT_FEED_STATES,
|
||||||
|
feedProvenanceEnvelope,
|
||||||
|
feedProvenanceNote,
|
||||||
|
layerFeedState,
|
||||||
|
layerSnapshot,
|
||||||
|
layerSnapshots,
|
||||||
|
snapshotAgeLabel,
|
||||||
|
snapshotAgeSec,
|
||||||
|
viewStateLayerRow,
|
||||||
|
worstFeedState,
|
||||||
|
} from './layerSnapshot.js';
|
||||||
|
|
||||||
|
const NOW = 1_700_000_000_000;
|
||||||
|
|
||||||
|
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
|
||||||
|
const src = readFileSync(new URL('./layerSnapshot.js', import.meta.url), 'utf8');
|
||||||
|
assert.equal(src.includes("from './manager.js'"), false);
|
||||||
|
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('layerFeedState matches the chip classifier for every honesty class', () => {
|
||||||
|
assert.equal(layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }), 'unavailable');
|
||||||
|
assert.equal(layerFeedState({
|
||||||
|
status: 'unavailable',
|
||||||
|
error: 'feed down',
|
||||||
|
count: 50,
|
||||||
|
lastUpdate: 1,
|
||||||
|
}), 'unavailable');
|
||||||
|
assert.equal(layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }), 'fallback');
|
||||||
|
assert.equal(layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }), 'fallback');
|
||||||
|
assert.equal(layerFeedState({
|
||||||
|
source: 'adsb.lol',
|
||||||
|
fallback: false,
|
||||||
|
count: 10,
|
||||||
|
lastUpdate: 1,
|
||||||
|
}), 'nominal');
|
||||||
|
assert.equal(layerFeedState({ stale: true, count: 0, lastUpdate: 1 }), 'stale');
|
||||||
|
assert.equal(layerFeedState({ error: 'partial group failure', count: 50, lastUpdate: 1 }), 'degraded');
|
||||||
|
assert.equal(layerFeedState({ loading: true }), 'loading');
|
||||||
|
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
|
||||||
|
assert.equal(layerFeedState({ status: 'zoom-in', error: 'zoom in to search', count: 12 }), 'nominal');
|
||||||
|
assert.equal(layerFeedState({ status: 'zoom-in', stale: true, count: 12 }), 'stale');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
||||||
|
assert.deepEqual([...LAYER_FEED_STATES], [
|
||||||
|
'nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable',
|
||||||
|
]);
|
||||||
|
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
||||||
|
for (const state of LAYER_FEED_STATES) {
|
||||||
|
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
|
||||||
|
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
|
||||||
|
}
|
||||||
|
assert.equal(FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale, true);
|
||||||
|
assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
|
||||||
|
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
|
||||||
|
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
|
||||||
|
assert.equal(worstFeedState([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a disabled layer is off even when leftover stats would read stale', () => {
|
||||||
|
const snap = layerSnapshot({
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: false,
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
stats: { stale: true, count: 12, lastUpdate: NOW - 240_000, source: 'OpenSky Network' },
|
||||||
|
}, { now: NOW });
|
||||||
|
assert.equal(snap.feedState, 'off');
|
||||||
|
assert.equal(snap.enabled, false);
|
||||||
|
assert.equal(snap.count, 12);
|
||||||
|
assert.equal(snap.ageLabel, '4m ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an enabled stale layer carries age, source, and a mechanical note', () => {
|
||||||
|
const snap = layerSnapshot({
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
stale: true,
|
||||||
|
count: 12,
|
||||||
|
lastUpdate: NOW - 240_000,
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
},
|
||||||
|
}, { now: NOW });
|
||||||
|
assert.equal(snap.feedState, 'stale');
|
||||||
|
assert.equal(snap.source, 'OpenSky Network');
|
||||||
|
assert.equal(snap.ageSec, 240);
|
||||||
|
assert.equal(snap.ageLabel, '4m ago');
|
||||||
|
const envelope = feedProvenanceEnvelope([snap], { now: NOW });
|
||||||
|
assert.equal(envelope.overall, 'stale');
|
||||||
|
assert.match(envelope.note, /Live Flights is STALE/);
|
||||||
|
assert.match(envelope.note, /OpenSky Network/);
|
||||||
|
assert.match(envelope.note, /4m ago/);
|
||||||
|
assert.match(envelope.note, /do not describe this as live/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
|
||||||
|
const layers = layerSnapshots([
|
||||||
|
{
|
||||||
|
id: 'earthquakes',
|
||||||
|
name: 'Earthquakes',
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: 3, lastUpdate: NOW - 10_000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local-firms',
|
||||||
|
name: 'Active Fires',
|
||||||
|
enabled: true,
|
||||||
|
stats: { error: 'FIRMS key missing', count: 8, lastUpdate: NOW - 30_000 },
|
||||||
|
},
|
||||||
|
], { now: NOW });
|
||||||
|
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
|
||||||
|
assert.equal(envelope.overall, 'degraded');
|
||||||
|
assert.match(envelope.note, /Active Fires is DEGRADED/);
|
||||||
|
assert.match(envelope.note, /FIRMS key missing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unavailable feed with no prior data does not become a confident zero', () => {
|
||||||
|
const snap = layerSnapshot({
|
||||||
|
id: 'ais-live-vessels',
|
||||||
|
name: 'Live Vessels',
|
||||||
|
enabled: true,
|
||||||
|
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
|
||||||
|
}, { now: NOW });
|
||||||
|
assert.equal(snap.feedState, 'unavailable');
|
||||||
|
const envelope = feedProvenanceEnvelope([snap]);
|
||||||
|
assert.equal(envelope.overall, 'unavailable');
|
||||||
|
assert.match(envelope.note, /UNAVAILABLE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback provenance names the fallback source instead of calling it live', () => {
|
||||||
|
const snap = layerSnapshot({
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: { source: 'adsb.lol fallback', count: 40, lastUpdate: NOW - 8_000 },
|
||||||
|
}, { now: NOW });
|
||||||
|
assert.equal(snap.feedState, 'fallback');
|
||||||
|
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('view-state rows keep the existing identity fields and add feedState', () => {
|
||||||
|
const row = viewStateLayerRow({
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: 9, lastUpdate: 42, error: null, source: 'OpenSky Network' },
|
||||||
|
}, { now: NOW });
|
||||||
|
assert.deepEqual(row, {
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
count: 9,
|
||||||
|
error: null,
|
||||||
|
feedState: 'nominal',
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
lastUpdate: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty roster envelope does not pretend to be nominal', () => {
|
||||||
|
const envelope = feedProvenanceEnvelope([]);
|
||||||
|
assert.equal(envelope.overall, null);
|
||||||
|
assert.match(envelope.note, /Do not invent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('age labels match the Data Layers meta clock', () => {
|
||||||
|
assert.equal(snapshotAgeSec(null, NOW), null);
|
||||||
|
assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now');
|
||||||
|
assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago');
|
||||||
|
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
|
||||||
|
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
|
||||||
|
});
|
||||||
+7
-59
@@ -1,5 +1,9 @@
|
|||||||
import { governorRequestRender } from '../renderGovernor.js';
|
import { governorRequestRender } from '../renderGovernor.js';
|
||||||
import { markDetectionSourcesChanged } from './detection.js';
|
import { markDetectionSourcesChanged } from './detection.js';
|
||||||
|
import { LAYER_FEED_STATE_LABELS, layerFeedState } from './layerSnapshot.js';
|
||||||
|
|
||||||
|
export { layerFeedState };
|
||||||
|
|
||||||
function cloneLayerParams(value) {
|
function cloneLayerParams(value) {
|
||||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
@@ -10,15 +14,6 @@ function cloneLayerParams(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FEED_STATE_LABELS = Object.freeze({
|
|
||||||
nominal: 'ON',
|
|
||||||
loading: 'LOADING',
|
|
||||||
degraded: 'DEGRADED',
|
|
||||||
stale: 'STALE',
|
|
||||||
fallback: 'FALLBACK',
|
|
||||||
unavailable: 'UNAVAILABLE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
|
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
|
||||||
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||||||
'enabled-only',
|
'enabled-only',
|
||||||
@@ -64,53 +59,6 @@ function refreshFailureFromStats(stats, label) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize heterogeneous layer stats into one honest control-chip state.
|
|
||||||
* @param {object|null} stats Layer getStats() result.
|
|
||||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
|
||||||
*/
|
|
||||||
export function layerFeedState(stats = {}) {
|
|
||||||
const state = stats || {};
|
|
||||||
const status = typeof state.status === 'string' ? state.status.toLowerCase() : '';
|
|
||||||
const source = `${state.source || ''} ${state.coverage || ''}`;
|
|
||||||
const hasExplicitFallback = typeof state.fallback === 'boolean';
|
|
||||||
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
|
|
||||||
const presentedError = state.error || state.lastError || state.managerRefreshError;
|
|
||||||
if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable';
|
|
||||||
if (
|
|
||||||
(presentedError || state.unavailable === true || state.available === false)
|
|
||||||
&& !hasPriorData
|
|
||||||
&& !['zoom-in', 'empty', 'idle'].includes(status)
|
|
||||||
) {
|
|
||||||
return 'unavailable';
|
|
||||||
}
|
|
||||||
if (state.loading) return 'loading';
|
|
||||||
// Guidance states ask the user to act (zoom in, run a search) — normal
|
|
||||||
// operation, not feed faults. One honesty carve-out: layers keep their
|
|
||||||
// rendered records through the guidance state, so a genuinely stale cache
|
|
||||||
// still reads STALE; a guidance prompt alone never reads DEGRADED.
|
|
||||||
if (['zoom-in', 'empty', 'idle'].includes(status)) {
|
|
||||||
return state.stale ? 'stale' : 'nominal';
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
state.fallback === true
|
|
||||||
|| status === 'fallback'
|
|
||||||
|| state.mode === 'sim'
|
|
||||||
|| /\bfallback\b/i.test(source)
|
|
||||||
|| (!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
|
|
||||||
) {
|
|
||||||
return 'fallback';
|
|
||||||
}
|
|
||||||
if (state.stale || status === 'stale') return 'stale';
|
|
||||||
if (
|
|
||||||
state.degraded
|
|
||||||
|| presentedError
|
|
||||||
|| state.unavailable === true
|
|
||||||
|| state.available === false
|
|
||||||
) return 'degraded';
|
|
||||||
return 'nominal';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataLayerManager — Manages registration, toggling, and update loops
|
* DataLayerManager — Manages registration, toggling, and update loops
|
||||||
* for real-time data overlays on the CesiumJS globe.
|
* for real-time data overlays on the CesiumJS globe.
|
||||||
@@ -2217,7 +2165,7 @@ export class DataLayerManager {
|
|||||||
_buildMetaText(layer) {
|
_buildMetaText(layer) {
|
||||||
const stats = layer.stats || {};
|
const stats = layer.stats || {};
|
||||||
const feedState = layerFeedState(stats);
|
const feedState = layerFeedState(stats);
|
||||||
const stateLabel = FEED_STATE_LABELS[feedState];
|
const stateLabel = LAYER_FEED_STATE_LABELS[feedState];
|
||||||
const source = stats.source || layer.source;
|
const source = stats.source || layer.source;
|
||||||
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||||
@@ -2267,7 +2215,7 @@ export class DataLayerManager {
|
|||||||
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
|
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
|
||||||
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
|
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
|
||||||
button.classList.toggle('lifecycle-uncertain', uncertain);
|
button.classList.toggle('lifecycle-uncertain', uncertain);
|
||||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
for (const state of Object.keys(LAYER_FEED_STATE_LABELS)) {
|
||||||
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
|
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
|
||||||
}
|
}
|
||||||
button.dataset.feedState = transitioning
|
button.dataset.feedState = transitioning
|
||||||
@@ -2281,7 +2229,7 @@ export class DataLayerManager {
|
|||||||
button.setAttribute('aria-busy', String(transitioning));
|
button.setAttribute('aria-busy', String(transitioning));
|
||||||
button.textContent = transitioning
|
button.textContent = transitioning
|
||||||
? layer.lifecycleState.toUpperCase()
|
? layer.lifecycleState.toUpperCase()
|
||||||
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
|
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? LAYER_FEED_STATE_LABELS[feedState] : 'OFF'));
|
||||||
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
|
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -654,15 +654,13 @@ test('the voice TOOL SCHEMA is byte-identical to main — the mission mapping is
|
|||||||
const end = src.indexOf('\n];\n', start);
|
const end = src.indexOf('\n];\n', start);
|
||||||
const block = src.slice(start, end + 4);
|
const block = src.slice(start, end + 4);
|
||||||
|
|
||||||
// Re-pinned 2026-08-28: the Provider Settings / Esri release DELIBERATELY
|
// Re-pinned 2026-09-12: analyst_query and get_current_view_state descriptions
|
||||||
// extends set_map_stack's enum with 'esri-imagery' (a real new basemap —
|
// now require feedProvenance narration. First-run missions still ride
|
||||||
// exactly the kind of schema change this pin exists to make loud). The
|
// existing tools; any NEW drift from this recorded schema still fails here.
|
||||||
// guarded claim is unchanged: first-run missions ride existing tools, and
|
assert.equal(block.length, 31550, 'tool schema byte length drifted from the pinned release schema');
|
||||||
// any NEW drift from this recorded schema still fails here.
|
|
||||||
assert.equal(block.length, 31189, 'tool schema byte length drifted from the pinned release schema');
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'73aaabdb169a5478893d28688f327a21edd32ed3ec16fc6287bd944ed77beecf',
|
'350a7678155a97d0ded399dc2368db8596d520bed65c5b81bebce0ee3d9a76d4',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -19,7 +19,7 @@ import { CITY_POIS } from './locations.js';
|
|||||||
import { composeLocalityTag } from './hudLocality.js';
|
import { composeLocalityTag } from './hudLocality.js';
|
||||||
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
||||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||||
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
|
import { isHudSummaryUnconfigured, hudSummaryLayerContext, hudTelemetryProvenanceTag } from './hudSummaryResponse.js';
|
||||||
|
|
||||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||||
const HUD_COLORS = {
|
const HUD_COLORS = {
|
||||||
@@ -587,8 +587,9 @@ export class IntelHUD {
|
|||||||
: 'N/A';
|
: 'N/A';
|
||||||
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
||||||
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
|
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
|
||||||
|
const provenance = hudTelemetryProvenanceTag(this._dataManager?.getAll?.() || []);
|
||||||
return `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
||||||
|
return provenance ? `${line} | ${provenance}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -701,14 +702,11 @@ export class IntelHUD {
|
|||||||
|
|
||||||
async _summaryContext() {
|
async _summaryContext() {
|
||||||
const labels = await getBasemapLabelContext(this.viewer);
|
const labels = await getBasemapLabelContext(this.viewer);
|
||||||
const enabledLayers = this._dataManager?.getAll?.()
|
|
||||||
?.filter((layer) => layer.enabled)
|
|
||||||
.map((layer) => layer.name) || [];
|
|
||||||
return {
|
return {
|
||||||
placeLabels: labels.placeLabels,
|
placeLabels: labels.placeLabels,
|
||||||
streetLabels: labels.streetLabels,
|
streetLabels: labels.streetLabels,
|
||||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||||
enabledLayerLabels: enabledLayers,
|
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,7 +836,13 @@ export class IntelHUD {
|
|||||||
this._dataManager = dataManager || null;
|
this._dataManager = dataManager || null;
|
||||||
if (typeof this._dataManager?.subscribe === 'function') {
|
if (typeof this._dataManager?.subscribe === 'function') {
|
||||||
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
|
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
|
||||||
if (change?.type === 'visibility') this._markSummaryDirty();
|
if (
|
||||||
|
change?.type === 'visibility'
|
||||||
|
|| change?.type === 'refresh-transition'
|
||||||
|
|| change?.type === 'refresh-cancelled'
|
||||||
|
) {
|
||||||
|
this._markSummaryDirty();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._markSummaryDirty();
|
this._markSummaryDirty();
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
feedProvenanceEnvelope,
|
||||||
|
layerSnapshots,
|
||||||
|
} from './data/layerSnapshot.js';
|
||||||
|
|
||||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,3 +38,60 @@ export function isHudSummaryUnconfigured(status, data) {
|
|||||||
&& data?.error === null
|
&& data?.error === null
|
||||||
&& data?.summary === null;
|
&& data?.summary === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HUD summary model instructions. The five-word line may only describe
|
||||||
|
* supplied labels, and must carry feed-state when any enabled layer is not
|
||||||
|
* nominal.
|
||||||
|
*/
|
||||||
|
export const HUD_SUMMARY_INSTRUCTIONS = [
|
||||||
|
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||||
|
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
|
||||||
|
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||||
|
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
|
||||||
|
'Do not infer from coordinates or invent a place or feed state.',
|
||||||
|
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp HUD summary context with the same layer snapshots voice tools use.
|
||||||
|
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
|
||||||
|
* @param {{ now?: number }} [options]
|
||||||
|
* @returns {{
|
||||||
|
* enabledLayerLabels: string[],
|
||||||
|
* enabledLayers: Array<{id: string|null, name: string|null, feedState: string, source: string|null, count: number}>,
|
||||||
|
* feedProvenance: object,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function hudSummaryLayerContext(layers = [], options) {
|
||||||
|
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||||
|
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||||
|
return {
|
||||||
|
enabledLayerLabels: snapshots.map((s) => s.name).filter(Boolean),
|
||||||
|
enabledLayers: snapshots.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
feedState: s.feedState,
|
||||||
|
source: s.source,
|
||||||
|
count: s.count,
|
||||||
|
})),
|
||||||
|
feedProvenance: envelope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic telemetry suffix when an enabled feed is not nominal.
|
||||||
|
* @param {Array<object>} [layers]
|
||||||
|
* @param {{ now?: number }} [options]
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function hudTelemetryProvenanceTag(layers = [], options) {
|
||||||
|
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||||
|
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||||
|
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
||||||
|
const names = envelope.layers
|
||||||
|
.filter((s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off')
|
||||||
|
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
|
||||||
|
.slice(0, 2);
|
||||||
|
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import {
|
import {
|
||||||
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
|
hudSummaryLayerContext,
|
||||||
|
hudTelemetryProvenanceTag,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
@@ -128,3 +132,41 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
|||||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('HUD summary context and telemetry tag carry chip-identical feed-state', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
const layers = [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'earthquakes',
|
||||||
|
name: 'Earthquakes',
|
||||||
|
enabled: false,
|
||||||
|
stats: { count: 3, lastUpdate: now },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const context = hudSummaryLayerContext(layers, { now });
|
||||||
|
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
|
||||||
|
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
||||||
|
assert.equal(context.feedProvenance.overall, 'stale');
|
||||||
|
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
|
||||||
|
assert.equal(hudTelemetryProvenanceTag([{
|
||||||
|
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
|
||||||
|
}], { now }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
||||||
|
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
|
||||||
|
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
|
||||||
|
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HUD proxy uses the shared provenance instructions', () => {
|
||||||
|
const local = readFileSync(new URL('../server/providers/local.js', import.meta.url), 'utf8');
|
||||||
|
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||||
|
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||||
|
});
|
||||||
@@ -64,6 +64,17 @@ test('the counting contract is stated in the Realtime instructions', () => {
|
|||||||
assert.match(text, /flights layer loads where you look/, 'rule 5: the loaded-data caveat');
|
assert.match(text, /flights layer loads where you look/, 'rule 5: the loaded-data caveat');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the feed-state contract is stated in the Realtime instructions', () => {
|
||||||
|
const start = voice.indexOf("'FEED STATE CONTRACT");
|
||||||
|
assert.ok(start >= 0, 'the feed-state contract instruction is missing');
|
||||||
|
const text = voice.slice(start, voice.indexOf('\n', start));
|
||||||
|
assert.match(text, /feedProvenance/, 'names the envelope');
|
||||||
|
assert.match(text, /layerFeedState/, 'same classifier as the chips');
|
||||||
|
assert.match(text, /nominal\/loading\/degraded\/stale\/fallback\/unavailable/);
|
||||||
|
assert.match(text, /NEVER narrate a count as live/);
|
||||||
|
assert.match(text, /Confirm only what the tool result returned/);
|
||||||
|
});
|
||||||
|
|
||||||
test('Context panel opening stays distinct from Contacts activation', () => {
|
test('Context panel opening stays distinct from Contacts activation', () => {
|
||||||
const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel");
|
const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel");
|
||||||
assert.ok(start >= 0, 'panel-routing instruction is missing');
|
assert.ok(start >= 0, 'panel-routing instruction is missing');
|
||||||
@@ -106,6 +117,10 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => {
|
|||||||
assert.match(nearest.description, /exclude on-ground records/);
|
assert.match(nearest.description, /exclude on-ground records/);
|
||||||
assert.match(nearest.description, /fallback feeds remain usable/);
|
assert.match(nearest.description, /fallback feeds remain usable/);
|
||||||
assert.match(nearest.description, /does not open Contacts or Cockpit/);
|
assert.match(nearest.description, /does not open Contacts or Cockpit/);
|
||||||
|
|
||||||
|
const analyst = byName.get('analyst_query');
|
||||||
|
assert.match(analyst.description, /feedProvenance/);
|
||||||
|
assert.match(analyst.description, /never describe a stale/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the two Context/Cockpit tools pin their enums and required arguments', () => {
|
test('the two Context/Cockpit tools pin their enums and required arguments', () => {
|
||||||
@@ -145,9 +160,12 @@ test('the edited existing tools changed exactly as intended', () => {
|
|||||||
);
|
);
|
||||||
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
assert.deepEqual(panel.parameters.required, ['panelId', 'open']);
|
||||||
|
|
||||||
// Edit 2: description only — the view state now reports Context and Cockpit.
|
// Edit 2: description only — the view state now reports Context and Cockpit
|
||||||
|
// plus honest per-layer feedState / feedProvenance.
|
||||||
const viewState = byName.get('get_current_view_state');
|
const viewState = byName.get('get_current_view_state');
|
||||||
assert.match(viewState.description, /Context, Cockpit/);
|
assert.match(viewState.description, /Context, Cockpit/);
|
||||||
|
assert.match(viewState.description, /feedState/);
|
||||||
|
assert.match(viewState.description, /feedProvenance/);
|
||||||
assert.deepEqual(viewState.parameters.properties, {});
|
assert.deepEqual(viewState.parameters.properties, {});
|
||||||
|
|
||||||
// Edit 3: dependent multi-tool navigation can wait for the destination view.
|
// Edit 3: dependent multi-tool navigation can wait for the destination view.
|
||||||
@@ -157,11 +175,11 @@ test('the edited existing tools changed exactly as intended', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('no unchanged Realtime tool definition drifts silently', () => {
|
test('no unchanged Realtime tool definition drifts silently', () => {
|
||||||
// Context/Cockpit parity, the dependent-location wait edit, and the retired
|
// Context/Cockpit parity, the dependent-location wait edit, the retired
|
||||||
// `bing-road` stack leaving `set_map_stack`'s enum are the known schema
|
// `bing-road` stack leaving `set_map_stack`'s enum, and analyst_query
|
||||||
// changes. Everything else must be byte-identical: an unnoticed edit
|
// feedProvenance copy are the known schema changes. Everything else must be
|
||||||
// to a shipped tool changes
|
// byte-identical: an unnoticed edit to a shipped tool changes model behavior
|
||||||
// model behavior in production with nothing in review to catch it.
|
// in production with nothing in review to catch it.
|
||||||
//
|
//
|
||||||
// If this fails and the change was deliberate, re-derive the digest and say
|
// If this fails and the change was deliberate, re-derive the digest and say
|
||||||
// in the mic-test brief which tools moved — the session cache busts on any
|
// in the mic-test brief which tools moved — the session cache busts on any
|
||||||
@@ -174,16 +192,17 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
|||||||
'fly_to_location',
|
'fly_to_location',
|
||||||
'select_nearest_aircraft',
|
'select_nearest_aircraft',
|
||||||
'set_map_stack',
|
'set_map_stack',
|
||||||
|
'analyst_query',
|
||||||
]);
|
]);
|
||||||
const unchanged = realtimeTools()
|
const unchanged = realtimeTools()
|
||||||
.filter((tool) => !TOUCHED.has(tool.name))
|
.filter((tool) => !TOUCHED.has(tool.name))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
assert.equal(unchanged.length, 21);
|
assert.equal(unchanged.length, 20);
|
||||||
const digest = createHash('sha256')
|
const digest = createHash('sha256')
|
||||||
.update(JSON.stringify(unchanged))
|
.update(JSON.stringify(unchanged))
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 16);
|
.slice(0, 16);
|
||||||
assert.equal(digest, '802ed694b8887b88', 'an unchanged Realtime tool definition drifted');
|
assert.equal(digest, '5db68d7c29f6ed4f', 'an unchanged Realtime tool definition drifted');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
||||||
|
|||||||
+92
-15
@@ -9,7 +9,12 @@ import { getNextIssPass } from '../data/satellites.js';
|
|||||||
import { CCTV_FOCUS_RESULT } from '../data/cctv.js';
|
import { CCTV_FOCUS_RESULT } from '../data/cctv.js';
|
||||||
import { contextModeWord } from '../contextModePolicy.js';
|
import { contextModeWord } from '../contextModePolicy.js';
|
||||||
import { createAnalystEngine } from '../data/analystEngine.js';
|
import { createAnalystEngine } from '../data/analystEngine.js';
|
||||||
import { layerFeedState } from '../data/manager.js';
|
import {
|
||||||
|
feedProvenanceEnvelope,
|
||||||
|
layerSnapshot,
|
||||||
|
layerSnapshots,
|
||||||
|
viewStateLayerRow,
|
||||||
|
} from '../data/layerSnapshot.js';
|
||||||
import militaryAwarenessLayer, {
|
import militaryAwarenessLayer, {
|
||||||
collectAircraftProximityWindow,
|
collectAircraftProximityWindow,
|
||||||
contactsWindowFromSnapshot,
|
contactsWindowFromSnapshot,
|
||||||
@@ -503,10 +508,17 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
|||||||
}
|
}
|
||||||
const stats = layerModule?.getStats?.() || {};
|
const stats = layerModule?.getStats?.() || {};
|
||||||
const source = String(stats.source || layerModule?.source || '').trim() || null;
|
const source = String(stats.source || layerModule?.source || '').trim() || null;
|
||||||
const feed = {
|
const snap = layerSnapshot({
|
||||||
state: layerFeedState({ ...stats, source }),
|
id: layerId,
|
||||||
|
name: layer.label || layerId,
|
||||||
|
enabled: true,
|
||||||
source,
|
source,
|
||||||
count: Number.isFinite(Number(stats.count)) ? Number(stats.count) : null,
|
stats: { ...stats, source },
|
||||||
|
});
|
||||||
|
const feed = {
|
||||||
|
state: snap.feedState,
|
||||||
|
source: snap.source,
|
||||||
|
count: snap.count,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, {
|
const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, {
|
||||||
@@ -2356,6 +2368,8 @@ function normalizeLocationId(value) {
|
|||||||
|
|
||||||
function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = null) {
|
function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = null) {
|
||||||
const cartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
|
const cartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
|
||||||
|
const layerRows = (dataManager?.getAll?.() || []).map((layer) => viewStateLayerRow(layer));
|
||||||
|
const enabledSnapshots = layerSnapshots(dataManager?.getAll?.() || []).filter((s) => s.enabled);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
action: 'get_current_view_state',
|
action: 'get_current_view_state',
|
||||||
@@ -2379,13 +2393,8 @@ function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector =
|
|||||||
controls: typeof styleManager.getControlState === 'function' ? styleManager.getControlState() : null,
|
controls: typeof styleManager.getControlState === 'function' ? styleManager.getControlState() : null,
|
||||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||||
tracked: collectTrackedEntities(dataManager),
|
tracked: collectTrackedEntities(dataManager),
|
||||||
layers: dataManager.getAll().map((layer) => ({
|
layers: layerRows,
|
||||||
id: layer.id,
|
feedProvenance: feedProvenanceEnvelope(enabledSnapshots),
|
||||||
name: layer.name,
|
|
||||||
enabled: layer.enabled,
|
|
||||||
count: layer.stats?.count || 0,
|
|
||||||
error: layer.stats?.error || null,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2482,6 +2491,7 @@ function aircraftProximityWindowForQuery(args, result) {
|
|||||||
scope: `window:${radiusKm}km@${label}`,
|
scope: `window:${radiusKm}km@${label}`,
|
||||||
followUp: false,
|
followUp: false,
|
||||||
note: 'Contacts window engine — the same computation and cohort the Contacts panel displays, so this count matches the panel exactly — counts cover loaded data; the flights layer loads by viewport.',
|
note: 'Contacts window engine — the same computation and cohort the Contacts panel displays, so this count matches the panel exactly — counts cover loaded data; the flights layer loads by viewport.',
|
||||||
|
...(result?.coverage?.feedProvenance ? { feedProvenance: result.coverage.feedProvenance } : {}),
|
||||||
},
|
},
|
||||||
// (D) The answer always says whose window it is and which engine produced it.
|
// (D) The answer always says whose window it is and which engine produced it.
|
||||||
window: {
|
window: {
|
||||||
@@ -2493,6 +2503,12 @@ function aircraftProximityWindowForQuery(args, result) {
|
|||||||
aircraft: window.aircraft,
|
aircraft: window.aircraft,
|
||||||
},
|
},
|
||||||
...(activeContactsWindow() ? { contactsWindow: activeContactsWindow() } : {}),
|
...(activeContactsWindow() ? { contactsWindow: activeContactsWindow() } : {}),
|
||||||
|
...(result?.coverage?.feedProvenance
|
||||||
|
? {
|
||||||
|
feedProvenance: result.coverage.feedProvenance,
|
||||||
|
feedState: result.coverage.feedProvenance.overall,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3310,6 +3326,47 @@ function activeContactsWindow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function provenanceForQuery(dataManager, layerKeys) {
|
||||||
|
const keys = Array.isArray(layerKeys) && layerKeys.length ? layerKeys : ['flights'];
|
||||||
|
const all = dataManager?.getAll?.() || [];
|
||||||
|
const snapshots = keys.map((key) => {
|
||||||
|
const row = all.find((layer) => layer.id === key);
|
||||||
|
if (row) return layerSnapshot(row);
|
||||||
|
const enabled = Boolean(dataManager?.isEnabled?.(key));
|
||||||
|
return layerSnapshot({ id: key, enabled });
|
||||||
|
});
|
||||||
|
return feedProvenanceEnvelope(snapshots);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachQueryProvenance(result, dataManager, args = {}) {
|
||||||
|
if (!result || typeof result !== 'object') return result;
|
||||||
|
const keys = Array.isArray(args.layers) && args.layers.length
|
||||||
|
? args.layers
|
||||||
|
: (result.coverage?.layersQueried || []).map((row) => row.layerKey);
|
||||||
|
const envelope = provenanceForQuery(dataManager, keys);
|
||||||
|
if (result.coverage) {
|
||||||
|
result.coverage.feedProvenance = envelope;
|
||||||
|
if (Array.isArray(result.coverage.layersQueried)) {
|
||||||
|
const byId = new Map(envelope.layers.map((snap) => [snap.id, snap]));
|
||||||
|
result.coverage.layersQueried = result.coverage.layersQueried.map((row) => {
|
||||||
|
const snap = byId.get(row.layerKey);
|
||||||
|
if (!snap) return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
feedState: snap.feedState,
|
||||||
|
source: snap.source,
|
||||||
|
lastUpdate: snap.lastUpdate,
|
||||||
|
enabled: snap.enabled,
|
||||||
|
error: snap.error,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.feedProvenance = envelope;
|
||||||
|
result.feedState = envelope.overall;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {}) {
|
function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {}) {
|
||||||
return {
|
return {
|
||||||
getRecords(layerKey) {
|
getRecords(layerKey) {
|
||||||
@@ -3322,6 +3379,20 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {
|
|||||||
? (mod.getAnalystRecords(requestedLimit) || [])
|
? (mod.getAnalystRecords(requestedLimit) || [])
|
||||||
: (mod.getAnalystRecords() || []);
|
: (mod.getAnalystRecords() || []);
|
||||||
},
|
},
|
||||||
|
getLayerSnapshot(layerKey) {
|
||||||
|
const row = (dataManager.getAll?.() || []).find((layer) => layer.id === layerKey);
|
||||||
|
if (row) return layerSnapshot(row);
|
||||||
|
const enabled = Boolean(dataManager.isEnabled?.(layerKey));
|
||||||
|
const entry = dataManager.layers?.get?.(layerKey);
|
||||||
|
const stats = entry?.module?.getStats?.() || {};
|
||||||
|
return layerSnapshot({
|
||||||
|
id: layerKey,
|
||||||
|
name: entry?.module?.name || layerKey,
|
||||||
|
enabled,
|
||||||
|
source: stats.source || entry?.module?.source || null,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
},
|
||||||
resolveRegionRing: (name) => resolveRegionRingForQuery(name),
|
resolveRegionRing: (name) => resolveRegionRingForQuery(name),
|
||||||
/**
|
/**
|
||||||
* The active Contacts subject, when there is one — the centre the operator
|
* The active Contacts subject, when there is one — the centre the operator
|
||||||
@@ -3367,7 +3438,13 @@ async function runAnalystQuery(viewer, dataManager, args = {}) {
|
|||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
followUp: Boolean(args.followUp),
|
followUp: Boolean(args.followUp),
|
||||||
});
|
});
|
||||||
if (!result.ok) return { ok: false, action: 'analyst_query', error: result.error, coverage: result.coverage };
|
if (!result.ok) {
|
||||||
|
return attachQueryProvenance(
|
||||||
|
{ ok: false, action: 'analyst_query', error: result.error, coverage: result.coverage },
|
||||||
|
dataManager,
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Compact payload for the voice model: identity + the fields queries sort/
|
// Compact payload for the voice model: identity + the fields queries sort/
|
||||||
// filter on. The full record set stays engine-side for follow-ups.
|
// filter on. The full record set stays engine-side for follow-ups.
|
||||||
//
|
//
|
||||||
@@ -3411,7 +3488,7 @@ async function runAnalystQuery(viewer, dataManager, args = {}) {
|
|||||||
// arbitrary points — only "how many aircraft around <this contact>" is
|
// arbitrary points — only "how many aircraft around <this contact>" is
|
||||||
// unified, because that is the question the panel is already answering.
|
// unified, because that is the question the panel is already answering.
|
||||||
const entityWindow = aircraftProximityWindowForQuery(args, result);
|
const entityWindow = aircraftProximityWindowForQuery(args, result);
|
||||||
if (entityWindow) return entityWindow;
|
if (entityWindow) return attachQueryProvenance(entityWindow, dataManager, args);
|
||||||
|
|
||||||
const contactsWindow = activeContactsWindow();
|
const contactsWindow = activeContactsWindow();
|
||||||
const aircraftQueried = (result.coverage?.layersQueried || [])
|
const aircraftQueried = (result.coverage?.layersQueried || [])
|
||||||
@@ -3432,7 +3509,7 @@ async function runAnalystQuery(viewer, dataManager, args = {}) {
|
|||||||
+ `"how many aircraft are nearby". This query measured something else — ${result.count} ${result.scopeLabel}. `
|
+ `"how many aircraft are nearby". This query measured something else — ${result.count} ${result.scopeLabel}. `
|
||||||
+ 'Give this one only if the operator asked about that specific area, and name both scopes if you give both.'
|
+ 'Give this one only if the operator asked about that specific area, and name both scopes if you give both.'
|
||||||
: null;
|
: null;
|
||||||
return {
|
return attachQueryProvenance({
|
||||||
ok: true,
|
ok: true,
|
||||||
action: 'analyst_query',
|
action: 'analyst_query',
|
||||||
count: result.count,
|
count: result.count,
|
||||||
@@ -3455,5 +3532,5 @@ async function runAnalystQuery(viewer, dataManager, args = {}) {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(countsReconciliation ? { countsReconciliation } : {}),
|
...(countsReconciliation ? { countsReconciliation } : {}),
|
||||||
};
|
}, dataManager, args);
|
||||||
}
|
}
|
||||||
@@ -732,6 +732,9 @@ test('Data Layers voice inventory hides the Context coordinator while current-vi
|
|||||||
assert.deepEqual(menu.layers.map(({ id }) => id), ['flights']);
|
assert.deepEqual(menu.layers.map(({ id }) => id), ['flights']);
|
||||||
const current = await runner('get_current_view_state');
|
const current = await runner('get_current_view_state');
|
||||||
assert.deepEqual(current.layers.map(({ id }) => id), ['flights', 'military-awareness']);
|
assert.deepEqual(current.layers.map(({ id }) => id), ['flights', 'military-awareness']);
|
||||||
|
assert.equal(current.layers.find(({ id }) => id === 'flights').feedState, 'off');
|
||||||
|
assert.equal(current.layers.find(({ id }) => id === 'military-awareness').feedState, 'nominal');
|
||||||
|
assert.equal(current.feedProvenance.overall, 'nominal');
|
||||||
// The Contacts mode's internal id is 'flights'; the tools accept 'contacts'.
|
// The Contacts mode's internal id is 'flights'; the tools accept 'contacts'.
|
||||||
// State output reports the accepted word so the model cannot read its own
|
// State output reports the accepted word so the model cannot read its own
|
||||||
// active context as "off", with the internal id kept for layer reasoning.
|
// active context as "off", with the internal id kept for layer reasoning.
|
||||||
@@ -3020,3 +3023,66 @@ test('front5: 0.99 km due EAST is the subject, though a degree box rejects it',
|
|||||||
assert.equal(result.window.centeredOn, 'N546PC');
|
assert.equal(result.window.centeredOn, 'N546PC');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('analyst_query and get_current_view_state carry stale feed provenance', async () => {
|
||||||
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
|
const now = Date.now();
|
||||||
|
const flights = {
|
||||||
|
id: 'flights',
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
getStats: () => ({
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
stale: true,
|
||||||
|
count: 12,
|
||||||
|
lastUpdate: now - 240_000,
|
||||||
|
}),
|
||||||
|
getAnalystRecords: () => ([
|
||||||
|
{ id: 'SWA1', icao24: 'aaa001', lat: 30.27, lon: -97.74, altitudeM: 11000, onGround: false },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const viewer = {
|
||||||
|
clock: { onTick: { addEventListener: () => () => {} } },
|
||||||
|
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||||
|
camera: {
|
||||||
|
moveEnd: { addEventListener() {} },
|
||||||
|
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000),
|
||||||
|
positionCartographic: { height: 300_000, latitude: 0.52, longitude: -1.71 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dataManager = {
|
||||||
|
layers: new Map([['flights', { module: flights }]]),
|
||||||
|
isEnabled: (id) => id === 'flights',
|
||||||
|
getAll: () => [{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
stats: flights.getStats(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const runner = createGevActionRunner({
|
||||||
|
viewer,
|
||||||
|
styleManager: {
|
||||||
|
activeStyle: 'normal',
|
||||||
|
getContextModeState: () => ({ mode: null, active: false }),
|
||||||
|
getCockpitState: () => ({ active: false }),
|
||||||
|
getControlState: () => null,
|
||||||
|
},
|
||||||
|
dataManager,
|
||||||
|
});
|
||||||
|
const view = await runner('get_current_view_state');
|
||||||
|
assert.equal(view.layers[0].feedState, 'stale');
|
||||||
|
assert.equal(view.feedProvenance.overall, 'stale');
|
||||||
|
assert.match(view.feedProvenance.note, /STALE/);
|
||||||
|
|
||||||
|
const query = await runner('analyst_query', {
|
||||||
|
layers: ['flights'],
|
||||||
|
scope: { kind: 'view' },
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
assert.equal(query.ok, true);
|
||||||
|
assert.equal(query.feedState, 'stale');
|
||||||
|
assert.equal(query.feedProvenance.overall, 'stale');
|
||||||
|
assert.equal(query.coverage.layersQueried[0].feedState, 'stale');
|
||||||
|
assert.match(query.feedProvenance.note, /do not describe this as live/i);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user