package eino import ( "context" "encoding/json" "strings" "testing" "github.com/sundynix/sundynix-dispatcher/internal/harness" "github.com/sundynix/sundynix-dispatcher/internal/llm" "github.com/sundynix/sundynix-shared/contract" ) // ---- RAG 管线集成测试:检索 → 注入 prompt → 生成 → refs 回流(喂忠实度评测)整条链。---- const ragSnippet = "西湖是杭州著名景点,三面环山。" // kbTool 返回一个把 kb_search 应答成固定命中、并捕获入参的 fakeTools。 func kbTool(capture *contract.ToolCall) *fakeTools { return &fakeTools{fn: func(c *contract.ToolCall) *contract.ToolResult { if c.Tool == "kb_search" { if capture != nil { *capture = *c } return &contract.ToolResult{OK: true, Content: `[{"text":"` + ragSnippet + `","score":0.92}]`} } return &contract.ToolResult{OK: true, Content: ""} }} } // 对话 RAG:input→retriever→agent。检索命中应注入 agent 的 system prompt,且 refs 经 runGraph 回流。 func TestRAG_ConversationInjectsAndReturnsRefs(t *testing.T) { g := `{"nodes":[ {"id":"i","kind":"input","config":{"text":"介绍杭州西湖"}}, {"id":"r","kind":"retriever","config":{"kb":"travel"}}, {"id":"a","kind":"agent","config":{"system":"你是导游"}} ],"edges":[{"source":"i","target":"r"},{"source":"r","target":"a"}]}` // fakeLLM 回显 system 消息 → 借此断言检索片段已注入 prompt。 ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string { return m[0].Content }} var captured contract.ToolCall o := newOrch(ll, kbTool(&captured), &fakeSink{}, &fakeExec{}) tk := &contract.Task{ID: "t1", Graph: json.RawMessage(g), Meta: map[string]any{contract.MetaUserID: "u42"}} ans, refs, err := o.runGraph(context.Background(), tk, o.tracer("t1")) if err != nil { t.Fatal(err) } if captured.Tool != "kb_search" { t.Fatal("应调用 kb_search 检索") } // owner 作用域:未带 "/" 的库名应被前缀成 uid/kb。 if kb, _ := captured.Args["kb"].(string); kb != "u42/travel" { t.Errorf("kb 应按 owner 作用域为 u42/travel,got %q", kb) } if !strings.Contains(ans, ragSnippet) { t.Errorf("检索片段应注入 agent prompt,prompt=%q", ans) } // refs 回流(喂忠实度评测的 sources)。 if len(refs) == 0 || !strings.Contains(strings.Join(refs, ""), ragSnippet) { t.Errorf("runGraph 应回流含检索片段的 refs,got %v", refs) } } // refs 回流后应触发「有来源」的忠实度评测路径(grounded judge),而非无来源路径。 func TestRAG_RefsDriveGroundedEval(t *testing.T) { ll := &fakeLLM{ready: true} o := newOrch(ll, &fakeTools{}, &fakeSink{}, &fakeExec{}) var sawSources bool o.eval = harness.NewEvaluator(func() bool { return true }, func(_ context.Context, _, user string) (string, error) { // grounded judge 的 user 含「检索资料」段;无来源路径则没有。 if strings.Contains(user, "检索资料") { sawSources = true return `{"quality":5,"faithfulness":5,"unsupported":[],"reason":"忠实"}`, nil } return `{"score":3,"reason":"无来源"}`, nil }) sink := &fakeEvalSink{} o.evalSink = sink o.evaluate(&contract.Task{ID: "t1"}, "介绍西湖", "西湖三面环山。", []string{ragSnippet}) if !sawSources { t.Fatal("有 refs 时应走 grounded(含检索资料)评测路径") } if ev := sink.get(); ev == nil || ev.Faithful == 0 { t.Errorf("应记录忠实度分,got %+v", ev) } } // 报告 RAG:writeSection 检索命中应注入撰写 prompt。 func TestRAG_ReportSectionInjectsRefs(t *testing.T) { // writeSection 走 Chat(非流式)→ fakeLLM.chat 回显 user 消息以验证注入。 ll := &fakeLLM{ready: true, chat: func(m []llm.ChatMessage) (string, error) { return m[len(m)-1].Content, nil // user 消息(含参考资料) }} o := newOrch(ll, kbTool(nil), &fakeSink{}, &fakeExec{}) body := o.writeSection(context.Background(), "杭州旅游", "u1/travel", "西湖", o.tracer("t1"), "section:0") if !strings.Contains(body, ragSnippet) { t.Errorf("报告章节撰写 prompt 应含检索资料,got %q", body) } } // 优雅降级:检索工具不可用时返回空 refs,agent 仍正常出答案、不报错。 func TestRAG_DegradesWhenRetrieverDown(t *testing.T) { g := `{"nodes":[ {"id":"i","kind":"input","config":{"text":"介绍杭州"}}, {"id":"r","kind":"retriever","config":{"kb":"travel"}}, {"id":"a","kind":"agent","config":{"system":"导游答复"}} ],"edges":[{"source":"i","target":"r"},{"source":"r","target":"a"}]}` ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string { return m[0].Content }} // kb_search 返回失败 → retrieve 降级空。 ft := &fakeTools{fn: func(c *contract.ToolCall) *contract.ToolResult { if c.Tool == "kb_search" { return &contract.ToolResult{OK: false, Error: "milvus down"} } return &contract.ToolResult{OK: true} }} o := newOrch(ll, ft, &fakeSink{}, &fakeExec{}) ans, refs, err := o.runGraph(context.Background(), &contract.Task{ID: "t1", Graph: json.RawMessage(g), Meta: map[string]any{}}, o.tracer("t1")) if err != nil { t.Fatalf("检索失败不应让整图失败: %v", err) } if len(refs) != 0 { t.Errorf("检索失败应空 refs,got %v", refs) } if !strings.Contains(ans, "导游答复") { t.Errorf("无检索仍应正常出答案,got %q", ans) } }