fix(admin): 诊断解析放错了函数 + 补 kb_search 形状测试

上一个提交里,诊断解析被塞进了用户面的 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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 15:24:49 +08:00
parent b26fe21408
commit 7c8c723245
2 changed files with 111 additions and 16 deletions
+17 -16
View File
@@ -18,9 +18,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-gateway/internal/nats" "github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-shared/contract" "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}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
return return
} }
var out struct { var hits []map[string]any
Hits []map[string]any `json:"hits"` _ = json.Unmarshal([]byte(res.Content), &hits)
Routes []map[string]any `json:"routes"` c.JSON(http.StatusOK, gin.H{"hits": hits})
}
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})
} }
// AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键** // 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}) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
return return
} }
var hits []map[string]any // 带 diag 时 mcp-go 回的是 {hits, routes} 对象;老版本回裸数组,做兼容降级
_ = json.Unmarshal([]byte(res.Content), &hits) // (少了诊断而已,不该让整个试验台打不开)。
c.JSON(http.StatusOK, gin.H{"hits": hits}) 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_graphNeo4j)。 // KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。
@@ -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 时被总闸短路了(返回空数组而非逐路诊断)")
}
}