fix(agent): full-catalog dashboard search and working LLM retry

- search_dashboards: call /api/dashboards with page_context=other and
  page_size=100 so the profile 'My Dashboards Only' filter can no longer
  hide the whole catalog; parse available_total/effective_profile_filter
  and report hidden-by-filter instead of a false 'no dashboards' answer
- prefetch_dashboards: same full-catalog context; fix dead code where
  data=resp.json() sat after return '' inside the error branch, making
  every 200 response raise NameError and the prefetch always return ''
- llm-status: ?force=1 bypasses the 30s health cache so the 'Retry now'
  button performs a fresh probe instead of re-reading the stale status;
  frontend keeps a single retry interval (previously stacked intervals
  decayed the countdown faster than 1/s and fired duplicate probes)
- tests: agent tool/prefetch, backend route bypass + force param,
  frontend retry/force coverage
This commit is contained in:
2026-08-19 19:21:00 +03:00
parent 615f3ccd25
commit e291ba757f
11 changed files with 396 additions and 35 deletions

View File

@@ -206,30 +206,34 @@ async def prefetch_dashboards(env_id: str) -> str:
client = get_shared_http_client(timeout=10)
resp = await client.get(
f"{FASTAPI_URL}/api/dashboards",
params={"q": "", "env_id": env_id or ""},
# Full-catalog context: page_context=other disables the "My Dashboards Only"
# profile filter, so prefetch reflects the whole environment instead of the
# user's filtered view (which would report "No dashboards found." for every
# dashboard without matching owner metadata). page_size=100 avoids truncation.
params={"q": "", "env_id": env_id or "", "page_context": "other", "page_size": 100},
headers=_dual_auth_headers(),
)
if resp.status_code != 200:
return ""
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = _PREFETCH_LIMIT
total = len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
data = resp.json()
dashboards = data.get("dashboards", [])
if not dashboards:
return "No dashboards found."
limit = _PREFETCH_LIMIT
total = data.get("total") or len(dashboards)
lines = []
for db in dashboards[:limit]:
title = db.get("title", "Untitled")
dashboard_id = db.get("id") or db.get("dashboard_id")
modified = (db.get("last_modified", "") or "")[:10]
if modified:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
else:
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
suffix = ""
if total > limit:
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
except Exception as e:
logger.explore("Prefetch dashboards failed", payload={"env_id": env_id}, error=str(e), extra={"src": "AgentChat.Persistence.PrefetchDashboards"})
return ""

View File

@@ -367,13 +367,26 @@ class SearchDashboardsInput(BaseModel):
# @BRIEF Search and list dashboards by name, with optional environment filter.
# @PRE User authenticated via dual-identity JWT.
# @POST Returns formatted dashboard list string.
# @POST Response surfaces effective profile filter metadata so hidden dashboards are never reported as absent.
# @RATIONALE Agent search must see the full environment catalog: without page_context=other the backend
# applies the UI "My Dashboards Only" profile filter, which hides every dashboard whose owners/modified_by
# do not match the bound Superset user — including all dashboards lacking owner metadata — and the tool
# previously discarded available_total/effective_profile_filter, producing a false "no dashboards" answer.
# @SIDE_EFFECT HTTP GET to FastAPI /api/dashboards.
@tool(args_schema=SearchDashboardsInput)
async def search_dashboards(query: str, env_id: str | None = None) -> str:
"""Search and list dashboards by name, with optional environment filter."""
logger.reason("Search dashboards", payload={"query": query, "env_id": env_id},
extra={"src": "AgentChat.Tools.SearchDashboards"})
params = {"q": query, "env_id": env_id or ""}
# Full-catalog search: page_context=other disables the profile-default filter,
# page_size=100 avoids truncation for environments with more dashboards than
# the default page size (10).
params = {
"q": query,
"env_id": env_id or "",
"page_context": "other",
"page_size": 100,
}
resp = await _get("/api/dashboards", params=params)
if resp.status_code != 200:
logger.explore("Dashboard search failed",
@@ -386,14 +399,28 @@ async def search_dashboards(query: str, env_id: str | None = None) -> str:
data = resp.json()
dashboards = data.get("dashboards", [])
total = data.get("total", 0)
available_total = data.get("available_total")
profile_filter = data.get("effective_profile_filter") or {}
filter_applied = bool(profile_filter.get("applied"))
env_label = env_id or "default"
if total == 0:
if isinstance(available_total, int) and available_total > 0:
logger.reflect("Dashboards hidden by profile filter",
payload={"query": query, "total": 0, "available_total": available_total, "filter_applied": filter_applied},
extra={"src": "AgentChat.Tools.SearchDashboards"})
return (
f"Found {available_total} dashboard(s) in environment '{env_label}', but all are hidden "
f"by the profile filter 'My Dashboards Only' (bound username: "
f"{profile_filter.get('username') or 'unknown'}). No dashboards are listed; "
f"use override_show_all=true or page_context=other to see the full catalog."
)
logger.reflect("No dashboards found",
payload={"query": query, "total": 0},
extra={"src": "AgentChat.Tools.SearchDashboards"})
return f"No dashboards found matching '{query}' in environment '{env_id or 'default'}'."
return f"No dashboards found matching '{query}' in environment '{env_label}'."
lines = [f"Found {total} dashboard(s) in environment '{env_id or 'default'}':"]
lines = [f"Found {total} dashboard(s) in environment '{env_label}':"]
for db in dashboards:
title = db.get("title", "Untitled")
owners = ", ".join(db.get("owners", [])) or "N/A"
@@ -401,6 +428,15 @@ async def search_dashboards(query: str, env_id: str | None = None) -> str:
lines.append(f" - {title}")
lines.append(f" Owners: {owners}")
lines.append(f" Last modified: {modified}")
if filter_applied:
lines.append(
f"(Note: profile filter 'My Dashboards Only' active — showing only dashboards "
f"matching {profile_filter.get('username') or 'bound user'})"
)
if len(dashboards) < total:
lines.append(
f"(Note: showing first {len(dashboards)} of {total} dashboards — refine the query or paginate for more)"
)
logger.reflect("Dashboards listed",
payload={"query": query, "total": total},
extra={"src": "AgentChat.Tools.SearchDashboards"})

View File

@@ -228,6 +228,99 @@ async def test_search_dashboards_correct_url():
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/dashboards" in url
# Agent searches the full catalog: profile-default filter must be disabled
# and the page size raised above the default (10) to avoid truncation.
params = kwargs.get("params", {})
assert params.get("page_context") == "other"
assert params.get("page_size") == 100
assert params.get("q") == "dashboard-name"
assert params.get("env_id") == "prod"
@pytest.mark.anyio
async def test_search_dashboards_surfaces_profile_filter_hidden():
"""total=0 with available_total>0 must report hidden dashboards, not absence."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [],
"total": 0,
"available_total": 11,
"effective_profile_filter": {
"applied": True,
"username": "admin",
"match_logic": "owners_or_modified_by",
},
}
mock_resp = Mock(status_code=200, text="")
mock_resp.json.return_value = payload
_, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
result = await search_dashboards.ainvoke({"query": "", "env_id": "ss-dev"})
assert "11" in result
assert "hidden" in result
assert "My Dashboards Only" in result
assert "admin" in result
assert "no dashboards found" not in result.lower() or "hidden" in result.lower()
@pytest.mark.anyio
async def test_search_dashboards_truncation_note():
"""When the API returns fewer items than total, the tool must say so."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [
{"title": f"Dashboard {i}", "owners": ["admin"], "last_modified": "2026-01-01T00:00:00"} for i in range(10)
],
"total": 15,
"available_total": 15,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_resp = Mock(status_code=200, text="")
mock_resp.json.return_value = payload
_, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
result = await search_dashboards.ainvoke({"query": "", "env_id": "prod"})
assert "Found 15 dashboard(s)" in result
assert "showing first 10 of 15" in result
@pytest.mark.anyio
async def test_search_dashboards_empty_env_still_reports_absent():
"""A genuinely empty environment must still produce the 'no dashboards' message."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent.tools import search_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [],
"total": 0,
"available_total": 0,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_resp = Mock(status_code=200, text="")
mock_resp.json.return_value = payload
_, patcher = _mock_http_client(get_return=mock_resp)
with patcher:
result = await search_dashboards.ainvoke({"query": "x", "env_id": "prod"})
assert "No dashboards found matching 'x'" in result
# #endregion TestAgentChat.Tools.ToolContracts

View File

@@ -0,0 +1,99 @@
# #region Test.Agent.Persistence.PrefetchDashboards [C:3] [TYPE Module] [SEMANTICS test,agent,persistence,prefetch,dashboards]
# @BRIEF Tests for prefetch_dashboards — full-catalog context injection for the LLM.
# @RELATION BINDS_TO -> [AgentChat.Persistence]
# @TEST_EDGE: http_error -> non-200 returns empty string
# @TEST_EDGE: empty_catalog -> "No dashboards found." text
# @TEST_EDGE: full_catalog -> lists dashboards and requests page_context=other (profile filter bypass)
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.append(str(Path(__file__).resolve().parent.parent.parent / "src"))
import httpx
import pytest
os.environ.setdefault("FASTAPI_URL", "http://test-backend:8000")
os.environ.setdefault("SERVICE_JWT", "test-service-jwt")
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
def _mock_response(status_code=200, json_data=None):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.json.return_value = json_data or {}
return resp
@pytest.mark.anyio
async def test_prefetch_dashboards_uses_full_catalog_context():
"""prefetch_dashboards must request page_context=other + page_size=100 so the
"My Dashboards Only" profile filter cannot hide the whole catalog."""
from ss_tools.agent.context import set_service_jwt, set_user_jwt
from ss_tools.agent._persistence import prefetch_dashboards
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
payload = {
"dashboards": [
{"id": i, "title": f"Dash {i}", "slug": f"dash-{i}", "last_modified": "2026-01-01T00:00:00"}
for i in range(1, 12)
],
"total": 11,
"available_total": 11,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_resp = _mock_response(200, payload)
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get = AsyncMock(return_value=mock_resp)
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
result = await prefetch_dashboards("ss-dev")
call_kwargs = mock_client.get.call_args
assert call_kwargs is not None
_, kwargs = call_kwargs
params = kwargs.get("params", {})
assert params.get("page_context") == "other"
assert params.get("page_size") == 100
# The previous dead-code bug made every 200 response raise NameError and return "".
assert result != ""
assert "11 total" in result
assert "Dash 1" in result
assert "Dash 11" in result
@pytest.mark.anyio
async def test_prefetch_dashboards_http_error_returns_empty():
"""Non-200 response must degrade to an empty string, not raise."""
from ss_tools.agent._persistence import prefetch_dashboards
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get = AsyncMock(return_value=_mock_response(500, {}))
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
result = await prefetch_dashboards("ss-dev")
assert result == ""
@pytest.mark.anyio
async def test_prefetch_dashboards_empty_catalog():
"""A genuinely empty catalog returns the explicit no-dashboards marker."""
from ss_tools.agent._persistence import prefetch_dashboards
payload = {
"dashboards": [],
"total": 0,
"available_total": 0,
"effective_profile_filter": {"applied": False, "username": None},
}
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get = AsyncMock(return_value=_mock_response(200, payload))
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
result = await prefetch_dashboards("ss-dev")
assert result == "No dashboards found."
# #endregion Test.Agent.Persistence.PrefetchDashboards