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:
@@ -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 ""
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
99
agent/tests/test_agent/test_persistence_prefetch.py
Normal file
99
agent/tests/test_agent/test_persistence_prefetch.py
Normal 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
|
||||
Reference in New Issue
Block a user