From 540629a0fc890e7206ce485e41386b0b4ba52568 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 09:09:28 -0800 Subject: [PATCH] fix(osm): keep a capped place list incomplete in analyst follow-ups osm_query said "At least N" when the server capped its list, but handed the places to the analyst engine with truncated:false, so "which of those have emergency?" answered as if the list were whole. The handoff now carries the cap and its caveat, and follow-ups (and their follow-ups) stay incomplete with an unknown total. --- docs/CURRENT-STATE.md | 2 +- src/data/analystEngine.js | 23 ++++++++++++----- src/data/analystEngine.test.mjs | 17 ++++++++++++ src/voice/gevActions.js | 3 ++- src/voice/osmActions.js | 28 ++++++++++++-------- src/voice/osmActions.test.mjs | 46 +++++++++++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6774b8bb0..9ad3b787d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -831,7 +831,7 @@ The standalone app offers OpenAI and Gemini Live through an explicit provider ch Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields. -Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit; stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne::` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry. +Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit; stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne::` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry. `createGevActionRunner` routes `control_cctv` enable/disable through the shared layer visibility lifecycle. Results distinguish successful changes from diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index ea65f35c9..cc8519521 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -1182,12 +1182,21 @@ export function createAnalystEngine(providers) { * so a follow-up filters exactly what the user just heard about. * @param {string} layerKey Queryable layer the rows belong to. * @param {Array} rows Records in that layer's analyst shape. - * @param {{scope?: string, presentation?: object|null}} [options] + * @param {{scope?: string, presentation?: object|null, truncated?: boolean}} [options] * `presentation` is how the set was scoped ("inside Kathmandu"), so a - * follow-up names the same scope instead of "anywhere". + * follow-up names the same scope instead of "anywhere". `truncated` + * says the set is only the first part of a larger one, so follow-ups + * stay incomplete (a count is a floor) rather than claiming the whole. */ - remember(layerKey, rows, { scope = 'handoff', presentation = null } = {}) { + remember( + layerKey, + rows, + { scope = 'handoff', presentation = null, truncated = false } = {}, + ) { if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false; + const capped = truncated === true; + // A capped set's true size is unknown. + const total = capped ? null : rows.length; lastResult = { ...(presentation ? { presentation: structuredClone(presentation) } @@ -1200,14 +1209,14 @@ export function createAnalystEngine(providers) { layerKey, status: rows.length ? 'ok' : 'empty', returned: rows.length, - total: rows.length, - truncated: false, + total, + truncated: capped, }, ], records: { returned: rows.length, - total: rows.length, - truncated: false, + total, + truncated: capped, }, scope, followUp: false, diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 51052f4cf..7bf20defa 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -1204,6 +1204,23 @@ test('a follow-up on handed-off places keeps the area they were found in', async assert.equal(result.coverage.scope, 'area:area-1'); }); +test('a follow-up on a capped handed-off set is not complete', async () => { + const rows = [ + { id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' }, + ]; + const engine = createAnalystEngine({ + getRecords: (key) => (key === 'osm-places' ? rows : []), + resolveRegionRing: async () => null, + getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }), + }); + engine.remember('osm-places', rows, { truncated: true }); + const result = await engine.query({ layers: ['osm-places'], followUp: true }); + assert.equal(result.count, 1); + assert.equal(result.complete, false); + assert.equal(result.coverage.records.truncated, true); + assert.equal(result.coverage.records.total, null, 'the true size is unknown'); +}); + test('a slow scan yields on a time budget, not only every 20,000 rows', async () => { // 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice // under a row-count budget alone. diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index e2acd8933..53208c538 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1226,11 +1226,12 @@ export function createGevActionRunner({ onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT), // The listed places become the analyst's "last answer", so // "which of those…" filters them and never an older set. - rememberResults: (rows, presentation) => { + rememberResults: (rows, presentation, { truncated = false } = {}) => { if (!current()) return; if (rows) ensureAnalystEngine().remember('osm-places', rows, { presentation, + truncated, }); else analystEngine?.reset(); }, diff --git a/src/voice/osmActions.js b/src/voice/osmActions.js index cceecda5c..1958c025f 100644 --- a/src/voice/osmActions.js +++ b/src/voice/osmActions.js @@ -71,9 +71,9 @@ const EXAMPLES = Object.values(OSM_PRESETS) * @param {(enabled: boolean) => Promise} deps.enableLayer * @param {Function} deps.search `/api/osm/features` client. * @param {() => void} [deps.onCredit] Registers the OpenStreetMap credit. - * @param {(rows: object[]|null, presentation?: object) => void} [deps.rememberResults] - * Hands the listed places, and the scope they were found in, to analyst - * follow-ups; null forgets the previous set. + * @param {(rows: object[]|null, presentation?: object, options?: {truncated: boolean}) => void} [deps.rememberResults] + * Hands the listed places, the scope they were found in and whether the + * list was capped to analyst follow-ups; null forgets the previous set. * @param {(step: string, label?: string) => void} [deps.progress] * @param {object} args * @param {{isCurrent?: () => boolean, budgetMs?: number, signal?: AbortSignal}} [options] @@ -266,15 +266,21 @@ export async function osmQuery( // These places are now "the last answer": follow-ups filter them and // "the second one" is the second row below. A follow-up keeps naming the // area they were found in, as an analyst count over that area would. + // A capped list stays capped: follow-ups count a floor, with the caveat. + const cappedCaveat = `listed the first ${limit}; there are more`; rememberResults( records, - area - ? { - scopeLabel: `inside ${areaName}`, - coverageScope: `area:${args.areaId}`, - areaId: args.areaId, - } - : { scopeLabel: 'in view', coverageScope: 'view' }, + { + ...(area + ? { + scopeLabel: `inside ${areaName}`, + coverageScope: `area:${args.areaId}`, + areaId: args.areaId, + } + : { scopeLabel: 'in view', coverageScope: 'view' }), + ...(complete ? {} : { display: { caveat: cappedCaveat } }), + }, + { truncated: !complete }, ); const unnamed = `unnamed ${singularLabel(preset.label)}`; const referents = [ @@ -309,7 +315,7 @@ export async function osmQuery( ...(merged ? { merged: `${merged} duplicate mappings merged` } : {}), caveat: complete ? 'OpenStreetMap coverage varies by place' - : `listed the first ${limit}; there are more`, + : cappedCaveat, }, }; } finally { diff --git a/src/voice/osmActions.test.mjs b/src/voice/osmActions.test.mjs index 25879cf2a..fa0760a62 100644 --- a/src/voice/osmActions.test.mjs +++ b/src/voice/osmActions.test.mjs @@ -8,6 +8,7 @@ import { boxDeg2, osmQuery } from './osmActions.js'; import { matchOsmPreset, OSM_PRESET_IDS } from '../data/osmPresets.js'; import { createAreaStore } from '../data/areaStore.js'; import { dedupeOsmRecords, mapOsmFeature } from '../layers/osmPlaces/model.js'; +import { createAnalystEngine } from '../data/analystEngine.js'; const square = (w, s, e, n) => [ [w, s], @@ -346,6 +347,51 @@ test('a truncated list reports at least N', async () => { assert.match(result.say, /^At least 1 hospital in Kathmandu/); }); +test('a capped list stays incomplete through analyst follow-ups', async () => { + // Wired as gevActions wires it: the listed places become the last answer. + const run = async (truncated) => { + const { value } = deps({ + answer: { ok: true, features: FEATURES.slice(2, 3), truncated }, + }); + const records = []; + const engine = createAnalystEngine({ + getRecords: (key) => (key === 'osm-places' ? records : []), + resolveRegionRing: async () => null, + getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }), + }); + value.rememberResults = (rows, presentation, options) => { + records.splice(0, records.length, ...rows); + engine.remember('osm-places', rows, { presentation, ...options }); + }; + const first = await osmQuery(value, { + what: 'hospitals', + areaId: 'osm:r1', + limit: 1, + }); + const followUp = await engine.query({ + layers: ['osm-places'], + followUp: true, + filters: [{ field: 'emergency', op: 'eq', value: 'yes' }], + }); + // And a follow-up of the follow-up. + const again = await engine.query({ layers: ['osm-places'], followUp: true }); + return { first, followUp, again }; + }; + const capped = await run(true); + assert.equal(capped.first.complete, false); + assert.equal(capped.followUp.ok, true); + assert.equal(capped.followUp.count, 1); + assert.equal(capped.followUp.complete, false, 'the count is a floor'); + assert.equal(capped.followUp.display.caveat, 'listed the first 1; there are more'); + assert.equal(capped.followUp.scopeLabel, 'inside Kathmandu'); + assert.equal(capped.again.complete, false); + assert.equal(capped.again.display.caveat, 'listed the first 1; there are more'); + + const whole = await run(false); + assert.equal(whole.first.complete, true); + assert.equal(whole.followUp.complete, true); +}); + test('spoken kinds map to curated presets', () => { assert.equal(matchOsmPreset('all hospitals').id, 'hospital'); assert.equal(matchOsmPreset('petrol stations').id, 'fuel');