diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9ad3b787d..b6afa13ff 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -831,7 +831,7 @@ The standalone app offers OpenAI and Gemini Live through an explicit provider ch Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields. -Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit; stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne::` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry. +Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne::` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry. `createGevActionRunner` routes `control_cctv` enable/disable through the shared layer visibility lifecycle. Results distinguish successful changes from diff --git a/src/data/adminBoundaries.js b/src/data/adminBoundaries.js index 200ab657a..61701b879 100644 --- a/src/data/adminBoundaries.js +++ b/src/data/adminBoundaries.js @@ -421,6 +421,49 @@ const COUNTRY_TARGET_ALIASES = new Map([ ['drc', 'democratic republic of the congo'], ]); +/** + * Country aliases that are another name for the very same unit. "Great + * Britain" and "Britain" are islands, "America" is also two continents and + * "Korea" two countries, so those find a country without naming it. + */ +const COUNTRY_IDENTITY_ALIASES = new Set([ + 'usa', + 'us', + 'u s', + 'u s a', + 'united states', + 'uk', + 'u k', + 'russian federation', + 'czechia', + 'tanzania', + 'drc', +]); + +/** + * The unit an ask names in the matcher's own terms, when its words differ + * from the unit's name: "State of Texas" names Texas at admin level 1, and + * "USA" or "Country of USA" names United States of America. Null when the + * ask is just a name. Names are folded with `normalizeAdminName`. + * @param {string} text + * @returns {{name: string, level: 'country'|'admin1'}|null} + */ +export function adminQueryIdentity(text) { + const parsed = parseAdminQuery(text); + if (!parsed?.name) return null; + if (parsed.kind === 'state' && parsed.form === 'prefix') + return { name: parsed.name, level: 'admin1' }; + if (parsed.kind !== 'country' && parsed.kind !== 'any') return null; + if (COUNTRY_IDENTITY_ALIASES.has(parsed.name)) + return { + name: COUNTRY_TARGET_ALIASES.get(parsed.name), + level: 'country', + }; + return parsed.kind === 'country' + ? { name: parsed.name, level: 'country' } + : null; +} + const COUNTRY_ALIASES = new Map([ ...COUNTRY_TARGET_ALIASES, ['england', 'united kingdom'], diff --git a/src/data/areaResolver.js b/src/data/areaResolver.js index 80fe2e9f1..1134e37ca 100644 --- a/src/data/areaResolver.js +++ b/src/data/areaResolver.js @@ -25,6 +25,7 @@ import { AREA_LEVELS } from './areaStore.js'; import { pointInPreparedArea, prepareArea } from './areaGeometry.js'; +import { adminQueryIdentity, normalizeAdminName } from './adminBoundaries.js'; import { isAreaCandidateId, MAX_AREA_CANDIDATE_ID_LENGTH, @@ -149,8 +150,18 @@ function explicitAdminName(text) { }; } -/** Fail closed when a bundled unit's own name contradicts the requested unit. */ -function bundledAdminIdentityRefusal(admin, query, wantedLevel) { +/** + * Fail closed when a bundled unit's own name contradicts the requested unit. + * With `aliases`, an ask the administrative matcher reads as naming this very + * unit at its level ("State of Texas", "USA") also matches; the natural-region + * check leaves it off, since "Great Britain" is not the United Kingdom. + */ +function bundledAdminIdentityRefusal( + admin, + query, + wantedLevel, + { aliases = true } = {}, +) { const source = explicitAdminName(admin?.name || ''); const requested = explicitAdminName(query); const expectedLevel = wantedLevel || requested.level; @@ -161,8 +172,16 @@ function bundledAdminIdentityRefusal(admin, query, wantedLevel) { code: 'AREA_IDENTITY_UNVERIFIED', error: `The bundled boundary is ${admin?.name || 'an unverified administrative unit'}, not a verified current match for "${query}".`, }; + const alias = aliases ? adminQueryIdentity(query) : null; + const named = + source.bare === requested.bare || + Boolean( + alias && + alias.level === emittedLevel && + alias.name === normalizeAdminName(admin.name), + ); if ( - source.bare !== requested.bare || + !named || (source.level && expectedLevel && source.level !== expectedLevel) || (expectedLevel && emittedLevel !== expectedLevel) ) @@ -343,7 +362,10 @@ export function createAreaResolver({ places.length > 0 && level !== 'natural' && (await findAdminCandidates(query, { signal }).catch(() => [])).some( - (admin) => !bundledAdminIdentityRefusal(admin, query, null), + (admin) => + !bundledAdminIdentityRefusal(admin, query, null, { + aliases: false, + }), ); signal?.throwIfAborted?.(); if (places.length > 1 && !shadowsAdmin) { diff --git a/src/voice/areaActions.js b/src/voice/areaActions.js index 2c1b477fe..c66db7ee1 100644 --- a/src/voice/areaActions.js +++ b/src/voice/areaActions.js @@ -21,7 +21,10 @@ import { createAreaStore, summarizeArea } from '../data/areaStore.js'; import { createAreaResolver, foldLatinMarks } from '../data/areaResolver.js'; import { circleRing, displayParts } from '../data/areaGeometry.js'; import { findNaturalRegion } from '../data/naturalEarthRegions.js'; -import { findAdminCandidates as findBundledAdminCandidates } from '../data/adminBoundaries.js'; +import { + adminQueryIdentity, + findAdminCandidates as findBundledAdminCandidates, +} from '../data/adminBoundaries.js'; import { NATURAL_EARTH_CREDIT, US_CENSUS_CREDIT, @@ -87,6 +90,16 @@ function administrativeIdentity(value) { .toLocaleLowerCase() .replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ') .trim(); + // Forms the administrative matcher reads as one unit's name: "State of + // Texas" is Texas at admin level 1, "USA" is United States of America. + const named = adminQueryIdentity(value); + if (named?.level === 'admin1') + return { + name: foldLatinMarks(normalized.replace(/^(?:the )?.+? of /, '')), + level: 'admin1', + }; + if (named?.level === 'country') + return { name: foldLatinMarks(named.name), level: 'country' }; const match = normalized.match( /\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/, ); diff --git a/src/voice/areaActions.test.mjs b/src/voice/areaActions.test.mjs index 19f8a04dd..b9a3ff944 100644 --- a/src/voice/areaActions.test.mjs +++ b/src/voice/areaActions.test.mjs @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import { createVoiceAreas, spokenArea } from './areaActions.js'; import { pointInPreparedArea } from '../data/areaGeometry.js'; import { presentResult } from './resultDisplay.js'; +import { findAdminCandidates as findBundledAdminCandidates } from '../data/adminBoundaries.js'; const square = (w, s, e, n) => [ [w, s], @@ -392,6 +393,49 @@ test('a verified province accepts a bare query with a separate level', async () assert.equal(retired.areas.store.size, 0); }); +test('an explicit level accepts the names the administrative matcher reads as that unit', async () => { + const bundled = () => + makeAreas({ findAdminCandidates: findBundledAdminCandidates }).areas; + for (const [query, level, name] of [ + ['State of Texas', 'admin1', 'Texas'], + ['State of Texas', undefined, 'Texas'], + ['USA', 'country', 'United States of America'], + ['UK', 'country', 'United Kingdom'], + ]) { + const result = await bundled().resolveAreaAction({ query, level }); + assert.equal(result.ok, true, `${query} (${level}): ${result.error}`); + assert.equal(result.name, name); + assert.equal(result.level, level || 'admin1'); + } + // The level still has to match the unit. + for (const [query, level] of [ + ['State of Texas', 'country'], + ['USA', 'admin1'], + ]) { + const areas = bundled(); + const result = await areas.resolveAreaAction({ query, level }); + assert.equal(result.ok, false, `${query} is not a ${level}`); + assert.equal(areas.store.size, 0); + } + + // The outline ladder's identity check reads the same forms. + const prefixed = makeAreas().areas; + const bagmati = await prefixed.resolveAreaAction({ + query: 'Province of Bagmati', + level: 'admin1', + }); + assert.equal(bagmati.ok, true, bagmati.error); + assert.equal(bagmati.name, 'Bagmati Province'); + const wrongLevel = makeAreas().areas; + const county = await wrongLevel.resolveAreaAction({ + query: 'Province of Bagmati', + level: 'admin2', + }); + assert.equal(county.ok, false); + assert.equal(county.code, 'AREA_IDENTITY_MISMATCH'); + assert.equal(wrongLevel.store.size, 0); +}); + test('a current Zone the source verified is not refused as retired', async () => { const gamo = makeAreas({ annotationResolver: fakeLadder({