fix(area): accept "State of Texas" and "USA" as the units they name

resolve_area compared the spoken name with the unit's name literally, so
"State of Texas" (admin1) and "USA" or "UK" (country) were refused with
AREA_IDENTITY_MISMATCH although the bundled matcher found exactly those
units. Both identity checks, the bundled one and the outline ladder's, now
also accept the prefix forms and same-unit country aliases the matcher
reads, at the level they imply, so the level check still applies.
"Great Britain", "Britain", "America" and "Korea" still need their own
names, and the natural-region check stays literal.
This commit is contained in:
Sameh Khamis committed 2026-11-03 09:12:28 -08:00
1 parent 540629a0fc
commit 68667e2e3c
5 files changed
+128 -6

No files matched your search

+1 -1
View File
@@ -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:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
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:<kind>:<slug>` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry.
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
layer visibility lifecycle. Results distinguish successful changes from
+43
View File
@@ -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'],
+26 -4
View File
@@ -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) {
+14 -1
View File
@@ -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)$/,
);
+44
View File
@@ -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({