From 1e8f919cf3379824490c972fbf2af410d327682a Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:28:41 -0500 Subject: [PATCH] fix: pin additive voice contracts and require HUD provenance wording --- server/providers/openai/hud-summary.js | 5 +- server/providers/openai/instructions.js | 2 +- server/providers/openai/toolDescriptions.js | 3 +- src/data/layerSnapshot.js | 41 +++- src/data/layerSnapshot.test.mjs | 224 +++++++++++++------- src/data/satellitePass.js | 63 ++++-- src/data/satellitePass.test.mjs | 31 ++- src/firstRunExperience.test.mjs | 6 +- src/hud.js | 4 +- src/hudSummaryResponse.js | 7 + src/hudSummaryResponse.test.mjs | 7 + src/layers/satellites/orbits.js | 21 +- src/layers/satellites/records.js | 82 ++++--- src/radioMarkup.test.mjs | 9 +- src/voice/actionSchemas.js | 4 +- src/voice/actionSchemas.test.mjs | 13 +- 16 files changed, 368 insertions(+), 154 deletions(-) diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index d57f7ead0..ca3778ba0 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -1,4 +1,7 @@ -import { HUD_SUMMARY_INSTRUCTIONS, keylessHudSummaryResponse } from '../../../src/hudSummaryResponse.js'; +import { + HUD_SUMMARY_INSTRUCTIONS, + keylessHudSummaryResponse, +} from '../../../src/hudSummaryResponse.js'; import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js'; import { readRequestBody } from '../common/request.js'; import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js'; diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 2bcd93d93..52c8c0b95 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -19,7 +19,7 @@ function realtimeInstructions( '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, earthquakes, satellites, datacenters, or dams ("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.', -'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.', + '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.', '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.', '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.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 00045aff9..f4e328b7f 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -572,7 +572,8 @@ export const ACTION_DESCRIPTIONS = { }, }, next_satellite_pass: { - description: 'Predict the next pass in 24 hours for one satellite in the loaded catalog, identified by exact NORAD ID or name. Ambiguous names return candidates: ask for a choice rather than selecting one. Defaults to geometric passes; visibleOnly requires estimated illumination and a dark observer sky, which does not guarantee naked-eye visibility. Uses camera location unless coordinates are supplied.', + description: + 'Predict the next pass in 24 hours for one satellite in the loaded catalog, identified by exact NORAD ID or name. Ambiguous names return candidates: ask for a choice rather than selecting one. Defaults to geometric passes; visibleOnly requires estimated illumination and a dark observer sky, which does not guarantee naked-eye visibility. Uses camera location unless coordinates are supplied.', $position: 1, }, }; diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js index f2b12da9c..13fd3afe2 100644 --- a/src/data/layerSnapshot.js +++ b/src/data/layerSnapshot.js @@ -32,7 +32,10 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({ }); /** Snapshot feed-state including a disabled layer. */ -export const SNAPSHOT_FEED_STATES = Object.freeze([...LAYER_FEED_STATES, 'off']); +export const SNAPSHOT_FEED_STATES = Object.freeze([ + ...LAYER_FEED_STATES, + 'off', +]); /** * Lower rank is more severe. Voice/HUD `overall` is the worst queried state @@ -132,7 +135,9 @@ export function layerSnapshot(layer = {}, { now = Date.now() } = {}) { 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 lastUpdate = Number.isFinite(Number(stats.lastUpdate)) + ? Number(stats.lastUpdate) + : null; const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off'; return { id: layer.id || layer.layerKey || null, @@ -179,14 +184,22 @@ export function feedProvenanceNote(layers = [], overall = null) { 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'; + 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 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] + 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})` : ''}`; @@ -201,11 +214,17 @@ export function feedProvenanceNote(layers = [], overall = null) { * @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)); +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); + const overall = + worstFeedState(active.map((s) => s.feedState)) || + (layers.length ? 'off' : null); return { overall, layers, diff --git a/src/data/layerSnapshot.test.mjs b/src/data/layerSnapshot.test.mjs index a7c1a1346..9e3b08c80 100644 --- a/src/data/layerSnapshot.test.mjs +++ b/src/data/layerSnapshot.test.mjs @@ -20,45 +20,87 @@ import { 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'); + 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({ 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'); + 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( + [...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.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'); @@ -66,13 +108,21 @@ test('chip labels and severity cover every feed-state the envelope can emit', () }); 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 }); + 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); @@ -80,17 +130,20 @@ test('a disabled layer is off even when leftover stats would read stale', () => }); 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', + const snap = layerSnapshot( + { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { + stale: true, + count: 12, + lastUpdate: NOW - 240_000, + source: 'OpenSky Network', + }, }, - }, { now: NOW }); + { now: NOW }, + ); assert.equal(snap.feedState, 'stale'); assert.equal(snap.source, 'OpenSky Network'); assert.equal(snap.ageSec, 240); @@ -104,20 +157,27 @@ test('an enabled stale layer carries age, source, and a mechanical note', () => }); 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 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/); @@ -125,12 +185,15 @@ test('degraded and unavailable envelopes outrank leftover nominal neighbours', ( }); 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 }); + 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'); @@ -138,23 +201,38 @@ test('an unavailable feed with no prior data does not become a confident zero', }); 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 }); + 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 }); + 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', diff --git a/src/data/satellitePass.js b/src/data/satellitePass.js index 49433ee1b..7f8f75337 100644 --- a/src/data/satellitePass.js +++ b/src/data/satellitePass.js @@ -24,9 +24,18 @@ const AU_KM = 149597870.7; export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) { const date = new Date(dateMs); let pv; - try { pv = propagate(satrec, date); } catch { return null; } + try { + pv = propagate(satrec, date); + } catch { + return null; + } const pos = pv && pv.position; - if (!pos || typeof pos === 'boolean' || ![pos.x, pos.y, pos.z].every(Number.isFinite)) return null; + if ( + !pos || + typeof pos === 'boolean' || + ![pos.x, pos.y, pos.z].every(Number.isFinite) + ) + return null; const ecf = eciToEcf(pos, gstime(date)); const look = ecfToLookAngles( { latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 }, @@ -199,10 +208,31 @@ export function findNextSatellitePass({ requireVisible = false, maxSunElevDeg = -6, }) { - if (!satrec || ![latDeg, lonDeg, fromMs, minElevDeg, horizonHours, coarseStepSec, fineStepSec, maxSunElevDeg].every(Number.isFinite) - || Math.abs(latDeg) > 90 || Math.abs(lonDeg) > 180 || Math.abs(fromMs) > 8.63e15 - || minElevDeg < 0 || minElevDeg > 90 || horizonHours <= 0 || horizonHours > 72 - || coarseStepSec < 1 || coarseStepSec > 120 || fineStepSec < 0.1 || fineStepSec > 60) return null; + if ( + !satrec || + ![ + latDeg, + lonDeg, + fromMs, + minElevDeg, + horizonHours, + coarseStepSec, + fineStepSec, + maxSunElevDeg, + ].every(Number.isFinite) || + Math.abs(latDeg) > 90 || + Math.abs(lonDeg) > 180 || + Math.abs(fromMs) > 8.63e15 || + minElevDeg < 0 || + minElevDeg > 90 || + horizonHours <= 0 || + horizonHours > 72 || + coarseStepSec < 1 || + coarseStepSec > 120 || + fineStepSec < 0.1 || + fineStepSec > 60 + ) + return null; const horizonMs = fromMs + horizonHours * 3600_000; const coarseMs = coarseStepSec * 1000; const fineMs = Math.max(1000, (fineStepSec || 5) * 1000); @@ -338,11 +368,16 @@ export function findNextSatellitePass({ const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev); const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs; if (Math.abs(shift) < peakStepMs) { - refinedPeakMs = Math.max(riseMs, Math.min(setMs, Math.round(maxElevMs + shift))); - refinedPeakElevDeg = - Math.min(90, maxElevDeg - - Math.pow(peakLeftElev - peakRightElev, 2) / - (8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev))); + refinedPeakMs = Math.max( + riseMs, + Math.min(setMs, Math.round(maxElevMs + shift)), + ); + refinedPeakElevDeg = Math.min( + 90, + maxElevDeg - + Math.pow(peakLeftElev - peakRightElev, 2) / + (8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)), + ); } } @@ -365,7 +400,10 @@ export function findNextSatellitePass({ if (!hasVisibleSegment) { hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => { const look = lookAnglesAt(satrec, time, latDeg, lonDeg); - return [Boolean(look && isSatelliteSunlit(look.satECI, time)), isObserverDark(latDeg, lonDeg, time, maxSunElevDeg)]; + return [ + Boolean(look && isSatelliteSunlit(look.satECI, time)), + isObserverDark(latDeg, lonDeg, time, maxSunElevDeg), + ]; }); } @@ -391,7 +429,6 @@ export function findNextSatellitePass({ return null; } - /** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */ export function hasVisibleInterval(start, end, conditionsAt) { const visible = (states) => states[0] && states[1]; diff --git a/src/data/satellitePass.test.mjs b/src/data/satellitePass.test.mjs index 66db1a327..f195dff99 100644 --- a/src/data/satellitePass.test.mjs +++ b/src/data/satellitePass.test.mjs @@ -239,25 +239,44 @@ test('findNextSatellitePass samples the peak at fineStepSec', () => { assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01); }); - test('invalid numerical search options return promptly without unbounded scans', () => { const satrec = twoline2satrec(L1, L2); const options = { satrec, ...AUSTIN, fromMs: FROM_MS }; - for (const change of [{ coarseStepSec: 0 }, { coarseStepSec: -1 }, { horizonHours: Infinity }, { horizonHours: 73 }, { fineStepSec: NaN }, { latDeg: 91 }, { fromMs: NaN }]) { + for (const change of [ + { coarseStepSec: 0 }, + { coarseStepSec: -1 }, + { horizonHours: Infinity }, + { horizonHours: 73 }, + { fineStepSec: NaN }, + { latDeg: 91 }, + { fromMs: NaN }, + ]) { assert.equal(findNextSatellitePass({ ...options, ...change }), null); } }); test('visibility catches a short overlap between shadow exit and dawn', () => { - assert.equal(hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]), true); - assert.equal(hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]), false); + assert.equal( + hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]), + true, + ); + assert.equal( + hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]), + false, + ); }); test('a pass already underway clips rise to the requested start', () => { const satrec = twoline2satrec(L1, L2); const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS }); - const clipped = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: first.maxElevMs }); + const clipped = findNextSatellitePass({ + satrec, + ...AUSTIN, + fromMs: first.maxElevMs, + }); assert.equal(clipped.riseMs, first.maxElevMs); assert.ok(clipped.setMs >= clipped.riseMs); - assert.ok(clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs); + assert.ok( + clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs, + ); }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index ccaacf7e4..bad66f8fc 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -657,15 +657,15 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins // ── Voice: instruction-only, tool schema unchanged ───────────────────── test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => { - // ALPR deliberately adds its ID to the two layer menus and visibility aliases. + // Analyst layers and the separate satellite-pass tool deliberately extend the schema. // Canonical serialization pins every tool name, description, property and // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). const block = JSON.stringify(GEV_REALTIME_TOOLS); - assert.equal(block.length, 26208, 'serialized tool schema length drifted'); + assert.equal(block.length, 27271, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686', + 'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/hud.js b/src/hud.js index 16288fcd3..f40a32da5 100644 --- a/src/hud.js +++ b/src/hud.js @@ -20,7 +20,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 { hudSummaryLayerContext, hudTelemetryProvenanceTag, isHudSummaryUnconfigured } from './hudSummaryResponse.js'; +import { hudSummaryMatchesProvenance, hudSummaryLayerContext, hudTelemetryProvenanceTag, isHudSummaryUnconfigured } from './hudSummaryResponse.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -677,7 +677,7 @@ export class IntelHUD { if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - this._setSummaryText(data.summary, animate); + this._setSummaryText(hudSummaryMatchesProvenance(data.summary, context.feedProvenance) ? data.summary : fallbackText, animate); } catch (error) { if (error?.name !== 'AbortError') { console.warn('[HUD] AI summary unavailable:', error); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 12c50c1b6..65ec252bb 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -95,3 +95,10 @@ export function hudTelemetryProvenanceTag(layers = [], options) { .slice(0, 2); return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`; } + +/** Require the supplied non-nominal state before showing an AI summary. */ +export function hudSummaryMatchesProvenance(summary, provenance) { + const state = provenance?.overall; + if (!state || state === 'nominal' || state === 'off') return true; + return String(summary || '').toUpperCase().split(/\W+/).includes(String(state).toUpperCase()); +} diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index 31d474e08..194d66106 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -170,3 +170,10 @@ test('the HUD proxy uses the shared provenance instructions', () => { assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/); assert.doesNotMatch(local, /enabled-layer text labels/); }); + +test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => { + const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js'); + assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false); + assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true); + assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true); +}); diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index bafde7ac4..ed26a8883 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -204,14 +204,27 @@ export function createOrbits({ state: layerState, services, parts, source }) { const exactId = /^\d+$/.test(text) ? Number(text) : null; if (exactId !== null) { const sat = layerState._catalog.get(exactId); - return sat?.satrec ? { status: 'ok', noradId: exactId, name: sat.name } : { status: 'not-found' }; + return sat?.satrec + ? { status: 'ok', noradId: exactId, name: sat.name } + : { status: 'not-found' }; } const q = text.toLowerCase(); const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec); - const exact = rows.filter(([, sat]) => String(sat.name).toLowerCase() === q); - const matches = exact.length ? exact : rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q)); + const exact = rows.filter( + ([, sat]) => String(sat.name).toLowerCase() === q, + ); + const matches = exact.length + ? exact + : rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q)); if (!matches.length) return { status: 'not-found' }; - if (matches.length > 1) return { status: 'ambiguous', candidates: matches.slice(0, 8).map(([noradId, sat]) => ({ noradId, name: sat.name })), totalMatches: matches.length }; + if (matches.length > 1) + return { + status: 'ambiguous', + candidates: matches + .slice(0, 8) + .map(([noradId, sat]) => ({ noradId, name: sat.name })), + totalMatches: matches.length, + }; return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name }; } diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index 7b4e6603a..eddae75b6 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -14,9 +14,15 @@ import { ISS_NORAD } from './policy.js'; */ export function mapAnalystRecord(raw) { const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const noradNum = raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId); - const noradId = Number.isFinite(noradNum) ? String(Math.trunc(noradNum)) : text(raw?.noradId); + const text = (v) => { + const t = String(v ?? '').trim(); + return t || null; + }; + const noradNum = + raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId); + const noradId = Number.isFinite(noradNum) + ? String(Math.trunc(noradNum)) + : text(raw?.noradId); const name = text(raw?.name); const group = text(raw?.group); const isIss = noradNum === ISS_NORAD; @@ -30,54 +36,64 @@ export function mapAnalystRecord(raw) { speedMps: num(raw?.speedMps), // Empty records stay class-less; the class table's fallback is VISUAL and // must not leak onto a row that has no satellite identity. - satelliteClass: (noradId || group) ? satelliteClassLabel(group, { isIss }) : null, + satelliteClass: + noradId || group ? satelliteClassLabel(group, { isIss }) : null, group, }; } - /** Create on-demand analyst snapshots owned by one layer instance. */ export function createRecords({ state, parts }) { -function analystGeo(noradId, sat, now) { - const pos = sat?.satrec ? parts.orbits.propagatePosition(sat.satrec, now) : null; - if (pos && Number.isFinite(pos.latitude) && Number.isFinite(pos.longitude)) { + function analystGeo(noradId, sat, now) { + const pos = sat?.satrec + ? parts.orbits.propagatePosition(sat.satrec, now) + : null; + if ( + pos && + Number.isFinite(pos.latitude) && + Number.isFinite(pos.longitude) + ) { + return { + lat: pos.latitude, + lon: pos.longitude, + altitudeM: pos.altitude, + speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null, + }; + } + const point = state._points.get(noradId); + if (!point?.position) return null; + const carto = Cesium.Cartographic.fromCartesian(point.position); + if (!carto) return null; return { - lat: pos.latitude, - lon: pos.longitude, - altitudeM: pos.altitude, - speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null, + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + speedMps: null, }; } - const point = state._points.get(noradId); - if (!point?.position) return null; - const carto = Cesium.Cartographic.fromCartesian(point.position); - if (!carto) return null; - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - altitudeM: carto.height, - speedMps: null, - }; -} function getAnalystRecords(maxCount = 2000) { if (!state._enabled || !state._catalog.size) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; const now = new Date(); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; const pos = analystGeo(noradId, sat, now); if (!pos) return true; - result.push(mapAnalystRecord({ - noradId, - name: sat?.name, - group: sat?.group, - lat: pos.lat, - lon: pos.lon, - altitudeM: pos.altitudeM, - speedMps: pos.speedMps, - })); + result.push( + mapAnalystRecord({ + noradId, + name: sat?.name, + group: sat?.group, + lat: pos.lat, + lon: pos.lon, + altitudeM: pos.altitudeM, + speedMps: pos.speedMps, + }), + ); return result.length < limit; }; for (const [noradId, sat] of state._catalog) { diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 778f89a8c..ea8c7ca1c 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -177,17 +177,20 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'fly_to_location', 'select_nearest_aircraft', 'set_map_stack', + 'analyst_query', + 'next_iss_pass', + 'next_satellite_pass', ]); 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, 19); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); - // ALPR intentionally extends the two layer enums; retain the complete pin. - assert.equal(digest, '6963175a0c9a76de', 'an unchanged Realtime tool definition drifted'); + // Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin. + assert.equal(digest, '7411ea585e534795', 'an unchanged Realtime tool definition drifted'); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 9160b85cf..ff1a8dcf9 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -866,7 +866,9 @@ const schemas = [ { name: 'next_satellite_pass', parameters: { - type: 'object', additionalProperties: false, required: ['target'], + type: 'object', + additionalProperties: false, + required: ['target'], properties: { target: { type: 'string', minLength: 1, maxLength: 120 }, latitude: { type: 'number', minimum: -90, maximum: 90 }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index e3107f0b3..121bd9de3 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -15,13 +15,13 @@ const stable = (value) => ) : value; -test('the complete Realtime tool payload retains its pre-extraction contract and wording', () => { +test('the complete Realtime tool payload pins the additive analyst and satellite release', () => { const digest = createHash('sha256') .update(JSON.stringify(stable(GEV_REALTIME_TOOLS))) .digest('hex'); assert.equal( digest, - '956381c3456d3644ed7c9cda72910dc68a34d9191e0b3e414ee200c348245214', + '9e33ac0fa5a860a17bb6d79f2c43646b6c36d0c932590bf114814e891a1c96de', ); }); @@ -76,3 +76,12 @@ test('metadata cannot add tools, fields, types or enum values', () => { ]) assert.throws(() => createActionTools(descriptions), TypeError); }); + + +test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { + const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter((tool) => tool.name !== 'next_satellite_pass'); + const layers = legacy.find((tool) => tool.name === 'analyst_query').parameters.properties.layers.items; + layers.enum = layers.enum.filter((key) => !['satellites', 'local-datacenters', 'local-dams'].includes(key)); + // Independently derived by executing trusted c9f9896 actionSchemas in the restricted container. + assert.equal(createHash('sha256').update(JSON.stringify(legacy)).digest('hex'), '820fff21658f6907e1010b2b79c5431a77f4e34afd2277d62d8de46c368b6f8c'); +});