mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(voice): add reusable geometry actions
- turn named places and mapped landmarks into reusable bounded areas - count loaded records inside resolved, drawn and annotated geometry - find recent imagery for an area, the view or the pointer snapshot - keep osm place search behind an operator-configured overpass endpoint - preserve cancellation, ownership and follow-up memory across every action - cover geometry, provider lifecycle, cache isolation and voice routing
This commit is contained in:
1 parent
01979bba1e
commit
b234d7b427
84 files changed
+14262
-1681
No files matched your search
+132
-47
@@ -2,29 +2,19 @@
|
||||
* The one adapter from a tool result to what the voice card shows.
|
||||
*
|
||||
* Most tools carry a card-ready `display` ({title, lines, chips, notes,
|
||||
* sources}) from their speech builder. The analyst instead returns semantic
|
||||
* fields the engine owns (count, complete, scopeLabel, partial, and a
|
||||
* `display` of scope, window and caveat). This module turns those into the
|
||||
* card shape so every result renders the same way, and names the numbered
|
||||
* referents the card lists (the referent registry reads the same answer, so
|
||||
* "the second one" always means the second row on screen).
|
||||
* sources}) from their speech builder. The analyst, area, imagery and OSM
|
||||
* tools instead return semantic fields the engine owns (count, complete,
|
||||
* scopeLabel, needsClarification, candidates, and a `display` of scope,
|
||||
* source, window and caveat). This module turns those into the card shape so
|
||||
* every result renders the same way, and names the numbered referents the
|
||||
* card lists (the referent registry reads the same answer, so "the second
|
||||
* one" always means the second row on screen).
|
||||
*
|
||||
* Pure: no DOM, no Cesium.
|
||||
* @module voice/resultDisplay
|
||||
*/
|
||||
|
||||
import { voiceLayer } from './layerManifest.js';
|
||||
|
||||
const LAYER_NOUNS = Object.freeze({
|
||||
flights: 'aircraft',
|
||||
military: 'military aircraft',
|
||||
'local-adsb': 'aircraft',
|
||||
'ais-live-vessels': 'ships',
|
||||
satellites: 'satellites',
|
||||
'rocket-launches': 'launches',
|
||||
earthquakes: 'earthquakes',
|
||||
'local-firms': 'fires',
|
||||
});
|
||||
import { analystHeadline, spokenLabel } from './speech.js';
|
||||
|
||||
/** Split "a; b" caveat text into separate notes. */
|
||||
function notesOf(...values) {
|
||||
@@ -34,37 +24,39 @@ function notesOf(...values) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function analystNounFor(layerKeys) {
|
||||
const keys = [...new Set(layerKeys.filter(Boolean))];
|
||||
if (keys.length !== 1) return 'results';
|
||||
const key = keys[0];
|
||||
return LAYER_NOUNS[key] || voiceLayer(key)?.aliases?.[0] || 'results';
|
||||
/** Area levels in words. */
|
||||
const LEVEL_WORDS = Object.freeze({
|
||||
admin1: 'state or province',
|
||||
admin2: 'county or district',
|
||||
district: 'neighbourhood',
|
||||
natural: 'natural region',
|
||||
});
|
||||
|
||||
/** Candidate lines for a clarification: "1 · Punjab — state or province, India". */
|
||||
function candidateLines(candidates) {
|
||||
return (Array.isArray(candidates) ? candidates : []).map((candidate, i) => {
|
||||
const level = candidate?.level
|
||||
? LEVEL_WORDS[candidate.level] || candidate.level
|
||||
: null;
|
||||
const detail = [level, candidate?.country].filter(Boolean).join(', ');
|
||||
return `${i + 1} · ${spokenLabel(candidate?.name || 'Unnamed', 40)}${detail ? ` — ${detail}` : ''}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** The count/scope headline shared by the analyst card and spoken answer. */
|
||||
const ANALYST_HEADLINE_MAX = 64;
|
||||
|
||||
function boundedHeadline(value) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (text.length <= ANALYST_HEADLINE_MAX) return text;
|
||||
return `${text.slice(0, ANALYST_HEADLINE_MAX - 1).trimEnd()}…`;
|
||||
function clarification(result, subject) {
|
||||
if (!result?.needsClarification) return null;
|
||||
return {
|
||||
display: {
|
||||
title: `Which ${spokenLabel(subject || 'place', 40)}?`,
|
||||
lines: candidateLines(result.candidates),
|
||||
},
|
||||
referents: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function analystHeadline(result) {
|
||||
const keys = (result?.coverage?.layersQueried || []).map(
|
||||
(layer) => layer.layerKey,
|
||||
);
|
||||
const count = Number(result?.count) || 0;
|
||||
const floor = result?.complete === false ? 'At least ' : '';
|
||||
return boundedHeadline(
|
||||
`${floor}${count.toLocaleString('en-US')} ${analystNounFor(keys)} ${result?.scopeLabel || ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
function analystCard(result) {
|
||||
if (!result?.ok) return null;
|
||||
function analystCard(result, args = {}) {
|
||||
if (!result?.ok)
|
||||
return clarification(result, result?.query || args?.scope?.name);
|
||||
const display = result.display || {};
|
||||
const unanswered = Array.isArray(result.unanswered) ? result.unanswered : [];
|
||||
return {
|
||||
@@ -93,16 +85,109 @@ function analystCard(result) {
|
||||
};
|
||||
}
|
||||
|
||||
function areaCard(result, args = {}) {
|
||||
if (!result?.ok) return clarification(result, result?.query || args.query);
|
||||
const display = result.display || {};
|
||||
const km2 = Number(result.areaKm2);
|
||||
return {
|
||||
display: {
|
||||
title: `${spokenLabel(result.name || 'Area', 48)}${result.country ? `, ${spokenLabel(result.country, 24)}` : ''}`,
|
||||
lines: [
|
||||
Number.isFinite(km2) && km2 > 0
|
||||
? `${km2.toLocaleString('en-US')} km²`
|
||||
: null,
|
||||
display.basis,
|
||||
display.parts,
|
||||
display.drawnParts,
|
||||
result.notDrawn ? `Not drawn: ${result.notDrawn}` : null,
|
||||
].filter(Boolean),
|
||||
chips: [
|
||||
...(result.approximate ? [{ label: 'approximate' }] : []),
|
||||
...(result.partial ? [{ label: 'partial' }] : []),
|
||||
],
|
||||
notes: notesOf(display.caveat),
|
||||
sources: display.source ? [{ label: display.source }] : [],
|
||||
},
|
||||
referents: [],
|
||||
};
|
||||
}
|
||||
|
||||
const capitalized = (text) =>
|
||||
text
|
||||
? `${String(text).charAt(0).toUpperCase()}${String(text).slice(1)}`
|
||||
: null;
|
||||
|
||||
function imageryCard(result) {
|
||||
if (!result?.ok) return null;
|
||||
const display = result.display || {};
|
||||
const shown = result.shown;
|
||||
const lines = shown
|
||||
? [
|
||||
shown.cloudPct !== null && shown.cloudPct !== undefined
|
||||
? `${shown.cloudPct}% cloud`
|
||||
: null,
|
||||
result.area ? `Over ${spokenLabel(result.area, 48)}` : null,
|
||||
`${result.count} of ${result.total} acquisitions match`,
|
||||
capitalized(display.window),
|
||||
]
|
||||
: [
|
||||
`${result.total ?? 0} acquisitions searched`,
|
||||
capitalized(display.window),
|
||||
];
|
||||
return {
|
||||
display: {
|
||||
title: shown
|
||||
? `${spokenLabel(shown.source, 32)} · ${shown.date}`
|
||||
: 'No matching imagery',
|
||||
lines: lines.filter(Boolean),
|
||||
chips: shown && !shown.onMap ? [{ label: 'loading' }] : [],
|
||||
notes: notesOf(display.caveat),
|
||||
},
|
||||
referents: [],
|
||||
};
|
||||
}
|
||||
|
||||
function osmCard(result) {
|
||||
if (!result?.ok) return null;
|
||||
const display = result.display || {};
|
||||
const count = Number(result.count);
|
||||
const counted = Number.isFinite(count)
|
||||
? count.toLocaleString('en-US')
|
||||
: 'Unknown number of';
|
||||
const floor =
|
||||
result.countScope === 'bounding-box'
|
||||
? 'About '
|
||||
: result.complete === false
|
||||
? 'At least '
|
||||
: '';
|
||||
return {
|
||||
display: {
|
||||
title: `${floor}${counted} ${spokenLabel(result.kind || 'places', 32)}`,
|
||||
lines: [
|
||||
result.area ? `In ${spokenLabel(result.area, 48)}` : null,
|
||||
display.merged,
|
||||
].filter(Boolean),
|
||||
notes: notesOf(display.caveat),
|
||||
sources: display.source ? [{ label: display.source }] : [],
|
||||
},
|
||||
referents: Array.isArray(result.referents) ? result.referents : [],
|
||||
resultSet: true,
|
||||
};
|
||||
}
|
||||
|
||||
const ADAPTERS = Object.freeze({
|
||||
analyst_query: analystCard,
|
||||
resolve_area: areaCard,
|
||||
find_imagery: imageryCard,
|
||||
osm_query: osmCard,
|
||||
});
|
||||
|
||||
/**
|
||||
* What the card shows for one result, or null when it shows nothing (a
|
||||
* silent lookup, a refusal, a cancelled call).
|
||||
* silent lookup, a refusal without a question, a cancelled call).
|
||||
* @param {string} name Tool name.
|
||||
* @param {object} result Tool result as the model received it.
|
||||
* @param {object} [args] Tool arguments.
|
||||
* @param {object} [args] Tool arguments (names a clarification's subject).
|
||||
* @returns {{display: object, referents: Array<object>, resultSet?: boolean}|null}
|
||||
* `resultSet` marks a fresh answer set that replaces the numbered list
|
||||
* even when empty.
|
||||
|
||||
Reference in new issue
Block a user