fix the remaining voice review gaps

keep analyst counts, speech, cards, cancellation, pointer capture, tool schema boundaries, and session lifetime consistent
add the exact Alps fallback and focused regression coverage
This commit is contained in:
manjunath22466 committed 2026-10-05 04:37:28 +05:30
1 parent 6858976b01
commit 0343b4495e
21 files changed
+1745 -441

No files matched your search

+13 -4
View File
@@ -1,5 +1,15 @@
# Changelog
- Unqualified Alps searches use the bundled European range for navigation
and annotations instead of a same-named peak returned by a geocoder.
Coordinates, supplied presets and geographically qualified place names
keep their precedence; annotation proximity and containment guards remain.
- Contacts analyst follow-ups retain the snapshot's distance ranking rather
than switching to the camera. Requested radii and explicit scopes remain
authoritative, and a request for a list still requires an analyst query.
Analyst speech names stale or degraded feeds and withholds authoritative
counts when unavailable. The existing NOTES disclosure preserves complete,
deduplicated coverage and source caveats, including crowded results.
- The voice card shows analyst counts ("At least 250,000 aircraft …") with
their scope, feed window and caveats, and marks partial answers. Analyst
speech uses the same deterministic headline, preserves lower bounds and says
@@ -31,7 +41,8 @@
referent-only tracking calls are accepted while empty targets are rejected.
Asking about
bare ground up close gives the assistant a small crop of the map around the
point.
point. A slow render gets one bounded second frame request, while hidden,
stale, moved-camera, invalid and oversized captures still fail closed.
## [Unreleased]
@@ -284,7 +295,6 @@
geocode and admin-boundary fallback answers `region-timeout` after 3 s
instead of holding the reply.
- Transit and Directions rows repaint as soon as their data lands again:
`refreshLayerStats()` now lives on the layer lifecycle, not only on the
compatibility facade. `scripts/qa-radio.mjs` uses it instead of a private
@@ -486,6 +496,7 @@
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
wildfire incident perimeters as ground-clamped polygons with a
containment-colored fire line, refreshed every 5 minutes from the public
@@ -762,7 +773,6 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Other changes
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
@@ -1571,4 +1581,3 @@ represent previously published GitHub Releases.
## [0.1.0] — 2026-02-09
- Initial project version.
+47 -27
View File
@@ -353,6 +353,15 @@ replacement session; audio meters release failed initialization and reject revok
frames. Delayed action results and post-capture continuations cannot resume a
stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md).
Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even
after the camera moves. An explicit new scope replaces that basis. Provider
instructions require an analyst query for requested lists and use the requested
radius rather than substituting the panel's fixed window. Analyst speech carries
stale/degraded feed status and does not speak an authoritative unavailable count.
The existing NOTES disclosure deduplicates coverage/source caveats and retains
their full text in at most six rows, grouping analyst overflow in the final row;
ordinary tool-card limits are unchanged.
Voice speech: `src/voice/speech.js` adds `{ say, display, referents?, schedule? }`
to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
@@ -392,7 +401,10 @@ accepts either a named query or a referent, while an empty target is rejected. A
point during the turn and the voice card shows "THIS"/"HERE"; both hide in
Clean-UI and recording. A ground-only pointer lookup at local/city scale sends a ringed 512 px crop in
the one retained image slot, dropped if the camera moved or a newer capture or
turn started; screenX/screenY against the crop are mapped back to the canvas. Harness: `qa-voice-routing.mjs --layer deixis`,
turn started. The pointer path makes at most two bounded fresh-frame requests so
a slow render loop can recover without accepting a stale frame; a missing camera
identity, expired snapshot, invalid geometry, black frame or oversized payload
still fails closed. screenX/screenY against the crop are mapped back to the canvas. Harness: `qa-voice-routing.mjs --layer deixis`,
`--layer deixis-live`, `--layer behavior --deixis-only`.
The application shell composes focused state owners for navigation, destination
@@ -1529,10 +1541,10 @@ This is the current runtime/source-of-truth snapshot for the project.
> samples, `enabling` throughout, panel non-numeric) and across a failing
> one. Its `status: 'idle'` is not what saves it — the lifecycle is; the
> module has no `loading` status at all.
> Any new dependency that can be slow must report `loading` and `lastUpdate`
> honestly for the contract to hold, and must be pinned against the shape its
> own module really returns — a fixture that invents a status the module cannot
> emit guards nothing (that is exactly how a hole here survived a green suite).
> Any new dependency that can be slow must report `loading` and `lastUpdate`
> honestly for the contract to hold, and must be pinned against the shape its
> own module really returns — a fixture that invents a status the module cannot
> emit guards nothing (that is exactly how a hole here survived a green suite).
> - **A held ground snap is dropped when measured ground contradicts it
> (2026-08-23):** the WARM hold described below answers on DISTANCE travelled,
> which is only a proxy for whether the value still describes the ground. A
@@ -2964,27 +2976,27 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
@@ -4047,6 +4059,15 @@ are omitted rather than framing the wrong part of the globe.
retrying. Missing capability never synthesizes an outline from a radius.
Manual geometry, bundled neighborhoods, Natural Earth regions and bundled
state/province/county outlines remain.
- The shared default place search resolves unqualified `Alps`, `the Alps`
and the `Alps mountains` forms from the bundled European Natural Earth
range before network geocoders. Navigation uses its contained anchor and
region viewport; annotations still require the existing proximity and
outline-containment checks. Coordinates and supplied presets keep priority;
geographic qualifiers, local POI names, other ranges and custom geocoder
arrays retain their existing resolution. A failed Alps pack load reports a
search failure rather than caching an unrelated peak as the range, and the
bundled loader retains its normal retry cooldown.
- County queries rank eligible US counties and international admin-1 units together, retaining exact aliases and country/state qualifiers.
- Bundled administrative outlines (`src/data/adminBoundaries.js`): states and
provinces worldwide (Natural Earth admin-1,
@@ -4909,7 +4930,6 @@ Temperature uses stronger fixed −40..50°C colors; the underlying 1° forecast
numeric inspection values are unchanged. No volumetric cloud height or local rain
arrival prediction is claimed. NOAA source limits are documented in DATA_SOURCES.
Traffic source refinements: ALPR admits z9-z12 detail only when the view fits 16 tiles,
returning explicit zoom guidance before I/O and allowing the next smaller view to
load normally. Outside the US/Canada extracts the row reads “No ALPR data for this
+9
View File
@@ -322,12 +322,18 @@
"src/search/geospatial.js",
"src/search/http.js",
"src/search/defaults.js",
"src/search/alpsGeocoder.js",
"src/search/coordinateGeocoder.js",
"src/search/coordinateParser.js",
"src/search/presetGeocoder.js",
"src/search/nominatim.js",
"src/nominatimGeocode.js",
"src/data/regionalModel.js",
"src/data/naturalEarthRegions.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
"src/data/local_data/natural_earth/regions.json",
"src/data/local_data/natural_earth/marine.json",
"src/sources/httpBody.js",
"src/sources/nominatim.js"
],
@@ -1784,6 +1790,7 @@
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
"src/scopeMask.js",
"src/search/alpsGeocoder.js",
"src/search/defaults.js",
"src/search/geospatial.js",
"src/search/google.js",
@@ -2444,6 +2451,7 @@
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayTokens.js",
"src/renderGovernor.js",
"src/search/alpsGeocoder.js",
"src/search/defaults.js",
"src/search/geospatial.js",
"src/search/google.js",
@@ -2617,6 +2625,7 @@
"src/layers/firms/anchors.js",
"src/locations.js",
"src/cameraGroundGuard.js",
"src/search/alpsGeocoder.js",
"src/search/defaults.js",
"src/search/geospatial.js",
"src/search/google.js",
+1260 -364
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -22,7 +22,7 @@ function realtimeInstructions(
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
'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.',
'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.',
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (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.',
@@ -30,7 +30,7 @@ function realtimeInstructions(
'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.',
'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, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. 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 stale, degraded or unavailable feed states in say are part of the answer — always preserve 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.
//
+9 -3
View File
@@ -543,7 +543,7 @@ export const ACTION_DESCRIPTIONS = {
},
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". A requested list or ranking requires this tool even after set_context_mode returned a Contacts count; that count alone has no requested items. 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).',
$position: 1,
parameters: {
properties: {
@@ -554,7 +554,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). Explicit "in view" uses view; "nearby" uses view when Contacts is off. Around the active Contacts subject uses radius with the requested km and center omitted, never view: the five closest within 250 km use scope:{kind:"radius",km:250}, sortBy:"distance", limit:5. For another requested radius use its actual km, not 250. pointer = "around here/this" while pointing (radius at the cursor); region = "over Texas"; radius with center {lat, lon} = "near <place>" or explicit coordinates; anywhere = no place ("biggest anywhere", "today"). Preserve explicit view, named place, pointer and explicit center even while Contacts is active; never invent coordinates. There is no drawn-area scope yet: say so rather than answering for the view.',
$position: 2,
properties: {
name: {
@@ -563,7 +563,13 @@ export const ACTION_DESCRIPTIONS = {
$position: 1,
},
km: {
description: 'For kind=radius or pointer.',
description:
'For kind=radius or pointer: the actual requested kilometers. An active-subject 250 km request uses 250; another requested radius keeps that number.',
$position: 1,
},
center: {
description:
'Omit for a radius around the active Contacts subject. Supply only an explicitly requested coordinate or a resolved named-place coordinate; never invent 0,0 or replace an explicit center with the subject.',
$position: 1,
},
},
+8 -1
View File
@@ -698,7 +698,10 @@ export async function selectAnalystRows(
const item = { layerKey: key, ...row };
const age = ageOf(key, row);
if (age !== null) item[AGE_FIELD] = age;
if (sortBy === 'distance') item.distanceKm = distance(row);
if (sortBy === 'distance') {
const km = distance(row);
item.distanceKm = Number.isFinite(km) ? Math.round(km * 10) / 10 : km;
}
return item;
});
return { count, items, matched, summary };
@@ -881,6 +884,8 @@ export function createAnalystEngine(providers) {
// 2) Spatial scope
const rememberedScope =
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
const rememberedDistanceOf =
followUp && !spec.scope ? lastResult.distanceOf || null : null;
let resolvedScope = null;
let scopeNote = rememberedScope?.coverage || 'anywhere';
// Human phrasing for the same scope, so every spoken count can name what it
@@ -985,6 +990,7 @@ export function createAnalystEngine(providers) {
sortDir: spec.sortDir,
limit: spec.limit,
layerKeys: layers,
distanceOf: rememberedDistanceOf,
ref:
spec.sortBy === 'distance'
? resolvedScope?.center || providers.getViewContext()
@@ -1070,6 +1076,7 @@ export function createAnalystEngine(providers) {
const committedCoverage = memoryOverride?.coverage || result.coverage;
lastResult = {
matched: memoryOverride?.matched || selection.matched,
distanceOf: memoryOverride?.distanceOf || rememberedDistanceOf,
layerKeys: [...(memoryOverride?.layerKeys || layers)],
coverage: committedCoverage,
unanswered: [
+4 -3
View File
@@ -671,11 +671,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// field hints), point-and-ask's pointer/referent arguments and the prompt
// consolidation (shorter analyst, annotate_map and ISS wording), plus the
// referent-only track_entity alternative (kept out of the model-facing
// schema, which may not carry top-level anyOf); the missions still ride existing tools.
assert.equal(block.length, 28831, 'serialized tool schema length drifted');
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
assert.equal(block.length, 29724, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'cce7eb17b2af9ac34c06bdf59d1d31f4e8ace6a9d695d79111f3e4d49d033f1f',
'5dd41cfbd4f52acce3071dcfbc80acd758e390ad251ca07b5db67094383c6a93',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+72
View File
@@ -0,0 +1,72 @@
import { findNaturalRegion, pointInRing } from '../data/naturalEarthRegions.js';
/**
* The unqualified Alps ask names the European range, not a same-named peak
* or local POI. Keep this correction exact and Alps-only: other ranges and
* geographic qualifiers retain the caller's configured geocoder semantics.
*/
export function createAlpsGeocoder({ lookupRegion = findNaturalRegion } = {}) {
return {
async geocode(query, { signal } = {}) {
signal?.throwIfAborted();
const name = String(query ?? '')
.trim()
.replace(/\s+/g, ' ');
if (!/^(?:the )?alps(?: mountains)?$/i.test(name))
return { place: null, answered: true };
const region = await lookupRegion('Alps');
signal?.throwIfAborted();
if (
region?.name !== 'Alps' ||
region.kind !== 'natural' ||
!Array.isArray(region.polygons) ||
region.polygons.length === 0 ||
!region.polygons.every(
(ring) =>
Array.isArray(ring) &&
ring.length >= 3 &&
ring.every(
(point) =>
Array.isArray(point) &&
point.length === 2 &&
point.every(Number.isFinite) &&
Math.abs(point[0]) <= 180 &&
Math.abs(point[1]) <= 90,
),
)
)
throw new Error('Bundled Alps geometry unavailable');
const vertices = region.polygons.flat();
const west = Math.min(...vertices.map(([lon]) => lon));
const east = Math.max(...vertices.map(([lon]) => lon));
const south = Math.min(...vertices.map(([, lat]) => lat));
const north = Math.max(...vertices.map(([, lat]) => lat));
const lat = (south + north) / 2;
const lng = (west + east) / 2;
if (
![west, east, south, north, lat, lng].every(Number.isFinite) ||
!region.polygons.some((ring) => pointInRing(ring, lat, lng))
) {
// A broken pack is not permission to cache an unrelated network peak
// as the canonical range. Existing search error/retry handling owns it.
throw new Error('Bundled Alps geometry unavailable');
}
return {
answered: true,
place: {
lat,
lng,
name: region.name,
label: region.name,
types: ['natural_feature'],
exact: true,
viewport: {
southwest: { lat: south, lng: west },
northeast: { lat: north, lng: east },
},
},
};
},
};
}
+3 -1
View File
@@ -6,9 +6,10 @@ import { createGoogleGeocoder } from './google.js';
import { createPhotonGeocoder } from '../keylessGeocoder.js';
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
import { createPresetGeocoder } from './presetGeocoder.js';
import { createAlpsGeocoder } from './alpsGeocoder.js';
/**
* Coordinates and bundled names first — both answer offline and with no key —
* Coordinates, bundled names and the unqualified Alps first — all offline —
* then Google when configured, then keyless Photon, then the local Nominatim
* route as a last resort. Transport stays local to setup.
*
@@ -37,6 +38,7 @@ export function createDefaultPlaceSearch({
providers: providers.geocode || [
createCoordinateGeocoder(),
...(presets ? [createPresetGeocoder({ presets })] : []),
createAlpsGeocoder(),
...(selected
? [selected]
: [
+8
View File
@@ -32,6 +32,14 @@
animation: gev-voice-card-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* The visible card must clear the dock trays' parent stacking contexts. */
#command-dock
> #gev-voice-control:not([data-status='error']):has(
.gev-voice-card:not([hidden])
) {
z-index: 101;
}
#gev-voice-control .gev-voice-card[hidden],
#gev-voice-control .gev-voice-card [hidden],
#gev-voice-control[data-status='error'] .gev-voice-card {
+3 -2
View File
@@ -34,8 +34,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Re-derived for the voice layer manifest (generated layer enums and
// aliases), point-and-ask (pointer sentinels, referent args) and the
// consolidated tool wording (each policy stated once); model-facing tools
// omit top-level anyOf (OpenAI rejects it).
'66c955a41e36c6e7d2d255a950993128528288be13946fe4f7dacb05ad2ba291',
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
// requested radius and center authority in descriptions only.
'71c66bb725e6987e56780db1e1a05e17359df6f2fcc4884b7ad46f326eae5f5d',
);
});
+63 -15
View File
@@ -3478,6 +3478,9 @@ async function aircraftProximityWindowForQuery(
result,
isCurrent = () => true,
) {
// Follow-ups re-filter the engine's retained answer. Re-projecting a fresh
// Contacts snapshot here would restore rows a previous filter removed.
if (result?.coverage?.followUp) return null;
const spec = normalizeAnalystSpec(args, args?.layers || []);
if (!spec) return null;
const scope = args?.scope;
@@ -3552,8 +3555,10 @@ async function aircraftProximityWindowForQuery(
lat,
lon,
};
// Rank the retained cohort at its original precision; display rounding
// must not turn nearby contacts into artificial ID-sorted ties.
if (Number.isFinite(item.distance))
distanceKm.set(row, Math.round(item.distance / 100) / 10);
distanceKm.set(row, item.distance / 1000);
return row;
});
return { key, rows };
@@ -3659,6 +3664,9 @@ async function aircraftProximityWindowForQuery(
answer,
memory: {
matched: selection.matched,
// Follow-up ranking belongs to this retained Contacts evaluation, not
// the camera's current position. Keep the accessor with its row owners.
distanceOf: (row) => distanceKm.get(row),
layerKeys: wanted,
coverage,
unanswered: [...(result?.unanswered || [])],
@@ -4791,6 +4799,46 @@ const ANALYST_REFUSAL_FIELDS = Object.freeze([
'cancelled',
]);
const VIEWPORT_COVERAGE_NOTE = 'the flights layer loads by viewport';
/** Resolve omitted follow-up scope from the engine's retained coverage. */
function effectiveAnalystScopeKind(args, coverage) {
const explicit = String(args.scope?.kind || '').toLowerCase();
if (explicit) return explicit;
const retained = String(coverage?.scope || '')
.split(':', 1)[0]
.toLowerCase();
if (retained === 'window') return 'radius';
if (['anywhere', 'radius', 'region', 'view'].includes(retained))
return retained;
return args.followUp ? 'anywhere' : 'view';
}
/** Add the viewport qualification without replacing retained provenance. */
function withViewportCoverageNote(coverage) {
if (!coverage) return coverage;
const current = typeof coverage.note === 'string' ? coverage.note : '';
if (current.includes(VIEWPORT_COVERAGE_NOTE)) return coverage;
const separator = current ? '; ' : '';
return {
...coverage,
note: current
? `${current}${separator}${VIEWPORT_COVERAGE_NOTE}`
: VIEWPORT_COVERAGE_NOTE,
};
}
/** Whether this query counts records from a viewport-loaded layer. */
function isViewportScopedAnalystResult(args, coverage) {
const scopeKind = effectiveAnalystScopeKind(args, coverage);
return (
(scopeKind === 'radius' || scopeKind === 'view') &&
(coverage?.layersQueried || []).some(
(layer) => voiceLayer(layer.layerKey)?.query?.viewportLoaded,
)
);
}
async function runAnalystQuery(
analystEngine,
dataManager,
@@ -4820,6 +4868,15 @@ async function runAnalystQuery(
candidate,
isCurrent,
);
if (
projected?.answer &&
projected?.memory &&
isViewportScopedAnalystResult(args, projected.answer.coverage)
) {
const coverage = withViewportCoverageNote(projected.answer.coverage);
projected.answer.coverage = coverage;
projected.memory.coverage = coverage;
}
entityWindow = projected?.answer || null;
return projected?.memory || null;
},
@@ -4855,14 +4912,12 @@ async function runAnalystQuery(
// A radius/view count over a viewport-loaded layer counts what is LOADED, and
// the flights layer reloads as the camera moves — so this number can sit well
// under the Contacts cohort without either being wrong.
const scopeKind = String(args.scope?.kind || 'view').toLowerCase();
const viewportScoped =
(scopeKind === 'radius' || scopeKind === 'view') &&
(result.coverage?.layersQueried || []).some(
(l) => voiceLayer(l.layerKey)?.query?.viewportLoaded,
);
const scopeKind = effectiveAnalystScopeKind(args, result.coverage);
const viewportScoped = isViewportScopedAnalystResult(args, result.coverage);
if (viewportScoped && result.coverage) {
result.coverage.note = 'the flights layer loads by viewport';
// The engine's follow-up memory retains this same object, so update its
// note in place after deriving the qualified copy.
result.coverage.note = withViewportCoverageNote(result.coverage).note;
}
// ENTITY-CENTRED NEARBY: answered by the SAME engine that fills the Contacts
// panel, so the spoken number and the panel readout for one centre cannot
@@ -4870,13 +4925,6 @@ async function runAnalystQuery(
// arbitrary points — only "how many aircraft around <this contact>" is
// unified, because that is the question the panel is already answering.
if (entityWindow) {
// The detached Contacts snapshot keeps its own coverage; carry the same
// viewport note so both answer shapes say what they measured.
if (viewportScoped && entityWindow.coverage)
entityWindow.coverage = {
...entityWindow.coverage,
note: result.coverage?.note,
};
return entityWindow;
}
+5 -1
View File
@@ -15,7 +15,7 @@ const ACTION_NAMES = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
*/
export function withToolCatalog(runner, loadCatalog) {
if (typeof loadCatalog !== 'function') return runner;
return async function runGevTool(name, args, options = {}) {
const runGevTool = async function runGevTool(name, args, options = {}) {
if (ACTION_NAMES.has(name)) return runner(name, args, options);
const catalog = await loadCatalog();
if (!catalog?.get(name)) return runner(name, args, options);
@@ -24,6 +24,10 @@ export function withToolCatalog(runner, loadCatalog) {
});
return toFunctionOutput(name, result);
};
runGevTool.resetConversation = (...args) =>
runner.resetConversation?.(...args);
runGevTool.dispose = (...args) => runner.dispose?.(...args);
return runGevTool;
}
/** Compose the standalone action runner with the voice controls. */
+14
View File
@@ -3733,6 +3733,16 @@ test('voice runs app actions itself and other tools through the catalog', async
actions.push([name, args]);
return { ok: true, action: name };
};
const resets = [];
const disposals = [];
runner.resetConversation = (...args) => {
resets.push(args);
return 'reset';
};
runner.dispose = (...args) => {
disposals.push(args);
return 'disposed';
};
const calls = [];
let loads = 0;
const catalog = {
@@ -3764,5 +3774,9 @@ test('voice runs app actions itself and other tools through the catalog', async
actions.map(([name]) => name),
['zoom_to_globe', 'not_a_tool'],
);
assert.equal(run.resetConversation('stopped'), 'reset');
assert.equal(run.dispose('removed'), 'disposed');
assert.deepEqual(resets, [['stopped']]);
assert.deepEqual(disposals, [['removed']]);
assert.equal(withToolCatalog(runner, undefined), runner);
});
+118 -1
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { RealtimeViewport, pointerCropRect } from './realtimeViewport.js';
import {
RealtimeViewport,
pointerCropRect,
renderFreshPointerFrame,
} from './realtimeViewport.js';
test('crop: a square around the pointer, clamped inside the canvas, in buffer pixels', () => {
assert.deepEqual(
@@ -48,6 +52,88 @@ test('crop: a square around the pointer, clamped inside the canvas, in buffer pi
}),
null,
);
assert.equal(
pointerCropRect({
x: Number.NaN,
y: 1,
cssWidth: 100,
cssHeight: 100,
pixelWidth: 100,
pixelHeight: 100,
}),
null,
'non-finite pointer geometry fails closed',
);
assert.equal(
pointerCropRect({
x: 101,
y: 1,
cssWidth: 100,
cssHeight: 100,
pixelWidth: 100,
pixelHeight: 100,
}),
null,
'out-of-canvas pointer geometry fails closed',
);
assert.equal(
pointerCropRect({
x: 120,
y: 100,
cssWidth: 320,
cssHeight: 240,
pixelWidth: 320,
pixelHeight: 240,
}).output,
512,
'the provider image stays 512 px even when the source crop is smaller',
);
});
test('crop: a slow render gets one bounded fresh-frame recovery request', async () => {
const originalDocument = globalThis.document;
let requests = 0;
let listener = null;
try {
globalThis.document = { hidden: false };
const scene = {
postRender: {
addEventListener(next) {
listener = next;
return () => {
if (listener === next) listener = null;
};
},
},
requestRender() {
requests += 1;
if (requests === 2) queueMicrotask(() => listener?.());
},
};
assert.equal(await renderFreshPointerFrame({ scene }), true);
assert.equal(requests, 2, 'only the pointer path retries once');
assert.equal(listener, null, 'the successful listener is removed');
} finally {
globalThis.document = originalDocument;
}
});
test('crop: a hidden document never starts the recovery request', async () => {
const originalDocument = globalThis.document;
let requests = 0;
try {
globalThis.document = { hidden: true };
const scene = {
postRender: { addEventListener: () => () => {} },
requestRender() {
requests += 1;
},
};
assert.equal(await renderFreshPointerFrame({ scene }), false);
assert.equal(requests, 0);
} finally {
globalThis.document = originalDocument;
}
});
function viewportFixture({ capturePointer, capture } = {}) {
@@ -191,6 +277,15 @@ test('crop: a camera move before or during capture drops it; pixels only name th
false,
'moved before capture',
);
state.camera = null;
assert.equal(
await viewport.sendPointerCrop(
{ ...ground, cameraKey: null },
{ viewScale: 'local' },
),
false,
'a missing pose identity fails closed',
);
state.camera = 'pose-A';
const pending = viewport.sendPointerCrop(
{ ...ground },
@@ -202,6 +297,28 @@ test('crop: a camera move before or during capture drops it; pixels only name th
assert.equal(sent.length, 0);
});
test('crop: a snapshot that expires during capture is not published', async () => {
let release;
const { viewport, sent, state } = viewportFixture({
capturePointer: () =>
new Promise((resolve) => {
release = () =>
resolve({
dataUrl: 'data:image/jpeg;base64,CROP',
rect: { x: 0, y: 0, w: 1, h: 1 },
});
}),
});
const pending = viewport.sendPointerCrop(
{ ...ground },
{ viewScale: 'local' },
);
state.clock = 40_000;
release();
assert.equal(await pending, false);
assert.equal(sent.length, 0);
});
test('crop: an older capture that finishes last never replaces the newer image', async () => {
const releases = [];
const { viewport, sent } = viewportFixture({
+8 -1
View File
@@ -143,7 +143,14 @@ export function responseInstructionForToolResult(result) {
return 'The map annotations are cleared. Continue naturally; do not announce the clear.';
}
if (typeof result?.say === 'string' && result.say) {
return "Speak the say line of this turn's tool results once, in order, lightly rephrased at most; add no qualifiers and don't repeat your preamble.";
return "Speak the say line of this turn's tool results once, in order, lightly rephrased at most. Preserve its lower bounds, partial answers and stale, degraded or unavailable feed states. Add no counts, lists, qualifiers or action claims beyond the executed tool results, and don't repeat your preamble. If a requested list or another action is still unfinished, call the required tool before the final answer.";
}
if (
result?.ok &&
result.action === 'set_context_mode' &&
result.contactsWindow
) {
return 'Contacts activation and its window count do not fulfill a requested list or ranking. Continue any unfinished request with analyst_query: around the active subject use radius with the actual requested km and omit center; preserve explicit view, place, pointer and explicit center scopes. Use the returned items, never invent aircraft, counts or qualifications. Otherwise briefly confirm the completed action from its result.';
}
return 'Briefly confirm the completed GEV action once. Do not repeat yourself.';
}
+35 -6
View File
@@ -86,13 +86,29 @@ export function pointerCropRect({
pixelHeight,
size = POINTER_CROP_SIZE,
}) {
if (!cssWidth || !cssHeight || !pixelWidth || !pixelHeight) return null;
const values = [x, y, cssWidth, cssHeight, pixelWidth, pixelHeight, size];
if (
values.some((value) => !Number.isFinite(value)) ||
cssWidth <= 0 ||
cssHeight <= 0 ||
pixelWidth <= 0 ||
pixelHeight <= 0 ||
size <= 0 ||
x < 0 ||
x > cssWidth ||
y < 0 ||
y > cssHeight
)
return null;
const side = Math.min(size, cssWidth, cssHeight);
const left = Math.min(Math.max(0, x - side / 2), cssWidth - side);
const top = Math.min(Math.max(0, y - side / 2), cssHeight - side);
const scaleX = pixelWidth / cssWidth;
const scaleY = pixelHeight / cssHeight;
const output = Math.round(Math.min(size, side * scaleX));
// Keep the provider-facing image contract stable even on a narrow canvas.
// drawImage may upscale a smaller source crop, but the retained image and
// pointer-ring coordinates are always expressed in one 512 px square.
const output = Math.round(size);
return {
sx: Math.round(left * scaleX),
sy: Math.round(top * scaleY),
@@ -116,7 +132,7 @@ export async function capturePointerCrop(screenPx) {
const viewer = window.__godsEyeView?.viewer;
const source = viewer?.scene?.canvas;
if (!source?.width || !source?.height || !screenPx) return null;
const fresh = await renderFreshCesiumFrame(viewer);
const fresh = await renderFreshPointerFrame(viewer);
if (!fresh) return null;
const rect = pointerCropRect({
x: screenPx.x,
@@ -166,6 +182,16 @@ export async function capturePointerCrop(screenPx) {
}
}
/**
* Pointer crops get one bounded recovery request for a slow render loop.
* Full-viewport captures intentionally keep their single 400 ms attempt.
*/
export async function renderFreshPointerFrame(viewer) {
if (await renderFreshCesiumFrame(viewer)) return true;
if (typeof document !== 'undefined' && document.hidden) return false;
return renderFreshCesiumFrame(viewer);
}
// Scale (w, h) down so w*h <= maxPixels while preserving aspect ratio. Never
// upscales. Both dimensions shrink together, so portrait and landscape are
// treated equally (M13). Pure + deterministic → unit-tested (exported below).
@@ -345,11 +371,14 @@ export class RealtimeViewport {
// Too far out, the crop is a smear: every path shares this guard.
if (!POINTER_CROP_SCALES.has(viewScale)) return false;
if (snapshot === this.lastPointerCrop) return false;
if (this.now() - (snapshot.capturedAt || 0) > POINTER_CROP_MAX_AGE_MS)
return false;
const capturedAt = Number(snapshot.capturedAt) || 0;
if (this.now() - capturedAt > POINTER_CROP_MAX_AGE_MS) return false;
// The snapshot's pixels only name its own camera pose.
const snapshotCameraKey = snapshot.cameraKey;
if (!snapshotCameraKey) return false;
const samePose = () =>
!snapshot.cameraKey || snapshot.cameraKey === this.readCameraKey();
snapshotCameraKey === this.readCameraKey() &&
this.now() - capturedAt <= POINTER_CROP_MAX_AGE_MS;
if (!samePose()) return false;
this.lastPointerCrop = snapshot;
const sent = await this.sendRetainedImage(
+31 -5
View File
@@ -28,10 +28,19 @@ const LAYER_NOUNS = Object.freeze({
/** Split "a; b" caveat text into separate notes. */
function notesOf(...values) {
return values
.flatMap((value) => String(value || '').split(/;\s*/))
.map((text) => text.trim())
.filter(Boolean);
return [
...new Set(
values
.flatMap((value) => String(value || '').split(/;\s*/))
.map((text) =>
text
.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim(),
)
.filter(Boolean),
),
];
}
export function analystNounFor(layerKeys) {
@@ -67,6 +76,19 @@ function analystCard(result) {
if (!result?.ok) return null;
const display = result.display || {};
const unanswered = Array.isArray(result.unanswered) ? result.unanswered : [];
const provenance = result.feedProvenance || result.coverage?.feedProvenance;
const sources = [
...new Set(
[...(provenance?.layers || []), ...(result.coverage?.layersQueried || [])]
.map((layer) =>
String(layer?.source || '')
.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim(),
)
.filter(Boolean),
),
].map((label) => ({ label }));
return {
display: {
title: analystHeadline(result),
@@ -81,12 +103,16 @@ function analystCard(result) {
: []),
...(result.partial ? [{ label: 'partial' }] : []),
],
// Coverage qualifications lead the feed detail. The bounded card groups
// overflow in its existing Notes disclosure rather than dropping them.
preserveNotes: true,
notes: [
...notesOf(display.caveat, result.coverage?.note),
...notesOf(result.coverage?.note, display.caveat),
...(unanswered.length
? [`Not answered: ${unanswered.join(', ')}`]
: []),
],
sources,
},
referents: Array.isArray(result.items) ? result.items : [],
resultSet: true,
+13 -1
View File
@@ -401,6 +401,18 @@ function annotateMap(result) {
function analystQuery(result) {
if (!result?.ok || result.cancelled) return null;
const headline = spokenLabel(analystHeadline(result), 96);
const state =
result.feedState ||
result.feedProvenance?.overall ||
result.coverage?.feedProvenance?.overall;
const tag = feedTag(state);
const scope = spokenLabel(result.scopeLabel || '', 96);
const answer =
tag === 'feed unavailable'
? sentence(
`Feed unavailable${scope ? ` ${scope}` : ''}; no authoritative count${result.complete === false ? ', retained coverage is a lower bound' : ''}`,
)
: sentence(`${headline}${tag ? `, ${tag}` : ''}`);
const unanswered = (Array.isArray(result.unanswered) ? result.unanswered : [])
.map((layer) => spokenLabel(layer, 24))
.filter(Boolean);
@@ -408,7 +420,7 @@ function analystQuery(result) {
? `Partial; ${listPhrase(unanswered.slice(0, 2))}${unanswered.length > 2 ? ` and ${unanswered.length - 2} more` : ''} not answered.`
: 'Partial answer.';
return {
say: `${sentence(headline)}${result.partial ? ` ${missing}` : ''}`,
say: `${answer}${result.partial ? ` ${missing}` : ''}`,
};
}
+20 -4
View File
@@ -242,12 +242,28 @@ export function reduceVoiceCard(state, event) {
export function voiceCardView(state) {
const running = state.steps.some((step) => step.status === 'running');
const display = state.result?.display || null;
const notes = [
const allNotes = [
...(display?.notes || []),
...(display?.sources || []).map((source) => `Source: ${source.label}`),
]
.map((note) => spokenLabel(note, 96))
.slice(0, MAX_NOTES);
];
// Analyst notes carry count/scope/provenance qualifications. Preserve their
// full sanitized text in at most six existing rows, joining overflow into
// the last row. Other tool cards retain their compact note contract.
const completeNotes = display?.preserveNotes
? [
...new Set(
allNotes.map((note) => spokenLabel(note, Infinity)).filter(Boolean),
),
]
: null;
const notes = completeNotes
? completeNotes.length > MAX_NOTES
? [
...completeNotes.slice(0, MAX_NOTES - 1),
completeNotes.slice(MAX_NOTES - 1).join('; '),
]
: completeNotes
: allNotes.map((note) => spokenLabel(note, 96)).slice(0, MAX_NOTES);
return {
visible: Boolean(state.visible),
busy: running,