Files
ragflow/internal/agent/harness/route_test.go
Zhichang Yu 4e78f1f440 Port Python agentic search to Go (nav service, harness, tools) (#17702)
Port Python rag/advanced_rag agentic search to Go: ES-backed dataset-nav
service, agentic-search harness, and agent tools.

Includes agentic-search port plan and self-review docs.
2026-08-03 11:16:16 +08:00

141 lines
5.3 KiB
Go

package harness
import (
"context"
"testing"
"gorm.io/gorm"
"ragflow/internal/agent/component"
)
// fakeChatInvoker returns a fixed content for the chat call, so tests can drive
// the route/planner LLM output deterministically.
type fakeChatInvoker struct{ content string }
func (f *fakeChatInvoker) Invoke(_ context.Context, _ *gorm.DB, _ component.ChatInvokeRequest) (*component.ChatInvokeResponse, error) {
return &component.ChatInvokeResponse{Content: f.content}, nil
}
func installChat(t *testing.T, content string) {
t.Helper()
component.SetDefaultChatInvoker(&fakeChatInvoker{content: content})
t.Cleanup(func() { component.SetDefaultChatInvoker(nil) })
}
// TestRouteNode_Classifies asserts route classification drives the execution
// strategy from the mode and the LLM's question_type.
func TestRouteNode_Classifies(t *testing.T) {
installChat(t, `{"question_type":"comparative","requires_decomposition":true,"reasoning":"cmp"}`)
r := RouteNode(context.Background(), nil, "Compare A and B", "medium")
if r.QuestionType != "comparative" {
t.Errorf("question_type = %q, want comparative", r.QuestionType)
}
// medium mode requires decomposition AND LLM says true -> true.
if !r.RequiresDecomposition {
t.Errorf("requires_decomposition = false, want true")
}
if r.ExecutionStrategy != "decompose_and_search" {
t.Errorf("execution_strategy = %q, want decompose_and_search", r.ExecutionStrategy)
}
}
// TestRouteNode_LowModeDisablesDecomposition asserts low mode never decomposes
// even if the LLM requests it.
func TestRouteNode_LowModeDisablesDecomposition(t *testing.T) {
installChat(t, `{"question_type":"analytical","requires_decomposition":true}`)
r := RouteNode(context.Background(), nil, "Analyze X", "low")
if r.RequiresDecomposition {
t.Errorf("low mode must disable decomposition, got true")
}
if r.ExecutionStrategy != "direct_search" {
t.Errorf("low mode execution_strategy = %q, want direct_search", r.ExecutionStrategy)
}
}
// TestRouteNode_FencedJSON asserts think-tag/fence stripping works.
func TestRouteNode_FencedJSON(t *testing.T) {
installChat(t, "Sure!\n```json\n{\"question_type\":\"factual\",\"requires_decomposition\":false}\n```")
r := RouteNode(context.Background(), nil, "What is X?", "medium")
if r.QuestionType != "factual" {
t.Errorf("question_type = %q, want factual", r.QuestionType)
}
if r.RequiresDecomposition {
t.Errorf("requires_decomposition = true, want false")
}
}
// TestRouteNode_EmptyQuestionFallsBack asserts an empty question yields a
// direct factual decision without calling the LLM.
func TestRouteNode_EmptyQuestionFallsBack(t *testing.T) {
r := RouteNode(context.Background(), nil, "", "medium")
if r.QuestionType != "factual" || r.RequiresDecomposition {
t.Errorf("empty question must fall back to direct factual, got %+v", r)
}
}
// TestPlannerNode_DirectMode asserts a non-decomposed route yields one coarse
// claim without calling the LLM.
func TestPlannerNode_DirectMode(t *testing.T) {
plan := PlannerNode(context.Background(), nil, RouteDecision{
Question: "What is X?", RequiresDecomposition: false,
}, nil)
if plan.PlanType != "direct" || len(plan.Claims) != 1 {
t.Fatalf("direct plan = %+v, want 1 direct claim", plan)
}
}
// TestPlannerNode_Decomposes asserts the planner builds claims from the LLM
// output and applies the mode's max iterations.
func TestPlannerNode_Decomposes(t *testing.T) {
installChat(t, `{"claims":[
{"claim_id":"c0","description":"fact one","priority":0},
{"claim_id":"c1","description":"fact two","priority":1}
]}`)
plan := PlannerNode(context.Background(), nil, RouteDecision{
Question: "Compare A and B", QuestionType: "comparative", RequiresDecomposition: true, ThinkingMode: "medium",
}, nil)
if plan.PlanType != "comparative_decomposition" {
t.Errorf("plan_type = %q, want comparative_decomposition", plan.PlanType)
}
if len(plan.Claims) != 2 {
t.Fatalf("claims = %d, want 2", len(plan.Claims))
}
if plan.Claims[1].Priority != 1 {
t.Errorf("claim priority = %d, want 1", plan.Claims[1].Priority)
}
// medium maxOrchestratorCycles = 3
if plan.MaxIterations != 3 {
t.Errorf("max_iterations = %d, want 3", plan.MaxIterations)
}
}
// TestPlannerNode_UnknownModeFallsBack asserts an unknown (non-empty) mode label
// falls back to medium so the planner is not driven by a zero-valued mode
// (which would produce a degenerate plan with max_claims=0).
func TestPlannerNode_UnknownModeFallsBack(t *testing.T) {
installChat(t, `{"claims":[{"claim_id":"c0","description":"fact one","priority":0}]}`)
plan := PlannerNode(context.Background(), nil, RouteDecision{
Question: "Q", RequiresDecomposition: true, ThinkingMode: "turbo-unknown",
}, nil)
// medium maxOrchestratorCycles = 3, and claims must still be built.
if plan.MaxIterations != 3 {
t.Errorf("max_iterations = %d, want 3 (medium fallback)", plan.MaxIterations)
}
if len(plan.Claims) != 1 {
t.Errorf("claims = %d, want 1", len(plan.Claims))
}
}
// TestPlannerNode_BadJSONFallsBack asserts unparseable planner output falls back
// to the direct plan.
func TestPlannerNode_BadJSONFallsBack(t *testing.T) {
installChat(t, "not json at all")
plan := PlannerNode(context.Background(), nil, RouteDecision{
Question: "Q", RequiresDecomposition: true, ThinkingMode: "medium",
}, nil)
if plan.PlanType != "direct" || len(plan.Claims) != 1 {
t.Fatalf("fallback plan = %+v, want direct", plan)
}
}