mirror of
https://github.com/zvx-echo6/recon.git
synced 2026-05-20 06:34:40 +02:00
fix(navi): address book prefix+boundary match for longer queries
lookup() previously did exact-alias-only matching, so "214 north st
filer" missed the home entry with alias "214 north st". Extend to
match when the query begins with an alias followed by a word
boundary, and when an alias appears as a contiguous token sequence
inside the query. Short aliases ("home") keep matching exactly and
also match with trailing text.
Fixes the UX case where typing a known full address falls through
to Netsyms instead of short-circuiting to address_book.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dfab388769
commit
a14501347b
2 changed files with 92 additions and 30 deletions
|
|
@ -8,6 +8,7 @@ Config: /opt/recon/config/address_book.yaml
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -79,49 +80,76 @@ def load():
|
||||||
return _entries
|
return _entries
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: str) -> str:
|
||||||
|
"""Lowercase, strip, remove commas, collapse whitespace."""
|
||||||
|
t = text.strip().lower()
|
||||||
|
t = t.replace(',', ' ')
|
||||||
|
return ' '.join(t.split())
|
||||||
|
|
||||||
|
|
||||||
def lookup(query: str):
|
def lookup(query: str):
|
||||||
"""
|
"""
|
||||||
Look up a query against name and aliases.
|
Look up a query against name and aliases.
|
||||||
|
|
||||||
Returns dict with the matching entry plus a 'confidence' field:
|
Returns dict with the matching entry plus a 'confidence' field:
|
||||||
- "exact": full name or alias match
|
- "exact": full name/alias match, OR query starts with alias + word boundary
|
||||||
- "partial": query is a substring of an alias or name (or vice versa)
|
- "partial": alias starts with query + word boundary, or alias appears
|
||||||
|
as a contiguous token sequence inside the query
|
||||||
- None if no match
|
- None if no match
|
||||||
|
|
||||||
|
Matching order (first exact wins, else first partial):
|
||||||
|
1. normalized(query) == normalized(name or alias) → exact
|
||||||
|
2. normalized(query) starts with normalized(alias) + " " → exact
|
||||||
|
3. normalized(alias) starts with normalized(query) + " " → partial
|
||||||
|
4. normalized(alias) is a contiguous token sub-sequence → partial
|
||||||
"""
|
"""
|
||||||
_reload_if_changed()
|
_reload_if_changed()
|
||||||
q = query.strip().lower()
|
q = _normalize(query)
|
||||||
if not q:
|
if not q:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
best = None
|
first_exact = None
|
||||||
best_confidence = None
|
first_partial = None
|
||||||
|
|
||||||
for entry in _entries:
|
for entry in _entries:
|
||||||
# Exact match on name
|
norm_name = _normalize(entry['name'])
|
||||||
if q == entry['name'].lower():
|
check_aliases = [_normalize(a) for a in entry.get('aliases', [])]
|
||||||
return {**entry, 'confidence': 'exact'}
|
all_forms = [norm_name] + check_aliases
|
||||||
|
|
||||||
# Exact match on any alias
|
for form in all_forms:
|
||||||
if q in entry['aliases']:
|
if not form:
|
||||||
return {**entry, 'confidence': 'exact'}
|
|
||||||
|
|
||||||
# Partial: query is substring of name/alias, or name/alias is substring of query
|
|
||||||
name_lower = entry['name'].lower()
|
|
||||||
if q in name_lower or name_lower in q:
|
|
||||||
if best is None:
|
|
||||||
best = entry
|
|
||||||
best_confidence = 'partial'
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for alias in entry['aliases']:
|
# Rule 1: exact match
|
||||||
if q in alias or alias in q:
|
if q == form:
|
||||||
if best is None:
|
return {**entry, 'confidence': 'exact'}
|
||||||
best = entry
|
|
||||||
best_confidence = 'partial'
|
|
||||||
break
|
|
||||||
|
|
||||||
if best is not None:
|
# Rule 2: query starts with alias + word boundary
|
||||||
return {**best, 'confidence': best_confidence}
|
if q.startswith(form + ' '):
|
||||||
|
if first_exact is None:
|
||||||
|
first_exact = entry
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rule 3: alias starts with query (user still typing)
|
||||||
|
if form.startswith(q) and len(q) < len(form):
|
||||||
|
if first_partial is None:
|
||||||
|
first_partial = entry
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rule 4: alias is contiguous token sub-sequence in query
|
||||||
|
# Build regex: token1\s+token2\s+...tokenN
|
||||||
|
tokens = form.split()
|
||||||
|
if len(tokens) >= 1:
|
||||||
|
pattern = r'(?:^|\s)' + r'\s+'.join(re.escape(t) for t in tokens) + r'(?:\s|$)'
|
||||||
|
if re.search(pattern, q):
|
||||||
|
if first_partial is None:
|
||||||
|
first_partial = entry
|
||||||
|
|
||||||
|
if first_exact is not None:
|
||||||
|
return {**first_exact, 'confidence': 'exact'}
|
||||||
|
|
||||||
|
if first_partial is not None:
|
||||||
|
return {**first_partial, 'confidence': 'partial'}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from lib import address_book
|
from lib import address_book
|
||||||
|
|
||||||
TESTS = [
|
TESTS = [
|
||||||
|
# ── Existing tests ──
|
||||||
("lookup('home') → exact",
|
("lookup('home') → exact",
|
||||||
lambda: address_book.lookup("home"),
|
lambda: address_book.lookup("home"),
|
||||||
lambda r: r is not None and r['confidence'] == 'exact' and r['id'] == 'home'),
|
lambda r: r is not None and r['confidence'] == 'exact' and r['id'] == 'home'),
|
||||||
|
|
@ -32,6 +33,39 @@ TESTS = [
|
||||||
("list_all() → 1 entry",
|
("list_all() → 1 entry",
|
||||||
lambda: address_book.list_all(),
|
lambda: address_book.list_all(),
|
||||||
lambda r: isinstance(r, list) and len(r) == 1 and r[0]['id'] == 'home'),
|
lambda r: isinstance(r, list) and len(r) == 1 and r[0]['id'] == 'home'),
|
||||||
|
|
||||||
|
# ── New prefix+boundary tests ──
|
||||||
|
("lookup('214 north st filer') → exact (query starts with alias)",
|
||||||
|
lambda: address_book.lookup("214 north st filer"),
|
||||||
|
lambda r: r is not None and r['confidence'] == 'exact' and r['id'] == 'home'),
|
||||||
|
|
||||||
|
("lookup('214 North St Filer ID') → exact (case + trailing state)",
|
||||||
|
lambda: address_book.lookup("214 North St Filer ID"),
|
||||||
|
lambda r: r is not None and r['confidence'] == 'exact' and r['id'] == 'home'),
|
||||||
|
|
||||||
|
("lookup('214 north st, filer, id') → exact (commas stripped)",
|
||||||
|
lambda: address_book.lookup("214 north st, filer, id"),
|
||||||
|
lambda r: r is not None and r['confidence'] == 'exact' and r['id'] == 'home'),
|
||||||
|
|
||||||
|
("lookup('home today') → exact (short alias + trailing text)",
|
||||||
|
lambda: address_book.lookup("home today"),
|
||||||
|
lambda r: r is not None and r['confidence'] == 'exact' and r['id'] == 'home'),
|
||||||
|
|
||||||
|
("lookup('214') → partial (query is prefix of alias)",
|
||||||
|
lambda: address_book.lookup("214"),
|
||||||
|
lambda r: r is not None and r['confidence'] == 'partial'),
|
||||||
|
|
||||||
|
("lookup('214 n') → partial (partial prefix of alias)",
|
||||||
|
lambda: address_book.lookup("214 n"),
|
||||||
|
lambda r: r is not None and r['confidence'] == 'partial'),
|
||||||
|
|
||||||
|
("lookup('completely unrelated query') → None",
|
||||||
|
lambda: address_book.lookup("completely unrelated query"),
|
||||||
|
lambda r: r is None),
|
||||||
|
|
||||||
|
("lookup('214 north streets of filer') → None (no word boundary after st)",
|
||||||
|
lambda: address_book.lookup("214 north streets of filer"),
|
||||||
|
lambda r: r is None),
|
||||||
]
|
]
|
||||||
|
|
||||||
passed = 0
|
passed = 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue