mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
+11
-1
@@ -1,7 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
- The voice card shows analyst counts ("At least 250,000 aircraft …") with
|
- 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
|
- 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
|
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`
|
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
|
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
|
"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
|
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
|
bare ground up close gives the assistant a small crop of the map around the
|
||||||
point.
|
point.
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -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.
|
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
|
Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
|
||||||
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
|
`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? }`
|
Voice speech: `src/voice/speech.js` adds `{ say, display, referents?, schedule? }`
|
||||||
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
||||||
get_entity_context and annotate_map (analyst_query plugs in through the
|
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||||
runner's `speechBuilders`). `get_current_view_state` lists enabled layers only.
|
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
|
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
|
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
|
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
|
track_entity/fly_to_location queries, get_entity_context scope, analyst_query
|
||||||
`scope.kind` and annotate_map targets, plus `referent:n` from the latest
|
`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
|
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
|
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
|
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
|
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
|
flight and forgets the last answer, the pointer and the numbered list; removing
|
||||||
voice disposes the runner.
|
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.
|
Application startup constructs fresh layer instances from explicit source objects.
|
||||||
Standalone composition selects the existing providers. Controls, launch orbit
|
Standalone composition selects the existing providers. Controls, launch orbit
|
||||||
lookups, Contacts voice answers and ISS pass queries use the registered catalog.
|
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`).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
- **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.
|
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.
|
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).
|
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).
|
||||||
|
|||||||
@@ -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).
|
* properties / additionalProperties:false / items / min/max bounds).
|
||||||
* Returns a list of human-readable violations; empty means valid.
|
* 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)) {
|
if (schema.enum && !schema.enum.includes(value)) {
|
||||||
errors.push(`${where}=${JSON.stringify(value)} is not one of ${schema.enum.join(', ')}`);
|
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 value === 'number') {
|
||||||
if (typeof schema.minimum === 'number' && value < schema.minimum) errors.push(`${where} below ${schema.minimum}`);
|
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}`);
|
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}`));
|
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;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-12
@@ -18,7 +18,7 @@
|
|||||||
*
|
*
|
||||||
* Providers (injected — keeps the engine pure and node-testable):
|
* Providers (injected — keeps the engine pure and node-testable):
|
||||||
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
* 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)
|
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||||
*
|
*
|
||||||
* @module data/analystEngine
|
* @module data/analystEngine
|
||||||
@@ -714,12 +714,20 @@ export function createAnalystEngine(providers) {
|
|||||||
/**
|
/**
|
||||||
* Run one query.
|
* Run one query.
|
||||||
* @param {object} spec Query arguments (see the analyst_query tool).
|
* @param {object} spec Query arguments (see the analyst_query tool).
|
||||||
* @param {{isCurrent?: () => boolean}} [options] A query that is no longer
|
* @param {{isCurrent?: () => boolean, signal?: AbortSignal,
|
||||||
* current (cancelled or superseded while awaiting a boundary or between
|
* beforeCommit?: (result: object) => Promise<object|void>}} [options]
|
||||||
* scan slices) returns CANCELLED and never replaces the follow-up memory.
|
* 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.
|
* @returns {Promise<object>} Result or refusal.
|
||||||
*/
|
*/
|
||||||
async function query(spec = {}, { isCurrent = () => true } = {}) {
|
async function query(
|
||||||
|
spec = {},
|
||||||
|
{ isCurrent = () => true, signal = undefined, beforeCommit = null } = {},
|
||||||
|
) {
|
||||||
const cancelled = () =>
|
const cancelled = () =>
|
||||||
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
|
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
|
||||||
if (spec.followUp && !lastResult)
|
if (spec.followUp && !lastResult)
|
||||||
@@ -788,6 +796,7 @@ export function createAnalystEngine(providers) {
|
|||||||
groups = lastResult.matched;
|
groups = lastResult.matched;
|
||||||
layersQueried = lastResult.coverage.layersQueried;
|
layersQueried = lastResult.coverage.layersQueried;
|
||||||
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
|
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
|
||||||
|
unanswered = [...(lastResult.unanswered || [])];
|
||||||
} else {
|
} else {
|
||||||
groups = [];
|
groups = [];
|
||||||
layersQueried = [];
|
layersQueried = [];
|
||||||
@@ -870,18 +879,20 @@ export function createAnalystEngine(providers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) Spatial scope
|
// 2) Spatial scope
|
||||||
|
const rememberedScope =
|
||||||
|
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
|
||||||
let resolvedScope = 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
|
// 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
|
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||||
// arriving as a bare number that contradicts the panel.
|
// arriving as a bare number that contradicts the panel.
|
||||||
let scopeLabel = 'anywhere in the loaded data';
|
let scopeLabel = rememberedScope?.label || 'anywhere in the loaded data';
|
||||||
let scopeDetail = scopeLabel;
|
let scopeDetail = rememberedScope?.detail || scopeLabel;
|
||||||
// A follow-up re-filters a set that was already scoped, so it only narrows
|
// A follow-up re-filters a set that was already scoped, so it only narrows
|
||||||
// further when a new scope is given.
|
// further when a new scope is given.
|
||||||
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
|
||||||
if (scope.kind === 'region') {
|
if (scope.kind === 'region') {
|
||||||
const region = await providers.resolveRegionRing(scope.name);
|
const region = await providers.resolveRegionRing(scope.name, signal);
|
||||||
if (!isCurrent()) return cancelled();
|
if (!isCurrent()) return cancelled();
|
||||||
if (region?.error === 'region-timeout') {
|
if (region?.error === 'region-timeout') {
|
||||||
return {
|
return {
|
||||||
@@ -1039,6 +1050,7 @@ export function createAnalystEngine(providers) {
|
|||||||
layersQueried,
|
layersQueried,
|
||||||
records: totals,
|
records: totals,
|
||||||
scope: scopeNote,
|
scope: scopeNote,
|
||||||
|
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
|
||||||
...(queriedSnapshots.length
|
...(queriedSnapshots.length
|
||||||
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1050,10 +1062,27 @@ export function createAnalystEngine(providers) {
|
|||||||
? { centeredOn: resolvedScope.centeredOn }
|
? { centeredOn: resolvedScope.centeredOn }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
const memoryOverride =
|
||||||
|
typeof beforeCommit === 'function'
|
||||||
|
? (await beforeCommit(result)) || null
|
||||||
|
: null;
|
||||||
|
if (!isCurrent()) return cancelled();
|
||||||
|
const committedCoverage = memoryOverride?.coverage || result.coverage;
|
||||||
lastResult = {
|
lastResult = {
|
||||||
matched: selection.matched,
|
matched: memoryOverride?.matched || selection.matched,
|
||||||
layerKeys: [...layers],
|
layerKeys: [...(memoryOverride?.layerKeys || layers)],
|
||||||
coverage: result.coverage,
|
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;
|
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 () => {
|
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
|
||||||
const eng = makeEngine();
|
const eng = makeEngine();
|
||||||
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
const first = await eng.query({
|
||||||
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
|
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.count, 1);
|
||||||
assert.equal(r.items[0].id, 'GND1');
|
assert.equal(r.items[0].id, 'GND1');
|
||||||
assert.equal(r.coverage.followUp, true);
|
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 () => {
|
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');
|
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 () => {
|
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||||
const r = await makeEngine().query({
|
const r = await makeEngine().query({
|
||||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
formatAwarenessLabel,
|
formatAwarenessLabel,
|
||||||
getAwarenessNavigationTargets,
|
getAwarenessNavigationTargets,
|
||||||
} from './militaryAwarenessEngine.js';
|
} from './militaryAwarenessEngine.js';
|
||||||
|
import { formatAwarenessCount } from '../layers/awareness/panel.js';
|
||||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||||
|
|
||||||
const militaryAwarenessSource = readLayerSource(
|
const militaryAwarenessSource = readLayerSource(
|
||||||
@@ -55,6 +56,18 @@ const AWARENESS_DEPENDENCIES = [
|
|||||||
'military-installations',
|
'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({
|
function installAwarenessRuntime({
|
||||||
isEnabled = () => true,
|
isEnabled = () => true,
|
||||||
isEffectivelyEnabled = isEnabled,
|
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.flights, 42);
|
||||||
assert.equal(window.military, 13);
|
assert.equal(window.military, 13);
|
||||||
assert.equal(window.aircraft, 55);
|
assert.equal(window.aircraft, 55);
|
||||||
|
assert.equal(window.complete, true);
|
||||||
assert.equal(window.centeredOn, 'ASA635');
|
assert.equal(window.centeredOn, 'ASA635');
|
||||||
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
|
assert.equal(window.radiusKm, Math.round(AWARENESS_RADIUS_M / 1000));
|
||||||
// A feed that cannot answer says so rather than reporting a confident zero.
|
// 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');
|
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', () => {
|
test('there is no contacts window without a subject', () => {
|
||||||
assert.equal(contactsWindowFromSnapshot(null), null);
|
assert.equal(contactsWindowFromSnapshot(null), null);
|
||||||
assert.equal(contactsWindowFromSnapshot({ cohorts: [] }), 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 ─────────────────────
|
// ── 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.
|
// Analyst layers and the separate satellite-pass tool deliberately extend the schema.
|
||||||
// Canonical serialization pins every tool name, description, property and
|
// Canonical serialization pins every tool name, description, property and
|
||||||
// ordering while allowing source formatting. Derived from the unchanged
|
// ordering while allowing source formatting. Derived from the unchanged
|
||||||
@@ -669,12 +669,12 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
|
|||||||
const block = JSON.stringify(legacyTools);
|
const block = JSON.stringify(legacyTools);
|
||||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||||
// consolidation (shorter analyst, annotate_map and ISS wording); the
|
// consolidation (shorter analyst, annotate_map and ISS wording), plus the
|
||||||
// missions still ride existing tools.
|
// referent-only track_entity alternative; the missions still ride existing tools.
|
||||||
assert.equal(block.length, 28800, 'serialized tool schema length drifted');
|
assert.equal(block.length, 28922, 'serialized tool schema length drifted');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'7e346dadcc6e6b520c77a5c8cae501c9ee9a2b6e6c61babbe1b0f564cd396db3',
|
'2b0b1b6677eb735a80fb3955544bf6909df1acf74ca83d3047fcd51edf15a04e',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||||
|
|||||||
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
|
* Release Contact-owned camera tracking without discarding the selected
|
||||||
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
|
* 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) {
|
function contactsWindowFromSnapshot(snapshot) {
|
||||||
if (!snapshot?.subject) return null;
|
if (!snapshot?.subject) return null;
|
||||||
|
const aircraftCohorts = (snapshot.cohorts || []).filter((cohort) =>
|
||||||
|
['flights', 'military'].includes(cohort?.id),
|
||||||
|
);
|
||||||
const countFor = (cohortId) => {
|
const countFor = (cohortId) => {
|
||||||
const cohort = Array.isArray(snapshot.cohorts)
|
const cohort = Array.isArray(snapshot.cohorts)
|
||||||
? snapshot.cohorts.find((item) => item?.id === cohortId)
|
? snapshot.cohorts.find((item) => item?.id === cohortId)
|
||||||
@@ -164,6 +167,12 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
flights,
|
flights,
|
||||||
military,
|
military,
|
||||||
vessels: countFor('ais-live-vessels'),
|
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,
|
coverage: cohort.coverage || null,
|
||||||
relationship: cohort.summary.relationship,
|
relationship: cohort.summary.relationship,
|
||||||
count: cohort.summary.count,
|
count: cohort.summary.count,
|
||||||
|
complete: cohort.summary.complete !== false,
|
||||||
|
truncated: cohort.summary.truncated === true,
|
||||||
reason: cohort.summary.reason,
|
reason: cohort.summary.reason,
|
||||||
nearest: cohort.summary.nearest.slice(),
|
nearest: cohort.summary.nearest.slice(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function createNavigation({
|
|||||||
{
|
{
|
||||||
displayLimit = AWARENESS_MAX_EXAMPLES,
|
displayLimit = AWARENESS_MAX_EXAMPLES,
|
||||||
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
|
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
|
||||||
|
complete = true,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const summary = summarizeAwarenessCohort(items, {
|
const summary = summarizeAwarenessCohort(items, {
|
||||||
@@ -34,6 +35,8 @@ export function createNavigation({
|
|||||||
if (summary.count === null) return summary;
|
if (summary.count === null) return summary;
|
||||||
return {
|
return {
|
||||||
...summary,
|
...summary,
|
||||||
|
complete: Boolean(complete),
|
||||||
|
truncated: !complete,
|
||||||
nearest: summary.nearest.slice(0, displayLimit),
|
nearest: summary.nearest.slice(0, displayLimit),
|
||||||
navigationNearest: summary.nearest,
|
navigationNearest: summary.nearest,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
} from '../../data/militaryAwarenessEngine.js';
|
} from '../../data/militaryAwarenessEngine.js';
|
||||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.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 }) {
|
export function createPanel({ state: layerState, services, parts, source }) {
|
||||||
function ensurePanel() {
|
function ensurePanel() {
|
||||||
if (layerState.panel) return layerState.panel;
|
if (layerState.panel) return layerState.panel;
|
||||||
@@ -61,7 +70,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function rowHtml(cohort) {
|
function rowHtml(cohort) {
|
||||||
const summary = cohort.summary;
|
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 page = layerState.cohortPages.get(cohort.id) || 0;
|
||||||
const nearest = summary.nearest
|
const nearest = summary.nearest
|
||||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
.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
|
// 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
|
// the display limit. They are still FINITE: an unbounded materialization would
|
||||||
// let a pathological feed sort an eleven-thousand-contact array on every
|
// let a pathological feed sort an eleven-thousand-contact array on every
|
||||||
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
|
// refresh. Callers sample beyond this cap and carry explicit completeness, so
|
||||||
// derives `count` from the full in-range set before any slice.
|
// a saturated cohort is a lower bound rather than a silently exact count.
|
||||||
|
|
||||||
export const AWARENESS_QUERY_LIMIT = 20000;
|
export const AWARENESS_QUERY_LIMIT = 20000;
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
|||||||
neverAnswered ||
|
neverAnswered ||
|
||||||
['unavailable', 'zoom-in'].includes(stats.status) ||
|
['unavailable', 'zoom-in'].includes(stats.status) ||
|
||||||
Boolean(stats.error && stats.count === 0);
|
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() {
|
function collectSourceStates() {
|
||||||
@@ -147,8 +152,10 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
|||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
|
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
|
||||||
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
|
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
|
||||||
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
|
* @returns {{flights: Array, military: Array, aircraft: number,
|
||||||
* plus the combined aircraft count, or null without a position.
|
* completeByLayer: {flights: boolean, military: boolean}}|null} Cohorts,
|
||||||
|
* their lower-bound completeness, and the combined aircraft count, or
|
||||||
|
* null without a position.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function collectAircraftProximityWindow(
|
function collectAircraftProximityWindow(
|
||||||
@@ -156,21 +163,35 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
|||||||
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
|
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
|
||||||
) {
|
) {
|
||||||
if (!position) return null;
|
if (!position) return null;
|
||||||
const flights = flightsLayer
|
// Ask for one usable row beyond the retained limit so a capped cohort is
|
||||||
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
// 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,
|
includeHidden: true,
|
||||||
})
|
})
|
||||||
.filter(
|
.filter(
|
||||||
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
|
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
|
||||||
);
|
);
|
||||||
const military = militaryFlightsLayer
|
const militarySample = militaryFlightsLayer
|
||||||
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
.getNearby(position, radiusM, sampleLimit, {
|
||||||
includeHidden: true,
|
includeHidden: true,
|
||||||
})
|
})
|
||||||
.filter(
|
.filter(
|
||||||
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
|
(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 {
|
return {
|
||||||
sourceState,
|
sourceState,
|
||||||
|
|||||||
@@ -6,6 +6,39 @@ import { createSubject } from './subject.js';
|
|||||||
import { createControls } from '../installations/controls.js';
|
import { createControls } from '../installations/controls.js';
|
||||||
import { createModel } from '../installations/model.js';
|
import { createModel } from '../installations/model.js';
|
||||||
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.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', () => {
|
test('Contacts excludes square corners and measures from the current moving subject', () => {
|
||||||
const records = [
|
const records = [
|
||||||
|
|||||||
@@ -37,11 +37,23 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
const militaryState = sourceStates.military;
|
const militaryState = sourceStates.military;
|
||||||
const vesselsState = sourceStates['ais-live-vessels'];
|
const vesselsState = sourceStates['ais-live-vessels'];
|
||||||
const installationsState = sourceStates['military-installations'];
|
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.
|
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
|
||||||
const { flights, military } = parts.queries.collectAircraftProximityWindow(
|
const aircraftWindow = parts.queries.collectAircraftProximityWindow(
|
||||||
position,
|
position,
|
||||||
{ subject },
|
{ subject },
|
||||||
);
|
);
|
||||||
|
const { flights, military, completeByLayer } = aircraftWindow;
|
||||||
const vessels = aisLiveVesselsLayer
|
const vessels = aisLiveVesselsLayer
|
||||||
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -61,31 +73,57 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
evaluatedAt: Date.now(),
|
evaluatedAt,
|
||||||
radiusM: AWARENESS_RADIUS_M,
|
radiusM: AWARENESS_RADIUS_M,
|
||||||
cohorts: [
|
cohorts: [
|
||||||
{
|
{
|
||||||
id: 'flights',
|
id: 'flights',
|
||||||
label: 'Flights',
|
label: 'Flights',
|
||||||
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
||||||
|
provenance: retainedProvenance(
|
||||||
|
'flights',
|
||||||
|
'Flights',
|
||||||
|
flightsState,
|
||||||
|
SOURCE_LABEL.flights,
|
||||||
|
),
|
||||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||||
flights,
|
flights,
|
||||||
flightsState,
|
flightsState,
|
||||||
|
{
|
||||||
|
navigationLimit: AWARENESS_QUERY_LIMIT,
|
||||||
|
complete: completeByLayer.flights,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'military',
|
id: 'military',
|
||||||
label: 'Military flights',
|
label: 'Military flights',
|
||||||
source: militaryState.stats.source || SOURCE_LABEL.military,
|
source: militaryState.stats.source || SOURCE_LABEL.military,
|
||||||
|
provenance: retainedProvenance(
|
||||||
|
'military',
|
||||||
|
'Military flights',
|
||||||
|
militaryState,
|
||||||
|
SOURCE_LABEL.military,
|
||||||
|
),
|
||||||
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||||
military,
|
military,
|
||||||
militaryState,
|
militaryState,
|
||||||
|
{
|
||||||
|
navigationLimit: AWARENESS_QUERY_LIMIT,
|
||||||
|
complete: completeByLayer.military,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ais-live-vessels',
|
id: 'ais-live-vessels',
|
||||||
label: 'AIS vessels',
|
label: 'AIS vessels',
|
||||||
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-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(
|
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||||
vessels,
|
vessels,
|
||||||
vesselsState,
|
vesselsState,
|
||||||
@@ -97,6 +135,12 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
source:
|
source:
|
||||||
installationsState.stats.source ||
|
installationsState.stats.source ||
|
||||||
SOURCE_LABEL['military-installations'],
|
SOURCE_LABEL['military-installations'],
|
||||||
|
provenance: retainedProvenance(
|
||||||
|
'military-installations',
|
||||||
|
'Mapped installations',
|
||||||
|
installationsState,
|
||||||
|
SOURCE_LABEL['military-installations'],
|
||||||
|
),
|
||||||
coverage:
|
coverage:
|
||||||
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
|
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
|
||||||
summary: parts.queries.summarizeInstallationViewport(
|
summary: parts.queries.summarizeInstallationViewport(
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ test('toGeminiSchema drops unsupported keywords and expresses unions as anyOf',
|
|||||||
assert.deepEqual(out.required, ['a']);
|
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', () => {
|
test('toGeminiDeclarations: jsonSchema mode is verbatim; openapi mode omits empty parameter objects', () => {
|
||||||
const json = toGeminiDeclarations(GEV_REALTIME_TOOLS);
|
const json = toGeminiDeclarations(GEV_REALTIME_TOOLS);
|
||||||
assert.deepEqual(json[0].parametersJsonSchema, GEV_REALTIME_TOOLS[0].parameters);
|
assert.deepEqual(json[0].parametersJsonSchema, GEV_REALTIME_TOOLS[0].parameters);
|
||||||
|
|||||||
@@ -527,15 +527,21 @@ const schemas = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
pattern: '\\S',
|
||||||
},
|
},
|
||||||
layerId: {
|
layerId: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
referent: {
|
referent: {
|
||||||
type: 'integer',
|
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 { createHash } from 'node:crypto';
|
||||||
import { GEV_ACTION_SCHEMAS, createActionTools } from './actionSchemas.js';
|
import { GEV_ACTION_SCHEMAS, createActionTools } from './actionSchemas.js';
|
||||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||||
|
import { validateArgs } from '../../scripts/voice-bench/grade.mjs';
|
||||||
|
|
||||||
const stable = (value) =>
|
const stable = (value) =>
|
||||||
Array.isArray(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
|
// Re-derived for the voice layer manifest (generated layer enums and
|
||||||
// aliases), point-and-ask (pointer sentinels, referent args) and the
|
// aliases), point-and-ask (pointer sentinels, referent args) and the
|
||||||
// consolidated tool wording (each policy stated once).
|
// 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');
|
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')
|
const analystScopeKind = legacy.find((tool) => tool.name === 'analyst_query')
|
||||||
.parameters.properties.scope.properties.kind;
|
.parameters.properties.scope.properties.kind;
|
||||||
analystScopeKind.enum = analystScopeKind.enum.filter(
|
analystScopeKind.enum = analystScopeKind.enum.filter(
|
||||||
@@ -131,3 +137,17 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
|||||||
'01f14fdb1523eebfcbff8b115e48e6ab99e36fa06f55a3bb13c65e198f79d758',
|
'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.equal(referents.get(1), null);
|
||||||
assert.deepEqual(referentsFromResult('x', 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;
|
: SPEECH_BUILDERS;
|
||||||
const _layerEnabledAt = new Map();
|
const _layerEnabledAt = new Map();
|
||||||
let analystEngine;
|
let analystEngine;
|
||||||
const resolveRegionRing = (name) =>
|
const resolveRegionRing = (name, signal) =>
|
||||||
annotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch);
|
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
|
||||||
const ensureAnalystEngine = () =>
|
const ensureAnalystEngine = () =>
|
||||||
(analystEngine ||= createAnalystEngine(
|
(analystEngine ||= createAnalystEngine(
|
||||||
analystProviders(viewer, dataManager, {
|
analystProviders(viewer, dataManager, {
|
||||||
@@ -1014,6 +1014,7 @@ export function createGevActionRunner({
|
|||||||
args,
|
args,
|
||||||
_layerEnabledAt,
|
_layerEnabledAt,
|
||||||
current,
|
current,
|
||||||
|
runOptions.signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1162,7 +1163,9 @@ export function createGevActionRunner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'track_entity') {
|
if (name === 'track_entity') {
|
||||||
return trackEntity(viewer, dataManager, styleManager, args);
|
return trackEntity(viewer, dataManager, styleManager, args, {
|
||||||
|
isCurrent: current,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'stop_tracking') {
|
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. */
|
/** 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();
|
const query = String(args.query || '').trim();
|
||||||
if (!query) throw new Error('track_entity needs a query');
|
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',
|
error: 'The strongest fire has no usable position',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!isCurrent()) return cancelled();
|
||||||
return runManagedVoiceNavigation(
|
return runManagedVoiceNavigation(
|
||||||
styleManager,
|
styleManager,
|
||||||
'fire',
|
'fire',
|
||||||
@@ -2259,6 +2276,8 @@ async function trackEntity(viewer, dataManager, styleManager, args = {}) {
|
|||||||
const found = module.findByQuery(query);
|
const found = module.findByQuery(query);
|
||||||
if (!found) continue;
|
if (!found) continue;
|
||||||
|
|
||||||
|
if (!isCurrent()) return cancelled();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
family.kind === 'vessel' &&
|
family.kind === 'vessel' &&
|
||||||
(!Number.isFinite(found.latitude) || !Number.isFinite(found.longitude))
|
(!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.
|
* @param {object} result The general engine's result, reused for scope text.
|
||||||
* @returns {object|null} A unified-count payload, or null.
|
* @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 || []);
|
const spec = normalizeAnalystSpec(args, args?.layers || []);
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
const scope = args?.scope;
|
const scope = args?.scope;
|
||||||
@@ -3461,9 +3485,8 @@ async function aircraftProximityWindowForQuery(dataManager, args, result) {
|
|||||||
const layers = Array.isArray(args.layers) ? args.layers : [];
|
const layers = Array.isArray(args.layers) ? args.layers : [];
|
||||||
if (!layers.some((layer) => layer === 'flights' || layer === 'military'))
|
if (!layers.some((layer) => layer === 'flights' || layer === 'military'))
|
||||||
return null;
|
return null;
|
||||||
const snapshot = dataManager?.layers
|
const awareness = dataManager?.layers?.get('military-awareness')?.module;
|
||||||
?.get('military-awareness')
|
const snapshot = awareness?.getAircraftQuerySnapshot?.();
|
||||||
?.module?.getContextSnapshot?.();
|
|
||||||
const subject = snapshot?.subject;
|
const subject = snapshot?.subject;
|
||||||
if (!subject?.position) return null;
|
if (!subject?.position) return null;
|
||||||
// An explicit centre only qualifies when it IS the subject; otherwise the
|
// 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)
|
const radiusM = Number.isFinite(scope.km)
|
||||||
? scope.km * 1000
|
? scope.km * 1000
|
||||||
: snapshot.radiusM || 250_000;
|
: snapshot.radiusM || 250_000;
|
||||||
const window = dataManager.layers
|
// A different radius is a different question. Let the general analyst
|
||||||
.get('military-awareness')
|
// engine answer it instead of relabelling the panel's 250 km snapshot.
|
||||||
.module.collectAircraftProximityWindow(subject.position, {
|
if (
|
||||||
radiusM,
|
Number.isFinite(snapshot.radiusM) &&
|
||||||
subject,
|
Math.abs(radiusM - snapshot.radiusM) > 1
|
||||||
});
|
)
|
||||||
if (!window) return null;
|
return null;
|
||||||
const label = subject.label || subject.id || 'the selected contact';
|
const label = subject.label || subject.id || 'the selected contact';
|
||||||
const radiusKm = Math.round(radiusM / 1000);
|
const radiusKm = Math.round(radiusM / 1000);
|
||||||
const wanted = layers.filter(
|
const wanted = layers.filter(
|
||||||
(layer) => layer === 'flights' || layer === 'military',
|
(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
|
// 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
|
// record so the same filters, ranking and item shape apply as everywhere
|
||||||
// else. The cohort still owns membership and distance.
|
// else. The cohort still owns membership and distance.
|
||||||
@@ -3495,35 +3529,33 @@ async function aircraftProximityWindowForQuery(dataManager, args, result) {
|
|||||||
.get(key)
|
.get(key)
|
||||||
?.module?.getAnalystRecords?.(ANALYST_RECORD_CAP) || [])
|
?.module?.getAnalystRecords?.(ANALYST_RECORD_CAP) || [])
|
||||||
if (record?.icao24) records.set(record.icao24, record);
|
if (record?.icao24) records.set(record.icao24, record);
|
||||||
const rows = window[key === 'flights' ? 'flights' : 'military'].map(
|
const rows = (snapshot.cohorts?.[key]?.items || []).map((item) => {
|
||||||
(item) => {
|
const record = records.get(item.icao24);
|
||||||
const record = records.get(item.icao24);
|
let lat = record?.lat;
|
||||||
let lat = record?.lat;
|
let lon = record?.lon;
|
||||||
let lon = record?.lon;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
const carto = item.position
|
||||||
const carto = item.position
|
? Cesium.Cartographic.fromCartesian(item.position)
|
||||||
? Cesium.Cartographic.fromCartesian(item.position)
|
: null;
|
||||||
: null;
|
lat = carto ? Cesium.Math.toDegrees(carto.latitude) : null;
|
||||||
lat = carto ? Cesium.Math.toDegrees(carto.latitude) : null;
|
lon = carto ? Cesium.Math.toDegrees(carto.longitude) : null;
|
||||||
lon = carto ? Cesium.Math.toDegrees(carto.longitude) : null;
|
}
|
||||||
}
|
const row = {
|
||||||
const row = {
|
...(record || {
|
||||||
...(record || {
|
id: item.id,
|
||||||
id: item.id,
|
icao24: item.icao24,
|
||||||
icao24: item.icao24,
|
callsign: item.callsign ?? null,
|
||||||
callsign: item.callsign ?? null,
|
altitudeM: item.altitudeM ?? null,
|
||||||
altitudeM: item.altitudeM ?? null,
|
speedMps: item.velocityMps ?? null,
|
||||||
speedMps: item.velocityMps ?? null,
|
aircraftClass: item.aircraftClass ?? null,
|
||||||
aircraftClass: item.aircraftClass ?? null,
|
}),
|
||||||
}),
|
lat,
|
||||||
lat,
|
lon,
|
||||||
lon,
|
};
|
||||||
};
|
if (Number.isFinite(item.distance))
|
||||||
if (Number.isFinite(item.distance))
|
distanceKm.set(row, Math.round(item.distance / 100) / 10);
|
||||||
distanceKm.set(row, Math.round(item.distance / 100) / 10);
|
return row;
|
||||||
return row;
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
return { key, rows };
|
return { key, rows };
|
||||||
});
|
});
|
||||||
const selection = await selectAnalystRows(groups, {
|
const selection = await selectAnalystRows(groups, {
|
||||||
@@ -3533,39 +3565,106 @@ async function aircraftProximityWindowForQuery(dataManager, args, result) {
|
|||||||
limit: Math.round(clampNumber(args.limit, 1, 50, 12)),
|
limit: Math.round(clampNumber(args.limit, 1, 50, 12)),
|
||||||
layerKeys: wanted,
|
layerKeys: wanted,
|
||||||
distanceOf: (row) => distanceKm.get(row),
|
distanceOf: (row) => distanceKm.get(row),
|
||||||
|
isCurrent,
|
||||||
});
|
});
|
||||||
if (!selection) return null;
|
if (!selection || !isCurrent()) return null;
|
||||||
const items = selection.items.map(compactAnalystItem);
|
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,
|
ok: true,
|
||||||
action: 'analyst_query',
|
action: 'analyst_query',
|
||||||
count: selection.count,
|
count: selection.count,
|
||||||
complete: true,
|
complete,
|
||||||
scopeLabel: `within ${radiusKm} km of ${label}`,
|
scopeLabel: `within ${radiusKm} km of ${label}`,
|
||||||
listed: items.length,
|
listed: items.length,
|
||||||
items,
|
items,
|
||||||
summary: selection.summary,
|
summary: selection.summary,
|
||||||
|
...(result?.partial
|
||||||
|
? { partial: true, unanswered: [...(result.unanswered || [])] }
|
||||||
|
: {}),
|
||||||
display: {
|
display: {
|
||||||
scope: `within ${radiusKm} km of ${label} (Contacts window)`,
|
scope: `within ${radiusKm} km of ${label} (Contacts window)`,
|
||||||
},
|
},
|
||||||
coverage: {
|
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.',
|
|
||||||
},
|
|
||||||
// (D) The answer always says whose window it is and which engine produced it.
|
// (D) The answer always says whose window it is and which engine produced it.
|
||||||
window: {
|
window: {
|
||||||
engine: 'contacts-window',
|
engine: 'contacts-window',
|
||||||
centeredOn: label,
|
centeredOn: label,
|
||||||
radiusKm,
|
radiusKm,
|
||||||
flights: window.flights.length,
|
flights: snapshot.cohorts?.flights?.count ?? 'unknown',
|
||||||
military: window.military.length,
|
military: snapshot.cohorts?.military?.count ?? 'unknown',
|
||||||
aircraft: window.aircraft,
|
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)
|
...(snapshot.contactsWindow
|
||||||
? { contactsWindow: activeContactsWindow(dataManager) }
|
? { 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 = {},
|
args = {},
|
||||||
_layerEnabledAt,
|
_layerEnabledAt,
|
||||||
isCurrent = () => true,
|
isCurrent = () => true,
|
||||||
|
signal = undefined,
|
||||||
) {
|
) {
|
||||||
|
let entityWindow = null;
|
||||||
const result = await analystEngine.query(
|
const result = await analystEngine.query(
|
||||||
{
|
{
|
||||||
layers: Array.isArray(args.layers) ? args.layers : undefined,
|
layers: Array.isArray(args.layers) ? args.layers : undefined,
|
||||||
@@ -4709,7 +4810,20 @@ async function runAnalystQuery(
|
|||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
followUp: Boolean(args.followUp),
|
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) {
|
if (!result.ok) {
|
||||||
const refusal = {
|
const refusal = {
|
||||||
@@ -4755,22 +4869,8 @@ async function runAnalystQuery(
|
|||||||
// differ. The generic record/scope engine still owns explicit regions and
|
// differ. The generic record/scope engine still owns explicit regions and
|
||||||
// arbitrary points — only "how many aircraft around <this contact>" is
|
// arbitrary points — only "how many aircraft around <this contact>" is
|
||||||
// unified, because that is the question the panel is already answering.
|
// unified, because that is the question the panel is already answering.
|
||||||
const entityWindow = await aircraftProximityWindowForQuery(
|
|
||||||
dataManager,
|
|
||||||
args,
|
|
||||||
result,
|
|
||||||
);
|
|
||||||
if (entityWindow) {
|
if (entityWindow) {
|
||||||
const provenance = feedProvenanceEnvelope(
|
return entityWindow;
|
||||||
layerSnapshots(dataManager.getAll?.() || []).filter(
|
|
||||||
(layer) => layer.enabled && ['flights', 'military'].includes(layer.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...entityWindow,
|
|
||||||
feedProvenance: provenance,
|
|
||||||
feedState: provenance.overall,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contactsWindow = activeContactsWindow(dataManager);
|
const contactsWindow = activeContactsWindow(dataManager);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
} from './gevActions.js';
|
} from './gevActions.js';
|
||||||
import { MAP_STACKS } from '../mapStackController.js';
|
import { MAP_STACKS } from '../mapStackController.js';
|
||||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.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', () => {
|
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)
|
// 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 = [] }) {
|
function awarenessSubjectHarness({ subject, flights = [], military = [] }) {
|
||||||
const position = subject ? { __subject: true } : null;
|
const position = subject ? { __subject: true } : null;
|
||||||
|
const snapshotFlights = flights.slice(0, 20_000);
|
||||||
|
const snapshotMilitary = military.slice(0, 20_000);
|
||||||
return {
|
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,
|
flights,
|
||||||
military,
|
military,
|
||||||
};
|
};
|
||||||
@@ -2832,11 +2884,40 @@ async function withAwareness(harness, run) {
|
|||||||
const militaryLayer = (await import('../data/militaryFlights.js')).default;
|
const militaryLayer = (await import('../data/militaryFlights.js')).default;
|
||||||
const originals = {
|
const originals = {
|
||||||
snapshot: awareness.getContextSnapshot,
|
snapshot: awareness.getContextSnapshot,
|
||||||
|
aircraftSnapshot: awareness.getAircraftQuerySnapshot,
|
||||||
flightsNearby: flightsLayer.getNearby,
|
flightsNearby: flightsLayer.getNearby,
|
||||||
militaryNearby: militaryLayer.getNearby,
|
militaryNearby: militaryLayer.getNearby,
|
||||||
cartoFrom: Cesium.Cartographic.fromCartesian,
|
cartoFrom: Cesium.Cartographic.fromCartesian,
|
||||||
};
|
};
|
||||||
awareness.getContextSnapshot = () => harness.snapshot;
|
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();
|
flightsLayer.getNearby = () => harness.flights.slice();
|
||||||
militaryLayer.getNearby = () => harness.military.slice();
|
militaryLayer.getNearby = () => harness.military.slice();
|
||||||
Cesium.Cartographic.fromCartesian = (value) => (
|
Cesium.Cartographic.fromCartesian = (value) => (
|
||||||
@@ -2848,13 +2929,14 @@ async function withAwareness(harness, run) {
|
|||||||
return await run(awareness);
|
return await run(awareness);
|
||||||
} finally {
|
} finally {
|
||||||
awareness.getContextSnapshot = originals.snapshot;
|
awareness.getContextSnapshot = originals.snapshot;
|
||||||
|
awareness.getAircraftQuerySnapshot = originals.aircraftSnapshot;
|
||||||
flightsLayer.getNearby = originals.flightsNearby;
|
flightsLayer.getNearby = originals.flightsNearby;
|
||||||
militaryLayer.getNearby = originals.militaryNearby;
|
militaryLayer.getNearby = originals.militaryNearby;
|
||||||
Cesium.Cartographic.fromCartesian = originals.cartoFrom;
|
Cesium.Cartographic.fromCartesian = originals.cartoFrom;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function analystRunner(awareness) {
|
function analystRunner(awareness, { getAll } = {}) {
|
||||||
const flights = {
|
const flights = {
|
||||||
id: 'flights',
|
id: 'flights',
|
||||||
// Deliberately a DIFFERENT population from the proximity window: this is
|
// Deliberately a DIFFERENT population from the proximity window: this is
|
||||||
@@ -2878,7 +2960,16 @@ function analystRunner(awareness) {
|
|||||||
dataManager: {
|
dataManager: {
|
||||||
layers: new Map([['flights', { module: flights }], ['military-awareness', { module: awareness }]]),
|
layers: new Map([['flights', { module: flights }], ['military-awareness', { module: awareness }]]),
|
||||||
isEnabled: (id) => id === 'flights',
|
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 () => {
|
test('point-and-ask: the runner resolves pointer and referent targets', async () => {
|
||||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
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 { viewer, styleManager } = createVoiceNavigationHarness();
|
||||||
const referents = createReferentRegistry();
|
const referents = createReferentRegistry();
|
||||||
let used = 0;
|
let used = 0;
|
||||||
|
let tracked = 0;
|
||||||
const runner = createActionRunner({
|
const runner = createActionRunner({
|
||||||
viewer,
|
viewer,
|
||||||
styleManager,
|
styleManager,
|
||||||
dataManager: {
|
dataManager: {
|
||||||
layers: new Map([['flights', { module: {
|
layers: new Map([
|
||||||
findByQuery: () => ({ icao24: 'ccc333', callsign: 'AAL9', latitude: 1, longitude: 2 }),
|
[
|
||||||
trackById: () => true,
|
'flights',
|
||||||
} }]]),
|
{
|
||||||
|
module: {
|
||||||
|
findByQuery: () => ({
|
||||||
|
icao24: 'ccc333',
|
||||||
|
callsign: 'AAL9',
|
||||||
|
latitude: 1,
|
||||||
|
longitude: 2,
|
||||||
|
}),
|
||||||
|
trackById: () => {
|
||||||
|
tracked++;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
isEnabled: () => true,
|
isEnabled: () => true,
|
||||||
getAll: () => [],
|
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 });
|
await runner('track_entity', { query: 'pointer' }, { signal: aborted.signal });
|
||||||
assert.equal(referents.get(1), null, 'a cancelled action leaves the registry alone');
|
assert.equal(referents.get(1), null, 'a cancelled action leaves the registry alone');
|
||||||
assert.equal(used, 0, 'nor does it announce the pointer');
|
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(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' });
|
await runner('track_entity', { query: 'pointer' });
|
||||||
assert.equal(referents.get(1)?.id, 'ccc333');
|
assert.equal(referents.get(1)?.id, 'ccc333');
|
||||||
assert.equal(used, 1, 'a current pointer resolution announces the chip');
|
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 { harness, tick } from './voiceWorkflowHarness.mjs';
|
||||||
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
||||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.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. */
|
/** Run one call and fold it through the real voice card. */
|
||||||
async function throughCard(runner, name, args, card = initialVoiceCardState()) {
|
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.ok, true);
|
||||||
assert.equal(result.complete, true, 'complete survives the projection');
|
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.visible, true, 'the card shows an analyst result');
|
||||||
assert.equal(view.title, '3 aircraft anywhere in the loaded data');
|
assert.equal(view.title, '3 aircraft anywhere in the loaded data');
|
||||||
assert.ok(
|
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 () => {
|
test('a capped count is a floor everywhere: model, card and caveat', async () => {
|
||||||
const rows = Array.from({ length: 250_001 }, (_, i) => ({
|
const rows = Array.from({ length: 250_001 }, (_, i) => ({
|
||||||
id: `F${i}`,
|
id: `F${i}`,
|
||||||
@@ -76,6 +106,7 @@ test('a capped count is a floor everywhere: model, card and caveat', async () =>
|
|||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
assert.equal(result.complete, false);
|
assert.equal(result.complete, false);
|
||||||
|
assert.match(result.say, /^At least 250,000 aircraft/);
|
||||||
assert.match(view.title, /^At least 250,000 aircraft/);
|
assert.match(view.title, /^At least 250,000 aircraft/);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
view.notes.some((note) => /counted 250,000 of 400,000/.test(note)),
|
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' },
|
scope: { kind: 'anywhere' },
|
||||||
});
|
});
|
||||||
assert.equal(result.partial, true);
|
assert.equal(result.partial, true);
|
||||||
|
assert.match(result.say, /Partial; earthquakes not answered/);
|
||||||
assert.deepEqual(result.unanswered, ['earthquakes']);
|
assert.deepEqual(result.unanswered, ['earthquakes']);
|
||||||
assert.ok(view.chips.includes('partial'));
|
assert.ok(view.chips.includes('partial'));
|
||||||
assert.ok(view.notes.includes('Not answered: earthquakes'));
|
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');
|
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 () => {
|
test('a follow-up naming other layers than the last answer is refused, not answered from it', async () => {
|
||||||
const { runner } = harness();
|
const { runner } = harness();
|
||||||
await runner('analyst_query', {
|
await runner('analyst_query', {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
import { presentResult } from './resultDisplay.js';
|
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) {
|
function finite(value) {
|
||||||
if (value === null || value === undefined || value === '') return null;
|
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}). */
|
/** 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 : [])
|
return (Array.isArray(entries) ? entries : [])
|
||||||
.slice(0, max)
|
.slice(0, max)
|
||||||
.map(normalize)
|
.map(normalize)
|
||||||
@@ -55,7 +56,7 @@ export function normalizeReferents(entries, max = MAX_REFERENTS) {
|
|||||||
.map((entry, index) => ({ ...entry, n: index + 1 }));
|
.map((entry, index) => ({ ...entry, n: index + 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReferentRegistry({ max = MAX_REFERENTS } = {}) {
|
export function createReferentRegistry({ max = MAX_DISPLAYED_REFERENTS } = {}) {
|
||||||
let list = [];
|
let list = [];
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,26 +34,42 @@ function notesOf(...values) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nounFor(layerKeys) {
|
export function analystNounFor(layerKeys) {
|
||||||
const keys = [...new Set(layerKeys.filter(Boolean))];
|
const keys = [...new Set(layerKeys.filter(Boolean))];
|
||||||
if (keys.length !== 1) return 'results';
|
if (keys.length !== 1) return 'results';
|
||||||
const key = keys[0];
|
const key = keys[0];
|
||||||
return LAYER_NOUNS[key] || voiceLayer(key)?.aliases?.[0] || 'results';
|
return LAYER_NOUNS[key] || voiceLayer(key)?.aliases?.[0] || 'results';
|
||||||
}
|
}
|
||||||
|
|
||||||
function analystCard(result) {
|
/** The count/scope headline shared by the analyst card and spoken answer. */
|
||||||
if (!result?.ok) return null;
|
const ANALYST_HEADLINE_MAX = 64;
|
||||||
const keys = (result.coverage?.layersQueried || []).map(
|
|
||||||
|
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,
|
(layer) => layer.layerKey,
|
||||||
);
|
);
|
||||||
const count = Number(result.count) || 0;
|
const count = Number(result?.count) || 0;
|
||||||
const floor = result.complete === false ? 'At least ' : '';
|
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 display = result.display || {};
|
||||||
const unanswered = Array.isArray(result.unanswered) ? result.unanswered : [];
|
const unanswered = Array.isArray(result.unanswered) ? result.unanswered : [];
|
||||||
return {
|
return {
|
||||||
display: {
|
display: {
|
||||||
title:
|
title: analystHeadline(result),
|
||||||
`${floor}${count.toLocaleString('en-US')} ${nounFor(keys)} ${result.scopeLabel || ''}`.trim(),
|
|
||||||
// The precise scope, unless the title already says exactly that.
|
// The precise scope, unless the title already says exactly that.
|
||||||
lines: [
|
lines: [
|
||||||
display.scope !== result.scopeLabel ? display.scope : null,
|
display.scope !== result.scopeLabel ? display.scope : null,
|
||||||
|
|||||||
+19
-4
@@ -15,11 +15,11 @@
|
|||||||
* continue, not something to read back).
|
* continue, not something to read back).
|
||||||
*
|
*
|
||||||
* Builders are small pure functions keyed by tool name. Tools without one are
|
* Builders are small pure functions keyed by tool name. Tools without one are
|
||||||
* returned unchanged. analyst_query deliberately has no builder here: its
|
* returned unchanged.
|
||||||
* result contract is owned by the analyst engine. Supply one through
|
|
||||||
* `createGevActionRunner({ speechBuilders: { analyst_query: fn } })`.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { analystHeadline } from './resultDisplay.js';
|
||||||
|
|
||||||
const LAYER_NOUNS = Object.freeze({
|
const LAYER_NOUNS = Object.freeze({
|
||||||
flights: 'aircraft',
|
flights: 'aircraft',
|
||||||
military: 'military 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({
|
export const SPEECH_BUILDERS = Object.freeze({
|
||||||
set_layer_visibility: setLayerVisibility,
|
set_layer_visibility: setLayerVisibility,
|
||||||
fly_to_location: flyToLocation,
|
fly_to_location: flyToLocation,
|
||||||
@@ -407,6 +421,7 @@ export const SPEECH_BUILDERS = Object.freeze({
|
|||||||
get_entity_context: getEntityContext,
|
get_entity_context: getEntityContext,
|
||||||
get_current_view_state: getCurrentViewState,
|
get_current_view_state: getCurrentViewState,
|
||||||
annotate_map: annotateMap,
|
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']);
|
assert.deepEqual(pending.display.notes, ['Tracing outlines']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('builders never break an action and analyst_query has no default builder', () => {
|
test('analyst speech preserves exact counts, lower bounds and partial status', () => {
|
||||||
assert.equal(SPEECH_BUILDERS.analyst_query, undefined);
|
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 };
|
const raw = { ok: true, action: 'analyst_query', count: 3 };
|
||||||
assert.equal(attachVoiceResult('analyst_query', raw), raw);
|
|
||||||
const hooked = attachVoiceResult(
|
const hooked = attachVoiceResult(
|
||||||
'analyst_query',
|
'analyst_query',
|
||||||
raw,
|
raw,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
|
import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
|
||||||
import { presentResult } from './resultDisplay.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,
|
* 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_STEPS = 6;
|
||||||
const MAX_LINES = 6;
|
const MAX_LINES = 6;
|
||||||
const MAX_REFERENTS = 5;
|
|
||||||
const MAX_NOTES = 6;
|
const MAX_NOTES = 6;
|
||||||
const MAX_CAPTION = 220;
|
const MAX_CAPTION = 220;
|
||||||
|
|
||||||
@@ -205,7 +204,10 @@ export function reduceVoiceCard(state, event) {
|
|||||||
...next,
|
...next,
|
||||||
result: {
|
result: {
|
||||||
display: shown.display,
|
display: shown.display,
|
||||||
referents: normalizeReferents(shown.referents),
|
referents: normalizeReferents(
|
||||||
|
shown.referents,
|
||||||
|
MAX_DISPLAYED_REFERENTS,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
announce: spokenLabel(shown.display.title, 64),
|
announce: spokenLabel(shown.display.title, 64),
|
||||||
});
|
});
|
||||||
@@ -278,7 +280,7 @@ export function voiceCardView(state) {
|
|||||||
.map((line) => spokenLabel(line, 80))
|
.map((line) => spokenLabel(line, 80))
|
||||||
.slice(0, MAX_LINES),
|
.slice(0, MAX_LINES),
|
||||||
referents: (state.result?.referents || [])
|
referents: (state.result?.referents || [])
|
||||||
.slice(0, MAX_REFERENTS)
|
.slice(0, MAX_DISPLAYED_REFERENTS)
|
||||||
.map((ref) => ({ n: ref.n, label: spokenLabel(ref.label, 48) })),
|
.map((ref) => ({ n: ref.n, label: spokenLabel(ref.label, 48) })),
|
||||||
notes,
|
notes,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user