fix(hud): keep English out of localized summaries, cap ja/zh by characters only

Second follow-up review fixes:

- Japanese and Chinese summaries are capped at 20 characters only, without
  the five-token cut first, and the cap steps back to a space instead of
  splitting a Latin word.
- The HUD request sends localized layer names in feedProvenance too and
  leaves the English narration note out outside English. A non-English reply
  that still echoes a feed-state id or a translated English layer name falls
  back to the deterministic line.
- A camera move during typing shows the deterministic line, not a fragment.
- A view with no place labels and no enabled layer does not ask the model.
- Behaviour tests on IntelHUD replace the regex checks of hud.js; docs and
  the instruction wording updated.
This commit is contained in:
German Khokhlov committed 2026-10-30 16:28:56 +04:00
1 parent 7b223d7d7b
commit 7713fb8ae8
8 files changed
+328 -60

No files matched your search

+52 -9
View File
@@ -27,6 +27,7 @@ import {
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hudSummaryHasEnglish,
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenance,
@@ -237,12 +238,17 @@ export class IntelHUD {
// the camera has moved to.
this._onCameraMoveStart = () => {
this._markSummaryDirty();
if (this._summaryRequest || this._summaryTypingInterval) {
const typing = Boolean(this._summaryTypingInterval);
if (this._summaryRequest || typing) {
// Cancelled requests and partially typed replies are not reusable answers.
this._lastSummarySignature = null;
}
this._summaryRequest?.abort();
this._stopSummaryTyping();
// A half-typed reply would stay until moveEnd, which a camera that
// keeps moving may not send for a long time: show the plain line.
if (typing && this._latestMetrics)
this._setSummaryText(this._composeSummary(), false);
};
this._onCameraMoveEnd = () => {
this._markSummaryDirty();
@@ -856,6 +862,19 @@ export class IntelHUD {
return;
}
if (revision !== this._summaryRevision) return;
// Nothing to describe (no place labels, no enabled layer): the model only
// answers about the empty input, so the plain line is shown instead.
const described = [
context.placeLabels,
context.streetLabels,
context.nearbyPlaceLabels,
context.enabledLayerLabels,
].some((list) => list?.length);
if (!described) {
this._summaryDirty = false;
this._setSummaryText(fallbackText, animate);
return;
}
const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false;
@@ -892,7 +911,14 @@ export class IntelHUD {
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
if (hudSummaryMatchesProvenance(data, context.feedProvenance)) {
if (
hudSummaryMatchesProvenance(data, context.feedProvenance) &&
!hudSummaryHasEnglish(
data.summary,
context.locale,
context.translatedLayerNames,
)
) {
// The warning is the HUD's own translated state, not the model's words.
this._lastSummaryText = withProvenance(
data.summary,
@@ -958,23 +984,40 @@ 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 locale = getLocale();
const layerContext = hudSummaryLayerContext(
this._dataManager?.getAll?.() || [],
);
return {
locale: getLocale(),
const named = (layer) => ({
...layer,
name: localizeLayerName(layer.name),
});
const { feedProvenance } = layerContext;
const context = {
locale,
placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels,
...layerContext,
// The names the user sees; feedProvenance keeps the English ids.
// The model sees only the layer names the user sees. The English note
// goes too: a small model copies its words. `overall` stays the id.
enabledLayerLabels:
layerContext.enabledLayerLabels.map(localizeLayerName),
enabledLayers: layerContext.enabledLayers.map((layer) => ({
...layer,
name: localizeLayerName(layer.name),
})),
enabledLayers: layerContext.enabledLayers.map(named),
feedProvenance: {
...feedProvenance,
layers: feedProvenance.layers.map(named),
note: locale === 'en' ? feedProvenance.note : undefined,
},
};
// Not sent and not part of the signature: the English names a reply in
// another language must not echo.
Object.defineProperty(context, 'translatedLayerNames', {
value: layerContext.enabledLayerLabels.filter(
(name) => localizeLayerName(name) !== name,
),
});
return context;
}
_markSummaryDirty() {
+11 -9
View File
@@ -304,7 +304,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
try {
hud = new IntelHUD(env.viewer, { summaryService });
let place = 'Yellowstone';
hud._summaryContext = async () => ({ place });
hud._summaryContext = async () => ({ placeLabels: [place] });
const summary = () => env.elements.get('hud-summary').textContent;
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -330,7 +330,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
const moving = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 2, 'the move marked the summary dirty');
assert.deepEqual(pending[1].context, { place: 'Atlantic' });
assert.deepEqual(pending[1].context, { placeLabels: ['Atlantic'] });
pending[1].reply('North Atlantic Ocean');
await moving;
assert.equal(summary(), 'North Atlantic Ocean');
@@ -363,7 +363,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'Yellowstone' });
hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -425,7 +425,7 @@ test('a cancelled summary retries the same context but completed answers stay re
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve));
const first = hud._updateSummary(false);
@@ -483,7 +483,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move
try {
hud = new IntelHUD(env.viewer, { summaryService });
// The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ place: 'Lisboa' });
hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
@@ -510,15 +510,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move
});
await update;
// A reply still typing stops when the camera starts moving.
// A reply still typing stops when the camera starts moving, and the
// plain line replaces the fragment.
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
t.mock.timers.tick(48);
const typed = element.textContent;
env.cameraListeners.moveStart();
const plain = element.textContent;
assert.equal(plain, hud._composeSummary());
t.mock.timers.tick(2_000);
assert.equal(
element.textContent,
typed,
plain,
'no more characters after the move',
);
} finally {
@@ -545,7 +547,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData();
await hud._updateSummary(true);
t.mock.timers.tick(48);
+62
View File
@@ -0,0 +1,62 @@
// revmain-astra-1 / revmain-sol-1: a ja/zh reply within the 20-character
// limit is still cut to its first five space-separated tokens.
import assert from 'node:assert/strict';
import test from 'node:test';
import { Readable } from 'node:stream';
import { HUD_SUMMARY_MAX_UNSPACED_CHARS } from './hudSummaryResponse.js';
let client = 200;
async function postHudSummary(context, reply) {
client += 1;
const previousKey = process.env.OPENAI_API_KEY;
const previousFetch = globalThis.fetch;
process.env.OPENAI_API_KEY = 'test-key';
globalThis.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({ output_text: reply }),
});
try {
const { handleHudSummary } = await import(
'../server/providers/openai/hud-summary.js'
);
const req = Object.assign(
Readable.from([Buffer.from(JSON.stringify(context))]),
{
method: 'POST',
url: '/',
headers: {},
socket: { remoteAddress: `10.0.1.${client}` },
},
);
return await new Promise((resolve, reject) => {
const res = {
statusCode: 200,
setHeader() {},
end(body = '') {
resolve(JSON.parse(body));
},
};
Promise.resolve(handleHudSummary(req, res)).catch(reject);
});
} finally {
globalThis.fetch = previousFetch;
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = previousKey;
}
}
for (const [locale, summary] of [
['ja', '東京 上空 の 航空 情報 は 古い'],
['zh-CN', 'SFO 附近 4 架 航班 数据 过期'],
['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'],
]) {
test(`a ${locale} phrase within ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters is kept whole`, async () => {
assert.ok([...summary].length <= HUD_SUMMARY_MAX_UNSPACED_CHARS);
const body = await postHudSummary(
{ locale, feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary, feedState: 'stale' }),
);
assert.equal(body.summary, summary);
});
}
+145
View File
@@ -0,0 +1,145 @@
// The HUD summary line in another language: what the model is sent and what
// the readout shows. hud.js imports `mgrs`, which Node's ESM loader cannot
// read named exports from, so a module hook swaps it for a stub before the
// import (the same setup as hudSummaryGate.test.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === MGRS_STUB_URL) {
return {
format: 'module',
shortCircuit: true,
source:
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
},
});
const { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js');
const { hudSummaryInput } = await import('./hudSummaryResponse.js');
const PARTIAL_FLIGHTS = {
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { count: 4, partial: true, source: 'opensky' },
};
/** A ru HUD over one partial Live Flights layer, answering with `reply`. */
function createHud(t, reply) {
const summary = { textContent: '' };
const previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = {
hidden: false,
visibilityState: 'visible',
getElementById: (id) => (id === 'hud-summary' ? summary : null),
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
};
globalThis.window ??= {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
};
t.mock.timers.enable({ apis: ['setInterval'] });
const viewer = {
scene: { canvas: { width: 0, height: 0 }, pickPositionSupported: false },
camera: {
pitch: -Math.PI / 2,
heading: 0,
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
computeViewRectangle: () => undefined,
moveStart: { addEventListener() {}, removeEventListener() {} },
moveEnd: { addEventListener() {}, removeEventListener() {} },
},
};
const sent = [];
const hud = new IntelHUD(viewer, {
summaryService: {
configured: async () => true,
async summarize(context) {
sent.push(context);
return { ok: true, status: 200, data: { error: null, ...reply } };
},
},
});
t.after(() => {
hud.destroy();
setLocale('en');
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
});
hud._dataManager = { getAll: () => [PARTIAL_FLIGHTS] };
hud._latestMetrics = { altM: 600 };
setLocale('ru');
return { hud, sent, shown: () => summary.textContent };
}
test('a ru HUD request carries no English layer name', async (t) => {
const { hud } = createHud(t, {});
const context = await hud._summaryContext();
assert.equal(context.locale, 'ru');
assert.deepEqual(context.enabledLayerLabels, ['Рейсы онлайн']);
const input = hudSummaryInput(context);
assert.doesNotMatch(input, /Live Flights/, input);
// The locale signs the dedupe signature; the English names stay local.
assert.match(JSON.stringify(context), /"locale":"ru"/);
assert.doesNotMatch(JSON.stringify(context), /translatedLayerNames/);
});
test('a ru summary shows the state word first and the layer names last', async (t) => {
const { hud, shown } = createHud(t, {
summary: 'Аэропорт задерживает рейсы',
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.equal(shown(), 'ЧАСТИЧНО | Аэропорт задерживает рейсы | РЕЙСЫ ОНЛАЙН');
});
for (const words of [
'Live Flights частично онлайн',
'Рейсы онлайн stale данные',
]) {
test(`a ru reply echoing English falls back: ${words}`, async (t) => {
const { hud, shown } = createHud(t, {
summary: words,
feedState: 'partial',
});
await hud._updateSummary(false, true);
assert.ok(shown().startsWith('ЧАСТИЧНО | '), shown());
assert.ok(!shown().includes(words), shown());
});
}
test('a camera move during typing shows the plain line, not a fragment', (t) => {
const { hud, shown } = createHud(t, {});
hud._setSummaryText('ЧАСТИЧНО | Аэропорт задерживает рейсы', true);
hud._onCameraMoveStart();
assert.equal(shown(), hud._composeSummary());
});
test('a view with no labels and no layers does not ask the model', async (t) => {
const { hud, sent, shown } = createHud(t, {
summary: 'пусто',
feedState: null,
});
hud._dataManager = { getAll: () => [] };
await hud._updateSummary(false, true);
assert.equal(sent.length, 0);
assert.equal(shown(), hud._composeSummary());
});
+27 -1
View File
@@ -59,7 +59,7 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
"Write one concise intelligence-HUD summary for God's Eye View.",
'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.',
'Name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance or its note.',
'In the summary, name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance into it.',
'Set feedState to feedProvenance.overall exactly as given, or none when it is null.',
'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.',
@@ -107,6 +107,32 @@ const SUMMARY_LANGUAGES = Object.freeze({
/** Scripts written without spaces, where five words do not apply. */
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
/**
* Whether a summary for a non-English locale still carries English the HUD
* sent only as data: a feed-state id (`stale`) or an English layer name that
* has a translation (`Live Flights` in Russian).
* @param {string} summary The model's words.
* @param {unknown} locale The request locale.
* @param {string[]} [englishNames] English layer names that the UI translates.
* @returns {boolean}
*/
export function hudSummaryHasEnglish(summary, locale, englishNames = []) {
if (hudSummaryLocale(locale) === DEFAULT_LOCALE) return false;
const text = String(summary || '').toLowerCase();
return [...HUD_SUMMARY_FEED_STATES, ...englishNames].some((word) => {
const needle = String(word).toLowerCase();
let at = text.indexOf(needle);
while (at >= 0) {
const before = text[at - 1];
const after = text[at + needle.length];
if (!/\p{L}/u.test(before ?? '') && !/\p{L}/u.test(after ?? ''))
return true;
at = text.indexOf(needle, at + 1);
}
return false;
});
}
/** Whether the summary for this locale is capped in characters, not words. */
export function hudSummaryIsUnspaced(locale) {
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
+11 -27
View File
@@ -255,33 +255,6 @@ test('the instructions name the UI language, with English as the fallback', () =
assert.equal(hudSummaryLocale(42), 'en');
});
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 word leads and the layer names trail the one-line summary', () => {
const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8');
assert.match(
hud,
/return \[state, text, layers\]\.filter\(Boolean\)\.join\(' \| '\);/,
);
assert.match(hud, /withProvenance\(\s*data\.summary,/);
assert.match(hud, /return withProvenance\(line, provenance\);/);
// The model sees only the layer names the user sees.
assert.match(hud, /name: localizeLayerName\(layer\.name\)/);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/Name layers only as in enabledLayerLabels/,
);
});
test('shipped catalogs get their exact language, and CJK a character limit', () => {
assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
assert.match(
@@ -397,6 +370,17 @@ for (const [locale, summary] of [
});
}
test('a ja cap steps back to a space instead of cutting a Latin word', async () => {
const { response } = await postHudSummary(
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({
summary: 'San Francisco Bay Area flights',
feedState: 'stale',
}),
);
assert.equal(response.body.summary, 'San Francisco Bay');
});
test('a reply that skips the format carries no summary and no state', async () => {
const { response } = await postHudSummary(
{ locale: 'ru', feedProvenance: { overall: 'stale' } },