mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(voice): add reusable geometry actions
- turn named places and mapped landmarks into reusable bounded areas - count loaded records inside resolved, drawn and annotated geometry - find recent imagery for an area, the view or the pointer snapshot - keep osm place search behind an operator-configured overpass endpoint - preserve cancellation, ownership and follow-up memory across every action - cover geometry, provider lifecycle, cache isolation and voice routing
This commit is contained in:
1 parent
01979bba1e
commit
b234d7b427
84 files changed
+14262
-1681
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
|
||||
/**
|
||||
* A bounded two-level cache for JSON answers: memory (entry count) and disk
|
||||
* (entry count, total bytes, age). Every memory insertion goes through one
|
||||
* function, so a disk hit is bounded like a fresh answer; disk writes are
|
||||
* followed by a prune that removes expired files first, then the oldest,
|
||||
* until both quotas hold.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string|null} options.dir Disk directory; null keeps memory only.
|
||||
* @param {number} options.ttlMs Age past which an entry is not served.
|
||||
* @param {number} [options.maxMemoryEntries]
|
||||
* @param {number} [options.maxDiskEntries]
|
||||
* @param {number} [options.maxDiskBytes]
|
||||
* @param {() => number} [options.now]
|
||||
*/
|
||||
export function createBoundedCache({
|
||||
dir,
|
||||
ttlMs,
|
||||
maxMemoryEntries = 60,
|
||||
maxDiskEntries = 400,
|
||||
maxDiskBytes = 256 * 1024 * 1024,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
const memory = new Map();
|
||||
let pruning = null;
|
||||
let prunePending = false;
|
||||
|
||||
const fileFor = (key) =>
|
||||
path.join(dir, `${createHash('sha1').update(key).digest('hex')}.json`);
|
||||
|
||||
function remember(key, entry) {
|
||||
memory.delete(key);
|
||||
memory.set(key, entry);
|
||||
while (memory.size > maxMemoryEntries)
|
||||
memory.delete(memory.keys().next().value);
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
if (!dir) return;
|
||||
let names;
|
||||
try {
|
||||
names = (await fsp.readdir(dir)).filter((name) => name.endsWith('.json'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const files = [];
|
||||
for (const name of names) {
|
||||
const file = path.join(dir, name);
|
||||
try {
|
||||
const stat = await fsp.stat(file);
|
||||
files.push({ file, size: stat.size, mtime: stat.mtimeMs });
|
||||
} catch {
|
||||
/* removed meanwhile */
|
||||
}
|
||||
}
|
||||
const cutoff = now() - ttlMs;
|
||||
const keep = [];
|
||||
for (const entry of files) {
|
||||
if (entry.mtime < cutoff) await fsp.rm(entry.file, { force: true });
|
||||
else keep.push(entry);
|
||||
}
|
||||
keep.sort((a, b) => a.mtime - b.mtime);
|
||||
let bytes = keep.reduce((sum, entry) => sum + entry.size, 0);
|
||||
while (
|
||||
keep.length &&
|
||||
(keep.length > maxDiskEntries || bytes > maxDiskBytes)
|
||||
) {
|
||||
const oldest = keep.shift();
|
||||
bytes -= oldest.size;
|
||||
await fsp.rm(oldest.file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one prune at a time; a request during a prune schedules one more. */
|
||||
function schedulePrune() {
|
||||
if (pruning) {
|
||||
prunePending = true;
|
||||
return pruning;
|
||||
}
|
||||
pruning = prune()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pruning = null;
|
||||
if (prunePending) {
|
||||
prunePending = false;
|
||||
schedulePrune();
|
||||
}
|
||||
});
|
||||
return pruning;
|
||||
}
|
||||
|
||||
return {
|
||||
/** The cached value, or undefined. A disk hit is re-inserted into memory. */
|
||||
async get(key) {
|
||||
const held = memory.get(key);
|
||||
if (held && now() - held.cachedAt <= ttlMs) return held.value;
|
||||
if (held) memory.delete(key);
|
||||
if (!dir) return undefined;
|
||||
try {
|
||||
const entry = JSON.parse(await fsp.readFile(fileFor(key), 'utf8'));
|
||||
if (!Number.isFinite(entry?.cachedAt) || now() - entry.cachedAt > ttlMs)
|
||||
return undefined;
|
||||
remember(key, entry);
|
||||
return entry.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
/** Store a value in memory and, when a directory is set, on disk. */
|
||||
async set(key, value) {
|
||||
const entry = { value, cachedAt: now() };
|
||||
remember(key, entry);
|
||||
if (!dir) return;
|
||||
try {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
await fsp.writeFile(fileFor(key), JSON.stringify(entry));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await schedulePrune();
|
||||
},
|
||||
prune: schedulePrune,
|
||||
get memorySize() {
|
||||
return memory.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
const DEFAULT_ANNOTATION_GUIDANCE =
|
||||
'Put related places in one annotate_map call. Set flyTo true only when the user is not already looking at the place (off-screen marks auto-frame, so when unsure leave it false). Do not say you are drawing or annotating — talk about the places while the marks appear. Marks ACCUMULATE AND PERSIST across flights and topics so the user can build up the map: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
|
||||
|
||||
/**
|
||||
* The Realtime session instructions. `overpass` says an operator configured
|
||||
* Overpass, which is what offers osm_query (see tools.js); without it the
|
||||
* prompt never mentions the tool.
|
||||
*/
|
||||
function realtimeInstructions(
|
||||
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
|
||||
{ overpass = false } = {},
|
||||
) {
|
||||
return [
|
||||
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
|
||||
@@ -19,17 +25,21 @@ function realtimeInstructions(
|
||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||
'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.',
|
||||
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
|
||||
'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names, or an areaId you already have; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.',
|
||||
'AREAS: resolve_area turns a named boundary (country, state, province, county, city, district, natural region) into an areaId — to outline it (draw=true), to count or search inside it, or for "an area around <landmark>" (around=true); annotate_map is for marks, not boundaries. Count inside a named place → analyst_query kind=region, or kind=area with an areaId; "the area I drew" → kind=drawn; "the area you outlined" → kind=annotation. Imagery of a named place → resolve_area, then find_imagery with its areaId. For an ambiguous name call resolve_area anyway; on needsClarification ask which one, never guess.' +
|
||||
(overpass
|
||||
? ' "All hospitals/schools/… in <place>" → resolve_area, then osm_query with its areaId; in view → osm_query alone; the first question about those results → analyst_query layers [osm-places], scope anywhere.'
|
||||
: ''),
|
||||
'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.',
|
||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — the exact numbers on the user\'s panel (set_context_mode, analyst_query and get_current_view_state carry it once Contacts settles). For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft, not from a pre-Contacts analyst query; analyst_query\'s own count measures loaded records and is usually lower — never give it as the window count. The centre follows WHERE; with Contacts active but no subject yet, use the view, never an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.',
|
||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||
'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.',
|
||||
'Do not mention disabled layers or stale selections.',
|
||||
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'POINTING: pointer_context says what the cursor is on (pointing:<kind>: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.',
|
||||
'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.',
|
||||
'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.',
|
||||
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never adding to it and never repeating your preamble; if say is null, confirm briefly or continue. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500") and partial answers are part of the answer — always say them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
|
||||
'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never adding to it and never repeating your preamble; if say is null, confirm briefly or continue. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers and approximate outlines are part of the answer — always say them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.',
|
||||
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
|
||||
// INSTRUCTION-ONLY mapping for the two globe-scale named views.
|
||||
//
|
||||
@@ -58,7 +68,7 @@ function realtimeInstructions(
|
||||
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
|
||||
'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.',
|
||||
'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.',
|
||||
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus, district or boundary, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park, district or boundary outline; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
|
||||
'WHITEBOARD THE WORLD: when you describe a specific place, building, campus or district, or how places relate, call annotate_map as you talk. type=area traces a building, campus, compound, park or district footprint; pin marks a spot; highlight pulses a spot with no footprint; arrow joins two places ("it sits next to the Marina"). Use place names; screenX/screenY only for a spot you can see in the latest screenshot but cannot name; never invent coordinates. Set entityKind whenever you know what the target is.',
|
||||
annotationGuidance,
|
||||
'annotate_map results: work any say line in naturally (places not found, a straight-line fallback with no travel time, or a full map — then ask before clearing). outlinePending means the boundary is still being traced: never claim it is drawn. A later map_annotation_outline system item reports each outcome; use it to quietly confirm or correct yourself.',
|
||||
'ROUTES vs DISTANCES: "walking/driving route from A to B" → type=route with the ordered points and the mode; the app draws the street path and reports distance and time you can read aloud. "How far is X from Y" or "X is next to Y" → type=arrow (straight-line distance), never route.',
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
OPENAI_REALTIME_TRANSCRIBE_MODEL_DEFAULT,
|
||||
} from './constants.js';
|
||||
import { realtimeInstructions } from './instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from './tools.js';
|
||||
import { realtimeTools } from './tools.js';
|
||||
import { resolveOverpassUpstreams } from '../overpass/constants.js';
|
||||
|
||||
function createRealtimeTokenHandler({
|
||||
annotationGuidance,
|
||||
@@ -21,6 +22,8 @@ function createRealtimeTokenHandler({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
resolveApiKey = () => process.env.OPENAI_API_KEY,
|
||||
models = {},
|
||||
// osm_query is offered only when an operator configured Overpass.
|
||||
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
|
||||
} = {}) {
|
||||
return async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -97,6 +100,7 @@ function createRealtimeTokenHandler({
|
||||
transcribeModel.toLowerCase() === 'off'
|
||||
? {}
|
||||
: { transcription: { model: transcribeModel } };
|
||||
const overpass = overpassConfigured();
|
||||
const sessionConfig = {
|
||||
session: {
|
||||
type: 'realtime',
|
||||
@@ -122,8 +126,8 @@ function createRealtimeTokenHandler({
|
||||
},
|
||||
output: { voice },
|
||||
},
|
||||
instructions: realtimeInstructions(annotationGuidance),
|
||||
tools: GEV_REALTIME_TOOLS,
|
||||
instructions: realtimeInstructions(annotationGuidance, { overpass }),
|
||||
tools: realtimeTools({ overpass }),
|
||||
tool_choice: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -399,7 +399,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
annotate_map: {
|
||||
description:
|
||||
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints and boundaries, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
|
||||
'Mark places on the 3D map as you talk about them (see WHITEBOARD): pins, highlights, footprints, arrows, routes and labels. Give place names (or explicit lat/lng); the app resolves real positions and outlines. Several places may go in one call.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -541,9 +541,48 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve_area: {
|
||||
description:
|
||||
'Named place → reusable areaId. draw=true outlines it ("outline Bagmati province"); around=true is "an area around <landmark>" — its enclosing mapped area, else a buffer marked approximate. Pass the areaId to analyst_query scope {kind:area}, find_imagery or osm_query. needsClarification: ask which, then call again with its candidateId. AREA_TIMEOUT: say you are still looking and retry once.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
query: {
|
||||
description: 'The place as said, with any kind word.',
|
||||
$position: 1,
|
||||
},
|
||||
level: {
|
||||
description: 'Only when the user names the kind.',
|
||||
$position: 1,
|
||||
},
|
||||
within: {
|
||||
description: 'Country or state the user added.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
find_imagery: {
|
||||
description:
|
||||
'Recent satellite imagery (Sentinel-2/Landsat 30 m, VIIRS daily; last 30 days): pins the best acquisition in the Recent Imagery panel. areaId from resolve_area; area=pointer for "this spot"; omit for the view. BOX_REFUSED: offer to zoom in. DISPLACED: the user changed the panel, leave it.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
maxCloudPct: {
|
||||
description: '"cloud-free" → 10.',
|
||||
$position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
osm_query: {
|
||||
description:
|
||||
'OpenStreetMap places of one kind ("hospitals in Kathmandu"). areaId from resolve_area; omit for the view. Shows them in OSM Places with numbered referents; they become the last answer. countScope bounding-box means an approximate count. OSM_TIMEOUT: retry once.',
|
||||
$position: 1,
|
||||
},
|
||||
analyst_query: {
|
||||
description:
|
||||
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values).',
|
||||
'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers — "how many flights over Texas", "biggest fire near LA", "ships headed to Oakland", "next rocket launch". complete:false means the count is a floor ("at least") and rankings cover the records examined; partial names unanswered layers. Items carry lat/lon for a follow-up fly_to_location. ok:false codes: LAYER_OFF (offer to enable), NOT_READY (loading), NEEDS_CLARIFICATION (ask which), FOLLOW_UP_MISMATCH (ask again without followUp), UNKNOWN_FIELD/UNKNOWN_SCOPE/BAD_VALUE (retry with the allowed values).',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -554,7 +593,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
scope: {
|
||||
description:
|
||||
'Always set it (see WHERE). view = a radius around the view centre ("in view", "nearby"); pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>"; anywhere = no place ("biggest anywhere", "today"). There is no drawn-area scope yet: say so rather than answering for the view.',
|
||||
'Always set it (see WHERE). view = a radius around the view centre ("in view", "nearby"); pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>"; anywhere = no place ("biggest anywhere", "today"); area + areaId from resolve_area; drawn = "the area I drew"; annotation = "the area you outlined".',
|
||||
$position: 2,
|
||||
properties: {
|
||||
name: {
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { createActionTools } from '../../../src/voice/actionSchemas.js';
|
||||
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
|
||||
|
||||
/** Every voice tool, as the Realtime session describes them. */
|
||||
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
|
||||
|
||||
/** Tools that need an operator-configured Overpass (OVERPASS_UPSTREAMS). */
|
||||
export const OVERPASS_ONLY_TOOLS = Object.freeze(['osm_query']);
|
||||
|
||||
/**
|
||||
* The tools one session is offered. Bulk OpenStreetMap place search runs
|
||||
* only on an operator's Overpass, so without one `osm_query` is left out
|
||||
* rather than offered and refused.
|
||||
* @param {{overpass?: boolean}} [options]
|
||||
*/
|
||||
export function realtimeTools({ overpass = false } = {}) {
|
||||
return overpass
|
||||
? GEV_REALTIME_TOOLS
|
||||
: GEV_REALTIME_TOOLS.filter(
|
||||
(tool) => !OVERPASS_ONLY_TOOLS.includes(tool.name),
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
overpassNotConfigured,
|
||||
} from './overpass/transport.js';
|
||||
import { installRouteMiddleware } from './places/routes.js';
|
||||
import { installOsmFeaturesRoute } from './overpass/osmFeaturesRoute.js';
|
||||
|
||||
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
|
||||
const _overpassInFlight = new Map();
|
||||
@@ -232,6 +233,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
});
|
||||
|
||||
installRouteMiddleware(server.middlewares, routing);
|
||||
installOsmFeaturesRoute(server.middlewares);
|
||||
};
|
||||
return {
|
||||
name: 'overpass-proxy',
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { OSM_PRESETS } from '../../../src/data/osmPresets.js';
|
||||
|
||||
/**
|
||||
* Bounded, typed OpenStreetMap feature search: one preset from the curated
|
||||
* table inside a bounding box.
|
||||
*
|
||||
* The request carries a preset id, a box, a mode and a limit — never query
|
||||
* text. The server writes the Overpass query itself and bounds it: box size
|
||||
* per mode (a count may cover more ground than a fetch), result count,
|
||||
* Overpass timeout and memory, response bytes, and one query in flight. It
|
||||
* runs only against the operator's `OVERPASS_UPSTREAMS`; the generic
|
||||
* `/api/overpass` guard is untouched.
|
||||
*
|
||||
* There is deliberately no boundary (`area`) filter: Overpass servers differ
|
||||
* in whether they hold area data, and one without it answers zero rather than
|
||||
* failing. The browser filters the box's features by the exact area geometry
|
||||
* instead, so a count inside a province is exact and a box count is labelled
|
||||
* as a box count.
|
||||
*/
|
||||
export const OSM_FEATURE_LIMITS = Object.freeze({
|
||||
/** Largest box a fetch may cover, in square degrees (~200 × 200 km at the equator). */
|
||||
maxFetchDeg2: 4,
|
||||
/** Largest box a count may cover. */
|
||||
maxCountDeg2: 36,
|
||||
/** Most features one fetch returns. */
|
||||
maxLimit: 1000,
|
||||
/** Overpass server-side timeout, seconds. */
|
||||
timeoutS: 25,
|
||||
/** Overpass server-side memory cap, bytes. */
|
||||
maxsize: 64 * 1024 * 1024,
|
||||
/** Response bytes read from the upstream. */
|
||||
maxResponseBytes: 16 * 1024 * 1024,
|
||||
/** Per-upstream wait: a busy Overpass answers these in 10–30 s. */
|
||||
upstreamTimeoutMs: 40_000,
|
||||
});
|
||||
|
||||
/** Tags a returned feature keeps, besides the preset's own. */
|
||||
const KEPT_TAGS = [
|
||||
'name',
|
||||
'name:en',
|
||||
'operator',
|
||||
'brand',
|
||||
'opening_hours',
|
||||
'wheelchair',
|
||||
'emergency',
|
||||
'healthcare',
|
||||
'addr:street',
|
||||
'addr:city',
|
||||
];
|
||||
|
||||
const quote = (text) =>
|
||||
`"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
|
||||
/**
|
||||
* Parse and bound a request. Returns the compiled query or a refusal.
|
||||
* @param {{preset: string, bbox: string|number[], mode?: string, limit?: string|number}} request
|
||||
* @returns {{ok: true, ql: string, mode: string, preset: object, limit: number, boxes: number[][]}|{ok: false, code: string, error: string}}
|
||||
*/
|
||||
export function compileOsmFeatureQuery(request = {}) {
|
||||
const refuse = (code, error) => ({ ok: false, code, error });
|
||||
const presetId = String(request.preset || '');
|
||||
const preset = Object.hasOwn(OSM_PRESETS, presetId)
|
||||
? OSM_PRESETS[presetId]
|
||||
: null;
|
||||
if (!preset) return refuse('UNKNOWN_PRESET', 'Unknown feature kind.');
|
||||
const mode = request.mode === 'count' ? 'count' : 'features';
|
||||
const parts = (
|
||||
Array.isArray(request.bbox)
|
||||
? request.bbox
|
||||
: String(request.bbox || '').split(',')
|
||||
).map(Number);
|
||||
if (parts.length !== 4 || !parts.every(Number.isFinite))
|
||||
return refuse('BAD_BOX', 'bbox needs west,south,east,north.');
|
||||
const [west, south, east, north] = parts;
|
||||
if (
|
||||
Math.abs(west) > 180 ||
|
||||
Math.abs(east) > 180 ||
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
south >= north ||
|
||||
west === east
|
||||
)
|
||||
return refuse('BAD_BOX', 'bbox edges are out of range.');
|
||||
// A box crossing the antimeridian is searched as its two halves.
|
||||
const boxes =
|
||||
west < east
|
||||
? [[south, west, north, east]]
|
||||
: [
|
||||
[south, west, north, 180],
|
||||
[south, -180, north, east],
|
||||
];
|
||||
const deg2 = boxes.reduce((sum, [s, w, n, e]) => sum + (n - s) * (e - w), 0);
|
||||
const cap =
|
||||
mode === 'count'
|
||||
? OSM_FEATURE_LIMITS.maxCountDeg2
|
||||
: OSM_FEATURE_LIMITS.maxFetchDeg2;
|
||||
if (deg2 > cap)
|
||||
return refuse(
|
||||
'AREA_TOO_LARGE',
|
||||
mode === 'count'
|
||||
? 'That area is too large to search; narrow it down.'
|
||||
: 'That area is too large to list; count first or narrow it down.',
|
||||
);
|
||||
const requested = Number(request.limit);
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
OSM_FEATURE_LIMITS.maxLimit,
|
||||
Number.isFinite(requested) ? Math.trunc(requested) : 500,
|
||||
),
|
||||
);
|
||||
const selectors = [];
|
||||
for (const [key, value] of preset.tags)
|
||||
for (const [s, w, n, e] of boxes)
|
||||
selectors.push(
|
||||
`nwr[${quote(key)}=${quote(value)}](${s},${w},${n},${e});`,
|
||||
);
|
||||
const ql =
|
||||
`[out:json][timeout:${OSM_FEATURE_LIMITS.timeoutS}][maxsize:${OSM_FEATURE_LIMITS.maxsize}];` +
|
||||
`(${selectors.join('')});` +
|
||||
(mode === 'count' ? 'out count;' : `out center tags ${limit};`);
|
||||
return { ok: true, ql, mode, preset, limit, boxes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overpass JSON → the compact answer the browser gets.
|
||||
* @param {object} payload Parsed Overpass JSON.
|
||||
* @param {{mode: string, preset: object, limit: number}} compiled
|
||||
*/
|
||||
export function summarizeOsmFeatures(payload, compiled) {
|
||||
if (!Array.isArray(payload?.elements))
|
||||
throw new TypeError('Malformed Overpass feature response');
|
||||
const elements = payload.elements;
|
||||
if (compiled.mode === 'count') {
|
||||
const tags = elements.find((el) => el?.type === 'count')?.tags;
|
||||
const rawTotal = tags?.total;
|
||||
const total = Number(rawTotal);
|
||||
if (
|
||||
!tags ||
|
||||
!Object.hasOwn(tags, 'total') ||
|
||||
!['string', 'number'].includes(typeof rawTotal) ||
|
||||
String(rawTotal).trim() === '' ||
|
||||
!Number.isSafeInteger(total) ||
|
||||
total < 0
|
||||
)
|
||||
throw new TypeError('Malformed Overpass count response');
|
||||
return {
|
||||
mode: 'count',
|
||||
preset: compiled.preset.id,
|
||||
count: total,
|
||||
nodes: Number(tags.nodes) || 0,
|
||||
ways: Number(tags.ways) || 0,
|
||||
relations: Number(tags.relations) || 0,
|
||||
};
|
||||
}
|
||||
const features = [];
|
||||
let unplaced = 0;
|
||||
for (const el of elements) {
|
||||
if (
|
||||
!['node', 'way', 'relation'].includes(el?.type) ||
|
||||
(!Number.isFinite(Number(el?.id)) && typeof el?.id !== 'string')
|
||||
) {
|
||||
unplaced += 1;
|
||||
continue;
|
||||
}
|
||||
const lat = Number.isFinite(el?.lat) ? el.lat : el?.center?.lat;
|
||||
const lon = Number.isFinite(el?.lon) ? el.lon : el?.center?.lon;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
unplaced += 1;
|
||||
continue;
|
||||
}
|
||||
const tags = {};
|
||||
for (const key of [...KEPT_TAGS, ...compiled.preset.tags.map(([k]) => k)])
|
||||
if (typeof el.tags?.[key] === 'string')
|
||||
tags[key] = el.tags[key].slice(0, 120);
|
||||
features.push({ id: `${el.type}/${el.id}`, lat, lon, tags });
|
||||
}
|
||||
// A genuinely empty `elements` array is authoritative. A non-empty answer
|
||||
// whose every row is unusable is a schema failure, not "zero places"; do
|
||||
// not let the route cache that as current state for a day.
|
||||
if (elements.length && features.length === 0)
|
||||
throw new TypeError('Overpass feature response has no usable rows');
|
||||
return {
|
||||
mode: 'features',
|
||||
preset: compiled.preset.id,
|
||||
count: features.length,
|
||||
features,
|
||||
truncated: elements.length >= compiled.limit,
|
||||
...(unplaced ? { unplaced } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import path from 'node:path';
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { createBoundedCache } from '../common/boundedCache.js';
|
||||
import {
|
||||
compileOsmFeatureQuery,
|
||||
OSM_FEATURE_LIMITS,
|
||||
summarizeOsmFeatures,
|
||||
} from './osmFeatures.js';
|
||||
import { resolveOverpassUpstreams } from './constants.js';
|
||||
import { fetchOverpassPayload, overpassPayloadIsData } from './transport.js';
|
||||
|
||||
/** Places of interest change slowly: answers are kept a day. */
|
||||
const OSM_FEATURES_TTL_MS = 86_400_000;
|
||||
/** Cache-key version; nothing cached under another version is reused. */
|
||||
const CACHE_VERSION = 'v2';
|
||||
|
||||
/** Never simplify: these answers are points and counts, not drawings. */
|
||||
const keepBody = (body) => body;
|
||||
|
||||
const jsonError = (status, error, extra = {}) => ({
|
||||
status,
|
||||
body: JSON.stringify({ error, ...extra }),
|
||||
});
|
||||
|
||||
/**
|
||||
* One upstream Overpass query at a time, with identical requests sharing it
|
||||
* and answers in a bounded memory + disk cache.
|
||||
*/
|
||||
function createOverpassAnswerer({
|
||||
fetchPayload,
|
||||
cache,
|
||||
maxResponseBytes,
|
||||
upstreamTimeoutMs,
|
||||
}) {
|
||||
const inFlight = new Map();
|
||||
let running = 0;
|
||||
return async function answer(key, ql, summarize, signal = null) {
|
||||
signal?.throwIfAborted?.();
|
||||
const held = await cache.get(key);
|
||||
signal?.throwIfAborted?.();
|
||||
if (typeof held === 'string')
|
||||
return { status: 200, body: held, cache: 'HIT' };
|
||||
let operation = inFlight.get(key);
|
||||
if (operation?.controller.signal.aborted) {
|
||||
inFlight.delete(key);
|
||||
operation = null;
|
||||
}
|
||||
if (!operation) {
|
||||
if (running >= 1)
|
||||
return {
|
||||
...jsonError(
|
||||
503,
|
||||
'Another map search is running — try again shortly.',
|
||||
),
|
||||
retryAfter: '3',
|
||||
};
|
||||
running += 1;
|
||||
const controller = new AbortController();
|
||||
operation = { controller, waiters: 0, settled: false, promise: null };
|
||||
const owned = operation;
|
||||
owned.promise = (async () => {
|
||||
try {
|
||||
const payload = await fetchPayload(
|
||||
`data=${encodeURIComponent(ql)}`,
|
||||
maxResponseBytes,
|
||||
{
|
||||
simplify: keepBody,
|
||||
timeoutMs: upstreamTimeoutMs,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!overpassPayloadIsData(payload))
|
||||
return {
|
||||
...jsonError(
|
||||
payload?.rateLimited ? 429 : 503,
|
||||
'OpenStreetMap search is unavailable right now.',
|
||||
),
|
||||
retryAfter: '10',
|
||||
};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(payload.body);
|
||||
} catch {
|
||||
return jsonError(
|
||||
502,
|
||||
'OpenStreetMap returned an unreadable answer.',
|
||||
);
|
||||
}
|
||||
const body = JSON.stringify(summarize(parsed));
|
||||
controller.signal.throwIfAborted();
|
||||
await cache.set(key, body);
|
||||
controller.signal.throwIfAborted();
|
||||
return { status: 200, body, cache: 'MISS' };
|
||||
} catch {
|
||||
return jsonError(502, 'OpenStreetMap search failed.');
|
||||
} finally {
|
||||
owned.settled = true;
|
||||
running -= 1;
|
||||
if (inFlight.get(key) === owned) inFlight.delete(key);
|
||||
}
|
||||
})();
|
||||
inFlight.set(key, owned);
|
||||
}
|
||||
|
||||
operation.waiters += 1;
|
||||
let abort = null;
|
||||
const abandoned = signal
|
||||
? new Promise((_, reject) => {
|
||||
abort = () => {
|
||||
operation.waiters -= 1;
|
||||
if (operation.waiters === 0 && !operation.settled)
|
||||
operation.controller.abort();
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
})
|
||||
: null;
|
||||
try {
|
||||
return await (abandoned
|
||||
? Promise.race([operation.promise, abandoned])
|
||||
: operation.promise);
|
||||
} finally {
|
||||
if (signal && abort) signal.removeEventListener('abort', abort);
|
||||
if (!signal?.aborted) operation.waiters -= 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sender(res) {
|
||||
return ({ status, body, cache, retryAfter }) => {
|
||||
if (res.writableEnded) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(cache ? { 'X-OSM-Cache': cache } : {}),
|
||||
...(retryAfter ? { 'Retry-After': retryAfter } : {}),
|
||||
});
|
||||
res.end(body);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `GET /api/osm/features?preset=&bbox=w,s,e,n[&mode=count][&limit=]`:
|
||||
* one curated preset inside a box, answered by the operator's Overpass
|
||||
* (`OVERPASS_UPSTREAMS`). With none configured the route answers
|
||||
* `OVERPASS_NOT_CONFIGURED` and fetches nothing; public Nominatim is never a
|
||||
* substitute for bulk place search.
|
||||
*
|
||||
* @param {{use: Function}} middlewares
|
||||
* @param {{fetchPayload?: Function, featuresCache?: object, cacheRoot?: string|null, configured?: () => boolean}} [deps]
|
||||
*/
|
||||
export function installOsmFeaturesRoute(
|
||||
middlewares,
|
||||
{
|
||||
fetchPayload = fetchOverpassPayload,
|
||||
configured = () => resolveOverpassUpstreams().length > 0,
|
||||
cacheRoot = path.join(process.cwd(), '.gev-cache'),
|
||||
featuresCache = createBoundedCache({
|
||||
dir: cacheRoot ? path.join(cacheRoot, 'osm-features') : null,
|
||||
ttlMs: OSM_FEATURES_TTL_MS,
|
||||
maxMemoryEntries: 60,
|
||||
maxDiskEntries: 400,
|
||||
maxDiskBytes: 256 * 1024 * 1024,
|
||||
}),
|
||||
} = {},
|
||||
) {
|
||||
const limiter = makeRateLimiter({ windowMs: 60_000, max: 12, globalMax: 30 });
|
||||
const features = createOverpassAnswerer({
|
||||
fetchPayload,
|
||||
cache: featuresCache,
|
||||
maxResponseBytes: OSM_FEATURE_LIMITS.maxResponseBytes,
|
||||
upstreamTimeoutMs: OSM_FEATURE_LIMITS.upstreamTimeoutMs,
|
||||
});
|
||||
|
||||
middlewares.use('/api/osm/features', async (req, res) => {
|
||||
const send = sender(res);
|
||||
if (req.method !== 'GET') return send(jsonError(405, 'Method Not Allowed'));
|
||||
if (!configured())
|
||||
return send(
|
||||
jsonError(503, 'Place search needs a configured Overpass server.', {
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const compiled = compileOsmFeatureQuery({
|
||||
preset: url.searchParams.get('preset'),
|
||||
bbox: url.searchParams.get('bbox'),
|
||||
mode: url.searchParams.get('mode'),
|
||||
limit: url.searchParams.get('limit'),
|
||||
});
|
||||
if (!compiled.ok)
|
||||
return send(jsonError(400, compiled.error, { code: compiled.code }));
|
||||
if (!limiter(clientKey(req)))
|
||||
return send({
|
||||
...jsonError(429, 'Rate limit exceeded'),
|
||||
retryAfter: '10',
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
req.once?.('aborted', abort);
|
||||
res.once?.('close', abort);
|
||||
try {
|
||||
const answer = await features(
|
||||
`${CACHE_VERSION}|features|${compiled.ql}`,
|
||||
compiled.ql,
|
||||
(parsed) => summarizeOsmFeatures(parsed, compiled),
|
||||
controller.signal,
|
||||
);
|
||||
if (!controller.signal.aborted) send(answer);
|
||||
} catch {
|
||||
if (!controller.signal.aborted)
|
||||
send(jsonError(502, 'OpenStreetMap search failed.'));
|
||||
} finally {
|
||||
req.removeListener?.('aborted', abort);
|
||||
res.removeListener?.('close', abort);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -115,6 +115,8 @@ async function fetchOverpassPayload(
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
now = Date.now,
|
||||
timeoutMs = OVERPASS_TIMEOUT_MS,
|
||||
signal = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!endpoints.length) return overpassNotConfigured();
|
||||
@@ -126,7 +128,10 @@ async function fetchOverpassPayload(
|
||||
continue;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS);
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const requestSignal = signal
|
||||
? AbortSignal.any([signal, controller.signal])
|
||||
: controller.signal;
|
||||
try {
|
||||
const requestUrl = new URL(endpoint);
|
||||
const authorization =
|
||||
@@ -147,7 +152,7 @@ async function fetchOverpassPayload(
|
||||
...(authorization ? { Authorization: authorization } : {}),
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
signal: requestSignal,
|
||||
});
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const rateLimited =
|
||||
@@ -189,7 +194,12 @@ async function fetchOverpassPayload(
|
||||
endpoint: 'configured',
|
||||
rateLimited: false,
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// A route whose last requester disconnected owns this cancellation. It
|
||||
// is not evidence that the configured upstream is unhealthy, so do not
|
||||
// put the endpoint on cooldown or translate the abort into a response
|
||||
// that a now-gone client could cache.
|
||||
if (signal?.aborted) throw signal.reason ?? error;
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const delay = retryDelay(null, failures - 1, now());
|
||||
cooldowns.set(endpoint, { until: now() + delay, failures, status: 502 });
|
||||
|
||||
Reference in new issue
Block a user