Skip to content

Commit 4796072

Browse files
fix: dashboard search stays on legacy path without false partial (#2020)
* fix: Keep dashboard search_types off the component path and stop counting auto-generated dashboards as scan failures ha_search(search_types=["dashboard"]) was forwarded to the ha_mcp_tools component, whose search command has no dashboard surface — the request bounced off the component schema into a warning-laden legacy fallback on every call. Requests naming a surface the component lacks now stay on the legacy path silently, like the other route-ineligible modes; supported-type pins keep the component fast path. The legacy dashboard scan also counted auto-generated dashboards (never taken control, lovelace/config answers config_not_found) as failed scans, so a stock install reported partial: true with "N dashboard(s) not scanned". A config-less dashboard has nothing to scan and now reads as a clean no-match. Fixes #2008 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Only skip config-less dashboards, not unresolved ones; pin routing gate to component schema Review round: config_not_found is HA's code for BOTH "No config found." (auto-generated dashboard — clean skip) and "Unknown config specified" (dashboard deleted since the registry-list snapshot — a genuine gap), so the skip now matches the message too, via the canonical error extractors. A lockstep contract test pins the routing gate's allowlist to the real component schema and the public search_types vocabulary, since the silent legacy route removed the warning that would have exposed future drift. Also: seam test composing both fixes through ha_search, e2e assertions against the real component, and a stale client-envelope comment corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * chore: Remove accidentally committed review scratch file Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Serve ha_search's dashboard bucket from the component's in-process search The ha_mcp_tools component already answers a cross-dashboard search in one in-process frame, and ha_config_get_dashboard(mode="search") routes through it — ha_search's dashboard surface never did, fanning out one lovelace/config read per dashboard even with the component installed. The bucket now rides the component search for the exact-match, no-body shape (the default ha_search call), with the legacy walk kept for fuzzy scoring, include_config bodies, truncated frames, and every component- fallback case. YAML-mode dashboards, which the component deliberately never scans (their bodies can carry resolved !secret values), surface as an explicit partial reason instead of looking exhaustive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Use the registry row title in component-served dashboard records Live parity check: the component match only knows the config body's (often absent) title, so records fell back to the url_path where the legacy walk emits the registry row's title. The rows are already fetched for the YAML exclusion count — join them for the title. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Whole-document dashboard search verdicts from the component (1.3.0) Codex review round on the component wiring: the card-scoped matches walk narrowed ha_search's dashboard coverage (a view-title-only hit vanished), the yaml exclusion count missed a YAML-mode default dashboard (no list row), and fail-soft load errors made unreadable dashboards look clean. The component's search mode now also emits document_matches (a per- dashboard whole-config substring verdict, ported leaf-for-leaf from the server's _search_in_dict), yaml_skipped counted in-process over the full map, and load_failed with ConfigNotFound kept a clean skip. The server requires the new dashboards_doc_search capability (component 1.3.0) and those keys, or falls back to the full legacy walk — a pre-1.3.0 component never narrows coverage. Registry titles ride a separate doc key so MODE 4 match-record parity keeps the body title. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Align capability/version pins and drop an unused type-ignore CI round: the info drift guard and manifest parity test pin the literal capability list and version — updated for dashboards_doc_search / 1.3.0. The guarded ConfigNotFound import resolves to Any under mypy's homeassistant override, so the assignment ignore was unused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Component version 1.2.4, not 1.3.0 The dashboard search coverage was supposed to be there all along, so this is a patch on the pending version line, not a new-feature minor. Capability negotiation (dashboards_doc_search), never the version, gates the server routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Label the default dashboard "Default Dashboard" on the component path Codex round: the legacy walk always emits ("default", "Default Dashboard") for the built-in dashboard; a taken-control default can carry its own title, which the component-served record must not leak into the envelope — record-shape parity between the two paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Single-assignment title selection to satisfy mypy narrowing The branch-assigned default-title fix typed `title` as str in the first branch and Any | None in the second ([assignment] under strict mypy). One conditional expression keeps the inferred type consistent; an empty component title now falls back to the url_path, which reads better than an empty label anyway. Also records the pending-version evidence (stable v7.14.2 ships 1.2.3) on the version pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * test: Drop literal component-version pins The lockstep invariant (manifest == COMPONENT_VERSION) is the test; literal pins only forced churn on every bump and bred release-cycle narration in comments that rotted the moment the next stable shipped (the "Pending 1.2.3" claim that misled a review round). Bump rules live in AGENTS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * test: Restore the literal version pin, single-sited with a timeless rationale The literal is protection, not churn: a wholesale accidental downgrade reverts manifest and const together, passing pure lockstep — only the pin catches it, and it keeps every version change a conscious, review-visible edit. One site (the parity test) carries it; the duplicate pin stays removed, and the comment states the invariant without narrating stable/pending state that rots between releases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * ci: Guard the release-cycle version invariant at PR time and release time A PR that changes the component while its version equals the mirror's released stable would merge changes onto a shipped version: the stable tag step skips existing tags and dev pre-release tags sort below the equal stable, so the changes silently reach nobody. Two layers close it: a PR-level Component Version Gate (fires only on a provable component diff, fails open with a warning when the base or mirror API is unreadable) and a loud release-time backstop — the mirror's stable tag step now diffs an already-tagged version against the snapshot and fails when content drifted, which also covers the race a PR check cannot see (stable shipping while a PR sits idle re-runs no checks). The idempotent silent skip stays for identical content (hotfix without component changes). AGENTS.md documents the enforcement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * ci: Version gate compares strictly, not equality-only Codex round: a manifest version BEHIND the released stable (stale tree or bad merge resurrecting an old version) passed the equality check and printed "ahead", deferring the failure to release time. sort -V ordering now yields ahead/equal/behind verdicts with distinct failure messages; only strictly-ahead passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * fix: Fall back to the legacy walk on YAML-bearing installs Patch76 review: the YAML exclusion made the component path's default call shape strictly narrower than the legacy walk it replaces — the legacy walk reads YAML dashboard bodies (as the fuzzy and include_config routes still do), so a YAML-bearing install got a permanently-partial response missing YAML-body matches. yaml_skipped>0 now discards the component frame and runs the legacy walk: coverage is identical to pre-component behaviour regardless of path, the false partial disappears, and storage-only installs keep the single-frame win. The yaml partial fragment and its threading are gone (the signal became the fallback trigger). Also fixes the fetch_dashboards_list docstring that claimed the component list serves storage-only rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB * chore: empty commit to re-dispatch GitHub's default-setup CodeQL checks The Analyze pair died at runner level during a GitHub Actions incident and dynamic runs cannot be re-run or re-requested for an existing SHA. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxGQhYBQg7681uCdr9w9qB --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 68d44ed commit 4796072

13 files changed

Lines changed: 1220 additions & 30 deletions

.github/workflows/pr.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,68 @@ jobs:
2727
# DO matter and always count as code. On non-PR events (manual dispatch)
2828
# always run. Consumed by e2e-validation's `if:` below — a job-level skip
2929
# reports Success to a required check (a workflow path-skip would not).
30+
# PR-time guard for the release-cycle version invariant (AGENTS.md "Version
31+
# bumps ride the stable release cycle"): a PR that changes the component
32+
# while the manifest version EQUALS the mirror's latest stable release would
33+
# merge changes onto an already-shipped version — the mirror's stable tag
34+
# step skips existing tags and v<VER>-dev.N pre-release tags sort BELOW the
35+
# equal stable tag, so the changes would silently reach nobody. Level with
36+
# stable means: bump once to open the pending version. The gate fires only
37+
# on a provable component diff and fails open (with a warning) when the base
38+
# or the mirror API cannot be read — the mirror sync's stable tag step
39+
# carries the loud release-time backstop for what this check cannot see
40+
# (stable shipping while the PR sits idle re-runs no PR checks).
41+
component-version-gate:
42+
name: Component Version Gate
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 5
45+
if: github.event_name == 'pull_request'
46+
steps:
47+
- uses: actions/checkout@v7
48+
- name: Fail component changes riding an already-released version
49+
env:
50+
BASE_REF: ${{ github.base_ref }}
51+
GH_TOKEN: ${{ github.token }}
52+
run: |
53+
if ! git fetch --depth=1 origin "$BASE_REF" \
54+
|| ! base_sha=$(git rev-parse "origin/$BASE_REF"); then
55+
echo "::warning::could not resolve the base ref - skipping the version gate (the release-time backstop still applies)"
56+
exit 0
57+
fi
58+
if git diff --quiet "$base_sha" HEAD -- custom_components/ha_mcp_tools; then
59+
echo "no component changes - gate not applicable"
60+
exit 0
61+
fi
62+
VER=$(python3 -c "import json; print(json.load(open('custom_components/ha_mcp_tools/manifest.json'))['version'])")
63+
if ! latest=$(gh api repos/homeassistant-ai/ha-mcp-integration/releases/latest --jq .tag_name); then
64+
echo "::warning::could not read the mirror's latest stable release - skipping the version gate (the release-time backstop still applies)"
65+
exit 0
66+
fi
67+
stable="${latest#v}"
68+
echo "component changed; manifest=${VER} released-stable=${stable}"
69+
# Strict version comparison, not equality: a version BEHIND the
70+
# released stable (a stale tree or bad merge resurrecting an old
71+
# manifest) must fail here too, not slip through to a late
72+
# release-time failure. sort -V gives semver ordering; both inputs
73+
# come from our own manifest / release automation.
74+
if [ "$VER" = "$stable" ]; then
75+
verdict=equal
76+
elif [ "$(printf '%s\n%s\n' "$stable" "$VER" | sort -V | tail -1)" = "$VER" ]; then
77+
verdict=ahead
78+
else
79+
verdict=behind
80+
fi
81+
case "$verdict" in
82+
ahead)
83+
echo "version gate ok: pending ${VER} is strictly ahead of released ${stable}" ;;
84+
equal)
85+
echo "::error::This PR changes custom_components/ha_mcp_tools but keeps the component version at ${VER}, which is already released as the mirror's latest stable - merged this way the changes never ship (the stable tag step skips existing tags; dev pre-release tags sort below the equal stable). Master is level with stable: bump manifest.json + const.py (and the parity-test literal) once to open the next pending version, per AGENTS.md 'Version bumps ride the stable release cycle'."
86+
exit 1 ;;
87+
behind)
88+
echo "::error::This PR changes custom_components/ha_mcp_tools with version ${VER}, which is BEHIND the mirror's released stable ${stable} - a stale tree or bad merge resurrected an old version. Restore the version to lead the released stable (per AGENTS.md 'Version bumps ride the stable release cycle')."
89+
exit 1 ;;
90+
esac
91+
3092
changes:
3193
name: Detect relevant changes
3294
runs-on: ubuntu-latest

.github/workflows/sync-integration-mirror.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,28 @@ jobs:
268268
# aborts loud instead.
269269
existing=$(git -C /tmp/mirror ls-remote --tags origin "refs/tags/v${VER}")
270270
if [ -n "$existing" ]; then
271-
echo "mirror tag v${VER} exists"
272-
exit 0
271+
# Stranded-changes backstop (issue #2008 review follow-up): the
272+
# idempotent skip is harmless ONLY when the already-tagged content
273+
# matches the current snapshot (a hotfix without component
274+
# changes). If master took component changes WITHOUT a version
275+
# bump after v${VER} went stable — e.g. a PR opened while the
276+
# version was still pending merged after the stable cut — a
277+
# silent skip here strands them: no mirror release ever carries
278+
# them, and v${VER}-dev.N pre-release tags sort BELOW the equal
279+
# stable tag so even pre-release users never see them. Fail loud
280+
# with the bump instruction instead. Tag-fetch failure keeps the
281+
# old silent skip (a network blip must not fail the release) but
282+
# says so.
283+
if ! git -C /tmp/mirror fetch --quiet --depth=1 origin "refs/tags/v${VER}:refs/tags/v${VER}"; then
284+
echo "::warning::mirror tag v${VER} exists but could not be fetched for the stranded-changes check - skipping the stable tag as before"
285+
exit 0
286+
fi
287+
if git -C /tmp/mirror diff --quiet "v${VER}" HEAD -- custom_components; then
288+
echo "mirror tag v${VER} exists and the component snapshot is identical - nothing new to release"
289+
exit 0
290+
fi
291+
echo "::error::mirror tag v${VER} already exists but the current component snapshot DIFFERS from its content - master took component changes without a version bump after v${VER} went stable; skipping would strand them with no installable release. Bump manifest.json + const.py (and the parity-test literal) to open the next pending version per AGENTS.md 'Version bumps ride the stable release cycle', then let the next stable carry the stranded changes."
292+
exit 1
273293
fi
274294
python3 scripts/build_mirror_release_notes.py \
275295
--component-version "$VER" \

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,15 @@ fully validate a component change before merge.
674674
go straight to that minor, not an extra patch. Never go past the current
675675
pending version otherwise; per-revision bumps skip never-shipped numbers and
676676
desync the version from the release cycle.
677+
- CI enforces the level-with-stable case twice: the PR-level **Component
678+
Version Gate** fails a component change whose manifest version does not
679+
strictly lead the mirror's released stable (equal = bump to open the
680+
pending version; behind = a stale tree or bad merge resurrected an old
681+
version), and the mirror sync's stable tag step fails loud
682+
when an already-tagged version's component content has drifted (changes
683+
merged onto a shipped version would otherwise strand with no installable
684+
release — the gap is a PR opened while a version is pending that merges
685+
only after that version goes stable, which re-runs no PR checks).
677686
- **When the change adds a service or argument the server depends on**, this PR
678687
must **open a fresh pending component version** (bump `manifest.json` +
679688
`COMPONENT_VERSION`) and raise `MIN_COMPONENT_VERSION` in

custom_components/ha_mcp_tools/websocket_api.py

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,13 @@
357357
"backup_prep",
358358
"registries",
359359
"dashboards",
360+
# A flag, not a standalone command: gates the additive whole-document
361+
# search-result keys on ``ha_mcp_tools/dashboards`` mode=search
362+
# (``document_matches`` + ``yaml_skipped`` + ``load_failed``, issue #2008).
363+
# The server's ha_search dashboard bucket routes through the component only
364+
# when this is advertised — an older component without the keys would
365+
# silently narrow coverage to the card-scoped walk and hide load failures.
366+
"dashboards_doc_search",
360367
"services_list",
361368
"reference_data",
362369
# A flag, not a standalone command: gates the optional ``visibility`` param
@@ -4086,6 +4093,15 @@ def _do_dashboards(
40864093
"available": True,
40874094
"matches": matches,
40884095
"truncated": truncated,
4096+
# ``dashboards_doc_search`` additions (issue #2008): the whole-
4097+
# document per-dashboard verdicts + honesty counters the server's
4098+
# ha_search dashboard bucket needs. Additive — a pre-#2008 server
4099+
# ignores them.
4100+
"document_matches": _dashboard_document_matches(
4101+
prepped.get("docs") or [], query_lower
4102+
),
4103+
"yaml_skipped": prepped.get("yaml_skipped", 0),
4104+
"load_failed": prepped.get("load_failed", 0),
40894105
}
40904106
return {"mode": "list", "available": True, "dashboards": prepped.get("rows") or []}
40914107

@@ -4108,7 +4124,11 @@ async def _dashboards_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str
41084124
if mode == "get":
41094125
prepped.update(await _dashboard_get_config(dashboards_map, msg.get("url_path")))
41104126
elif mode == "search":
4111-
prepped["docs"] = await _dashboard_search_docs(dashboards_map)
4127+
(
4128+
prepped["docs"],
4129+
prepped["yaml_skipped"],
4130+
prepped["load_failed"],
4131+
) = await _dashboard_search_docs(dashboards_map)
41124132
else:
41134133
prepped["rows"] = _dashboard_list_rows(dashboards_map)
41144134
return {"prepped": prepped}
@@ -4198,35 +4218,72 @@ async def _dashboard_get_config(
41984218

41994219
async def _dashboard_search_docs(
42004220
dashboards_map: Mapping[Any, Any],
4201-
) -> list[dict[str, Any]]:
4221+
) -> tuple[list[dict[str, Any]], int, int]:
42024222
"""Load every STORAGE dashboard's config for the ``search`` walk.
42034223
42044224
Only storage dashboards are loaded — YAML bodies are never searched/emitted.
4205-
A per-dashboard load error is skipped (fail-soft) rather than failing the
4206-
whole search. Returns ``[{url_path, title, config}, ...]`` plain dicts.
4225+
Returns ``(docs, yaml_skipped, load_failed)``: ``docs`` are
4226+
``[{url_path, title, registry_title, config}, ...]`` plain dicts —
4227+
``title`` stays the config body's (the card-scoped ``matches`` records pin
4228+
byte parity with the server's legacy MODE 4 walk on it) while the additive
4229+
``registry_title`` carries the list-row metadata title that
4230+
``document_matches`` emits (what the legacy ha_search bucket records
4231+
carry); ``yaml_skipped`` counts
4232+
the YAML-mode entries this walk never reads, INCLUDING a default dashboard
4233+
forced to YAML (``lovelace: mode: yaml``), which has no ``list`` row for
4234+
the server to count — the server treats a non-zero count as its
4235+
fall-back-to-legacy signal, since the legacy walk DOES read YAML bodies
4236+
and coverage must not depend on which path served (issue #2008 review);
4237+
``load_failed`` counts storage
4238+
dashboards whose config load raised or returned a non-dict — real gaps the
4239+
caller must surface as partial rather than fail-soft into a clean-looking
4240+
result. A ``ConfigNotFound`` load is a clean skip, not a failure: an
4241+
auto-generated (never taken control of) dashboard has no stored config to
4242+
scan. If core drift breaks the guarded ``ConfigNotFound`` import, those
4243+
loads degrade to ``load_failed`` — over-reported as partial, never silent.
42074244
"""
4245+
try:
4246+
from homeassistant.components.lovelace.const import ConfigNotFound
4247+
except Exception: # pragma: no cover - defensive; core drift
4248+
ConfigNotFound = None
4249+
42084250
docs: list[dict[str, Any]] = []
4251+
yaml_skipped = 0
4252+
load_failed = 0
42094253
for url_path, dash in dashboards_map.items():
4254+
if _dashboard_mode(dash) == _LOVELACE_MODE_YAML:
4255+
yaml_skipped += 1
4256+
continue
42104257
if _dashboard_mode(dash) != _LOVELACE_MODE_STORAGE:
42114258
continue
42124259
loader = getattr(dash, "async_load", None)
42134260
if not callable(loader):
42144261
continue
42154262
try:
42164263
config = await loader(False)
4217-
except Exception: # skip an unreadable dashboard, keep going (fail-soft)
4264+
except Exception as err:
4265+
if ConfigNotFound is not None and isinstance(err, ConfigNotFound):
4266+
# Auto-generated dashboard: nothing stored, nothing to scan.
4267+
continue
4268+
load_failed += 1
42184269
continue
42194270
if not isinstance(config, dict):
4271+
load_failed += 1
42204272
continue
4273+
meta = getattr(dash, "config", None)
4274+
registry_title = meta.get("title") if isinstance(meta, Mapping) else None
42214275
title = config.get("title")
42224276
docs.append(
42234277
{
42244278
"url_path": url_path,
42254279
"title": str(title) if title is not None else None,
4280+
"registry_title": (
4281+
str(registry_title) if registry_title is not None else None
4282+
),
42264283
"config": config,
42274284
}
42284285
)
4229-
return docs
4286+
return docs, yaml_skipped, load_failed
42304287

42314288

42324289
def _dashboard_mode(dash: Any) -> str | None:
@@ -4235,6 +4292,52 @@ def _dashboard_mode(dash: Any) -> str | None:
42354292
return str(mode) if isinstance(mode, str) else None
42364293

42374294

4295+
def _dashboard_document_matches(
4296+
docs: list[dict[str, Any]], query_lower: str
4297+
) -> list[dict[str, Any]]:
4298+
"""Per-dashboard whole-document verdicts: ``[{url_path, title}, ...]``.
4299+
4300+
One entry per doc whose ENTIRE config contains ``query_lower`` — the
4301+
coverage the server's legacy ``_search_in_dict`` walk provides (view
4302+
titles, dashboard-level keys, every leaf), which the card-scoped
4303+
``matches`` walk deliberately narrows to. ``title`` is the registry
4304+
metadata's (falling back to the body's) — what the legacy ha_search
4305+
bucket records carry. An empty query matches nothing. Bounded by the
4306+
dashboard count, so no cap/truncation applies.
4307+
"""
4308+
if not query_lower:
4309+
return []
4310+
return [
4311+
{
4312+
"url_path": doc.get("url_path"),
4313+
"title": doc.get("registry_title") or doc.get("title"),
4314+
}
4315+
for doc in docs
4316+
if _doc_contains(doc.get("config"), query_lower)
4317+
]
4318+
4319+
4320+
def _doc_contains(data: Any, query_lower: str) -> bool:
4321+
"""Case-insensitive substring test over keys and every leaf of a config.
4322+
4323+
Exact port of the server's ``_search_in_dict_exact`` (keys + string
4324+
leaves + ``str()`` of non-None scalars) so the component-served verdict
4325+
matches the legacy walk's, leaf for leaf.
4326+
"""
4327+
if isinstance(data, dict):
4328+
return any(
4329+
query_lower in str(key).lower() or _doc_contains(value, query_lower)
4330+
for key, value in data.items()
4331+
)
4332+
if isinstance(data, list):
4333+
return any(_doc_contains(item, query_lower) for item in data)
4334+
if isinstance(data, str):
4335+
return query_lower in data.lower()
4336+
if data is not None:
4337+
return query_lower in str(data).lower()
4338+
return False
4339+
4340+
42384341
def _search_dashboard_docs(
42394342
docs: list[dict[str, Any]], query_lower: str
42404343
) -> tuple[list[dict[str, Any]], bool]:

0 commit comments

Comments
 (0)