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

+3 -3
View File
@@ -5255,10 +5255,10 @@ and unreachable upstream (502/504) separately from road geometry.
### AI HUD Summary (June 2026) ### 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). - 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 layer names as the user sees them) and `feedProvenance`; the model is instructed not to infer from coordinates. - Input is the live basemap label context (place/street/nearby-place labels + enabled layers) and `feedProvenance`, with every layer name as the user sees it; outside English the narration note is left out, since a small model copies its English words. The model is instructed not to infer from coordinates. A view with no labels and no enabled layer is not sent: the deterministic line is shown.
- 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. - 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 and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout clips neither the warning nor the text. `get_hud_caption` builds its caption the same way and returns `feedState`. - 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 and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout never clips the warning; the text and then the layer names may be cut off on narrow layouts. Outside English a reply that echoes a feed-state id or an English layer name the UI translates falls back too. `get_hud_caption` builds its caption the same way and returns `feedState`.
- Output is sanitized to five words (20 characters when the request locale is Japanese or Chinese, whatever script the words use); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. - Output is sanitized to five words, or for a Japanese or Chinese request locale to 20 characters (stepping back to a space rather than splitting a Latin word); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update; a camera move during typing shows the deterministic line instead of the fragment.
- `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. - `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. - 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.
+17 -11
View File
@@ -45,18 +45,24 @@ function toFiveWordHudSummary(value, unspaced) {
.replace(/[^\p{L}\p{N}\s-]/gu, ' ') .replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean);
.slice(0, 5)
.join(' ');
// Japanese and Chinese have no spaces to count words by: cap characters.
// Other languages keep five words, even around a Han place name. // Other languages keep five words, even around a Han place name.
if (!unspaced) return words; if (!unspaced) return words.slice(0, 5).join(' ');
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); // Japanese and Chinese have no spaces to count words by: cap characters,
return [...graphemes.segment(words)] // stepping back to a space rather than cutting a Latin word in two.
.slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS) const text = words.join(' ');
.map(({ segment }) => segment) const graphemes = [
.join('') ...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text),
.trim(); ].map(({ segment }) => segment);
if (graphemes.length <= HUD_SUMMARY_MAX_UNSPACED_CHARS) return text;
const head = graphemes.slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS);
const next = graphemes[HUD_SUMMARY_MAX_UNSPACED_CHARS];
const inWord = (g) => /^[\p{Script=Latin}\p{N}]/u.test(g ?? '');
if (inWord(head.at(-1)) && inWord(next)) {
const space = head.lastIndexOf(' ');
if (space > 0) return head.slice(0, space).join('').trim();
}
return head.join('').trim();
} }
async function handleHudSummary(req, res) { async function handleHudSummary(req, res) {
+52 -9
View File
@@ -27,6 +27,7 @@ import {
} from './data/geoid.js'; } from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js'; import { getBasemapLabelContext } from './voice/gevActions.js';
import { import {
hudSummaryHasEnglish,
hudSummaryMatchesProvenance, hudSummaryMatchesProvenance,
hudSummaryLayerContext, hudSummaryLayerContext,
hudTelemetryProvenance, hudTelemetryProvenance,
@@ -237,12 +238,17 @@ export class IntelHUD {
// the camera has moved to. // the camera has moved to.
this._onCameraMoveStart = () => { this._onCameraMoveStart = () => {
this._markSummaryDirty(); 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. // Cancelled requests and partially typed replies are not reusable answers.
this._lastSummarySignature = null; this._lastSummarySignature = null;
} }
this._summaryRequest?.abort(); this._summaryRequest?.abort();
this._stopSummaryTyping(); 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._onCameraMoveEnd = () => {
this._markSummaryDirty(); this._markSummaryDirty();
@@ -856,6 +862,19 @@ export class IntelHUD {
return; return;
} }
if (revision !== this._summaryRevision) 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); const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) { if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false; this._summaryDirty = false;
@@ -892,7 +911,14 @@ export class IntelHUD {
if (!response.ok || !data?.summary) { if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`); 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. // The warning is the HUD's own translated state, not the model's words.
this._lastSummaryText = withProvenance( this._lastSummaryText = withProvenance(
data.summary, data.summary,
@@ -958,23 +984,40 @@ export class IntelHUD {
// A slower build for a view the camera has left must not replace the // A slower build for a view the camera has left must not replace the
// newer view's lookup. // newer view's lookup.
if (revision === this._summaryRevision) this._placeLabels = labels; if (revision === this._summaryRevision) this._placeLabels = labels;
const locale = getLocale();
const layerContext = hudSummaryLayerContext( const layerContext = hudSummaryLayerContext(
this._dataManager?.getAll?.() || [], this._dataManager?.getAll?.() || [],
); );
return { const named = (layer) => ({
locale: getLocale(), ...layer,
name: localizeLayerName(layer.name),
});
const { feedProvenance } = layerContext;
const context = {
locale,
placeLabels: labels.placeLabels, placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels, streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels,
...layerContext, ...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: enabledLayerLabels:
layerContext.enabledLayerLabels.map(localizeLayerName), layerContext.enabledLayerLabels.map(localizeLayerName),
enabledLayers: layerContext.enabledLayers.map((layer) => ({ enabledLayers: layerContext.enabledLayers.map(named),
...layer, feedProvenance: {
name: localizeLayerName(layer.name), ...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() { _markSummaryDirty() {
+11 -9
View File
@@ -304,7 +304,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
try { try {
hud = new IntelHUD(env.viewer, { summaryService }); hud = new IntelHUD(env.viewer, { summaryService });
let place = 'Yellowstone'; let place = 'Yellowstone';
hud._summaryContext = async () => ({ place }); hud._summaryContext = async () => ({ placeLabels: [place] });
const summary = () => env.elements.get('hud-summary').textContent; const summary = () => env.elements.get('hud-summary').textContent;
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData(); 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); const moving = hud._updateSummary(false);
await settle(); await settle();
assert.equal(pending.length, 2, 'the move marked the summary dirty'); 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'); pending[1].reply('North Atlantic Ocean');
await moving; await moving;
assert.equal(summary(), 'North Atlantic Ocean'); 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; let hud;
try { try {
hud = new IntelHUD(env.viewer, { summaryService }); hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'Yellowstone' }); hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] });
const element = env.elements.get('hud-summary'); const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData(); hud._updateCameraData();
@@ -425,7 +425,7 @@ test('a cancelled summary retries the same context but completed answers stay re
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer, { summaryService }); hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'San Francisco' }); hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData(); hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
const first = hud._updateSummary(false); 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 { try {
hud = new IntelHUD(env.viewer, { summaryService }); hud = new IntelHUD(env.viewer, { summaryService });
// The same place before and after: a slow follow camera, a small pan. // 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 element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData(); hud._updateCameraData();
@@ -510,15 +510,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move
}); });
await update; 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'); hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
t.mock.timers.tick(48); t.mock.timers.tick(48);
const typed = element.textContent;
env.cameraListeners.moveStart(); env.cameraListeners.moveStart();
const plain = element.textContent;
assert.equal(plain, hud._composeSummary());
t.mock.timers.tick(2_000); t.mock.timers.tick(2_000);
assert.equal( assert.equal(
element.textContent, element.textContent,
typed, plain,
'no more characters after the move', 'no more characters after the move',
); );
} finally { } finally {
@@ -545,7 +547,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t)
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer, { summaryService }); hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'San Francisco' }); hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] });
hud._updateCameraData(); hud._updateCameraData();
await hud._updateSummary(true); await hud._updateSummary(true);
t.mock.timers.tick(48); 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.", "Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.', '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.', '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.', '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.', '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.', '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. */ /** Scripts written without spaces, where five words do not apply. */
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/; 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. */ /** Whether the summary for this locale is capped in characters, not words. */
export function hudSummaryIsUnspaced(locale) { export function hudSummaryIsUnspaced(locale) {
return UNSPACED_LOCALES.test(hudSummaryLocale(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'); 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', () => { test('shipped catalogs get their exact language, and CJK a character limit', () => {
assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/); assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
assert.match( 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 () => { test('a reply that skips the format carries no summary and no state', async () => {
const { response } = await postHudSummary( const { response } = await postHudSummary(
{ locale: 'ru', feedProvenance: { overall: 'stale' } }, { locale: 'ru', feedProvenance: { overall: 'stale' } },