Skip to content

Commit 5076c1d

Browse files
fix: triage all 10 ha_search_entities behaviors from #1170 (#1195)
* fix: triage all 10 ha_search_entities behaviors from #1170 Closes #1170. Closes #1166. Each finding from the umbrella issue is addressed: 1. domain_filter case-insensitive — silent zero-result on "Light" was normalized away at the boundary; the response echoes the canonical lowercase value. 2. Token elision — `bedlight` no longer ties bed_light against five unrelated `*_lights` at score 76. Per-entity, the entity_id tail and friendly_name now contribute their separator-stripped concat as a single high-IDF BM25 token. 3. area_only response shape — results carry `score=100` and `match_type="area_match"` to match the four other search-type branches. 4. Per-result `area_filter` echo dropped on `area_filtered_query` — top-level field still echoes; per-result was redundant and asymmetric vs the other branches. 5. Multi-token coverage gate — `xyz_irrelevant_garbage` no longer surfaces `cover.garage_door` at score 92 via the `garbage~garage` ratio. typo_fallback now requires ≥50% of distinct query tokens to fuzzy-match a doc token; single-token typos like `ligth` are unaffected. 6. `_partial_results_search` removed — the "last resort" fallback returned every entity at score 0 with `partial: true`, masking real errors. Exceptions now propagate so callers see the cause. 7. area_only aggregates ALL fuzzy-matched areas (was first-match-wins, and from a `set` so non-deterministic). Public-API change: new `area_names: list[str]` joins legacy `area_name` (kept as the first match for one minor version of compat). 8. Aliases — entity registry aliases are now folded into the BM25 corpus (one extra `config/entity_registry/get_entries` round-trip after the hidden filter), and area registry aliases are consulted in `get_entities_by_area`. Alias-driven matches surface as `match_type="alias_match"`. 9. Hidden-by filter — new `include_hidden: bool = False` parameter on `ha_search_entities`. Defaults skip entities where `entity_registry.hidden_by` is set (UI-hidden infra entities, diagnostic helpers); set True for diagnostics workflows. 10. Test coverage — new `TestSearchEntitiesSeededAreasIssue1170` class exercises the area_filter branches against a multi-domain populated area at the scale of the `tests/initial_test_state` seed, closing the gap noted in the triage. Tests - 5 new unit tests (`tests/src/unit/test_bm25_search.py::TestFuzzySearcherIssue1170`) - 8 new E2E tests (`tests/src/e2e/tools/test_search_entities.py`) - 1 new E2E test class with 3 test methods for finding 10 - 2 new regression tests for hidden_by filter in test_search_fallback.py - Updated test_search_fallback / test_search_pagination to drop references to the deleted `_partial_results_search`. Public API impact - Adds: `include_hidden` parameter; `area_names` field on area_only; `match_type="alias_match"` value. - Behavior change: `domain_filter` is normalized; hidden entities require explicit opt-in; area_only now includes `score`+`match_type`; `area_filtered_query` per-result `area_filter` echo dropped; `search_type="partial_listing"` no longer returned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address pr-review-toolkit findings - Propagate get_states() exceptions in 3 fetch sites instead of silently emptying — auth/connection errors now surface rather than being masked as "zero matches" with success=true. - Fix alias_match label: subtract entity_id+friendly_name tokens from alias_hit so a query token present in BOTH name and alias doesn't mislabel as alias_match. - Move alias batch-fetch out of get_entities_by_area into tools_search.py's area+query branch — get_entities_by_area is exposed via server.py, so injecting `_aliases` was leaking an internal field through any caller round-tripping the response. - Tighten alias-fetch except clause to (KeyError, TypeError, AttributeError) so unexpected errors propagate while malformed payloads still degrade gracefully. - Sort area_filtered_query iteration order to match area_only. - Emit `area_names: []` on the empty-area-match branch for response-shape symmetry with the populated branch. - Document case-insensitive domain_filter in the Field description. - Tighten _strip_separators docstring + _exact_match_search docstring (no longer pure — also queries entity registry). - Soften "preserved for one minor version" comment to "kept for backward compatibility" — no removal deadline is committed. - Add unit test: alias-vs-friendly_name precedence (when query token matches both, name match wins, not alias_match). - Add e2e test: fuzzy-mode hidden filter (the existing test only exercised exact_match=True; fuzzy is a separate code path). - Add e2e test: area_only branch hidden filter + include_hidden=true opt-in. - Add e2e test: total_matches in area_only aggregates across all matched areas (locks down finding-7 fix at the pagination metadata layer). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: address gemini docstring action-verb feedback Three docstrings now lead with an approved action verb per .gemini/styleguide.md and AGENTS.md: - smart_search.py:smart_entity_search — "Advanced entity search" → "Search entities..." - tools_search.py:_exact_match_search — "Substring search across..." → "Search entities by substring..." - fuzzy_search.py:_strip_separators — "Lowercase ``text``..." → "Strip ``.``, ``_``, ``-``..." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address pr-review-toolkit review findings - Add `except ToolError: raise` guard in fuzzy fallback wrapper so auth/connection failures from the service layer propagate instead of being silently retried via _exact_match_search. - Lowercase domain_filter at the service-layer boundary too, so internal callers of SmartSearchTools.smart_entity_search get the same normalization the tool layer applies. - Log alias-enrichment failures with a structured `alias_enrichment_failed` prefix and survivor count, including the previously-silent case where send_websocket_message returns success=False. - Strip task-shaped `(#1170 finding N)` / `(closes #1166)` parentheticals from production-code comments and docstrings; technical why prose stays. - Drop two paraphrasing comments that restated the code below them. - Tests: tighten single-token typo assertion to require a `light.*` result, lock down `partial_id` precedence over `alias_match` when the query token also lives in id/name, assert area_only ordering is deterministic across calls, propagation of get_states failures as ToolError, plus E2E coverage for area-registry alias resolution and separator-elided concat-token queries. - seeded_bedroom fixture cleanup wrapped in try/finally so a fixture- body failure (or pytest.skip) doesn't leak the seed area assignments. * fix: switch finding 9 to score-penalty (option c) Hidden entities now surface in results with a 20-point score penalty applied whenever hidden_by is non-None — the option (c) approach from issue #1170 finding 9, in line with the issue's explicit menu of choices. Visible matches sort above hidden ones at comparable raw scores; agents that need to see hidden infrastructure entities still get them, just lower in the list. - New apply_hidden_penalty(score, hidden_by) helper in fuzzy_search.py; wired through every branch that emits a score (BM25, typo_fallback, exact_match, area_only, area_filtered_query, domain_listing). - include_hidden default flips True → callers keep the explicit opt-out (False) for visible-only search. - get_entities_by_area carries _hidden_by through entity dicts so the area_filtered_query and area_only branches can apply the penalty without a second registry lookup. - area_only and domain_listing now sort the result list by score so visible matches outrank penalised hidden peers within the same area/domain. - Tests rewritten: test_search_excludes_hidden_by_default → includes-with- penalty; new TestHiddenScorePenalty class covers helper math, ordering, and zero-clamp boundary; existing include_hidden=True opt-in test becomes include_hidden=False filter assertion. * fix: address round-2 pr-review-toolkit findings Critical: - BM25 + typo_fallback now gate the threshold on the *raw* score and apply the hidden-score penalty only afterwards. Previously a hidden entity at raw threshold (60 default, 80 in the area+query searcher) was penalised below threshold and silently dropped — partially regressing to option (b) for borderline matches and breaking the option (c) "still surface, just rank lower" contract. Important: - server.py:get_entities_by_area bridge now strips internal leading-underscore fields (`_hidden_by`, `_aliases`) via the new strip_internal_fields helper so they don't leak to MCP clients. - All three coerce_bool_param calls in ha_search_entities now sit inside the try/except block. A bad string ("maybe") was previously raising ValueError that escaped the structured exception handler and surfaced as INTERNAL_ERROR. - _exact_match_search and the domain_listing branch now log hidden_filter_unavailable: when the registry/list call returns non-success, mirroring the alias_enrichment_failed: pattern. Without this an operator can't correlate "diagnostic entity ranking first" with a transient WS hiccup. Suggestions: - apply_hidden_penalty coerces score to int defensively so a stray float caller can't break the result-dict's int score contract. - New public_fields(d) helper centralises the "strip leading underscore" convention for non-mutating call sites; area_only branch now uses it. - Split the dense area_only multi-WHY comment into two focused ones. - Drop paraphrase first sentence at the area_filtered_query result builder; keep the why-comment about the dropped per-result field. Tests: - TestHiddenScorePenalty gains test_hidden_borderline_match_still_surfaces (locks down the threshold-on-raw-score fix) and test_hidden_typo_fallback_penalised (per-branch coverage for the typo_fallback path). - New E2E test_search_area_filtered_query_penalises_hidden_issue_1170 covers the area_filter+query path's _hidden_by plumbing through get_entities_by_area → entities_for_search → BM25. - New E2E test_search_domain_listing_penalises_hidden_issue_1170 covers the empty-query+domain_filter path's penalty + sort and the include_hidden=False filter. * fix: address stress-test findings + failing E2E Stress test against the user's real HA caught a domain_filter bug: " LIGHT " was returning 0 results silently because lowercase normalization didn't strip whitespace first. Now both the tool layer (ha_search_entities) and the service layer (smart_entity_search) strip+lowercase, mirroring what was already done case-wise. Failing E2E test fixes: - test_search_area_filtered_query_penalises_hidden_issue_1170 was timing out for two reasons: missing ha_config_set_helper entity_id fallback (could be None when HA returns helper_data.id instead), and the no-separator helper name produced a single huge BM25 token that the prefix-only query couldn't fuzzy-match. Now uses a distinctive token in a space-separated name so BM25 hits at score 100, and falls back to helper_data.id when entity_id is absent. - test_search_domain_listing_penalises_hidden_issue_1170 had the same entity_id-fallback bug; same fix. * fix: third-pass review findings (Gemini + review-toolkit) Gemini Code Assist: - Tool docstring opens with "Search" instead of "Find or list" (latter not on the approved-verb list). - Sort tie-breaker on entity_id wherever the sort key was score-only (fuzzy_search BM25 path, _exact_match_search, area_only, and the domain_listing scored_entities sort). Without a stable secondary key, paginated requests could shift the within-tier order between calls — common with the hidden-penalty banding (visible@100, hidden@80) and BM25's coarse buckets. Silent-failure findings: - Re-raise asyncio.CancelledError when it surfaces as a captured exception from gather(return_exceptions=True). Previously the `else` branch would log "hidden_filter_unavailable: ... CancelledError" and continue, leaving the canceller waiting. Three call sites: smart_entity_search, _exact_match_search, and the empty-query domain-listing path. - domain_filter / area_filter strip+lowercase now happens BEFORE the at-least-one-set validation. Previously a whitespace-only filter (" ") passed validation truthy then collapsed to "" and fell through to a silent zero-result fuzzy search. - strip_internal_fields now carries an _seen-set cycle guard so a future caller feeding it a non-tree structure gets a clean short-circuit instead of RecursionError. Comment cleanup: - Drop "option-c contract" task-shaped refs from fuzzy_search.py comments; the WHY prose stands alone without the triage label. - Tighten public_fields docstring: drop the parenthetical example (one call site at the moment) and document the shallow-copy contract so future callers don't trip on shared list values. Code-reviewer suggestions: - Drop the dead `query_lower` parameter from _typo_fallback (private method, no API to preserve). - area_filter zero-match echo now uses the canonical (stripped) form by virtue of normalisation moved to the tool entry point. Tests: - New TestStripInternalFields and TestPublicFields in test_util_helpers_internal_strip.py — pin the leak-guard contract end to end (recursion, cycles, non-string keys, mutation semantics). - New parametrised E2E test_domain_filter_whitespace_normalized (5 padded variants) locks down the strip step that the stress test caught was missing. - New test_domain_filter_whitespace_only_rejected pins the validation-order fix. - TestFuzzySearcherIssue1170::test_hidden_borderline_raw_score_threshold_edge constructs a single-token single-doc corpus that lands at exactly threshold=100 to lock down the post-gate penalty contract. - test_search_area_filtered_query_penalises_hidden_issue_1170 escape hatch removed: with the distinctive token now hitting BM25 at score 100, both helpers must surface unconditionally. * test: add coverage for round-4 review gaps Round-4 pr-review-toolkit final pass flagged three coverage gaps that the prior rounds didn't already close: 1. `TestFuzzySearcherIssue1170::test_score_ties_break_on_entity_id_ascending` pins the new `(-score, entity_id)` sort tuple. Without an order assertion, a regression that drops the secondary key (or flips its direction) silently shifts pagination between calls. 2. `TestFuzzySearcherIssue1170::test_cancelled_error_propagates_from_registry_gather` and `TestExactMatchSearchCancelledPropagation::test_cancelled_on_registry_task_propagates` lock down the new asyncio.CancelledError re-raise after gather(return_exceptions=True). Pre-fix the captured cancellation hit the `hidden_filter_unavailable:` log and the function continued — the canceller would wait forever. 3. `test_server_bridge_strip.test_get_entities_by_area_bridge_strips_internal_fields` exercises the public bridge end-to-end with a mock smart_tools that returns a dict carrying `_hidden_by` / `_aliases`. If a future refactor deletes the `strip_internal_fields(result)` line in the bridge, internal fields would leak to MCP clients with no signal in CI — this test catches that. * fix(tests): correct server class name in bridge strip test ImportError: HASmartMCPServer doesn't exist — the actual class is HomeAssistantSmartMCPServer. Drop the unused monkeypatch fixture while I'm in there. * fix(tests): set _smart_tools backing field, not the property smart_tools is a lazy-init property with no setter (server.py:218); the test was hitting 'object has no setter'. Set the underscored backing field directly so the property short-circuits to the fake. * fix: address 5 follow-up findings from stress-test 1. Exact area_id short-circuits fuzzy aggregation. A query like area_filter='bedroom_kids' was partial_ratio-matching its parent 'bedroom' (score=100, clears the 80 threshold) and aggregating sibling areas' entities. Now exact id/name/alias matches suppress the fuzzy step entirely; fuzzy only fires when no exact hit exists. 2. Single-token typo_fallback min-length gate. The coverage gate from finding 5 was multi-token-only by construction: 'lit' (3 chars) still surfaced every '*_lite*' entity at score 85 via partial overlap. Short single-token queries now skip the fallback entirely; 4+ chars (typical real typos like 'ligth'→'light') are unaffected. 3. Result shape parity. fuzzy_search emitted an 'essential_attributes' dict that the other four branches (exact_match, area_only, area_filtered_query, domain_listing) never carried — a shape asymmetry that issue #1170 finding 3 didn't address. Dropped from fuzzy_search for consistency; callers needing full state should follow up with ha_get_state. 4. Validation errors no longer carry misleading generic suggestions. A 'limit=0' input would surface as VALIDATION_FAILED with message 'limit must be at least 1, got 0' but suggestions like 'Check Home Assistant connection' — boilerplate from the generic exception handler. ValueError from coerce_*_param is now caught separately and surfaced via create_validation_error with the helper's own message and no operational suggestions. 5. area_filter+domain_filter zero-overlap now emits a message. When areas resolve but the domain_filter wipes out every entity in them, the response carries 'No <domain> entities found in area: <area>' instead of returning total_matches=0 silently. Test coverage: - TestFuzzySearcherIssue1170 gets test_typo_fallback_short_single_token_returns_empty and test_typo_fallback_four_char_single_token_still_fires for finding 2. - New E2E test_exact_area_id_short_circuits_fuzzy_aggregation pins finding 1 against the two_areas_with_shared_prefix fixture. - New E2E test_result_shape_consistent_across_branches loops all 5 search_types and asserts base-keys-present plus essential_attributes-absent. - New E2E test_validation_error_carries_no_generic_suggestions hits limit=0 and asserts the leaker strings are absent. - New E2E test_area_filter_with_domain_filter_zero_overlap_has_message hits area_filter=kitchen + domain_filter=zone (never assigned per-area) and asserts the new message field. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2fbd6a8 commit 5076c1d

11 files changed

Lines changed: 2778 additions & 348 deletions

src/ha_mcp/server.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from .config import _PACKAGE_VERSION, get_global_settings
2323
from .tools.enhanced import EnhancedToolsMixin
24+
from .tools.util_helpers import strip_internal_fields
2425
from .transforms import DEFAULT_PINNED_TOOLS
2526

2627
if TYPE_CHECKING:
@@ -930,13 +931,19 @@ async def call_service(
930931
return await self.client.call_service(domain, service, service_data)
931932

932933
async def get_entities_by_area(self, area_name: str) -> dict[str, Any]:
933-
"""Bridge method to existing area functionality."""
934-
return cast(
935-
dict[str, Any],
936-
await self.smart_tools.get_entities_by_area(
937-
area_query=area_name, group_by_domain=True
938-
),
934+
"""Bridge method to existing area functionality.
935+
936+
``smart_tools.get_entities_by_area`` enriches per-entity dicts
937+
with leading-underscore internals (``_hidden_by`` etc.) so
938+
downstream search branches can apply the score penalty without
939+
a second registry lookup. Strip them here so this public bridge
940+
doesn't leak internals to MCP clients.
941+
"""
942+
result = await self.smart_tools.get_entities_by_area(
943+
area_query=area_name, group_by_domain=True
939944
)
945+
strip_internal_fields(result)
946+
return cast(dict[str, Any], result)
940947

941948
async def start(self) -> None:
942949
"""Start the Smart MCP server with async compatibility."""

src/ha_mcp/tools/smart_search.py

Lines changed: 192 additions & 32 deletions
Large diffs are not rendered by default.

src/ha_mcp/tools/tools_search.py

Lines changed: 386 additions & 152 deletions
Large diffs are not rendered by default.

src/ha_mcp/tools/util_helpers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,53 @@
2323
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
2424

2525

26+
def strip_internal_fields(obj: Any, _seen: set[int] | None = None) -> Any:
27+
"""Remove leading-underscore keys from ``obj`` and any nested dicts
28+
or lists in place.
29+
30+
The ha-mcp tool layer enriches entity / area dicts with internal
31+
fields like ``_hidden_by`` and ``_aliases`` so downstream branches
32+
can rank without re-querying the entity registry. Those keys must
33+
not leak through public tool returns: this helper centralises the
34+
convention so individual call sites don't have to remember to strip.
35+
36+
Mutates in place and returns the same reference for chaining. Cycle
37+
guard via ``_seen`` (id-tracked) keeps the recursion safe if a
38+
future caller ever feeds it a non-tree structure — JSON payloads
39+
don't, but the helper is now a generic utility (importable from
40+
``server.py``) so the protection is cheap insurance.
41+
"""
42+
if _seen is None:
43+
_seen = set()
44+
obj_id = id(obj)
45+
if obj_id in _seen:
46+
return obj
47+
if isinstance(obj, dict):
48+
_seen.add(obj_id)
49+
for key in [k for k in obj if isinstance(k, str) and k.startswith("_")]:
50+
obj.pop(key, None)
51+
for value in obj.values():
52+
strip_internal_fields(value, _seen)
53+
elif isinstance(obj, list):
54+
_seen.add(obj_id)
55+
for item in obj:
56+
strip_internal_fields(item, _seen)
57+
return obj
58+
59+
60+
def public_fields(d: dict[str, Any]) -> dict[str, Any]:
61+
"""Return a shallow copy of ``d`` with leading-underscore keys
62+
removed. Non-mutating counterpart to :func:`strip_internal_fields`.
63+
Shallow only — list/dict values are shared with the source, so a
64+
later mutation of those values would propagate.
65+
"""
66+
return {
67+
k: v
68+
for k, v in d.items()
69+
if not (isinstance(k, str) and k.startswith("_"))
70+
}
71+
72+
2673
def coerce_bool_param(
2774
value: bool | str | None,
2875
param_name: str = "parameter",

src/ha_mcp/utils/fuzzy_search.py

Lines changed: 162 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,43 @@
2525

2626
_SPLIT_RE = re.compile(r"[._\-\s]+")
2727

28+
# Score subtraction for entities marked ``hidden_by`` in the entity
29+
# registry. Hidden entities still surface in results, but a 20-point
30+
# penalty pushes them below comparable visible matches when both are
31+
# emitted by the same search branch. Picked so that an exact id/name
32+
# hit on a hidden entity (raw 100 → 80) still ranks above a fuzzy
33+
# threshold-floor visible match (60-70), but loses to any visible
34+
# entity scoring 80+.
35+
HIDDEN_SCORE_PENALTY = 20
36+
37+
38+
def apply_hidden_penalty(score: int, hidden_by: Any) -> int:
39+
"""Return ``score`` reduced by :data:`HIDDEN_SCORE_PENALTY` when
40+
``hidden_by`` indicates a hidden entity. Used by every search
41+
branch that emits a ``score`` field so the ranking is consistent.
42+
Coerces ``score`` to ``int`` so a stray float caller can't break
43+
the result-dict's int contract for ``score``.
44+
"""
45+
s = int(score)
46+
if hidden_by is not None:
47+
return max(0, s - HIDDEN_SCORE_PENALTY)
48+
return s
49+
2850

2951
def tokenize(text: str) -> list[str]:
3052
"""Split text on `.`, `_`, `-`, and whitespace, lowercase, drop empties."""
3153
return [t for t in _SPLIT_RE.split(text.lower()) if t]
3254

3355

56+
def _strip_separators(text: str) -> str:
57+
"""Strip ``.``, ``_``, ``-``, whitespace from *text* and lowercase.
58+
59+
Used to add elided-separator forms to the BM25 corpus so queries
60+
like ``bedlight`` match tokens like ``bed_light``.
61+
"""
62+
return _SPLIT_RE.sub("", text.lower())
63+
64+
3465
# ---------------------------------------------------------------------------
3566
# BM25 scorer – lightweight, zero-dependency
3667
# ---------------------------------------------------------------------------
@@ -163,8 +194,16 @@ def search_entities(
163194
return [], 0
164195

165196
# Build per-entity document: tokens from entity_id + friendly_name
197+
# + entity registry aliases (when callers enrich entities with the
198+
# ``_aliases`` key — see smart_search.smart_entity_search).
166199
docs: list[list[str]] = []
167200
meta: list[tuple[str, str, str, dict[str, Any], str]] = [] # eid, name, domain, attrs, state
201+
# Track which entities matched on alias (for `match_type="alias_match"`).
202+
alias_hit: list[set[str]] = []
203+
# Track ``hidden_by`` per entity so the score-penalty pass can
204+
# depress hidden hits without filtering them. Callers enrich via
205+
# the ``_hidden_by`` key — see smart_search.smart_entity_search.
206+
hidden_flags: list[Any] = []
168207

169208
for entity in entities:
170209
entity_id = entity.get("entity_id", "")
@@ -173,9 +212,48 @@ def search_entities(
173212
domain = entity_id.split(".")[0] if "." in entity_id else ""
174213
state = entity.get("state", "unknown")
175214

176-
tokens = tokenize(entity_id) + tokenize(friendly_name)
215+
id_tokens = tokenize(entity_id)
216+
name_tokens = tokenize(friendly_name)
217+
tokens = list(id_tokens + name_tokens)
218+
219+
# Separator-stripped forms (concat tokens) so queries that
220+
# elide separators match — e.g. `bedlight` finds `light.bed_light`.
221+
tail = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
222+
tail_concat = _strip_separators(tail)
223+
if tail_concat:
224+
tokens.append(tail_concat)
225+
name_concat = _strip_separators(friendly_name)
226+
if name_concat and name_concat != tail_concat:
227+
tokens.append(name_concat)
228+
229+
# Aliases (entity registry). Each alias contributes both its
230+
# tokenized form and its separator-stripped concat. We track
231+
# only the alias tokens that *aren't* already in id+name —
232+
# otherwise a query like `bed` would mislabel a friendly_name
233+
# match as `alias_match` whenever the entity also has a
234+
# `bed`-containing alias.
235+
id_name_tokens = set(id_tokens) | set(name_tokens)
236+
id_name_tokens.add(tail_concat)
237+
id_name_tokens.add(name_concat)
238+
entity_alias_tokens: set[str] = set()
239+
for alias in entity.get("_aliases", []) or []:
240+
if not isinstance(alias, str):
241+
continue
242+
a_tokens = tokenize(alias)
243+
tokens.extend(a_tokens)
244+
for t in a_tokens:
245+
if t not in id_name_tokens:
246+
entity_alias_tokens.add(t)
247+
a_concat = _strip_separators(alias)
248+
if a_concat:
249+
tokens.append(a_concat)
250+
if a_concat not in id_name_tokens:
251+
entity_alias_tokens.add(a_concat)
252+
177253
docs.append(tokens)
178254
meta.append((entity_id, friendly_name, domain, attributes, state))
255+
alias_hit.append(entity_alias_tokens)
256+
hidden_flags.append(entity.get("_hidden_by"))
179257

180258
# Fit BM25
181259
scorer = BM25Scorer()
@@ -190,21 +268,39 @@ def search_entities(
190268
matches: list[dict[str, Any]] = []
191269

192270
if theoretical_max > 0:
271+
query_token_set = set(query_tokens)
193272
for i, raw in enumerate(raw_scores):
194273
if raw <= 0:
195274
continue
196-
score = min(100, round(raw / theoretical_max * 100))
197-
if score < self.threshold:
275+
# Threshold gates the *raw* match quality: a hidden
276+
# entity that genuinely matches at threshold shouldn't
277+
# get penalised below it and silently disappear.
278+
# Penalty is applied only after the gate, so it affects
279+
# ranking but not visibility.
280+
raw_score = min(100, round(raw / theoretical_max * 100))
281+
if raw_score < self.threshold:
198282
continue
283+
score = apply_hidden_penalty(raw_score, hidden_flags[i])
199284
eid, fname, domain, attrs, state = meta[i]
285+
# If any query token matched only on the alias haystack,
286+
# surface that to the caller via match_type — useful both
287+
# for telemetry and for the agent to know the friendly_name
288+
# alone wouldn't have led it here.
289+
hit_alias_tokens = query_token_set & alias_hit[i]
290+
if hit_alias_tokens:
291+
match_type = "alias_match"
292+
else:
293+
match_type = self._get_match_type(
294+
eid, fname, domain, query_lower
295+
)
200296
matches.append({
201297
"entity_id": eid,
202298
"friendly_name": fname,
203299
"domain": domain,
204300
"state": state,
205301
"attributes": attrs,
206302
"score": score,
207-
"match_type": self._get_match_type(eid, fname, domain, query_lower),
303+
"match_type": match_type,
208304
})
209305

210306
# Tier-3 fallback: token-level SequenceMatcher only if BM25 scored
@@ -214,9 +310,14 @@ def search_entities(
214310
# exactly the noise floor the new absolute normalization is fixing.
215311
bm25_found_any = any(raw > 0 for raw in raw_scores)
216312
if not matches and not bm25_found_any:
217-
matches = self._typo_fallback(query_tokens, query_lower, docs, meta)
313+
matches = self._typo_fallback(
314+
query_tokens, docs, meta, hidden_flags
315+
)
218316

219-
matches.sort(key=lambda x: x["score"], reverse=True)
317+
# Tie-break on entity_id so paginated requests return stable
318+
# ordering when several entities share a score (common with the
319+
# hidden-penalty bands at 100/80 and BM25's coarse score buckets).
320+
matches.sort(key=lambda x: (-x["score"], x["entity_id"]))
220321
total_matches = len(matches)
221322
return matches[offset:offset + limit], total_matches
222323

@@ -225,30 +326,72 @@ def search_entities(
225326
def _typo_fallback(
226327
self,
227328
query_tokens: list[str],
228-
query_lower: str,
229329
docs: list[list[str]],
230330
meta: list[tuple[str, str, str, dict[str, Any], str]],
331+
hidden_flags: list[Any] | None = None,
231332
) -> list[dict[str, Any]]:
232-
"""Token-level SequenceMatcher fallback for typo correction."""
333+
"""Token-level SequenceMatcher fallback for typo correction.
334+
335+
For multi-token queries, additionally requires coverage:
336+
at least half of the distinct query tokens must each have *some*
337+
doc token they ratio-match. Without this, a single-token
338+
accidental hit (e.g. ``garbage`` ≈ ``garage``) is enough to
339+
surface unrelated entities at score 92 from a 3-token query
340+
whose other two tokens have no doc relationship.
341+
"""
233342
results: list[dict[str, Any]] = []
343+
distinct_query_tokens = list(dict.fromkeys(query_tokens))
344+
n_distinct = len(distinct_query_tokens)
345+
# Single-token min-length gate: short queries like ``lit`` (3
346+
# chars) match too generously via partial overlap (every
347+
# ``*_lite*`` entity surfaces at score 85). The multi-token
348+
# coverage gate above doesn't help here (n_distinct == 1).
349+
# Suppress the fallback entirely for short single-token
350+
# queries — they almost never represent a typo, and the BM25
351+
# path above already serves substring intent at a higher
352+
# score floor.
353+
if n_distinct == 1 and len(query_tokens[0]) < 4:
354+
return results
234355
for i, doc_tokens in enumerate(docs):
235356
best_token_score = 0
236357
for qt in query_tokens:
237358
for dt in doc_tokens:
238359
ratio = calculate_ratio(qt, dt)
239360
best_token_score = max(best_token_score, ratio)
240361

241-
if best_token_score >= 75: # stricter threshold for typo fallback
242-
eid, fname, domain, attrs, state = meta[i]
243-
results.append({
244-
"entity_id": eid,
245-
"friendly_name": fname,
246-
"domain": domain,
247-
"state": state,
248-
"attributes": attrs,
249-
"score": best_token_score,
250-
"match_type": "typo_fallback",
251-
})
362+
if best_token_score < 75: # stricter threshold for typo fallback
363+
continue
364+
365+
# Multi-token coverage gate: how many distinct query tokens
366+
# have any doc token within the typo-fallback threshold?
367+
# A 3-token nonsense query that only one token explains
368+
# (coverage 1/3) is rejected; a single-token query is always
369+
# fully covered so unaffected.
370+
if n_distinct > 1:
371+
covered = 0
372+
for qt in distinct_query_tokens:
373+
if any(calculate_ratio(qt, dt) >= 75 for dt in doc_tokens):
374+
covered += 1
375+
if covered * 2 < n_distinct: # < 50% coverage
376+
continue
377+
378+
eid, fname, domain, attrs, state = meta[i]
379+
entity_hidden = (
380+
hidden_flags[i] if hidden_flags is not None else None
381+
)
382+
# Apply the hidden penalty after the threshold gate above
383+
# so borderline hidden matches still surface; the penalty
384+
# only re-ranks them.
385+
score = apply_hidden_penalty(best_token_score, entity_hidden)
386+
results.append({
387+
"entity_id": eid,
388+
"friendly_name": fname,
389+
"domain": domain,
390+
"state": state,
391+
"attributes": attrs,
392+
"score": score,
393+
"match_type": "typo_fallback",
394+
})
252395
return results
253396

254397
def _calculate_entity_score(

0 commit comments

Comments
 (0)