mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(voice): keep FIRMS and ALPR entity labels English in voice data (#1207)
Keep FIRMS and ALPR entity data labels in English and localize their presentation. Contribution by @daikaginza.
This commit is contained in:
10 files changed
+140
-17
No files matched your search
@@ -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).
|
||||||
@@ -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
|
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
|
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.
|
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
|
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||||
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||||
non-voice consumers. It reads the same enabled-only state and feed
|
non-voice consumers. It reads the same enabled-only state and feed
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
|
|||||||
} from './alprCameras.js';
|
} from './alprCameras.js';
|
||||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||||
import { DATA_CREDITS } from './dataCredits.js';
|
import { DATA_CREDITS } from './dataCredits.js';
|
||||||
|
import { setLocale } from '../i18n/index.js';
|
||||||
|
import { shownEntityLabel } from '../voice/speech.js';
|
||||||
import {
|
import {
|
||||||
clearSelectedEntityContextForLayer,
|
clearSelectedEntityContextForLayer,
|
||||||
getContextStore,
|
getContextStore,
|
||||||
@@ -473,6 +475,29 @@ 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();
|
||||||
|
try {
|
||||||
|
setLocale('ru');
|
||||||
|
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');
|
||||||
|
assert.equal(
|
||||||
|
shownEntityLabel(getSelectedEntityContext().label),
|
||||||
|
'Камера ALPR',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLocale('en');
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
|
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])
|
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
|
||||||
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
|
|||||||
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
||||||
import { fireDetectionKey } from './firmsLabels.js';
|
import { fireDetectionKey } from './firmsLabels.js';
|
||||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.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 { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -234,6 +237,27 @@ 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', () => {
|
||||||
|
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 {
|
||||||
|
h.cleanup();
|
||||||
|
setLocale('en');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a click on neither sprite nor card clears without moving the camera', () => {
|
test('a click on neither sprite nor card clears without moving the camera', () => {
|
||||||
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
MARKER_SCALE_BY_DISTANCE,
|
MARKER_SCALE_BY_DISTANCE,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { boxContains } from './model.js';
|
import { boxContains } from './model.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { msg } from '../../i18n/index.js';
|
||||||
import { createAlprFloorResolver } from './floors.js';
|
import { createAlprFloorResolver } from './floors.js';
|
||||||
import { createAlprOverlay } from './overlay.js';
|
import { createAlprOverlay } from './overlay.js';
|
||||||
import {
|
import {
|
||||||
@@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
layerName: 'ALPR Cameras',
|
layerName: 'ALPR Cameras',
|
||||||
source:
|
source:
|
||||||
source.attribution?.description || source.label || 'Camera source',
|
source.attribution?.description || source.label || 'Camera source',
|
||||||
label: t('ALPR camera'),
|
label: msg('ALPR camera'),
|
||||||
latitude: record.latitude,
|
latitude: record.latitude,
|
||||||
longitude: record.longitude,
|
longitude: record.longitude,
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ export function createQueries({
|
|||||||
if (!fire) return null;
|
if (!fire) return null;
|
||||||
return {
|
return {
|
||||||
id: fireDetectionKey(fire),
|
id: fireDetectionKey(fire),
|
||||||
label: t('Fire · FRP {frp} MW', {
|
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
|
||||||
frp: components.model.formatFrp(fire.frp),
|
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||||
}),
|
|
||||||
latitude: fire.lat,
|
latitude: fire.lat,
|
||||||
longitude: fire.lon,
|
longitude: fire.lon,
|
||||||
frp: fire.frp,
|
frp: fire.frp,
|
||||||
@@ -118,9 +117,8 @@ export function createQueries({
|
|||||||
latitude: strongest.lat,
|
latitude: strongest.lat,
|
||||||
longitude: strongest.lon,
|
longitude: strongest.lon,
|
||||||
frp: strongest.frp,
|
frp: strongest.frp,
|
||||||
label: t('Fire · FRP {frp} MW', {
|
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
|
||||||
frp: components.model.formatFrp(strongest.frp),
|
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -193,9 +193,8 @@ export function createSelection({
|
|||||||
layerName: name,
|
layerName: name,
|
||||||
source: 'NASA FIRMS',
|
source: 'NASA FIRMS',
|
||||||
dataSource: layerState._dataSource,
|
dataSource: layerState._dataSource,
|
||||||
label: t('Fire · FRP {frp} MW', {
|
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
|
||||||
frp: components.model.formatFrp(fire.frp),
|
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||||
}),
|
|
||||||
latitude: fire.lat,
|
latitude: fire.lat,
|
||||||
longitude: fire.lon,
|
longitude: fire.lon,
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
+13
-1
@@ -115,6 +115,18 @@ function shownSources(source) {
|
|||||||
return source ? [{ label: sourceLabel(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
|
* Tool errors stay English in the result (the model reads them). The card
|
||||||
* translates the known ones; the English must match gevActions exactly.
|
* translates the known ones; the English must match gevActions exactly.
|
||||||
@@ -592,7 +604,7 @@ function getEntityContext(result) {
|
|||||||
return {
|
return {
|
||||||
say: null,
|
say: null,
|
||||||
display: {
|
display: {
|
||||||
title,
|
title: spokenLabel(shownEntityLabel(title)),
|
||||||
lines: [
|
lines: [
|
||||||
selected.layerName
|
selected.layerName
|
||||||
? shownLayerName(selected.layerName, selected.layerId)
|
? shownLayerName(selected.layerName, selected.layerId)
|
||||||
|
|||||||
@@ -3,8 +3,16 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { setLocale } from '../i18n/index.js';
|
import { setLocale } from '../i18n/index.js';
|
||||||
import { attachVoiceResult, planStepLabel } from './speech.js';
|
import {
|
||||||
import { voiceCardView } from './voiceCardPresentation.js';
|
attachVoiceResult,
|
||||||
|
planStepLabel,
|
||||||
|
shownEntityLabel,
|
||||||
|
} from './speech.js';
|
||||||
|
import {
|
||||||
|
initialVoiceCardState,
|
||||||
|
reduceVoiceCard,
|
||||||
|
voiceCardView,
|
||||||
|
} from './voiceCardPresentation.js';
|
||||||
|
|
||||||
for (const locale of ['ru', 'es']) {
|
for (const locale of ['ru', 'es']) {
|
||||||
test(`voice card is in ${locale} for a layer switch`, (t) => {
|
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',
|
'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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 { presentResult } from './resultDisplay.js';
|
||||||
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
|
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
|
||||||
|
|
||||||
@@ -132,7 +137,7 @@ export function reduceVoiceCard(state, event) {
|
|||||||
return reveal({
|
return reveal({
|
||||||
...state,
|
...state,
|
||||||
pointer,
|
pointer,
|
||||||
announce: `${kind}: ${spokenLabel(pointer.label, 32)}`,
|
announce: `${kind}: ${spokenLabel(shownEntityLabel(pointer.label), 32)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'transcript': {
|
case 'transcript': {
|
||||||
@@ -288,7 +293,7 @@ export function voiceCardView(state) {
|
|||||||
pointer: {
|
pointer: {
|
||||||
visible: Boolean(state.pointer),
|
visible: Boolean(state.pointer),
|
||||||
kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'),
|
kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'),
|
||||||
text: spokenLabel(state.pointer?.label || '', 32),
|
text: spokenLabel(shownEntityLabel(state.pointer?.label || ''), 32),
|
||||||
},
|
},
|
||||||
assistant: {
|
assistant: {
|
||||||
visible: Boolean(state.assistant),
|
visible: Boolean(state.assistant),
|
||||||
|
|||||||
Reference in new issue
Block a user