mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix: pin additive voice contracts and require HUD provenance wording
This commit is contained in:
1 parent
6d6d43c36c
commit
1e8f919cf3
16 files changed
+368
-154
No files matched your search
@@ -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 { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||||
import { readRequestBody } from '../common/request.js';
|
import { readRequestBody } from '../common/request.js';
|
||||||
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
|
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.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.',
|
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||||
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, 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.',
|
'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 <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
||||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
||||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||||
|
|||||||
@@ -572,7 +572,8 @@ export const ACTION_DESCRIPTIONS = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
next_satellite_pass: {
|
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,
|
$position: 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+30
-11
@@ -32,7 +32,10 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Snapshot feed-state including a disabled layer. */
|
/** 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
|
* 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 enabled = Boolean(layer.enabled);
|
||||||
const countRaw = Number(stats.count);
|
const countRaw = Number(stats.count);
|
||||||
const count = Number.isFinite(countRaw) ? countRaw : 0;
|
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';
|
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
|
||||||
return {
|
return {
|
||||||
id: layer.id || layer.layerKey || null,
|
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.';
|
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
|
||||||
}
|
}
|
||||||
if (overall === 'off') {
|
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.`;
|
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
|
||||||
}
|
}
|
||||||
const flagged = layers.filter((s) => (
|
const flagged = layers.filter(
|
||||||
s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off'
|
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||||
));
|
);
|
||||||
const bits = flagged.map((s) => {
|
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)
|
.filter(Boolean)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
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]
|
* @param {{ now?: number }} [options]
|
||||||
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
|
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
|
||||||
*/
|
*/
|
||||||
export function feedProvenanceEnvelope(snapshots = [], { now = Date.now() } = {}) {
|
export function feedProvenanceEnvelope(
|
||||||
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) => asSnapshot(entry, now));
|
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 active = layers.filter((s) => s.feedState && s.feedState !== 'off');
|
||||||
const overall = worstFeedState(active.map((s) => s.feedState))
|
const overall =
|
||||||
|| (layers.length ? 'off' : null);
|
worstFeedState(active.map((s) => s.feedState)) ||
|
||||||
|
(layers.length ? 'off' : null);
|
||||||
return {
|
return {
|
||||||
overall,
|
overall,
|
||||||
layers,
|
layers,
|
||||||
|
|||||||
+151
-73
@@ -20,45 +20,87 @@ import {
|
|||||||
const NOW = 1_700_000_000_000;
|
const NOW = 1_700_000_000_000;
|
||||||
|
|
||||||
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
|
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.equal(src.includes("from './manager.js'"), false);
|
||||||
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
|
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('layerFeedState matches the chip classifier for every honesty class', () => {
|
test('layerFeedState matches the chip classifier for every honesty class', () => {
|
||||||
assert.equal(layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }), 'unavailable');
|
assert.equal(
|
||||||
assert.equal(layerFeedState({
|
layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }),
|
||||||
status: 'unavailable',
|
'unavailable',
|
||||||
error: 'feed down',
|
);
|
||||||
count: 50,
|
assert.equal(
|
||||||
lastUpdate: 1,
|
layerFeedState({
|
||||||
}), 'unavailable');
|
status: 'unavailable',
|
||||||
assert.equal(layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }), 'fallback');
|
error: 'feed down',
|
||||||
assert.equal(layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }), 'fallback');
|
count: 50,
|
||||||
assert.equal(layerFeedState({
|
lastUpdate: 1,
|
||||||
source: 'adsb.lol',
|
}),
|
||||||
fallback: false,
|
'unavailable',
|
||||||
count: 10,
|
);
|
||||||
lastUpdate: 1,
|
assert.equal(
|
||||||
}), 'nominal');
|
layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }),
|
||||||
assert.equal(layerFeedState({ stale: true, count: 0, lastUpdate: 1 }), 'stale');
|
'fallback',
|
||||||
assert.equal(layerFeedState({ error: 'partial group failure', count: 50, lastUpdate: 1 }), 'degraded');
|
);
|
||||||
|
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({ loading: true }), 'loading');
|
||||||
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
|
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
|
||||||
assert.equal(layerFeedState({ status: 'zoom-in', error: 'zoom in to search', count: 12 }), 'nominal');
|
assert.equal(
|
||||||
assert.equal(layerFeedState({ status: 'zoom-in', stale: true, count: 12 }), 'stale');
|
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', () => {
|
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
||||||
assert.deepEqual([...LAYER_FEED_STATES], [
|
assert.deepEqual(
|
||||||
'nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable',
|
[...LAYER_FEED_STATES],
|
||||||
]);
|
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
|
||||||
|
);
|
||||||
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
||||||
for (const state of LAYER_FEED_STATES) {
|
for (const state of LAYER_FEED_STATES) {
|
||||||
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
|
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
|
||||||
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
|
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(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
|
||||||
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
|
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
|
||||||
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
|
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', () => {
|
test('a disabled layer is off even when leftover stats would read stale', () => {
|
||||||
const snap = layerSnapshot({
|
const snap = layerSnapshot(
|
||||||
id: 'flights',
|
{
|
||||||
name: 'Live Flights',
|
id: 'flights',
|
||||||
enabled: false,
|
name: 'Live Flights',
|
||||||
source: 'OpenSky Network',
|
enabled: false,
|
||||||
stats: { stale: true, count: 12, lastUpdate: NOW - 240_000, source: 'OpenSky Network' },
|
source: 'OpenSky Network',
|
||||||
}, { now: NOW });
|
stats: {
|
||||||
|
stale: true,
|
||||||
|
count: 12,
|
||||||
|
lastUpdate: NOW - 240_000,
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ now: NOW },
|
||||||
|
);
|
||||||
assert.equal(snap.feedState, 'off');
|
assert.equal(snap.feedState, 'off');
|
||||||
assert.equal(snap.enabled, false);
|
assert.equal(snap.enabled, false);
|
||||||
assert.equal(snap.count, 12);
|
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', () => {
|
test('an enabled stale layer carries age, source, and a mechanical note', () => {
|
||||||
const snap = layerSnapshot({
|
const snap = layerSnapshot(
|
||||||
id: 'flights',
|
{
|
||||||
name: 'Live Flights',
|
id: 'flights',
|
||||||
enabled: true,
|
name: 'Live Flights',
|
||||||
stats: {
|
enabled: true,
|
||||||
stale: true,
|
stats: {
|
||||||
count: 12,
|
stale: true,
|
||||||
lastUpdate: NOW - 240_000,
|
count: 12,
|
||||||
source: 'OpenSky Network',
|
lastUpdate: NOW - 240_000,
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, { now: NOW });
|
{ now: NOW },
|
||||||
|
);
|
||||||
assert.equal(snap.feedState, 'stale');
|
assert.equal(snap.feedState, 'stale');
|
||||||
assert.equal(snap.source, 'OpenSky Network');
|
assert.equal(snap.source, 'OpenSky Network');
|
||||||
assert.equal(snap.ageSec, 240);
|
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', () => {
|
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
|
||||||
const layers = layerSnapshots([
|
const layers = layerSnapshots(
|
||||||
{
|
[
|
||||||
id: 'earthquakes',
|
{
|
||||||
name: 'Earthquakes',
|
id: 'earthquakes',
|
||||||
enabled: true,
|
name: 'Earthquakes',
|
||||||
stats: { count: 3, lastUpdate: NOW - 10_000 },
|
enabled: true,
|
||||||
},
|
stats: { count: 3, lastUpdate: NOW - 10_000 },
|
||||||
{
|
},
|
||||||
id: 'local-firms',
|
{
|
||||||
name: 'Active Fires',
|
id: 'local-firms',
|
||||||
enabled: true,
|
name: 'Active Fires',
|
||||||
stats: { error: 'FIRMS key missing', count: 8, lastUpdate: NOW - 30_000 },
|
enabled: true,
|
||||||
},
|
stats: {
|
||||||
], { now: NOW });
|
error: 'FIRMS key missing',
|
||||||
|
count: 8,
|
||||||
|
lastUpdate: NOW - 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ now: NOW },
|
||||||
|
);
|
||||||
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
|
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
|
||||||
assert.equal(envelope.overall, 'degraded');
|
assert.equal(envelope.overall, 'degraded');
|
||||||
assert.match(envelope.note, /Active Fires is 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', () => {
|
test('an unavailable feed with no prior data does not become a confident zero', () => {
|
||||||
const snap = layerSnapshot({
|
const snap = layerSnapshot(
|
||||||
id: 'ais-live-vessels',
|
{
|
||||||
name: 'Live Vessels',
|
id: 'ais-live-vessels',
|
||||||
enabled: true,
|
name: 'Live Vessels',
|
||||||
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
|
enabled: true,
|
||||||
}, { now: NOW });
|
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
|
||||||
|
},
|
||||||
|
{ now: NOW },
|
||||||
|
);
|
||||||
assert.equal(snap.feedState, 'unavailable');
|
assert.equal(snap.feedState, 'unavailable');
|
||||||
const envelope = feedProvenanceEnvelope([snap]);
|
const envelope = feedProvenanceEnvelope([snap]);
|
||||||
assert.equal(envelope.overall, 'unavailable');
|
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', () => {
|
test('fallback provenance names the fallback source instead of calling it live', () => {
|
||||||
const snap = layerSnapshot({
|
const snap = layerSnapshot(
|
||||||
id: 'flights',
|
{
|
||||||
name: 'Live Flights',
|
id: 'flights',
|
||||||
enabled: true,
|
name: 'Live Flights',
|
||||||
stats: { source: 'adsb.lol fallback', count: 40, lastUpdate: NOW - 8_000 },
|
enabled: true,
|
||||||
}, { now: NOW });
|
stats: {
|
||||||
|
source: 'adsb.lol fallback',
|
||||||
|
count: 40,
|
||||||
|
lastUpdate: NOW - 8_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ now: NOW },
|
||||||
|
);
|
||||||
assert.equal(snap.feedState, 'fallback');
|
assert.equal(snap.feedState, 'fallback');
|
||||||
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
|
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('view-state rows keep the existing identity fields and add feedState', () => {
|
test('view-state rows keep the existing identity fields and add feedState', () => {
|
||||||
const row = viewStateLayerRow({
|
const row = viewStateLayerRow(
|
||||||
id: 'flights',
|
{
|
||||||
name: 'Live Flights',
|
id: 'flights',
|
||||||
enabled: true,
|
name: 'Live Flights',
|
||||||
stats: { count: 9, lastUpdate: 42, error: null, source: 'OpenSky Network' },
|
enabled: true,
|
||||||
}, { now: NOW });
|
stats: {
|
||||||
|
count: 9,
|
||||||
|
lastUpdate: 42,
|
||||||
|
error: null,
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ now: NOW },
|
||||||
|
);
|
||||||
assert.deepEqual(row, {
|
assert.deepEqual(row, {
|
||||||
id: 'flights',
|
id: 'flights',
|
||||||
name: 'Live Flights',
|
name: 'Live Flights',
|
||||||
|
|||||||
+50
-13
@@ -24,9 +24,18 @@ const AU_KM = 149597870.7;
|
|||||||
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
|
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
|
||||||
const date = new Date(dateMs);
|
const date = new Date(dateMs);
|
||||||
let pv;
|
let pv;
|
||||||
try { pv = propagate(satrec, date); } catch { return null; }
|
try {
|
||||||
|
pv = propagate(satrec, date);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const pos = pv && pv.position;
|
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 ecf = eciToEcf(pos, gstime(date));
|
||||||
const look = ecfToLookAngles(
|
const look = ecfToLookAngles(
|
||||||
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
|
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
|
||||||
@@ -199,10 +208,31 @@ export function findNextSatellitePass({
|
|||||||
requireVisible = false,
|
requireVisible = false,
|
||||||
maxSunElevDeg = -6,
|
maxSunElevDeg = -6,
|
||||||
}) {
|
}) {
|
||||||
if (!satrec || ![latDeg, lonDeg, fromMs, minElevDeg, horizonHours, coarseStepSec, fineStepSec, maxSunElevDeg].every(Number.isFinite)
|
if (
|
||||||
|| Math.abs(latDeg) > 90 || Math.abs(lonDeg) > 180 || Math.abs(fromMs) > 8.63e15
|
!satrec ||
|
||||||
|| minElevDeg < 0 || minElevDeg > 90 || horizonHours <= 0 || horizonHours > 72
|
![
|
||||||
|| coarseStepSec < 1 || coarseStepSec > 120 || fineStepSec < 0.1 || fineStepSec > 60) return null;
|
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 horizonMs = fromMs + horizonHours * 3600_000;
|
||||||
const coarseMs = coarseStepSec * 1000;
|
const coarseMs = coarseStepSec * 1000;
|
||||||
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
|
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
|
||||||
@@ -338,11 +368,16 @@ export function findNextSatellitePass({
|
|||||||
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
|
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
|
||||||
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
|
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
|
||||||
if (Math.abs(shift) < peakStepMs) {
|
if (Math.abs(shift) < peakStepMs) {
|
||||||
refinedPeakMs = Math.max(riseMs, Math.min(setMs, Math.round(maxElevMs + shift)));
|
refinedPeakMs = Math.max(
|
||||||
refinedPeakElevDeg =
|
riseMs,
|
||||||
Math.min(90, maxElevDeg -
|
Math.min(setMs, Math.round(maxElevMs + shift)),
|
||||||
Math.pow(peakLeftElev - peakRightElev, 2) /
|
);
|
||||||
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)));
|
refinedPeakElevDeg = Math.min(
|
||||||
|
90,
|
||||||
|
maxElevDeg -
|
||||||
|
Math.pow(peakLeftElev - peakRightElev, 2) /
|
||||||
|
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +400,10 @@ export function findNextSatellitePass({
|
|||||||
if (!hasVisibleSegment) {
|
if (!hasVisibleSegment) {
|
||||||
hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => {
|
hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => {
|
||||||
const look = lookAnglesAt(satrec, time, latDeg, lonDeg);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */
|
/** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */
|
||||||
export function hasVisibleInterval(start, end, conditionsAt) {
|
export function hasVisibleInterval(start, end, conditionsAt) {
|
||||||
const visible = (states) => states[0] && states[1];
|
const visible = (states) => states[0] && states[1];
|
||||||
|
|||||||
@@ -239,25 +239,44 @@ test('findNextSatellitePass samples the peak at fineStepSec', () => {
|
|||||||
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
|
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('invalid numerical search options return promptly without unbounded scans', () => {
|
test('invalid numerical search options return promptly without unbounded scans', () => {
|
||||||
const satrec = twoline2satrec(L1, L2);
|
const satrec = twoline2satrec(L1, L2);
|
||||||
const options = { satrec, ...AUSTIN, fromMs: FROM_MS };
|
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);
|
assert.equal(findNextSatellitePass({ ...options, ...change }), null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('visibility catches a short overlap between shadow exit and dawn', () => {
|
test('visibility catches a short overlap between shadow exit and dawn', () => {
|
||||||
assert.equal(hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]), true);
|
assert.equal(
|
||||||
assert.equal(hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]), false);
|
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', () => {
|
test('a pass already underway clips rise to the requested start', () => {
|
||||||
const satrec = twoline2satrec(L1, L2);
|
const satrec = twoline2satrec(L1, L2);
|
||||||
const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS });
|
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.equal(clipped.riseMs, first.maxElevMs);
|
||||||
assert.ok(clipped.setMs >= clipped.riseMs);
|
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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -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 ─────────────────────
|
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
|
||||||
|
|
||||||
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
|
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
|
// Canonical serialization pins every tool name, description, property and
|
||||||
// ordering while allowing source formatting. Derived from the unchanged
|
// ordering while allowing source formatting. Derived from the unchanged
|
||||||
// release schema before formatting (the previous source-byte pin passed).
|
// release schema before formatting (the previous source-byte pin passed).
|
||||||
const block = JSON.stringify(GEV_REALTIME_TOOLS);
|
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(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686',
|
'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ import { CITY_POIS } from './locations.js';
|
|||||||
import { composeLocalityTag } from './hudLocality.js';
|
import { composeLocalityTag } from './hudLocality.js';
|
||||||
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
import { ellipsoidalToMslDisplayM, ensureGeoidReady, geoidHeight } from './data/geoid.js';
|
||||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||||
import { 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. */
|
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||||
const HUD_COLORS = {
|
const HUD_COLORS = {
|
||||||
@@ -677,7 +677,7 @@ export class IntelHUD {
|
|||||||
if (!response.ok || !data?.summary) {
|
if (!response.ok || !data?.summary) {
|
||||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
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) {
|
} catch (error) {
|
||||||
if (error?.name !== 'AbortError') {
|
if (error?.name !== 'AbortError') {
|
||||||
console.warn('[HUD] AI summary unavailable:', error);
|
console.warn('[HUD] AI summary unavailable:', error);
|
||||||
|
|||||||
@@ -95,3 +95,10 @@ export function hudTelemetryProvenanceTag(layers = [], options) {
|
|||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
|
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());
|
||||||
|
}
|
||||||
@@ -170,3 +170,10 @@ test('the HUD proxy uses the shared provenance instructions', () => {
|
|||||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
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);
|
||||||
|
});
|
||||||
@@ -204,14 +204,27 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
|||||||
const exactId = /^\d+$/.test(text) ? Number(text) : null;
|
const exactId = /^\d+$/.test(text) ? Number(text) : null;
|
||||||
if (exactId !== null) {
|
if (exactId !== null) {
|
||||||
const sat = layerState._catalog.get(exactId);
|
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 q = text.toLowerCase();
|
||||||
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
|
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
|
||||||
const exact = rows.filter(([, sat]) => String(sat.name).toLowerCase() === q);
|
const exact = rows.filter(
|
||||||
const matches = exact.length ? exact : rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q));
|
([, 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) 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 };
|
return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ import { ISS_NORAD } from './policy.js';
|
|||||||
*/
|
*/
|
||||||
export function mapAnalystRecord(raw) {
|
export function mapAnalystRecord(raw) {
|
||||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||||
const text = (v) => { const t = String(v ?? '').trim(); return t || null; };
|
const text = (v) => {
|
||||||
const noradNum = raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId);
|
const t = String(v ?? '').trim();
|
||||||
const noradId = Number.isFinite(noradNum) ? String(Math.trunc(noradNum)) : text(raw?.noradId);
|
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 name = text(raw?.name);
|
||||||
const group = text(raw?.group);
|
const group = text(raw?.group);
|
||||||
const isIss = noradNum === ISS_NORAD;
|
const isIss = noradNum === ISS_NORAD;
|
||||||
@@ -30,54 +36,64 @@ export function mapAnalystRecord(raw) {
|
|||||||
speedMps: num(raw?.speedMps),
|
speedMps: num(raw?.speedMps),
|
||||||
// Empty records stay class-less; the class table's fallback is VISUAL and
|
// Empty records stay class-less; the class table's fallback is VISUAL and
|
||||||
// must not leak onto a row that has no satellite identity.
|
// 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,
|
group,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Create on-demand analyst snapshots owned by one layer instance. */
|
/** Create on-demand analyst snapshots owned by one layer instance. */
|
||||||
export function createRecords({ state, parts }) {
|
export function createRecords({ state, parts }) {
|
||||||
function analystGeo(noradId, sat, now) {
|
function analystGeo(noradId, sat, now) {
|
||||||
const pos = sat?.satrec ? parts.orbits.propagatePosition(sat.satrec, now) : null;
|
const pos = sat?.satrec
|
||||||
if (pos && Number.isFinite(pos.latitude) && Number.isFinite(pos.longitude)) {
|
? 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 {
|
return {
|
||||||
lat: pos.latitude,
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||||
lon: pos.longitude,
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
altitudeM: pos.altitude,
|
altitudeM: carto.height,
|
||||||
speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null,
|
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) {
|
function getAnalystRecords(maxCount = 2000) {
|
||||||
if (!state._enabled || !state._catalog.size) return [];
|
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 now = new Date();
|
||||||
const result = [];
|
const result = [];
|
||||||
const emit = (noradId, sat) => {
|
const emit = (noradId, sat) => {
|
||||||
if (result.length >= limit) return false;
|
if (result.length >= limit) return false;
|
||||||
const pos = analystGeo(noradId, sat, now);
|
const pos = analystGeo(noradId, sat, now);
|
||||||
if (!pos) return true;
|
if (!pos) return true;
|
||||||
result.push(mapAnalystRecord({
|
result.push(
|
||||||
noradId,
|
mapAnalystRecord({
|
||||||
name: sat?.name,
|
noradId,
|
||||||
group: sat?.group,
|
name: sat?.name,
|
||||||
lat: pos.lat,
|
group: sat?.group,
|
||||||
lon: pos.lon,
|
lat: pos.lat,
|
||||||
altitudeM: pos.altitudeM,
|
lon: pos.lon,
|
||||||
speedMps: pos.speedMps,
|
altitudeM: pos.altitudeM,
|
||||||
}));
|
speedMps: pos.speedMps,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return result.length < limit;
|
return result.length < limit;
|
||||||
};
|
};
|
||||||
for (const [noradId, sat] of state._catalog) {
|
for (const [noradId, sat] of state._catalog) {
|
||||||
|
|||||||
@@ -177,17 +177,20 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
|||||||
'fly_to_location',
|
'fly_to_location',
|
||||||
'select_nearest_aircraft',
|
'select_nearest_aircraft',
|
||||||
'set_map_stack',
|
'set_map_stack',
|
||||||
|
'analyst_query',
|
||||||
|
'next_iss_pass',
|
||||||
|
'next_satellite_pass',
|
||||||
]);
|
]);
|
||||||
const unchanged = realtimeTools()
|
const unchanged = realtimeTools()
|
||||||
.filter((tool) => !TOUCHED.has(tool.name))
|
.filter((tool) => !TOUCHED.has(tool.name))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
assert.equal(unchanged.length, 21);
|
assert.equal(unchanged.length, 19);
|
||||||
const digest = createHash('sha256')
|
const digest = createHash('sha256')
|
||||||
.update(JSON.stringify(unchanged))
|
.update(JSON.stringify(unchanged))
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 16);
|
.slice(0, 16);
|
||||||
// ALPR intentionally extends the two layer enums; retain the complete pin.
|
// Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin.
|
||||||
assert.equal(digest, '6963175a0c9a76de', 'an unchanged Realtime tool definition drifted');
|
assert.equal(digest, '7411ea585e534795', 'an unchanged Realtime tool definition drifted');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
||||||
|
|||||||
@@ -866,7 +866,9 @@ const schemas = [
|
|||||||
{
|
{
|
||||||
name: 'next_satellite_pass',
|
name: 'next_satellite_pass',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object', additionalProperties: false, required: ['target'],
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['target'],
|
||||||
properties: {
|
properties: {
|
||||||
target: { type: 'string', minLength: 1, maxLength: 120 },
|
target: { type: 'string', minLength: 1, maxLength: 120 },
|
||||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ const stable = (value) =>
|
|||||||
)
|
)
|
||||||
: 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')
|
const digest = createHash('sha256')
|
||||||
.update(JSON.stringify(stable(GEV_REALTIME_TOOLS)))
|
.update(JSON.stringify(stable(GEV_REALTIME_TOOLS)))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
digest,
|
digest,
|
||||||
'956381c3456d3644ed7c9cda72910dc68a34d9191e0b3e414ee200c348245214',
|
'9e33ac0fa5a860a17bb6d79f2c43646b6c36d0c932590bf114814e891a1c96de',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,3 +76,12 @@ test('metadata cannot add tools, fields, types or enum values', () => {
|
|||||||
])
|
])
|
||||||
assert.throws(() => createActionTools(descriptions), TypeError);
|
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');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user