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:
German Khokhlov committed 2026-10-30 13:06:53 +04:00
1 parent 54750a01e5
commit f967218a04
10 files changed
+232 -80

No files matched your search

+3 -5
View File
@@ -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).
+5 -2
View File
@@ -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
+12 -2
View File
@@ -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) {
+34
View File
@@ -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;
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+47 -20
View File
@@ -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(
+23 -5
View File
@@ -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');
});