diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 325fd3580..2a5bb7f90 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5257,8 +5257,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, the layer names as the user sees them) and `feedProvenance`; the model is instructed not to infer from coordinates. - 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 is shown after the HUD's own translated state tag (`STALE LIVE FLIGHTS | …`), which leads so the one-line readout never clips it. -- Output is sanitized to five words (20 characters for Japanese and Chinese); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- The reply is structured (`text.format` JSON schema): `{summary, feedState}`, `feedState` being the snapshot state or `none`. For a view that is not nominal, a missing or different `feedState` falls back to the deterministic line. An accepted summary and the deterministic line carry the HUD's own translated state: the state word leads and the layer names trail (`STALE | … | LIVE FLIGHTS`), so the one-line readout clips neither the warning nor the text. `get_hud_caption` builds its caption the same way and returns `feedState`. +- Output is sanitized to five words (20 characters when the request locale is Japanese or Chinese, whatever script the words use); falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. - `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/server/providers/openai/hud-summary.js b/server/providers/openai/hud-summary.js index 6986d9697..1384537a0 100644 --- a/server/providers/openai/hud-summary.js +++ b/server/providers/openai/hud-summary.js @@ -4,6 +4,7 @@ import { HUD_SUMMARY_RESPONSE_FORMAT, hudSummaryInput, hudSummaryInstructions, + hudSummaryIsUnspaced, keylessHudSummaryResponse, } from '../../../src/hudSummaryResponse.js'; import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js'; @@ -23,7 +24,7 @@ function extractOpenAiResponseText(data) { } /** Parse the summary and normalize the canonical feed state. */ -function parseHudSummaryReply(text) { +function parseHudSummaryReply(text, locale) { let reply = null; try { reply = JSON.parse(text); @@ -34,12 +35,12 @@ function parseHudSummaryReply(text) { ? reply.feedState : null; return { - summary: toFiveWordHudSummary(reply?.summary), + summary: toFiveWordHudSummary(reply?.summary, hudSummaryIsUnspaced(locale)), feedState: feedState === 'none' ? null : feedState, }; } -function toFiveWordHudSummary(value) { +function toFiveWordHudSummary(value, unspaced) { const words = String(value || '') .replace(/[^\p{L}\p{N}\s-]/gu, ' ') .trim() @@ -48,8 +49,8 @@ function toFiveWordHudSummary(value) { .slice(0, 5) .join(' '); // Japanese and Chinese have no spaces to count words by: cap characters. - if (!/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(words)) - return words; + // Other languages keep five words, even around a Han place name. + if (!unspaced) return words; const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); return [...graphemes.segment(words)] .slice(0, HUD_SUMMARY_MAX_UNSPACED_CHARS) @@ -117,6 +118,7 @@ async function handleHudSummary(req, res) { const data = await response.json().catch(() => ({})); 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'); diff --git a/src/hud.js b/src/hud.js index df649212a..4c969e38b 100644 --- a/src/hud.js +++ b/src/hud.js @@ -104,22 +104,21 @@ const FEED_STATE_LABELS = Object.freeze({ }); /** - * 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. + * A summary line with its feed-state warning in the active language, e.g. + * `UNAVAILABLE | | LIVE AIS VESSELS`. The state word leads so the + * one-line readout never clips it; the layer names trail so they never push + * the text out. State ids and layer names stay English in layer data; only + * this line translates them. */ -function localizedProvenanceTag(provenance) { - if (!provenance) return null; +function withProvenance(text, provenance) { + if (!provenance) return text; 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('/'), - }); + const layers = provenance.names + .map((name) => localizeLayerName(name).toUpperCase()) + .join('/'); + return [state, text, layers].filter(Boolean).join(' | '); } /** The name shown for a _viewBand() id. */ @@ -764,8 +763,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( @@ -782,8 +781,7 @@ export class IntelHUD { zone: localTag, }, ); - // The feed state leads: the one-line readout clips its end. - return provenance ? `${provenance} | ${line}` : line; + return withProvenance(line, provenance); } /** Stop a summary still typing out, leaving what it has typed so far. */ @@ -895,12 +893,11 @@ export class IntelHUD { throw new Error(data?.error || `HTTP ${response.status}`); } if (hudSummaryMatchesProvenance(data, context.feedProvenance)) { - // The warning is the HUD's own translated tag, not the model's - // words, and it leads so the one-line readout never clips it. - const tag = localizedProvenanceTag( + // The warning is the HUD's own translated state, not the model's words. + this._lastSummaryText = withProvenance( + data.summary, provenanceTag(context.feedProvenance), ); - this._lastSummaryText = tag ? `${tag} | ${data.summary}` : data.summary; } this._setSummaryText(this._lastSummaryText || fallbackText, animate); } catch (error) { @@ -973,6 +970,10 @@ export class IntelHUD { // The names the user sees; feedProvenance keeps the English ids. enabledLayerLabels: layerContext.enabledLayerLabels.map(localizeLayerName), + enabledLayers: layerContext.enabledLayers.map((layer) => ({ + ...layer, + name: localizeLayerName(layer.name), + })), }; } diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 63efff42b..c727e5918 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -59,6 +59,7 @@ 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.', + 'Name layers only as in enabledLayerLabels; never copy layer names or state words from feedProvenance or its note.', 'Set feedState to feedProvenance.overall exactly as given, or none when it is null.', 'If feedProvenance.overall is partial, do not present the coverage as complete; if it is any other state but nominal, do not present the view as live or healthy.', 'Do not infer from coordinates or invent a place or feed state.', @@ -95,7 +96,6 @@ export function hudSummaryLocale(value) { return normalizeLocale(value) || DEFAULT_LOCALE; } -/** The language a request's summary is written in, named in English. */ // 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({ @@ -107,6 +107,12 @@ const SUMMARY_LANGUAGES = Object.freeze({ /** Scripts written without spaces, where five words do not apply. */ const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/; +/** Whether the summary for this locale is capped in characters, not words. */ +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 { @@ -125,7 +131,7 @@ export function hudSummaryInstructions(locale) { const tag = hudSummaryLocale(locale); if (tag === DEFAULT_LOCALE) return HUD_SUMMARY_INSTRUCTIONS; const language = summaryLanguage(tag); - const length = UNSPACED_LOCALES.test(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, giving places their usual ${language} names and layers the names in enabledLayerLabels; feedState stays the English value.${length}`; @@ -172,7 +178,7 @@ export function hudSummaryLayerContext(layers = [], options) { } /** - * The feed-state tag for the HUD telemetry suffix when an enabled feed is not + * The warning for the HUD telemetry line when an enabled feed is not * nominal. * @param {Array} [layers] * @param {{ now?: number }} [options] diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index efa6ec581..aece0694d 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -164,6 +164,9 @@ test('the installed keyless HUD route stays successful after the voice quota is } finally { if (previousKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = previousKey; + if (previousLimit === undefined) + delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN; + else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit; } }); @@ -263,10 +266,20 @@ test('the HUD sends its UI locale inside the context it signs for dedupe', () => ); }); -test('the state tag leads the one-line summary so clipping cannot hide it', () => { +test('the state word leads and the layer names trail the one-line summary', () => { const hud = readFileSync(new URL('./hud.js', import.meta.url), 'utf8'); - assert.match(hud, /tag \? `\$\{tag\} \| \$\{data\.summary\}`/); - assert.match(hud, /provenance \? `\$\{provenance\} \| \$\{line\}`/); + assert.match( + hud, + /return \[state, text, layers\]\.filter\(Boolean\)\.join\(' \| '\);/, + ); + assert.match(hud, /withProvenance\(\s*data\.summary,/); + assert.match(hud, /return withProvenance\(line, provenance\);/); + // The model sees only the layer names the user sees. + assert.match(hud, /name: localizeLayerName\(layer\.name\)/); + assert.match( + HUD_SUMMARY_INSTRUCTIONS, + /Name layers only as in enabledLayerLabels/, + ); }); test('shipped catalogs get their exact language, and CJK a character limit', () => { @@ -370,6 +383,20 @@ test('a Japanese summary is capped in characters, not words', async () => { ); }); +for (const [locale, summary] of [ + ['en', 'Shibuya 渋谷 live flights overhead'], + ['es', 'Vuelos sobre 東京駅 datos antiguos'], + ['ru', 'Башня Тайбэй 101 台北101 рейсы'], +]) { + test(`a ${locale} summary with a Han place name keeps its five words`, async () => { + const { response } = await postHudSummary( + { locale, feedProvenance: { overall: 'stale' } }, + JSON.stringify({ summary, feedState: 'stale' }), + ); + assert.equal(response.body.summary, summary); + }); +} + test('a reply that skips the format carries no summary and no state', async () => { const { response } = await postHudSummary( { locale: 'ru', feedProvenance: { overall: 'stale' } }, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8e383aded..64a4f6714 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1125,7 +1125,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 c66eae636..90dd3f375 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2025,7 +2025,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 931e7c3ff..51530d779 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -332,7 +332,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 62c9931c1..278782175 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -332,7 +332,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 5a48dc886..406ae12a1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -326,7 +326,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 de42b5aab..9ca409750 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -332,7 +332,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 3aa2197e9..5521eb6d8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2059,7 +2059,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 6041d339f..67b86d081 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -335,7 +335,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 5fb455546..3d9e7b3d6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -326,7 +326,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 a2bb4ffa0..362e4726c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -326,7 +326,6 @@ "STALE": "過期", "PARTIAL": "部分", "FALLBACK": "備用", - "{state} {layers}": "{state} {layers}", "view band|STREET": "街道", "view band|CITY": "城市", "view band|METRO": "都市圈", diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 34d7f5998..86915a72a 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -256,14 +256,16 @@ export const getHudCaption = defineTool({ 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 warning is the app's own state tag, and a reply that - // names another state than the app's keeps only the place. + // 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 tag = provenanceTag(feedProvenance); const text = hudSummaryMatchesProvenance(response.data, feedProvenance) ? words : area.label; const caption = tag - ? `${text} | ${[tag.state, ...tag.names].join(' ').toUpperCase()}` + ? [tag.state.toUpperCase(), text, tag.names.join('/').toUpperCase()] + .filter(Boolean) + .join(' | ') : text; return { summary: caption, diff --git a/src/tools/queries/hud-caption-feed-state.test.mjs b/src/tools/queries/hud-caption-feed-state.test.mjs index c65b5436f..47afb97a4 100644 --- a/src/tools/queries/hud-caption-feed-state.test.mjs +++ b/src/tools/queries/hud-caption-feed-state.test.mjs @@ -2,9 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; -// The proxy returns the feed state as its own field ({ summary, feedState }); -// the five words no longer carry STALE, so get_hud_caption adds the app's own -// state tag and keeps only the place when the reply names another state. +// 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', @@ -53,7 +51,10 @@ test('get_hud_caption keeps the canonical feed state of a stale view', async () feedState: 'stale', }); assert.equal(data.feedState, 'stale'); - assert.equal(data.caption, 'Austin weather across the area | STALE WEATHER'); + 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 () => { @@ -62,5 +63,5 @@ test('get_hud_caption drops words whose feed state is not the app one', async () feedState: 'nominal', }); assert.equal(data.feedState, 'stale'); - assert.equal(data.caption, 'Austin, Texas | STALE WEATHER'); + assert.equal(data.caption, 'STALE | Austin, Texas | WEATHER'); });