mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
0d3c1ef1d9
commit
818d2c8295
12 files changed
+2429
-663
No files matched your search
@@ -5364,10 +5364,10 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
### 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).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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: 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.
|
||||
- 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 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -1110,6 +1110,7 @@
|
||||
"src/hudDms.test.mjs",
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
"src/hudSummaryLine.test.mjs",
|
||||
"src/testSupport/stubMgrs.mjs",
|
||||
"src/tools/queries/hud-caption-feed-state.test.mjs",
|
||||
"src/voice/hudPlaceLabels.test.mjs",
|
||||
"src/voice/panel/preferences.test.mjs",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
HUD_SUMMARY_FEED_STATES,
|
||||
HUD_SUMMARY_MAX_CHARS,
|
||||
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||
hudSummaryInput,
|
||||
hudSummaryInstructions,
|
||||
hudSummaryIsUnspaced,
|
||||
hudSummaryLocale,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
@@ -35,36 +37,50 @@ function parseHudSummaryReply(text, locale) {
|
||||
? reply.feedState
|
||||
: null;
|
||||
return {
|
||||
summary: toFiveWordHudSummary(reply?.summary, hudSummaryIsUnspaced(locale)),
|
||||
feedState: feedState === 'none' ? null : feedState,
|
||||
summary: toFiveWordHudSummary(reply?.summary, locale),
|
||||
feedState,
|
||||
};
|
||||
}
|
||||
|
||||
function toFiveWordHudSummary(value, unspaced) {
|
||||
function toFiveWordHudSummary(value, requested) {
|
||||
const locale = hudSummaryLocale(requested);
|
||||
const words = String(value || '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||
.trim()
|
||||
.normalize('NFC')
|
||||
// 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+/)
|
||||
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
|
||||
.filter(Boolean);
|
||||
// Other languages keep five words, even around a Han place name.
|
||||
if (!unspaced) return words.slice(0, 5).join(' ');
|
||||
// Japanese and Chinese have no spaces to count words by: cap characters,
|
||||
// stepping back to the start of a Latin word rather than cutting it in two.
|
||||
const text = words.join(' ');
|
||||
const graphemes = [
|
||||
...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text),
|
||||
].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)) {
|
||||
let start = head.length - 1;
|
||||
while (start > 0 && inWord(head[start - 1])) start -= 1;
|
||||
// A Latin word longer than the cap is cut where the cap falls.
|
||||
if (start > 0) return head.slice(0, start).join('').trim();
|
||||
// Japanese and Chinese have no spaces to count words by: cap characters.
|
||||
if (hudSummaryIsUnspaced(locale))
|
||||
return capAtWord(words.join(' '), HUD_SUMMARY_MAX_UNSPACED_CHARS, locale);
|
||||
return capAtWord(words.slice(0, 5).join(' '), HUD_SUMMARY_MAX_CHARS, locale);
|
||||
}
|
||||
return head.join('').trim();
|
||||
|
||||
/**
|
||||
* Cut `text` to `max` characters at a word boundary; a first word longer
|
||||
* than that is cut where the cap falls.
|
||||
*/
|
||||
function capAtWord(text, max, locale) {
|
||||
const graphemes = (value) => [
|
||||
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(value),
|
||||
];
|
||||
if (graphemes(text).length <= max) return text;
|
||||
let kept = '';
|
||||
for (const { segment } of new Intl.Segmenter(locale, {
|
||||
granularity: 'word',
|
||||
}).segment(text)) {
|
||||
if (graphemes(kept + segment).length > max) break;
|
||||
kept += segment;
|
||||
}
|
||||
return (
|
||||
kept.trim() ||
|
||||
graphemes(text)
|
||||
.slice(0, max)
|
||||
.map(({ segment }) => segment)
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
|
||||
@@ -259,8 +259,9 @@ export function viewStateLayerRow(layer, options) {
|
||||
|
||||
/**
|
||||
* The feed state and up to two layer names of a provenance envelope that is
|
||||
* not nominal, or null. Values stay English (`state` is a feed-state id,
|
||||
* `names` are `layer.name`); the HUD translates them for display.
|
||||
* not nominal, or null; the worst layers come first, so the names match the
|
||||
* 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
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
@@ -271,6 +272,11 @@ export function provenanceTag(envelope) {
|
||||
.filter(
|
||||
(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'))
|
||||
.slice(0, 2);
|
||||
return { state: overall, names };
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
layerFeedState,
|
||||
layerSnapshot,
|
||||
layerSnapshots,
|
||||
provenanceTag,
|
||||
snapshotAgeLabel,
|
||||
snapshotAgeSec,
|
||||
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 - 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
@@ -27,7 +27,9 @@ import {
|
||||
} from './data/geoid.js';
|
||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import {
|
||||
hasWord,
|
||||
hudSummaryHasEnglish,
|
||||
hudSummaryIsLatin,
|
||||
hudSummaryMatchesProvenance,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenance,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
onLocaleChange,
|
||||
t,
|
||||
} from './i18n/index.js';
|
||||
import { isRtlLocale } from './i18n/locale.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { isEmbeddedInline } from './app/embed.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
|
||||
* translated layers' English names. A state word the locale keeps in English
|
||||
* (de FALLBACK) and words inside a place label (Radio City, Loading Dock) are
|
||||
* fine.
|
||||
* The AI line. In an RTL locale a leading right-to-left mark keeps a reply
|
||||
* that opens with a Latin name (Times Square …) reading right to left.
|
||||
*/
|
||||
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 = [
|
||||
...(context.placeLabels || []),
|
||||
...(context.streetLabels || []),
|
||||
...(context.nearbyPlaceLabels || []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
].join(' ');
|
||||
const latin = hudSummaryIsLatin(context.locale);
|
||||
const overall = context.feedProvenance?.overall;
|
||||
return [
|
||||
...Object.keys(FEED_STATE_LABELS).filter(
|
||||
(state) =>
|
||||
t(FEED_STATE_LABELS[state]).toLowerCase() !== state &&
|
||||
!places.includes(state),
|
||||
!hasWord(places, state),
|
||||
),
|
||||
...(context.translatedLayerNames || []).filter(
|
||||
(name) => !places.includes(name.toLowerCase()),
|
||||
...(context.englishLayerWords || []).filter(
|
||||
(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._lastSummarySignature = '';
|
||||
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
|
||||
this._lastSummaryText = null;
|
||||
this._lastSummaryReply = null;
|
||||
this._summaryRevision = 0;
|
||||
// One-shot guards so the very first summary lands immediately instead of
|
||||
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
|
||||
@@ -325,15 +343,11 @@ export class IntelHUD {
|
||||
this._startTimers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Show the HUD's words in the new locale; drop AI text in the old one. */
|
||||
_relocalize() {
|
||||
this._markSummaryDirty();
|
||||
this._lastSummarySignature = null;
|
||||
this._lastSummaryText = null;
|
||||
this._lastSummaryReply = null;
|
||||
this._summaryRequest?.abort();
|
||||
this._present({
|
||||
mode: MODE_LABELS[this._currentStyle]
|
||||
@@ -887,8 +901,7 @@ export class IntelHUD {
|
||||
return;
|
||||
}
|
||||
if (revision !== this._summaryRevision) return;
|
||||
// No place to name: with layers alone the model invents one, so the plain
|
||||
// line (state and layers) is shown instead.
|
||||
// Without a place label the model invents a place: show the plain line.
|
||||
const described = [
|
||||
context.placeLabels,
|
||||
context.streetLabels,
|
||||
@@ -896,7 +909,7 @@ export class IntelHUD {
|
||||
].some((list) => list?.length);
|
||||
if (!described) {
|
||||
this._summaryDirty = false;
|
||||
this._setSummaryText(fallbackText, animate);
|
||||
this._setSummaryText(fallbackText, false);
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify(context);
|
||||
@@ -904,7 +917,12 @@ export class IntelHUD {
|
||||
this._summaryDirty = false;
|
||||
// A settle painted the fallback line over the reply this context
|
||||
// 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;
|
||||
}
|
||||
if (this._summaryRequest) return;
|
||||
@@ -912,7 +930,7 @@ export class IntelHUD {
|
||||
if (force) this._setSummaryText(fallbackText, false);
|
||||
this._summaryDirty = false;
|
||||
this._lastSummarySignature = signature;
|
||||
this._lastSummaryText = null;
|
||||
this._lastSummaryReply = null;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -940,13 +958,18 @@ export class IntelHUD {
|
||||
!hudSummaryHasEnglish(
|
||||
data.summary,
|
||||
context.locale,
|
||||
summaryEnglishWords(context),
|
||||
summaryForbiddenWords(context),
|
||||
)
|
||||
) {
|
||||
// The warning is the HUD's own translated state, not the model's words.
|
||||
this._lastSummaryText = withProvenance(data.summary, context.warning);
|
||||
this._lastSummaryReply = data.summary;
|
||||
}
|
||||
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) {
|
||||
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||
// destroy's) is not.
|
||||
@@ -1021,7 +1044,6 @@ export class IntelHUD {
|
||||
placeLabels: labels.placeLabels,
|
||||
streetLabels: labels.streetLabels,
|
||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||
...layerContext,
|
||||
// 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:
|
||||
@@ -1034,14 +1056,14 @@ export class IntelHUD {
|
||||
},
|
||||
};
|
||||
// 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
|
||||
// another language must not echo.
|
||||
// before the layer states are left out, and the English names and ids of
|
||||
// translated layers, which a reply in another language must not echo.
|
||||
Object.defineProperties(context, {
|
||||
warning: { value: provenanceTag(feedProvenance) },
|
||||
translatedLayerNames: {
|
||||
value: layerContext.enabledLayerLabels.filter(
|
||||
(name) => localizeLayerName(name) !== name,
|
||||
),
|
||||
englishLayerWords: {
|
||||
value: layerContext.enabledLayers
|
||||
.filter(({ name }) => localizeLayerName(name) !== name)
|
||||
.flatMap(({ name, id }) => [name, id].filter(Boolean)),
|
||||
},
|
||||
});
|
||||
return context;
|
||||
|
||||
+77
-38
@@ -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 assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import './testSupport/stubMgrs.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 { setLocale } = await import('./i18n/index.js');
|
||||
const { hudSummaryHasEnglish, hudSummaryInput } =
|
||||
@@ -38,11 +15,11 @@ const PARTIAL_FLIGHTS = {
|
||||
stats: { count: 4, partial: true, source: 'opensky' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A HUD over one partial Live Flights layer and `places`, answering with
|
||||
* `reply`; ru unless `locale` says otherwise.
|
||||
*/
|
||||
function createHud(t, reply, { places = ['Остин'], locale = 'ru' } = {}) {
|
||||
function createHud(
|
||||
t,
|
||||
reply,
|
||||
{ places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
|
||||
) {
|
||||
const { elements, publish } = hudReadouts();
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
globalThis.document = {
|
||||
@@ -94,7 +71,7 @@ function createHud(t, reply, { places = ['Остин'], locale = 'ru' } = {}) {
|
||||
const buildContext = hud._summaryContext.bind(hud);
|
||||
hud._summaryContext = async () =>
|
||||
Object.assign(await buildContext(), { placeLabels: places });
|
||||
hud._dataManager = { getAll: () => [PARTIAL_FLIGHTS] };
|
||||
hud._dataManager = { getAll: layers };
|
||||
hud._latestMetrics = { altM: 600 };
|
||||
setLocale(locale);
|
||||
return {
|
||||
@@ -113,7 +90,7 @@ test('a ru HUD request carries no English layer name', async (t) => {
|
||||
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/);
|
||||
assert.doesNotMatch(JSON.stringify(context), /englishLayerWords|warning/);
|
||||
});
|
||||
|
||||
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 [
|
||||
'Live Flights частично онлайн',
|
||||
'Рейсы онлайн stale данные',
|
||||
'Остин flights частично онлайн',
|
||||
'Austin flights partially online',
|
||||
]) {
|
||||
test(`a ru reply echoing English falls back: ${words}`, async (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) => {
|
||||
const { hud, sent, shown } = createHud(
|
||||
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.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);
|
||||
});
|
||||
+85
-32
@@ -60,20 +60,23 @@ export const HUD_SUMMARY_INSTRUCTIONS = [
|
||||
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'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.',
|
||||
'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(' ');
|
||||
|
||||
/** Longest summary, in characters, for a script written without spaces. */
|
||||
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
|
||||
|
||||
/** feedState values the model may return; none stands for a null overall. */
|
||||
export const HUD_SUMMARY_FEED_STATES = Object.freeze([
|
||||
...SNAPSHOT_FEED_STATES,
|
||||
'none',
|
||||
]);
|
||||
/** Longest five-word summary, in characters. */
|
||||
export const HUD_SUMMARY_MAX_CHARS = 80;
|
||||
|
||||
/** 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. */
|
||||
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
|
||||
@@ -84,7 +87,14 @@ export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
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'],
|
||||
additionalProperties: false,
|
||||
@@ -107,38 +117,82 @@ const SUMMARY_LANGUAGES = Object.freeze({
|
||||
/** Scripts written without spaces, where five words do not apply. */
|
||||
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
|
||||
* feed-state id or a translated layer's English name. Only Latin letters
|
||||
* extend a word, so `stale` flush against kana or Han still counts.
|
||||
* Whether `word` stands on its own in `text`. Only a letter of the same
|
||||
* script extends it, 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 {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}
|
||||
*/
|
||||
export function hudSummaryHasEnglish(summary, locale, words = []) {
|
||||
if (hudSummaryLocale(locale) === DEFAULT_LOCALE) return false;
|
||||
const flat = (value) =>
|
||||
String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/-/g, ' ');
|
||||
const text = flat(summary);
|
||||
const latin = (char) => /\p{Script=Latin}/u.test(char ?? '');
|
||||
return words.some((word) => {
|
||||
const needle = flat(word);
|
||||
if (!needle) return false;
|
||||
for (
|
||||
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;
|
||||
const tag = hudSummaryLocale(locale);
|
||||
if (tag === DEFAULT_LOCALE) return false;
|
||||
const letters = String(summary || '').replace(/[^\p{L}]/gu, '');
|
||||
if (!hudSummaryIsLatin(tag) && !letters.replace(/\p{Script=Latin}/gu, ''))
|
||||
return true;
|
||||
return words.some((word) => hasWord(summary, word));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
/** Whether the locale is written in Latin letters. */
|
||||
export function hudSummaryIsLatin(locale) {
|
||||
return !NON_LATIN_LOCALES.test(hudSummaryLocale(locale));
|
||||
}
|
||||
|
||||
/** Whether the summary for this locale is capped in characters, not words. */
|
||||
export function hudSummaryIsUnspaced(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.
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
HUD_SUMMARY_FEED_STATES,
|
||||
@@ -233,9 +232,10 @@ test('HUD summary instructions ask for the canonical feedState as its own field'
|
||||
'feedState',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.enum,
|
||||
[...HUD_SUMMARY_FEED_STATES],
|
||||
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
|
||||
[{ 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'));
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ test('shipped catalogs get their exact language, and CJK a character limit', ()
|
||||
hudSummaryInstructions('ja'),
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -350,10 +350,35 @@ test('a Japanese summary is capped in characters, not words', async () => {
|
||||
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
|
||||
JSON.stringify({ summary: long, feedState: 'stale' }),
|
||||
);
|
||||
assert.equal(
|
||||
response.body.summary,
|
||||
[...long].slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS).join(''),
|
||||
const shown = response.body.summary;
|
||||
assert.ok(long.startsWith(shown), shown);
|
||||
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 [
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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 layers = [
|
||||
{
|
||||
@@ -486,13 +511,4 @@ test('a partial view keeps its warning tag, translated in es and ru', async () =
|
||||
state: 'partial',
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
+2084
-512
File diff suppressed because it is too large.
Load diff
@@ -422,7 +422,10 @@ function analystCard(result) {
|
||||
...(result.feedState && result.feedState !== 'nominal'
|
||||
? [{ 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
|
||||
// overflow in its existing Notes disclosure rather than dropping them.
|
||||
|
||||
Reference in new issue
Block a user