Files
gods-eye-view/src/voice/deixis.js
T
German Khokhlov f3996da0af Street Level: a landed link, a replaced photo and a retired lookup keep their state
- A link's photo step compares with the navigation token its own restore
  flight owned, so a navigation after the flight landed (while the layers
  still load) keeps the camera.
- A request that switches the source keeps the open photo up until it is
  replaced (holdViewer), so EXPAND stays across sources.
- A lookup given up while it looked leaves the open photo and the panel as
  they were, also when it found nothing or failed.
- A failed replacement shows its error inside the expanded viewer.
- A replacing lookup stops a playing Mapillary sequence before the found
  photo opens.
- The action refuses coordinates outside the globe, keeps a name given with
  coordinates as its label, and fly: false keeps the camera only for
  coordinates (its test-only entity and query branches are gone).
- Numbered entities carry their label; a view's street-level point is a pair.
- Mapillary's nearest search always asks for 50 m and 100 images.
- Tests: the lifecycle reproductions, PLAY on replacement, out-of-range
  coordinates, half a point, and the named label; three layer tests share
  enabledLayer; the second payload digest is gone.
- Docs: the tool count, the panel freshness rule, armed: false, the photo
  replacement, the browser-gate comment and a shorter changelog fragment.
2026-11-10 23:48:57 +04:00

390 lines
12 KiB
JavaScript

/**
* Resolve deictic tool arguments before an action runs.
*
* The model never copies coordinates or ids for "this", "here" or "the second
* one". It passes a sentinel and the runner fills in the real target:
*
* track_entity {query:'pointer'} | {referent:n}
* fly_to_location {query:'pointer'} | {referent:n}
* get_entity_context {scope:'pointer'} | {referent:n}
* analyst_query {scope:{kind:'pointer', km?}}
* annotate_map annotations[].target / toTarget / points[].target = 'pointer'
* find_imagery {area:'pointer'}
*
* The pointer is the current turn's snapshot (see pointerContext.js); the
* referents are the latest result's numbered list (see referents.js).
* Resolution is pure: it returns rewritten arguments, what they resolved
* from, or a plain error the model can say.
*/
import { finiteOrNull } from './pointerContext.js';
const POINTER_SENTINEL = 'pointer';
const TRACKABLE_LAYERS = new Set([
'flights',
'military',
'ais-live-vessels',
'satellites',
]);
const DEFAULT_POINTER_RADIUS_KM = 50;
const NO_POINTER =
'Nothing is under the pointer. Ask the user to point at it or name it.';
function isPointer(value) {
return (
typeof value === 'string' && value.trim().toLowerCase() === POINTER_SENTINEL
);
}
function hasPosition(target) {
return (
finiteOrNull(target?.lat) !== null && finiteOrNull(target?.lon) !== null
);
}
function referentIndex(args) {
if (args?.referent === undefined || args?.referent === null) return null;
const n = Number(args.referent);
return Number.isInteger(n) ? n : NaN;
}
function withoutKeys(args, keys) {
const out = { ...args };
for (const key of keys) delete out[key];
return out;
}
/** The pointer as a target record, or null. */
function pointerTarget(pointer) {
if (!pointer?.fresh) return null;
if (pointer.entity) {
return {
...pointer.entity,
lat: pointer.lat,
lon: pointer.lon,
};
}
if (hasPosition(pointer))
return { lat: pointer.lat, lon: pointer.lon, label: null };
return null;
}
function lookupReferent(referents, n) {
if (Number.isNaN(n)) return { error: 'referent must be a whole number.' };
const ref = referents?.get?.(n) || null;
if (!ref)
return {
error: `There is no item ${n} in the last list.`,
};
return { ref };
}
function resolveTrack(args, { pointer, referents }) {
const n = referentIndex(args);
let target = null;
let source = null;
if (n !== null) {
const { ref, error } = lookupReferent(referents, n);
if (error) return { error };
target = ref;
source = 'referent';
} else if (isPointer(args.query)) {
target = pointerTarget(pointer);
if (!target) return { error: NO_POINTER };
if (!target.layerId)
return {
error:
'The pointer is on the ground, not on an aircraft, ship or satellite.',
};
source = 'pointer';
} else {
return null;
}
if (!TRACKABLE_LAYERS.has(target.layerId))
return {
error: `${target.label || 'That'} cannot be followed; only aircraft, ships and satellites can.`,
};
return {
args: {
...withoutKeys(args, ['referent']),
query: String(target.id || target.label),
layerId: target.layerId,
},
used: { source, label: target.label || null, layerId: target.layerId },
};
}
function resolveFly(args, { pointer, referents }) {
const n = referentIndex(args);
let target = null;
let source = null;
if (n !== null) {
const { ref, error } = lookupReferent(referents, n);
if (error) return { error };
target = ref;
source = 'referent';
} else if (isPointer(args.query)) {
target = pointerTarget(pointer);
if (!target) return { error: NO_POINTER };
source = 'pointer';
} else {
return null;
}
const rest = withoutKeys(args, ['referent', 'query', 'locationId']);
const used = { source, label: target.label || null };
if (target.layerId && target.id && source === 'referent') {
// Contacts move: locate them when the flight starts, not from a list.
return {
args: {
...rest,
entity: {
layerId: target.layerId,
id: target.id,
// The answer names it, not its id ("near cam-9").
...(target.label ? { label: target.label } : {}),
...(hasPosition(target) ? { lat: target.lat, lon: target.lon } : {}),
},
},
used,
};
}
if (hasPosition(target))
return {
args: { ...rest, latitude: target.lat, longitude: target.lon },
used,
};
if (target.label) return { args: { ...rest, query: target.label }, used };
return { error: 'That item has no position to fly to.' };
}
function resolveContext(args, { pointer, referents }) {
const n = referentIndex(args);
if (n !== null) {
const { ref, error } = lookupReferent(referents, n);
if (error) return { error };
return {
args: {
...withoutKeys(args, ['referent']),
scope: 'target',
target: ref,
},
used: { source: 'referent', label: ref.label || null },
};
}
if (String(args.scope || '').toLowerCase() !== POINTER_SENTINEL) return null;
const target = pointerTarget(pointer);
if (!target) {
// A read-only lookup falls back to the usual scope instead of failing.
return {
args: { ...args, scope: 'auto' },
used: { source: 'pointer', label: null, missing: true },
};
}
return {
args: { ...args, scope: 'target', target },
used: {
source: 'pointer',
label: target.label || null,
},
};
}
function resolveAnalyst(args, { pointer }) {
const scope = args.scope;
if (!scope || String(scope.kind || '').toLowerCase() !== POINTER_SENTINEL)
return null;
const target = pointerTarget(pointer);
if (!target || !hasPosition(target)) return { error: NO_POINTER };
const asked = finiteOrNull(scope.km);
const km =
asked > 0
? asked
: finiteOrNull(pointer.radiusKm) > 0
? pointer.radiusKm
: DEFAULT_POINTER_RADIUS_KM;
const nextScope = withoutKeys(scope, ['name']);
return {
args: {
...args,
scope: {
...nextScope,
kind: 'radius',
center: { lat: target.lat, lon: target.lon },
km,
},
},
used: { source: 'pointer', label: target.label || null, km },
};
}
function resolveAnnotate(args, { pointer }) {
const list = Array.isArray(args.annotations) ? args.annotations : null;
if (!list) return null;
const mentions = list.some(
(spec) =>
isPointer(spec?.target) ||
isPointer(spec?.toTarget) ||
(Array.isArray(spec?.points) &&
spec.points.some((point) => isPointer(point?.target))),
);
if (!mentions) return null;
const target = pointerTarget(pointer);
if (!target || !hasPosition(target)) return { error: NO_POINTER };
const annotations = list.map((spec) => {
if (!spec || typeof spec !== 'object') return spec;
let next = spec;
if (isPointer(spec.target)) {
next = withoutKeys(next, ['target']);
next.latitude = target.lat;
next.longitude = target.lon;
if (!next.label && target.label) next.label = target.label;
}
if (isPointer(spec.toTarget)) {
next = withoutKeys(next, ['toTarget']);
next.toLatitude = target.lat;
next.toLongitude = target.lon;
}
if (Array.isArray(spec.points)) {
next = {
...next,
points: spec.points.map((point) =>
isPointer(point?.target)
? {
...withoutKeys(point, ['target']),
latitude: target.lat,
longitude: target.lon,
}
: point,
),
};
}
return next;
});
return {
args: { ...args, annotations },
used: { source: 'pointer', label: target.label || null },
};
}
function resolveImagery(args, { pointer }) {
if (!isPointer(args.area)) return null;
const target = pointerTarget(pointer);
if (!target || !hasPosition(target)) return { error: NO_POINTER };
return {
args: {
...withoutKeys(args, ['area']),
center: { lat: target.lat, lon: target.lon },
...(target.label ? { centerLabel: target.label } : {}),
},
used: { source: 'pointer', label: target.label || null },
};
}
const SCREEN_PAIRS = [
['screenX', 'screenY'],
['toScreenX', 'toScreenY'],
];
function hasScreenPoint(spec) {
return (
SCREEN_PAIRS.some(
([x, y]) => spec?.[x] !== undefined || spec?.[y] !== undefined,
) ||
(Array.isArray(spec?.points) &&
spec.points.some(
(point) => point?.screenX !== undefined || point?.screenY !== undefined,
))
);
}
/**
* screenX/screenY are normalized against the latest image the model saw, but
* the annotation resolver reads them against the full canvas. When that image
* is the pointer crop, map crop coordinates back to the canvas; when the
* camera moved since, no pixel names anything any more.
*/
function resolveCropPixels(args, { imageFrame, cameraKey }) {
const list = Array.isArray(args.annotations) ? args.annotations : null;
if (!list || imageFrame?.kind !== 'crop' || !list.some(hasScreenPoint))
return null;
const rect = imageFrame.rect;
if (
!rect ||
(imageFrame.cameraKey && cameraKey && imageFrame.cameraKey !== cameraKey)
)
return {
error:
'The view has moved since that image, so its pixels no longer match. Name the place or point at it.',
};
const map = (value, origin, span) => {
const n = finiteOrNull(value);
return n === null ? value : origin + Math.min(1, Math.max(0, n)) * span;
};
const remap = (spec) => {
const next = { ...spec };
for (const [xKey, yKey] of SCREEN_PAIRS) {
if (xKey in next) next[xKey] = map(next[xKey], rect.x, rect.w);
if (yKey in next) next[yKey] = map(next[yKey], rect.y, rect.h);
}
return next;
};
return {
args: {
...args,
annotations: list.map((spec) => {
if (!spec || typeof spec !== 'object') return spec;
const next = remap(spec);
if (Array.isArray(spec.points)) next.points = spec.points.map(remap);
return next;
}),
},
};
}
const RESOLVERS = Object.freeze({
track_entity: resolveTrack,
fly_to_location: resolveFly,
show_street_level: resolveFly,
get_entity_context: resolveContext,
analyst_query: resolveAnalyst,
annotate_map: resolveAnnotate,
find_imagery: resolveImagery,
});
/**
* @param {string} name Tool name.
* @param {object} args Model arguments.
* @param {object} context
* @param {object|null} [context.pointer] Current turn's pointer snapshot.
* @param {{get: Function}|null} [context.referents] Referent registry.
* @param {object|null} [context.imageFrame] What the retained image shows.
* @param {string|null} [context.cameraKey] Current camera pose identity.
* @returns {{args: object, used: object|null, error: string|null}}
*/
export function resolveDeicticArgs(
name,
args,
{
pointer = null,
referents = null,
imageFrame = null,
cameraKey = null,
} = {},
) {
let input = args && typeof args === 'object' ? args : {};
if (name === 'annotate_map') {
const pixels = resolveCropPixels(input, { imageFrame, cameraKey });
if (pixels?.error) return { args: input, used: null, error: pixels.error };
if (pixels) input = pixels.args;
}
const resolver = RESOLVERS[name];
if (!resolver) return { args: input, used: null, error: null };
const resolved = resolver(input, { pointer, referents });
if (!resolved) return { args: input, used: null, error: null };
if (resolved.error) return { args: input, used: null, error: resolved.error };
return { args: resolved.args, used: resolved.used || null, error: null };
}