From 0d3c1ef1d95ed61f379e2a2687192930dec7298a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 12:00:44 +0400 Subject: [PATCH] fix(hud): ask again in the new language after an in-place switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _relocalize() now drops the received reply and aborts one in flight, so the next tick asks in the new language instead of keeping old-language text under the new-language state tag. - The English-echo check also allows state words inside a place label (Estación Loading Dock). - Reword the non-English instruction so it reads for every language name. - Add the two new test files to the format scope. --- docs/CURRENT-STATE.md | 4 ++-- scripts/format-scope.json | 2 ++ src/hud.js | 17 ++++++++++---- src/hudSummaryLine.test.mjs | 47 +++++++++++++++++++++++++++++++++++++ src/hudSummaryResponse.js | 2 +- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc80c55e5..85045d851 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5365,8 +5365,8 @@ and unreachable upstream (502/504) separately from road geometry. - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). - Input is the live basemap label context (place/street/nearby-place labels + enabled layers) and `feedProvenance`, with every layer name as the user sees it; outside English the narration note and the per-layer state ids are left out (`overall` still carries the state), since a small model copies English words. The model is instructed not to infer from coordinates. A view with no place, street or nearby-place label is not sent, since with layers alone the model invents a place: the deterministic line is shown. -- Language (October 2026): the HUD sends `locale`, which is part of the dedupe signature. The proxy keeps a shipped locale (English otherwise) and names the language in the instructions and at the start of the input; Japanese and Chinese get a 20-character phrase instead of five words. -- The reply is structured (`text.format` JSON schema): `{summary, feedState}`, `feedState` being the snapshot state or `none`. For a view that is not nominal, a missing or different `feedState` falls back to the deterministic line. An accepted summary and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout never clips the warning; the text and then the layer names may be cut off on narrow layouts. Outside English a reply that echoes a feed-state id or an English layer name the UI translates falls back too, even written flush against kana or Han; a state word the locale itself keeps in English (de `FALLBACK`) and a name inside a place label do not count. `get_hud_caption` builds its caption the same way and returns `feedState`. +- Language (October 2026): the HUD sends `locale`, which is part of the dedupe signature. The proxy keeps a shipped locale (English otherwise) and names the language in the instructions and at the start of the input; Japanese and Chinese get a 20-character phrase instead of five words. Switching language in place drops the reply received and aborts one in flight, so the next tick asks again in the new language. +- The reply is structured (`text.format` JSON schema): `{summary, feedState}`, `feedState` being the snapshot state or `none`. For a view that is not nominal, a missing or different `feedState` falls back to the deterministic line. An accepted summary and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout never clips the warning; the text and then the layer names may be cut off on narrow layouts. Outside English a reply that echoes a feed-state id or an English layer name the UI translates falls back too, even written flush against kana or Han; a state word the locale itself keeps in English (de `FALLBACK`) and words inside a place label do not count. `get_hud_caption` builds its caption the same way and returns `feedState`. - Output is sanitized to five words, or for a Japanese or Chinese request locale to 20 characters (stepping back to the start of a Latin word rather than splitting it); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update; a camera move during typing shows the deterministic line instead of the fragment. - `partial` is a feed state of its own, ranked between `fallback` and `nominal`, so one partial layer no longer reads as off and partial plus nominal reads partial (HUD, voice view state, `analyst_query`, `brief`, the agent world packet). Its narration note says coverage is incomplete rather than not live. - A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0cd9edca5..7c84af8ac 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1109,6 +1109,8 @@ "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", "src/hudSummaryGate.test.mjs", + "src/hudSummaryLine.test.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/src/hud.js b/src/hud.js index 27cae5f28..458db42b2 100644 --- a/src/hud.js +++ b/src/hud.js @@ -132,7 +132,8 @@ function withProvenance(text, provenance) { /** * The English a summary outside English must not carry: feed-state ids and * translated layers' English names. A state word the locale keeps in English - * (de FALLBACK) and a layer name inside a place label (Radio City) are fine. + * (de FALLBACK) and words inside a place label (Radio City, Loading Dock) are + * fine. */ function summaryEnglishWords(context) { const places = [ @@ -144,7 +145,9 @@ function summaryEnglishWords(context) { .toLowerCase(); return [ ...Object.keys(FEED_STATE_LABELS).filter( - (state) => t(FEED_STATE_LABELS[state]).toLowerCase() !== state, + (state) => + t(FEED_STATE_LABELS[state]).toLowerCase() !== state && + !places.includes(state), ), ...(context.translatedLayerNames || []).filter( (name) => !places.includes(name.toLowerCase()), @@ -323,11 +326,15 @@ export class IntelHUD { } /** - * 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 own words in the new locale. The AI reply is written in + * the locale too, so the one received is dropped like a camera move drops + * it: a request in flight is aborted and the next tick asks again. */ _relocalize() { + this._markSummaryDirty(); + this._lastSummarySignature = null; + this._lastSummaryText = null; + this._summaryRequest?.abort(); this._present({ mode: MODE_LABELS[this._currentStyle] ? t(MODE_LABELS[this._currentStyle]) diff --git a/src/hudSummaryLine.test.mjs b/src/hudSummaryLine.test.mjs index 0919049c8..468cf347c 100644 --- a/src/hudSummaryLine.test.mjs +++ b/src/hudSummaryLine.test.mjs @@ -185,6 +185,7 @@ 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( @@ -196,3 +197,49 @@ for (const [locale, summary, places] of [ 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()); +}); diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 2627c6641..fcf9e032e 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -165,7 +165,7 @@ export function hudSummaryInstructions(locale) { 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, giving places their usual ${language} names and layers the names in enabledLayerLabels; feedState stays the English value.${length}`; + 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}`; } /**