mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
4efba8ccca
commit
540629a0fc
6 files changed
+99
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user