diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ce2aff3..2d8f85f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 250a2f106..c01b06465 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 \" 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) diff --git a/server/providers/local.js b/server/providers/local.js index 934e7c442..b513a46d8 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -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 " 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, diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 58c28c7d8..2b2de873b 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -18,14 +18,21 @@ * * Providers (injected — keeps the engine pure and node-testable): * getRecords(layerKey) → Array (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 } : {}), diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 6521a5464..94964fa4b 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -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'); +}); diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js new file mode 100644 index 000000000..767f8f6ba --- /dev/null +++ b/src/data/layerSnapshot.js @@ -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} 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} [layers] + * @param {{ now?: number }} [options] + * @returns {Array} + */ +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} 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} [snapshots] Snapshots or raw `getAll()` rows. + * @param {{ now?: number }} [options] + * @returns {{ overall: string|null, layers: Array, 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, + }; +} diff --git a/src/data/layerSnapshot.test.mjs b/src/data/layerSnapshot.test.mjs new file mode 100644 index 000000000..a7c1a1346 --- /dev/null +++ b/src/data/layerSnapshot.test.mjs @@ -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'); +}); diff --git a/src/data/manager.js b/src/data/manager.js index 7db25def4..d567678ee 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -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}`); } diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 6d887eb58..8c54f6357 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -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', ); diff --git a/src/hud.js b/src/hud.js index 74b9c044e..6f68cfbdc 100644 --- a/src/hud.js +++ b/src/hud.js @@ -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(); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 9c516eed1..12c50c1b6 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -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} [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} [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('/')}` : ''}`; +} diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index bbdc9d01a..305920828 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -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/); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 8d59e6787..bd09ca583 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -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', () => { diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a56c840e7..6fe42be6e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -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 " 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); } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 0f6baa651..bb1c0abbd 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -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); +});