mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(hud): lead with the state tag, keep feedState in get_hud_caption
Review fixes for the HUD summary language change: - The translated feed-state tag now leads the one-line summary and the fallback line, so the readout's ellipsis cannot hide it. - get_hud_caption checks the reply's feedState, returns it and appends the state tag; a reply naming another state keeps only the place. - The model gets the layer names the user sees (localizeLayerName). - Japanese and Chinese summaries are capped at 20 characters; pt, zh-CN and zh-Hant get explicit language names. - A partial view is narrated as incomplete coverage, not as not live. - provenanceTag and hudSummaryMatchesProvenance move to layerSnapshot.js so the tools package can use them without browser globals. - docs/CURRENT-STATE.md, changelog and comments updated; redundant tests trimmed.
This commit is contained in:
1 parent
54750a01e5
commit
f967218a04
10 files changed
+232
-80
No files matched your search
@@ -1,5 +1,3 @@
|
||||
- Write the AI HUD summary in the interface language. The model now returns
|
||||
the feed state as its own field, and the HUD checks it against its own and
|
||||
adds its translated state tag, so a stale or partial view is never shown as
|
||||
live. A partial layer no longer reads as off or nominal in the HUD, voice
|
||||
and analyst provenance (German, @gekh, #1488).
|
||||
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||
translated feed-state warning. A partial layer no longer reads as off or
|
||||
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||
@@ -5255,8 +5255,11 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
### AI HUD Summary (June 2026)
|
||||
|
||||
- HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning).
|
||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates.
|
||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- Input is the live basemap label context (place/street/nearby-place labels + enabled layers, the layer names as the user sees them) and `feedProvenance`; the model is instructed not to infer from coordinates.
|
||||
- Language (October 2026): the HUD sends `locale`, which is part of the dedupe signature. The proxy keeps a shipped locale (English otherwise) and names the language in the instructions and at the start of the input; Japanese and Chinese get a 20-character phrase instead of five words.
|
||||
- The reply is structured (`text.format` JSON schema): `{summary, feedState}`, `feedState` being the snapshot state or `none`. For a view that is not nominal, a missing or different `feedState` falls back to the deterministic line. An accepted summary is shown after the HUD's own translated state tag (`STALE LIVE FLIGHTS | …`), which leads so the one-line readout never clips it.
|
||||
- Output is sanitized to five words (20 characters for Japanese and Chinese); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
||||
- `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer no longer reads as off and partial plus nominal reads partial (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live.
|
||||
- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable.
|
||||
|
||||
### Place-search providers
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
HUD_SUMMARY_FEED_STATES,
|
||||
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
hudSummaryInput,
|
||||
hudSummaryInstructions,
|
||||
@@ -21,7 +22,7 @@ function extractOpenAiResponseText(data) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** The structured reply, or nulls when the model did not follow the format. */
|
||||
/** Parse the summary and normalize the canonical feed state. */
|
||||
function parseHudSummaryReply(text) {
|
||||
let reply = null;
|
||||
try {
|
||||
@@ -39,13 +40,22 @@ function parseHudSummaryReply(text) {
|
||||
}
|
||||
|
||||
function toFiveWordHudSummary(value) {
|
||||
return String(value || '')
|
||||
const words = String(value || '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.join(' ');
|
||||
// Japanese and Chinese have no spaces to count words by: cap characters.
|
||||
if (!/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(words))
|
||||
return words;
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
return [...graphemes.segment(words)]
|
||||
.slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS)
|
||||
.map(({ segment }) => segment)
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
|
||||
@@ -208,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
|
||||
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
||||
});
|
||||
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
||||
if (overall === 'partial')
|
||||
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
|
||||
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
||||
}
|
||||
|
||||
@@ -254,3 +256,35 @@ export function viewStateLayerRow(layer, options) {
|
||||
lastUpdate: snap.lastUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed state and up to two layer names of a provenance envelope that is
|
||||
* not nominal, or null. Values stay English (`state` is a feed-state id,
|
||||
* `names` are `layer.name`); the HUD translates them for display.
|
||||
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
export function provenanceTag(envelope) {
|
||||
const overall = envelope?.overall;
|
||||
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
||||
const names = (envelope.layers || [])
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||
.slice(0, 2);
|
||||
return { state: overall, names };
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a matching feedState for warning states: a view that is not
|
||||
* nominal falls back on a missing or different state.
|
||||
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
||||
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hudSummaryMatchesProvenance(response, provenance) {
|
||||
const state = provenance?.overall;
|
||||
if (!state || state === 'nominal' || state === 'off') return true;
|
||||
return response?.feedState === state;
|
||||
}
|
||||
@@ -132,6 +132,9 @@ test('partial is a feed-state of its own: above nominal, below every fault', ()
|
||||
assert.equal(overall([partial, stale]), 'stale');
|
||||
assert.equal(overall([stale, partial]), 'stale');
|
||||
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
|
||||
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
|
||||
assert.match(note, /coverage is incomplete/);
|
||||
assert.doesNotMatch(note, /do not describe this as live/);
|
||||
assert.equal(
|
||||
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
|
||||
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
|
||||
|
||||
+12
-10
@@ -782,7 +782,8 @@ export class IntelHUD {
|
||||
zone: localTag,
|
||||
},
|
||||
);
|
||||
return provenance ? `${line} | ${provenance}` : line;
|
||||
// The feed state leads: the one-line readout clips its end.
|
||||
return provenance ? `${provenance} | ${line}` : line;
|
||||
}
|
||||
|
||||
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||
@@ -894,11 +895,12 @@ export class IntelHUD {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (hudSummaryMatchesProvenance(data, context.feedProvenance)) {
|
||||
// The warning is the HUD's own translated tag, not the model's words.
|
||||
// The warning is the HUD's own translated tag, not the model's
|
||||
// words, and it leads so the one-line readout never clips it.
|
||||
const tag = localizedProvenanceTag(
|
||||
provenanceTag(context.feedProvenance),
|
||||
);
|
||||
this._lastSummaryText = tag ? `${data.summary} | ${tag}` : data.summary;
|
||||
this._lastSummaryText = tag ? `${tag} | ${data.summary}` : data.summary;
|
||||
}
|
||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||
} catch (error) {
|
||||
@@ -959,18 +961,18 @@ export class IntelHUD {
|
||||
// A slower build for a view the camera has left must not replace the
|
||||
// newer view's lookup.
|
||||
if (revision === this._summaryRevision) this._placeLabels = labels;
|
||||
const enabledLayers =
|
||||
this._dataManager
|
||||
?.getAll?.()
|
||||
?.filter((layer) => layer.enabled)
|
||||
.map((layer) => layer.name) || [];
|
||||
const layerContext = hudSummaryLayerContext(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
);
|
||||
return {
|
||||
locale: getLocale(),
|
||||
placeLabels: labels.placeLabels,
|
||||
streetLabels: labels.streetLabels,
|
||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||
enabledLayerLabels: enabledLayers,
|
||||
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
||||
...layerContext,
|
||||
// The names the user sees; feedProvenance keeps the English ids.
|
||||
enabledLayerLabels:
|
||||
layerContext.enabledLayerLabels.map(localizeLayerName),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+27
-36
@@ -2,9 +2,15 @@ import {
|
||||
SNAPSHOT_FEED_STATES,
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshots,
|
||||
provenanceTag,
|
||||
} from './data/layerSnapshot.js';
|
||||
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js';
|
||||
|
||||
export {
|
||||
hudSummaryMatchesProvenance,
|
||||
provenanceTag,
|
||||
} from './data/layerSnapshot.js';
|
||||
|
||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||
|
||||
/**
|
||||
@@ -54,11 +60,14 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
|
||||
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'Set feedState to feedProvenance.overall exactly as given, or none when it is null.',
|
||||
'If feedProvenance.overall is not nominal, the summary must not present the view as live or healthy.',
|
||||
'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.',
|
||||
'Do not infer from coordinates or invent a place or feed state.',
|
||||
'The summary is exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||
].join(' ');
|
||||
|
||||
/** Longest summary, in characters, for a script written without spaces. */
|
||||
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
|
||||
|
||||
/** feedState values the model may return; none stands for a null overall. */
|
||||
export const HUD_SUMMARY_FEED_STATES = Object.freeze([
|
||||
...SNAPSHOT_FEED_STATES,
|
||||
@@ -87,7 +96,19 @@ export function hudSummaryLocale(value) {
|
||||
}
|
||||
|
||||
/** The language a request's summary is written in, named in English. */
|
||||
// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the
|
||||
// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script.
|
||||
const SUMMARY_LANGUAGES = Object.freeze({
|
||||
pt: 'Brazilian Portuguese',
|
||||
'zh-CN': 'Simplified Chinese',
|
||||
'zh-Hant': 'Chinese in Traditional characters (mainland wording)',
|
||||
});
|
||||
|
||||
/** Scripts written without spaces, where five words do not apply. */
|
||||
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
|
||||
|
||||
function summaryLanguage(tag) {
|
||||
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
|
||||
} catch {
|
||||
@@ -96,8 +117,7 @@ function summaryLanguage(tag) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The instructions for one request: English as before, otherwise one more
|
||||
* sentence naming the language the five words are written in.
|
||||
* Build the summary instructions for the requested locale.
|
||||
* @param {unknown} locale The `locale` the HUD sent.
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -105,7 +125,10 @@ export function hudSummaryInstructions(locale) {
|
||||
const tag = hudSummaryLocale(locale);
|
||||
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
|
||||
const language = summaryLanguage(tag);
|
||||
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English, giving places and layers their usual ${language} names; feedState stays the English value.`;
|
||||
const length = UNSPACED_LOCALES.test(tag)
|
||||
? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.`
|
||||
: '';
|
||||
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English, giving places their usual ${language} names and layers the names in enabledLayerLabels; feedState stays the English value.${length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,25 +171,6 @@ export function hudSummaryLayerContext(layers = [], options) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed state and up to two layer names of a provenance envelope that is
|
||||
* not nominal, or null. Values stay English (`state` is a feed-state id,
|
||||
* `names` are `layer.name`); the HUD translates them for display.
|
||||
* @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
export function provenanceTag(envelope) {
|
||||
const overall = envelope?.overall;
|
||||
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
||||
const names = (envelope.layers || [])
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||
.slice(0, 2);
|
||||
return { state: overall, names };
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed-state tag for the HUD telemetry suffix when an enabled feed is not
|
||||
* nominal.
|
||||
@@ -178,16 +182,3 @@ export function hudTelemetryProvenance(layers = [], options) {
|
||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an AI summary only when its canonical feedState is the HUD's own: a
|
||||
* view that is not nominal falls back on a missing or different state.
|
||||
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
||||
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hudSummaryMatchesProvenance(response, provenance) {
|
||||
const state = provenance?.overall;
|
||||
if (!state || state === 'nominal' || state === 'off') return true;
|
||||
return response?.feedState === state;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Readable } from 'node:stream';
|
||||
import {
|
||||
HUD_SUMMARY_FEED_STATES,
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
hudSummaryInput,
|
||||
@@ -163,9 +164,6 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = previousKey;
|
||||
if (previousLimit === undefined)
|
||||
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -219,7 +217,14 @@ test('HUD summary instructions ask for the canonical feedState as its own field'
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/Set feedState to feedProvenance\.overall exactly/,
|
||||
);
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /must not present the view as live/);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/do not present the view as live or healthy/,
|
||||
);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/partial, do not present the coverage as complete/,
|
||||
);
|
||||
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
|
||||
'summary',
|
||||
'feedState',
|
||||
@@ -251,6 +256,32 @@ test('the HUD sends its UI locale inside the context it signs for dedupe', () =>
|
||||
const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8');
|
||||
assert.match(hud, /locale: getLocale\(\),\s+placeLabels:/);
|
||||
assert.match(hud, /const signature = JSON\.stringify\(context\);/);
|
||||
// The model gets the layer names the user sees.
|
||||
assert.match(
|
||||
hud,
|
||||
/enabledLayerLabels:\s+layerContext\.enabledLayerLabels\.map\(localizeLayerName\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the state tag leads the one-line summary so clipping cannot hide it', () => {
|
||||
const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8');
|
||||
assert.match(hud, /tag \? `\$\{tag\} \| \$\{data\.summary\}`/);
|
||||
assert.match(hud, /provenance \? `\$\{provenance\} \| \$\{line\}`/);
|
||||
});
|
||||
|
||||
test('shipped catalogs get their exact language, and CJK a character limit', () => {
|
||||
assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
|
||||
assert.match(
|
||||
hudSummaryInstructions('zh-Hant'),
|
||||
/in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/,
|
||||
);
|
||||
assert.match(hudSummaryInstructions('zh-CN'), /in Simplified Chinese/);
|
||||
assert.match(
|
||||
hudSummaryInstructions('ja'),
|
||||
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
|
||||
);
|
||||
assert.doesNotMatch(hudSummaryInstructions('es'), /at most/);
|
||||
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
|
||||
});
|
||||
|
||||
let hudSummaryClient = 0;
|
||||
@@ -259,10 +290,8 @@ async function postHudSummary(context, reply) {
|
||||
// A fresh address per request: the per-IP limiter outlives a single test.
|
||||
hudSummaryClient += 1;
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
const previousLimit = process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
const previousFetch = globalThis.fetch;
|
||||
process.env.OPENAI_API_KEY = 'test-key';
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '0';
|
||||
let sent = null;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
sent = JSON.parse(init.body);
|
||||
@@ -301,9 +330,6 @@ async function postHudSummary(context, reply) {
|
||||
globalThis.fetch = previousFetch;
|
||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = previousKey;
|
||||
if (previousLimit === undefined)
|
||||
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +358,18 @@ test('an es request reaches the model in Spanish and returns the canonical state
|
||||
});
|
||||
});
|
||||
|
||||
test('a Japanese summary is capped in characters, not words', async () => {
|
||||
const long = 'テキサス州議会議事堂周辺の航空機データは古くなっています';
|
||||
const { response } = await postHudSummary(
|
||||
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
|
||||
JSON.stringify({ summary: long, feedState: 'stale' }),
|
||||
);
|
||||
assert.equal(
|
||||
response.body.summary,
|
||||
[...long].slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS).join(''),
|
||||
);
|
||||
});
|
||||
|
||||
test('a reply that skips the format carries no summary and no state', async () => {
|
||||
const { response } = await postHudSummary(
|
||||
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
|
||||
@@ -346,17 +384,6 @@ test('a reply that skips the format carries no summary and no state', async () =
|
||||
assert.equal(unknown.response.body.feedState, null);
|
||||
});
|
||||
|
||||
test('the HUD proxy builds its instructions from the shared contract', () => {
|
||||
const local = readFileSync(
|
||||
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(local, /hudSummaryInstructions\(context\?\.locale\)/);
|
||||
assert.match(local, /hudSummaryInput\(context\)/);
|
||||
assert.match(local, /HUD_SUMMARY_RESPONSE_FORMAT/);
|
||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||
});
|
||||
|
||||
test("a localized summary is shown only with the HUD's own canonical state", () => {
|
||||
const stale = { overall: 'stale' };
|
||||
assert.equal(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/** Composite queries that combine other tools' answers for one area. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js';
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
hudSummaryMatchesProvenance,
|
||||
provenanceTag,
|
||||
} from '../../data/layerSnapshot.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
@@ -232,6 +236,7 @@ export const getHudCaption = defineTool({
|
||||
feedState: sectionFeedState(brief[key]),
|
||||
}),
|
||||
);
|
||||
const feedProvenance = feedProvenanceEnvelope(layers);
|
||||
const response = await context.services.summary.summarize(
|
||||
{
|
||||
placeLabels: [area.label],
|
||||
@@ -244,16 +249,29 @@ export const getHudCaption = defineTool({
|
||||
name,
|
||||
feedState,
|
||||
})),
|
||||
feedProvenance: feedProvenanceEnvelope(layers),
|
||||
feedProvenance,
|
||||
},
|
||||
{ signal: context.signal },
|
||||
);
|
||||
const caption = response?.data?.summary;
|
||||
if (!response?.ok || typeof caption !== 'string' || !caption)
|
||||
const words = response?.data?.summary;
|
||||
if (!response?.ok || typeof words !== 'string' || !words)
|
||||
throw new ToolError('unavailable', 'The caption service did not answer');
|
||||
// As on the HUD: the warning is the app's own state tag, and a reply that
|
||||
// names another state than the app's keeps only the place.
|
||||
const tag = provenanceTag(feedProvenance);
|
||||
const text = hudSummaryMatchesProvenance(response.data, feedProvenance)
|
||||
? words
|
||||
: area.label;
|
||||
const caption = tag
|
||||
? `${text} | ${[tag.state, ...tag.names].join(' ').toUpperCase()}`
|
||||
: text;
|
||||
return {
|
||||
summary: caption,
|
||||
data: { area: area.label, caption },
|
||||
data: {
|
||||
area: area.label,
|
||||
caption,
|
||||
feedState: feedProvenance.overall,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
|
||||
// The proxy returns the feed state as its own field ({ summary, feedState });
|
||||
// the five words no longer carry STALE, so get_hud_caption adds the app's own
|
||||
// state tag and keeps only the place when the reply names another state.
|
||||
async function staleCaption(reply) {
|
||||
const conditions = {
|
||||
observedAt: '2026-10-01T19:15:00.000Z',
|
||||
temperatureC: 26.3,
|
||||
apparentTemperatureC: 32,
|
||||
precipitationMm: 0,
|
||||
cloudCoverPct: 100,
|
||||
windKph: 5.4,
|
||||
windDirectionDeg: 318,
|
||||
visibilityM: 15400,
|
||||
weatherCode: 3,
|
||||
};
|
||||
const places = {
|
||||
resolve: async (name) =>
|
||||
name === 'Austin'
|
||||
? {
|
||||
name: 'Austin, Texas',
|
||||
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const staleWeather = {
|
||||
getConditions: async () => ({ status: 'stale', weather: conditions }),
|
||||
};
|
||||
let sent = null;
|
||||
const summary = {
|
||||
async summarize(context) {
|
||||
sent = context;
|
||||
return { ok: true, status: 200, data: { error: null, ...reply } };
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { weather: staleWeather, summary, places },
|
||||
});
|
||||
const result = await catalog.call('get_hud_caption', {
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(sent.feedProvenance.overall, 'stale');
|
||||
return result.data;
|
||||
}
|
||||
|
||||
test('get_hud_caption keeps the canonical feed state of a stale view', async () => {
|
||||
const data = await staleCaption({
|
||||
summary: 'Austin weather across the area',
|
||||
feedState: 'stale',
|
||||
});
|
||||
assert.equal(data.feedState, 'stale');
|
||||
assert.equal(data.caption, 'Austin weather across the area | STALE WEATHER');
|
||||
});
|
||||
|
||||
test('get_hud_caption drops words whose feed state is not the app one', async () => {
|
||||
const data = await staleCaption({
|
||||
summary: 'Austin live weather looks fine',
|
||||
feedState: 'nominal',
|
||||
});
|
||||
assert.equal(data.feedState, 'stale');
|
||||
assert.equal(data.caption, 'Austin, Texas | STALE WEATHER');
|
||||
});
|
||||
Reference in new issue
Block a user