fix(voice): resolve areas per view, without accents and per natural place

From review:
- An answer the view picked ("Springfield" over Illinois) is reused or
  shared only within the same ~2° view cell, so asking again elsewhere no
  longer gets the first state's area.
- Admin names compare with accents folded on Latin letters only: "Quebec"
  matches "Québec" and "Cordoba" asks between Argentina and Spain, while a
  Thai or Devanagari mark still tells names apart.
- A Natural Earth name used by several features is one place with every
  part when they touch (the Great Rift Valley, the Lau Group across 180°)
  and a question when they are far apart (Flores); natural places can now
  be candidates.
- The AREAS rule and the area tool descriptions are tighter, leaving a
  ~270-character margin under the 55,500 prompt ceiling, and resolve_area
  no longer names osm_query when that tool is not offered.
This commit is contained in:
Manjunath committed 2026-11-03 19:36:25 +05:30
1 parent fb74354d30
commit 207373389a
12 files changed
+328 -22

No files matched your search

+3 -2
View File
@@ -53,8 +53,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// The Operator HUD layout leaves set_hud's layout enum.
// The layer alias hint leaves out aliases the layer id already spells.
// Voice geometry adds resolve_area, find_imagery, osm_query, the analyst
// area scopes and the osm-places layer.
'6a60cb849fe6835295354b0a6711c9d2b6428114f66d14690430f1732070b59c',
// area scopes and the osm-places layer; natural-region candidates and the
// tightened area tool wording follow review.
'1e07573229f7901148a8e2d30d5111dc359ed35156aa7878afac8f0d940badd8',
);
});
+5 -2
View File
@@ -18,7 +18,7 @@
*/
import { createAreaStore, summarizeArea } from '../data/areaStore.js';
import { createAreaResolver } from '../data/areaResolver.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';
@@ -91,7 +91,10 @@ function administrativeIdentity(value) {
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
);
return {
name: match ? normalized.slice(0, -match[0].length).trim() : normalized,
// Compared, never shown: "Quebec" as spoken names "Québec".
name: foldLatinMarks(
match ? normalized.slice(0, -match[0].length).trim() : normalized,
),
level: match ? ADMIN_IDENTITY_LEVEL[match[1]] || null : null,
};
}
+21
View File
@@ -412,6 +412,27 @@ test('a current Zone the source verified is not refused as retired', async () =>
assert.equal(gamo.areas.store.size, 1);
});
test('an outline named with accents is accepted for the same name spoken without them', async () => {
const quebec = makeAreas({
annotationResolver: fakeLadder({
label: 'Québec',
match: /quebec/i,
outline: {
ring: BAGMATI_POLYGONS[0][0],
polygons: BAGMATI_POLYGONS,
adminArea: 'Québec',
adminLevel: 'admin1',
synthesized: false,
outlineSource: 'nominatim',
},
}),
});
const result = await quebec.areas.resolveAreaAction({
query: 'Quebec province',
});
assert.equal(result.ok, true, result.error);
});
test('a place no outline source can trace is refused, not guessed', async () => {
const miss = makeAreas({
annotationResolver: fakeLadder({ outline: null }),
+2 -2
View File
@@ -5,10 +5,10 @@ export const MAX_AREA_CANDIDATE_ID_LENGTH = 80;
/** JSON Schema-compatible grammar for bundled candidate handles. */
export const AREA_CANDIDATE_ID_PATTERN =
'^ne:(?:country|state|county):[\\p{L}\\p{M}\\p{N}]+(?:-[\\p{L}\\p{M}\\p{N}]+)*$';
'^ne:(?:country|state|county|natural):[\\p{L}\\p{M}\\p{N}]+(?:-[\\p{L}\\p{M}\\p{N}]+)*$';
const AREA_CANDIDATE_ID_RE =
/^ne:(?:country|state|county):[\p{L}\p{M}\p{N}]+(?:-[\p{L}\p{M}\p{N}]+)*$/u;
/^ne:(?:country|state|county|natural):[\p{L}\p{M}\p{N}]+(?:-[\p{L}\p{M}\p{N}]+)*$/u;
export function isAreaCandidateId(value) {
const candidateId = String(value || '');
+5
View File
@@ -695,6 +695,11 @@ test('boundaries route to resolve_area; annotate_map is for marks', () => {
test('without an operator Overpass the prompt never offers place search', () => {
const plain = realtimeInstructions();
assert.doesNotMatch(plain, /osm_query/);
assert.doesNotMatch(
JSON.stringify(realtimeTools()),
/osm_query/,
'no other tool description points at it either',
);
assert.equal(
realtimeTools().some((tool) => tool.name === 'osm_query'),
false,
+3 -1
View File
@@ -344,7 +344,9 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in
// characters. It was 56,308 after the area/imagery/OSM merge; stating each
// policy once (WHERE precedence, AREAS routing, AFTER TOOLS speech) brought
// it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and
// the analyst area scopes) raised the ceiling to 55,500. A new tool or rule
// the analyst area scopes) raised the ceiling to 55,500; tightening the
// AREAS rule and the area tool descriptions left it at 55,233 with an
// operator Overpass, a deliberate ~270-character margin. A new tool or rule
// has to fit under this ceiling.
const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length;
assert.ok(size <= 55_500, `prompt size ${size}`);