Files
sundynix-agentix/sundynix-dispatcher/internal/eino/rag_integration_test.go
T
Blizzard de36ed4cb3 fix(dispatcher): Map 节点错误传播 —— 并行子项失败不再静默(T4.E)
- writeSection 返回 (body, error):仅真·LLM 调用失败返 err;预算触顶/模型未配置
  是主动降级(可见降级正文,err=nil)不计失败
- writeSections 返回 ([]section, failed):失败项 Body 带可见「撰写失败」标记 + 汇总失败数
- mapNode:全部子项失败 → 置 b.fatalErr(任务判 failed 而非静默 done-空);
  部分失败 → trace span + 流式 ⚠️ 告警
- report handleReport:部分章节失败时流式提示,不再当全成功
- 3 单测:全失败/部分精确计数(=1 非 all-or-nothing)/mapNode 置 fatalErr

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:56:26 +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.runComposeGraph(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.runComposeGraph(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)
}
}