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.
This commit is contained in:
Sameh Khamis committed 2026-11-03 09:09:28 -08:00
1 parent 4efba8ccca
commit 540629a0fc
6 files changed
+99 -20

No files matched your search

+1 -1
View File
@@ -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:<kind>:<slug>` 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:<kind>:<slug>` 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
+16 -7
View File
@@ -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<object>} 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,
+17
View File
@@ -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.
+2 -1
View File
@@ -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();
},
+17 -11
View File
@@ -71,9 +71,9 @@ const EXAMPLES = Object.values(OSM_PRESETS)
* @param {(enabled: boolean) => Promise<object>} 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 {
+46
View File
@@ -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');