From 207373389aa045626a961b657aa864115cdd4480 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:36:25 +0530 Subject: [PATCH] fix(voice): resolve areas per view, without accents and per natural place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/providers/openai/instructions.js | 4 +- server/providers/openai/toolDescriptions.js | 4 +- src/data/areaResolver.js | 77 ++++++++++- src/data/areaResolver.test.mjs | 136 ++++++++++++++++++++ src/data/naturalEarthRegions.js | 65 +++++++++- src/data/naturalEarthRegions.test.mjs | 18 +++ src/voice/actionSchemas.test.mjs | 5 +- src/voice/areaActions.js | 7 +- src/voice/areaActions.test.mjs | 21 +++ src/voice/areaCandidateContract.js | 4 +- src/voice/integrationContract.test.mjs | 5 + src/voice/pointAndAsk.test.mjs | 4 +- 12 files changed, 328 insertions(+), 22 deletions(-) diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index cd4a67c3d..63ef4bbeb 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -28,9 +28,9 @@ function realtimeInstructions( 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', '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, or an areaId you already have; (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.', - 'AREAS: resolve_area turns a named boundary (country, state, province, county, city, district, natural region) into an areaId — to outline it (draw=true), to count or search inside it, or for "an area around " (around=true); annotate_map is for marks, not boundaries. Count inside a named place → analyst_query kind=region, or kind=area with an areaId; "the area I drew" → kind=drawn; "the area you outlined" → kind=annotation. Imagery of a named place → resolve_area, then find_imagery with its areaId. For an ambiguous name call resolve_area anyway; on needsClarification ask which one, never guess.' + + 'AREAS: resolve_area turns a named boundary (country, state, province, county, city, district, natural region) into an areaId; annotate_map is for marks, not boundaries. Count inside it → analyst_query kind=area with the areaId (or kind=region); "the area I drew" → kind=drawn; "the area you outlined" → kind=annotation. Imagery of a place → resolve_area, then find_imagery. Ambiguous name: call resolve_area; on needsClarification ask which, never guess.' + (overpass - ? ' "All hospitals/schools/… in " → resolve_area, then osm_query with its areaId; in view → osm_query alone; follow-ups about them → analyst_query layers [osm-places], scope anywhere.' + ? ' "All hospitals/schools/… in " → resolve_area, then osm_query; in view → osm_query alone; follow-ups → analyst_query layers [osm-places], scope anywhere.' : ''), '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, 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 " 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.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 1d650947b..7b25d34db 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -549,7 +549,7 @@ export const ACTION_DESCRIPTIONS = { }, resolve_area: { description: - 'Named place → reusable areaId. draw=true outlines it ("outline Bagmati province"); around=true is "an area around " — its enclosing mapped area, else a buffer marked approximate. Pass the areaId to analyst_query scope {kind:area}, find_imagery or osm_query. needsClarification: ask which, then call again with its candidateId. AREA_TIMEOUT: say you are still looking and retry once.', + 'Named place → reusable areaId for analyst_query scope {kind:area} and other area tools. draw=true outlines it ("outline Bagmati province"); around=true is "an area around ": its enclosing mapped area, else a buffer marked approximate. needsClarification: ask which, then call again with its candidateId. AREA_TIMEOUT: retry once.', $position: 1, parameters: { properties: { @@ -570,7 +570,7 @@ export const ACTION_DESCRIPTIONS = { }, find_imagery: { description: - 'Recent satellite imagery (Sentinel-2/Landsat 30 m, VIIRS daily; last 30 days): pins the best acquisition in the Recent Imagery panel. areaId from resolve_area; area=pointer for "this spot"; omit for the view. BOX_REFUSED: offer to zoom in. DISPLACED: the user changed the panel, leave it.', + 'Recent satellite imagery (Sentinel-2/Landsat 30 m, VIIRS daily; last 30 days): pins the best acquisition in the Recent Imagery panel. areaId from resolve_area; area=pointer for "this spot"; omit for the view. BOX_REFUSED: offer to zoom in. DISPLACED: leave it.', $position: 1, parameters: { properties: { diff --git a/src/data/areaResolver.js b/src/data/areaResolver.js index 0cbcd4894..80fe2e9f1 100644 --- a/src/data/areaResolver.js +++ b/src/data/areaResolver.js @@ -84,8 +84,29 @@ const ADMIN_LEVEL = Object.freeze({ country: 'country', state: 'admin1', county: 'admin2', + natural: 'natural', }); +/** + * One of several same-name natural places as a clarification choice, located + * by its rough centre ("39 N 31 W") since it has no country. + */ +function naturalCandidate(place, kind) { + const { lat, lon } = place.center || {}; + const locator = Number.isFinite(lat) + ? `${Math.abs(Math.round(lat))} ${lat >= 0 ? 'N' : 'S'} ${Math.abs(Math.round(lon))} ${lon >= 0 ? 'E' : 'W'}` + : ''; + return { + kind: 'natural', + name: place.name, + country: '', + regionCode: locator, + polygons: place.polygons.map((ring) => [ring]), + source: 'natural-earth', + meta: { kind }, + }; +} + const ADMIN_NAME_LEVEL = Object.freeze({ country: 'country', nation: 'country', @@ -101,6 +122,18 @@ const ADMIN_NAME_LEVEL = Object.freeze({ department: 'admin2', }); +/** + * Drop accents from Latin letters only, for comparing names: speech often + * loses them ("Quebec" for "Québec"), while a Thai or Devanagari mark changes + * the name and is kept. + */ +export function foldLatinMarks(text) { + return String(text || '') + .normalize('NFD') + .replace(/(\p{Script=Latin})\p{M}+/gu, '$1') + .normalize('NFC'); +} + function explicitAdminName(text) { const normalized = normalizeAreaQuery(String(text).split(',')[0]); const match = normalized.match( @@ -108,7 +141,9 @@ function explicitAdminName(text) { ); return { normalized, - bare: match ? normalized.slice(0, -match[0].length).trim() : normalized, + bare: foldLatinMarks( + match ? normalized.slice(0, -match[0].length).trim() : normalized, + ), word: match?.[1] || null, level: match ? ADMIN_NAME_LEVEL[match[1]] || null : null, }; @@ -191,6 +226,13 @@ function describeCandidate(admin) { * itself stands for its same-named state ("Mexico" is the country, not the * State of Mexico); across countries each name is its own place. */ +/** A coarse ~2° cell of the view, or "none", for keying view-picked answers. */ +function viewCell(center) { + if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon)) + return 'none'; + return `${Math.floor(center.lat / 2)},${Math.floor(center.lon / 2)}`; +} + export function distinctAdminPlaces(candidates) { const byCountry = new Map(); for (const admin of candidates) { @@ -290,16 +332,37 @@ export function createAreaResolver({ // "Punjab" the desert) never hides it; asked for by level, it answers. // Only a unit that really has this name counts: "Great Britain" finds // the United Kingdom by alias, which is not a Great Britain. + // A feature split into parts is one place with every part; distant + // namesakes (Flores in the Azores and Indonesia) are asked about. + const places = ne?.places?.length + ? ne.places + : ne?.polygons?.length + ? [{ name: ne.name, polygons: ne.polygons }] + : []; const shadowsAdmin = - ne?.polygons?.length && + places.length > 0 && level !== 'natural' && (await findAdminCandidates(query, { signal }).catch(() => [])).some( (admin) => !bundledAdminIdentityRefusal(admin, query, null), ); signal?.throwIfAborted?.(); - if (ne?.polygons?.length && !shadowsAdmin) { + if (places.length > 1 && !shadowsAdmin) { + const offered = places + .slice(0, MAX_AREA_CANDIDATES) + .map((place) => naturalCandidate(place, ne.kind)); + const identity = { query, wantedLevel: 'natural' }; + offered.forEach((admin) => remember(admin, identity)); + return { + ok: false, + needsClarification: true, + code: 'AMBIGUOUS_AREA', + candidates: offered.map(describeCandidate), + error: `"${query}" names ${offered.length} places — ask which one.`, + }; + } + if (places.length === 1 && !shadowsAdmin) { const record = store.put({ - geometry: ne.polygons.map((ring) => [ring]), + geometry: places[0].polygons.map((ring) => [ring]), name: ne.name, level: 'natural', source: 'natural-earth', @@ -472,9 +535,11 @@ export function createAreaResolver({ error: 'Name a place to resolve.', }); const nameKey = normalizeAreaQuery(query); - // A name that normalizes to nothing is never cached or shared. + // The outline ladder and geocoder pick by the view ("Springfield" over + // Illinois), so an answer is reused or shared only within the same + // ~2° view cell. A name that normalizes to nothing is never cached. const key = nameKey - ? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}` + ? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}|${viewCell(viewCenter())}` : null; const recalled = key ? store.recallQuery(key) : null; if (recalled) diff --git a/src/data/areaResolver.test.mjs b/src/data/areaResolver.test.mjs index 2e371ca87..4c411b5ac 100644 --- a/src/data/areaResolver.test.mjs +++ b/src/data/areaResolver.test.mjs @@ -725,3 +725,139 @@ test('a bundled name mismatch falls through to the outline ladder', async () => 'the bundled refusal is kept', ); }); + +test('a view-picked outline answer is not reused for the same name elsewhere', async () => { + let view = { lat: 39.8, lon: -89.6 }; // Springfield, Illinois + const springfield = (center) => ({ + ok: true, + name: + center.lon < -80 ? 'Springfield, Illinois' : 'Springfield, Massachusetts', + polygons: [ + [ + square( + center.lon - 0.1, + center.lat - 0.1, + center.lon + 0.1, + center.lat + 0.1, + ), + ], + ], + source: 'osm', + rung: 'nominatim', + }); + const { resolver } = makeResolver({ + viewCenter: () => view, + resolveNamedOutline: async () => springfield(view), + }); + const illinois = await resolver.resolve({ query: 'Springfield' }); + assert.equal(illinois.record.name, 'Springfield, Illinois'); + const again = await resolver.resolve({ query: 'Springfield' }); + assert.equal(again.cached, true, 'the same view still reuses it'); + view = { lat: 42.1, lon: -72.6 }; // Springfield, Massachusetts + const massachusetts = await resolver.resolve({ query: 'Springfield' }); + assert.equal(massachusetts.cached, false); + assert.equal(massachusetts.record.name, 'Springfield, Massachusetts'); +}); + +test('a name spoken without accents matches its accented unit, Latin letters only', async () => { + const quebec = admin('state', 'Québec', 'Canada', [-79.8, 45, -57.1, 62.6]); + const cordobaAr = admin( + 'state', + 'Córdoba', + 'Argentina', + [-65.8, -35, -61.8, -29.5], + ); + const cordobaEs = admin('state', 'Córdoba', 'Spain', [-5.6, 37.2, -4, 38.7]); + const thai = admin('state', 'ป่าย', 'Thailand', [98, 19, 99, 20]); + const { resolver } = makeResolver({ + findAdminCandidates: async (q) => + /^quebec$/i.test(q) + ? [quebec] + : /^cordoba$/i.test(q) + ? [cordobaAr, cordobaEs] + : q === 'ปาย' + ? [thai] + : [], + }); + const qc = await resolver.resolve({ query: 'Quebec' }); + assert.equal(qc.ok, true, qc.error); + assert.equal(qc.record.name, 'Québec'); + const cordoba = await resolver.resolve({ query: 'Cordoba' }); + assert.equal(cordoba.code, 'AMBIGUOUS_AREA', 'both Córdobas are asked about'); + assert.equal(cordoba.candidates.length, 2); + const tone = await resolver.resolve({ query: 'ปาย' }); + assert.equal(tone.ok, false, 'a Thai tone mark still tells names apart'); +}); + +test('a natural name for two distant places is asked about, and the choice resolves', async () => { + const azores = { + name: 'Flores', + featurecla: 'Island', + polygons: [square(-31.3, 39.4, -31.1, 39.5)], + areaKm2: 140, + center: { lat: 39.45, lon: -31.2 }, + }; + const indonesia = { + name: 'Flores', + featurecla: 'Island', + polygons: [square(119.8, -8.9, 123, -8.1)], + areaKm2: 14000, + center: { lat: -8.5, lon: 121.4 }, + }; + const { resolver } = makeResolver({ + findNaturalRegion: async (q) => + /^flores$/i.test(q) + ? { + name: 'Flores', + kind: 'natural', + polygons: indonesia.polygons, + places: [indonesia, azores], + } + : null, + }); + const asked = await resolver.resolve({ query: 'Flores' }); + assert.equal(asked.code, 'AMBIGUOUS_AREA'); + assert.equal(asked.candidates.length, 2); + assert.ok(asked.candidates.every((c) => c.level === 'natural' && c.region)); + const picked = await resolver.resolve({ + candidateId: asked.candidates[1].candidateId, + }); + assert.equal(picked.ok, true, picked.error); + assert.equal(picked.record.level, 'natural'); + assert.equal( + pointInPreparedArea(picked.record.prepared, 39.45, -31.2), + true, + 'the Azores Flores', + ); +}); + +test('a natural feature in several parts is stored whole', async () => { + const north = square(33.7, 2.4, 44.2, 15.4); + const south = square(28.9, -15.6, 35.8, 2.4); + const { resolver } = makeResolver({ + findNaturalRegion: async (q) => + /rift/i.test(q) + ? { + name: 'Great Rift Valley', + kind: 'natural', + polygons: [north], + places: [ + { + name: 'Great Rift Valley', + featurecla: 'Valley', + polygons: [north, south], + areaKm2: 2, + center: { lat: 0, lon: 36 }, + }, + ], + } + : null, + }); + const rift = await resolver.resolve({ query: 'Great Rift Valley' }); + assert.equal(rift.ok, true); + assert.equal( + pointInPreparedArea(rift.record.prepared, -10, 33), + true, + 'the southern part counts', + ); +}); diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 512391311..c73a29888 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -189,7 +189,58 @@ const loadIndex = createRetryableLoader(async () => { return index; }); -function toResult(entry, candidates = 1) { +/** Same-name parts this close are one feature split by the pack. */ +const SAME_PLACE_GAP_KM = 100; + +/** Gap between two bboxes in km, measured the short way across 180°. */ +function bboxGapKm(a, b) { + const midLat = (a[1] + a[3] + b[1] + b[3]) / 4; + const kmPerLon = 111.32 * Math.cos((midLat * Math.PI) / 180); + let dLon = Infinity; + for (const shift of [-360, 0, 360]) { + dLon = Math.min( + dLon, + Math.max(0, Math.max(a[0], b[0] + shift) - Math.min(a[2], b[2] + shift)), + ); + } + const dLat = Math.max(0, Math.max(a[1], b[1]) - Math.min(a[3], b[3])); + return Math.hypot(dLon * kmPerLon, dLat * 111.32); +} + +/** + * Distinct places among same-name entries: parts that touch or nearly touch + * (the Great Rift Valley's two halves, the Lau Group across 180°) merge into + * one place with every part; distant namesakes (Flores in the Azores and in + * Indonesia) stay separate. Largest place first. + */ +function groupPlaces(list) { + const groups = []; + for (const entry of list) { + const near = groups.filter((group) => + group.some( + (other) => bboxGapKm(other.bbox, entry.bbox) <= SAME_PLACE_GAP_KM, + ), + ); + const merged = [entry, ...near.flat()]; + for (const group of near) groups.splice(groups.indexOf(group), 1); + groups.push(merged); + } + return groups + .map((group) => { + const largest = group.reduce((a, b) => (b.areaKm2 > a.areaKm2 ? b : a)); + const [w, s, e, n] = largest.bbox; + return { + name: largest.name, + featurecla: largest.featurecla, + polygons: group.flatMap((entry) => entry.polygons), + areaKm2: group.reduce((sum, entry) => sum + entry.areaKm2, 0), + center: { lat: (s + n) / 2, lon: (w + e) / 2 }, + }; + }) + .sort((a, b) => b.areaKm2 - a.areaKm2); +} + +function toResult(entry, candidates = 1, list = [entry]) { return { name: entry.name, featurecla: entry.featurecla, @@ -198,6 +249,7 @@ function toResult(entry, candidates = 1) { bboxDiagonalKm: entry.bboxDiagonalKm, areaKm2: entry.areaKm2, candidates, + places: groupPlaces(list), }; } @@ -210,9 +262,12 @@ function toResult(entry, candidates = 1) { * @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico" * @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine', * polygons:Array>, bboxDiagonalKm:number, - * areaKm2:number, candidates:number}|null>} largest-area match plus the - * number of same-name entries, or null when nothing matches. Uniqueness in - * this pack alone does not establish the user's intended place. + * areaKm2:number, candidates:number, places:Array<{name:string, + * featurecla:string, polygons:Array, areaKm2:number, + * center:{lat:number, lon:number}}>}|null>} largest-area match plus the + * number of same-name entries, or null when nothing matches. `places` + * groups those entries into distinct places, each with all its parts. + * Uniqueness in this pack alone does not establish the user's intended place. */ export async function findNaturalRegion(query) { const norm = normalizeName(query); @@ -226,7 +281,7 @@ export async function findNaturalRegion(query) { for (const key of candidates) { if (!key) continue; const list = index.get(key); - if (list && list.length) return toResult(list[0], list.length); + if (list && list.length) return toResult(list[0], list.length, list); } return null; } diff --git a/src/data/naturalEarthRegions.test.mjs b/src/data/naturalEarthRegions.test.mjs index 92109a034..1256a28ae 100644 --- a/src/data/naturalEarthRegions.test.mjs +++ b/src/data/naturalEarthRegions.test.mjs @@ -195,3 +195,21 @@ test('pointInRing: basic square', () => { assert.equal(pointInRing(sq, 15, 5), false); assert.equal(pointInRing([[0, 0], [1, 1]], 0.5, 0.5), false, 'degenerate ring'); }); + +test('same-name parts of one feature are one place; distant namesakes are separate places', async () => { + for (const name of ['Great Rift Valley', 'Lau Group', 'Transantarctic Mountains']) { + const region = await findNaturalRegion(name); + assert.equal(region?.places?.length, 1, `${name} is one place`); + const place = region.places[0]; + assert.equal(place.polygons.length >= 2, true, `${name} keeps every part`); + assert.ok(place.areaKm2 > region.areaKm2, `${name} counts more than its largest part`); + } + for (const name of ['Flores', 'Cordillera Oriental']) { + const region = await findNaturalRegion(name); + assert.equal(region?.places?.length, 2, `${name} names two places`); + for (const place of region.places) { + assert.ok(Number.isFinite(place.center.lat) && Number.isFinite(place.center.lon)); + } + } + assert.equal((await findNaturalRegion('Alps'))?.places?.length, 1); +}); diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 165ed8737..e494d48a1 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -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', ); }); diff --git a/src/voice/areaActions.js b/src/voice/areaActions.js index d8785a84d..2c1b477fe 100644 --- a/src/voice/areaActions.js +++ b/src/voice/areaActions.js @@ -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, }; } diff --git a/src/voice/areaActions.test.mjs b/src/voice/areaActions.test.mjs index e99107e27..19f8a04dd 100644 --- a/src/voice/areaActions.test.mjs +++ b/src/voice/areaActions.test.mjs @@ -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 }), diff --git a/src/voice/areaCandidateContract.js b/src/voice/areaCandidateContract.js index 0ce17a4cd..3ffd9beec 100644 --- a/src/voice/areaCandidateContract.js +++ b/src/voice/areaCandidateContract.js @@ -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 || ''); diff --git a/src/voice/integrationContract.test.mjs b/src/voice/integrationContract.test.mjs index 917c7ab06..64d1e394e 100644 --- a/src/voice/integrationContract.test.mjs +++ b/src/voice/integrationContract.test.mjs @@ -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, diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index f46b41968..e17dbebe7 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -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}`);