diff --git a/changelog.d/1488-hud-summary-language.md b/changelog.d/1488-hud-summary-language.md new file mode 100644 index 000000000..ec2eed198 --- /dev/null +++ b/changelog.d/1488-hud-summary-language.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5fa7b7b23..35d81dd05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5413,8 +5413,11 @@ and unreachable upstream (502/504) separately from road geometry. ### AI HUD Summary (June 2026) - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). -- Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. -- Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- Input: place/street/nearby-place labels, enabled layers and `feedProvenance`, with layer names as the user sees them; outside English the narration note and per-layer state ids are left out (`overall` carries the state). No model call without a place, street or nearby-place label: the deterministic line is shown. +- Language: the HUD sends `locale` (part of the dedupe signature); the proxy keeps a shipped locale, English otherwise. Switching language in place drops the reply and aborts the request in flight, so the next tick asks again. +- Reply: `{summary, feedState}` (`text.format` JSON schema, `feedState` a snapshot state or null). A view that is not nominal needs the matching `feedState`. The HUD adds its own translated tag, state first and layer names last (`STALE | … | LIVE FLIGHTS`), so the warning is never clipped. Outside English a reply falls back when it carries a feed-state id, a translated layer's English name or id, only Latin letters in a non-Latin language, or, with every feed healthy, the UNAVAILABLE word. `get_hud_caption` uses the same check and tag order, in English, and returns `feedState`. +- 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. ### Place-search providers diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 447dbf199..bcf815de1 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1111,6 +1111,9 @@ "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", "src/hudSummaryGate.test.mjs", + "src/hudSummaryLine.test.mjs", + "src/testSupport/stubMgrs.mjs", + "src/tools/queries/hud-caption-feed-state.test.mjs", "src/voice/hudPlaceLabels.test.mjs", "src/voice/panel/preferences.test.mjs", "src/voice/panel/preferencesView.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index de6b2e490..6d55213e7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -485,6 +485,7 @@ "exports": ["./server/providers/openai"], "modules": [ "src/i18n/marker.js", + "src/i18n/locale.js", "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", diff --git a/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 9033c7cb3..a95de17ea 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -1,5 +1,12 @@ +import { SNAPSHOT_FEED_STATES } from '../../../src/data/layerSnapshot.js'; import { - HUD_SUMMARY_INSTRUCTIONS, + HUD_SUMMARY_MAX_CHARS, + HUD_SUMMARY_MAX_UNSPACED_CHARS, + HUD_SUMMARY_RESPONSE_FORMAT, + hudSummaryInput, + hudSummaryInstructions, + hudSummaryIsUnspaced, + hudSummaryLocale, keylessHudSummaryResponse, } from '../../../src/hudSummaryResponse.js'; import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js'; @@ -18,14 +25,58 @@ function extractOpenAiResponseText(data) { .trim(); } -function toFiveWordHudSummary(value) { - return String(value || '') - .replace(/[^\p{L}\p{N}\s-]/gu, ' ') - .trim() +/** Parse the summary and normalize the canonical feed state. */ +function parseHudSummaryReply(text, locale) { + let reply = null; + 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+/) - .filter(Boolean) - .slice(0, 5) - .join(' '); + .map((word) => word.replace(/^['’·・-]+|['’·・-]+$/gu, '')) + .filter(Boolean); + // 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) { @@ -77,14 +128,18 @@ async function handleHudSummary(req, res) { model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT, - instructions: HUD_SUMMARY_INSTRUCTIONS, - input: JSON.stringify(context), + instructions: hudSummaryInstructions(context?.locale), + input: hudSummaryInput(context), + text: { format: HUD_SUMMARY_RESPONSE_FORMAT }, reasoning: { effort: 'minimal' }, - max_output_tokens: 100, + max_output_tokens: 200, }), }); 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.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); @@ -93,6 +148,7 @@ async function handleHudSummary(req, res) { res.end( JSON.stringify({ summary: summary || null, + feedState, // Never relay `data.error.message`: that is OpenAI's own wording, and // it carries request ids, organization hints and quota phrasing. error: response.ok ? null : 'OpenAI HUD summary request failed', @@ -110,4 +166,4 @@ async function handleHudSummary(req, res) { } } -export { handleHudSummary }; +export { handleHudSummary, parseHudSummaryReply }; diff --git a/src/data/layerSnapshot.js b/src/data/layerSnapshot.js index 13fd3afe2..db0f326c8 100644 --- a/src/data/layerSnapshot.js +++ b/src/data/layerSnapshot.js @@ -17,6 +17,7 @@ export const LAYER_FEED_STATES = Object.freeze([ 'loading', 'degraded', 'stale', + 'partial', 'fallback', 'unavailable', ]); @@ -27,6 +28,7 @@ export const LAYER_FEED_STATE_LABELS = Object.freeze({ loading: 'LOADING', degraded: 'DEGRADED', stale: 'STALE', + partial: 'PARTIAL', fallback: 'FALLBACK', unavailable: 'UNAVAILABLE', }); @@ -48,8 +50,9 @@ export const FEED_STATE_SEVERITY = Object.freeze({ degraded: 2, stale: 3, fallback: 4, - nominal: 5, - off: 6, + partial: 5, + nominal: 6, + off: 7, }); 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. * @param {Iterable} 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) { 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})` : ''}`; }); 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.`; } @@ -251,3 +256,63 @@ export function viewStateLayerRow(layer, options) { 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 }|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; +} diff --git a/src/data/layerSnapshot.test.mjs b/src/data/layerSnapshot.test.mjs index 9e3b08c80..65d473972 100644 --- a/src/data/layerSnapshot.test.mjs +++ b/src/data/layerSnapshot.test.mjs @@ -11,6 +11,7 @@ import { layerFeedState, layerSnapshot, layerSnapshots, + provenanceTag, snapshotAgeLabel, snapshotAgeSec, 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', () => { assert.deepEqual( [...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']); 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); }); +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', () => { 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 - 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'], + }); +}); diff --git a/src/hud.js b/src/hud.js index 457b7bd39..7e61fe91b 100644 --- a/src/hud.js +++ b/src/hud.js @@ -32,17 +32,31 @@ import { } from './data/geoid.js'; import { getBasemapLabelContext } from './voice/gevActions.js'; import { - hudSummaryMatchesProvenance, + hasWord, + hudSummaryHasEnglish, + hudSummaryIsLatin, hudSummaryLayerContext, hudTelemetryProvenance, isHudSummaryUnconfigured, } from './hudSummaryResponse.js'; +import { + hudSummaryMatchesProvenance, + provenanceLine, + provenanceTag, +} from './data/layerSnapshot.js'; import { applyHudUiTheme, DEFAULT_HUD_LAYOUT, normalizeHudLayout, } 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 { isEmbeddedInline } from './app/embed.js'; import { subscribeSelectedVessel } from './hudReadouts.js'; @@ -108,23 +122,58 @@ const FEED_STATE_LABELS = Object.freeze({ 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. - * `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in - * layer data; only this line translates them. + * The AI line. In an RTL locale a leading right-to-left mark keeps a reply + * that opens with a Latin name (Times Square …) reading right to left. */ -function localizedProvenanceTag(provenance) { - if (!provenance) return null; - const state = FEED_STATE_LABELS[provenance.state] - ? t(FEED_STATE_LABELS[provenance.state]) - : provenance.state.toUpperCase(); - if (!provenance.names.length) return state; - return t('{state} {layers}', { - state, - layers: provenance.names - .map((name) => localizeLayerName(name).toUpperCase()) - .join('/'), - }); +function aiSummaryLine(summary, warning) { + const line = withProvenance(summary, warning); + return isRtlLocale(getLocale()) ? `\u200f${line}` : line; +} + +/** + * What a summary outside English must not carry: feed-state ids, translated + * layers' English names and ids, and with every feed healthy the + * UNAVAILABLE word. A state word the locale keeps in English (de FALLBACK) + * and words of a place label (Radio City, Loading Dock) are fine; so is a + * one-word layer name in a Latin-script locale, where it may be a word of + * the language (fr transit). + */ +function summaryForbiddenWords(context) { + const places = [ + ...(context.placeLabels || []), + ...(context.streetLabels || []), + ...(context.nearbyPlaceLabels || []), + ].join(' '); + 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. */ @@ -221,7 +270,7 @@ export class IntelHUD { this._placeLabels = null; this._lastSummarySignature = ''; /** The reply painted for `_lastSummarySignature`, when it was accepted. */ - this._lastSummaryText = null; + this._lastSummaryReply = null; this._summaryRevision = 0; // One-shot guards so the very first summary lands immediately instead of // waiting for the 15s interval tick: B) swap the "Awaiting telemetry..." @@ -250,12 +299,17 @@ export class IntelHUD { // the camera has moved to. this._onCameraMoveStart = () => { 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. this._lastSummarySignature = null; } this._summaryRequest?.abort(); 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._markSummaryDirty(); @@ -295,12 +349,12 @@ export class IntelHUD { this._startTimers(); } - /** - * 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. - */ + /** Show the HUD's words in the new locale; drop AI text in the old one. */ _relocalize() { + this._markSummaryDirty(); + this._lastSummarySignature = null; + this._lastSummaryReply = null; + this._summaryRequest?.abort(); this._present({ mode: MODE_LABELS[this._currentStyle] ? t(MODE_LABELS[this._currentStyle]) @@ -763,8 +817,8 @@ export class IntelHUD { LOCALIZED_LOCALITY, ); - const provenance = localizedProvenanceTag( - hudTelemetryProvenance(this._dataManager?.getAll?.() || []), + const provenance = hudTelemetryProvenance( + this._dataManager?.getAll?.() || [], ); // ALT, SUN and ONA are sensor codes, as in the corner readouts. const line = t( @@ -781,7 +835,7 @@ export class IntelHUD { zone: localTag, }, ); - return provenance ? `${line} | ${provenance}` : line; + return withProvenance(line, provenance); } /** Stop a summary still typing out, leaving what it has typed so far. */ @@ -854,12 +908,28 @@ export class IntelHUD { 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); if (!force && signature === this._lastSummarySignature) { this._summaryDirty = false; // A settle painted the fallback line over the reply this context // already has; reused place labels make that common, so restore it. - if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText); + // The warning is the current one: layer states are not in a + // non-English signature. + if (this._lastSummaryReply) + this._setSummaryText( + aiSummaryLine(this._lastSummaryReply, context.warning), + ); return; } if (this._summaryRequest) return; @@ -867,7 +937,7 @@ export class IntelHUD { if (force) this._setSummaryText(fallbackText, false); this._summaryDirty = false; this._lastSummarySignature = signature; - this._lastSummaryText = null; + this._lastSummaryReply = null; const controller = new AbortController(); let timedOut = false; const timeout = window.setTimeout(() => { @@ -890,9 +960,23 @@ export class IntelHUD { if (!response.ok || !data?.summary) { throw new Error(data?.error || `HTTP ${response.status}`); } - if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance)) - this._lastSummaryText = data.summary; - this._setSummaryText(this._lastSummaryText || fallbackText, animate); + if ( + hudSummaryMatchesProvenance(data, context.feedProvenance) && + !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) { // The 5 s timeout is a failure like any other; a move's abort (or // 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 // newer view's lookup. if (revision === this._summaryRevision) this._placeLabels = labels; - const enabledLayers = - this._dataManager - ?.getAll?.() - ?.filter((layer) => layer.enabled) - .map((layer) => layer.name) || []; - return { + const locale = getLocale(); + const layerContext = hudSummaryLayerContext( + this._dataManager?.getAll?.() || [], + ); + // Outside English a layer's state id is left out: the model echoes it, + // 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, streetLabels: labels.streetLabels, nearbyPlaceLabels: labels.nearbyPlaceLabels, - enabledLayerLabels: enabledLayers, - ...hudSummaryLayerContext(this._dataManager?.getAll?.() || []), + // The model sees only the layer names the user sees. The English note + // goes too: a small model copies its words. `overall` stays the id. + enabledLayerLabels: + 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() { diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index 543427d0f..2dc528602 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -295,7 +295,7 @@ test('a summary asked for one view is dropped once the camera moves', async () = try { hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); let place = 'Yellowstone'; - hud._summaryContext = async () => ({ place }); + hud._summaryContext = async () => ({ placeLabels: [place] }); const summary = () => env.elements.get('hud-summary').textContent; const settle = () => new Promise((resolve) => setImmediate(resolve)); 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); await settle(); 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'); await moving; 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; try { 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 settle = () => new Promise((resolve) => setImmediate(resolve)); hud._updateCameraData(); @@ -416,7 +416,7 @@ test('a cancelled summary retries the same context but completed answers stay re let hud; try { hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); - hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] }); hud._updateCameraData(); const settle = () => new Promise((resolve) => setImmediate(resolve)); 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 { hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); // 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 settle = () => new Promise((resolve) => setImmediate(resolve)); hud._updateCameraData(); @@ -501,15 +501,17 @@ test('after a dropped or timed-out request the next tick asks again, and a move }); 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'); t.mock.timers.tick(48); - const typed = element.textContent; env.cameraListeners.moveStart(); + const plain = element.textContent; + assert.equal(plain, hud._composeSummary()); t.mock.timers.tick(2_000); assert.equal( element.textContent, - typed, + plain, 'no more characters after the move', ); } finally { @@ -536,7 +538,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t) let hud; try { hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); - hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] }); hud._updateCameraData(); await hud._updateSummary(true); t.mock.timers.tick(48); diff --git a/src/hudSummaryLine.test.mjs b/src/hudSummaryLine.test.mjs new file mode 100644 index 000000000..2e8845e88 --- /dev/null +++ b/src/hudSummaryLine.test.mjs @@ -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); +}); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 82a5c2a23..1f41b994d 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -1,7 +1,10 @@ import { + SNAPSHOT_FEED_STATES, feedProvenanceEnvelope, layerSnapshots, + provenanceTag, } from './data/layerSnapshot.js'; +import { DEFAULT_LOCALE, normalizeLocale } from './i18n/locale.js'; 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 - * supplied labels, and must carry feed-state when any enabled layer is not - * nominal. + * HUD summary model instructions. The five words may only describe supplied + * labels; the feed state comes back as its own canonical field, so the check + * does not depend on the summary's language or wording. */ export const HUD_SUMMARY_INSTRUCTIONS = [ "Write one concise intelligence-HUD summary for God's Eye View.", '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.', - '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.', - '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(' '); +/** 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 ? `(? 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. * @param {Array} [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 - * an enabled feed is not nominal. Values stay English (`state` is a feed-state - * id, `names` are `layer.name`); the HUD translates them for display. + * The warning for the HUD telemetry line when an enabled feed is not + * nominal. * @param {Array} [layers] * @param {{ now?: number }} [options] * @returns {{ state: string, names: string[] }|null} */ export function hudTelemetryProvenance(layers = [], options) { const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled); - const envelope = 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()); + return provenanceTag(feedProvenanceEnvelope(snapshots, options)); } diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index d7da91129..097d7133d 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -1,14 +1,25 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync } from 'node:fs'; +import { Readable } from 'node:stream'; import { HUD_SUMMARY_INSTRUCTIONS, + HUD_SUMMARY_MAX_UNSPACED_CHARS, + HUD_SUMMARY_RESPONSE_FORMAT, HUD_SUMMARY_UNCONFIGURED_CODE, + hudSummaryInput, + hudSummaryInstructions, hudSummaryLayerContext, + hudSummaryLocale, hudTelemetryProvenance, isHudSummaryUnconfigured, keylessHudSummaryResponse, } 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'; 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( HUD_SUMMARY_INSTRUCTIONS, - /five words MUST include that feedState token/, + /Set feedState to feedProvenance\.overall exactly/, ); assert.match( 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', () => { - const local = readFileSync( - new URL('../server/providers/openai/hud-summary.js', import.meta.url), - 'utf8', +test('the instructions name the UI language, with English as the fallback', () => { + assert.match(hudSummaryInstructions('es'), /in Spanish \(es\)/); + assert.match(hudSummaryInstructions('ru-RU'), /in Russian \(ru\)/); + 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.doesNotMatch(local, /enabled-layer text labels/); + assert.equal(hudSummaryLocale(42), 'en'); }); -test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => { - const { hudSummaryMatchesProvenance } = - await import('./hudSummaryResponse.js'); - assert.equal( - hudSummaryMatchesProvenance('Austin flights operating normally today', { - overall: 'stale', +test('shipped catalogs get their exact language, and CJK a character limit', () => { + assert.match(hudSummaryInstructions('pt'), /in Brazilian Portuguese \(pt\)/); + assert.match( + hudSummaryInstructions('zh-Hant'), + /in Chinese in Traditional characters \(mainland wording\) \(zh-Hant\)/, + ); + 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, ); assert.equal( - hudSummaryMatchesProvenance('Austin stale flights over downtown', { - overall: 'stale', - }), - true, + hudSummaryMatchesProvenance( + { summary: 'Austin stale flights over downtown', feedState: null }, + stale, + ), + false, ); assert.equal( - hudSummaryMatchesProvenance('Austin flights operating normally today', { - overall: 'nominal', - }), + hudSummaryMatchesProvenance( + { summary: 'Austin flights' }, + { overall: 'partial' }, + ), + false, + ); + assert.equal( + hudSummaryMatchesProvenance( + { summary: 'Austin flights operating normally today' }, + { overall: 'nominal' }, + ), 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'], + }); }); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index e15e15652..e2e05348d 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1146,7 +1146,6 @@ "OCEANIA": "أوقيانوسيا", "NORTHERN OCEANIC GRID": "الشبكة المحيطية الشمالية", "SOUTHERN OCEANIC GRID": "الشبكة المحيطية الجنوبية", - "{state} {layers}": "{state}: {layers}", "view band|STREET": "شارع", "view band|CITY": "مدينة", "view band|METRO": "حضري", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6835d3350..0abaa4ddf 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1114,7 +1114,6 @@ "OCEANIA": "OZEANIEN", "NORTHERN OCEANIC GRID": "NÖRDLICHES OZEANRASTER", "SOUTHERN OCEANIC GRID": "SÜDLICHES OZEANRASTER", - "{state} {layers}": "{state} {layers}", "view band|STREET": "STRASSE", "view band|CITY": "STADT", "view band|METRO": "GROSSRAUM", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 8a2fd87ad..65997812d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2021,7 +2021,6 @@ "Globe field image unavailable": "Imagen del campo en el globo no disponible", "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.", - "{state} {layers}": "{state}: {layers}", "Selected Space Mission": "Misión espacial seleccionada", "{duration}s + {hold}s": "{duration} s + {hold} s", "Configured Source": "Fuente configurada", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e9ecb5468..0d1460e1d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -331,7 +331,6 @@ "STALE": "OBSOLÈTE", "PARTIAL": "PARTIEL", "FALLBACK": "SECOURS", - "{state} {layers}": "{state} : {layers}", "view band|STREET": "RUE", "view band|CITY": "VILLE", "view band|METRO": "MÉTROPOLE", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index fff8fa8cd..d460440e2 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -331,7 +331,6 @@ "STALE": "OBSOLETO", "PARTIAL": "PARZIALE", "FALLBACK": "RISERVA", - "{state} {layers}": "{state} {layers}", "view band|STREET": "STRADA", "view band|CITY": "CITTÀ", "view band|METRO": "METRO", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f93feb73e..237c55f6c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -325,7 +325,6 @@ "STALE": "更新遅延", "PARTIAL": "部分的", "FALLBACK": "フォールバック", - "{state} {layers}": "{state} {layers}", "view band|STREET": "街路", "view band|CITY": "市街", "view band|METRO": "都市圏", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7d83d2b21..25f53f162 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -331,7 +331,6 @@ "STALE": "DESATUALIZADO", "PARTIAL": "PARCIAL", "FALLBACK": "ALTERNATIVO", - "{state} {layers}": "{state}: {layers}", "view band|STREET": "RUA", "view band|CITY": "CIDADE", "view band|METRO": "METRÓPOLE", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 97c840a5b..f3217b079 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2055,7 +2055,6 @@ "Globe field image 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.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.", - "{state} {layers}": "{state}: {layers}", "Selected Space Mission": "Выбранная космическая миссия", "{duration}s + {hold}s": "{duration} с + {hold} с", "Configured Source": "Настроенный источник", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index e0827bc7a..720b0a7bd 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -334,7 +334,6 @@ "STALE": "ЗАСТАРІЛО", "PARTIAL": "ЧАСТКОВО", "FALLBACK": "РЕЗЕРВ", - "{state} {layers}": "{state}: {layers}", "view band|STREET": "ВУЛИЦЯ", "view band|CITY": "МІСТО", "view band|METRO": "АГЛОМЕРАЦІЯ", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 466a35c66..b9d8799c4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -325,7 +325,6 @@ "STALE": "过期", "PARTIAL": "部分", "FALLBACK": "备用", - "{state} {layers}": "{state} {layers}", "view band|STREET": "街道", "view band|CITY": "城市", "view band|METRO": "都市圈", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 74b72c869..06ce0fcaa 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -325,7 +325,6 @@ "STALE": "過期", "PARTIAL": "部分", "FALLBACK": "備用", - "{state} {layers}": "{state} {layers}", "view band|STREET": "街道", "view band|CITY": "城市", "view band|METRO": "都市圈", diff --git a/src/testSupport/stubMgrs.mjs b/src/testSupport/stubMgrs.mjs new file mode 100644 index 000000000..9932e367a --- /dev/null +++ b/src/testSupport/stubMgrs.mjs @@ -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); + }, +}); diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index ba8e858dd..1ffc5bee3 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -1,7 +1,12 @@ /** Composite queries that combine other tools' answers for one area. */ 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 { AREA_SCHEMA, @@ -232,6 +237,7 @@ export const getHudCaption = defineTool({ feedState: sectionFeedState(brief[key]), }), ); + const feedProvenance = feedProvenanceEnvelope(layers); const response = await context.services.summary.summarize( { placeLabels: [area.label], @@ -244,16 +250,28 @@ export const getHudCaption = defineTool({ name, feedState, })), - feedProvenance: feedProvenanceEnvelope(layers), + feedProvenance, }, { signal: context.signal }, ); - const caption = response?.data?.summary; - if (!response?.ok || typeof caption !== 'string' || !caption) + const words = response?.data?.summary; + if (!response?.ok || typeof words !== 'string' || !words) 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 { summary: caption, - data: { area: area.label, caption }, + data: { + area: area.label, + caption, + feedState: feedProvenance.overall, + }, }; }, }); diff --git a/src/tools/queries/hud-caption-feed-state.test.mjs b/src/tools/queries/hud-caption-feed-state.test.mjs new file mode 100644 index 000000000..47afb97a4 --- /dev/null +++ b/src/tools/queries/hud-caption-feed-state.test.mjs @@ -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'); +}); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index e240571d6..f11abc5ae 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -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 }]); }); + +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)); +}); diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index ac99f86d7..4a547f80a 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -422,7 +422,10 @@ function analystCard(result) { ...(result.feedState && result.feedState !== 'nominal' ? [{ label: shownFeedState(result.feedState) }] : []), - ...(result.partial ? [{ label: t('partial') }] : []), + // A partial feed state already says it. + ...(result.partial && result.feedState !== 'partial' + ? [{ label: t('partial') }] + : []), ], // Coverage qualifications lead the feed detail. The bounded card groups // overflow in its existing Notes disclosure rather than dropping them.