mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
+94
-15
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 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
|
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
|
||||||
layer visibility lifecycle. Results distinguish successful changes from
|
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.
|
* so a follow-up filters exactly what the user just heard about.
|
||||||
* @param {string} layerKey Queryable layer the rows belong to.
|
* @param {string} layerKey Queryable layer the rows belong to.
|
||||||
* @param {Array<object>} rows Records in that layer's analyst shape.
|
* @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
|
* `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;
|
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 = {
|
lastResult = {
|
||||||
...(presentation
|
...(presentation
|
||||||
? { presentation: structuredClone(presentation) }
|
? { presentation: structuredClone(presentation) }
|
||||||
@@ -1200,14 +1209,14 @@ export function createAnalystEngine(providers) {
|
|||||||
layerKey,
|
layerKey,
|
||||||
status: rows.length ? 'ok' : 'empty',
|
status: rows.length ? 'ok' : 'empty',
|
||||||
returned: rows.length,
|
returned: rows.length,
|
||||||
total: rows.length,
|
total,
|
||||||
truncated: false,
|
truncated: capped,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
records: {
|
records: {
|
||||||
returned: rows.length,
|
returned: rows.length,
|
||||||
total: rows.length,
|
total,
|
||||||
truncated: false,
|
truncated: capped,
|
||||||
},
|
},
|
||||||
scope,
|
scope,
|
||||||
followUp: false,
|
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');
|
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 () => {
|
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
|
// 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice
|
||||||
// under a row-count budget alone.
|
// under a row-count budget alone.
|
||||||
|
|||||||
@@ -1226,11 +1226,12 @@ export function createGevActionRunner({
|
|||||||
onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT),
|
onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT),
|
||||||
// The listed places become the analyst's "last answer", so
|
// The listed places become the analyst's "last answer", so
|
||||||
// "which of those…" filters them and never an older set.
|
// "which of those…" filters them and never an older set.
|
||||||
rememberResults: (rows, presentation) => {
|
rememberResults: (rows, presentation, { truncated = false } = {}) => {
|
||||||
if (!current()) return;
|
if (!current()) return;
|
||||||
if (rows)
|
if (rows)
|
||||||
ensureAnalystEngine().remember('osm-places', rows, {
|
ensureAnalystEngine().remember('osm-places', rows, {
|
||||||
presentation,
|
presentation,
|
||||||
|
truncated,
|
||||||
});
|
});
|
||||||
else analystEngine?.reset();
|
else analystEngine?.reset();
|
||||||
},
|
},
|
||||||
|
|||||||
+12
-6
@@ -71,9 +71,9 @@ const EXAMPLES = Object.values(OSM_PRESETS)
|
|||||||
* @param {(enabled: boolean) => Promise<object>} deps.enableLayer
|
* @param {(enabled: boolean) => Promise<object>} deps.enableLayer
|
||||||
* @param {Function} deps.search `/api/osm/features` client.
|
* @param {Function} deps.search `/api/osm/features` client.
|
||||||
* @param {() => void} [deps.onCredit] Registers the OpenStreetMap credit.
|
* @param {() => void} [deps.onCredit] Registers the OpenStreetMap credit.
|
||||||
* @param {(rows: object[]|null, presentation?: object) => void} [deps.rememberResults]
|
* @param {(rows: object[]|null, presentation?: object, options?: {truncated: boolean}) => void} [deps.rememberResults]
|
||||||
* Hands the listed places, and the scope they were found in, to analyst
|
* Hands the listed places, the scope they were found in and whether the
|
||||||
* follow-ups; null forgets the previous set.
|
* list was capped to analyst follow-ups; null forgets the previous set.
|
||||||
* @param {(step: string, label?: string) => void} [deps.progress]
|
* @param {(step: string, label?: string) => void} [deps.progress]
|
||||||
* @param {object} args
|
* @param {object} args
|
||||||
* @param {{isCurrent?: () => boolean, budgetMs?: number, signal?: AbortSignal}} [options]
|
* @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
|
// 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
|
// "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.
|
// 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(
|
rememberResults(
|
||||||
records,
|
records,
|
||||||
area
|
{
|
||||||
|
...(area
|
||||||
? {
|
? {
|
||||||
scopeLabel: `inside ${areaName}`,
|
scopeLabel: `inside ${areaName}`,
|
||||||
coverageScope: `area:${args.areaId}`,
|
coverageScope: `area:${args.areaId}`,
|
||||||
areaId: args.areaId,
|
areaId: args.areaId,
|
||||||
}
|
}
|
||||||
: { scopeLabel: 'in view', coverageScope: 'view' },
|
: { scopeLabel: 'in view', coverageScope: 'view' }),
|
||||||
|
...(complete ? {} : { display: { caveat: cappedCaveat } }),
|
||||||
|
},
|
||||||
|
{ truncated: !complete },
|
||||||
);
|
);
|
||||||
const unnamed = `unnamed ${singularLabel(preset.label)}`;
|
const unnamed = `unnamed ${singularLabel(preset.label)}`;
|
||||||
const referents = [
|
const referents = [
|
||||||
@@ -309,7 +315,7 @@ export async function osmQuery(
|
|||||||
...(merged ? { merged: `${merged} duplicate mappings merged` } : {}),
|
...(merged ? { merged: `${merged} duplicate mappings merged` } : {}),
|
||||||
caveat: complete
|
caveat: complete
|
||||||
? 'OpenStreetMap coverage varies by place'
|
? 'OpenStreetMap coverage varies by place'
|
||||||
: `listed the first ${limit}; there are more`,
|
: cappedCaveat,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { boxDeg2, osmQuery } from './osmActions.js';
|
|||||||
import { matchOsmPreset, OSM_PRESET_IDS } from '../data/osmPresets.js';
|
import { matchOsmPreset, OSM_PRESET_IDS } from '../data/osmPresets.js';
|
||||||
import { createAreaStore } from '../data/areaStore.js';
|
import { createAreaStore } from '../data/areaStore.js';
|
||||||
import { dedupeOsmRecords, mapOsmFeature } from '../layers/osmPlaces/model.js';
|
import { dedupeOsmRecords, mapOsmFeature } from '../layers/osmPlaces/model.js';
|
||||||
|
import { createAnalystEngine } from '../data/analystEngine.js';
|
||||||
|
|
||||||
const square = (w, s, e, n) => [
|
const square = (w, s, e, n) => [
|
||||||
[w, s],
|
[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/);
|
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', () => {
|
test('spoken kinds map to curated presets', () => {
|
||||||
assert.equal(matchOsmPreset('all hospitals').id, 'hospital');
|
assert.equal(matchOsmPreset('all hospitals').id, 'hospital');
|
||||||
assert.equal(matchOsmPreset('petrol stations').id, 'fuel');
|
assert.equal(matchOsmPreset('petrol stations').id, 'fuel');
|
||||||
|
|||||||
Reference in new issue
Block a user