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

+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');