Files
sundynix-agentix/sundynix-dispatcher/internal/eino/rag_integration_test.go
T
Blizzard 165ecb4ec6 test(dispatcher): RAG 管线集成测试 —— 检索→注入→生成→refs→忠实度评测整条链
补全核心链路最后一块。此前只测了 retriever 组件的解析,整条 RAG 管线没被集成覆盖。

新增 rag_integration_test.go 4 例:
- ConversationInjectsAndReturnsRefs:input→retriever→agent,断言 kb_search 被调、
  kb 按 owner 作用域(u42/travel)、检索片段注入 agent system prompt、refs 经 runGraph 回流。
- RefsDriveGroundedEval:有 refs 时 evaluate 走「含检索资料」的 grounded judge 路径,
  记录忠实度分(而非无来源路径)。
- ReportSectionInjectsRefs:报告 writeSection 检索命中注入撰写 prompt。
- DegradesWhenRetrieverDown:kb_search 失败 → 空 refs,agent 仍正常出答案、整图不失败。

go test -race ./internal/eino 干净,四模块全绿。至此核心编排链 + RAG 管线均有集成兜底。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:21:11 +08:00

133 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: ""}
}}
}
// 对话 RAGinput→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/travelgot %q", kb)
}
if !strings.Contains(ans, ragSnippet) {
t.Errorf("检索片段应注入 agent promptprompt=%q", ans)
}
// refs 回流(喂忠实度评测的 sources)。
if len(refs) == 0 || !strings.Contains(strings.Join(refs, ""), ragSnippet) {
t.Errorf("runGraph 应回流含检索片段的 refsgot %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)
}
}
// 报告 RAGwriteSection 检索命中应注入撰写 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("检索失败应空 refsgot %v", refs)
}
if !strings.Contains(ans, "导游答复") {
t.Errorf("无检索仍应正常出答案,got %q", ans)
}
}