From cbca6a228d83ff13a19a9f20bd9003a4eefd3d3a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:31:18 +0100 Subject: [PATCH] fix(voice): keep canonical analyst IDs out of display-text normalization compactAnalystItem normalized every string field, including `id`. Bikeshare uses the raw station name as its ID, so a station with repeated spaces or a name over 180 code units was registered as a referent under a different key and a numbered fly_to_location reported it as no longer loaded. Normalize display fields only; layerKey, id, icao24, mmsi, noradId, registration and callsign pass through as the layer reports them. Adds an analyst_query -> referent -> fly_to_location test for repeated-space and long station names, and documents the rule in docs/CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 +++++-- src/voice/gevActions.js | 20 +++++++++++++++++- src/voice/gevActions.test.mjs | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 721caabd4..0e875c1ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,8 +13,12 @@ External ALPR tags, surveillance-tool display fields, voice context text and compact analyst strings collapse controls and whitespace before a final 180-character cap (scene labels retain their 120-character cap). Empty ALPR values remain null and Unicode text is preserved without splitting a surrogate -pair. These fields remain untrusted source data in structured tool output; text -normalization does not establish trust or eliminate prompt injection. +pair. Only display fields are normalized: canonical analyst identifiers +(`id`, `layerKey`, `icao24`, `mmsi`, `noradId`, `registration`, `callsign`) +pass through unchanged, because bikeshare uses the raw station name as its ID +and numbered voice references resolve by that key. These fields remain +untrusted source data in structured tool output; text normalization does not +establish trust or eliminate prompt injection. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 308736ed5..71610992c 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4570,6 +4570,21 @@ const ANALYST_IDENTITY_FIELDS = Object.freeze([ 'noradId', ]); +/** + * Analyst item keys that identify a record rather than describe it: the + * referent registry, track_entity and locateLayerEntity match on them, so + * display-text normalization must leave them byte-for-byte unchanged. + */ +const ANALYST_CANONICAL_FIELDS = new Set([ + 'layerKey', + 'id', + 'icao24', + 'mmsi', + 'noradId', + 'registration', + 'callsign', +]); + /** * Compact one analyst record for the voice payload: identity, position (so a * follow-up can fly to it), distance, and that layer's manifest fields with @@ -4607,8 +4622,11 @@ function compactAnalystItem(r) { } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; + // Display text only: canonical keys are lookup identities (bikeshare uses + // the raw station name as its id), so the referent registry and + // locateLayerEntity must see them exactly as the layer reports them. for (const key of Object.keys(compact)) { - if (typeof compact[key] === 'string') + if (typeof compact[key] === 'string' && !ANALYST_CANONICAL_FIELDS.has(key)) compact[key] = normalizeDisplayText(compact[key]); } return compact; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 2009d789e..5367bd2f3 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3944,3 +3944,43 @@ test('analyst item display strings are single-line and bounded for voice', async assert.equal(result.items[0].id, 'stable-id'); for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/); }); + +test('analyst_query keeps canonical IDs raw so numbered references still fly to the station', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + const referents = createReferentRegistry(); + const longName = 'Station ' + 'é'.repeat(200); + const spaced = 'Congress Ave & 2nd'; + const stations = [ + { id: spaced, name: spaced, lat: 30.2672, lon: -97.7431 }, + { id: longName, name: longName, lat: 30.2711, lon: -97.7437 }, + ]; + const bikeshare = { getStats: () => ({ count: stations.length, lastUpdate: Date.now() }), getAnalystRecords: () => stations }; + const runner = createActionRunner({ + viewer, styleManager, + dataManager: { + layers: new Map([['bikeshare', { module: bikeshare }]]), + isEnabled: () => true, + getAll: () => [{ id: 'bikeshare', name: 'bikeshare', enabled: true, stats: bikeshare.getStats() }], + }, + deixis: { referents }, + }); + const listed = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 2 }); + assert.equal(listed.ok, true); + const bySpaced = listed.items.find((item) => item.id === spaced); + const byLong = listed.items.find((item) => item.id === longName); + assert.ok(bySpaced && byLong, 'compact items keep the raw station-name IDs'); + assert.equal(bySpaced.name, 'Congress Ave & 2nd', 'display name is still normalized'); + assert.equal(byLong.name.length, 180, 'display name is still bounded'); + assert.equal(byLong.id.length, longName.length); + const registered = referents.list(); + assert.equal(registered.length, 2, 'the analyst result became the numbered list'); + for (const ref of registered) { + const station = stations.find((row) => row.id === ref.id); + assert.ok(station, `referent ${ref.n} keeps the canonical ID`); + const flown = await runner('fly_to_location', { referent: ref.n }); + assert.equal(flown.ok, true, `referent ${ref.n} resolves instead of "no longer loaded"`); + assert.equal(flown.latitude, station.lat); + assert.equal(flown.longitude, station.lon); + } +});