diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 7afa6fc..ee5ae2f 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -5929,7 +5929,21 @@ def node_route_reference(node): return {key: value for key, value in item.items() if value not in (None, "")} -def discovery_envelope(project_id, items, args, *, view=None, next_cursor=None, has_more=None, diagnostics=None, query_analysis=None, warnings=None): +def discovery_envelope( + project_id, + items, + args, + *, + view=None, + next_cursor=None, + has_more=None, + diagnostics=None, + query_analysis=None, + warnings=None, + search_strategy=None, + search_coverage=None, + search_projection=None, +): view = view or normalize_discovery_view(args)[0] limit = normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, discovery_max_limit(view)) preview_chars = normalize_int(args.get("preview_chars"), 240, 80, 1000) @@ -5945,6 +5959,12 @@ def discovery_envelope(project_id, items, args, *, view=None, next_cursor=None, "omitted_fields": [] if view == "full" else ["content_text"], "budget": {"limit": limit, "max_limit": discovery_max_limit(view), "preview_chars": preview_chars}, } + if search_strategy: + response["search_strategy"] = search_strategy + if search_coverage: + response["search_coverage"] = search_coverage + if search_projection: + response["search_projection"] = search_projection if as_bool(args.get("verbose"), False): if query_analysis is not None: response["query_analysis"] = query_analysis @@ -9661,6 +9681,9 @@ def list_project_nodes(args): diagnostics=diagnostics, query_analysis=query_analysis, warnings=view_warnings + filters["warnings"], + search_strategy=result.get("search_strategy"), + search_coverage=result.get("search_coverage"), + search_projection=result.get("search_projection"), ) @@ -11643,6 +11666,7 @@ def search_memory(args): encryption_state = project_encryption_state(project_id) collected = [] sources = [] + server_search = {} diagnostics = { "query": query_analysis, "local_index": local_index_diagnostics(project_id), @@ -11692,6 +11716,11 @@ def search_memory(args): # calls, especially on a new/sparse local index. remaining = max(1, limit - len(local_filtered)) server_result = list_project_nodes({**args, "q": q, "limit": remaining, "view": view}) + server_search = { + key: server_result.get(key) + for key in ("search_strategy", "search_coverage", "search_projection") + if server_result.get(key) + } server = server_result.get("items", []) diagnostics["server_queries"].append({"q": q, "matched": len(server)}) for node in server: @@ -11719,6 +11748,19 @@ def search_memory(args): response["mode"] = mode response["encryption_state"] = encryption_state response["sources"] = sources + local_used = bool((diagnostics.get("local_index") or {}).get("used")) + if local_used and server_needed: + response["search_strategy"] = "fused" + response["search_coverage"] = "identity_and_local_body" + response["search_projection"] = "+".join( + filter(None, ["sqlite_fts5_current", server_search.get("search_projection")]) + ) + elif local_used: + response["search_strategy"] = "local_fts" + response["search_coverage"] = "identity_and_local_body" + response["search_projection"] = "sqlite_fts5_current" + elif server_needed: + response.update(server_search) return response diff --git a/tests/test_mcp_search_latency.py b/tests/test_mcp_search_latency.py index f201a0f..197d0ee 100644 --- a/tests/test_mcp_search_latency.py +++ b/tests/test_mcp_search_latency.py @@ -30,7 +30,14 @@ def test_natural_language_discovery_uses_one_backend_search_request(tmp_path): def request(method, path, payload=None, **_kwargs): calls.append((method, path, payload)) - return {"items": [], "next_cursor": None, "query_analysis": {"mode": "terms"}} + return { + "items": [], + "next_cursor": None, + "query_analysis": {"mode": "terms"}, + "search_strategy": "identity_fts", + "search_coverage": "identity_only", + "search_projection": "postgres_identity_v1", + } mod.request_json = request @@ -46,6 +53,9 @@ def test_natural_language_discovery_uses_one_backend_search_request(tmp_path): assert len(calls) == 1 assert calls[0][0] == "GET" assert "/nodes?" in calls[0][1] + assert result["search_strategy"] == "identity_fts" + assert result["search_coverage"] == "identity_only" + assert result["search_projection"] == "postgres_identity_v1" def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path): @@ -102,7 +112,7 @@ def test_complete_local_search_skips_remote_encryption_preflight_and_search(tmp_ "items": items, "query": {"terms": ["result"]}, "search_strategies": [], - "index": {"exists": True, "graph_complete": True}, + "index": {"exists": True, "graph_complete": True, "used": True}, } def unexpected_request(*_args, **_kwargs): @@ -112,6 +122,9 @@ def test_complete_local_search_skips_remote_encryption_preflight_and_search(tmp_ result = mod.search_memory({"q": "result", "limit": 5, "view": "summary"}) assert result["sources"] == ["local"] + assert result["search_strategy"] == "local_fts" + assert result["search_coverage"] == "identity_and_local_body" + assert result["search_projection"] == "sqlite_fts5_current" assert len(result["items"]) == 5 assert all(item["node_id"].startswith("node-") for item in result["items"])