From 681ec84748f055051cb899bc876fe9dd6c520dfd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 1/5] fix(voice): show FIRMS and ALPR entity labels translated on the voice card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pointer chip, its live-region line and the get_entity_context card title now translate the English entity labels the FIRMS and ALPR layers register ("Fire · FRP {frp} MW", "ALPR camera"). Other names, such as callsigns and place names, show as they came. --- src/voice/speech.js | 14 +++++++- src/voice/voiceCardLocale.test.mjs | 58 ++++++++++++++++++++++++++++-- src/voice/voiceCardPresentation.js | 11 ++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/voice/speech.js b/src/voice/speech.js index ab129aad3..711805502 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -115,6 +115,18 @@ function shownSources(source) { return source ? [{ label: sourceLabel(source) }] : []; } +// Entity labels that layers register as English data (firms/, alpr/); keep in +// step with the producers. Other names (callsigns, places) show as they came. +const ENTITY_LABELS = new Set([msg('ALPR camera')]); +const FIRE_LABEL = /^Fire · FRP (\S+) MW$/; + +/** An entity label as the card shows it, in the active language. */ +export function shownEntityLabel(label) { + if (ENTITY_LABELS.has(label)) return t(label); + const fire = FIRE_LABEL.exec(label ?? ''); + return fire ? t('Fire · FRP {frp} MW', { frp: fire[1] }) : label; +} + /* * Tool errors stay English in the result (the model reads them). The card * translates the known ones; the English must match gevActions exactly. @@ -592,7 +604,7 @@ function getEntityContext(result) { return { say: null, display: { - title, + title: spokenLabel(shownEntityLabel(title)), lines: [ selected.layerName ? shownLayerName(selected.layerName, selected.layerId) diff --git a/src/voice/voiceCardLocale.test.mjs b/src/voice/voiceCardLocale.test.mjs index 08274d21b..9c70d91f9 100644 --- a/src/voice/voiceCardLocale.test.mjs +++ b/src/voice/voiceCardLocale.test.mjs @@ -3,8 +3,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../i18n/index.js'; -import { attachVoiceResult, planStepLabel } from './speech.js'; -import { voiceCardView } from './voiceCardPresentation.js'; +import { + attachVoiceResult, + planStepLabel, + shownEntityLabel, +} from './speech.js'; +import { + initialVoiceCardState, + reduceVoiceCard, + voiceCardView, +} from './voiceCardPresentation.js'; for (const locale of ['ru', 'es']) { test(`voice card is in ${locale} for a layer switch`, (t) => { @@ -86,3 +94,49 @@ test('en: layer switch, distance and plan labels in English', () => { 'Earthquakes (24h) on', ); }); + +// FIRMS and ALPR register English entity labels: the model reads them as data +// (get_entity_context, pointer_context), the card shows them translated. +const ENTITY_LABELS = { + ru: { fire: 'Пожар · FRP 12 МВт', alpr: 'Камера ALPR' }, + es: { fire: 'Incendio · FRP 12 MW', alpr: 'Cámara ALPR' }, +}; + +for (const [locale, shown] of Object.entries(ENTITY_LABELS)) { + test(`entity labels stay English in the result and show in ${locale}`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + const out = attachVoiceResult('get_entity_context', { + ok: true, + selected: { + id: 'fire-1', + name: 'Fire · FRP 12 MW', + layerId: 'local-firms', + layerName: 'NASA FIRMS', + }, + }); + assert.equal(out.selected.name, 'Fire · FRP 12 MW'); + assert.equal(out.display.title, shown.fire); + + const state = reduceVoiceCard(initialVoiceCardState(), { + type: 'pointer', + pointer: { kind: 'this', label: 'ALPR camera' }, + }); + assert.equal(state.pointer.label, 'ALPR camera'); + assert.equal(voiceCardView(state).pointer.text, shown.alpr); + assert.equal(state.announce, `This: ${shown.alpr}`); + }); +} + +test('only the registered entity labels are translated', () => { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Fire · FRP 4.9 MW'); + assert.equal(shownEntityLabel('ALPR camera'), 'ALPR camera'); + setLocale('ru'); + try { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Пожар · FRP 4.9 МВт'); + assert.equal(shownEntityLabel('UPS793'), 'UPS793'); + assert.equal(shownEntityLabel('Fire Station 5'), 'Fire Station 5'); + } finally { + setLocale('en'); + } +}); diff --git a/src/voice/voiceCardPresentation.js b/src/voice/voiceCardPresentation.js index f8bf8ff7d..218241488 100644 --- a/src/voice/voiceCardPresentation.js +++ b/src/voice/voiceCardPresentation.js @@ -1,4 +1,9 @@ -import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js'; +import { + planStepLabel, + progressStepLabel, + shownEntityLabel, + spokenLabel, +} from './speech.js'; import { presentResult } from './resultDisplay.js'; import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js'; @@ -132,7 +137,7 @@ export function reduceVoiceCard(state, event) { return reveal({ ...state, pointer, - announce: `${kind}: ${spokenLabel(pointer.label, 32)}`, + announce: `${kind}: ${spokenLabel(shownEntityLabel(pointer.label), 32)}`, }); } case 'transcript': { @@ -288,7 +293,7 @@ export function voiceCardView(state) { pointer: { visible: Boolean(state.pointer), kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'), - text: spokenLabel(state.pointer?.label || '', 32), + text: spokenLabel(shownEntityLabel(state.pointer?.label || ''), 32), }, assistant: { visible: Boolean(state.assistant), From cec5d99e13e1564813d05a6ed5694e428dd831e8 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 2/5] fix(voice): keep FIRMS and ALPR entity labels English in voice data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fire and camera labels in the context store, getStrongestFire() and getSelectedInfo() were translated at the source, so the voice model got "Пожар · FRP 12 МВт" or "Cámara ALPR" in track_entity, get_entity_context and pointer_context while every other entity name in those payloads is English. Keep them English, as before the interface was translated; the voice card translates them where it shows them. --- src/data/alprCameras.test.mjs | 20 ++++++++++++++++++++ src/data/firmsInteraction.test.mjs | 18 ++++++++++++++++++ src/layers/alpr/presentation.js | 4 ++-- src/layers/firms/queries.js | 10 ++++------ src/layers/firms/selection.js | 5 ++--- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..71ee6d675 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -9,6 +9,7 @@ import alprCamerasLayer, { } from './alprCameras.js'; import { createAlprTileSource } from '../layers/alpr/source.js'; import { DATA_CREDITS } from './dataCredits.js'; +import { setLocale } from '../i18n/index.js'; import { clearSelectedEntityContextForLayer, getContextStore, @@ -473,6 +474,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a } }); +// The voice model reads the label as data; the voice card translates it. +test('the camera label stays English data in a translated interface', async () => { + const h = cameraHarness(); + setLocale('ru'); + try { + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }), + ]), + ); + await alprCamerasLayer.update(); + h.click(h.source.entities.values[0].id); + assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + } finally { + setLocale('en'); + h.restore(); + } +}); + test('invalid OSM ids and out-of-range coordinates never become camera entities', () => { for (const id of [undefined, null, 0, -1, 1.5, '42', NaN]) assert.equal(normalizeAlprNode(cameraNode(id, { id })), null); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 442af60b2..cde867f87 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -8,6 +8,8 @@ import * as Cesium from 'cesium'; import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js'; import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; +import { getSelectedEntityContext } from './contextStore.js'; +import { setLocale } from '../i18n/index.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; @@ -234,6 +236,22 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => { } }); +// The voice model reads these labels as data (track_entity, get_entity_context, +// pointer_context); the voice card translates them where it shows them. +test('fire labels stay English data in a translated interface', () => { + setLocale('ru'); + const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } }); + try { + h.click(); + assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW'); + } finally { + h.cleanup(); + setLocale('en'); + } +}); + test('a click on neither sprite nor card clears without moving the camera', () => { const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } }); try { diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 866f15a6d..c7343d573 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -13,7 +13,7 @@ import { MARKER_SCALE_BY_DISTANCE, } from './policy.js'; import { boxContains } from './model.js'; -import { t } from '../../i18n/index.js'; +import { msg } from '../../i18n/index.js'; import { createAlprFloorResolver } from './floors.js'; import { createAlprOverlay } from './overlay.js'; import { @@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) { layerName: 'ALPR Cameras', source: source.attribution?.description || source.label || 'Camera source', - label: t('ALPR camera'), + label: msg('ALPR camera'), latitude: record.latitude, longitude: record.longitude, properties: { diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index a7d22589e..16ca01166 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -18,9 +18,8 @@ export function createQueries({ if (!fire) return null; return { id: fireDetectionKey(fire), - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, frp: fire.frp, @@ -117,9 +116,8 @@ export function createQueries({ latitude: strongest.lat, longitude: strongest.lon, frp: strongest.frp, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(strongest.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`, }; }, diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index fc05eea2a..d5cfc4d26 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -193,9 +193,8 @@ export function createSelection({ layerName: name, source: 'NASA FIRMS', dataSource: layerState._dataSource, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, properties: { From 9011918731b4a3d2444c7c11e1a472195dce1035 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 3/5] docs(changelog): add the #1207 entry --- changelog.d/1207-voice-entity-labels-english.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1207-voice-entity-labels-english.md diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md new file mode 100644 index 000000000..b69cdc7ed --- /dev/null +++ b/changelog.d/1207-voice-entity-labels-english.md @@ -0,0 +1,3 @@ +- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results + (`track_entity`, `get_entity_context`, pointer context); the voice card still + shows them in the interface language (daikaginza, #1207). From 843c8b1a6bba03073b58e9a00e60467891827a4b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:31 +0700 Subject: [PATCH 4/5] test(i18n): feed real FIRMS and ALPR labels through the voice card The card's fire pattern is written by hand against the producers' template, so a format change upstream would silently show English. Both locale tests now pass the label the layer actually produced through shownEntityLabel() under ru, and set the locale inside the try that resets it. --- src/data/alprCameras.test.mjs | 7 ++++++- src/data/firmsInteraction.test.mjs | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 71ee6d675..549adde96 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -10,6 +10,7 @@ import alprCamerasLayer, { import { createAlprTileSource } from '../layers/alpr/source.js'; import { DATA_CREDITS } from './dataCredits.js'; import { setLocale } from '../i18n/index.js'; +import { shownEntityLabel } from '../voice/speech.js'; import { clearSelectedEntityContextForLayer, getContextStore, @@ -477,8 +478,8 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a // The voice model reads the label as data; the voice card translates it. test('the camera label stays English data in a translated interface', async () => { const h = cameraHarness(); - setLocale('ru'); try { + setLocale('ru'); h.setFetch(async () => cameraResponse([ cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }), @@ -487,6 +488,10 @@ test('the camera label stays English data in a translated interface', async () = await alprCamerasLayer.update(); h.click(h.source.entities.values[0].id); assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + assert.equal( + shownEntityLabel(getSelectedEntityContext().label), + 'Камера ALPR', + ); } finally { setLocale('en'); h.restore(); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index cde867f87..d735df3f8 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -10,6 +10,7 @@ import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { getSelectedEntityContext } from './contextStore.js'; import { setLocale } from '../i18n/index.js'; +import { shownEntityLabel } from '../voice/speech.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; @@ -239,11 +240,16 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => { // The voice model reads these labels as data (track_entity, get_entity_context, // pointer_context); the voice card translates them where it shows them. test('fire labels stay English data in a translated interface', () => { - setLocale('ru'); const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } }); try { + setLocale('ru'); h.click(); assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW'); + // The card's pattern must match what the producer actually writes. + assert.equal( + shownEntityLabel(getSelectedEntityContext().label), + 'Пожар · FRP 1520 МВт', + ); assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW'); assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW'); } finally { From 1a72da81b5ffae71d330c1731545e3e908ea2b3d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 07:54:47 -0700 Subject: [PATCH 5/5] docs(voice): document stable entity labels and localized presentation --- docs/CURRENT-STATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e00041eac..3cca1acc8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -554,6 +554,9 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft, get_entity_context, annotate_map and analyst_query. Analyst speech and the card share one deterministic count/scope headline; lower bounds use "At least" and partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only. +FIRMS and ALPR entity labels remain English in shared context and tool data; +voice-card titles and pointer presentation translate those known labels at display +time, while arbitrary names and callsigns remain unchanged. The model-free `gods-eye-view/agent/world-packet` export exposes `getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to non-voice consumers. It reads the same enabled-only state and feed