feat(voice): voice core — honest layer reach, spoken summary with on-screen card, narration, point-and-ask

- Voice layer manifest (src/voice/layerManifest.js) generates the layer enums,
  aliases and analyst field hints; a test fails when a catalog layer has no entry.
- analyst_query validates fields, operators, units, values and scopes, counts up
  to 250,000 loaded records per layer, reports complete/partial/unanswered, tells
  LAYER_OFF / NOT_READY / FEED_UNAVAILABLE apart from zero, refuses mismatched
  follow-ups and never lets a superseded query replace follow-up memory. Seven
  more layers expose analyst records.
- Tool results carry a code-built say line and a display card; the voice card in
  the mic control shows captions, plan steps and the result
  (src/voice/resultDisplay.js is the one adapter). Slow tools narrate their
  current step out of band; holding Space while the assistant talks cuts it off.
  Turn spans go to the debug log; caption transcription is metered; the log
  omits what was said unless GEV_VOICE_LOG_CONTENT=1.
- Point and ask: the Space-down pointer snapshot resolves "this", "that one" and
  "around here" through 'pointer' targets, and "the second one" through
  referent:n from the last numbered list.
- Prompt states each policy once (instructions + tools 48.5k -> 46.7k chars).
- Voice benchmark harness (scripts/qa-voice-bench.mjs, scripts/voice-bench/)
  shares its phrase table and grading with scripts/qa-voice-routing.mjs.
This commit is contained in:
Bilawal Sidhu committed 2026-09-28 21:19:23 -05:00
1 parent e7707d9a0f
commit a19897ad15
97 files changed
+14343 -755

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
/**
* 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).
*
* 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',
});
/** Split "a; b" caveat text into separate notes. */
function notesOf(...values) {
return values
.flatMap((value) => String(value || '').split(/;\s*/))
.map((text) => text.trim())
.filter(Boolean);
}
function nounFor(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';
}
function analystCard(result) {
if (!result?.ok) return null;
const keys = (result.coverage?.layersQueried || []).map(
(layer) => layer.layerKey,
);
const count = Number(result.count) || 0;
const floor = result.complete === false ? 'At least ' : '';
const display = result.display || {};
const unanswered = Array.isArray(result.unanswered) ? result.unanswered : [];
return {
display: {
title:
`${floor}${count.toLocaleString('en-US')} ${nounFor(keys)} ${result.scopeLabel || ''}`.trim(),
// The precise scope, unless the title already says exactly that.
lines: [
display.scope !== result.scopeLabel ? display.scope : null,
display.window,
].filter(Boolean),
chips: [
...(result.feedState && result.feedState !== 'nominal'
? [{ label: result.feedState }]
: []),
...(result.partial ? [{ label: 'partial' }] : []),
],
notes: [
...notesOf(display.caveat, result.coverage?.note),
...(unanswered.length
? [`Not answered: ${unanswered.join(', ')}`]
: []),
],
},
referents: Array.isArray(result.items) ? result.items : [],
resultSet: true,
};
}
const ADAPTERS = Object.freeze({
analyst_query: analystCard,
});
/**
* What the card shows for one result, or null when it shows nothing (a
* silent lookup, a refusal, a cancelled call).
* @param {string} name Tool name.
* @param {object} result Tool result as the model received it.
* @param {object} [args] Tool arguments.
* @returns {{display: object, referents: Array<object>, resultSet?: boolean}|null}
* `resultSet` marks a fresh answer set that replaces the numbered list
* even when empty.
*/
export function presentResult(name, result, args = {}) {
if (!result || typeof result !== 'object' || result.cancelled) return null;
if (result.schedule === 'silent') return null;
const adapter = ADAPTERS[name];
if (adapter) {
try {
const card = adapter(result, args || {});
if (card?.display?.title) return card;
} catch {
/* Presentation never breaks a result. */
}
return null;
}
if (!result.display?.title) return null;
return {
display: result.display,
referents: Array.isArray(result.referents) ? result.referents : [],
};
}