mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
The Natural Earth and neighborhood packs were loaded with a JSON-attributed
import(). The Vite dev server answers that request with a JavaScript module,
which the browser rejects, so every analyst region scope failed and fell
through to a geocode lookup that could take 12-28 s.
- src/data/bundledJson.js fetches the pack from a new URL(..., import.meta.url)
asset in the browser; under Node the caller's attributed import runs.
- resolveRegionRingForQuery caps the geocode + admin-boundary rung at 3 s and
returns { error: 'region-timeout' }; the lookup keeps running and fills the
cache. The analyst query reports it as code 'region-timeout'. The Nepal
locator opts out of the budget.
- Unit tests for the loader, the timeout and the analyst error; the behavior
harness checks the Gulf of Mexico scope and retries Texas on region-timeout.
Co-authored-by: Bilawal Sidhu <[email protected]>
372 lines
13 KiB
JavaScript
372 lines
13 KiB
JavaScript
/**
|
|
* Analyst query engine — answers spoken questions over data ALREADY sitting
|
|
* client-side in the layers ("how many flights over Texas?", "biggest fire
|
|
* near LA?", "which ships are headed to Oakland?").
|
|
*
|
|
* Design (owner-ratified, docs/voice-engine-evaluation-2026-07-23.md §5.3):
|
|
* - ENGINE (this module) is pure query logic over plain record arrays; it
|
|
* renders nothing. SURFACES (voice narration, panels, detection brackets)
|
|
* consume the returned result set — the engine/surface seam is the
|
|
* `items` array with stable {layerKey, id} identities.
|
|
* - v1 scope is CLIENT-SIDE DATA ONLY. The one enrichment path (flight
|
|
* routes) reads the already-cached adsbdb results surfaced by the layer
|
|
* accessor; the engine never fetches. Fleet-wide route search is
|
|
* explicitly out of scope.
|
|
* - Follow-up memory: the previous result set can be re-queried ("which of
|
|
* those is closest?") via `followUp: true`. Held per engine instance,
|
|
* cleared by `reset()` (layer toggles should reset via the caller).
|
|
*
|
|
* Providers (injected — keeps the engine pure and node-testable):
|
|
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
|
* resolveRegionRing(name) → Promise<{ring, name}|{error:'region-timeout'}|null>
|
|
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
|
*
|
|
* @module data/analystEngine
|
|
*/
|
|
|
|
import { feedProvenanceEnvelope } from './layerSnapshot.js';
|
|
import { pointInRing } from './naturalEarthRegions.js';
|
|
|
|
/** Layers the engine understands, with the fields queries may reference. */
|
|
export const ANALYST_LAYERS = {
|
|
flights: {
|
|
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
|
text: [
|
|
'callsign',
|
|
'icao24',
|
|
'originCountry',
|
|
'operator',
|
|
'routeOrigin',
|
|
'routeDestination',
|
|
'aircraftClass',
|
|
],
|
|
flags: ['military', 'onGround'],
|
|
},
|
|
military: {
|
|
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
|
|
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
|
|
flags: ['military', 'onGround'],
|
|
},
|
|
'ais-live-vessels': {
|
|
numeric: ['speedKts', 'courseDeg'],
|
|
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
|
|
flags: [],
|
|
},
|
|
'local-firms': {
|
|
numeric: ['frp'],
|
|
text: ['confidence', 'satellite'],
|
|
flags: [],
|
|
},
|
|
earthquakes: {
|
|
numeric: ['magnitude', 'depthKm'],
|
|
text: ['place'],
|
|
flags: [],
|
|
},
|
|
satellites: {
|
|
numeric: ['altitudeM', 'speedMps'],
|
|
text: ['name', 'noradId', 'satelliteClass', 'group'],
|
|
flags: [],
|
|
},
|
|
'local-datacenters': {
|
|
numeric: [],
|
|
text: ['name', 'operator', 'capacity'],
|
|
flags: [],
|
|
},
|
|
'local-dams': {
|
|
numeric: [],
|
|
text: ['name', 'operator', 'river', 'output'],
|
|
flags: [],
|
|
},
|
|
'fire-perimeters': {
|
|
numeric: ['acres', 'containedPct', 'personnel', 'costToDate'],
|
|
text: ['name', 'state', 'county', 'cause', 'behavior', 'complexity'],
|
|
flags: [],
|
|
},
|
|
};
|
|
|
|
const EARTH_R_KM = 6371;
|
|
|
|
/** Great-circle distance in km. */
|
|
export function haversineKm(lat1, lon1, lat2, lon2) {
|
|
const d2r = Math.PI / 180;
|
|
const dLat = (lat2 - lat1) * d2r;
|
|
const dLon = (lon2 - lon1) * d2r;
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
|
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
}
|
|
|
|
/** One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. */
|
|
export function applyFilter(records, filter) {
|
|
const { field, op, value } = filter || {};
|
|
if (!field || !op) return records;
|
|
return records.filter((r) => {
|
|
const got = r[field];
|
|
if (got === null || got === undefined) return false;
|
|
switch (op) {
|
|
case 'gt':
|
|
return Number(got) > Number(value);
|
|
case 'gte':
|
|
return Number(got) >= Number(value);
|
|
case 'lt':
|
|
return Number(got) < Number(value);
|
|
case 'lte':
|
|
return Number(got) <= Number(value);
|
|
case 'eq': {
|
|
if (typeof got === 'boolean' || typeof value === 'boolean')
|
|
return Boolean(got) === Boolean(value);
|
|
return String(got).toLowerCase() === String(value).toLowerCase();
|
|
}
|
|
case 'neq':
|
|
return String(got).toLowerCase() !== String(value).toLowerCase();
|
|
case 'contains':
|
|
return String(got).toLowerCase().includes(String(value).toLowerCase());
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
|
export function applyScope(records, scope, resolved) {
|
|
if (!scope || scope.kind === 'anywhere') return records;
|
|
if (scope.kind === 'region' && resolved?.ring) {
|
|
return records.filter(
|
|
(r) =>
|
|
Number.isFinite(r.lat) &&
|
|
Number.isFinite(r.lon) &&
|
|
pointInRing(resolved.ring, r.lat, r.lon),
|
|
);
|
|
}
|
|
if (scope.kind === 'radius' || scope.kind === 'view') {
|
|
const c = resolved?.center;
|
|
const km = resolved?.km;
|
|
if (!c || !Number.isFinite(km)) return records;
|
|
return records.filter(
|
|
(r) =>
|
|
Number.isFinite(r.lat) &&
|
|
Number.isFinite(r.lon) &&
|
|
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
|
|
);
|
|
}
|
|
return records;
|
|
}
|
|
|
|
/** Numeric summary for the narration layer. */
|
|
function summarize(items, sortField) {
|
|
const summary = { count: items.length };
|
|
if (sortField && items.length) {
|
|
const vals = items.map((r) => Number(r[sortField])).filter(Number.isFinite);
|
|
if (vals.length) {
|
|
summary[`${sortField}Min`] = Math.min(...vals);
|
|
summary[`${sortField}Max`] = Math.max(...vals);
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* Create an engine bound to live providers. All spatial/text/number logic is
|
|
* in the pure helpers above; this closure only sequences and remembers.
|
|
*/
|
|
export function createAnalystEngine(providers) {
|
|
let lastResult = null;
|
|
|
|
async function query(spec = {}) {
|
|
const layers =
|
|
spec.followUp && lastResult
|
|
? null // follow-up: re-filter the remembered set, no re-snapshot
|
|
: Array.isArray(spec.layers) && spec.layers.length
|
|
? spec.layers
|
|
: ['flights'];
|
|
|
|
// 1) Source records
|
|
let records;
|
|
let layersQueried;
|
|
let queriedSnapshots;
|
|
if (layers === null) {
|
|
records = lastResult.items.slice();
|
|
layersQueried = lastResult.coverage.layersQueried;
|
|
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
|
|
} else {
|
|
records = [];
|
|
layersQueried = [];
|
|
queriedSnapshots = [];
|
|
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
|
|
if (unknown.length) {
|
|
return {
|
|
ok: false,
|
|
error: `I can't query ${unknown.join(', ')} yet — supported layers: ${Object.keys(ANALYST_LAYERS).join(', ')}.`,
|
|
coverage: { layersQueried: [], scope: 'unsupported-layer' },
|
|
};
|
|
}
|
|
for (const key of layers) {
|
|
if (!ANALYST_LAYERS[key]) continue;
|
|
const rows = providers.getRecords(key) || [];
|
|
const snapshot = providers.getLayerSnapshot?.(key);
|
|
if (snapshot) queriedSnapshots.push(snapshot);
|
|
layersQueried.push({
|
|
layerKey: key,
|
|
records: rows.length,
|
|
...(snapshot
|
|
? {
|
|
feedState: snapshot.feedState,
|
|
source: snapshot.source,
|
|
lastUpdate: snapshot.lastUpdate,
|
|
enabled: snapshot.enabled,
|
|
error: snapshot.error,
|
|
}
|
|
: {}),
|
|
...providers.getRecordCoverage?.(key, rows),
|
|
});
|
|
for (const row of rows) records.push({ layerKey: key, ...row });
|
|
}
|
|
}
|
|
|
|
// 2) Spatial scope
|
|
let resolvedScope = null;
|
|
let scopeNote = 'anywhere';
|
|
// Human phrasing for the same scope, so every spoken count can name what it
|
|
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
|
// arriving as a bare number that contradicts the panel.
|
|
let scopeLabel = 'anywhere in the loaded data';
|
|
const scope = spec.scope || { kind: 'view' };
|
|
if (scope.kind === 'region' && scope.name) {
|
|
const region = await providers.resolveRegionRing(scope.name);
|
|
if (region?.error === 'region-timeout') {
|
|
return {
|
|
ok: false,
|
|
code: 'region-timeout',
|
|
error: `Looking up the boundary for "${scope.name}" is taking too long — ask again in a moment.`,
|
|
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
|
|
};
|
|
}
|
|
if (!region?.ring) {
|
|
return {
|
|
ok: false,
|
|
error: `I couldn't resolve a boundary for "${scope.name}" — try a state, country, or a named natural region.`,
|
|
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
|
|
};
|
|
}
|
|
resolvedScope = region;
|
|
scopeNote = `region:${region.name}`;
|
|
scopeLabel = `over ${region.name}`;
|
|
} else if (scope.kind === 'radius') {
|
|
// An explicit center always wins. Otherwise, when Contacts is active its
|
|
// SUBJECT is the centre the operator is actually reasoning about: the
|
|
// panel counts a contact-centred window, so centring the radius on the
|
|
// camera made the two disagree — a parked, high-altitude camera answered
|
|
// "46 within 250 km" while the panel showed a far larger contact-centred
|
|
// count. Same question, two numbers.
|
|
const explicitCenter =
|
|
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
|
|
const subject = explicitCenter
|
|
? null
|
|
: providers.getContextSubject?.() || null;
|
|
const view = providers.getViewContext();
|
|
// Only a subject that actually SUPPLIED the centre may name it. A
|
|
// subject present but without usable coordinates silently fell back to
|
|
// the camera while the label still read "within 250 km of <contact>" —
|
|
// a count centred on one place, reported as centred on another, with
|
|
// nothing in the payload to show which.
|
|
const subjectCenter =
|
|
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
|
? { lat: subject.lat, lon: subject.lon }
|
|
: null;
|
|
const center = explicitCenter ||
|
|
subjectCenter || { lat: view.lat, lon: view.lon };
|
|
resolvedScope = { center, km: Number(scope.km) || 100 };
|
|
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
|
|
scopeNote = resolvedScope.centeredOn
|
|
? `radius:${resolvedScope.km}km@${resolvedScope.centeredOn}`
|
|
: `radius:${resolvedScope.km}km`;
|
|
scopeLabel = resolvedScope.centeredOn
|
|
? `within ${resolvedScope.km} km of ${resolvedScope.centeredOn}`
|
|
: `within ${resolvedScope.km} km`;
|
|
} else if (scope.kind === 'view') {
|
|
const view = providers.getViewContext();
|
|
resolvedScope = {
|
|
center: { lat: view.lat, lon: view.lon },
|
|
km: view.viewRadiusKm,
|
|
};
|
|
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
|
|
scopeLabel = 'in view';
|
|
} else {
|
|
scopeNote = 'anywhere';
|
|
scopeLabel = 'anywhere in the loaded data';
|
|
}
|
|
let items = applyScope(records, scope, resolvedScope);
|
|
|
|
// 3) Attribute filters
|
|
for (const f of spec.filters || []) items = applyFilter(items, f);
|
|
|
|
// 4) Aggregate / sort / limit — nearest needs a reference point
|
|
const sortBy = spec.sortBy || null;
|
|
if (sortBy === 'distance') {
|
|
const ref = resolvedScope?.center || providers.getViewContext();
|
|
for (const it of items) {
|
|
it.distanceKm =
|
|
Number.isFinite(it.lat) && Number.isFinite(it.lon)
|
|
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
|
|
10
|
|
: null;
|
|
}
|
|
}
|
|
if (sortBy) {
|
|
const dir = spec.sortDir === 'asc' ? 1 : -1;
|
|
items.sort(
|
|
(a, b) =>
|
|
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
|
|
String(a.id).localeCompare(String(b.id)),
|
|
);
|
|
if (sortBy === 'distance')
|
|
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
|
}
|
|
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
|
|
const top = items.slice(0, limit);
|
|
|
|
const result = {
|
|
ok: true,
|
|
count: items.length,
|
|
items: top,
|
|
truncated: items.length > top.length,
|
|
summary: summarize(
|
|
items,
|
|
sortBy && sortBy !== 'distance' ? sortBy : null,
|
|
),
|
|
scopeLabel,
|
|
coverage: {
|
|
layersQueried,
|
|
scope: scopeNote,
|
|
...(queriedSnapshots.length
|
|
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
|
|
: {}),
|
|
followUp: Boolean(spec.followUp && lastResult),
|
|
note: layersQueried.some(
|
|
(layer) => layer.basis === 'bounded-loaded-records',
|
|
)
|
|
? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.'
|
|
: 'client-side data only — answers cover what the enabled layers currently hold',
|
|
},
|
|
// Surfaced so the narration can name the centre it measured from rather
|
|
// than implying a view-centred answer.
|
|
...(resolvedScope?.centeredOn
|
|
? { centeredOn: resolvedScope.centeredOn }
|
|
: {}),
|
|
};
|
|
lastResult = { items, coverage: result.coverage };
|
|
return result;
|
|
}
|
|
|
|
return {
|
|
query,
|
|
reset() {
|
|
lastResult = null;
|
|
},
|
|
hasMemory() {
|
|
return Boolean(lastResult);
|
|
},
|
|
};
|
|
}
|