From a14501347b6d3b9a0b79d3d7d60a8e43a1e31ee9 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 20 Apr 2026 07:54:32 +0000 Subject: [PATCH] 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 --- lib/address_book.py | 80 +++++++++++++++++++++++++++------------- lib/address_book_test.py | 34 +++++++++++++++++ 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/lib/address_book.py b/lib/address_book.py index a9cfc40..f9827f6 100644 --- a/lib/address_book.py +++ b/lib/address_book.py @@ -8,6 +8,7 @@ Config: /opt/recon/config/address_book.yaml """ import os +import re import threading import yaml @@ -79,49 +80,76 @@ def load(): 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): """ Look up a query against name and aliases. Returns dict with the matching entry plus a 'confidence' field: - - "exact": full name or alias match - - "partial": query is a substring of an alias or name (or vice versa) + - "exact": full name/alias match, OR query starts with alias + word boundary + - "partial": alias starts with query + word boundary, or alias appears + as a contiguous token sequence inside the query - 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() - q = query.strip().lower() + q = _normalize(query) if not q: return None - best = None - best_confidence = None + first_exact = None + first_partial = None for entry in _entries: - # Exact match on name - if q == entry['name'].lower(): - return {**entry, 'confidence': 'exact'} + norm_name = _normalize(entry['name']) + check_aliases = [_normalize(a) for a in entry.get('aliases', [])] + all_forms = [norm_name] + check_aliases - # Exact match on any alias - if q in entry['aliases']: - return {**entry, 'confidence': 'exact'} + for form in all_forms: + if not form: + continue - # 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 + # Rule 1: exact match + if q == form: + return {**entry, 'confidence': 'exact'} - for alias in entry['aliases']: - if q in alias or alias in q: - if best is None: - best = entry - best_confidence = 'partial' - break + # Rule 2: query starts with alias + word boundary + if q.startswith(form + ' '): + if first_exact is None: + first_exact = entry + continue - if best is not None: - return {**best, 'confidence': best_confidence} + # 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 diff --git a/lib/address_book_test.py b/lib/address_book_test.py index e7fa7ef..75905f0 100644 --- a/lib/address_book_test.py +++ b/lib/address_book_test.py @@ -9,6 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from lib import address_book TESTS = [ + # ── Existing tests ── ("lookup('home') → exact", lambda: address_book.lookup("home"), lambda r: r is not None and r['confidence'] == 'exact' and r['id'] == 'home'), @@ -32,6 +33,39 @@ TESTS = [ ("list_all() → 1 entry", lambda: address_book.list_all(), 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