mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into upstream/terrain-partial-heights
This commit is contained in:
28 files changed
+1251
-136
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
- Write the AI HUD summary in the interface language, led by the HUD's own
|
||||||
|
translated feed-state warning. A partial layer no longer reads as off or
|
||||||
|
nominal in the HUD, voice and analyst answers (German, @gekh, #1488).
|
||||||
@@ -5413,8 +5413,11 @@ 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 model is instructed not to infer from coordinates.
|
- 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.
|
||||||
- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update.
|
- 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`.
|
||||||
|
- The model is asked for at most five words; the proxy keeps up to 80 characters, or 20 for Japanese and Chinese, cut at a word boundary unless the first word is longer. It does not count words, since the model reads a name like Rue de Rivoli as one. 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, or partial plus nominal, reads partial, not off or nominal (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.
|
||||||
|
|
||||||
### Place-search providers
|
### Place-search providers
|
||||||
|
|||||||
@@ -1111,6 +1111,9 @@
|
|||||||
"src/voice/globeResetConfirmation.test.mjs",
|
"src/voice/globeResetConfirmation.test.mjs",
|
||||||
"src/hudDms.test.mjs",
|
"src/hudDms.test.mjs",
|
||||||
"src/hudSummaryGate.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/hudPlaceLabels.test.mjs",
|
||||||
"src/voice/panel/preferences.test.mjs",
|
"src/voice/panel/preferences.test.mjs",
|
||||||
"src/voice/panel/preferencesView.test.mjs",
|
"src/voice/panel/preferencesView.test.mjs",
|
||||||
|
|||||||
@@ -485,6 +485,7 @@
|
|||||||
"exports": ["./server/providers/openai"],
|
"exports": ["./server/providers/openai"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
|
"src/i18n/locale.js",
|
||||||
"src/hudLayoutPolicy.js",
|
"src/hudLayoutPolicy.js",
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js';
|
||||||
import {
|
import {
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_MAX_CHARS,
|
||||||
|
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||||
|
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||||
|
hudSummaryInput,
|
||||||
|
hudSummaryInstructions,
|
||||||
|
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';
|
||||||
@@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFiveWordHudSummary(value) {
|
/** Parse the summary and normalize the canonical feed state. */
|
||||||
return String(value || '')
|
function parseHudSummaryReply(text, locale) {
|
||||||
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
let reply = null;
|
||||||
.trim()
|
try {
|
||||||
|
reply = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
/* not JSON: no summary */
|
||||||
|
}
|
||||||
|
const feedState = SNAPSHOT_FEED_STATES.includes(reply?.feedState)
|
||||||
|
? reply.feedState
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
summary: toHudSummary(reply?.summary, locale),
|
||||||
|
feedState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHudSummary(value, requested) {
|
||||||
|
const locale = hudSummaryLocale(requested);
|
||||||
|
const words = String(value || '')
|
||||||
|
.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+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean)
|
.map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, ''))
|
||||||
.slice(0, 5)
|
.filter(Boolean);
|
||||||
.join(' ');
|
// Characters, not words: the model counts Rue de Rivoli as one word.
|
||||||
|
const max = hudSummaryIsUnspaced(locale)
|
||||||
|
? HUD_SUMMARY_MAX_UNSPACED_CHARS
|
||||||
|
: HUD_SUMMARY_MAX_CHARS;
|
||||||
|
return capAtWord(words.join(' '), max, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = [
|
||||||
|
...new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(text),
|
||||||
|
];
|
||||||
|
if (graphemes.length <= max) return text;
|
||||||
|
const cut = graphemes[max].index;
|
||||||
|
let end = 0;
|
||||||
|
for (const { index, segment } of new Intl.Segmenter(locale, {
|
||||||
|
granularity: 'word',
|
||||||
|
}).segment(text)) {
|
||||||
|
if (index + segment.length > cut) break;
|
||||||
|
end = index + segment.length;
|
||||||
|
}
|
||||||
|
return text.slice(0, end).trim() || text.slice(0, cut);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleHudSummary(req, res) {
|
async function handleHudSummary(req, res) {
|
||||||
@@ -77,14 +128,18 @@ async function handleHudSummary(req, res) {
|
|||||||
model:
|
model:
|
||||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||||
instructions: HUD_SUMMARY_INSTRUCTIONS,
|
instructions: hudSummaryInstructions(context?.locale),
|
||||||
input: JSON.stringify(context),
|
input: hudSummaryInput(context),
|
||||||
|
text: { format: HUD_SUMMARY_RESPONSE_FORMAT },
|
||||||
reasoning: { effort: 'minimal' },
|
reasoning: { effort: 'minimal' },
|
||||||
max_output_tokens: 100,
|
max_output_tokens: 200,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
const { summary, feedState } = parseHudSummaryReply(
|
||||||
|
extractOpenAiResponseText(data),
|
||||||
|
context?.locale,
|
||||||
|
);
|
||||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -93,6 +148,7 @@ async function handleHudSummary(req, res) {
|
|||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
summary: summary || null,
|
summary: summary || null,
|
||||||
|
feedState,
|
||||||
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
// Never relay `data.error.message`: that is OpenAI's own wording, and
|
||||||
// it carries request ids, organization hints and quota phrasing.
|
// it carries request ids, organization hints and quota phrasing.
|
||||||
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
error: response.ok ? null : 'OpenAI HUD summary request failed',
|
||||||
@@ -110,4 +166,4 @@ async function handleHudSummary(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { handleHudSummary };
|
export { handleHudSummary, parseHudSummaryReply };
|
||||||
@@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([
|
|||||||
'loading',
|
'loading',
|
||||||
'degraded',
|
'degraded',
|
||||||
'stale',
|
'stale',
|
||||||
|
'partial',
|
||||||
'fallback',
|
'fallback',
|
||||||
'unavailable',
|
'unavailable',
|
||||||
]);
|
]);
|
||||||
@@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({
|
|||||||
loading: 'LOADING',
|
loading: 'LOADING',
|
||||||
degraded: 'DEGRADED',
|
degraded: 'DEGRADED',
|
||||||
stale: 'STALE',
|
stale: 'STALE',
|
||||||
|
partial: 'PARTIAL',
|
||||||
fallback: 'FALLBACK',
|
fallback: 'FALLBACK',
|
||||||
unavailable: 'UNAVAILABLE',
|
unavailable: 'UNAVAILABLE',
|
||||||
});
|
});
|
||||||
@@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({
|
|||||||
degraded: 2,
|
degraded: 2,
|
||||||
stale: 3,
|
stale: 3,
|
||||||
fallback: 4,
|
fallback: 4,
|
||||||
nominal: 5,
|
partial: 5,
|
||||||
off: 6,
|
nominal: 6,
|
||||||
|
off: 7,
|
||||||
});
|
});
|
||||||
|
|
||||||
export { layerFeedState } from './feedState.js';
|
export { layerFeedState } from './feedState.js';
|
||||||
@@ -90,7 +93,7 @@ export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
|
|||||||
/**
|
/**
|
||||||
* Pick the most severe feed-state from a list.
|
* Pick the most severe feed-state from a list.
|
||||||
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
* @param {Iterable<string|null|undefined>} states Feed-state strings.
|
||||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
|
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'|'off'|null}
|
||||||
*/
|
*/
|
||||||
export function worstFeedState(states) {
|
export function worstFeedState(states) {
|
||||||
let worst = null;
|
let worst = null;
|
||||||
@@ -205,6 +208,8 @@ export function feedProvenanceNote(layers = [], overall = null) {
|
|||||||
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
|
||||||
});
|
});
|
||||||
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
|
||||||
|
if (overall === 'partial')
|
||||||
|
return `${body}. Narrate that coverage is incomplete with any count; do not claim full coverage.`;
|
||||||
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) {
|
|||||||
lastUpdate: snap.lastUpdate,
|
lastUpdate: snap.lastUpdate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed state and up to two layer names of a provenance envelope that is
|
||||||
|
* 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}
|
||||||
|
*/
|
||||||
|
export function provenanceTag(envelope) {
|
||||||
|
const overall = envelope?.overall;
|
||||||
|
if (!overall || overall === 'nominal' || overall === 'off') return null;
|
||||||
|
const names = (envelope.layers || [])
|
||||||
|
.filter(
|
||||||
|
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
FEED_STATE_SEVERITY[a.feedState] - FEED_STATE_SEVERITY[b.feedState],
|
||||||
|
)
|
||||||
|
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||||
|
.slice(0, 2);
|
||||||
|
return { state: overall, names };
|
||||||
|
}
|
||||||
|
|
||||||
|
const upperCase = (word) => word.toUpperCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `STATE | text | LAYERS`: the state leads so a narrow readout never clips
|
||||||
|
* it; the layer names trail so they never push out the text.
|
||||||
|
* @param {string} text
|
||||||
|
* @param {{ state: string, names: string[] }|null} tag From `provenanceTag`.
|
||||||
|
* @param {(state: string) => string} [stateLabel]
|
||||||
|
* @param {(name: string) => string} [layerLabel]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function provenanceLine(
|
||||||
|
text,
|
||||||
|
tag,
|
||||||
|
stateLabel = upperCase,
|
||||||
|
layerLabel = upperCase,
|
||||||
|
) {
|
||||||
|
if (!tag) return text;
|
||||||
|
return [stateLabel(tag.state), text, tag.names.map(layerLabel).join('/')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a matching feedState for warning states: a view that is not
|
||||||
|
* nominal falls back on a missing or different state.
|
||||||
|
* @param {{ feedState?: string|null }|null} response The proxy's reply.
|
||||||
|
* @param {{ overall?: string|null }|null} provenance The context's envelope.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hudSummaryMatchesProvenance(response, provenance) {
|
||||||
|
const state = provenance?.overall;
|
||||||
|
if (!state || state === 'nominal' || state === 'off') return true;
|
||||||
|
return response?.feedState === state;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
layerFeedState,
|
layerFeedState,
|
||||||
layerSnapshot,
|
layerSnapshot,
|
||||||
layerSnapshots,
|
layerSnapshots,
|
||||||
|
provenanceTag,
|
||||||
snapshotAgeLabel,
|
snapshotAgeLabel,
|
||||||
snapshotAgeSec,
|
snapshotAgeSec,
|
||||||
viewStateLayerRow,
|
viewStateLayerRow,
|
||||||
@@ -90,7 +91,15 @@ test('layerFeedState matches the chip classifier for every honesty class', () =>
|
|||||||
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
test('chip labels and severity cover every feed-state the envelope can emit', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[...LAYER_FEED_STATES],
|
[...LAYER_FEED_STATES],
|
||||||
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
|
[
|
||||||
|
'nominal',
|
||||||
|
'loading',
|
||||||
|
'degraded',
|
||||||
|
'stale',
|
||||||
|
'partial',
|
||||||
|
'fallback',
|
||||||
|
'unavailable',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
|
||||||
for (const state of LAYER_FEED_STATES) {
|
for (const state of LAYER_FEED_STATES) {
|
||||||
@@ -107,6 +116,33 @@ test('chip labels and severity cover every feed-state the envelope can emit', ()
|
|||||||
assert.equal(worstFeedState([]), null);
|
assert.equal(worstFeedState([]), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('partial is a feed-state of its own: above nominal, below every fault', () => {
|
||||||
|
const layer = (id, stats) => ({ id, name: id, enabled: true, stats });
|
||||||
|
const partial = layer('flights', {
|
||||||
|
partial: true,
|
||||||
|
count: 4,
|
||||||
|
lastUpdate: NOW,
|
||||||
|
});
|
||||||
|
const nominal = layer('vessels', { count: 9, lastUpdate: NOW });
|
||||||
|
const stale = layer('cctv', { stale: true, count: 2, lastUpdate: NOW });
|
||||||
|
const overall = (layers) =>
|
||||||
|
feedProvenanceEnvelope(layers, { now: NOW }).overall;
|
||||||
|
assert.equal(overall([partial]), 'partial');
|
||||||
|
assert.equal(overall([partial, nominal]), 'partial');
|
||||||
|
assert.equal(overall([nominal, partial]), 'partial');
|
||||||
|
assert.equal(overall([partial, stale]), 'stale');
|
||||||
|
assert.equal(overall([stale, partial]), 'stale');
|
||||||
|
assert.equal(LAYER_FEED_STATE_LABELS.partial, 'PARTIAL');
|
||||||
|
const note = feedProvenanceEnvelope([partial], { now: NOW }).note;
|
||||||
|
assert.match(note, /coverage is incomplete/);
|
||||||
|
assert.doesNotMatch(note, /do not describe this as live/);
|
||||||
|
assert.equal(
|
||||||
|
FEED_STATE_SEVERITY.fallback < FEED_STATE_SEVERITY.partial &&
|
||||||
|
FEED_STATE_SEVERITY.partial < FEED_STATE_SEVERITY.nominal,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('a disabled layer is off even when leftover stats would read stale', () => {
|
test('a disabled layer is off even when leftover stats would read stale', () => {
|
||||||
const snap = layerSnapshot(
|
const snap = layerSnapshot(
|
||||||
{
|
{
|
||||||
@@ -258,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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
+152
-40
@@ -32,17 +32,31 @@ import {
|
|||||||
} from './data/geoid.js';
|
} from './data/geoid.js';
|
||||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||||
import {
|
import {
|
||||||
hudSummaryMatchesProvenance,
|
hasWord,
|
||||||
|
hudSummaryHasEnglish,
|
||||||
|
hudSummaryIsLatin,
|
||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
hudTelemetryProvenance,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
|
import {
|
||||||
|
hudSummaryMatchesProvenance,
|
||||||
|
provenanceLine,
|
||||||
|
provenanceTag,
|
||||||
|
} from './data/layerSnapshot.js';
|
||||||
import {
|
import {
|
||||||
applyHudUiTheme,
|
applyHudUiTheme,
|
||||||
DEFAULT_HUD_LAYOUT,
|
DEFAULT_HUD_LAYOUT,
|
||||||
normalizeHudLayout,
|
normalizeHudLayout,
|
||||||
} from './hudLayouts.js';
|
} from './hudLayouts.js';
|
||||||
import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js';
|
import {
|
||||||
|
formatNumber,
|
||||||
|
getLocale,
|
||||||
|
msg,
|
||||||
|
onLocaleChange,
|
||||||
|
t,
|
||||||
|
} 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';
|
||||||
@@ -108,23 +122,58 @@ const FEED_STATE_LABELS = Object.freeze({
|
|||||||
unavailable: msg('UNAVAILABLE'),
|
unavailable: msg('UNAVAILABLE'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A summary line with its warning in the active language. */
|
||||||
|
function withProvenance(text, provenance) {
|
||||||
|
return provenanceLine(
|
||||||
|
text,
|
||||||
|
provenance,
|
||||||
|
(state) =>
|
||||||
|
FEED_STATE_LABELS[state]
|
||||||
|
? t(FEED_STATE_LABELS[state])
|
||||||
|
: state.toUpperCase(),
|
||||||
|
(name) => localizeLayerName(name).toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The summary's feed-state suffix in the active language, e.g.
|
* The AI line. In an RTL locale a leading right-to-left mark keeps a reply
|
||||||
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
|
* that opens with a Latin name (Times Square …) reading right to left.
|
||||||
* layer data; only this line translates them.
|
|
||||||
*/
|
*/
|
||||||
function localizedProvenanceTag(provenance) {
|
function aiSummaryLine(summary, warning) {
|
||||||
if (!provenance) return null;
|
const line = withProvenance(summary, warning);
|
||||||
const state = FEED_STATE_LABELS[provenance.state]
|
return isRtlLocale(getLocale()) ? `\u200f${line}` : line;
|
||||||
? t(FEED_STATE_LABELS[provenance.state])
|
}
|
||||||
: provenance.state.toUpperCase();
|
|
||||||
if (!provenance.names.length) return state;
|
/**
|
||||||
return t('{state} {layers}', {
|
* What a summary outside English must not carry: feed-state ids, translated
|
||||||
state,
|
* layers' English names and ids, and with every feed healthy the
|
||||||
layers: provenance.names
|
* UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK)
|
||||||
.map((name) => localizeLayerName(name).toUpperCase())
|
* and words of a place label (Radio City, Loading Dock) are fine; so is a
|
||||||
.join('/'),
|
* 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(' ');
|
||||||
|
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 &&
|
||||||
|
!hasWord(places, state),
|
||||||
|
),
|
||||||
|
...(context.englishLayerWords || []).filter(
|
||||||
|
(word) =>
|
||||||
|
!hasWord(places, word) && (!latin || /[^\p{L}\p{N}]/u.test(word)),
|
||||||
|
),
|
||||||
|
...(!overall || overall === 'nominal'
|
||||||
|
? [t(FEED_STATE_LABELS.unavailable)]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The name shown for a _viewBand() id. */
|
/** The name shown for a _viewBand() id. */
|
||||||
@@ -221,7 +270,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..."
|
||||||
@@ -250,12 +299,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();
|
||||||
@@ -295,12 +349,12 @@ 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 deterministic summary
|
|
||||||
* is composed again; the AI reply does not depend on the locale, so the
|
|
||||||
* next tick shows the one already received rather than asking again.
|
|
||||||
*/
|
|
||||||
_relocalize() {
|
_relocalize() {
|
||||||
|
this._markSummaryDirty();
|
||||||
|
this._lastSummarySignature = null;
|
||||||
|
this._lastSummaryReply = null;
|
||||||
|
this._summaryRequest?.abort();
|
||||||
this._present({
|
this._present({
|
||||||
mode: MODE_LABELS[this._currentStyle]
|
mode: MODE_LABELS[this._currentStyle]
|
||||||
? t(MODE_LABELS[this._currentStyle])
|
? t(MODE_LABELS[this._currentStyle])
|
||||||
@@ -763,8 +817,8 @@ export class IntelHUD {
|
|||||||
LOCALIZED_LOCALITY,
|
LOCALIZED_LOCALITY,
|
||||||
);
|
);
|
||||||
|
|
||||||
const provenance = localizedProvenanceTag(
|
const provenance = hudTelemetryProvenance(
|
||||||
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
|
this._dataManager?.getAll?.() || [],
|
||||||
);
|
);
|
||||||
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
||||||
const line = t(
|
const line = t(
|
||||||
@@ -781,7 +835,7 @@ export class IntelHUD {
|
|||||||
zone: localTag,
|
zone: localTag,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return provenance ? `${line} | ${provenance}` : line;
|
return withProvenance(line, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop a summary still typing out, leaving what it has typed so far. */
|
/** Stop a summary still typing out, leaving what it has typed so far. */
|
||||||
@@ -854,12 +908,28 @@ export class IntelHUD {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (revision !== this._summaryRevision) return;
|
if (revision !== this._summaryRevision) return;
|
||||||
|
// Without a place label the model invents a place: show the plain line.
|
||||||
|
const described = [
|
||||||
|
context.placeLabels,
|
||||||
|
context.streetLabels,
|
||||||
|
context.nearbyPlaceLabels,
|
||||||
|
].some((list) => list?.length);
|
||||||
|
if (!described) {
|
||||||
|
this._summaryDirty = false;
|
||||||
|
this._setSummaryText(fallbackText, false);
|
||||||
|
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;
|
||||||
// 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;
|
||||||
@@ -867,7 +937,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(() => {
|
||||||
@@ -890,9 +960,23 @@ 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.summary, context.feedProvenance))
|
if (
|
||||||
this._lastSummaryText = data.summary;
|
hudSummaryMatchesProvenance(data, context.feedProvenance) &&
|
||||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
!hudSummaryHasEnglish(
|
||||||
|
data.summary,
|
||||||
|
context.locale,
|
||||||
|
summaryForbiddenWords(context),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this._lastSummaryReply = data.summary;
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
@@ -950,18 +1034,46 @@ 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 enabledLayers =
|
const locale = getLocale();
|
||||||
this._dataManager
|
const layerContext = hudSummaryLayerContext(
|
||||||
?.getAll?.()
|
this._dataManager?.getAll?.() || [],
|
||||||
?.filter((layer) => layer.enabled)
|
);
|
||||||
.map((layer) => layer.name) || [];
|
// Outside English a layer's state id is left out: the model echoes it,
|
||||||
return {
|
// and `overall` already carries the state.
|
||||||
|
const named = ({ feedState, ...layer }) => ({
|
||||||
|
...layer,
|
||||||
|
name: localizeLayerName(layer.name),
|
||||||
|
...(locale === 'en' && { feedState }),
|
||||||
|
});
|
||||||
|
const { feedProvenance } = layerContext;
|
||||||
|
const context = {
|
||||||
|
locale,
|
||||||
placeLabels: labels.placeLabels,
|
placeLabels: labels.placeLabels,
|
||||||
streetLabels: labels.streetLabels,
|
streetLabels: labels.streetLabels,
|
||||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||||
enabledLayerLabels: enabledLayers,
|
// The model sees only the layer names the user sees. The English note
|
||||||
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
// goes too: a small model copies its words. `overall` stays the id.
|
||||||
|
enabledLayerLabels:
|
||||||
|
layerContext.enabledLayerLabels.map(localizeLayerName),
|
||||||
|
enabledLayers: layerContext.enabledLayers.map(named),
|
||||||
|
feedProvenance: {
|
||||||
|
...feedProvenance,
|
||||||
|
layers: feedProvenance.layers.map(named),
|
||||||
|
note: locale === 'en' ? feedProvenance.note : undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
// Not sent and not part of the signature: the line's own warning, taken
|
||||||
|
// 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) },
|
||||||
|
englishLayerWords: {
|
||||||
|
value: layerContext.enabledLayers
|
||||||
|
.filter(({ name }) => localizeLayerName(name) !== name)
|
||||||
|
.flatMap(({ name, id }) => [name, id].filter(Boolean)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
_markSummaryDirty() {
|
_markSummaryDirty() {
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ test('a summary asked for one view is dropped once the camera moves', async () =
|
|||||||
try {
|
try {
|
||||||
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||||
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();
|
||||||
@@ -321,7 +321,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');
|
||||||
@@ -354,7 +354,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, publish: env.publish });
|
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||||
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();
|
||||||
@@ -416,7 +416,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, publish: env.publish });
|
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||||
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);
|
||||||
@@ -474,7 +474,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, publish: env.publish });
|
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||||
// 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();
|
||||||
@@ -501,15 +501,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 {
|
||||||
@@ -536,7 +538,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, publish: env.publish });
|
hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish });
|
||||||
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,300 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import './testSupport/stubMgrs.mjs';
|
||||||
|
import { hudReadouts } from './testSupport/hudReadouts.mjs';
|
||||||
|
|
||||||
|
const { IntelHUD } = await import('./hud.js');
|
||||||
|
const { setLocale } = await import('./i18n/index.js');
|
||||||
|
const { hudSummaryHasEnglish, hudSummaryInput } =
|
||||||
|
await import('./hudSummaryResponse.js');
|
||||||
|
|
||||||
|
const PARTIAL_FLIGHTS = {
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: 4, partial: true, source: 'opensky' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function createHud(
|
||||||
|
t,
|
||||||
|
reply,
|
||||||
|
{ places = ['Остин'], locale = 'ru', layers = () => [PARTIAL_FLIGHTS] } = {},
|
||||||
|
) {
|
||||||
|
const { elements, publish } = hudReadouts();
|
||||||
|
const previous = { document: globalThis.document, window: globalThis.window };
|
||||||
|
globalThis.document = {
|
||||||
|
hidden: false,
|
||||||
|
visibilityState: 'visible',
|
||||||
|
getElementById: (id) => elements.get(id) ?? 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, {
|
||||||
|
publish,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// The test viewer has no view target, so no place lookup runs.
|
||||||
|
const buildContext = hud._summaryContext.bind(hud);
|
||||||
|
hud._summaryContext = async () =>
|
||||||
|
Object.assign(await buildContext(), { placeLabels: places });
|
||||||
|
hud._dataManager = { getAll: layers };
|
||||||
|
hud._latestMetrics = { altM: 600 };
|
||||||
|
setLocale(locale);
|
||||||
|
return {
|
||||||
|
hud,
|
||||||
|
sent,
|
||||||
|
shown: () => elements.get('hud-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), /englishLayerWords|warning/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 данные',
|
||||||
|
'Остин flights частично онлайн',
|
||||||
|
'Austin flights partially online',
|
||||||
|
]) {
|
||||||
|
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 view with layers but no place label does not ask the model', async (t) => {
|
||||||
|
const { hud, sent, shown } = createHud(
|
||||||
|
t,
|
||||||
|
{ summary: 'Москва Рейсы онлайн частично', feedState: 'partial' },
|
||||||
|
{ places: [] },
|
||||||
|
);
|
||||||
|
await hud._updateSummary(false, true);
|
||||||
|
assert.equal(sent.length, 0);
|
||||||
|
assert.equal(shown(), hud._composeSummary());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ja request leaves the layer state ids to `overall`', async (t) => {
|
||||||
|
const { hud } = createHud(t, {}, { locale: 'ja' });
|
||||||
|
const context = await hud._summaryContext();
|
||||||
|
assert.equal(context.feedProvenance.overall, 'partial');
|
||||||
|
assert.ok(!('feedState' in context.enabledLayers[0]));
|
||||||
|
assert.ok(!('feedState' in context.feedProvenance.layers[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [locale, summary] of [
|
||||||
|
['ja', '東京上空のstale航空情報'],
|
||||||
|
['ja', '東京上空のLive Flights'],
|
||||||
|
['zh-CN', '北京航班stale数据'],
|
||||||
|
['ru', 'Остин Live-Flights частично'],
|
||||||
|
]) {
|
||||||
|
test(`English flush against other text is an echo: ${summary}`, () => {
|
||||||
|
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('English inside a longer Latin word is not an echo', () => {
|
||||||
|
assert.ok(!hudSummaryHasEnglish('Datos stalesque', 'es', ['stale']));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [locale, summary, places] of [
|
||||||
|
['es', 'Tráfico aéreo nominal sobre Madrid', ['Madrid']],
|
||||||
|
['de', 'Fallback Daten über Berlin', ['Berlin']],
|
||||||
|
['ja', 'Live Flights Plaza周辺', ['Live Flights Plaza']],
|
||||||
|
['es', 'Estación Loading Dock Madrid', ['Estación Loading Dock']],
|
||||||
|
]) {
|
||||||
|
test(`a ${locale} word that is not an echo is shown: ${summary}`, async (t) => {
|
||||||
|
const { hud, shown } = createHud(
|
||||||
|
t,
|
||||||
|
{ summary, feedState: 'partial' },
|
||||||
|
{ places, locale },
|
||||||
|
);
|
||||||
|
await hud._updateSummary(false, true);
|
||||||
|
assert.ok(shown().includes(summary), shown());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a language switch drops the reply in flight and asks again', async (t) => {
|
||||||
|
const { hud, sent, shown } = createHud(t, {});
|
||||||
|
let answer;
|
||||||
|
hud.summaryService.summarize = (context, { signal }) => {
|
||||||
|
sent.push(context);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
answer = () =>
|
||||||
|
resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
summary: 'Остин рейсы частично',
|
||||||
|
feedState: 'partial',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const pending = hud._updateSummary(false, true);
|
||||||
|
while (!answer) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
setLocale('es');
|
||||||
|
answer();
|
||||||
|
await pending;
|
||||||
|
assert.ok(!shown().includes('Остин'), shown());
|
||||||
|
assert.equal(shown(), hud._composeSummary());
|
||||||
|
|
||||||
|
hud.summaryService.summarize = async (context) => {
|
||||||
|
sent.push(context);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
summary: 'Austin vuelos parciales',
|
||||||
|
feedState: 'partial',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
await hud._updateSummary(false);
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [locale, place, summary] of [
|
||||||
|
['zh-CN', '北京', '北京航班不可用'],
|
||||||
|
['zh-Hant', '北京', '北京航班不可用'],
|
||||||
|
['ja', '東京', '東京航空便利用不可'],
|
||||||
|
]) {
|
||||||
|
test(`a ${locale} reply calling a feed unavailable flush against Han falls back`, async (t) => {
|
||||||
|
const { hud, shown } = createHud(
|
||||||
|
t,
|
||||||
|
{ summary, feedState: null },
|
||||||
|
{ locale, places: [place], 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);
|
||||||
|
});
|
||||||
+181
-27
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
SNAPSHOT_FEED_STATES,
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
layerSnapshots,
|
layerSnapshots,
|
||||||
|
provenanceTag,
|
||||||
} from './data/layerSnapshot.js';
|
} from './data/layerSnapshot.js';
|
||||||
|
import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js';
|
||||||
|
|
||||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||||
|
|
||||||
@@ -43,19 +46,189 @@ export function isHudSummaryUnconfigured(status, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HUD summary model instructions. The five-word line may only describe
|
* HUD summary model instructions. The five words may only describe supplied
|
||||||
* supplied labels, and must carry feed-state when any enabled layer is not
|
* labels; the feed state comes back as its own canonical field, so the check
|
||||||
* nominal.
|
* does not depend on the summary's language or wording.
|
||||||
*/
|
*/
|
||||||
export const HUD_SUMMARY_INSTRUCTIONS = [
|
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.',
|
||||||
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
|
'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 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.',
|
'Do not infer from coordinates or invent a place or feed state.',
|
||||||
'Output 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. */
|
||||||
|
export const HUD_SUMMARY_MAX_UNSPACED_CHARS = 20;
|
||||||
|
|
||||||
|
/** Longest summary, in characters; the cut falls between words. */
|
||||||
|
export const HUD_SUMMARY_MAX_CHARS = 80;
|
||||||
|
|
||||||
|
/** Responses API structured output: the summary plus the canonical state. */
|
||||||
|
export const HUD_SUMMARY_RESPONSE_FORMAT = Object.freeze({
|
||||||
|
type: 'json_schema',
|
||||||
|
name: 'hud_summary',
|
||||||
|
strict: true,
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string' },
|
||||||
|
// Nullable rather than a `none` token, which the model wrote into the
|
||||||
|
// summary of a view without layers.
|
||||||
|
feedState: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] },
|
||||||
|
{ type: 'null' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['summary', 'feedState'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A shipped locale for the request, English when missing or unsupported. */
|
||||||
|
export function hudSummaryLocale(value) {
|
||||||
|
return normalizeLocale(value) || DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names Intl.DisplayNames gets wrong for the shipped catalogs: pt is the
|
||||||
|
// Brazilian variant, and zh-Hant is the zh-CN wording in Traditional script.
|
||||||
|
const SUMMARY_LANGUAGES = Object.freeze({
|
||||||
|
pt: 'Brazilian Portuguese',
|
||||||
|
'zh-CN': 'Simplified Chinese',
|
||||||
|
'zh-Hant': 'Chinese in Traditional characters (mainland wording)',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Scripts written without spaces, capped in characters instead of words. */
|
||||||
|
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) => `\\p{Script=${script}}`);
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNSPACED_WORD =
|
||||||
|
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+$/u;
|
||||||
|
|
||||||
|
function wordScript(char) {
|
||||||
|
return WORD_SCRIPTS.find((script) => new RegExp(script, 'u').test(char));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* Han and kana have no word edges: a word in them counts anywhere.
|
||||||
|
* @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;
|
||||||
|
if (UNSPACED_WORD.test(needle)) return haystack.includes(needle);
|
||||||
|
const chars = [...needle];
|
||||||
|
const before = wordScript(chars[0]);
|
||||||
|
const after = wordScript(chars.at(-1));
|
||||||
|
// flatWords leaves no regex syntax in the needle.
|
||||||
|
return new RegExp(
|
||||||
|
`${before ? `(?<!${before})` : ''}${needle}${after ? `(?!${after})` : ''}`,
|
||||||
|
'u',
|
||||||
|
).test(haystack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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] Words the summary must not carry.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hudSummaryHasEnglish(summary, locale, words = []) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the locale is written in Latin letters. */
|
||||||
|
export function hudSummaryIsLatin(locale) {
|
||||||
|
return !NON_LATIN_LOCALES.test(hudSummaryLocale(locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hudSummaryIsUnspaced(locale) {
|
||||||
|
return UNSPACED_LOCALES.test(hudSummaryLocale(locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The language a request's summary is written in, named in English. */
|
||||||
|
function summaryLanguage(tag) {
|
||||||
|
if (SUMMARY_LANGUAGES[tag]) return SUMMARY_LANGUAGES[tag];
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames(['en'], { type: 'language' }).of(tag) || tag;
|
||||||
|
} catch {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} locale The `locale` the HUD sent.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function hudSummaryInstructions(locale) {
|
||||||
|
const tag = hudSummaryLocale(locale);
|
||||||
|
if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS;
|
||||||
|
const language = summaryLanguage(tag);
|
||||||
|
const length = hudSummaryIsUnspaced(tag)
|
||||||
|
? ` Instead of five words, write one phrase of at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters.`
|
||||||
|
: '';
|
||||||
|
return `${HUD_SUMMARY_INSTRUCTIONS} Write the summary in ${language} (${tag}), not English. Give places their usual names in that language and layers the names in enabledLayerLabels; feedState stays the English value.${length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model input: the HUD context, led by the summary language when it is
|
||||||
|
* not English. A small model follows the language far more reliably when it
|
||||||
|
* is part of the input as well as the instructions.
|
||||||
|
* @param {object} context The HUD summary context.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function hudSummaryInput(context) {
|
||||||
|
const tag = hudSummaryLocale(context?.locale);
|
||||||
|
const json = JSON.stringify(context ?? {});
|
||||||
|
if (tag === DEFAULT_LOCALE) return json;
|
||||||
|
return `Summary language: ${summaryLanguage(tag)} (${tag}).\n${json}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stamp HUD summary context with the same layer snapshots voice tools use.
|
* Stamp HUD summary context with the same layer snapshots voice tools use.
|
||||||
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
|
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
|
||||||
@@ -83,32 +256,13 @@ export function hudSummaryLayerContext(layers = [], options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The feed state and up to two layer names for the HUD telemetry suffix when
|
* The warning for the HUD telemetry line when an enabled feed is not
|
||||||
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
|
* nominal.
|
||||||
* id, `names` are `layer.name`); the HUD translates them for display.
|
|
||||||
* @param {Array<object>} [layers]
|
* @param {Array<object>} [layers]
|
||||||
* @param {{ now?: number }} [options]
|
* @param {{ now?: number }} [options]
|
||||||
* @returns {{ state: string, names: string[] }|null}
|
* @returns {{ state: string, names: string[] }|null}
|
||||||
*/
|
*/
|
||||||
export function hudTelemetryProvenance(layers = [], options) {
|
export function hudTelemetryProvenance(layers = [], options) {
|
||||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
return provenanceTag(feedProvenanceEnvelope(snapshots, options));
|
||||||
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
|
||||||
const names = envelope.layers
|
|
||||||
.filter(
|
|
||||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
|
||||||
)
|
|
||||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
|
||||||
.slice(0, 2);
|
|
||||||
return { state: envelope.overall, names };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Require the supplied non-nominal state before showing an AI summary. */
|
|
||||||
export function hudSummaryMatchesProvenance(summary, provenance) {
|
|
||||||
const state = provenance?.overall;
|
|
||||||
if (!state || state === 'nominal' || state === 'off') return true;
|
|
||||||
return String(summary || '')
|
|
||||||
.toUpperCase()
|
|
||||||
.split(/\W+/)
|
|
||||||
.includes(String(state).toUpperCase());
|
|
||||||
}
|
}
|
||||||
+273
-24
@@ -1,14 +1,25 @@
|
|||||||
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 {
|
import {
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
HUD_SUMMARY_MAX_UNSPACED_CHARS,
|
||||||
|
HUD_SUMMARY_RESPONSE_FORMAT,
|
||||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
|
hudSummaryInput,
|
||||||
|
hudSummaryInstructions,
|
||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
|
hudSummaryLocale,
|
||||||
hudTelemetryProvenance,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
|
import {
|
||||||
|
SNAPSHOT_FEED_STATES,
|
||||||
|
hudSummaryMatchesProvenance,
|
||||||
|
provenanceTag,
|
||||||
|
} from './data/layerSnapshot.js';
|
||||||
|
import { parseHudSummaryReply } from '../server/providers/openai/hud-summary.js';
|
||||||
import { openAiRealtimeProxy } from '../vite.config.js';
|
import { openAiRealtimeProxy } from '../vite.config.js';
|
||||||
|
|
||||||
const UNCONFIGURED_PAYLOAD = {
|
const UNCONFIGURED_PAYLOAD = {
|
||||||
@@ -206,46 +217,284 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
test('HUD summary instructions ask for the canonical feedState as its own field', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
/five words MUST include that feedState token/,
|
/Set feedState to feedProvenance\.overall exactly/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
|
/do not present the view as live or healthy/,
|
||||||
);
|
);
|
||||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
assert.match(
|
||||||
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
/partial, do not present the coverage as complete/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(HUD_SUMMARY_RESPONSE_FORMAT.schema.required, [
|
||||||
|
'summary',
|
||||||
|
'feedState',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
HUD_SUMMARY_RESPONSE_FORMAT.schema.properties.feedState.anyOf,
|
||||||
|
[{ type: 'string', enum: [...SNAPSHOT_FEED_STATES] }, { type: 'null' }],
|
||||||
|
);
|
||||||
|
assert.ok(!SNAPSHOT_FEED_STATES.includes('none'));
|
||||||
|
assert.ok(SNAPSHOT_FEED_STATES.includes('partial'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
test('the instructions name the UI language, with English as the fallback', () => {
|
||||||
const local = readFileSync(
|
assert.match(hudSummaryInstructions('es'), /in Spanish \(es\)/);
|
||||||
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
assert.match(hudSummaryInstructions('ru-RU'), /in Russian \(ru\)/);
|
||||||
'utf8',
|
assert.equal(hudSummaryInstructions('en'), HUD_SUMMARY_INSTRUCTIONS);
|
||||||
|
assert.equal(hudSummaryInstructions('xx-YY'), HUD_SUMMARY_INSTRUCTIONS);
|
||||||
|
assert.equal(hudSummaryInstructions(undefined), HUD_SUMMARY_INSTRUCTIONS);
|
||||||
|
assert.equal(hudSummaryLocale('es-MX'), 'es');
|
||||||
|
const english = { locale: 'en', placeLabels: ['Austin'] };
|
||||||
|
assert.equal(hudSummaryInput(english), JSON.stringify(english));
|
||||||
|
assert.match(
|
||||||
|
hudSummaryInput({ locale: 'ru' }),
|
||||||
|
/^Summary language: Russian \(ru\)\.\n\{/,
|
||||||
);
|
);
|
||||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
assert.equal(hudSummaryLocale(42), 'en');
|
||||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
test('shipped catalogs get their exact language, and CJK a character limit', () => {
|
||||||
const { hudSummaryMatchesProvenance } =
|
assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/);
|
||||||
await import('./hudSummaryResponse.js');
|
assert.match(
|
||||||
assert.equal(
|
hudSummaryInstructions('zh-Hant'),
|
||||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
/in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/,
|
||||||
overall: 'stale',
|
);
|
||||||
|
assert.match(hudSummaryInstructions('zh-CN'), /in Simplified Chinese/);
|
||||||
|
assert.match(
|
||||||
|
hudSummaryInstructions('ja'),
|
||||||
|
new RegExp(`at most ${HUD_SUMMARY_MAX_UNSPACED_CHARS} characters`),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(hudSummaryInstructions('es'), /characters/);
|
||||||
|
assert.match(hudSummaryInstructions('es'), /names in enabledLayerLabels/);
|
||||||
|
});
|
||||||
|
|
||||||
|
let hudSummaryClient = 0;
|
||||||
|
|
||||||
|
async function postHudSummary(context, reply) {
|
||||||
|
// A fresh address per request: the per-IP limiter outlives a single test.
|
||||||
|
hudSummaryClient += 1;
|
||||||
|
const previousKey = process.env.OPENAI_API_KEY;
|
||||||
|
const previousFetch = globalThis.fetch;
|
||||||
|
process.env.OPENAI_API_KEY = 'test-key';
|
||||||
|
let sent = null;
|
||||||
|
globalThis.fetch = async (url, init) => {
|
||||||
|
sent = JSON.parse(init.body);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ output_text: reply }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// The handler itself: the installed route adds a same-site gate that a
|
||||||
|
// bare test request does not pass.
|
||||||
|
const { handleHudSummary: hud } =
|
||||||
|
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.0.${hudSummaryClient}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
setHeader() {},
|
||||||
|
end(body = '') {
|
||||||
|
resolve({ statusCode: this.statusCode, body: JSON.parse(body) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Promise.resolve(hud(req, res)).catch(reject);
|
||||||
|
});
|
||||||
|
return { sent, response };
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = previousFetch;
|
||||||
|
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||||
|
else process.env.OPENAI_API_KEY = previousKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an es request reaches the model in Spanish and returns the canonical state', async () => {
|
||||||
|
const { sent, response } = await postHudSummary(
|
||||||
|
{
|
||||||
|
locale: 'es',
|
||||||
|
placeLabels: ['Austin'],
|
||||||
|
feedProvenance: { overall: 'stale' },
|
||||||
|
},
|
||||||
|
JSON.stringify({
|
||||||
|
summary: 'Austin vuelos con datos antiguos',
|
||||||
|
feedState: 'stale',
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
assert.match(sent.instructions, /in Spanish \(es\)/);
|
||||||
|
assert.equal(sent.text.format.name, 'hud_summary');
|
||||||
|
const [lead, json] = sent.input.split('\n');
|
||||||
|
assert.equal(lead, 'Summary language: Spanish (es).');
|
||||||
|
assert.equal(JSON.parse(json).locale, 'es');
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.deepEqual(response.body, {
|
||||||
|
summary: 'Austin vuelos con datos antiguos',
|
||||||
|
feedState: 'stale',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Japanese summary is capped in characters, not words', async () => {
|
||||||
|
const long = 'テキサス州議会議事堂周辺の航空機データは古くなっています';
|
||||||
|
const { response } = await postHudSummary(
|
||||||
|
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
|
||||||
|
JSON.stringify({ summary: long, feedState: 'stale' }),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Within the cap a reply comes back whole, in NFC: marks, in-word
|
||||||
|
// apostrophes and dots, multi-word names and Han names included.
|
||||||
|
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')],
|
||||||
|
['fr', 'Rue de Rivoli Vols en direct'],
|
||||||
|
['es', 'Times Square Nueva York Vuelos en vivo'],
|
||||||
|
['ru', 'Times Square Рейсы онлайн и суда'],
|
||||||
|
['en', 'Shibuya 渋谷 live flights overhead'],
|
||||||
|
['es', 'Vuelos sobre 東京駅 datos antiguos'],
|
||||||
|
['ru', 'Башня Тайбэй 101 台北101 рейсы'],
|
||||||
|
['ja', 'サンフランシスコ・ベイエリア'],
|
||||||
|
['ja', '東京 上空 の 航空 情報 は 古い'],
|
||||||
|
['zh-CN', 'SFO 附近 4 架 航班 数据 过期'],
|
||||||
|
['zh-Hant', 'SFO 附近 4 架 航班 數據 過期'],
|
||||||
|
]) {
|
||||||
|
test(`a ${locale} summary within the cap is kept whole: ${summary}`, () => {
|
||||||
|
const reply = JSON.stringify({ summary, feedState: null });
|
||||||
|
assert.equal(
|
||||||
|
parseHudSummaryReply(reply, locale).summary,
|
||||||
|
summary.normalize('NFC'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a summary is capped in characters', 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ja cap keeps the text before a Latin word it would split', async () => {
|
||||||
|
const { response } = await postHudSummary(
|
||||||
|
{ locale: 'ja', feedProvenance: { overall: 'stale' } },
|
||||||
|
JSON.stringify({
|
||||||
|
summary: '東京 羽田空港周辺のFlightAware航空情報',
|
||||||
|
feedState: 'stale',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(response.body.summary, '東京 羽田空港周辺の');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reply that skips the format carries no summary and no state', async () => {
|
||||||
|
const { response } = await postHudSummary(
|
||||||
|
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
|
||||||
|
'Остин рейсы данные устарели сейчас',
|
||||||
|
);
|
||||||
|
assert.equal(response.body.summary, null);
|
||||||
|
assert.equal(response.body.feedState, null);
|
||||||
|
const unknown = await postHudSummary(
|
||||||
|
{ locale: 'ru', feedProvenance: { overall: 'stale' } },
|
||||||
|
JSON.stringify({ summary: 'Остин рейсы', feedState: 'fine' }),
|
||||||
|
);
|
||||||
|
assert.equal(unknown.response.body.feedState, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a localized summary is shown only with the HUD's own canonical state", () => {
|
||||||
|
const stale = { overall: 'stale' };
|
||||||
|
assert.equal(
|
||||||
|
hudSummaryMatchesProvenance(
|
||||||
|
{ summary: 'Остин рейсы данные устарели сейчас', feedState: 'stale' },
|
||||||
|
stale,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
hudSummaryMatchesProvenance(
|
||||||
|
{ summary: 'Austin vuelos en vivo ahora', feedState: 'nominal' },
|
||||||
|
stale,
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
|
hudSummaryMatchesProvenance(
|
||||||
overall: 'stale',
|
{ summary: 'Austin stale flights over downtown', feedState: null },
|
||||||
}),
|
stale,
|
||||||
true,
|
),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
hudSummaryMatchesProvenance(
|
||||||
overall: 'nominal',
|
{ summary: 'Austin flights' },
|
||||||
}),
|
{ overall: 'partial' },
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
hudSummaryMatchesProvenance(
|
||||||
|
{ summary: 'Austin flights operating normally today' },
|
||||||
|
{ overall: 'nominal' },
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(hudSummaryMatchesProvenance({ summary: 'x' }, null), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a partial view keeps its warning tag', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
const layers = [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: { partial: true, count: 4, lastUpdate: now },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vessels',
|
||||||
|
name: 'Live AIS Vessels',
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: 9, lastUpdate: now },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const context = hudSummaryLayerContext(layers, { now });
|
||||||
|
assert.equal(context.feedProvenance.overall, 'partial');
|
||||||
|
assert.deepEqual(provenanceTag(context.feedProvenance), {
|
||||||
|
state: 'partial',
|
||||||
|
names: ['Live Flights'],
|
||||||
|
});
|
||||||
|
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
|
||||||
|
state: 'partial',
|
||||||
|
names: ['Live Flights'],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1146,7 +1146,6 @@
|
|||||||
"OCEANIA": "أوقيانوسيا",
|
"OCEANIA": "أوقيانوسيا",
|
||||||
"NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية",
|
"NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية",
|
||||||
"SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية",
|
"SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية",
|
||||||
"{state} {layers}": "{state}: {layers}",
|
|
||||||
"view band|STREET": "شارع",
|
"view band|STREET": "شارع",
|
||||||
"view band|CITY": "مدينة",
|
"view band|CITY": "مدينة",
|
||||||
"view band|METRO": "حضري",
|
"view band|METRO": "حضري",
|
||||||
|
|||||||
@@ -1114,7 +1114,6 @@
|
|||||||
"OCEANIA": "OZEANIEN",
|
"OCEANIA": "OZEANIEN",
|
||||||
"NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER",
|
"NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER",
|
||||||
"SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER",
|
"SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER",
|
||||||
"{state} {layers}": "{state} {layers}",
|
|
||||||
"view band|STREET": "STRASSE",
|
"view band|STREET": "STRASSE",
|
||||||
"view band|CITY": "STADT",
|
"view band|CITY": "STADT",
|
||||||
"view band|METRO": "GROSSRAUM",
|
"view band|METRO": "GROSSRAUM",
|
||||||
|
|||||||
@@ -2021,7 +2021,6 @@
|
|||||||
"Globe field image unavailable": "Imagen del campo en el globo no disponible",
|
"Globe field image unavailable": "Imagen del campo en el globo no disponible",
|
||||||
"status chip|UNAVAILABLE": "NO DISP.",
|
"status chip|UNAVAILABLE": "NO DISP.",
|
||||||
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
|
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
|
||||||
"{state} {layers}": "{state}: {layers}",
|
|
||||||
"Selected Space Mission": "Misión espacial seleccionada",
|
"Selected Space Mission": "Misión espacial seleccionada",
|
||||||
"{duration}s + {hold}s": "{duration} s + {hold} s",
|
"{duration}s + {hold}s": "{duration} s + {hold} s",
|
||||||
"Configured Source": "Fuente configurada",
|
"Configured Source": "Fuente configurada",
|
||||||
|
|||||||
@@ -331,7 +331,6 @@
|
|||||||
"STALE": "OBSOLÈTE",
|
"STALE": "OBSOLÈTE",
|
||||||
"PARTIAL": "PARTIEL",
|
"PARTIAL": "PARTIEL",
|
||||||
"FALLBACK": "SECOURS",
|
"FALLBACK": "SECOURS",
|
||||||
"{state} {layers}": "{state} : {layers}",
|
|
||||||
"view band|STREET": "RUE",
|
"view band|STREET": "RUE",
|
||||||
"view band|CITY": "VILLE",
|
"view band|CITY": "VILLE",
|
||||||
"view band|METRO": "MÉTROPOLE",
|
"view band|METRO": "MÉTROPOLE",
|
||||||
|
|||||||
@@ -331,7 +331,6 @@
|
|||||||
"STALE": "OBSOLETO",
|
"STALE": "OBSOLETO",
|
||||||
"PARTIAL": "PARZIALE",
|
"PARTIAL": "PARZIALE",
|
||||||
"FALLBACK": "RISERVA",
|
"FALLBACK": "RISERVA",
|
||||||
"{state} {layers}": "{state} {layers}",
|
|
||||||
"view band|STREET": "STRADA",
|
"view band|STREET": "STRADA",
|
||||||
"view band|CITY": "CITTÀ",
|
"view band|CITY": "CITTÀ",
|
||||||
"view band|METRO": "METRO",
|
"view band|METRO": "METRO",
|
||||||
|
|||||||
@@ -325,7 +325,6 @@
|
|||||||
"STALE": "更新遅延",
|
"STALE": "更新遅延",
|
||||||
"PARTIAL": "部分的",
|
"PARTIAL": "部分的",
|
||||||
"FALLBACK": "フォールバック",
|
"FALLBACK": "フォールバック",
|
||||||
"{state} {layers}": "{state} {layers}",
|
|
||||||
"view band|STREET": "街路",
|
"view band|STREET": "街路",
|
||||||
"view band|CITY": "市街",
|
"view band|CITY": "市街",
|
||||||
"view band|METRO": "都市圏",
|
"view band|METRO": "都市圏",
|
||||||
|
|||||||
@@ -331,7 +331,6 @@
|
|||||||
"STALE": "DESATUALIZADO",
|
"STALE": "DESATUALIZADO",
|
||||||
"PARTIAL": "PARCIAL",
|
"PARTIAL": "PARCIAL",
|
||||||
"FALLBACK": "ALTERNATIVO",
|
"FALLBACK": "ALTERNATIVO",
|
||||||
"{state} {layers}": "{state}: {layers}",
|
|
||||||
"view band|STREET": "RUA",
|
"view band|STREET": "RUA",
|
||||||
"view band|CITY": "CIDADE",
|
"view band|CITY": "CIDADE",
|
||||||
"view band|METRO": "METRÓPOLE",
|
"view band|METRO": "METRÓPOLE",
|
||||||
|
|||||||
@@ -2055,7 +2055,6 @@
|
|||||||
"Globe field image unavailable": "Изображение поля на глобусе недоступно",
|
"Globe field image unavailable": "Изображение поля на глобусе недоступно",
|
||||||
"status chip|UNAVAILABLE": "НЕДОСТ.",
|
"status chip|UNAVAILABLE": "НЕДОСТ.",
|
||||||
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.",
|
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.",
|
||||||
"{state} {layers}": "{state}: {layers}",
|
|
||||||
"Selected Space Mission": "Выбранная космическая миссия",
|
"Selected Space Mission": "Выбранная космическая миссия",
|
||||||
"{duration}s + {hold}s": "{duration} с + {hold} с",
|
"{duration}s + {hold}s": "{duration} с + {hold} с",
|
||||||
"Configured Source": "Настроенный источник",
|
"Configured Source": "Настроенный источник",
|
||||||
|
|||||||
@@ -334,7 +334,6 @@
|
|||||||
"STALE": "ЗАСТАРІЛО",
|
"STALE": "ЗАСТАРІЛО",
|
||||||
"PARTIAL": "ЧАСТКОВО",
|
"PARTIAL": "ЧАСТКОВО",
|
||||||
"FALLBACK": "РЕЗЕРВ",
|
"FALLBACK": "РЕЗЕРВ",
|
||||||
"{state} {layers}": "{state}: {layers}",
|
|
||||||
"view band|STREET": "ВУЛИЦЯ",
|
"view band|STREET": "ВУЛИЦЯ",
|
||||||
"view band|CITY": "МІСТО",
|
"view band|CITY": "МІСТО",
|
||||||
"view band|METRO": "АГЛОМЕРАЦІЯ",
|
"view band|METRO": "АГЛОМЕРАЦІЯ",
|
||||||
|
|||||||
@@ -325,7 +325,6 @@
|
|||||||
"STALE": "过期",
|
"STALE": "过期",
|
||||||
"PARTIAL": "部分",
|
"PARTIAL": "部分",
|
||||||
"FALLBACK": "备用",
|
"FALLBACK": "备用",
|
||||||
"{state} {layers}": "{state} {layers}",
|
|
||||||
"view band|STREET": "街道",
|
"view band|STREET": "街道",
|
||||||
"view band|CITY": "城市",
|
"view band|CITY": "城市",
|
||||||
"view band|METRO": "都市圈",
|
"view band|METRO": "都市圈",
|
||||||
|
|||||||
@@ -325,7 +325,6 @@
|
|||||||
"STALE": "過期",
|
"STALE": "過期",
|
||||||
"PARTIAL": "部分",
|
"PARTIAL": "部分",
|
||||||
"FALLBACK": "備用",
|
"FALLBACK": "備用",
|
||||||
"{state} {layers}": "{state} {layers}",
|
|
||||||
"view band|STREET": "街道",
|
"view band|STREET": "街道",
|
||||||
"view band|CITY": "城市",
|
"view band|CITY": "城市",
|
||||||
"view band|METRO": "都市圈",
|
"view band|METRO": "都市圈",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
/** Composite queries that combine other tools' answers for one area. */
|
/** Composite queries that combine other tools' answers for one area. */
|
||||||
|
|
||||||
import { suggestView } from '../views.js';
|
import { suggestView } from '../views.js';
|
||||||
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js';
|
import {
|
||||||
|
feedProvenanceEnvelope,
|
||||||
|
hudSummaryMatchesProvenance,
|
||||||
|
provenanceLine,
|
||||||
|
provenanceTag,
|
||||||
|
} from '../../data/layerSnapshot.js';
|
||||||
import { defineTool, ToolError } from '../catalog.js';
|
import { defineTool, ToolError } from '../catalog.js';
|
||||||
import {
|
import {
|
||||||
AREA_SCHEMA,
|
AREA_SCHEMA,
|
||||||
@@ -232,6 +237,7 @@ export const getHudCaption = defineTool({
|
|||||||
feedState: sectionFeedState(brief[key]),
|
feedState: sectionFeedState(brief[key]),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const feedProvenance = feedProvenanceEnvelope(layers);
|
||||||
const response = await context.services.summary.summarize(
|
const response = await context.services.summary.summarize(
|
||||||
{
|
{
|
||||||
placeLabels: [area.label],
|
placeLabels: [area.label],
|
||||||
@@ -244,16 +250,28 @@ export const getHudCaption = defineTool({
|
|||||||
name,
|
name,
|
||||||
feedState,
|
feedState,
|
||||||
})),
|
})),
|
||||||
feedProvenance: feedProvenanceEnvelope(layers),
|
feedProvenance,
|
||||||
},
|
},
|
||||||
{ signal: context.signal },
|
{ signal: context.signal },
|
||||||
);
|
);
|
||||||
const caption = response?.data?.summary;
|
const words = response?.data?.summary;
|
||||||
if (!response?.ok || typeof caption !== 'string' || !caption)
|
if (!response?.ok || typeof words !== 'string' || !words)
|
||||||
throw new ToolError('unavailable', 'The caption service did not answer');
|
throw new ToolError('unavailable', 'The caption service did not answer');
|
||||||
|
// As on the HUD: the app's own state leads and its layers trail, and a
|
||||||
|
// reply that names another state than the app's keeps only the place.
|
||||||
|
const caption = provenanceLine(
|
||||||
|
hudSummaryMatchesProvenance(response.data, feedProvenance)
|
||||||
|
? words
|
||||||
|
: area.label,
|
||||||
|
provenanceTag(feedProvenance),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
summary: caption,
|
summary: caption,
|
||||||
data: { area: area.label, caption },
|
data: {
|
||||||
|
area: area.label,
|
||||||
|
caption,
|
||||||
|
feedState: feedProvenance.overall,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeCatalog, coreTools } from '../index.js';
|
||||||
|
|
||||||
|
// get_hud_caption shows the app's own feed state, not the model's words.
|
||||||
|
async function staleCaption(reply) {
|
||||||
|
const conditions = {
|
||||||
|
observedAt: '2026-10-01T19:15:00.000Z',
|
||||||
|
temperatureC: 26.3,
|
||||||
|
apparentTemperatureC: 32,
|
||||||
|
precipitationMm: 0,
|
||||||
|
cloudCoverPct: 100,
|
||||||
|
windKph: 5.4,
|
||||||
|
windDirectionDeg: 318,
|
||||||
|
visibilityM: 15400,
|
||||||
|
weatherCode: 3,
|
||||||
|
};
|
||||||
|
const places = {
|
||||||
|
resolve: async (name) =>
|
||||||
|
name === 'Austin'
|
||||||
|
? {
|
||||||
|
name: 'Austin, Texas',
|
||||||
|
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const staleWeather = {
|
||||||
|
getConditions: async () => ({ status: 'stale', weather: conditions }),
|
||||||
|
};
|
||||||
|
let sent = null;
|
||||||
|
const summary = {
|
||||||
|
async summarize(context) {
|
||||||
|
sent = context;
|
||||||
|
return { ok: true, status: 200, data: { error: null, ...reply } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { weather: staleWeather, summary, places },
|
||||||
|
});
|
||||||
|
const result = await catalog.call('get_hud_caption', {
|
||||||
|
area: { place: 'Austin' },
|
||||||
|
});
|
||||||
|
assert.equal(sent.feedProvenance.overall, 'stale');
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('get_hud_caption keeps the canonical feed state of a stale view', async () => {
|
||||||
|
const data = await staleCaption({
|
||||||
|
summary: 'Austin weather across the area',
|
||||||
|
feedState: 'stale',
|
||||||
|
});
|
||||||
|
assert.equal(data.feedState, 'stale');
|
||||||
|
assert.equal(
|
||||||
|
data.caption,
|
||||||
|
'STALE | Austin weather across the area | WEATHER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get_hud_caption drops words whose feed state is not the app one', async () => {
|
||||||
|
const data = await staleCaption({
|
||||||
|
summary: 'Austin live weather looks fine',
|
||||||
|
feedState: 'nominal',
|
||||||
|
});
|
||||||
|
assert.equal(data.feedState, 'stale');
|
||||||
|
assert.equal(data.caption, 'STALE | Austin, Texas | WEATHER');
|
||||||
|
});
|
||||||
@@ -4032,3 +4032,20 @@ test('set_panel_open reaches the Utilities panel by its name, Draw or Import', a
|
|||||||
}
|
}
|
||||||
assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]);
|
assert.deepEqual(calls.at(-1), ['utilities-panel', false, { explicit: true }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a partial analyst card shows one partial chip', () => {
|
||||||
|
const card = presentResult('analyst_query', {
|
||||||
|
ok: true,
|
||||||
|
action: 'analyst_query',
|
||||||
|
count: 3,
|
||||||
|
feedState: 'partial',
|
||||||
|
partial: true,
|
||||||
|
unanswered: ['vessels'],
|
||||||
|
summary: '3 contacts',
|
||||||
|
scopeLabel: 'within 5 km of X',
|
||||||
|
display: { scope: 'within 5 km of X' },
|
||||||
|
coverage: {},
|
||||||
|
});
|
||||||
|
const labels = card.display.chips.map((chip) => chip.label);
|
||||||
|
assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels));
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user