mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(agent): align World Packet with reviewer feedback and voice parity
Reuse one context/contacts/tracking owner in voice and World Packet. Compare voice outputs after stripping presentation fields; remove the unconsumed fingerprint API pending an actual caller. Document the exported packet boundary and reject null options safely. Review guidance: asturksever on origin #880; Windows parity diagnosis by quanticsoul4772. Existing source ownership retained.
This commit is contained in:
1 parent
83fc895442
commit
1c4c903ac6
5 files changed
+29
-176
No files matched your search
@@ -2,5 +2,5 @@
|
|||||||
`get_current_view_state` owner without restoring disabled layers to the
|
`get_current_view_state` owner without restoring disabled layers to the
|
||||||
model context. Keep the enabled-only payload, disabled-layer count, source
|
model context. Keep the enabled-only payload, disabled-layer count, source
|
||||||
provenance, action result and view-state contract; read the layer roster once
|
provenance, action result and view-state contract; read the layer roster once
|
||||||
per packet. The semantic fingerprint is for change detection, not authority.
|
per packet; voice retains the speech/card envelope at its own boundary.
|
||||||
Credits: original GEV voice/layer contributors and #880 implementation.
|
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||||
@@ -474,6 +474,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
|||||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
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.
|
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||||
|
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||||
|
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||||
|
non-voice consumers. It reads the same enabled-only state and feed
|
||||||
|
provenance before voice adds speech/card fields, with no new network requests
|
||||||
|
or mutations.
|
||||||
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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '../data/layerSnapshot.js';
|
} from '../data/layerSnapshot.js';
|
||||||
import { contextModeWord } from '../contextModePolicy.js';
|
import { contextModeWord } from '../contextModePolicy.js';
|
||||||
|
|
||||||
const TRACKABLE_FAMILIES = Object.freeze([
|
export const TRACKABLE_FAMILIES = Object.freeze([
|
||||||
{ layerId: 'flights', kind: 'aircraft' },
|
{ layerId: 'flights', kind: 'aircraft' },
|
||||||
{ layerId: 'military', kind: 'aircraft' },
|
{ layerId: 'military', kind: 'aircraft' },
|
||||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||||
@@ -24,37 +24,6 @@ const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
|||||||
'contextRollback',
|
'contextRollback',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const NON_SEMANTIC_PACKET_KEYS = new Set(['note', 'ageLabel', 'generatedAt']);
|
|
||||||
|
|
||||||
function semanticPacketValue(value) {
|
|
||||||
if (Array.isArray(value)) return value.map(semanticPacketValue);
|
|
||||||
if (!value || typeof value !== 'object') return value;
|
|
||||||
const out = {};
|
|
||||||
for (const key of Object.keys(value).sort()) {
|
|
||||||
if (NON_SEMANTIC_PACKET_KEYS.has(key)) continue;
|
|
||||||
out[key] = semanticPacketValue(value[key]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable semantic fingerprint for change detection, not authentication.
|
|
||||||
*
|
|
||||||
* Presentation-only narration/age labels are excluded. Feed state, source,
|
|
||||||
* lastUpdate, camera, context, tracked entities, and other semantic state stay
|
|
||||||
* inside the digest.
|
|
||||||
*/
|
|
||||||
export function semanticWorldPacketFingerprint(packet) {
|
|
||||||
const canonical = JSON.stringify(semanticPacketValue(packet));
|
|
||||||
let hash = 0xcbf29ce484222325n;
|
|
||||||
const prime = 0x100000001b3n;
|
|
||||||
for (let i = 0; i < canonical.length; i += 1) {
|
|
||||||
hash ^= BigInt(canonical.charCodeAt(i));
|
|
||||||
hash = BigInt.asUintN(64, hash * prime);
|
|
||||||
}
|
|
||||||
return `fnv1a64:${hash.toString(16).padStart(16, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate context-mode payloads into the public tool vocabulary while
|
* Translate context-mode payloads into the public tool vocabulary while
|
||||||
* retaining the internal id beside each translated field.
|
* retaining the internal id beside each translated field.
|
||||||
@@ -124,13 +93,14 @@ export function activeContactsWindow(dataManager) {
|
|||||||
* This performs no network requests and intentionally mirrors the historical
|
* This performs no network requests and intentionally mirrors the historical
|
||||||
* get_current_view_state payload so voice can delegate without behavior drift.
|
* get_current_view_state payload so voice can delegate without behavior drift.
|
||||||
*/
|
*/
|
||||||
export function getWorldPacket({
|
export function getWorldPacket(options = {}) {
|
||||||
|
const {
|
||||||
viewer,
|
viewer,
|
||||||
styleManager,
|
styleManager,
|
||||||
dataManager,
|
dataManager,
|
||||||
sceneDirector = null,
|
sceneDirector = null,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
} = {}) {
|
} = options ?? {};
|
||||||
if (!viewer?.camera?.positionWC)
|
if (!viewer?.camera?.positionWC)
|
||||||
throw new TypeError('World Packet requires a viewer camera');
|
throw new TypeError('World Packet requires a viewer camera');
|
||||||
if (!dataManager || typeof dataManager.getAll !== 'function')
|
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
import {
|
import { getWorldPacket } from './worldPacket.js';
|
||||||
getWorldPacket,
|
|
||||||
semanticWorldPacketFingerprint,
|
|
||||||
} from './worldPacket.js';
|
|
||||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||||
|
|
||||||
|
function withoutVoicePresentation(result) {
|
||||||
|
const packet = { ...result };
|
||||||
|
delete packet.say;
|
||||||
|
delete packet.display;
|
||||||
|
delete packet.schedule;
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
function harness() {
|
function harness() {
|
||||||
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
||||||
const viewer = {
|
const viewer = {
|
||||||
@@ -64,7 +69,7 @@ test('World Packet preserves get_current_view_state parity', async () => {
|
|||||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||||
const voice = await runner('get_current_view_state');
|
const voice = await runner('get_current_view_state');
|
||||||
|
|
||||||
assert.deepEqual(voice, packet);
|
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||||
assert.equal(packet.context.mode, 'contacts');
|
assert.equal(packet.context.mode, 'contacts');
|
||||||
assert.equal(packet.context.modeInternal, 'flights');
|
assert.equal(packet.context.modeInternal, 'flights');
|
||||||
assert.equal(packet.layers[0].feedState, 'nominal');
|
assert.equal(packet.layers[0].feedState, 'nominal');
|
||||||
@@ -94,20 +99,6 @@ test('World Packet keeps stale/unavailable feed semantics distinct from empty',
|
|||||||
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('semantic fingerprint ignores narration prose but changes with observed state', () => {
|
|
||||||
const { viewer, dataManager, styleManager } = harness();
|
|
||||||
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
|
||||||
const original = semanticWorldPacketFingerprint(packet);
|
|
||||||
|
|
||||||
const proseOnly = structuredClone(packet);
|
|
||||||
proseOnly.feedProvenance.note = 'different generated narration';
|
|
||||||
assert.equal(semanticWorldPacketFingerprint(proseOnly), original);
|
|
||||||
|
|
||||||
const changed = structuredClone(packet);
|
|
||||||
changed.layers[0].count += 1;
|
|
||||||
assert.notEqual(semanticWorldPacketFingerprint(changed), original);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The 2026-10 main voice contract omits disabled layers from the model payload.
|
// The 2026-10 main voice contract omits disabled layers from the model payload.
|
||||||
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
|
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
|
||||||
const { viewer, dataManager, styleManager } = harness();
|
const { viewer, dataManager, styleManager } = harness();
|
||||||
@@ -134,22 +125,9 @@ test('World Packet and voice preserve enabled-only payload and count disabled la
|
|||||||
|
|
||||||
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||||
const voice = await runner('get_current_view_state');
|
const voice = await runner('get_current_view_state');
|
||||||
assert.deepEqual(voice, packet);
|
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('World Packet fingerprints distinguish enabled state but ignore narrative churn', () => {
|
test('World Packet rejects null options with the camera requirement', () => {
|
||||||
const { viewer, dataManager, styleManager } = harness();
|
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
|
||||||
const first = getWorldPacket({ viewer, dataManager, styleManager });
|
|
||||||
const prose = structuredClone(first);
|
|
||||||
prose.feedProvenance.note = 'new narration only';
|
|
||||||
assert.equal(
|
|
||||||
semanticWorldPacketFingerprint(first),
|
|
||||||
semanticWorldPacketFingerprint(prose),
|
|
||||||
);
|
|
||||||
const hidden = structuredClone(first);
|
|
||||||
hidden.disabledLayerCount += 1;
|
|
||||||
assert.notEqual(
|
|
||||||
semanticWorldPacketFingerprint(first),
|
|
||||||
semanticWorldPacketFingerprint(hidden),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
+6
-106
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
layerSnapshot,
|
layerSnapshot,
|
||||||
layerSnapshots,
|
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
} from '../data/layerSnapshot.js';
|
} from '../data/layerSnapshot.js';
|
||||||
import { readLayerLifecycleSummary } from './layerSummary.js';
|
import { readLayerLifecycleSummary } from './layerSummary.js';
|
||||||
@@ -26,7 +25,6 @@ import {
|
|||||||
isContextRecordActive,
|
isContextRecordActive,
|
||||||
} from '../data/contextStore.js';
|
} from '../data/contextStore.js';
|
||||||
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
||||||
import { contextModeWord } from '../contextModePolicy.js';
|
|
||||||
import {
|
import {
|
||||||
createAnalystEngine,
|
createAnalystEngine,
|
||||||
normalizeAnalystSpec,
|
normalizeAnalystSpec,
|
||||||
@@ -47,7 +45,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
|||||||
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
||||||
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
||||||
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
||||||
import { getWorldPacket } from '../agent/worldPacket.js';
|
import {
|
||||||
|
getWorldPacket,
|
||||||
|
withContextModeVocabulary,
|
||||||
|
activeContactsWindow,
|
||||||
|
TRACKABLE_FAMILIES,
|
||||||
|
} from '../agent/worldPacket.js';
|
||||||
|
|
||||||
const ALLOWED_STYLES = new Set([
|
const ALLOWED_STYLES = new Set([
|
||||||
'normal',
|
'normal',
|
||||||
@@ -119,64 +122,6 @@ const CONTEXT_MODE_ALIASES = new Map([
|
|||||||
['space-missions', 'space-missions'],
|
['space-missions', 'space-missions'],
|
||||||
['missions', 'space-missions'],
|
['missions', 'space-missions'],
|
||||||
]);
|
]);
|
||||||
/**
|
|
||||||
* Every model-readable field that carries a context-mode id, and what an
|
|
||||||
* absent value means for each.
|
|
||||||
*
|
|
||||||
* `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode`
|
|
||||||
* are absent when there is no such mode at all — calling those 'off' would
|
|
||||||
* assert a state that does not exist.
|
|
||||||
*/
|
|
||||||
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
|
||||||
{ field: 'mode', emptyAs: 'off' },
|
|
||||||
{ field: 'entering', emptyAs: null },
|
|
||||||
{ field: 'priorMode', emptyAs: null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Nested results that are themselves context-mode payloads the model reads. */
|
|
||||||
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
|
||||||
'context',
|
|
||||||
'contextRollback',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Report a context-mode payload in the tools' own vocabulary.
|
|
||||||
*
|
|
||||||
* `set_context_mode` accepts 'contacts' while the mode's internal id is
|
|
||||||
* 'flights'. Reporting the internal id back made the model read
|
|
||||||
* `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts
|
|
||||||
* window counts sitting in the very same payload (owner field session
|
|
||||||
* 2026-08-21). Secondary fields and nested transition/rollback results are
|
|
||||||
* translated too — one leaked internal id is enough to recreate the confusion,
|
|
||||||
* and a rollback result is exactly what the model reads when something went
|
|
||||||
* wrong. Each internal id is kept alongside as `<field>Internal` for anything
|
|
||||||
* reasoning about layers.
|
|
||||||
* @param {object|null|undefined} state Any payload carrying context-mode fields.
|
|
||||||
* @returns {object|null|undefined} The same payload, modes translated.
|
|
||||||
*/
|
|
||||||
function withContextModeVocabulary(state) {
|
|
||||||
if (!state || typeof state !== 'object') return state;
|
|
||||||
let out = state;
|
|
||||||
const mutable = () => {
|
|
||||||
if (out === state) out = { ...state };
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
|
||||||
if (!(field in state)) continue;
|
|
||||||
const internal = state[field] ?? null;
|
|
||||||
const target = mutable();
|
|
||||||
target[field] = contextModeWord(internal, { emptyAs });
|
|
||||||
target[`${field}Internal`] = internal;
|
|
||||||
}
|
|
||||||
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
|
||||||
const nested = state[field];
|
|
||||||
if (!nested || typeof nested !== 'object') continue;
|
|
||||||
const translated = withContextModeVocabulary(nested);
|
|
||||||
if (translated !== nested) mutable()[field] = translated;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COCKPIT_ACTION_ALIASES = new Map([
|
const COCKPIT_ACTION_ALIASES = new Map([
|
||||||
['next', 'next'],
|
['next', 'next'],
|
||||||
['previous', 'previous'],
|
['previous', 'previous'],
|
||||||
@@ -246,14 +191,6 @@ const STACK_ALIASES = new Map([
|
|||||||
['road map', 'osm'],
|
['road map', 'osm'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Search order for track_entity across entity layer families. */
|
|
||||||
const TRACKABLE_FAMILIES = [
|
|
||||||
{ layerId: 'flights', kind: 'aircraft' },
|
|
||||||
{ layerId: 'military', kind: 'aircraft' },
|
|
||||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
|
||||||
{ layerId: 'satellites', kind: 'satellite' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FRAME_TARGETS = new Map([
|
const FRAME_TARGETS = new Map([
|
||||||
['flights', 'flights'],
|
['flights', 'flights'],
|
||||||
['planes', 'flights'],
|
['planes', 'flights'],
|
||||||
@@ -2553,26 +2490,6 @@ function runManagedVoiceNavigation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gathers tracked/selected entities across layer families for read-back. */
|
|
||||||
function collectTrackedEntities(dataManager) {
|
|
||||||
const tracked = [];
|
|
||||||
for (const family of TRACKABLE_FAMILIES) {
|
|
||||||
const module = dataManager.layers.get(family.layerId)?.module;
|
|
||||||
if (!module) continue;
|
|
||||||
try {
|
|
||||||
const info =
|
|
||||||
family.kind === 'vessel'
|
|
||||||
? module.getSelectedInfo?.()
|
|
||||||
: module.getTrackedInfo?.();
|
|
||||||
if (info)
|
|
||||||
tracked.push({ kind: family.kind, layerId: family.layerId, ...info });
|
|
||||||
} catch {
|
|
||||||
// layer not ready
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBasemapLabelContext(
|
export async function getBasemapLabelContext(
|
||||||
viewer,
|
viewer,
|
||||||
service = defaultGeospatial,
|
service = defaultGeospatial,
|
||||||
@@ -4599,23 +4516,6 @@ function clampNumber(value, min, max, fallback) {
|
|||||||
* ("which of those is closest?") scoped to the session.
|
* ("which of those is closest?") scoped to the session.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* The Contacts panel's own counts, or null when Contacts has no subject.
|
|
||||||
* Read through the awareness snapshot the panel renders, so the two cannot
|
|
||||||
* diverge no matter which surface asks.
|
|
||||||
* @returns {object|null} Panel-equivalent window counts.
|
|
||||||
*/
|
|
||||||
function activeContactsWindow(dataManager) {
|
|
||||||
try {
|
|
||||||
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
|
||||||
return (
|
|
||||||
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Most records one layer hands a single query. Above every loaded feed seen
|
* Most records one layer hands a single query. Above every loaded feed seen
|
||||||
* so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so
|
* so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so
|
||||||
|
|||||||
Reference in new issue
Block a user