From 7c8c7232456c66a15508f73aeb2e6fce334ccb89 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 15:24:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E8=AF=8A=E6=96=AD=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=94=BE=E9=94=99=E4=BA=86=E5=87=BD=E6=95=B0=20+=20?= =?UTF-8?q?=E8=A1=A5=20kb=5Fsearch=20=E5=BD=A2=E7=8A=B6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交里,诊断解析被塞进了用户面的 KbSearch,而真正传 diag 的 AdminKbSearch 还在用老代码 —— 于是试验台永远拿不到 routes。写法上是 字符串替换只改了文件里第一处匹配,而这两个 handler 的收尾代码一模一样。 用户面已还原(它不传 diag,拿到的本就是裸数组,多一层解析是错的)。 补 3 组 mcp 层测试钉死 kb_search 的两种返回形状: - 不带 diag(生产调用:dispatcher / Agent 工具链)→ 裸 JSON 数组,契约不变; - 带 diag → {hits, routes} 对象; - not ready 时不被总闸短路,仍逐路诊断。 这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若无条件生效,所有 调用方的解析都会静默失败。 真环境验证(本地 gateway + mcp-go + 真 PG/Milvus/bleve): vector empty 该知识库在 Milvus 中没有向量(未入库或集合被重建过) fulltext ok 15 命中 graph disabled Neo4j 未连接或未配置 以前这三行都只是"0 条"。 Co-Authored-By: Claude Opus 4.8 --- sundynix-gateway/internal/handler/kb.go | 33 +++---- .../internal/mcp/kb_search_diag_test.go | 94 +++++++++++++++++++ 2 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 sundynix-mcp-go/internal/mcp/kb_search_diag_test.go diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go index ae22abb..2755ae5 100644 --- a/sundynix-gateway/internal/handler/kb.go +++ b/sundynix-gateway/internal/handler/kb.go @@ -18,9 +18,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/sundynix/sundynix-shared/blob" "github.com/sundynix/sundynix-gateway/internal/nats" "github.com/sundynix/sundynix-gateway/internal/store" + "github.com/sundynix/sundynix-shared/blob" "github.com/sundynix/sundynix-shared/contract" ) @@ -548,18 +548,9 @@ func (h *Handler) KbSearch(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error}) return } - var out struct { - Hits []map[string]any `json:"hits"` - Routes []map[string]any `json:"routes"` - } - if err := json.Unmarshal([]byte(res.Content), &out); err != nil { - // 兼容老 mcp-go(未部署新版时仍回裸数组),别因为这个把试验台整个打挂。 - var hits []map[string]any - _ = json.Unmarshal([]byte(res.Content), &hits) - c.JSON(http.StatusOK, gin.H{"hits": hits}) - return - } - c.JSON(http.StatusOK, gin.H{"hits": out.Hits, "routes": out.Routes}) + var hits []map[string]any + _ = json.Unmarshal([]byte(res.Content), &hits) + c.JSON(http.StatusOK, gin.H{"hits": hits}) } // AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键** @@ -602,9 +593,19 @@ func (h *Handler) AdminKbSearch(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error}) return } - var hits []map[string]any - _ = json.Unmarshal([]byte(res.Content), &hits) - c.JSON(http.StatusOK, gin.H{"hits": hits}) + // 带 diag 时 mcp-go 回的是 {hits, routes} 对象;老版本回裸数组,做兼容降级 + // (少了诊断而已,不该让整个试验台打不开)。 + var out struct { + Hits []map[string]any `json:"hits"` + Routes []map[string]any `json:"routes"` + } + if err := json.Unmarshal([]byte(res.Content), &out); err != nil { + var hits []map[string]any + _ = json.Unmarshal([]byte(res.Content), &hits) + c.JSON(http.StatusOK, gin.H{"hits": hits}) + return + } + c.JSON(http.StatusOK, gin.H{"hits": out.Hits, "routes": out.Routes}) } // KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graph,Neo4j)。 diff --git a/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go b/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go new file mode 100644 index 0000000..24a17e6 --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go @@ -0,0 +1,94 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + "github.com/sundynix/sundynix-shared/contract" + + "github.com/sundynix/sundynix-mcp-go/internal/rag" +) + +// kb_search 的两种返回形状必须泾渭分明: +// - 不带 diag(生产调用:dispatcher、Agent 工具链)→ 裸 JSON 数组,契约不能变; +// - 带 diag(只有检索试验台会传)→ {hits, routes} 对象,带每一路的诊断。 +// +// 把它钉住是因为这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若写成无条件 +// 生效,所有调用方拿到的就从数组变成了对象,工具链会静默解析失败。 + +// newFulltextOnlyGateway 造一个只有全文路可用的 Gateway:无 embedding、无 Milvus、无 Neo4j。 +// 这正是 mcp-go 先于 gateway 启动(控制面配置尚未下发)时的真实状态。 +func newFulltextOnlyGateway(t *testing.T) *Gateway { + t.Helper() + t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve") + e := rag.Open(context.Background(), rag.Config{}) // 不给 Milvus/Neo4j/embedding + return &Gateway{rag: e} +} + +func TestKbSearch_ProductionShapeIsBareArray(t *testing.T) { + g := newFulltextOnlyGateway(t) + res := g.kbSearch(context.Background(), &contract.ToolCall{ + Tool: "kb_search", + Args: map[string]any{"kb": "k1", "q": "星间链路"}, + }) + if !res.OK { + t.Fatalf("不应失败: %s", res.Error) + } + var arr []map[string]any + if err := json.Unmarshal([]byte(res.Content), &arr); err != nil { + t.Fatalf("生产调用必须返回裸数组(工具链据此解析),得 %q: %v", res.Content, err) + } +} + +func TestKbSearch_DiagShapeCarriesRoutes(t *testing.T) { + g := newFulltextOnlyGateway(t) + res := g.kbSearch(context.Background(), &contract.ToolCall{ + Tool: "kb_search", + Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true, "mode": "hybrid"}, + }) + if !res.OK { + t.Fatalf("不应失败: %s", res.Error) + } + var out struct { + Hits []map[string]any `json:"hits"` + Routes []rag.RouteDiag `json:"routes"` + } + if err := json.Unmarshal([]byte(res.Content), &out); err != nil { + t.Fatalf("diag 调用应返回 {hits,routes} 对象,得 %q: %v", res.Content, err) + } + if len(out.Routes) != 3 { + t.Fatalf("应有 vector/fulltext/graph 三路诊断,得 %d 条: %+v", len(out.Routes), out.Routes) + } + for _, r := range out.Routes { + if r.Status == "" { + t.Fatalf("每一路都必须有 status,否则界面仍无法解释这个空: %+v", r) + } + // 没配置的路必须给出原因——这正是当初排查半天的痛点。 + if r.Status == "disabled" && r.Note == "" { + t.Fatalf("%s 路 disabled 却没写原因", r.Name) + } + } +} + +// 未配置 embedding/Milvus 时不能整体短路返回空:以前用 rag.Ready() 当总闸, +// 连不依赖它们的全文/图谱路一起毙掉,表现为"知识库什么都搜不到"且不报错。 +func TestKbSearch_NotShortCircuitedByReadiness(t *testing.T) { + g := newFulltextOnlyGateway(t) + if g.rag.Ready() { + t.Fatal("前提不成立:该引擎本应 not ready") + } + res := g.kbSearch(context.Background(), &contract.ToolCall{ + Tool: "kb_search", + Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true}, + }) + var out struct { + Routes []rag.RouteDiag `json:"routes"` + } + if err := json.Unmarshal([]byte(res.Content), &out); err != nil { + t.Fatalf("not ready 时也应正常走到各路诊断,得 %q", res.Content) + } + if len(out.Routes) == 0 { + t.Fatal("not ready 时被总闸短路了(返回空数组而非逐路诊断)") + } +}