mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Merge feat/voice-geometry onto main
Both stack parents (#837, #842) are on main, so the voice geometry branch now builds on main directly. Conflict resolutions keep main's behaviour and add the geometry work on top: osm_query gating on every provider, restored admin identity for Nominatim outlines, merged analyst follow-up memory, and the cancelled Globe View reset instruction ahead of the generic cancellation.
This commit is contained in:
commit
ecaddd838b
102 files changed
+12925
-1006
No files matched your search
+222
-44
@@ -60,6 +60,14 @@ import {
|
||||
activeContactsWindow,
|
||||
TRACKABLE_FAMILIES,
|
||||
} from '../agent/worldPacket.js';
|
||||
import { createVoiceAreas } from './areaActions.js';
|
||||
import { findImagery } from './imageryActions.js';
|
||||
import { osmQuery } from './osmActions.js';
|
||||
import { createOsmFeatureSource } from '../sources/osmFeatures.js';
|
||||
import {
|
||||
OSM_PLACES_CREDIT,
|
||||
registerDynamicCredit,
|
||||
} from '../data/dataCredits.js';
|
||||
|
||||
const ALLOWED_STYLES = new Set([
|
||||
'normal',
|
||||
@@ -268,6 +276,8 @@ export function createGevActionRunner({
|
||||
speechBuilders = null,
|
||||
deixis = null,
|
||||
readVoicePanelOperation = null,
|
||||
osmSearch = null,
|
||||
areaOptions = null,
|
||||
}) {
|
||||
// Voice enable times and analyst follow-up memory belong to this runner.
|
||||
// Speech builders turn results into say/display envelopes; callers extend
|
||||
@@ -277,13 +287,25 @@ export function createGevActionRunner({
|
||||
: SPEECH_BUILDERS;
|
||||
const _layerEnabledAt = new Map();
|
||||
let analystEngine;
|
||||
const resolveRegionRing = (name, signal) =>
|
||||
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
|
||||
// Area handles live for this runner (the page): resolve_area, the
|
||||
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
|
||||
// the same store.
|
||||
const voiceAreas = createVoiceAreas({
|
||||
viewer,
|
||||
annotations,
|
||||
placeSearch,
|
||||
annotationResolver,
|
||||
...(areaOptions || {}),
|
||||
});
|
||||
const searchOsmFeatures = osmSearch || createOsmFeatureSource();
|
||||
const resolveRegionRing = (name, options) =>
|
||||
voiceAreas.resolveRegion(name, options);
|
||||
const ensureAnalystEngine = () =>
|
||||
(analystEngine ||= createAnalystEngine(
|
||||
analystProviders(viewer, dataManager, {
|
||||
placeSearch,
|
||||
resolveRegionRing,
|
||||
resolveAreaScope: voiceAreas.resolveAreaScope,
|
||||
isWarming: (layerKey) => layerIsWarming(_layerEnabledAt, layerKey),
|
||||
}),
|
||||
));
|
||||
@@ -1161,6 +1183,61 @@ export function createGevActionRunner({
|
||||
return nextIssPass(viewer, dataManager, args);
|
||||
}
|
||||
|
||||
if (name === 'resolve_area') {
|
||||
return voiceAreas.resolveAreaAction(args, {
|
||||
signal: runOptions.signal,
|
||||
isCurrent: current,
|
||||
progress: reportProgress,
|
||||
});
|
||||
}
|
||||
|
||||
if (name === 'find_imagery') {
|
||||
return findImagery(
|
||||
{
|
||||
dataManager,
|
||||
viewer,
|
||||
getArea: (areaId) => voiceAreas.store.get(areaId),
|
||||
enableLayer: (enabled) =>
|
||||
runGevAction(
|
||||
'set_layer_visibility',
|
||||
{ layerId: 'recent-imagery', enabled },
|
||||
runOptions,
|
||||
),
|
||||
progress: reportProgress,
|
||||
},
|
||||
args,
|
||||
{ isCurrent: current, signal: runOptions.signal },
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'osm_query') {
|
||||
return osmQuery(
|
||||
{
|
||||
dataManager,
|
||||
getArea: (areaId) => voiceAreas.store.get(areaId),
|
||||
viewBox: () => cameraViewBox(viewer),
|
||||
enableLayer: (enabled) =>
|
||||
runGevAction(
|
||||
'set_layer_visibility',
|
||||
{ layerId: 'osm-places', enabled },
|
||||
runOptions,
|
||||
),
|
||||
search: searchOsmFeatures,
|
||||
onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT),
|
||||
// The listed places become the analyst's "last answer", so
|
||||
// "which of those…" filters them and never an older set.
|
||||
rememberResults: (rows) => {
|
||||
if (!current()) return;
|
||||
if (rows) ensureAnalystEngine().remember('osm-places', rows);
|
||||
else analystEngine?.reset();
|
||||
},
|
||||
progress: reportProgress,
|
||||
},
|
||||
args,
|
||||
{ isCurrent: current, signal: runOptions.signal },
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'analyst_query') {
|
||||
return runAnalystQuery(
|
||||
ensureAnalystEngine(),
|
||||
@@ -1352,10 +1429,31 @@ export function createGevActionRunner({
|
||||
|
||||
throw new Error(`Unknown GEV tool: ${name}`);
|
||||
};
|
||||
// Lifetimes: the runner lives for the page; conversation state — the
|
||||
// Lifetimes: the runner lives for the page (area handles included, so an
|
||||
// outline stays countable across mic sessions); conversation state — the
|
||||
// analyst's follow-up memory and any write still in flight — lives for one
|
||||
// voice session and is dropped by resetConversation().
|
||||
let conversation = 0;
|
||||
// Calls emitted by one assistant response are siblings rather than newer
|
||||
// user intent. The new spatial actions share mutable surfaces (area board,
|
||||
// imagery panel, OSM layer/follow-up memory), so serialize those siblings in
|
||||
// event order just as analyst_query already was. A genuinely newer group or
|
||||
// direct call still invalidates the older attempt.
|
||||
const serializedSiblingActions = new Set([
|
||||
'analyst_query',
|
||||
'resolve_area',
|
||||
'find_imagery',
|
||||
'osm_query',
|
||||
]);
|
||||
let serializedAttempt = 0;
|
||||
let serializedSiblingGroup = null;
|
||||
let serializedSiblingTail = Promise.resolve();
|
||||
// Direct/programmatic callers do not necessarily arrive through
|
||||
// createVoiceSession, so the runner must own the conversation lifetime of
|
||||
// its serialized spatial workflows as well. Reset/dispose abort the real
|
||||
// provider/catalog work, not just the eventual commit, while area handles
|
||||
// remain owned by voiceAreas for the page lifetime.
|
||||
let conversationAbort = new AbortController();
|
||||
let disposed = false;
|
||||
async function runVoiceAction(name, rawArgs = {}, callerOptions = {}) {
|
||||
if (disposed)
|
||||
@@ -1366,56 +1464,113 @@ export function createGevActionRunner({
|
||||
error: 'Voice ended.',
|
||||
};
|
||||
const epoch = conversation;
|
||||
let attempt = null;
|
||||
let releaseSerializedSibling = null;
|
||||
if (serializedSiblingActions.has(name)) {
|
||||
const siblingGroup = callerOptions.authorityGroup || null;
|
||||
if (siblingGroup) {
|
||||
if (siblingGroup !== serializedSiblingGroup) {
|
||||
serializedSiblingGroup = siblingGroup;
|
||||
serializedSiblingTail = Promise.resolve();
|
||||
serializedAttempt++;
|
||||
}
|
||||
attempt = serializedAttempt;
|
||||
const predecessor = serializedSiblingTail;
|
||||
const completion = new Promise((resolve) => {
|
||||
releaseSerializedSibling = resolve;
|
||||
});
|
||||
serializedSiblingTail = predecessor.then(() => completion);
|
||||
await predecessor;
|
||||
} else {
|
||||
serializedSiblingGroup = null;
|
||||
serializedSiblingTail = Promise.resolve();
|
||||
attempt = ++serializedAttempt;
|
||||
}
|
||||
}
|
||||
const runnerSignal = serializedSiblingActions.has(name)
|
||||
? conversationAbort.signal
|
||||
: null;
|
||||
const signal = runnerSignal
|
||||
? callerOptions.signal
|
||||
? AbortSignal.any([runnerSignal, callerOptions.signal])
|
||||
: runnerSignal
|
||||
: callerOptions.signal;
|
||||
const runOptions = {
|
||||
...callerOptions,
|
||||
...(signal ? { signal } : {}),
|
||||
isCurrent: () =>
|
||||
!signal?.aborted &&
|
||||
epoch === conversation &&
|
||||
(attempt === null || attempt === serializedAttempt) &&
|
||||
!disposed &&
|
||||
(typeof callerOptions.isCurrent !== 'function' ||
|
||||
callerOptions.isCurrent()),
|
||||
};
|
||||
const input = rawArgs && typeof rawArgs === 'object' ? rawArgs : {};
|
||||
// Point-and-ask: 'pointer' and referent:n become real targets here, from
|
||||
// the turn's pointer snapshot and the latest numbered result.
|
||||
const deictic = resolveDeicticArgs(name, input, {
|
||||
pointer: deixis?.pointer?.activeSnapshot?.() || null,
|
||||
referents: deixis?.referents || null,
|
||||
imageFrame: deixis?.imageFrame?.() || null,
|
||||
cameraKey: deixis?.cameraKey?.() || null,
|
||||
});
|
||||
if (deictic.error) {
|
||||
return attachVoiceResult(
|
||||
name,
|
||||
{ ok: false, action: name, error: deictic.error },
|
||||
input,
|
||||
builders,
|
||||
);
|
||||
}
|
||||
const referentGeneration = deixis?.referents?.generation?.();
|
||||
let result = await runGevAction(name, deictic.args, runOptions);
|
||||
if (deictic.used && result && typeof result === 'object')
|
||||
result = { ...result, resolvedFrom: deictic.used };
|
||||
const out = attachVoiceResult(name, result, deictic.args, builders);
|
||||
// Only an accepted, still-current result may become "the last list": a
|
||||
// cancelled turn or one finishing after the session ended must not
|
||||
// repopulate a cleared registry.
|
||||
const stillCurrent =
|
||||
!runOptions.signal?.aborted &&
|
||||
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
|
||||
if (stillCurrent)
|
||||
deixis?.referents?.recordResult?.(name, out, {
|
||||
since: referentGeneration,
|
||||
try {
|
||||
const input = rawArgs && typeof rawArgs === 'object' ? rawArgs : {};
|
||||
if (attempt !== null && !runOptions.isCurrent()) {
|
||||
return attachVoiceResult(
|
||||
name,
|
||||
{
|
||||
ok: false,
|
||||
action: name,
|
||||
code: 'CANCELLED',
|
||||
cancelled: true,
|
||||
error: 'This request was superseded.',
|
||||
},
|
||||
input,
|
||||
builders,
|
||||
);
|
||||
}
|
||||
// Point-and-ask: 'pointer' and referent:n become real targets here, from
|
||||
// the turn's pointer snapshot and the latest numbered result.
|
||||
const deictic = resolveDeicticArgs(name, input, {
|
||||
pointer: deixis?.pointer?.activeSnapshot?.() || null,
|
||||
referents: deixis?.referents || null,
|
||||
imageFrame: deixis?.imageFrame?.() || null,
|
||||
cameraKey: deixis?.cameraKey?.() || null,
|
||||
});
|
||||
if (stillCurrent && deictic.used?.source === 'pointer')
|
||||
deixis?.onPointerUsed?.();
|
||||
return out;
|
||||
if (deictic.error) {
|
||||
return attachVoiceResult(
|
||||
name,
|
||||
{ ok: false, action: name, error: deictic.error },
|
||||
input,
|
||||
builders,
|
||||
);
|
||||
}
|
||||
const referentGeneration = deixis?.referents?.generation?.();
|
||||
let result = await runGevAction(name, deictic.args, runOptions);
|
||||
if (deictic.used && result && typeof result === 'object')
|
||||
result = { ...result, resolvedFrom: deictic.used };
|
||||
const out = attachVoiceResult(name, result, deictic.args, builders);
|
||||
// Only an accepted, still-current result may become "the last list": a
|
||||
// cancelled turn or one finishing after the session ended must not
|
||||
// repopulate a cleared registry.
|
||||
const stillCurrent =
|
||||
!runOptions.signal?.aborted &&
|
||||
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
|
||||
if (stillCurrent)
|
||||
deixis?.referents?.recordResult?.(name, out, {
|
||||
since: referentGeneration,
|
||||
});
|
||||
if (stillCurrent && deictic.used?.source === 'pointer')
|
||||
deixis?.onPointerUsed?.();
|
||||
return out;
|
||||
} finally {
|
||||
releaseSerializedSibling?.();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A voice session ended: invalidate every write still in flight, then
|
||||
* forget the analyst's "last answer".
|
||||
* forget the analyst's "last answer". Area handles stay (page lifetime).
|
||||
*/
|
||||
runVoiceAction.resetConversation = () => {
|
||||
conversationAbort.abort();
|
||||
conversationAbort = new AbortController();
|
||||
conversation++;
|
||||
serializedAttempt++;
|
||||
serializedSiblingGroup = null;
|
||||
serializedSiblingTail = Promise.resolve();
|
||||
analystEngine?.reset();
|
||||
};
|
||||
/** Voice was removed: nothing may run or write again. */
|
||||
@@ -1423,6 +1578,7 @@ export function createGevActionRunner({
|
||||
if (disposed) return;
|
||||
runVoiceAction.resetConversation();
|
||||
disposed = true;
|
||||
voiceAreas.dispose();
|
||||
};
|
||||
return runVoiceAction;
|
||||
}
|
||||
@@ -3876,8 +4032,14 @@ async function aircraftProximityWindowForQuery(
|
||||
layerKeys: wanted,
|
||||
coverage,
|
||||
unanswered: [...(result?.unanswered || [])],
|
||||
scopeLabel: answer.scopeLabel,
|
||||
scopeDetail: answer.display.scope,
|
||||
presentation: {
|
||||
scopeLabel: answer.scopeLabel,
|
||||
display: answer.display,
|
||||
coverageScope: coverage.scope,
|
||||
coverageNote: coverage.note,
|
||||
centeredOn: answer.window.centeredOn,
|
||||
window: answer.window,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4766,6 +4928,7 @@ function analystProviders(
|
||||
placeSearch,
|
||||
),
|
||||
isWarming = () => false,
|
||||
resolveAreaScope = null,
|
||||
} = {},
|
||||
) {
|
||||
// Per-layer truncation seen by the last getRecords call.
|
||||
@@ -4820,6 +4983,7 @@ function analystProviders(
|
||||
return note ? note.trim() : null;
|
||||
},
|
||||
resolveRegionRing,
|
||||
...(resolveAreaScope ? { resolveAreaScope } : {}),
|
||||
/**
|
||||
* The active Contacts subject, when there is one — the centre the operator
|
||||
* is reasoning about while Contacts is up. Null whenever Contacts is off,
|
||||
@@ -4855,6 +5019,15 @@ function analystProviders(
|
||||
};
|
||||
}
|
||||
|
||||
/** The camera's view rectangle as [west, south, east, north] degrees, or null. */
|
||||
function cameraViewBox(viewer) {
|
||||
const rect = viewer?.camera?.computeViewRectangle?.();
|
||||
if (!rect) return null;
|
||||
return [rect.west, rect.south, rect.east, rect.north].map((radians) =>
|
||||
Cesium.Math.toDegrees(radians),
|
||||
);
|
||||
}
|
||||
|
||||
/** Identity fields every compact analyst item keeps when present. */
|
||||
const ANALYST_IDENTITY_FIELDS = Object.freeze([
|
||||
'icao24',
|
||||
@@ -4914,6 +5087,9 @@ const ANALYST_REFUSAL_FIELDS = Object.freeze([
|
||||
'layers',
|
||||
'layerStatus',
|
||||
'cancelled',
|
||||
'needsClarification',
|
||||
'candidates',
|
||||
'query',
|
||||
]);
|
||||
|
||||
const VIEWPORT_COVERAGE_NOTE = 'the flights layer loads by viewport';
|
||||
@@ -4962,7 +5138,7 @@ async function runAnalystQuery(
|
||||
args = {},
|
||||
_layerEnabledAt,
|
||||
isCurrent = () => true,
|
||||
signal = undefined,
|
||||
signal = null,
|
||||
) {
|
||||
let entityWindow = null;
|
||||
const result = await analystEngine.query(
|
||||
@@ -4976,8 +5152,8 @@ async function runAnalystQuery(
|
||||
followUp: Boolean(args.followUp),
|
||||
},
|
||||
{
|
||||
isCurrent,
|
||||
signal,
|
||||
isCurrent,
|
||||
beforeCommit: async (candidate) => {
|
||||
const projected = await aircraftProximityWindowForQuery(
|
||||
dataManager,
|
||||
@@ -5006,8 +5182,8 @@ async function runAnalystQuery(
|
||||
code: result.code || null,
|
||||
error: result.error,
|
||||
};
|
||||
// The engine's semantic fields pass through unchanged: a cancellation
|
||||
// stays a cancellation.
|
||||
// The engine's semantic fields pass through unchanged: a clarification
|
||||
// keeps its candidates, a cancellation stays a cancellation.
|
||||
for (const key of ANALYST_REFUSAL_FIELDS)
|
||||
if (result[key] !== undefined) refusal[key] = result[key];
|
||||
if (result.coverage) refusal.coverage = result.coverage;
|
||||
@@ -5080,7 +5256,9 @@ async function runAnalystQuery(
|
||||
...(result.partial
|
||||
? { partial: true, unanswered: result.unanswered || [] }
|
||||
: {}),
|
||||
...(result.areaId ? { areaId: result.areaId } : {}),
|
||||
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
|
||||
...(result.window ? { window: result.window } : {}),
|
||||
// Every count names its scope in words; a bare number is what made two
|
||||
// honest answers look like a contradiction.
|
||||
scopeLabel: result.scopeLabel,
|
||||
|
||||
Reference in new issue
Block a user