mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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:
1 parent
a19897ad15
commit
095981eee7
27 files changed
+1155
-146
No files matched your search
+41
-12
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Vendored
+54
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user