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

+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');
});