mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +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]
|
||||
|
||||
- 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
|
||||
modules, preserving local configuration, routes and cache/error behavior.
|
||||
|
||||
|
||||
+18
-2
@@ -1,5 +1,20 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
|
||||
### 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).
|
||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- 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), 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)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
normalizeRegionalPlace,
|
||||
normalizeRegionalWeather,
|
||||
} 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 { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs';
|
||||
import {
|
||||
@@ -2767,13 +2767,7 @@ export function openAiRealtimeProxy() {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
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(' '),
|
||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
||||
input: JSON.stringify(context),
|
||||
reasoning: { effort: 'minimal' },
|
||||
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").',
|
||||
'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 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.',
|
||||
'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.',
|
||||
'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.',
|
||||
@@ -3314,7 +3309,7 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
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: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -3608,7 +3603,7 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
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: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -18,14 +18,21 @@
|
||||
*
|
||||
* Providers (injected — keeps the engine pure and node-testable):
|
||||
* 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)
|
||||
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||
*
|
||||
* @module data/analystEngine
|
||||
*/
|
||||
|
||||
import { feedProvenanceEnvelope } from './layerSnapshot.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. */
|
||||
export const ANALYST_LAYERS = {
|
||||
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
|
||||
let records;
|
||||
let layersQueried;
|
||||
let queriedSnapshots;
|
||||
if (layers === null) {
|
||||
records = lastResult.items.slice();
|
||||
layersQueried = lastResult.coverage.layersQueried;
|
||||
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
|
||||
} else {
|
||||
records = [];
|
||||
layersQueried = [];
|
||||
queriedSnapshots = [];
|
||||
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
|
||||
if (unknown.length) {
|
||||
return {
|
||||
@@ -132,7 +142,21 @@ export function createAnalystEngine(providers) {
|
||||
for (const key of layers) {
|
||||
if (!ANALYST_LAYERS[key]) continue;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -151,7 +175,10 @@ export function createAnalystEngine(providers) {
|
||||
return {
|
||||
ok: false,
|
||||
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;
|
||||
@@ -225,12 +252,12 @@ export function createAnalystEngine(providers) {
|
||||
truncated: items.length > top.length,
|
||||
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
|
||||
scopeLabel,
|
||||
coverage: {
|
||||
coverage: withFeedProvenance({
|
||||
layersQueried,
|
||||
scope: scopeNote,
|
||||
followUp: Boolean(spec.followUp && lastResult),
|
||||
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
|
||||
// than implying a view-centred answer.
|
||||
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
import { layerSnapshot } from './layerSnapshot.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
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 },
|
||||
];
|
||||
|
||||
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({
|
||||
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),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 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({
|
||||
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),
|
||||
// Parked far away, as a high-altitude camera often is.
|
||||
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 });
|
||||
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 { markDetectionSourcesChanged } from './detection.js';
|
||||
import { LAYER_FEED_STATE_LABELS, layerFeedState } from './layerSnapshot.js';
|
||||
|
||||
export { layerFeedState };
|
||||
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -10,15 +14,6 @@ function cloneLayerParams(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 VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
@@ -64,53 +59,6 @@ function refreshFailureFromStats(stats, label) {
|
||||
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
|
||||
* for real-time data overlays on the CesiumJS globe.
|
||||
@@ -2217,7 +2165,7 @@ export class DataLayerManager {
|
||||
_buildMetaText(layer) {
|
||||
const stats = layer.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 lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||
@@ -2267,7 +2215,7 @@ export class DataLayerManager {
|
||||
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
|
||||
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
|
||||
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.dataset.feedState = transitioning
|
||||
@@ -2281,7 +2229,7 @@ export class DataLayerManager {
|
||||
button.setAttribute('aria-busy', String(transitioning));
|
||||
button.textContent = transitioning
|
||||
? 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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 block = src.slice(start, end + 4);
|
||||
|
||||
// Re-pinned 2026-08-28: the Provider Settings / Esri release DELIBERATELY
|
||||
// extends set_map_stack's enum with 'esri-imagery' (a real new basemap —
|
||||
// exactly the kind of schema change this pin exists to make loud). The
|
||||
// guarded claim is unchanged: first-run missions ride existing tools, and
|
||||
// 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');
|
||||
// Re-pinned 2026-09-12: analyst_query and get_current_view_state descriptions
|
||||
// now require feedProvenance narration. First-run missions still ride
|
||||
// existing tools; any NEW drift from this recorded schema still fails here.
|
||||
assert.equal(block.length, 31550, 'tool schema byte length drifted from the pinned release schema');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'73aaabdb169a5478893d28688f327a21edd32ed3ec16fc6287bd944ed77beecf',
|
||||
'350a7678155a97d0ded399dc2368db8596d520bed65c5b81bebce0ee3d9a76d4',
|
||||
'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 { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.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. */
|
||||
const HUD_COLORS = {
|
||||
@@ -587,8 +587,9 @@ export class IntelHUD {
|
||||
: 'N/A';
|
||||
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
||||
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
|
||||
|
||||
return `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
||||
const provenance = hudTelemetryProvenanceTag(this._dataManager?.getAll?.() || []);
|
||||
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() {
|
||||
const labels = await getBasemapLabelContext(this.viewer);
|
||||
const enabledLayers = this._dataManager?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
return {
|
||||
placeLabels: labels.placeLabels,
|
||||
streetLabels: labels.streetLabels,
|
||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||
enabledLayerLabels: enabledLayers,
|
||||
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -838,7 +836,13 @@ export class IntelHUD {
|
||||
this._dataManager = dataManager || null;
|
||||
if (typeof this._dataManager?.subscribe === 'function') {
|
||||
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();
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshots,
|
||||
} from './data/layerSnapshot.js';
|
||||
|
||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||
|
||||
/**
|
||||
@@ -33,3 +38,60 @@ export function isHudSummaryUnconfigured(status, data) {
|
||||
&& data?.error === 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 test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenanceTag,
|
||||
isHudSummaryUnconfigured,
|
||||
keylessHudSummaryResponse,
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel");
|
||||
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, /fallback feeds remain usable/);
|
||||
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', () => {
|
||||
@@ -145,9 +160,12 @@ test('the edited existing tools changed exactly as intended', () => {
|
||||
);
|
||||
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');
|
||||
assert.match(viewState.description, /Context, Cockpit/);
|
||||
assert.match(viewState.description, /feedState/);
|
||||
assert.match(viewState.description, /feedProvenance/);
|
||||
assert.deepEqual(viewState.parameters.properties, {});
|
||||
|
||||
// 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', () => {
|
||||
// Context/Cockpit parity, the dependent-location wait edit, and the retired
|
||||
// `bing-road` stack leaving `set_map_stack`'s enum are the known schema
|
||||
// changes. Everything else must be byte-identical: an unnoticed edit
|
||||
// to a shipped tool changes
|
||||
// model behavior in production with nothing in review to catch it.
|
||||
// Context/Cockpit parity, the dependent-location wait edit, the retired
|
||||
// `bing-road` stack leaving `set_map_stack`'s enum, and analyst_query
|
||||
// feedProvenance copy are the known schema changes. Everything else must be
|
||||
// byte-identical: an unnoticed edit to a shipped tool changes model behavior
|
||||
// in production with nothing in review to catch it.
|
||||
//
|
||||
// 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
|
||||
@@ -174,16 +192,17 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'fly_to_location',
|
||||
'select_nearest_aircraft',
|
||||
'set_map_stack',
|
||||
'analyst_query',
|
||||
]);
|
||||
const unchanged = realtimeTools()
|
||||
.filter((tool) => !TOUCHED.has(tool.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
assert.equal(unchanged.length, 21);
|
||||
assert.equal(unchanged.length, 20);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
.digest('hex')
|
||||
.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', () => {
|
||||
|
||||
+92
-15
@@ -9,7 +9,12 @@ import { getNextIssPass } from '../data/satellites.js';
|
||||
import { CCTV_FOCUS_RESULT } from '../data/cctv.js';
|
||||
import { contextModeWord } from '../contextModePolicy.js';
|
||||
import { createAnalystEngine } from '../data/analystEngine.js';
|
||||
import { layerFeedState } from '../data/manager.js';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
viewStateLayerRow,
|
||||
} from '../data/layerSnapshot.js';
|
||||
import militaryAwarenessLayer, {
|
||||
collectAircraftProximityWindow,
|
||||
contactsWindowFromSnapshot,
|
||||
@@ -503,10 +508,17 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
|
||||
}
|
||||
const stats = layerModule?.getStats?.() || {};
|
||||
const source = String(stats.source || layerModule?.source || '').trim() || null;
|
||||
const feed = {
|
||||
state: layerFeedState({ ...stats, source }),
|
||||
const snap = layerSnapshot({
|
||||
id: layerId,
|
||||
name: layer.label || layerId,
|
||||
enabled: true,
|
||||
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, {
|
||||
@@ -2356,6 +2368,8 @@ function normalizeLocationId(value) {
|
||||
|
||||
function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = null) {
|
||||
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 {
|
||||
ok: true,
|
||||
action: 'get_current_view_state',
|
||||
@@ -2379,13 +2393,8 @@ function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector =
|
||||
controls: typeof styleManager.getControlState === 'function' ? styleManager.getControlState() : null,
|
||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||
tracked: collectTrackedEntities(dataManager),
|
||||
layers: dataManager.getAll().map((layer) => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
enabled: layer.enabled,
|
||||
count: layer.stats?.count || 0,
|
||||
error: layer.stats?.error || null,
|
||||
})),
|
||||
layers: layerRows,
|
||||
feedProvenance: feedProvenanceEnvelope(enabledSnapshots),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2482,6 +2491,7 @@ function aircraftProximityWindowForQuery(args, result) {
|
||||
scope: `window:${radiusKm}km@${label}`,
|
||||
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.',
|
||||
...(result?.coverage?.feedProvenance ? { feedProvenance: result.coverage.feedProvenance } : {}),
|
||||
},
|
||||
// (D) The answer always says whose window it is and which engine produced it.
|
||||
window: {
|
||||
@@ -2493,6 +2503,12 @@ function aircraftProximityWindowForQuery(args, result) {
|
||||
aircraft: window.aircraft,
|
||||
},
|
||||
...(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 } = {}) {
|
||||
return {
|
||||
getRecords(layerKey) {
|
||||
@@ -3322,6 +3379,20 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {
|
||||
? (mod.getAnalystRecords(requestedLimit) || [])
|
||||
: (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),
|
||||
/**
|
||||
* 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,
|
||||
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/
|
||||
// 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
|
||||
// unified, because that is the question the panel is already answering.
|
||||
const entityWindow = aircraftProximityWindowForQuery(args, result);
|
||||
if (entityWindow) return entityWindow;
|
||||
if (entityWindow) return attachQueryProvenance(entityWindow, dataManager, args);
|
||||
|
||||
const contactsWindow = activeContactsWindow();
|
||||
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}. `
|
||||
+ 'Give this one only if the operator asked about that specific area, and name both scopes if you give both.'
|
||||
: null;
|
||||
return {
|
||||
return attachQueryProvenance({
|
||||
ok: true,
|
||||
action: 'analyst_query',
|
||||
count: result.count,
|
||||
@@ -3455,5 +3532,5 @@ async function runAnalystQuery(viewer, dataManager, args = {}) {
|
||||
}
|
||||
: {}),
|
||||
...(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']);
|
||||
const current = await runner('get_current_view_state');
|
||||
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'.
|
||||
// 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.
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
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