mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
7b223d7d7b
commit
7713fb8ae8
8 files changed
+328
-60
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
|||||||
Reference in new issue
Block a user