mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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
+157
-38
@@ -2,12 +2,13 @@
|
||||
* 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).
|
||||
*
|
||||
* What the model receives stays English (`analystHeadline` feeds the spoken
|
||||
* answer); the card shows the same answer in the active language.
|
||||
@@ -16,7 +17,7 @@
|
||||
* @module voice/resultDisplay
|
||||
*/
|
||||
|
||||
import { voiceLayer } from './layerManifest.js';
|
||||
import { analystNounFor, spokenLabel } from './speech.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatNumber,
|
||||
@@ -31,17 +32,6 @@ import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { translateStatusLine } from '../ui/statusText.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.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',
|
||||
});
|
||||
|
||||
/** Split "a; b" caveat text into separate notes. */
|
||||
function notesOf(...values) {
|
||||
return [
|
||||
@@ -59,13 +49,6 @@ function notesOf(...values) {
|
||||
];
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/** The count/scope headline shared by the analyst card and spoken answer. */
|
||||
const ANALYST_HEADLINE_MAX = 64;
|
||||
// The card's headline wraps, and a translation runs longer than the English:
|
||||
@@ -73,6 +56,14 @@ const ANALYST_HEADLINE_MAX = 64;
|
||||
// English keeps the spoken bound, so its card title still equals `say`.
|
||||
const SHOWN_HEADLINE_MAX = 120;
|
||||
|
||||
/** Area levels in words. */
|
||||
const LEVEL_WORDS = Object.freeze({
|
||||
admin1: msg('state or province'),
|
||||
admin2: msg('county or district'),
|
||||
district: msg('neighbourhood'),
|
||||
natural: msg('natural region'),
|
||||
});
|
||||
|
||||
function boundedHeadline(value, max = ANALYST_HEADLINE_MAX) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -81,15 +72,30 @@ function boundedHeadline(value, max = ANALYST_HEADLINE_MAX) {
|
||||
return `${text.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
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 || ''}`,
|
||||
);
|
||||
/** 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]
|
||||
? t(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}` : ''}`;
|
||||
});
|
||||
}
|
||||
|
||||
function clarification(result, subject) {
|
||||
if (!result?.needsClarification) return null;
|
||||
return {
|
||||
display: {
|
||||
title: subject
|
||||
? t('Which {place}?', { place: spokenLabel(subject, 40) })
|
||||
: t('Which place?'),
|
||||
lines: candidateLines(result.candidates),
|
||||
},
|
||||
referents: [],
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- the analyst card in the active language ---------------- */
|
||||
@@ -393,8 +399,9 @@ function shownFeedState(state) {
|
||||
return key ? t(key) : state;
|
||||
}
|
||||
|
||||
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 : [];
|
||||
const provenance = result.feedProvenance || result.coverage?.feedProvenance;
|
||||
@@ -444,16 +451,128 @@ 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: `${result.name ? spokenLabel(result.name, 48) : t('Area', { context: 'voice area' })}${result.country ? `, ${spokenLabel(result.country, 24)}` : ''}`,
|
||||
lines: [
|
||||
Number.isFinite(km2) && km2 > 0 ? `${formatNumber(km2)} km²` : null,
|
||||
display.basis,
|
||||
display.parts,
|
||||
display.drawnParts,
|
||||
result.notDrawn
|
||||
? t('Not drawn: {parts}', { parts: result.notDrawn })
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
chips: [
|
||||
...(result.approximate ? [{ label: t('approximate') }] : []),
|
||||
...(result.partial ? [{ label: shownFeedState('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
|
||||
? t('{pct}% cloud', { pct: formatNumber(shown.cloudPct) })
|
||||
: null,
|
||||
result.area
|
||||
? t('Over {area}', { area: spokenLabel(result.area, 48) })
|
||||
: null,
|
||||
tn(
|
||||
Number(result.total) || 0,
|
||||
'{count} of {total} acquisitions match',
|
||||
'{count} of {total} acquisitions match',
|
||||
{
|
||||
count: formatNumber(Number(result.count) || 0),
|
||||
total: formatNumber(Number(result.total) || 0),
|
||||
},
|
||||
),
|
||||
capitalized(display.window),
|
||||
]
|
||||
: [
|
||||
tn(
|
||||
Number(result.total) || 0,
|
||||
'{n} acquisitions searched',
|
||||
'{n} acquisitions searched',
|
||||
{ n: formatNumber(Number(result.total) || 0) },
|
||||
),
|
||||
capitalized(display.window),
|
||||
];
|
||||
return {
|
||||
display: {
|
||||
title: shown
|
||||
? `${spokenLabel(shown.source, 32)} · ${shown.date}`
|
||||
: t('No matching imagery'),
|
||||
lines: lines.filter(Boolean),
|
||||
chips:
|
||||
shown && !shown.onMap ? [{ label: shownFeedState('loading') }] : [],
|
||||
notes: notesOf(display.caveat),
|
||||
},
|
||||
referents: [],
|
||||
};
|
||||
}
|
||||
|
||||
function osmCard(result) {
|
||||
if (!result?.ok) return null;
|
||||
const display = result.display || {};
|
||||
const count = Number(result.count);
|
||||
// The kind is the preset's English noun, the model's data.
|
||||
const kind = spokenLabel(result.kind || 'places', 32);
|
||||
const values = { count: formatNumber(count), kind };
|
||||
const title = !Number.isFinite(count)
|
||||
? t('Unknown number of {kind}', { kind })
|
||||
: result.countScope === 'bounding-box'
|
||||
? t('About {count} {kind}', values)
|
||||
: result.complete === false
|
||||
? t('At least {count} {kind}', values)
|
||||
: t('{count} {kind}', values);
|
||||
return {
|
||||
display: {
|
||||
title,
|
||||
lines: [
|
||||
result.area
|
||||
? t('In {area}', { area: 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