fix(voice): preserve analyst result and tracking contracts

keep contacts counts, rows, scope and provenance on one retained snapshot

report capped cohorts as lower bounds

separate sibling tool cancellation from newer user turns

align track_entity validation across runtime and benchmark paths
This commit is contained in:
manjunath22466 committed 2026-10-02 11:15:31 +05:30
1 parent a19897ad15
commit 095981eee7
27 files changed
+1155 -146

No files matched your search

+11 -1
View File
@@ -1,7 +1,14 @@
# Changelog
- The voice card shows analyst counts ("At least 250,000 aircraft …") with
their scope, feed window and caveats, and marks partial answers.
their scope, feed window and caveats, and marks partial answers. Analyst
speech uses the same deterministic headline, preserves lower bounds and says
when an answer is partial. Follow-ups retain unanswered-layer coverage, and
keep the original scope label when they narrow a remembered result.
Contacts-window answers read one immutable panel snapshot, mark a 20,000-row
layer cap as a lower bound, and commit that displayed cohort only after
cancellation checks pass. Concurrent analyst calls from one model response
remain independent; a stopped or replaced user turn still cancels them.
- Voice answers are shorter and fill slow work instead of going silent. Slow
or multi-step requests may open with one short spoken plan; instant ones
just happen. Tool results carry a code-built `say` line and a `display`
@@ -20,6 +27,9 @@
around here". A reticle marks the point and the voice card shows what
"this" meant. In open mic it works when you have just moved the cursor
there. "The second one" picks from the last numbered list. Asking about
"the last one" resolves to the last of the five rows actually shown, and
referent-only tracking calls are accepted while empty targets are rejected.
Asking about
bare ground up close gives the assistant a small crop of the map around the
point.
+17 -6
View File
@@ -222,7 +222,7 @@ Consult the linked official advisory for safety decisions.
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to the panel cohort. A superseded query never replaces follow-up memory. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance.
Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance.
Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
@@ -299,8 +299,9 @@ stopped conversation or send output into a replacement. See [voice ownership](VO
Voice speech: `src/voice/speech.js` adds `{ say, display, referents?, schedule? }`
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
get_entity_context and annotate_map (analyst_query plugs in through the
runner's `speechBuilders`). `get_current_view_state` lists enabled layers only.
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
share one deterministic count/scope headline; lower bounds use "At least" and
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
steps and the result. `resultDisplay.js` is the one adapter from a result to
the card: builder results show as built, and analyst results show a title
@@ -329,7 +330,9 @@ resolves it. Losing the pointer sends one `target:"none"` item. `deixis.js` reso
track_entity/fly_to_location queries, get_entity_context scope, analyst_query
`scope.kind` and annotate_map targets, plus `referent:n` from the latest
numbered result (`referents.js`, cleared at session end). A new result set (an analyst count) replaces
the numbered list even when it is empty. A reticle marks the
the numbered list even when it is empty. Only the five rows numbered on the
card are resolvable; `-1` means the fifth/last visible row. `track_entity`
accepts either a named query or a referent, while an empty target is rejected. A reticle marks the
point during the turn and the voice card shows "THIS"/"HERE"; both hide in
Clean-UI and recording. A ground-only pointer lookup at local/city scale sends a ringed 512 px crop in
the one retained image slot, dropped if the camera moved or a newer capture or
@@ -454,6 +457,14 @@ runner and ends with the voice session: stopping voice invalidates writes still
flight and forgets the last answer, the pointer and the numbered list; removing
voice disposes the runner.
Analyst result memory is committed only after any Contacts-window projection
finishes and the call is still current. Cancelled or superseded queries cannot
return success or replace follow-up memory; Contacts follow-ups filter the same
cohort that was shown, and partial/unanswered coverage remains attached when a
remembered result is narrowed.
The narrowed answer retains the remembered scope label and detail, including
the Contacts-window attribution, unless the follow-up supplies a new scope.
Application startup constructs fresh layer instances from explicit source objects.
Standalone composition selects the existing providers. Controls, launch orbit
lookups, Contacts voice answers and ISS pass queries use the registered catalog.
@@ -3876,7 +3887,7 @@ and unreachable upstream (502/504) separately from road geometry.
- **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`).
- **A typed command supersedes the turn it interrupts** (2026-08-21). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items.
- **Subject reconciliation across satellite catalog rebuilds** (2026-08-21). A dense↔core toggle or TLE refresh clears and repopulates the catalog, so the published subject is re-resolved against the new satrec; a subject that did NOT survive releases the slot. The per-frame refresh cannot do this itself — `_getTrackedFramePosition` returns early once the satellite has no catalog entry — so the reconcile runs at rebuild completion. An empty catalog is a rebuild in flight, not a disappearance.
- **One aircraft-proximity engine** (2026-08-22). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) is the single computation behind both the Contacts panel window and the voice analyst's entity-centred "how many nearby", so the panel readout and the spoken count for one centre are identical by construction. **Invariant: do not re-derive a proximity count anywhere else.** They diverged before because the panel read live billboard positions (20,000 cap) while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions and arbitrary points deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft}`.
- **One aircraft-proximity snapshot** (2026-08-22; hardened 2026-09-30). `collectAircraftProximityWindow` (`src/data/militaryAwareness.js`) computes the Contacts panel cohort once. The voice analyst reads the exact retained snapshot and evaluation timestamp instead of re-scanning live layers, so the panel readout and spoken count for one centre cannot cross snapshots. **Invariant: do not re-derive a proximity count anywhere else.** Each layer retains 20,000 rows and samples one row beyond the cap; a saturated cohort reports `complete:false`, `truncated:true`, and is spoken as a lower bound. They diverged before because the panel read live billboard positions while the analyst used last-fix coordinates over a 2,000-record slice — 111 on screen, 15 spoken. Explicit regions, arbitrary points, and a radius different from the panel radius deliberately keep the general record engine. Entity-centred results carry `window: {engine:'contacts-window', centeredOn, radiusKm, flights, military, aircraft, complete, evaluatedAt}`.
- **Centre precedence for nearby asks**: explicit place in the question > Contacts subject (a selected non-contact entity never silently becomes the centre) > an entity the user names > the current view, said aloud. Contacts active with no subject uses the view rather than reading an empty panel.
- **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded.
- **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction.
@@ -3894,7 +3905,7 @@ and unreachable upstream (502/504) separately from road geometry.
- **Aircraft identity honesty:** “What is this aircraft?” reads callsign, operator, registration, type, and route only from the selected contact context. Missing operator, route, or type enrichment is named explicitly rather than silently omitted or inferred from the callsign.
- **Diagnostics**: every client/server event is posted to `/api/realtime/debug-log` and appended to `.gev-logs/realtime-conversations.jsonl` (gitignored) with secret/image redaction; last 30 errors persist in `localStorage` (`gev-realtime-errors`); `window.__gevVoiceCommands.getDiagnostics()` in the console.
- **Counting semantics ("near")** — a CONTRACT; new count-bearing tool work inherits it. Three honest numbers exist for one question: the Contacts cohort (250 km around the subject, what the panel shows), `analyst_query`'s count of _currently-loaded_ records for the requested scope, and the layer-wide loaded total in `coverage.layersQueried[].total`. They diverge legitimately — the flights layer loads by viewport, so after a camera dive the loaded set can hold a fraction of the cohort (field case: panel 42, analyst 8). The contract:
1. **Contacts ACTIVE** → "near / nearby / how many aircraft" means the **Contacts window** — the panel's numbers, spoken verbatim. Mechanism: `contactsWindow` (`{centeredOn, radiusKm, flights, military, vessels}`), carried by both `analyst_query` and `get_current_view_state`, derived by `contactsWindowFromSnapshot()` from the same snapshot the panel renders so the two cannot drift. A cohort whose feed cannot answer reports `'unknown'`, never a confident zero.
1. **Contacts ACTIVE** → "near / nearby / how many aircraft" means the **Contacts window** — the panel's numbers, spoken verbatim. Mechanism: `contactsWindow` (`{centeredOn, radiusKm, flights, military, vessels, complete}`), carried by both `analyst_query` and `get_current_view_state`, derived by `contactsWindowFromSnapshot()` from the same snapshot the panel renders so the two cannot drift. Voice filtering and follow-up memory use that immutable snapshot too. A cohort whose feed cannot answer reports `'unknown'`, never a confident zero; a cohort at the 20,000-row retention bound reports a lower bound.
2. **Contacts OFF** → "nearby" means **in view**; "near \<place\>" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera.
3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction.
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
+14 -1
View File
@@ -153,7 +153,7 @@ const TYPE_CHECKS = {
};
/**
* Validate `value` against a JSON schema (type / enum / required /
* Validate `value` against a JSON schema (type / enum / anyOf / required /
* properties / additionalProperties:false / items / min/max bounds).
* Returns a list of human-readable violations; empty means valid.
*/
@@ -170,6 +170,12 @@ export function validateArgs(schema, value, where = 'args') {
if (schema.enum && !schema.enum.includes(value)) {
errors.push(`${where}=${JSON.stringify(value)} is not one of ${schema.enum.join(', ')}`);
}
if (typeof value === 'string') {
if (typeof schema.minLength === 'number' && value.length < schema.minLength)
errors.push(`${where} needs at least ${schema.minLength} characters`);
if (typeof schema.pattern === 'string' && !new RegExp(schema.pattern).test(value))
errors.push(`${where} does not match ${schema.pattern}`);
}
if (typeof value === 'number') {
if (typeof schema.minimum === 'number' && value < schema.minimum) errors.push(`${where} below ${schema.minimum}`);
if (typeof schema.maximum === 'number' && value > schema.maximum) errors.push(`${where} above ${schema.maximum}`);
@@ -191,6 +197,13 @@ export function validateArgs(schema, value, where = 'args') {
errors.push(...validateArgs(sub, v, `${where}.${key}`));
}
}
if (Array.isArray(schema.anyOf)) {
const branches = schema.anyOf.map((branch) =>
validateArgs(branch, value, where),
);
if (!branches.some((branchErrors) => branchErrors.length === 0))
errors.push(`${where} does not match any allowed shape`);
}
return errors;
}
+41 -12
View File
@@ -18,7 +18,7 @@
*
* Providers (injected — keeps the engine pure and node-testable):
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
* resolveRegionRing(name) → Promise<{ring, name}|{error:'region-timeout'}|null>
* resolveRegionRing(name, signal?) → Promise<{ring, name}|{error:'region-timeout'}|null>
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
*
* @module data/analystEngine
@@ -714,12 +714,20 @@ export function createAnalystEngine(providers) {
/**
* Run one query.
* @param {object} spec Query arguments (see the analyst_query tool).
* @param {{isCurrent?: () => boolean}} [options] A query that is no longer
* current (cancelled or superseded while awaiting a boundary or between
* scan slices) returns CANCELLED and never replaces the follow-up memory.
* @param {{isCurrent?: () => boolean, signal?: AbortSignal,
* beforeCommit?: (result: object) => Promise<object|void>}} [options]
* A query that is no longer current
* (cancelled or superseded while awaiting a boundary or between scan
* slices) returns CANCELLED and never replaces the follow-up memory.
* `beforeCommit` lets a caller finish required async projection work and
* optionally supply the exact cohort that was presented before memory is
* changed.
* @returns {Promise<object>} Result or refusal.
*/
async function query(spec = {}, { isCurrent = () => true } = {}) {
async function query(
spec = {},
{ isCurrent = () => true, signal = undefined, beforeCommit = null } = {},
) {
const cancelled = () =>
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
if (spec.followUp && !lastResult)
@@ -788,6 +796,7 @@ export function createAnalystEngine(providers) {
groups = lastResult.matched;
layersQueried = lastResult.coverage.layersQueried;
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
unanswered = [...(lastResult.unanswered || [])];
} else {
groups = [];
layersQueried = [];
@@ -870,18 +879,20 @@ export function createAnalystEngine(providers) {
}
// 2) Spatial scope
const rememberedScope =
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
let resolvedScope = null;
let scopeNote = 'anywhere';
let scopeNote = rememberedScope?.coverage || 'anywhere';
// Human phrasing for the same scope, so every spoken count can name what it
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
// arriving as a bare number that contradicts the panel.
let scopeLabel = 'anywhere in the loaded data';
let scopeDetail = scopeLabel;
let scopeLabel = rememberedScope?.label || 'anywhere in the loaded data';
let scopeDetail = rememberedScope?.detail || scopeLabel;
// A follow-up re-filters a set that was already scoped, so it only narrows
// further when a new scope is given.
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
if (scope.kind === 'region') {
const region = await providers.resolveRegionRing(scope.name);
const region = await providers.resolveRegionRing(scope.name, signal);
if (!isCurrent()) return cancelled();
if (region?.error === 'region-timeout') {
return {
@@ -1039,6 +1050,7 @@ export function createAnalystEngine(providers) {
layersQueried,
records: totals,
scope: scopeNote,
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
@@ -1050,10 +1062,27 @@ export function createAnalystEngine(providers) {
? { centeredOn: resolvedScope.centeredOn }
: {}),
};
const memoryOverride =
typeof beforeCommit === 'function'
? (await beforeCommit(result)) || null
: null;
if (!isCurrent()) return cancelled();
const committedCoverage = memoryOverride?.coverage || result.coverage;
lastResult = {
matched: selection.matched,
layerKeys: [...layers],
coverage: result.coverage,
matched: memoryOverride?.matched || selection.matched,
layerKeys: [...(memoryOverride?.layerKeys || layers)],
coverage: committedCoverage,
unanswered: [
...(memoryOverride?.unanswered === undefined
? unanswered
: memoryOverride.unanswered),
],
scopePresentation: {
label: memoryOverride?.scopeLabel || result.scopeLabel,
detail: memoryOverride?.scopeDetail || result.display.scope,
coverage: committedCoverage.scope,
note: committedCoverage.note || null,
},
};
return result;
}
+119 -2
View File
@@ -174,11 +174,95 @@ test('analyst: nearest sorting attaches distanceKm ascending', async () => {
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
const eng = makeEngine();
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
const first = await eng.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const r = await eng.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: true }],
});
assert.equal(r.count, 1);
assert.equal(r.items[0].id, 'GND1');
assert.equal(r.coverage.followUp, true);
assert.equal(r.scopeLabel, first.scopeLabel);
assert.equal(r.display.scope, first.display.scope);
assert.equal(r.coverage.scope, first.coverage.scope);
});
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
const eng = createAnalystEngine({
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
getLayerSnapshot: (key) => ({
enabled: key === 'flights',
feedState: key === 'flights' ? 'nominal' : 'off',
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await eng.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
});
assert.equal(first.partial, true);
assert.deepEqual(first.unanswered, ['military']);
const followUp = await eng.query({ followUp: true, limit: 1 });
assert.equal(followUp.partial, true);
assert.deepEqual(followUp.unanswered, ['military']);
assert.deepEqual(
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
first.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
);
});
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
let current = true;
const stale = await eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
isCurrent: () => current,
beforeCommit: async () => {
current = false;
},
},
);
assert.equal(stale.code, 'CANCELLED');
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
await assert.rejects(
eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
beforeCommit: async () => {
throw new Error('projection failed');
},
},
),
/projection failed/,
);
const followUp = await eng.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: unresolved region is an honest failure, not empty success', async () => {
@@ -200,6 +284,39 @@ test('analyst: a region lookup timeout is reported as region-timeout', async ()
assert.equal(r.coverage.scope, 'region:Texas:timeout');
});
test('analyst: region lookup receives and obeys the owning action signal', async () => {
const controller = new AbortController();
let receivedSignal = null;
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (_name, signal) => {
receivedSignal = signal;
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
);
return null;
},
getViewContext: () => ({
lat: 30.27,
lon: -97.74,
viewRadiusKm: 150,
}),
});
const pending = eng.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } },
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
await Promise.resolve();
controller.abort();
const result = await pending;
assert.equal(receivedSignal, controller.signal);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
});
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
+64
View File
@@ -41,6 +41,7 @@ import {
formatAwarenessLabel,
getAwarenessNavigationTargets,
} from './militaryAwarenessEngine.js';
import { formatAwarenessCount } from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
const militaryAwarenessSource = readLayerSource(
@@ -55,6 +56,18 @@ const AWARENESS_DEPENDENCIES = [
'military-installations',
];
test('Contacts labels a capped cohort as a lower bound', () => {
assert.equal(
formatAwarenessCount({ count: 20_000, complete: false, truncated: true }),
'At least 20,000',
);
assert.equal(
formatAwarenessCount({ count: 19_999, complete: true, truncated: false }),
'19,999',
);
assert.equal(formatAwarenessCount({ count: null }), '?');
});
function installAwarenessRuntime({
isEnabled = () => true,
isEffectivelyEnabled = isEnabled,
@@ -2554,6 +2567,7 @@ test('the contacts window reports exactly the counts the panel renders', () => {
assert.equal(window.flights, 42);
assert.equal(window.military, 13);
assert.equal(window.aircraft, 55);
assert.equal(window.complete, true);
assert.equal(window.centeredOn, 'ASA635');
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
// A feed that cannot answer says so rather than reporting a confident zero.
@@ -2561,6 +2575,56 @@ test('the contacts window reports exactly the counts the panel renders', () => {
assert.equal(window.vessels, 'unknown');
});
test('the voice query snapshot is a detached copy of the rendered aircraft cohort', async () => {
const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1_000);
const nearbyPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1_500);
const restoreCollections = stubAwarenessCollections({
flights: [
{
icao24: 'subject',
id: 'SUBJECT',
position: subjectPosition,
distance: 0,
},
{
icao24: 'nearby',
id: 'NEARBY',
position: nearbyPosition,
distance: 5_000,
},
],
});
const runtime = installAwarenessRuntime();
try {
runtime.dispatch(
'gev:awareness-subject-selected',
awarenessSubject('flights', 'subject', subjectPosition),
);
await nextTurn();
const first = militaryAwarenessLayer.getAircraftQuerySnapshot();
assert.equal(first.cohorts.flights.count, 1);
assert.equal(first.cohorts.flights.complete, true);
assert.equal(first.cohorts.flights.provenance.id, 'flights');
assert.equal(first.cohorts.flights.provenance.enabled, true);
assert.equal(first.cohorts.flights.items[0].icao24, 'nearby');
first.cohorts.flights.items[0].icao24 = 'mutated';
first.cohorts.flights.provenance.source = 'mutated';
assert.equal(
militaryAwarenessLayer.getAircraftQuerySnapshot().cohorts.flights.items[0]
.icao24,
'nearby',
);
assert.notEqual(
militaryAwarenessLayer.getAircraftQuerySnapshot().cohorts.flights
.provenance.source,
'mutated',
);
} finally {
runtime.restore();
restoreCollections();
}
});
test('there is no contacts window without a subject', () => {
assert.equal(contactsWindowFromSnapshot(null), null);
assert.equal(contactsWindowFromSnapshot({ cohorts: [] }), null);
+5 -5
View File
@@ -656,7 +656,7 @@ 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', () => {
test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping is instructions only', () => {
// 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
@@ -669,12 +669,12 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
const block = JSON.stringify(legacyTools);
// Re-derived for the voice layer manifest (generated layer enums, alias and
// field hints), point-and-ask's pointer/referent arguments and the prompt
// consolidation (shorter analyst, annotate_map and ISS wording); the
// missions still ride existing tools.
assert.equal(block.length, 28800, 'serialized tool schema length drifted');
// consolidation (shorter analyst, annotate_map and ISS wording), plus the
// referent-only track_entity alternative; the missions still ride existing tools.
assert.equal(block.length, 28922, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'7e346dadcc6e6b520c77a5c8cae501c9ee9a2b6e6c61babbe1b0f564cd396db3',
'2b0b1b6677eb735a80fb3955544bf6909df1acf74ca83d3047fcd51edf15a04e',
'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');
+54
View File
@@ -75,6 +75,60 @@ export function createControls({ state: layerState, services, parts, source }) {
);
},
/**
* Return the exact retained aircraft cohorts behind the current Contacts
* panel snapshot. Voice reads this immutable copy instead of re-scanning
* live layers, so panel and spoken counts share one evaluation timestamp.
*/
getAircraftQuerySnapshot() {
if (!layerState.enabled || !layerState.subject || !layerState.results)
return null;
// Build every public count from this exact results object before
// returning. A refresh may replace `layerState.results` as soon as the
// caller awaits, so consumers must not make a second read later and
// accidentally combine two evaluation timestamps.
const panelSnapshot = parts.model.buildAwarenessContextSnapshot(
layerState.results,
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
const cohorts = {};
for (const cohort of layerState.results.cohorts || []) {
if (!['flights', 'military'].includes(cohort?.id)) continue;
cohorts[cohort.id] = {
count: cohort.summary?.count ?? null,
complete:
Number.isFinite(cohort.summary?.count) &&
cohort.summary?.complete !== false,
truncated:
!Number.isFinite(cohort.summary?.count) ||
cohort.summary?.truncated === true,
items: (cohort.summary?.navigationNearest || []).map((item) => ({
...item,
})),
source: cohort.source || null,
provenance: cohort.provenance
? {
...cohort.provenance,
stats: cohort.provenance.stats
? { ...cohort.provenance.stats }
: undefined,
}
: null,
reason: cohort.summary?.reason || null,
};
}
return {
subject: { ...layerState.results.subject },
evaluatedAt: layerState.results.evaluatedAt,
radiusM: layerState.results.radiusM,
cohorts,
contactsWindow: parts.model.contactsWindowFromSnapshot(panelSnapshot),
};
},
/**
* Release Contact-owned camera tracking without discarding the selected
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
+11
View File
@@ -144,6 +144,9 @@ export function createModel({ state: layerState, services, parts, source }) {
function contactsWindowFromSnapshot(snapshot) {
if (!snapshot?.subject) return null;
const aircraftCohorts = (snapshot.cohorts || []).filter((cohort) =>
['flights', 'military'].includes(cohort?.id),
);
const countFor = (cohortId) => {
const cohort = Array.isArray(snapshot.cohorts)
? snapshot.cohorts.find((item) => item?.id === cohortId)
@@ -164,6 +167,12 @@ export function createModel({ state: layerState, services, parts, source }) {
flights,
military,
vessels: countFor('ais-live-vessels'),
complete:
aircraftCohorts.length === 2 &&
aircraftCohorts.every(
(cohort) =>
Number.isFinite(cohort.count) && cohort.complete !== false,
),
};
}
@@ -190,6 +199,8 @@ export function createModel({ state: layerState, services, parts, source }) {
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
complete: cohort.summary.complete !== false,
truncated: cohort.summary.truncated === true,
reason: cohort.summary.reason,
nearest: cohort.summary.nearest.slice(),
})),
+3
View File
@@ -25,6 +25,7 @@ export function createNavigation({
{
displayLimit = AWARENESS_MAX_EXAMPLES,
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
complete = true,
} = {},
) {
const summary = summarizeAwarenessCohort(items, {
@@ -34,6 +35,8 @@ export function createNavigation({
if (summary.count === null) return summary;
return {
...summary,
complete: Boolean(complete),
truncated: !complete,
nearest: summary.nearest.slice(0, displayLimit),
navigationNearest: summary.nearest,
};
+10 -1
View File
@@ -5,6 +5,15 @@ import {
} from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = summary.count.toLocaleString('en-US');
return summary.complete === false || summary.truncated === true
? `At least ${count}`
: count;
}
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
@@ -61,7 +70,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function rowHtml(cohort) {
const summary = cohort.summary;
const count = summary.count === null ? '?' : String(summary.count);
const count = formatAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
+2 -2
View File
@@ -38,8 +38,8 @@ export const AWARENESS_MAX_EXAMPLES = 10;
// so these caps sit far above any realistic in-range population rather than at
// the display limit. They are still FINITE: an unbounded materialization would
// let a pathological feed sort an eleven-thousand-contact array on every
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
// derives `count` from the full in-range set before any slice.
// refresh. Callers sample beyond this cap and carry explicit completeness, so
// a saturated cohort is a lower bound rather than a silently exact count.
export const AWARENESS_QUERY_LIMIT = 20000;
+29 -8
View File
@@ -53,7 +53,12 @@ export function createQueries({ state: layerState, services, parts, source }) {
neverAnswered ||
['unavailable', 'zoom-in'].includes(stats.status) ||
Boolean(stats.error && stats.count === 0);
return { available: !unavailable, stale: Boolean(stats.stale), stats };
return {
available: !unavailable,
enabled,
stale: Boolean(stats.stale),
stats,
};
}
function collectSourceStates() {
@@ -147,8 +152,10 @@ export function createQueries({ state: layerState, services, parts, source }) {
* @param {object} [options]
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
* plus the combined aircraft count, or null without a position.
* @returns {{flights: Array, military: Array, aircraft: number,
* completeByLayer: {flights: boolean, military: boolean}}|null} Cohorts,
* their lower-bound completeness, and the combined aircraft count, or
* null without a position.
*/
function collectAircraftProximityWindow(
@@ -156,21 +163,35 @@ export function createQueries({ state: layerState, services, parts, source }) {
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
) {
if (!position) return null;
const flights = flightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
// Ask for one usable row beyond the retained limit so a capped cohort is
// reported as a lower bound instead of an exact count. The selected
// subject can consume one slot before exclusion, hence the extra two.
const sampleLimit = AWARENESS_QUERY_LIMIT + 2;
const flightSample = flightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
);
const military = militaryFlightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
const militarySample = militaryFlightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
);
return { flights, military, aircraft: flights.length + military.length };
const flights = flightSample.slice(0, AWARENESS_QUERY_LIMIT);
const military = militarySample.slice(0, AWARENESS_QUERY_LIMIT);
return {
flights,
military,
aircraft: flights.length + military.length,
completeByLayer: {
flights: flightSample.length <= AWARENESS_QUERY_LIMIT,
military: militarySample.length <= AWARENESS_QUERY_LIMIT,
},
};
}
return {
sourceState,
+33
View File
@@ -6,6 +6,39 @@ import { createSubject } from './subject.js';
import { createControls } from '../installations/controls.js';
import { createModel } from '../installations/model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_QUERY_LIMIT } from './policy.js';
test('aircraft window marks a capped layer as a lower bound', () => {
const rows = Array.from({ length: AWARENESS_QUERY_LIMIT + 1 }, (_, index) => ({
icao24: `f${index}`,
distance: index,
}));
let requested = null;
const services = {
flights: {
getNearby: (_position, _radius, limit) => {
requested = limit;
return rows.slice(0, limit);
},
},
military: { getNearby: () => [] },
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
},
};
const queries = createQueries({ state: {}, services, parts });
const window = queries.collectAircraftProximityWindow({ center: true });
assert.equal(requested, AWARENESS_QUERY_LIMIT + 2);
assert.equal(window.flights.length, AWARENESS_QUERY_LIMIT);
assert.equal(window.aircraft, AWARENESS_QUERY_LIMIT);
assert.deepEqual(window.completeByLayer, {
flights: false,
military: true,
});
});
test('Contacts excludes square corners and measures from the current moving subject', () => {
const records = [
+46 -2
View File
@@ -37,11 +37,23 @@ export function createSubject({ state: layerState, services, parts, source }) {
const militaryState = sourceStates.military;
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
id,
name: label,
enabled: sourceState.enabled,
source: retainedSource,
stats: { ...sourceState.stats, source: retainedSource },
};
};
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
const { flights, military } = parts.queries.collectAircraftProximityWindow(
const aircraftWindow = parts.queries.collectAircraftProximityWindow(
position,
{ subject },
);
const { flights, military, completeByLayer } = aircraftWindow;
const vessels = aisLiveVesselsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
@@ -61,31 +73,57 @@ export function createSubject({ state: layerState, services, parts, source }) {
);
return {
subject,
evaluatedAt: Date.now(),
evaluatedAt,
radiusM: AWARENESS_RADIUS_M,
cohorts: [
{
id: 'flights',
label: 'Flights',
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
'Flights',
flightsState,
SOURCE_LABEL.flights,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
flights,
flightsState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.flights,
},
),
},
{
id: 'military',
label: 'Military flights',
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
'Military flights',
militaryState,
SOURCE_LABEL.military,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
military,
militaryState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.military,
},
),
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
vesselsState,
SOURCE_LABEL['ais-live-vessels'],
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
vessels,
vesselsState,
@@ -97,6 +135,12 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
installationsState,
SOURCE_LABEL['military-installations'],
),
coverage:
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
summary: parts.queries.summarizeInstallationViewport(
+21
View File
@@ -79,6 +79,27 @@ test('toGeminiSchema drops unsupported keywords and expresses unions as anyOf',
assert.deepEqual(out.required, ['a']);
});
test('referent-only tracking keeps object alternatives in Gemini OpenAPI mode', () => {
const track = toGeminiDeclarations(GEV_REALTIME_TOOLS, {
mode: 'openapi',
}).find((tool) => tool.name === 'track_entity');
assert.deepEqual(
track.parameters.anyOf.map((branch) => ({
type: branch.type,
required: branch.required,
})),
[
{ type: 'object', required: ['query'] },
{ type: 'object', required: ['referent'] },
],
);
assert.equal(track.parameters.properties.query.pattern, '\\S');
assert.deepEqual(
track.parameters.properties.referent.enum,
[-1, 1, 2, 3, 4, 5],
);
});
test('toGeminiDeclarations: jsonSchema mode is verbatim; openapi mode omits empty parameter objects', () => {
const json = toGeminiDeclarations(GEV_REALTIME_TOOLS);
assert.deepEqual(json[0].parametersJsonSchema, GEV_REALTIME_TOOLS[0].parameters);
+7 -1
View File
@@ -527,15 +527,21 @@ const schemas = [
properties: {
query: {
type: 'string',
minLength: 1,
pattern: '\\S',
},
layerId: {
type: 'string',
},
referent: {
type: 'integer',
enum: [-1, 1, 2, 3, 4, 5],
},
},
required: ['query'],
anyOf: [
{ type: 'object', required: ['query'] },
{ type: 'object', required: ['referent'] },
],
},
},
{
+21 -1
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { GEV_ACTION_SCHEMAS, createActionTools } from './actionSchemas.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { validateArgs } from '../../scripts/voice-bench/grade.mjs';
const stable = (value) =>
Array.isArray(value)
@@ -30,7 +31,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Re-derived for the voice layer manifest (generated layer enums and
// aliases), point-and-ask (pointer sentinels, referent args) and the
// consolidated tool wording (each policy stated once).
'087cb623adb90f0f6203aecd6f33345bc4e5616c66ca4f4c55afdba7ff25c8bf',
'a824c2ed0ceb553eb3041350856db05dbfc5abad8a3fa17d29dc378bf7a732d2',
);
});
@@ -110,6 +111,11 @@ test('all legacy action arguments are byte-identical after removing the delibera
value.enum = value.enum.filter((key) => key !== 'pointer');
}
}
const track = legacy.find((tool) => tool.name === 'track_entity').parameters;
delete track.anyOf;
delete track.properties.query.minLength;
delete track.properties.query.pattern;
track.required = ['query'];
const analystScopeKind = legacy.find((tool) => tool.name === 'analyst_query')
.parameters.properties.scope.properties.kind;
analystScopeKind.enum = analystScopeKind.enum.filter(
@@ -131,3 +137,17 @@ test('all legacy action arguments are byte-identical after removing the delibera
'01f14fdb1523eebfcbff8b115e48e6ab99e36fa06f55a3bb13c65e198f79d758',
);
});
test('track_entity accepts a referent alone but rejects an empty target', () => {
const schema = GEV_REALTIME_TOOLS.find(
(tool) => tool.name === 'track_entity',
).parameters;
assert.deepEqual(validateArgs(schema, { referent: 2 }), []);
assert.deepEqual(validateArgs(schema, { referent: -1 }), []);
assert.deepEqual(validateArgs(schema, { query: 'UPS793' }), []);
assert.ok(validateArgs(schema, {}).length > 0);
assert.ok(validateArgs(schema, { query: '' }).length > 0);
assert.ok(validateArgs(schema, { query: ' ' }).length > 0);
assert.ok(validateArgs(schema, { referent: 0 }).length > 0);
assert.ok(validateArgs(schema, { referent: 6 }).length > 0);
});
+20
View File
@@ -147,3 +147,23 @@ test('registry: mirrors the card, records analyst items, clears per session', ()
assert.equal(referents.get(1), null);
assert.deepEqual(referentsFromResult('x', null), []);
});
test('registry: hidden card rows never resolve and last means displayed item five', () => {
const referents = createReferentRegistry();
referents.recordResult('analyst_query', {
ok: true,
count: 6,
scopeLabel: 'anywhere',
coverage: { layersQueried: [{ layerKey: 'flights' }] },
items: Array.from({ length: 6 }, (_, index) => ({
layerKey: 'flights',
id: `F${index + 1}`,
})),
});
assert.deepEqual(
referents.list().map((entry) => entry.n),
[1, 2, 3, 4, 5],
);
assert.equal(referents.get(6), null);
assert.equal(referents.get(-1)?.id, 'F5');
});
+174 -74
View File
@@ -311,8 +311,8 @@ export function createGevActionRunner({
: SPEECH_BUILDERS;
const _layerEnabledAt = new Map();
let analystEngine;
const resolveRegionRing = (name) =>
annotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch);
const resolveRegionRing = (name, signal) =>
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
const ensureAnalystEngine = () =>
(analystEngine ||= createAnalystEngine(
analystProviders(viewer, dataManager, {
@@ -1014,6 +1014,7 @@ export function createGevActionRunner({
args,
_layerEnabledAt,
current,
runOptions.signal,
);
}
@@ -1162,7 +1163,9 @@ export function createGevActionRunner({
}
if (name === 'track_entity') {
return trackEntity(viewer, dataManager, styleManager, args);
return trackEntity(viewer, dataManager, styleManager, args, {
isCurrent: current,
});
}
if (name === 'stop_tracking') {
@@ -2182,7 +2185,20 @@ export function formatTrackedEntityLabel(found, query = '') {
}
/** Finds and tracks/selects an entity by spoken query across layer families. */
async function trackEntity(viewer, dataManager, styleManager, args = {}) {
async function trackEntity(
viewer,
dataManager,
styleManager,
args = {},
{ isCurrent = () => true } = {},
) {
const cancelled = () => ({
ok: false,
cancelled: true,
action: 'track_entity',
error: 'Tracking request was superseded by a newer voice turn',
});
if (!isCurrent()) return cancelled();
const query = String(args.query || '').trim();
if (!query) throw new Error('track_entity needs a query');
@@ -2217,6 +2233,7 @@ async function trackEntity(viewer, dataManager, styleManager, args = {}) {
error: 'The strongest fire has no usable position',
};
}
if (!isCurrent()) return cancelled();
return runManagedVoiceNavigation(
styleManager,
'fire',
@@ -2259,6 +2276,8 @@ async function trackEntity(viewer, dataManager, styleManager, args = {}) {
const found = module.findByQuery(query);
if (!found) continue;
if (!isCurrent()) return cancelled();
if (
family.kind === 'vessel' &&
(!Number.isFinite(found.latitude) || !Number.isFinite(found.longitude))
@@ -3453,7 +3472,12 @@ async function getTargetContext(
* @param {object} result The general engine's result, reused for scope text.
* @returns {object|null} A unified-count payload, or null.
*/
async function aircraftProximityWindowForQuery(dataManager, args, result) {
async function aircraftProximityWindowForQuery(
dataManager,
args,
result,
isCurrent = () => true,
) {
const spec = normalizeAnalystSpec(args, args?.layers || []);
if (!spec) return null;
const scope = args?.scope;
@@ -3461,9 +3485,8 @@ async function aircraftProximityWindowForQuery(dataManager, args, result) {
const layers = Array.isArray(args.layers) ? args.layers : [];
if (!layers.some((layer) => layer === 'flights' || layer === 'military'))
return null;
const snapshot = dataManager?.layers
?.get('military-awareness')
?.module?.getContextSnapshot?.();
const awareness = dataManager?.layers?.get('military-awareness')?.module;
const snapshot = awareness?.getAircraftQuerySnapshot?.();
const subject = snapshot?.subject;
if (!subject?.position) return null;
// An explicit centre only qualifies when it IS the subject; otherwise the
@@ -3473,18 +3496,29 @@ async function aircraftProximityWindowForQuery(dataManager, args, result) {
const radiusM = Number.isFinite(scope.km)
? scope.km * 1000
: snapshot.radiusM || 250_000;
const window = dataManager.layers
.get('military-awareness')
.module.collectAircraftProximityWindow(subject.position, {
radiusM,
subject,
});
if (!window) return null;
// A different radius is a different question. Let the general analyst
// engine answer it instead of relabelling the panel's 250 km snapshot.
if (
Number.isFinite(snapshot.radiusM) &&
Math.abs(radiusM - snapshot.radiusM) > 1
)
return null;
const label = subject.label || subject.id || 'the selected contact';
const radiusKm = Math.round(radiusM / 1000);
const wanted = layers.filter(
(layer) => layer === 'flights' || layer === 'military',
);
// Provenance belongs to the Awareness evaluation, not the current layer
// registry. The panel deliberately retains a cohort between render-cadence
// refreshes, so a live registry read here could already describe a newer
// source state than the count and items the operator is looking at.
const retainedProvenance = wanted
.map((key) => snapshot.cohorts?.[key]?.provenance)
.filter(Boolean);
const feedProvenance = feedProvenanceEnvelope(retainedProvenance);
const sourceSnapshotsById = new Map(
feedProvenance.layers.map((layer) => [layer.id, layer]),
);
// The cohort is the panel's; each contact is joined to its loaded analyst
// record so the same filters, ranking and item shape apply as everywhere
// else. The cohort still owns membership and distance.
@@ -3495,35 +3529,33 @@ async function aircraftProximityWindowForQuery(dataManager, args, result) {
.get(key)
?.module?.getAnalystRecords?.(ANALYST_RECORD_CAP) || [])
if (record?.icao24) records.set(record.icao24, record);
const rows = window[key === 'flights' ? 'flights' : 'military'].map(
(item) => {
const record = records.get(item.icao24);
let lat = record?.lat;
let lon = record?.lon;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
const carto = item.position
? Cesium.Cartographic.fromCartesian(item.position)
: null;
lat = carto ? Cesium.Math.toDegrees(carto.latitude) : null;
lon = carto ? Cesium.Math.toDegrees(carto.longitude) : null;
}
const row = {
...(record || {
id: item.id,
icao24: item.icao24,
callsign: item.callsign ?? null,
altitudeM: item.altitudeM ?? null,
speedMps: item.velocityMps ?? null,
aircraftClass: item.aircraftClass ?? null,
}),
lat,
lon,
};
if (Number.isFinite(item.distance))
distanceKm.set(row, Math.round(item.distance / 100) / 10);
return row;
},
);
const rows = (snapshot.cohorts?.[key]?.items || []).map((item) => {
const record = records.get(item.icao24);
let lat = record?.lat;
let lon = record?.lon;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
const carto = item.position
? Cesium.Cartographic.fromCartesian(item.position)
: null;
lat = carto ? Cesium.Math.toDegrees(carto.latitude) : null;
lon = carto ? Cesium.Math.toDegrees(carto.longitude) : null;
}
const row = {
...(record || {
id: item.id,
icao24: item.icao24,
callsign: item.callsign ?? null,
altitudeM: item.altitudeM ?? null,
speedMps: item.velocityMps ?? null,
aircraftClass: item.aircraftClass ?? null,
}),
lat,
lon,
};
if (Number.isFinite(item.distance))
distanceKm.set(row, Math.round(item.distance / 100) / 10);
return row;
});
return { key, rows };
});
const selection = await selectAnalystRows(groups, {
@@ -3533,39 +3565,106 @@ async function aircraftProximityWindowForQuery(dataManager, args, result) {
limit: Math.round(clampNumber(args.limit, 1, 50, 12)),
layerKeys: wanted,
distanceOf: (row) => distanceKm.get(row),
isCurrent,
});
if (!selection) return null;
if (!selection || !isCurrent()) return null;
const items = selection.items.map(compactAnalystItem);
return {
const complete = wanted.every((key) => {
const cohort = snapshot.cohorts?.[key];
return Number.isFinite(cohort?.count) && cohort.complete !== false;
});
const genericLayers = new Map(
(result?.coverage?.layersQueried || []).map((layer) => [
layer.layerKey,
layer,
]),
);
const layersQueried = wanted.map((key) => {
const cohort = snapshot.cohorts?.[key] || {};
const generic = genericLayers.get(key) || {};
const sourceSnapshot = sourceSnapshotsById.get(key);
const returned = Number.isFinite(cohort.count)
? cohort.count
: cohort.items?.length || 0;
return {
layerKey: key,
...(generic.status ? { status: generic.status } : {}),
...(generic.note ? { note: generic.note } : {}),
...(generic.window ? { window: generic.window } : {}),
returned,
total: cohort.complete === false ? null : returned,
truncated: cohort.complete === false,
...(cohort.source ? { source: cohort.source } : {}),
...(sourceSnapshot
? {
feedState: sourceSnapshot.feedState,
source: sourceSnapshot.source,
lastUpdate: sourceSnapshot.lastUpdate,
enabled: sourceSnapshot.enabled,
error: sourceSnapshot.error,
}
: {}),
};
});
const coverage = {
layersQueried,
records: {
returned: selection.count,
total: complete ? selection.count : null,
truncated: !complete,
},
scope: `window:${radiusKm}km@${label}`,
followUp: false,
feedProvenance,
note: `Contacts panel snapshot${snapshot.evaluatedAt ? ` at ${new Date(snapshot.evaluatedAt).toISOString()}` : ''} — the same retained cohort supplies the card, speech, and follow-up memory${complete ? '' : '; the count is a lower bound because at least one layer reached its retained limit'}.`,
};
const answer = {
ok: true,
action: 'analyst_query',
count: selection.count,
complete: true,
complete,
scopeLabel: `within ${radiusKm} km of ${label}`,
listed: items.length,
items,
summary: selection.summary,
...(result?.partial
? { partial: true, unanswered: [...(result.unanswered || [])] }
: {}),
display: {
scope: `within ${radiusKm} km of ${label} (Contacts window)`,
},
coverage: {
layersQueried: result?.coverage?.layersQueried || [],
scope: `window:${radiusKm}km@${label}`,
followUp: false,
note: 'Contacts window engine — the same computation and cohort the Contacts panel displays, so an unfiltered count matches the panel exactly — counts cover loaded data; the flights layer loads by viewport.',
},
coverage,
// (D) The answer always says whose window it is and which engine produced it.
window: {
engine: 'contacts-window',
centeredOn: label,
radiusKm,
flights: window.flights.length,
military: window.military.length,
aircraft: window.aircraft,
flights: snapshot.cohorts?.flights?.count ?? 'unknown',
military: snapshot.cohorts?.military?.count ?? 'unknown',
aircraft:
Number.isFinite(snapshot.cohorts?.flights?.count) &&
Number.isFinite(snapshot.cohorts?.military?.count)
? snapshot.cohorts.flights.count + snapshot.cohorts.military.count
: 'unknown',
complete,
evaluatedAt: snapshot.evaluatedAt || null,
},
...(activeContactsWindow(dataManager)
? { contactsWindow: activeContactsWindow(dataManager) }
...(snapshot.contactsWindow
? { contactsWindow: { ...snapshot.contactsWindow } }
: {}),
feedProvenance,
feedState: feedProvenance.overall,
};
return {
answer,
memory: {
matched: selection.matched,
layerKeys: wanted,
coverage,
unanswered: [...(result?.unanswered || [])],
scopeLabel: answer.scopeLabel,
scopeDetail: answer.display.scope,
},
};
}
@@ -4698,7 +4797,9 @@ async function runAnalystQuery(
args = {},
_layerEnabledAt,
isCurrent = () => true,
signal = undefined,
) {
let entityWindow = null;
const result = await analystEngine.query(
{
layers: Array.isArray(args.layers) ? args.layers : undefined,
@@ -4709,7 +4810,20 @@ async function runAnalystQuery(
limit: args.limit,
followUp: Boolean(args.followUp),
},
{ isCurrent },
{
isCurrent,
signal,
beforeCommit: async (candidate) => {
const projected = await aircraftProximityWindowForQuery(
dataManager,
args,
candidate,
isCurrent,
);
entityWindow = projected?.answer || null;
return projected?.memory || null;
},
},
);
if (!result.ok) {
const refusal = {
@@ -4755,22 +4869,8 @@ async function runAnalystQuery(
// differ. The generic record/scope engine still owns explicit regions and
// arbitrary points — only "how many aircraft around <this contact>" is
// unified, because that is the question the panel is already answering.
const entityWindow = await aircraftProximityWindowForQuery(
dataManager,
args,
result,
);
if (entityWindow) {
const provenance = feedProvenanceEnvelope(
layerSnapshots(dataManager.getAll?.() || []).filter(
(layer) => layer.enabled && ['flights', 'military'].includes(layer.id),
),
);
return {
...entityWindow,
feedProvenance: provenance,
feedState: provenance.overall,
};
return entityWindow;
}
const contactsWindow = activeContactsWindow(dataManager);
+315 -8
View File
@@ -21,6 +21,8 @@ import {
} from './gevActions.js';
import { MAP_STACKS } from '../mapStackController.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { presentResult } from './resultDisplay.js';
import { attachVoiceResult } from './speech.js';
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
@@ -2819,8 +2821,58 @@ test('a nested Cockpit rollback result is translated too', async () => {
*/
function awarenessSubjectHarness({ subject, flights = [], military = [] }) {
const position = subject ? { __subject: true } : null;
const snapshotFlights = flights.slice(0, 20_000);
const snapshotMilitary = military.slice(0, 20_000);
return {
snapshot: subject ? { subject: { ...subject, position }, radiusM: 250_000, cohorts: [] } : null,
snapshot: subject
? {
subject: { ...subject, position },
evaluatedAt: 1_790_782_400_000,
radiusM: 250_000,
cohorts: [
{
id: 'flights',
count: snapshotFlights.length,
complete: flights.length <= 20_000,
},
{
id: 'military',
count: snapshotMilitary.length,
complete: military.length <= 20_000,
},
],
}
: null,
aircraftSnapshot: subject
? {
subject: { ...subject, position },
evaluatedAt: 1_790_782_400_000,
radiusM: 250_000,
cohorts: {
flights: {
count: snapshotFlights.length,
complete: flights.length <= 20_000,
truncated: flights.length > 20_000,
items: snapshotFlights.map((item) => ({ ...item })),
},
military: {
count: snapshotMilitary.length,
complete: military.length <= 20_000,
truncated: military.length > 20_000,
items: snapshotMilitary.map((item) => ({ ...item })),
},
},
contactsWindow: {
centeredOn: subject.label || subject.id,
radiusKm: 250,
aircraft: snapshotFlights.length + snapshotMilitary.length,
flights: snapshotFlights.length,
military: snapshotMilitary.length,
vessels: 'unknown',
complete: flights.length <= 20_000 && military.length <= 20_000,
},
}
: null,
flights,
military,
};
@@ -2832,11 +2884,40 @@ async function withAwareness(harness, run) {
const militaryLayer = (await import('../data/militaryFlights.js')).default;
const originals = {
snapshot: awareness.getContextSnapshot,
aircraftSnapshot: awareness.getAircraftQuerySnapshot,
flightsNearby: flightsLayer.getNearby,
militaryNearby: militaryLayer.getNearby,
cartoFrom: Cesium.Cartographic.fromCartesian,
};
awareness.getContextSnapshot = () => harness.snapshot;
awareness.getAircraftQuerySnapshot = () => {
const snapshot = harness.aircraftSnapshot;
if (!snapshot) return null;
return {
...snapshot,
subject: { ...snapshot.subject },
contactsWindow: snapshot.contactsWindow
? { ...snapshot.contactsWindow }
: null,
cohorts: Object.fromEntries(
Object.entries(snapshot.cohorts).map(([key, cohort]) => [
key,
{
...cohort,
provenance: cohort.provenance
? {
...cohort.provenance,
stats: cohort.provenance.stats
? { ...cohort.provenance.stats }
: undefined,
}
: null,
items: cohort.items.map((item) => ({ ...item })),
},
]),
),
};
};
flightsLayer.getNearby = () => harness.flights.slice();
militaryLayer.getNearby = () => harness.military.slice();
Cesium.Cartographic.fromCartesian = (value) => (
@@ -2848,13 +2929,14 @@ async function withAwareness(harness, run) {
return await run(awareness);
} finally {
awareness.getContextSnapshot = originals.snapshot;
awareness.getAircraftQuerySnapshot = originals.aircraftSnapshot;
flightsLayer.getNearby = originals.flightsNearby;
militaryLayer.getNearby = originals.militaryNearby;
Cesium.Cartographic.fromCartesian = originals.cartoFrom;
}
}
function analystRunner(awareness) {
function analystRunner(awareness, { getAll } = {}) {
const flights = {
id: 'flights',
// Deliberately a DIFFERENT population from the proximity window: this is
@@ -2878,7 +2960,16 @@ function analystRunner(awareness) {
dataManager: {
layers: new Map([['flights', { module: flights }], ['military-awareness', { module: awareness }]]),
isEnabled: (id) => id === 'flights',
getAll: () => [{ id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 1 } }],
getAll:
getAll ||
(() => [
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { count: 1 },
},
]),
},
});
}
@@ -3426,6 +3517,199 @@ test('the Contacts window ranks, filters and formats like any analyst answer', a
});
});
test('a cancelled Contacts scan returns cancellation and cannot seed follow-up memory', async () => {
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const harness = awarenessSubjectHarness({
subject: { id: 'a1b2c3', label: 'N546PC' },
flights: Array.from({ length: 20_000 }, (_, index) => ({
id: `F${index}`,
icao24: `f${index}`,
distance: index,
})),
});
await withAwareness(harness, async (awareness) => {
const runner = analystRunner(awareness);
let current = true;
const pending = runner(
'analyst_query',
{
layers: ['flights'],
scope: { kind: 'radius', km: 250 },
},
{ isCurrent: () => current },
);
current = false;
const stale = await pending;
assert.equal(stale.ok, false);
assert.equal(stale.cancelled, true);
assert.equal(stale.code, 'CANCELLED');
const followUp = await runner('analyst_query', { followUp: true });
assert.equal(followUp.ok, false);
assert.equal(followUp.code, 'NO_RESULT_CONTEXT');
});
});
test('overlapping analyst siblings from one response complete independently', async () => {
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const harness = awarenessSubjectHarness({
subject: { id: 'a1b2c3', label: 'N546PC' },
flights: Array.from({ length: 20_000 }, (_, index) => ({
id: `F${index}`,
icao24: `f${index}`,
distance: index,
})),
});
await withAwareness(harness, async (awareness) => {
const runner = analystRunner(awareness);
const older = runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'radius', km: 250 },
});
const sibling = await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
});
assert.equal(sibling.ok, true);
assert.deepEqual(
sibling.items.map((item) => item.id),
['STALE1'],
);
assert.equal((await older).ok, true);
});
});
test('a capped Contacts snapshot is a lower bound from one immutable cohort', async () => {
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const harness = awarenessSubjectHarness({
subject: { id: 'a1b2c3', label: 'N546PC' },
flights: Array.from({ length: 20_001 }, (_, index) => ({
id: `F${index}`,
icao24: `f${index}`,
distance: index,
})),
});
await withAwareness(harness, async (awareness) => {
let source = 'snapshot-a';
const capture = awareness.getAircraftQuerySnapshot;
awareness.getAircraftQuerySnapshot = () => {
const frozen = capture();
frozen.cohorts.flights.provenance = {
id: 'flights',
name: 'Live Flights',
enabled: true,
feedState: 'nominal',
source,
count: 20_000,
lastUpdate: frozen.evaluatedAt,
ageSec: 0,
ageLabel: 'now',
error: null,
};
harness.aircraftSnapshot.cohorts.flights.items.length = 1;
harness.aircraftSnapshot.cohorts.flights.count = 1;
harness.aircraftSnapshot.contactsWindow.aircraft = 1;
harness.aircraftSnapshot.contactsWindow.flights = 1;
queueMicrotask(() => {
source = 'snapshot-b';
});
return frozen;
};
const pending = analystRunner(awareness, {
// The live registry has already advanced before query entry. The answer
// must still narrate the provenance retained with panel cohort A.
getAll: () => [
{
id: 'flights',
name: 'Live Flights',
enabled: true,
source: 'live-snapshot-b',
stats: { count: 1, source: 'live-snapshot-b' },
},
],
})('analyst_query', {
layers: ['flights'],
scope: { kind: 'radius', km: 250 },
});
const result = await pending;
assert.equal(result.ok, true);
assert.equal(result.count, 20_000);
assert.equal(result.complete, false);
assert.deepEqual(result.coverage.records, {
returned: 20_000,
total: null,
truncated: true,
});
assert.match(result.say, /^At least 20,000 aircraft within 250 km/);
assert.equal(result.window.flights, 20_000);
assert.equal(result.window.complete, false);
assert.equal(result.contactsWindow.aircraft, 20_000);
assert.equal(result.contactsWindow.flights, 20_000);
assert.equal(result.feedProvenance.layers[0].source, 'snapshot-a');
assert.equal(result.coverage.layersQueried[0].source, 'snapshot-a');
});
});
test('Contacts follow-ups filter the cohort that was actually displayed', async () => {
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const harness = awarenessSubjectHarness({
subject: { id: 'a1b2c3', label: 'N546PC' },
flights: Array.from({ length: 6 }, (_, index) => ({
id: `F${index + 1}`,
icao24: `f${index + 1}`,
distance: index * 1000,
})),
});
await withAwareness(harness, async (awareness) => {
const runner = analystRunner(awareness);
const first = await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'radius', km: 250 },
limit: 6,
});
assert.deepEqual(
first.items.map((item) => item.id),
['F1', 'F2', 'F3', 'F4', 'F5', 'F6'],
);
const followUp = await runner('analyst_query', {
followUp: true,
filters: [{ field: 'id', op: 'eq', value: 'F3' }],
});
assert.equal(followUp.count, 1);
assert.equal(followUp.items[0].id, 'F3');
assert.equal(followUp.scopeLabel, 'within 250 km of N546PC');
assert.equal(
followUp.display.scope,
'within 250 km of N546PC (Contacts window)',
);
assert.equal(followUp.coverage.scope, 'window:250km@N546PC');
assert.equal(
presentResult('analyst_query', followUp).display.title,
'1 aircraft within 250 km of N546PC',
);
assert.equal(
attachVoiceResult('analyst_query', followUp).say,
'1 aircraft within 250 km of N546PC.',
);
});
});
test('point-and-ask: the runner resolves pointer and referent targets', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { viewer, styleManager } = createVoiceNavigationHarness();
@@ -3479,14 +3763,30 @@ test('point-and-ask: only accepted, current results become the referent list', a
const { viewer, styleManager } = createVoiceNavigationHarness();
const referents = createReferentRegistry();
let used = 0;
let tracked = 0;
const runner = createActionRunner({
viewer,
styleManager,
dataManager: {
layers: new Map([['flights', { module: {
findByQuery: () => ({ icao24: 'ccc333', callsign: 'AAL9', latitude: 1, longitude: 2 }),
trackById: () => true,
} }]]),
layers: new Map([
[
'flights',
{
module: {
findByQuery: () => ({
icao24: 'ccc333',
callsign: 'AAL9',
latitude: 1,
longitude: 2,
}),
trackById: () => {
tracked++;
return true;
},
},
},
],
]),
isEnabled: () => true,
getAll: () => [],
},
@@ -3509,9 +3809,16 @@ test('point-and-ask: only accepted, current results become the referent list', a
await runner('track_entity', { query: 'pointer' }, { signal: aborted.signal });
assert.equal(referents.get(1), null, 'a cancelled action leaves the registry alone');
assert.equal(used, 0, 'nor does it announce the pointer');
await runner('track_entity', { query: 'pointer' }, { isCurrent: () => false });
assert.equal(tracked, 0, 'nor does it start tracking');
await runner(
'track_entity',
{ query: 'pointer' },
{ isCurrent: () => false },
);
assert.equal(referents.get(1), null, 'a superseded action leaves it alone');
assert.equal(tracked, 0, 'a superseded action does not start tracking');
await runner('track_entity', { query: 'pointer' });
assert.equal(referents.get(1)?.id, 'ccc333');
assert.equal(used, 1, 'a current pointer resolution announces the chip');
assert.equal(tracked, 1);
});
+58
View File
@@ -15,6 +15,7 @@ import { createVoiceCommands } from './sessionCommands.js';
import { harness, tick } from './voiceWorkflowHarness.mjs';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { attachVoiceResult } from './speech.js';
/** Run one call and fold it through the real voice card. */
async function throughCard(runner, name, args, card = initialVoiceCardState()) {
@@ -47,6 +48,7 @@ test('an analyst count reaches the model and the card with its scope and caveats
});
assert.equal(result.ok, true);
assert.equal(result.complete, true, 'complete survives the projection');
assert.equal(result.say, '3 aircraft anywhere in the loaded data.');
assert.equal(view.visible, true, 'the card shows an analyst result');
assert.equal(view.title, '3 aircraft anywhere in the loaded data');
assert.ok(
@@ -59,6 +61,34 @@ test('an analyst count reaches the model and the card with its scope and caveats
);
});
test('long analyst scope text is bounded identically for speech and card', () => {
const result = attachVoiceResult('analyst_query', {
ok: true,
action: 'analyst_query',
count: 12,
complete: true,
scopeLabel:
'over the extraordinarily long alpine administrative research corridor with a deliberately verbose official name',
coverage: { layersQueried: [{ layerKey: 'flights' }] },
});
const started = reduceVoiceCard(initialVoiceCardState(), {
type: 'action-call',
name: 'analyst_query',
callId: 'long-scope',
arguments: {},
});
const card = reduceVoiceCard(started, {
type: 'action-result',
name: 'analyst_query',
callId: 'long-scope',
result,
});
const title = voiceCardView(card).result.title;
assert.equal(result.say, title);
assert.ok(title.length <= 64);
assert.match(title, /…$/);
});
test('a capped count is a floor everywhere: model, card and caveat', async () => {
const rows = Array.from({ length: 250_001 }, (_, i) => ({
id: `F${i}`,
@@ -76,6 +106,7 @@ test('a capped count is a floor everywhere: model, card and caveat', async () =>
limit: 1,
});
assert.equal(result.complete, false);
assert.match(result.say, /^At least 250,000 aircraft/);
assert.match(view.title, /^At least 250,000 aircraft/);
assert.ok(
view.notes.some((note) => /counted 250,000 of 400,000/.test(note)),
@@ -90,6 +121,7 @@ test('a partial answer names the layers it could not read', async () => {
scope: { kind: 'anywhere' },
});
assert.equal(result.partial, true);
assert.match(result.say, /Partial; earthquakes not answered/);
assert.deepEqual(result.unanswered, ['earthquakes']);
assert.ok(view.chips.includes('partial'));
assert.ok(view.notes.includes('Not answered: earthquakes'));
@@ -105,6 +137,32 @@ test('"the second one" is the second row the card lists', async () => {
assert.equal(referents.get(-1).label, 'SWA3');
});
test('numbered and last referents are exactly the five rows the card displays', async () => {
const flights = {
getStats: () => ({ count: 6, lastUpdate: Date.now() }),
getAnalystRecords: () =>
Array.from({ length: 6 }, (_, index) => ({
id: `F${index + 1}`,
icao24: `f${index + 1}`,
callsign: `CALL${index + 1}`,
lat: 30,
lon: -97,
})),
};
const { runner, referents } = harness({ flights });
const { view } = await throughCard(runner, 'analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
limit: 6,
});
assert.deepEqual(
view.referents.map((entry) => entry.label),
['CALL1', 'CALL2', 'CALL3', 'CALL4', 'CALL5'],
);
assert.equal(referents.get(6), null);
assert.equal(referents.get(-1)?.label, 'CALL5');
});
test('a follow-up naming other layers than the last answer is refused, not answered from it', async () => {
const { runner } = harness();
await runner('analyst_query', {
+4 -3
View File
@@ -12,7 +12,8 @@
import { presentResult } from './resultDisplay.js';
const MAX_REFERENTS = 12;
/** The numbered rows visible in the voice card and therefore resolvable later. */
export const MAX_DISPLAYED_REFERENTS = 5;
function finite(value) {
if (value === null || value === undefined || value === '') return null;
@@ -47,7 +48,7 @@ export function referentsFromResult(name, result) {
}
/** Numbered referent records ({n, id, label, layerId, lat, lon}). */
export function normalizeReferents(entries, max = MAX_REFERENTS) {
export function normalizeReferents(entries, max = MAX_DISPLAYED_REFERENTS) {
return (Array.isArray(entries) ? entries : [])
.slice(0, max)
.map(normalize)
@@ -55,7 +56,7 @@ export function normalizeReferents(entries, max = MAX_REFERENTS) {
.map((entry, index) => ({ ...entry, n: index + 1 }));
}
export function createReferentRegistry({ max = MAX_REFERENTS } = {}) {
export function createReferentRegistry({ max = MAX_DISPLAYED_REFERENTS } = {}) {
let list = [];
let generation = 0;
return {
+24 -8
View File
@@ -34,26 +34,42 @@ function notesOf(...values) {
.filter(Boolean);
}
function nounFor(layerKeys) {
export function analystNounFor(layerKeys) {
const keys = [...new Set(layerKeys.filter(Boolean))];
if (keys.length !== 1) return 'results';
const key = keys[0];
return LAYER_NOUNS[key] || voiceLayer(key)?.aliases?.[0] || 'results';
}
function analystCard(result) {
if (!result?.ok) return null;
const keys = (result.coverage?.layersQueried || []).map(
/** The count/scope headline shared by the analyst card and spoken answer. */
const ANALYST_HEADLINE_MAX = 64;
function boundedHeadline(value) {
const text = String(value || '')
.replace(/\s+/g, ' ')
.trim();
if (text.length <= ANALYST_HEADLINE_MAX) return text;
return `${text.slice(0, ANALYST_HEADLINE_MAX - 1).trimEnd()}…`;
}
export function analystHeadline(result) {
const keys = (result?.coverage?.layersQueried || []).map(
(layer) => layer.layerKey,
);
const count = Number(result.count) || 0;
const floor = result.complete === false ? 'At least ' : '';
const count = Number(result?.count) || 0;
const floor = result?.complete === false ? 'At least ' : '';
return boundedHeadline(
`${floor}${count.toLocaleString('en-US')} ${analystNounFor(keys)} ${result?.scopeLabel || ''}`,
);
}
function analystCard(result) {
if (!result?.ok) return null;
const display = result.display || {};
const unanswered = Array.isArray(result.unanswered) ? result.unanswered : [];
return {
display: {
title:
`${floor}${count.toLocaleString('en-US')} ${nounFor(keys)} ${result.scopeLabel || ''}`.trim(),
title: analystHeadline(result),
// The precise scope, unless the title already says exactly that.
lines: [
display.scope !== result.scopeLabel ? display.scope : null,
+19 -4
View File
@@ -15,11 +15,11 @@
* continue, not something to read back).
*
* Builders are small pure functions keyed by tool name. Tools without one are
* returned unchanged. analyst_query deliberately has no builder here: its
* result contract is owned by the analyst engine. Supply one through
* `createGevActionRunner({ speechBuilders: { analyst_query: fn } })`.
* returned unchanged.
*/
import { analystHeadline } from './resultDisplay.js';
const LAYER_NOUNS = Object.freeze({
flights: 'aircraft',
military: 'military aircraft',
@@ -398,7 +398,21 @@ function annotateMap(result) {
};
}
/** Default builders by tool name. analyst_query plugs in through the runner. */
function analystQuery(result) {
if (!result?.ok || result.cancelled) return null;
const headline = spokenLabel(analystHeadline(result), 96);
const unanswered = (Array.isArray(result.unanswered) ? result.unanswered : [])
.map((layer) => spokenLabel(layer, 24))
.filter(Boolean);
const missing = unanswered.length
? `Partial; ${listPhrase(unanswered.slice(0, 2))}${unanswered.length > 2 ? ` and ${unanswered.length - 2} more` : ''} not answered.`
: 'Partial answer.';
return {
say: `${sentence(headline)}${result.partial ? ` ${missing}` : ''}`,
};
}
/** Default builders by tool name. */
export const SPEECH_BUILDERS = Object.freeze({
set_layer_visibility: setLayerVisibility,
fly_to_location: flyToLocation,
@@ -407,6 +421,7 @@ export const SPEECH_BUILDERS = Object.freeze({
get_entity_context: getEntityContext,
get_current_view_state: getCurrentViewState,
annotate_map: annotateMap,
analyst_query: analystQuery,
});
/**
+27 -3
View File
@@ -367,10 +367,34 @@ test('annotation speech names only what failed and never announces drawing', ()
assert.deepEqual(pending.display.notes, ['Tracing outlines']);
});
test('builders never break an action and analyst_query has no default builder', () => {
assert.equal(SPEECH_BUILDERS.analyst_query, undefined);
test('analyst speech preserves exact counts, lower bounds and partial status', () => {
const raw = {
ok: true,
action: 'analyst_query',
count: 3,
complete: true,
scopeLabel: 'anywhere in the loaded data',
coverage: { layersQueried: [{ layerKey: 'flights' }] },
};
assert.equal(
attachVoiceResult('analyst_query', raw).say,
'3 aircraft anywhere in the loaded data.',
);
assert.equal(
attachVoiceResult('analyst_query', {
...raw,
count: 250_000,
complete: false,
partial: true,
unanswered: ['military'],
}).say,
'At least 250,000 aircraft anywhere in the loaded data. Partial; military not answered.',
);
});
test('builders never break an action and callers can override analyst speech', () => {
assert.equal(typeof SPEECH_BUILDERS.analyst_query, 'function');
const raw = { ok: true, action: 'analyst_query', count: 3 };
assert.equal(attachVoiceResult('analyst_query', raw), raw);
const hooked = attachVoiceResult(
'analyst_query',
raw,
+6 -4
View File
@@ -1,6 +1,6 @@
import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
import { presentResult } from './resultDisplay.js';
import { normalizeReferents } from './referents.js';
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
/**
* Pure presentation of the voice card nested in the voice control: captions,
@@ -14,7 +14,6 @@ import { normalizeReferents } from './referents.js';
const MAX_STEPS = 6;
const MAX_LINES = 6;
const MAX_REFERENTS = 5;
const MAX_NOTES = 6;
const MAX_CAPTION = 220;
@@ -205,7 +204,10 @@ export function reduceVoiceCard(state, event) {
...next,
result: {
display: shown.display,
referents: normalizeReferents(shown.referents),
referents: normalizeReferents(
shown.referents,
MAX_DISPLAYED_REFERENTS,
),
},
announce: spokenLabel(shown.display.title, 64),
});
@@ -278,7 +280,7 @@ export function voiceCardView(state) {
.map((line) => spokenLabel(line, 80))
.slice(0, MAX_LINES),
referents: (state.result?.referents || [])
.slice(0, MAX_REFERENTS)
.slice(0, MAX_DISPLAYED_REFERENTS)
.map((ref) => ({ n: ref.n, label: spokenLabel(ref.label, 48) })),
notes,
},