Commit 5076c1d
* 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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
24 | 25 | | |
25 | 26 | | |
26 | 27 | | |
| |||
930 | 931 | | |
931 | 932 | | |
932 | 933 | | |
933 | | - | |
934 | | - | |
935 | | - | |
936 | | - | |
937 | | - | |
938 | | - | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
939 | 944 | | |
| 945 | + | |
| 946 | + | |
940 | 947 | | |
941 | 948 | | |
942 | 949 | | |
| |||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
26 | 73 | | |
27 | 74 | | |
28 | 75 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
28 | 50 | | |
29 | 51 | | |
30 | 52 | | |
31 | 53 | | |
32 | 54 | | |
33 | 55 | | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
34 | 65 | | |
35 | 66 | | |
36 | 67 | | |
| |||
163 | 194 | | |
164 | 195 | | |
165 | 196 | | |
| 197 | + | |
| 198 | + | |
166 | 199 | | |
167 | 200 | | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
168 | 207 | | |
169 | 208 | | |
170 | 209 | | |
| |||
173 | 212 | | |
174 | 213 | | |
175 | 214 | | |
176 | | - | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
177 | 253 | | |
178 | 254 | | |
| 255 | + | |
| 256 | + | |
179 | 257 | | |
180 | 258 | | |
181 | 259 | | |
| |||
190 | 268 | | |
191 | 269 | | |
192 | 270 | | |
| 271 | + | |
193 | 272 | | |
194 | 273 | | |
195 | 274 | | |
196 | | - | |
197 | | - | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
198 | 282 | | |
| 283 | + | |
199 | 284 | | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
200 | 296 | | |
201 | 297 | | |
202 | 298 | | |
203 | 299 | | |
204 | 300 | | |
205 | 301 | | |
206 | 302 | | |
207 | | - | |
| 303 | + | |
208 | 304 | | |
209 | 305 | | |
210 | 306 | | |
| |||
214 | 310 | | |
215 | 311 | | |
216 | 312 | | |
217 | | - | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
218 | 316 | | |
219 | | - | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
220 | 321 | | |
221 | 322 | | |
222 | 323 | | |
| |||
225 | 326 | | |
226 | 327 | | |
227 | 328 | | |
228 | | - | |
229 | 329 | | |
230 | 330 | | |
| 331 | + | |
231 | 332 | | |
232 | | - | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
233 | 342 | | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
234 | 355 | | |
235 | 356 | | |
236 | 357 | | |
237 | 358 | | |
238 | 359 | | |
239 | 360 | | |
240 | 361 | | |
241 | | - | |
242 | | - | |
243 | | - | |
244 | | - | |
245 | | - | |
246 | | - | |
247 | | - | |
248 | | - | |
249 | | - | |
250 | | - | |
251 | | - | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
252 | 395 | | |
253 | 396 | | |
254 | 397 | | |
| |||
0 commit comments