fix(hud): keep localized summary words whole and drop padding

- The proxy keeps combining marks, in-word apostrophes and name dots
  (ar vowel signs, fr d'Orléans, ja ・), normalizes to NFC, caps every
  summary in characters and cuts at a word boundary.
- feedState is nullable instead of a `none` token, and the summary has at
  most five words: "exactly five" made the model pad short views with
  "no data" or "unavailable".
- The HUD caches only the model's words and adds the current warning on
  every paint; an RTL locale gets a leading right-to-left mark.
- The echo check also rejects an all-Latin reply in a non-Latin language,
  translated layer ids and, with every feed healthy, the UNAVAILABLE word;
  places are matched by word and one-word layer names are skipped in
  Latin-script locales.
- The warning tag names the worst layers first; a partial analyst card
  shows one partial chip.
This commit is contained in:
German Khokhlov committed 2026-11-02 13:44:53 +04:00
1 parent 0d3c1ef1d9
commit 818d2c8295
12 files changed
+2428 -662

No files matched your search

+4 -4
View File
@@ -5364,10 +5364,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) and `feedProvenance`, with every layer name as the user sees it; outside English the narration note and the per-layer state ids are left out (`overall` still carries the state), since a small model copies English words. The model is instructed not to infer from coordinates. A view with no place, street or nearby-place label is not sent, since with layers alone the model invents a place: the deterministic line is shown. - Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: 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. Switching language in place drops the reply received and aborts one in flight, so the next tick asks again in the new language. - Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again.
- 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, even written flush against kana or Han; a state word the locale itself keeps in English (de `FALLBACK`) and words inside a place label do not count. `get_hud_caption` builds its caption the same way and returns `feedState`. - Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`.
- Output is sanitized to five words, or for a Japanese or Chinese request locale to 20 characters (stepping back to the start of a Latin word rather than splitting it); 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. - Output keeps at most five words (80 characters), or 20 characters for Japanese and Chinese, cut at a word boundary unless the first word is longer. Errors and the 5 s timeout fall back to the deterministic line; a camera move during typing shows that 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.
+1
View File
@@ -1110,6 +1110,7 @@
"src/hudDms.test.mjs", "src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs", "src/hudSummaryGate.test.mjs",
"src/hudSummaryLine.test.mjs", "src/hudSummaryLine.test.mjs",
"src/testSupport/stubMgrs.mjs",
"src/tools/queries/hud-caption-feed-state.test.mjs", "src/tools/queries/hud-caption-feed-state.test.mjs",
"src/voice/hudPlaceLabels.test.mjs", "src/voice/hudPlaceLabels.test.mjs",
"src/voice/panel/preferences.test.mjs", "src/voice/panel/preferences.test.mjs",
+39 -23
View File
@@ -1,10 +1,12 @@
import { import {
HUD_SUMMARY_FEED_STATES, HUD_SUMMARY_FEED_STATES,
HUD_SUMMARY_MAX_CHARS,
HUD_SUMMARY_MAX_UNSPACED_CHARS, HUD_SUMMARY_MAX_UNSPACED_CHARS,
HUD_SUMMARY_RESPONSE_FORMAT, HUD_SUMMARY_RESPONSE_FORMAT,
hudSummaryInput, hudSummaryInput,
hudSummaryInstructions, hudSummaryInstructions,
hudSummaryIsUnspaced, hudSummaryIsUnspaced,
hudSummaryLocale,
keylessHudSummaryResponse, keylessHudSummaryResponse,
} from '../../../src/hudSummaryResponse.js'; } from '../../../src/hudSummaryResponse.js';
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js'; import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
@@ -35,36 +37,50 @@ function parseHudSummaryReply(text, locale) {
? reply.feedState ? reply.feedState
: null; : null;
return { return {
summary: toFiveWordHudSummary(reply?.summary, hudSummaryIsUnspaced(locale)), summary: toFiveWordHudSummary(reply?.summary, locale),
feedState: feedState === 'none' ? null : feedState, feedState,
}; };
} }
function toFiveWordHudSummary(value, unspaced) { function toFiveWordHudSummary(value, requested) {
const locale = hudSummaryLocale(requested);
const words = String(value || '') const words = String(value || '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ') .normalize('NFC')
.trim() // Marks, and apostrophes and dots inside a word, belong to it: ar vowel
// signs, fr d'Orléans, ja サンフランシスコ・ベイエリア.
.replace(/[^\p{L}\p{M}\p{N}\s'’·・-]/gu, ' ')
.split(/\s+/) .split(/\s+/)
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
.filter(Boolean); .filter(Boolean);
// Other languages keep five words, even around a Han place name. // Japanese and Chinese have no spaces to count words by: cap characters.
if (!unspaced) return words.slice(0, 5).join(' '); if (hudSummaryIsUnspaced(locale))
// Japanese and Chinese have no spaces to count words by: cap characters, return capAtWord(words.join(' '), HUD_SUMMARY_MAX_UNSPACED_CHARS, locale);
// stepping back to the start of a Latin word rather than cutting it in two. return capAtWord(words.slice(0, 5).join(' '), HUD_SUMMARY_MAX_CHARS, locale);
const text = words.join(' '); }
const graphemes = [
...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text), /**
].map(({ segment }) => segment); * Cut `text` to `max` characters at a word boundary; a first word longer
if (graphemes.length <= HUD_SUMMARY_MAX_UNSPACED_CHARS) return text; * than that is cut where the cap falls.
const head = graphemes.slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS); */
const next = graphemes[HUD_SUMMARY_MAX_UNSPACED_CHARS]; function capAtWord(text, max, locale) {
const inWord = (g) => /^[\p{Script=Latin}\p{N}]/u.test(g ?? ''); const graphemes = (value) => [
if (inWord(head.at(-1)) && inWord(next)) { ...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(value),
let start = head.length - 1; ];
while (start > 0 && inWord(head[start - 1])) start -= 1; if (graphemes(text).length <= max) return text;
// A Latin word longer than the cap is cut where the cap falls. let kept = '';
if (start > 0) return head.slice(0, start).join('').trim(); for (const { segment } of new Intl.Segmenter(locale, {
granularity: 'word',
}).segment(text)) {
if (graphemes(kept + segment).length > max) break;
kept += segment;
} }
return head.join('').trim(); return (
kept.trim() ||
graphemes(text)
.slice(0, max)
.map(({ segment }) => segment)
.join('')
);
} }
async function handleHudSummary(req, res) { async function handleHudSummary(req, res) {
+8 -2
View File
@@ -259,8 +259,9 @@ export function viewStateLayerRow(layer, options) {
/** /**
* The feed state and up to two layer names of a provenance envelope that is * 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, * not nominal, or null; the worst layers come first, so the names match the
* `names` are `layer.name`); the HUD translates them for display. * state. 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 * @param {{ overall?: string|null, layers?: Array<object> }|null} envelope
* @returns {{ state: string, names: string[] }|null} * @returns {{ state: string, names: string[] }|null}
*/ */
@@ -271,6 +272,11 @@ export function provenanceTag(envelope) {
.filter( .filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
) )
.sort(
(a, b) =>
(FEED_STATE_SEVERITY[a.feedState] ?? Infinity) -
(FEED_STATE_SEVERITY[b.feedState] ?? Infinity),
)
.map((s) => String(s.name || s.id || 'LAYER')) .map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2); .slice(0, 2);
return { state: overall, names }; return { state: overall, names };
+16
View File
@@ -11,6 +11,7 @@ import {
layerFeedState, layerFeedState,
layerSnapshot, layerSnapshot,
layerSnapshots, layerSnapshots,
provenanceTag,
snapshotAgeLabel, snapshotAgeLabel,
snapshotAgeSec, snapshotAgeSec,
viewStateLayerRow, viewStateLayerRow,
@@ -293,3 +294,18 @@ test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago'); assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago'); assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
}); });
test('the warning tag names the layers in the reported state first', () => {
const tag = provenanceTag({
overall: 'unavailable',
layers: [
{ name: 'Live Flights', feedState: 'partial' },
{ name: 'Live AIS Vessels', feedState: 'partial' },
{ name: 'CCTV', feedState: 'unavailable' },
],
});
assert.deepEqual(tag, {
state: 'unavailable',
names: ['CCTV', 'Live Flights'],
});
});
+56 -34
View File
@@ -27,7 +27,9 @@ import {
} from './data/geoid.js'; } from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js'; import { getBasemapLabelContext } from './voice/gevActions.js';
import { import {
hasWord,
hudSummaryHasEnglish, hudSummaryHasEnglish,
hudSummaryIsLatin,
hudSummaryMatchesProvenance, hudSummaryMatchesProvenance,
hudSummaryLayerContext, hudSummaryLayerContext,
hudTelemetryProvenance, hudTelemetryProvenance,
@@ -46,6 +48,7 @@ import {
onLocaleChange, onLocaleChange,
t, t,
} from './i18n/index.js'; } from './i18n/index.js';
import { isRtlLocale } from './i18n/locale.js';
import { localizeLayerName } from './ui/layerPanel.js'; import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js'; import { isEmbeddedInline } from './app/embed.js';
import { subscribeSelectedVessel } from './hudReadouts.js'; import { subscribeSelectedVessel } from './hudReadouts.js';
@@ -130,28 +133,43 @@ function withProvenance(text, provenance) {
} }
/** /**
* The English a summary outside English must not carry: feed-state ids and * The AI line. In an RTL locale a leading right-to-left mark keeps a reply
* translated layers' English names. A state word the locale keeps in English * that opens with a Latin name (Times Square …) reading right to left.
* (de FALLBACK) and words inside a place label (Radio City, Loading Dock) are
* fine.
*/ */
function summaryEnglishWords(context) { function aiSummaryLine(summary, warning) {
const line = withProvenance(summary, warning);
return isRtlLocale(getLocale()) ? `\u200f${line}` : line;
}
/**
* What a summary outside English must not carry: feed-state ids, translated
* layers' English names and ids, and with every feed healthy the
* UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK)
* and words of a place label (Radio City, Loading Dock) are fine; so is a
* one-word layer name in a Latin-script locale, where it may be a word of
* the language (fr transit).
*/
function summaryForbiddenWords(context) {
const places = [ const places = [
...(context.placeLabels || []), ...(context.placeLabels || []),
...(context.streetLabels || []), ...(context.streetLabels || []),
...(context.nearbyPlaceLabels || []), ...(context.nearbyPlaceLabels || []),
] ].join(' ');
.join(' ') const latin = hudSummaryIsLatin(context.locale);
.toLowerCase(); const overall = context.feedProvenance?.overall;
return [ return [
...Object.keys(FEED_STATE_LABELS).filter( ...Object.keys(FEED_STATE_LABELS).filter(
(state) => (state) =>
t(FEED_STATE_LABELS[state]).toLowerCase() !== state && t(FEED_STATE_LABELS[state]).toLowerCase() !== state &&
!places.includes(state), !hasWord(places, state),
), ),
...(context.translatedLayerNames || []).filter( ...(context.englishLayerWords || []).filter(
(name) => !places.includes(name.toLowerCase()), (word) =>
!hasWord(places, word) && (!latin || /[^\p{L}\p{N}]/u.test(word)),
), ),
...(!overall || overall === 'nominal'
? [t(FEED_STATE_LABELS.unavailable)]
: []),
]; ];
} }
@@ -246,7 +264,7 @@ export class IntelHUD {
this._placeLabels = null; this._placeLabels = null;
this._lastSummarySignature = ''; this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */ /** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null; this._lastSummaryReply = null;
this._summaryRevision = 0; this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of // One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..." // waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
@@ -325,15 +343,11 @@ export class IntelHUD {
this._startTimers(); this._startTimers();
} }
/** /** Show the HUD's words in the new locale; drop AI text in the old one. */
* Show the HUD's own words in the new locale. The AI reply is written in
* the locale too, so the one received is dropped like a camera move drops
* it: a request in flight is aborted and the next tick asks again.
*/
_relocalize() { _relocalize() {
this._markSummaryDirty(); this._markSummaryDirty();
this._lastSummarySignature = null; this._lastSummarySignature = null;
this._lastSummaryText = null; this._lastSummaryReply = null;
this._summaryRequest?.abort(); this._summaryRequest?.abort();
this._present({ this._present({
mode: MODE_LABELS[this._currentStyle] mode: MODE_LABELS[this._currentStyle]
@@ -887,8 +901,7 @@ export class IntelHUD {
return; return;
} }
if (revision !== this._summaryRevision) return; if (revision !== this._summaryRevision) return;
// No place to name: with layers alone the model invents one, so the plain // Without a place label the model invents a place: show the plain line.
// line (state and layers) is shown instead.
const described = [ const described = [
context.placeLabels, context.placeLabels,
context.streetLabels, context.streetLabels,
@@ -896,7 +909,7 @@ export class IntelHUD {
].some((list) => list?.length); ].some((list) => list?.length);
if (!described) { if (!described) {
this._summaryDirty = false; this._summaryDirty = false;
this._setSummaryText(fallbackText, animate); this._setSummaryText(fallbackText, false);
return; return;
} }
const signature = JSON.stringify(context); const signature = JSON.stringify(context);
@@ -904,7 +917,12 @@ export class IntelHUD {
this._summaryDirty = false; this._summaryDirty = false;
// A settle painted the fallback line over the reply this context // A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it. // already has; reused place labels make that common, so restore it.
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText); // The warning is the current one: layer states are not in a
// non-English signature.
if (this._lastSummaryReply)
this._setSummaryText(
aiSummaryLine(this._lastSummaryReply, context.warning),
);
return; return;
} }
if (this._summaryRequest) return; if (this._summaryRequest) return;
@@ -912,7 +930,7 @@ export class IntelHUD {
if (force) this._setSummaryText(fallbackText, false); if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false; this._summaryDirty = false;
this._lastSummarySignature = signature; this._lastSummarySignature = signature;
this._lastSummaryText = null; this._lastSummaryReply = null;
const controller = new AbortController(); const controller = new AbortController();
let timedOut = false; let timedOut = false;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -940,13 +958,18 @@ export class IntelHUD {
!hudSummaryHasEnglish( !hudSummaryHasEnglish(
data.summary, data.summary,
context.locale, context.locale,
summaryEnglishWords(context), summaryForbiddenWords(context),
) )
) { ) {
// The warning is the HUD's own translated state, not the model's words. this._lastSummaryReply = data.summary;
this._lastSummaryText = withProvenance(data.summary, context.warning);
} }
this._setSummaryText(this._lastSummaryText || fallbackText, animate); // The warning is the HUD's own translated state, not the model's words.
this._setSummaryText(
this._lastSummaryReply
? aiSummaryLine(this._lastSummaryReply, context.warning)
: fallbackText,
animate,
);
} catch (error) { } catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or // The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not. // destroy's) is not.
@@ -1021,7 +1044,6 @@ export class IntelHUD {
placeLabels: labels.placeLabels, placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels, streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels,
...layerContext,
// The model sees only the layer names the user sees. The English note // 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. // goes too: a small model copies its words. `overall` stays the id.
enabledLayerLabels: enabledLayerLabels:
@@ -1034,14 +1056,14 @@ export class IntelHUD {
}, },
}; };
// Not sent and not part of the signature: the line's own warning, taken // Not sent and not part of the signature: the line's own warning, taken
// before the layer states are left out, and the English names a reply in // before the layer states are left out, and the English names and ids of
// another language must not echo. // translated layers, which a reply in another language must not echo.
Object.defineProperties(context, { Object.defineProperties(context, {
warning: { value: provenanceTag(feedProvenance) }, warning: { value: provenanceTag(feedProvenance) },
translatedLayerNames: { englishLayerWords: {
value: layerContext.enabledLayerLabels.filter( value: layerContext.enabledLayers
(name) => localizeLayerName(name) !== name, .filter(({ name }) => localizeLayerName(name) !== name)
), .flatMap(({ name, id }) => [name, id].filter(Boolean)),
}, },
}); });
return context; return context;
+77 -38
View File
@@ -1,31 +1,8 @@
// 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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { registerHooks } from 'node:module'; import './testSupport/stubMgrs.mjs';
import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { hudReadouts } from './testSupport/hudReadouts.mjs';
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 { IntelHUD } = await import('./hud.js');
const { setLocale } = await import('./i18n/index.js'); const { setLocale } = await import('./i18n/index.js');
const { hudSummaryHasEnglish, hudSummaryInput } = const { hudSummaryHasEnglish, hudSummaryInput } =
@@ -38,11 +15,11 @@ const PARTIAL_FLIGHTS = {
stats: { count: 4, partial: true, source: 'opensky' }, stats: { count: 4, partial: true, source: 'opensky' },
}; };
/** function createHud(
* A HUD over one partial Live Flights layer and `places`, answering with t,
* `reply`; ru unless `locale` says otherwise. reply,
*/ { places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
function createHud(t, reply, { places = ['Остин'], locale = 'ru' } = {}) { ) {
const { elements, publish } = hudReadouts(); const { elements, publish } = hudReadouts();
const previous = { document: globalThis.document, window: globalThis.window }; const previous = { document: globalThis.document, window: globalThis.window };
globalThis.document = { globalThis.document = {
@@ -94,7 +71,7 @@ function createHud(t, reply, { places = ['Остин'], locale = 'ru' } = {}) {
const buildContext = hud._summaryContext.bind(hud); const buildContext = hud._summaryContext.bind(hud);
hud._summaryContext = async () => hud._summaryContext = async () =>
Object.assign(await buildContext(), { placeLabels: places }); Object.assign(await buildContext(), { placeLabels: places });
hud._dataManager = { getAll: () => [PARTIAL_FLIGHTS] }; hud._dataManager = { getAll: layers };
hud._latestMetrics = { altM: 600 }; hud._latestMetrics = { altM: 600 };
setLocale(locale); setLocale(locale);
return { return {
@@ -113,7 +90,7 @@ test('a ru HUD request carries no English layer name', async (t) => {
assert.doesNotMatch(input, /Live Flights/, input); assert.doesNotMatch(input, /Live Flights/, input);
// The locale signs the dedupe signature; the English names stay local. // The locale signs the dedupe signature; the English names stay local.
assert.match(JSON.stringify(context), /"locale":"ru"/); assert.match(JSON.stringify(context), /"locale":"ru"/);
assert.doesNotMatch(JSON.stringify(context), /translatedLayerNames/); assert.doesNotMatch(JSON.stringify(context), /englishLayerWords|warning/);
}); });
test('a ru summary shows the state word first and the layer names last', async (t) => { test('a ru summary shows the state word first and the layer names last', async (t) => {
@@ -128,6 +105,8 @@ test('a ru summary shows the state word first and the layer names last', async (
for (const words of [ for (const words of [
'Live Flights частично онлайн', 'Live Flights частично онлайн',
'Рейсы онлайн stale данные', 'Рейсы онлайн stale данные',
'Остин flights частично онлайн',
'Austin flights partially online',
]) { ]) {
test(`a ru reply echoing English falls back: ${words}`, async (t) => { test(`a ru reply echoing English falls back: ${words}`, async (t) => {
const { hud, shown } = createHud(t, { const { hud, shown } = createHud(t, {
@@ -140,13 +119,6 @@ for (const words of [
}); });
} }
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 layers but no place label does not ask the model', async (t) => { test('a view with layers but no place label does not ask the model', async (t) => {
const { hud, sent, shown } = createHud( const { hud, sent, shown } = createHud(
t, t,
@@ -243,3 +215,70 @@ test('a language switch drops the reply in flight and asks again', async (t) =>
assert.equal(sent.at(-1).locale, 'es'); assert.equal(sent.at(-1).locale, 'es');
assert.ok(shown().startsWith('PARCIAL | Austin vuelos parciales'), shown()); assert.ok(shown().startsWith('PARCIAL | Austin vuelos parciales'), shown());
}); });
test('a reused ru reply carries the current warning, not the cached one', async (t) => {
// Both layers load an empty view: no count, no lastUpdate yet.
let flights = { loading: true, count: 0 };
const { hud, shown } = createHud(
t,
{ summary: 'Остин небо над городом', feedState: 'loading' },
{
layers: () => [
{ id: 'flights', name: 'Live Flights', enabled: true, stats: flights },
{
id: 'ais',
name: 'Live AIS Vessels',
enabled: true,
stats: { loading: true, count: 0 },
},
],
},
);
await hud._updateSummary(false, true);
assert.match(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
// Live Flights finishes with nothing to show; AIS is still loading.
flights = { count: 0 };
hud._markSummaryDirty();
await hud._updateSummary(false);
assert.match(
shown(),
/^ЗАГРУЗКА \| Остин небо над городом \| СУДА AIS/,
shown(),
);
assert.doesNotMatch(shown(), /РЕЙСЫ ОНЛАЙН/, shown());
});
test('with every feed healthy a reply calling one unavailable falls back', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Остин рейсы недоступно сейчас', feedState: null },
{ layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), hud._composeSummary());
});
test('an ar reply opening with a Latin name keeps reading right to left', async (t) => {
const { hud, shown } = createHud(
t,
{ summary: 'Times Square نيويورك الولايات المتحدة', feedState: null },
{ locale: 'ar', places: ['Times Square'], layers: () => [] },
);
await hud._updateSummary(false, true);
assert.equal(shown(), '\u200fTimes Square نيويورك الولايات المتحدة');
});
test('a one-word layer name that is a French word is not an echo', async (t) => {
const summary = 'Bus en transit à Paris';
const { hud, shown } = createHud(
t,
{ summary, feedState: null },
{
locale: 'fr',
places: ['Paris'],
layers: () => [{ id: 'transit', name: 'Transit', enabled: true }],
},
);
await hud._updateSummary(false, true);
assert.equal(shown(), summary);
});
+86 -33
View File
@@ -60,20 +60,23 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
'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.',
'In the summary, name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance into it.', '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 null when it is null.',
'When feedProvenance.overall is null, say nothing about feeds or layers, present or missing.',
'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.',
'The summary is exactly five words with no title, punctuation, markdown, or introductory phrase.', // "Exactly five words" made a small model pad a short view with words like
// "no data" or "unavailable".
'The summary has at most five words, with no title, punctuation, markdown, or introductory phrase.',
].join(' '); ].join(' ');
/** Longest summary, in characters, for a script written without spaces. */ /** Longest summary, in characters, for a script written without spaces. */
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20; export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
/** feedState values the model may return; none stands for a null overall. */ /** Longest five-word summary, in characters. */
export const HUD_SUMMARY_FEED_STATES = Object.freeze([ export const HUD_SUMMARY_MAX_CHARS = 80;
...SNAPSHOT_FEED_STATES,
'none', /** feedState values the model may return besides null. */
]); export const HUD_SUMMARY_FEED_STATES = SNAPSHOT_FEED_STATES;
/** Responses API structured output: the five words plus the canonical state. */ /** Responses API structured output: the five words plus the canonical state. */
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
@@ -84,7 +87,14 @@ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
type: 'object', type: 'object',
properties: { properties: {
summary: { type: 'string' }, summary: { type: 'string' },
feedState: { type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] }, // Nullable rather than a `none` token, which the model wrote into the
// summary of a view without layers.
feedState: {
anyOf: [
{ type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] },
{ type: 'null' },
],
},
}, },
required: ['summary', 'feedState'], required: ['summary', 'feedState'],
additionalProperties: false, additionalProperties: false,
@@ -107,38 +117,82 @@ 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)(?:-|$)/;
/** Shipped locales not written in Latin letters. */
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
/** Scripts whose letters extend a word; any other neighbour ends it. */
const WORD_SCRIPTS = [
'Latin',
'Cyrillic',
'Greek',
'Arabic',
'Hebrew',
'Han',
'Hiragana',
'Katakana',
'Hangul',
].map((script) => new RegExp(`\\p{Script=${script}}`, 'u'));
/** Lower case, with punctuation and brackets as spaces: `Earthquakes 24h`. */
function flatWords(value) {
return String(value || '')
.normalize('NFC')
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim();
}
function sameScript(a, b) {
return WORD_SCRIPTS.some((script) => script.test(a ?? '') && script.test(b));
}
/** /**
* Whether a non-English summary echoes English the HUD does not show: a * Whether `word` stands on its own in `text`. Only a letter of the same
* feed-state id or a translated layer's English name. Only Latin letters * script extends it, so `stale` flush against kana or Han still counts.
* extend a word, so `stale` flush against kana or Han still counts. * @param {string} text
* @param {string} word
* @returns {boolean}
*/
export function hasWord(text, word) {
const haystack = flatWords(text);
const needle = flatWords(word);
if (!needle) return false;
for (
let at = haystack.indexOf(needle);
at >= 0;
at = haystack.indexOf(needle, at + 1)
) {
const before = haystack[at - 1];
const after = haystack[at + needle.length];
if (!sameScript(before, needle[0]) && !sameScript(after, needle.at(-1)))
return true;
}
return false;
}
/**
* Whether a non-English summary carries what the HUD does not show: one of
* `words` (feed-state ids, translated layers' English names and ids), or only
* Latin letters in a language written in another script.
* @param {string} summary The model's words. * @param {string} summary The model's words.
* @param {unknown} locale The request locale. * @param {unknown} locale The request locale.
* @param {string[]} [words] The English words the summary must not carry. * @param {string[]} [words] Words the summary must not carry.
* @returns {boolean} * @returns {boolean}
*/ */
export function hudSummaryHasEnglish(summary, locale, words = []) { export function hudSummaryHasEnglish(summary, locale, words = []) {
if (hudSummaryLocale(locale) === DEFAULT_LOCALE) return false; const tag = hudSummaryLocale(locale);
const flat = (value) => if (tag === DEFAULT_LOCALE) return false;
String(value || '') const letters = String(summary || '').replace(/[^\p{L}]/gu, '');
.toLowerCase() if (!hudSummaryIsLatin(tag) && !letters.replace(/\p{Script=Latin}/gu, ''))
.replace(/-/g, ' '); return true;
const text = flat(summary); return words.some((word) => hasWord(summary, word));
const latin = (char) => /\p{Script=Latin}/u.test(char ?? ''); }
return words.some((word) => {
const needle = flat(word); /** Whether the locale is written in Latin letters. */
if (!needle) return false; export function hudSummaryIsLatin(locale) {
for ( return !NON_LATIN_LOCALES.test(hudSummaryLocale(locale));
let at = text.indexOf(needle);
at >= 0;
at = text.indexOf(needle, at + 1)
) {
if (!latin(text[at - 1]) && !latin(text[at + needle.length])) return true;
}
return false;
});
} }
/** 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));
} }
@@ -154,7 +208,6 @@ function summaryLanguage(tag) {
} }
/** /**
* Build the summary instructions for the requested locale.
* @param {unknown} locale The `locale` the HUD sent. * @param {unknown} locale The `locale` the HUD sent.
* @returns {string} * @returns {string}
*/ */
+33 -17
View File
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { import {
HUD_SUMMARY_FEED_STATES, HUD_SUMMARY_FEED_STATES,
@@ -233,9 +232,10 @@ test('HUD summary instructions ask for the canonical feedState as its own field'
'feedState', 'feedState',
]); ]);
assert.deepEqual( assert.deepEqual(
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.enum, HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
[...HUD_SUMMARY_FEED_STATES], [{ type: 'string', enum: [...HUD_SUMMARY_FEED_STATES] }, { type: 'null' }],
); );
assert.ok(!HUD_SUMMARY_FEED_STATES.includes('none'));
assert.ok(HUD_SUMMARY_FEED_STATES.includes('partial')); assert.ok(HUD_SUMMARY_FEED_STATES.includes('partial'));
}); });
@@ -266,7 +266,7 @@ test('shipped catalogs get their exact language, and CJK a character limit', ()
hudSummaryInstructions('ja'), hudSummaryInstructions('ja'),
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`), new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
); );
assert.doesNotMatch(hudSummaryInstructions('es'), /at most/); assert.doesNotMatch(hudSummaryInstructions('es'), /characters/);
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/); assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
}); });
@@ -350,10 +350,35 @@ test('a Japanese summary is capped in characters, not words', async () => {
{ locale: 'ja', feedProvenance: { overall: 'stale' } }, { locale: 'ja', feedProvenance: { overall: 'stale' } },
JSON.stringify({ summary: long, feedState: 'stale' }), JSON.stringify({ summary: long, feedState: 'stale' }),
); );
assert.equal( const shown = response.body.summary;
response.body.summary, assert.ok(long.startsWith(shown), shown);
[...long].slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS).join(''), assert.ok([...shown].length <= HUD_SUMMARY_MAX_UNSPACED_CHARS, shown);
assert.ok([...shown].length >= 10, shown);
});
for (const [locale, summary] of [
['ar', 'دبي حركة الطيران محدودة حاليًا'],
['ar', 'القاهرة تتبّع الرحلات الآن'],
['fr', "Vols au-dessus d'Orléans aujourd'hui partiels"],
['it', "Voli sull'aeroporto di Roma parziali"],
['fr', 'Données de vol à Paris'.normalize('NFD')],
['ja', 'サンフランシスコ・ベイエリア'],
]) {
test(`a ${locale} summary keeps marks, apostrophes and dots: ${summary}`, async () => {
const { response } = await postHudSummary(
{ locale, feedProvenance: { overall: null } },
JSON.stringify({ summary, feedState: null }),
); );
assert.equal(response.body.summary, summary.normalize('NFC'));
});
}
test('a five-word summary is capped in characters too', async () => {
const { response } = await postHudSummary(
{ locale: 'de', feedProvenance: { overall: null } },
JSON.stringify({ summary: `Berlin ${'x'.repeat(200)}`, feedState: null }),
);
assert.equal(response.body.summary, 'Berlin');
}); });
for (const [locale, summary] of [ for (const [locale, summary] of [
@@ -460,7 +485,7 @@ test("a localized summary is shown only with the HUD's own canonical state", ()
assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true); assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true);
}); });
test('a partial view keeps its warning tag, translated in es and ru', async () => { test('a partial view keeps its warning tag', () => {
const now = 1_000_000; const now = 1_000_000;
const layers = [ const layers = [
{ {
@@ -486,13 +511,4 @@ test('a partial view keeps its warning tag, translated in es and ru', async () =
state: 'partial', state: 'partial',
names: ['Live Flights'], names: ['Live Flights'],
}); });
const { setLocale, t } = await import('./i18n/index.js');
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
assert.notEqual(t('PARTIAL'), 'PARTIAL', locale);
}
} finally {
setLocale('en');
}
}); });
+22
View File
@@ -0,0 +1,22 @@
// hud.js imports `mgrs`, whose named exports Node's ESM loader cannot read:
// import this module before hud.js to swap it for a stub.
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);
},
});
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -422,7 +422,10 @@ function analystCard(result) {
...(result.feedState && result.feedState !== 'nominal' ...(result.feedState && result.feedState !== 'nominal'
? [{ label: shownFeedState(result.feedState) }] ? [{ label: shownFeedState(result.feedState) }]
: []), : []),
...(result.partial ? [{ label: t('partial') }] : []), // A partial feed state already says it.
...(result.partial && result.feedState !== 'partial'
? [{ label: t('partial') }]
: []),
], ],
// Coverage qualifications lead the feed detail. The bounded card groups // Coverage qualifications lead the feed detail. The bounded card groups
// overflow in its existing Notes disclosure rather than dropping them. // overflow in its existing Notes disclosure rather than dropping them.