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.
This commit is contained in:
Zhichang Yu
2026-08-03 11:16:16 +08:00
committed by GitHub
parent 2e0dda59fc
commit 4e78f1f440
42 changed files with 5318 additions and 1444 deletions

110
internal/agent/chat/chat.go Normal file
View File

@@ -0,0 +1,110 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Package chat is a dependency-light home for the LLM chat-invoker interface
// and its package-level singleton. It lives here (leaf, importing only eino
// schema + gorm) so that both internal/agent/component (which owns the
// production eino-based invoker) and internal/agent/tool / internal/agent/harness
// (which need to call the LLM for routing/selection) can depend on it without
// forming an import cycle. The production invoker is registered at boot via
// SetDefaultInvoker.
package chat
import (
"context"
"sync"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
)
// Invoker abstracts a chat-model call so callers can inject a stub in tests and
// production flows through the eino bridge. This is the shared seam for the LLM
// component and the agentic-search harness/tools.
type Invoker interface {
Invoke(ctx context.Context, db *gorm.DB, req Request) (*Response, error)
}
// Request is the minimal surface needed to dispatch a chat call.
type Request struct {
Driver string
ModelName string
APIKey string
BaseURL string
Messages []schema.Message
Temperature *float64
TopP *float64
PresencePenalty *float64
FrequencyPenalty *float64
MaxTokens *int
Thinking string // "enabled" | "disabled" | ""
}
// Response is the result of a chat call.
type Response struct {
Content string
Thinking string
Model string
Stopped bool
Tokens int
}
// ErrNotConfigured is returned by GetDefaultInvoker when no production invoker
// has been installed. Callers should treat it as "no chat model available".
var ErrNotConfigured = &configError{"chat: default invoker not configured"}
type configError struct{ msg string }
func (e *configError) Error() string { return e.msg }
var (
mu sync.RWMutex
inst Invoker
defaultModel string
)
// SetDefaultInvoker installs the (production or test) invoker. Pass nil to
// restore the "not configured" state.
func SetDefaultInvoker(inv Invoker) {
mu.Lock()
defer mu.Unlock()
inst = inv
}
// SetDefaultModelName records the tenant-default chat model name. The production
// invoker uses it when a Request omits ModelName, so harness/agentic-search LLM
// calls work without threading model config through every node.
func SetDefaultModelName(name string) {
mu.Lock()
defer mu.Unlock()
defaultModel = name
}
// GetDefaultModelName returns the configured default model name ("" if unset).
func GetDefaultModelName() string {
mu.RLock()
defer mu.RUnlock()
return defaultModel
}
// GetDefaultInvoker returns the installed invoker. It returns nil when no
// invoker has been installed (e.g. before server bootstrap or in tests that do
// not need the LLM).
func GetDefaultInvoker() Invoker {
mu.RLock()
defer mu.RUnlock()
return inst
}

View File

@@ -18,12 +18,12 @@ import (
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
"ragflow/internal/agent/chat"
"ragflow/internal/agent/component/prompts"
"ragflow/internal/agent/runtime"
"ragflow/internal/common"
@@ -130,84 +130,68 @@ type LLMOutput struct {
Tokens int
}
// ChatInvoker is the abstraction the LLM component uses to talk to a
// chat model. The default implementation lives in this file; tests can
// override the package-level defaultChatInvoker to inject a stub.
type ChatInvoker interface {
Invoke(ctx context.Context, db *gorm.DB, req ChatInvokeRequest) (*ChatInvokeResponse, error)
}
// ChatInvoker is an alias for the shared chat.Invoker seam. The production
// eino-based implementation lives in this file; the package-level singleton is
// owned by internal/agent/chat so agent tools and the harness can also call the
// LLM without an import cycle.
type ChatInvoker = chat.Invoker
// ChatInvokeRequest is the minimal surface the LLM component needs to
// dispatch a chat call. Driver / APIKey / ModelName are kept here so the
// invoker can wire the right provider without the component caring.
type ChatInvokeRequest struct {
Driver string
ModelName string
APIKey string
BaseURL string
Messages []schema.Message
Temperature *float64
TopP *float64
PresencePenalty *float64
FrequencyPenalty *float64
MaxTokens *int
// Thinking mirrors the agent-level `thinking` setting
// ("enabled" | "disabled" | ""). The default invoker is
// responsible for translating this into the provider-specific
// request body (e.g. Qwen `enable_thinking`, Kimi/GLM
// `thinking.type`). Empty string means "use provider default"
// and the invoker should leave the provider's reasoning mode
// untouched.
Thinking string
}
// ChatInvokeRequest is an alias for chat.Request.
type ChatInvokeRequest = chat.Request
// ChatInvokeResponse mirrors what the LLM component writes to its outputs.
type ChatInvokeResponse struct {
Content string
Thinking string
Model string
Stopped bool
Tokens int
}
// ChatInvokeResponse is an alias for chat.Response.
type ChatInvokeResponse = chat.Response
// defaultChatInvokerMu guards defaultChatInvoker swaps during tests.
var defaultChatInvokerMu sync.RWMutex
// defaultChatInvoker is the production ChatInvoker. Replaced in tests.
var defaultChatInvoker ChatInvoker = &einoChatInvoker{}
// SetDefaultChatInvoker swaps the package-level ChatInvoker (test helper).
// Pass nil to restore the default. Concurrent-safe.
// SetDefaultChatInvoker delegates to the shared chat package singleton (test
// helper). Pass nil to restore the "not configured" state. The production
// einoChatInvoker is registered at boot in cmd/server_main.go.
func SetDefaultChatInvoker(inv ChatInvoker) {
defaultChatInvokerMu.Lock()
defer defaultChatInvokerMu.Unlock()
defaultChatInvoker = inv
}
// getDefaultChatInvoker returns the current default ChatInvoker.
func getDefaultChatInvoker() ChatInvoker {
defaultChatInvokerMu.RLock()
defer defaultChatInvokerMu.RUnlock()
if defaultChatInvoker == nil {
return &einoChatInvoker{}
if inv == nil {
chat.SetDefaultInvoker(nil)
return
}
return defaultChatInvoker
chat.SetDefaultInvoker(inv)
}
// GetDefaultChatInvokerForTest exposes the current package-level invoker so
// GetDefaultChatInvokerForTest exposes the current shared chat invoker so
// cross-package tests can swap it and restore it safely.
func GetDefaultChatInvokerForTest() ChatInvoker {
return getDefaultChatInvoker()
return chat.GetDefaultInvoker()
}
// getDefaultChatInvoker returns the shared chat invoker, falling back to the
// production eino invoker when none has been installed.
func getDefaultChatInvoker() ChatInvoker {
if inv := chat.GetDefaultInvoker(); inv != nil {
return inv
}
return &einoChatInvoker{}
}
// InstallDefaultChatInvoker registers the production eino-based invoker as the
// shared chat default. Called at server bootstrap so harness/agentic-search LLM
// calls work in production; without it, chat.GetDefaultInvoker() stays nil and
// harness falls back gracefully.
func InstallDefaultChatInvoker() {
chat.SetDefaultInvoker(&einoChatInvoker{})
}
// einoChatInvoker is the production ChatInvoker — it constructs a fresh
// models.EinoChatModel per call from the request and dispatches.
// models.EinoChatModel per call from the request and dispatches. It is NOT
// registered as the shared chat default at init (so chat.GetDefaultInvoker()
// stays nil until bootstrap); cmd registers it via SetDefaultChatInvoker.
type einoChatInvoker struct{}
// Invoke satisfies ChatInvoker.
func (e *einoChatInvoker) Invoke(ctx context.Context, db *gorm.DB, req ChatInvokeRequest) (*ChatInvokeResponse, error) {
if req.ModelName == "" {
return nil, fmt.Errorf("component: LLM: model_id is required")
// Harness/agentic-search nodes may omit the model; fall back to the
// bootstrap-registered tenant default so those calls work in production.
if def := chat.GetDefaultModelName(); def != "" {
req.ModelName = def
} else {
return nil, fmt.Errorf("component: LLM: model_id is required and no default model is configured")
}
}
driver := req.Driver
modelName := req.ModelName

View File

@@ -0,0 +1,107 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"log"
"strings"
"gorm.io/gorm"
)
// AgenticState carries the shared state across the agentic-RAG graph nodes.
type AgenticState struct {
Question string
Keywords string
Route RouteDecision
SeedChunks []string
Plan WorkflowPlan
Kbinfos *Kbinfos
PartialAnswer bool
Abstain bool
EmptyResult bool
FinalAnswer string
FormalizeError string
}
// RunAgenticRAG drives the agentic-search graph: route → pre_search → planner →
// orchestrator → formalize_answer. Mirrors build_agentic_graph's linear flow.
//
// - low (direct_search): one hybrid search → answer.
// - medium+ (decompose_and_search / agentic_research / deep_research):
// pre_search grounds the planner, then decompose-and-search runs until a
// sufficiency verdict stops it.
func RunAgenticRAG(ctx context.Context, db *gorm.DB, question, keywords, modeLabel string, search SearchFn) AnswerResult {
state := &AgenticState{
Question: strings.TrimSpace(question),
Keywords: keywords,
Kbinfos: &Kbinfos{},
}
if state.Question == "" {
return AnswerResult{FinalAnswer: emptyResultMessage, Empty: true}
}
// ── route ──
state.Route = RouteNode(ctx, db, state.Question, modeLabel)
// ── pre_search (decomposition modes only) ──
if state.Route.RequiresDecomposition {
chunks, aggs := search(ctx, state.Question, state.Keywords)
state.SeedChunks = extractChunkTexts(chunks)
state.Kbinfos.Merge(chunks, aggs)
}
// ── planner ──
state.Plan = PlannerNode(ctx, db, state.Route, state.SeedChunks)
// ── orchestrator ──
var orch OrchestratorResult
if state.Route.RequiresDecomposition {
claims := make([]*ClaimTarget, len(state.Plan.Claims))
for i := range state.Plan.Claims {
claims[i] = &state.Plan.Claims[i]
}
orch = DecomposeAndSearch(ctx, search, state.Question, state.Keywords, claims, modeLabel, state.Kbinfos)
} else {
orch = DirectSearch(ctx, search, state.Question, state.Keywords, state.Kbinfos)
}
state.PartialAnswer = orch.PartialAnswer
state.Abstain = orch.Abstain
state.EmptyResult = orch.EmptyResult
if orch.Kbinfos != nil {
state.Kbinfos = orch.Kbinfos
}
// ── formalize_answer ──
res := FormalizeAnswer(ctx, db, state.Question, state.Kbinfos, state.PartialAnswer, state.Abstain, state.EmptyResult)
// Log only the question length, never its content, to avoid persisting user
// input in logs.
log.Printf("agentic_rag: finished (qlen=%d, strategy=%s, chunks=%d, partial=%v, abstain=%v)",
len(state.Question), state.Route.ExecutionStrategy, len(state.Kbinfos.Chunks), state.PartialAnswer, state.Abstain)
return res
}
func extractChunkTexts(chunks []map[string]interface{}) []string {
out := make([]string, 0, len(chunks))
for _, c := range chunks {
if t := chunkText(c); t != "" {
out = append(out, t)
}
}
return out
}

View File

@@ -0,0 +1,124 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
"ragflow/internal/agent/chat"
)
// FINAL_ANSWER_SYSTEM mirrors Python report_prompt.FINAL_ANSWER_SYSTEM.
const finalAnswerSystem = `You are a smart agent. Answer the user's question using ONLY the evidence provided below. Do not invent facts: if the evidence cannot support a claim, say so plainly instead of guessing.
# Citation rules
{cite_rules}
# Language
Answer in the SAME language as the question. Translate retrieved evidence into that language as part of composing the answer; only verbatim quoted snippets may stay in their source language.
# Fallback
If the evidence does not answer the question, reply with a clear statement that you don't have enough information based on the available sources (in the user's language).
`
const partialAnswerPreamble = "Note: the following answer is based on partial information and may be incomplete."
const defaultCiteRules = "Cite passages with their source documents where available."
const (
abstainMessage = "I cannot answer this question based on the available information."
emptyResultMessage = "I don't have enough information based on the available sources."
)
// AnswerResult mirrors the formalize_answer node output.
type AnswerResult struct {
FinalAnswer string
Abstained bool
Empty bool
}
// FormalizeAnswer generates the final answer from the gathered kbinfos. Mirrors
// Python's formalize_answer node: abstain/empty short-circuits, otherwise builds
// system+user and calls the chat invoker.
func FormalizeAnswer(ctx context.Context, db *gorm.DB, question string, kb *Kbinfos, partial, abstain, empty bool) AnswerResult {
if abstain {
return AnswerResult{FinalAnswer: abstainMessage, Abstained: true}
}
if empty || kb == nil || len(kb.Chunks) == 0 {
return AnswerResult{FinalAnswer: emptyResultMessage, Empty: true}
}
var b strings.Builder
b.WriteString("Question:\n" + question + "\n")
if partial {
b.WriteString(partialAnswerPreamble + "\n")
}
b.WriteString("\nEvidence:\n")
for i, c := range kb.Chunks {
text := chunkText(c)
doc := chunkDoc(c)
if text == "" {
continue
}
b.WriteString(fmt.Sprintf("[%d] %s", i+1, text))
if doc != "" {
b.WriteString(fmt.Sprintf(" (source: %s)", doc))
}
b.WriteString("\n")
}
system := strings.ReplaceAll(finalAnswerSystem, "{cite_rules}", defaultCiteRules)
inv := chat.GetDefaultInvoker()
if inv == nil {
return AnswerResult{FinalAnswer: "I'm sorry, the chat invoker is not configured."}
}
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: system},
{Role: schema.User, Content: b.String()},
},
})
if err != nil {
return AnswerResult{FinalAnswer: "I'm sorry, I encountered an error while composing the answer."}
}
return AnswerResult{FinalAnswer: resp.Content}
}
func chunkText(c map[string]interface{}) string {
if t, ok := c["content_with_weight"].(string); ok && t != "" {
return t
}
if t, ok := c["content"].(string); ok {
return t
}
return ""
}
func chunkDoc(c map[string]interface{}) string {
if d, ok := c["docnm_kwd"].(string); ok {
return d
}
if d, ok := c["doc_id"].(string); ok {
return d
}
return ""
}

View File

@@ -0,0 +1,71 @@
package harness
import (
"context"
"strings"
"testing"
)
// TestFormalizeAnswer_Abstain asserts abstain short-circuits to the abstain
// message without calling the model.
func TestFormalizeAnswer_Abstain(t *testing.T) {
res := FormalizeAnswer(context.Background(), nil, "Q", &Kbinfos{}, false, true, false)
if !res.Abstained || res.FinalAnswer != abstainMessage {
t.Errorf("abstain result = %+v, want abstain message", res)
}
}
// TestFormalizeAnswer_Empty asserts empty chunks short-circuit to the empty
// message.
func TestFormalizeAnswer_Empty(t *testing.T) {
res := FormalizeAnswer(context.Background(), nil, "Q", &Kbinfos{}, false, false, false)
if !res.Empty || res.FinalAnswer != emptyResultMessage {
t.Errorf("empty result = %+v, want empty message", res)
}
}
// TestFormalizeAnswer_Generates asserts a chat invoker is used to compose the
// final answer from the evidence.
func TestFormalizeAnswer_Generates(t *testing.T) {
installChat(t, "here is the final answer")
kb := &Kbinfos{Chunks: []map[string]interface{}{{"content_with_weight": "evidence alpha"}}}
res := FormalizeAnswer(context.Background(), nil, "What is X?", kb, false, false, false)
if res.FinalAnswer != "here is the final answer" {
t.Errorf("final answer = %q, want chat output", res.FinalAnswer)
}
}
// TestFormalizeAnswer_PartialPreamble asserts the partial preamble is included
// when partial=true.
func TestFormalizeAnswer_PartialPreamble(t *testing.T) {
installChat(t, "partial ans")
kb := &Kbinfos{Chunks: []map[string]interface{}{{"content_with_weight": "evidence"}}}
res := FormalizeAnswer(context.Background(), nil, "Q", kb, true, false, false)
if !strings.Contains(res.FinalAnswer, "partial ans") {
t.Errorf("final answer = %q", res.FinalAnswer)
}
}
// TestRunAgenticRAG_LowMode asserts low mode does a single direct search and
// produces an answer.
func TestRunAgenticRAG_LowMode(t *testing.T) {
installChat(t, "final composed answer")
res := RunAgenticRAG(context.Background(), nil, "What is a rocket?", "", "low",
func(_ context.Context, _, _ string) ([]map[string]interface{}, []map[string]interface{}) {
return []map[string]interface{}{{"chunk_id": "a", "content_with_weight": "rocket evidence"}}, nil
})
if res.FinalAnswer != "final composed answer" {
t.Errorf("final answer = %q", res.FinalAnswer)
}
}
// TestRunAgenticRAG_EmptySearch asserts an empty search yields the empty message.
func TestRunAgenticRAG_EmptySearch(t *testing.T) {
res := RunAgenticRAG(context.Background(), nil, "Q", "", "low",
func(_ context.Context, _, _ string) ([]map[string]interface{}, []map[string]interface{}) {
return nil, nil
})
if !res.Empty || res.FinalAnswer != emptyResultMessage {
t.Errorf("empty result = %+v", res)
}
}

View File

@@ -0,0 +1,225 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"fmt"
"log"
"strings"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
"ragflow/internal/agent/chat"
"ragflow/internal/service/nav"
)
// Dataset-nav router tunables mirror Python navigation.py.
const (
navMaxDocs = 8
navMaxClusters = 500
navChildrenPageSize = 1000
navTreeMaxDepth = 6
navTreeMaxLeaves = 300
)
// navSelectSystem mirrors Python _NAV_SELECT_SYSTEM.
const navSelectSystem = `You are routing a question through a dataset's navigation tree.
You are given a QUESTION and a numbered list of {noun}, each with a name and a short description.
Choose the {noun} most likely to contain information relevant to answering the question.
Rules:
1. Judge only from the names and descriptions shown.
2. Be selective — include an item only if it is plausibly relevant. Include several when several are equally plausible.
3. If none are clearly relevant, return an empty list.
4. Return the bracketed index numbers of the chosen {noun}.
Output ONLY JSON, no prose, no code fences:
{"relevant": [<index>, ...]}`
type navSelectVerdict struct {
Relevant []int `json:"relevant"`
}
// NavigateDatasetByTree walks the dataset nav tree with two LLM passes
// (cluster-select → document-select) and returns the routed doc_ids (capped at
// navMaxDocs). This is the LLM two-round selection (P1b) implemented in the
// harness package, which can import the chat invoker (agent/tool cannot without
// an import cycle). It routes only — it does not retrieve.
func NavigateDatasetByTree(ctx context.Context, db *gorm.DB, ns nav.NavService, tenantID, kbID, query string) []string {
query = strings.TrimSpace(query)
if query == "" {
return nil
}
// 1. List top-level clusters.
clusters, _, err := ns.ListClusters(ctx, tenantID, kbID, 0, navMaxClusters)
if err != nil {
log.Printf("datasetnav: list clusters failed: %v", err)
return nil
}
if len(clusters) == 0 {
return nil
}
clusterItems := make([]navSelectItem, len(clusters))
for i, c := range clusters {
clusterItems[i] = navSelectItem{Name: c.Name, Description: c.Description, DocCount: c.DocCount}
}
// 2. LLM selects relevant clusters.
selected := askNavSelect(ctx, db, query, "clusters", clusterItems, navMaxClusters)
if len(selected) == 0 {
return nil
}
// 3. BFS-descend selected clusters to document leaves.
leaves := collectNavLeaves(ctx, ns, tenantID, kbID, selected)
// 4. LLM selects relevant documents.
docs := askNavSelect(ctx, db, query, "documents", leaves, navTreeMaxLeaves)
// Dedup + cap.
seen := map[string]bool{}
var routed []string
for _, d := range docs {
if d.DocID == "" || seen[d.DocID] {
continue
}
seen[d.DocID] = true
routed = append(routed, d.DocID)
if len(routed) >= navMaxDocs {
break
}
}
return routed
}
// navSelectItem is a renderable cluster/document with a name + description.
type navSelectItem struct {
Name string
Description string
DocCount int
DocID string
}
// askNavSelect renders items as a numbered list and asks the model which indices
// are relevant. Returns the selected items (a subset). Mirrors _ask_nav_select.
func askNavSelect(ctx context.Context, db *gorm.DB, query, noun string, items []navSelectItem, maxItems int) []navSelectItem {
if len(items) == 0 {
return nil
}
capped := items
if len(capped) > maxItems {
capped = capped[:maxItems]
}
var b strings.Builder
for i, it := range capped {
name := strings.TrimSpace(it.Name)
if name == "" {
name = fmt.Sprintf("item-%d", i)
}
desc := strings.Join(strings.Fields(it.Description), " ")
if len(desc) > 300 {
desc = desc[:300]
}
b.WriteString(fmt.Sprintf("[%d] %s", i, name))
if it.DocCount > 0 {
b.WriteString(fmt.Sprintf(" [%d docs]", it.DocCount))
}
b.WriteString(": " + desc + "\n")
}
system := strings.ReplaceAll(navSelectSystem, "{noun}", noun)
user := fmt.Sprintf("Question:\n%s\n\n%s (numbered):\n%s\n\nOutput JSON:", query, strings.Title(noun), b.String())
inv := chat.GetDefaultInvoker()
if inv == nil {
log.Printf("datasetnav: LLM %s selection skipped (chat invoker not configured)", noun)
return nil
}
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: system},
{Role: schema.User, Content: user},
},
})
if err != nil {
log.Printf("datasetnav: LLM %s selection failed: %v", noun, err)
return nil
}
var v navSelectVerdict
if err := unmarshalModelJSON(resp.Content, &v); err != nil {
return nil
}
seen := map[int]bool{}
var out []navSelectItem
for _, idx := range v.Relevant {
if idx < 0 || idx >= len(capped) || seen[idx] {
continue
}
seen[idx] = true
out = append(out, capped[idx])
}
return out
}
// collectNavLeaves BFS-descents selected clusters to document leaves. Mirrors
// Python _collect_nav_leaves.
func collectNavLeaves(ctx context.Context, ns nav.NavService, tenantID, kbID string, selected []navSelectItem) []navSelectItem {
type node struct {
name string
depth int
}
frontier := make([]node, 0, len(selected))
for _, c := range selected {
if c.Name != "" {
frontier = append(frontier, node{c.Name, 0})
}
}
var leaves []navSelectItem
seenDocs := map[string]bool{}
seenNodes := map[string]bool{}
for len(frontier) > 0 && len(leaves) < navTreeMaxLeaves {
cur := frontier[0]
frontier = frontier[1:]
if seenNodes[cur.name] {
continue
}
seenNodes[cur.name] = true
children, _, err := ns.ListChildren(ctx, tenantID, kbID, cur.name, 0, navChildrenPageSize)
if err != nil {
continue
}
for _, ch := range children {
if ch.Type == "doc" {
did := strings.TrimSpace(ch.DocID)
if did != "" && !seenDocs[did] {
seenDocs[did] = true
leaves = append(leaves, navSelectItem{Name: ch.Name, Description: ch.Description, DocID: did})
if len(leaves) >= navTreeMaxLeaves {
break
}
}
} else if ch.Type == "cluster" && ch.Name != "" && cur.depth+1 < navTreeMaxDepth {
frontier = append(frontier, node{ch.Name, cur.depth + 1})
}
}
}
return leaves
}

View File

@@ -0,0 +1,102 @@
package harness
import (
"context"
"testing"
"ragflow/internal/service/nav"
)
// TestAskNavSelect_IndexBased asserts the model's index-based selection maps
// back to the item subset.
func TestAskNavSelect_IndexBased(t *testing.T) {
installChat(t, `{"relevant":[0,2]}`)
items := []navSelectItem{
{Name: "Alpha", Description: "aaa"},
{Name: "Beta", Description: "bbb"},
{Name: "Gamma", Description: "ggg"},
}
out := askNavSelect(context.Background(), nil, "query", "clusters", items, 10)
if len(out) != 2 {
t.Fatalf("selected = %d, want 2", len(out))
}
if out[0].Name != "Alpha" || out[1].Name != "Gamma" {
t.Errorf("selected names = %q, %q; want Alpha, Gamma", out[0].Name, out[1].Name)
}
}
// TestAskNavSelect_Empty asserts an empty "relevant" list yields nothing.
func TestAskNavSelect_Empty(t *testing.T) {
installChat(t, `{"relevant":[]}`)
if out := askNavSelect(context.Background(), nil, "q", "clusters", []navSelectItem{{Name: "A"}}, 10); len(out) != 0 {
t.Errorf("expected no selection, got %d", len(out))
}
}
// TestAskNavSelect_OutOfRange asserts invalid indices are skipped.
func TestAskNavSelect_OutOfRange(t *testing.T) {
installChat(t, `{"relevant":[0,99,-1]}`)
out := askNavSelect(context.Background(), nil, "q", "clusters", []navSelectItem{{Name: "A"}, {Name: "B"}}, 10)
if len(out) != 1 || out[0].Name != "A" {
t.Errorf("out-of-range selection = %+v, want [A]", out)
}
}
// TestNavigateDatasetByTree_NoQuery asserts empty query returns nil without
// calling anything.
func TestNavigateDatasetByTree_NoQuery(t *testing.T) {
if out := NavigateDatasetByTree(context.Background(), nil, nil, "t1", "kb1", " "); out != nil {
t.Errorf("expected nil for empty query, got %v", out)
}
}
// fakeNavSvcHarness is an in-memory nav.NavService for the BFS test.
type fakeNavSvcHarness struct {
clusters []nav.NavNode
children map[string][]nav.NavNode
}
func (f *fakeNavSvcHarness) UpsertDoc(context.Context, nav.UpsertDocInput) error { return nil }
func (f *fakeNavSvcHarness) RemoveDoc(context.Context, string, string, string) error {
return nil
}
func (f *fakeNavSvcHarness) Search(context.Context, string, string, string, []float32, int) ([]nav.NavHit, error) {
return nil, nil
}
func (f *fakeNavSvcHarness) ListClusters(context.Context, string, string, int, int) ([]nav.NavNode, int64, error) {
return f.clusters, int64(len(f.clusters)), nil
}
func (f *fakeNavSvcHarness) ListChildren(_ context.Context, _, _, name string, _, _ int) ([]nav.NavNode, int64, error) {
return f.children[name], int64(len(f.children[name])), nil
}
// TestCollectNavLeaves_BFS asserts document leaves are collected, sub-clusters
// descended, and leaves deduped by doc_id.
func TestCollectNavLeaves_BFS(t *testing.T) {
ns := &fakeNavSvcHarness{
clusters: []nav.NavNode{{Name: "C1", Description: "cluster 1"}},
children: map[string][]nav.NavNode{
"C1": {
{Name: "Sub", Type: "cluster"},
{Name: "DocA", Type: "doc", DocID: "d1"},
{Name: "DocB", Type: "doc", DocID: "d2"},
},
"Sub": {{Name: "DocC", Type: "doc", DocID: "d3"}},
},
}
selected := []navSelectItem{{Name: "C1", Description: "cluster 1"}}
leaves := collectNavLeaves(context.Background(), ns, "t1", "kb1", selected)
if len(leaves) != 3 {
t.Fatalf("leaves = %d, want 3 (d1,d2 from C1 + d3 from Sub)", len(leaves))
}
seen := map[string]bool{}
for _, l := range leaves {
if l.DocID == "" {
t.Errorf("leaf %q has empty doc_id", l.Name)
}
seen[l.DocID] = true
}
if !seen["d1"] || !seen["d2"] || !seen["d3"] {
t.Errorf("collected doc_ids = %v, want d1,d2,d3", seen)
}
}

View File

@@ -0,0 +1,281 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
"ragflow/internal/agent/chat"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
)
// Structure navigation mirrors Python navigation.py (ontology_navigate /
// mindmap_navigate). These live in the harness package (not agent/tool) because
// they need the chat invoker, and agent/tool → agent/component would form an
// import cycle.
const (
toolOntologyNavigate = "ontology_navigate"
toolMindmapNavigate = "mindmap_navigate"
)
var catalogKinds = map[string]bool{"tree": true, "timeline": true, "raptor": true, "page_index": true, "pageindex": true}
var mindmapKinds = map[string]bool{"mindmap": true, "mind_map": true}
const navSystemPrompt = `You are given the {noun} of one or more documents — an outline of entities and their relations — and a question.
Decide whether that outline alone already answers the question.
Rules:
1. Answer ONLY from the outline below. Do not invent facts.
2. Set "is_sufficient" to true only when the outline genuinely answers the question; otherwise false with an empty answer.
3. Always fill "relevant_entities" with the exact ` + "`name`" + ` values of the entities most related to the question (up to 10), even when the outline is not sufficient — they are used to pull the underlying source text.
Output ONLY JSON, no prose, no code fences:
{"is_sufficient": true/false, "answer": "<answer, or empty>", "relevant_entities": ["<entity name>", ...]}`
const (
maxStructureEntities = 300
maxStructureRelations = 300
maxEvidenceChunks = 24
)
type structureNavArgs struct {
Topic string `json:"topic"`
Keywords string `json:"keywords,omitempty"`
DocScope []string `json:"doc_scope,omitempty"`
}
type structureEntity struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
SourceChunkIDs []string `json:"source_chunk_ids"`
DocID string `json:"-"`
}
type structureNavVerdict struct {
IsSufficient bool `json:"is_sufficient"`
Answer string `json:"answer"`
RelevantEntities []string `json:"relevant_entities"`
}
// NavigateStructure implements ontology_navigate / mindmap_navigate. It reads
// the compiled structure (entities) of the in-scope documents, asks the chat
// model which entities answer the question, and pulls the source chunks behind
// the selected entities. Routing only — returns empty on any failure.
func NavigateStructure(ctx context.Context, tenantID string, kind string, args structureNavArgs) (string, error) {
noun := "catalog"
var kinds map[string]bool
if kind == toolMindmapNavigate {
kinds = mindmapKinds
noun = "mindmap"
} else {
kinds = catalogKinds
}
query := strings.TrimSpace(args.Topic + " " + args.Keywords)
if query == "" || len(args.DocScope) == 0 {
return `{"chunks":[]}`, nil
}
var entities []structureEntity
for _, docID := range args.DocScope {
es := loadStructureEntities(ctx, tenantID, docID, kinds)
for _, e := range es {
if e.Name != "" {
e.DocID = docID
entities = append(entities, e)
}
}
}
if len(entities) == 0 {
return `{"chunks":[]}`, nil
}
selected, err := askStructureSelect(ctx, query, noun, entities)
if err != nil || len(selected) == 0 {
return `{"chunks":[]}`, nil
}
idsByDoc := map[string][]string{}
for _, e := range selected {
idsByDoc[e.DocID] = append(idsByDoc[e.DocID], e.SourceChunkIDs...)
}
var chunks []map[string]interface{}
for _, ids := range idsByDoc {
chunks = append(chunks, loadChunksByIDs(ctx, tenantID, dedupStrings(ids))...)
if len(chunks) >= maxEvidenceChunks {
break
}
}
b, _ := json.Marshal(map[string]interface{}{"chunks": chunks})
return string(b), nil
}
func loadStructureEntities(ctx context.Context, tenantID, docID string, kinds map[string]bool) []structureEntity {
de := engine.Get()
if de == nil {
return nil
}
idx := fmt.Sprintf("ragflow_%s", tenantID)
req := &types.SearchRequest{
IndexNames: []string{idx},
Filter: map[string]interface{}{"doc_id": []string{docID}, "knowledge_graph_kwd": []string{"graph"}},
SelectFields: []string{"content_with_weight", "compile_kwd", "compilation_template_kind_kwd"},
Limit: 1000,
}
res, err := de.Search(ctx, req)
if err != nil {
return nil
}
var out []structureEntity
for _, row := range res.Chunks {
kind := normalizeKind(row)
if !kinds[kind] {
continue
}
payload, _ := row["content_with_weight"].(string)
var graph struct {
Entities []structureEntity `json:"entities"`
}
if err := json.Unmarshal([]byte(payload), &graph); err != nil {
continue
}
out = append(out, graph.Entities...)
}
return out
}
func normalizeKind(row map[string]interface{}) string {
if ck, _ := row["compile_kwd"].(string); ck == "raptor_graph" {
return "raptor"
}
kind, _ := row["compilation_template_kind_kwd"].(string)
if kind == "" {
kind, _ = row["compile_kwd"].(string)
}
kind = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(kind, "-", "_")))
if kind == "pageindex" || kind == "page_index" || kind == "knowledge_graph" {
return "timeline"
}
return kind
}
func askStructureSelect(ctx context.Context, query, noun string, entities []structureEntity) ([]structureEntity, error) {
rendered := renderStructureEntities(entities)
inv := chat.GetDefaultInvoker()
if inv == nil {
return nil, fmt.Errorf("dataset navigation: chat invoker not configured")
}
resp, err := inv.Invoke(ctx, nil, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: strings.ReplaceAll(navSystemPrompt, "{noun}", noun)},
{Role: schema.User, Content: fmt.Sprintf("Question:\n%s\n\n%s:\n%s\n\nOutput JSON:", query, noun, rendered)},
},
})
if err != nil {
return nil, err
}
var v structureNavVerdict
if err := unmarshalModelJSON(resp.Content, &v); err != nil {
return nil, err
}
want := map[string]bool{}
for _, n := range v.RelevantEntities {
want[n] = true
}
var out []structureEntity
for _, e := range entities {
if want[e.Name] {
out = append(out, e)
}
}
return out, nil
}
func renderStructureEntities(entities []structureEntity) string {
var b strings.Builder
b.WriteString("Entities:")
for i, e := range entities {
if i >= maxStructureEntities {
break
}
b.WriteString("\n- " + e.Name + " (" + orStr(e.Type, "other") + ")")
if d := strings.Join(strings.Fields(e.Description), " "); d != "" {
b.WriteString(": " + d)
}
}
return b.String()
}
func loadChunksByIDs(ctx context.Context, tenantID string, ids []string) []map[string]interface{} {
if len(ids) == 0 {
return nil
}
de := engine.Get()
if de == nil {
return nil
}
idx := fmt.Sprintf("ragflow_%s", tenantID)
limit := maxEvidenceChunks
if len(ids) < limit {
limit = len(ids)
}
req := &types.SearchRequest{
IndexNames: []string{idx},
Filter: map[string]interface{}{"id": ids},
SelectFields: []string{"content_with_weight", "docnm_kwd", "doc_id"},
Limit: limit,
}
res, err := de.Search(ctx, req)
if err != nil {
return nil
}
var out []map[string]interface{}
for _, row := range res.Chunks {
out = append(out, map[string]interface{}{
"chunk_id": row["id"], "content_with_weight": row["content_with_weight"],
"docnm_kwd": row["docnm_kwd"], "doc_id": row["doc_id"],
})
}
return out
}
func dedupStrings(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if s != "" && !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
func orStr(v, def string) string {
if v == "" {
return def
}
return v
}

View File

@@ -0,0 +1,65 @@
package harness
import (
"context"
"strings"
"testing"
)
// TestRenderStructureEntities asserts the prompt outline is compact and includes
// entity name/type/description.
func TestRenderStructureEntities(t *testing.T) {
entities := []structureEntity{
{Name: "Rocket", Type: "concept", Description: "a propulsion device"},
{Name: "Engine", Type: "part"},
}
out := renderStructureEntities(entities)
if out == "" {
t.Fatal("expected rendered outline")
}
if !containsAll(out, "Rocket", "Engine", "concept") {
t.Errorf("rendered outline missing entities: %q", out)
}
}
// TestNavigateStructure_NoScope asserts empty doc scope yields empty chunks
// without calling the model.
func TestNavigateStructure_NoScope(t *testing.T) {
out, err := NavigateStructure(context.Background(), "t1", toolOntologyNavigate, structureNavArgs{
Topic: "rocket", DocScope: nil,
})
if err != nil {
t.Fatal(err)
}
if out != `{"chunks":[]}` {
t.Errorf("no-scope result = %q, want empty chunks", out)
}
}
// TestNormalizeKind mirrors the API's kind normalization.
func TestNormalizeKind(t *testing.T) {
cases := []struct {
row map[string]interface{}
want string
}{
{map[string]interface{}{"compile_kwd": "raptor_graph"}, "raptor"},
{map[string]interface{}{"compilation_template_kind_kwd": "page_index"}, "timeline"},
{map[string]interface{}{"compilation_template_kind_kwd": "knowledge_graph"}, "timeline"},
{map[string]interface{}{"compilation_template_kind_kwd": "mindmap"}, "mindmap"},
{map[string]interface{}{"compile_kwd": "tree"}, "tree"},
}
for _, c := range cases {
if got := normalizeKind(c.row); got != c.want {
t.Errorf("normalizeKind(%v) = %q, want %q", c.row, got, c.want)
}
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !strings.Contains(s, sub) {
return false
}
}
return true
}

View File

@@ -0,0 +1,247 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"fmt"
"strings"
)
// Orchestration input/output mirrors Python orchestrator (direct.py / decompose.py).
// SearchFn performs one hybrid search for a query and returns chunks + doc aggs,
// so the orchestrator is decoupled from the concrete retrieval backend.
type SearchFn func(ctx context.Context, query, keywords string) ([]map[string]interface{}, []map[string]interface{})
// Kbinfos is the shared accumulation store (Python tools.kbinfos).
type Kbinfos struct {
Chunks []map[string]interface{}
DocAggs []map[string]interface{}
}
func (k *Kbinfos) HasChunks() bool { return len(k.Chunks) > 0 }
// Merge appends the given chunks/aggs, deduplicating by chunkKey. It returns the
// GLOBAL indices (positions in k.Chunks after the merge) of the chunks this call
// contributed, so callers can store stable evidence references across multiple
// Merge calls (per-search indices would diverge from the accumulated list).
func (k *Kbinfos) Merge(chunks, aggs []map[string]interface{}) []int {
seen := map[string]bool{}
for _, c := range k.Chunks {
seen[chunkKey(c)] = true
}
var added []int
for _, c := range chunks {
kk := chunkKey(c)
if !seen[kk] {
seen[kk] = true
k.Chunks = append(k.Chunks, c)
}
// Record the global index of every contributed chunk (dedup or new),
// so EvidenceIDs always reference the accumulated kbinfos positions.
added = append(added, indexOfChunk(k.Chunks, kk))
}
dseen := map[string]bool{}
for _, d := range k.DocAggs {
if id, _ := d["doc_id"].(string); id != "" {
dseen[id] = true
}
}
for _, d := range aggs {
if id, _ := d["doc_id"].(string); id != "" && !dseen[id] {
dseen[id] = true
k.DocAggs = append(k.DocAggs, d)
}
}
return added
}
// indexOfChunk returns the global index of the chunk whose key matches kk.
func indexOfChunk(chunks []map[string]interface{}, kk string) int {
for i, c := range chunks {
if chunkKey(c) == kk {
return i
}
}
return -1
}
// chunkKey returns a stable dedup key for a chunk. Prefers chunk_id/id when
// present; otherwise falls back to a content-derived hash so chunks without an
// id still dedup correctly (and never collide on a shared empty/"" key).
func chunkKey(c map[string]interface{}) string {
if id, ok := c["chunk_id"].(string); ok && id != "" {
return "cid:" + id
}
if id, ok := c["id"].(string); ok && id != "" {
return "id:" + id
}
content := ""
if t, ok := c["content_with_weight"].(string); ok {
content = t
} else if t, ok := c["content"].(string); ok {
content = t
}
if content == "" {
// No stable identity: fall back to the doc reference so at least
// per-document grouping is preserved (rarely reached).
content = fmt.Sprintf("%s|%s", anyString(c["doc_id"]), anyString(c["docnm_kwd"]))
}
return "h:" + fnv64(content)
}
func anyString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// fnv64 is a deterministic non-crypto hash for dedup keys.
func fnv64(s string) string {
h := uint64(14695981039346656037)
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= 1099511628211
}
return fmt.Sprintf("%016x", h)
}
// OrchestratorResult mirrors the state updates returned by the Python
// orchestrator nodes.
type OrchestratorResult struct {
Verdict *SufficiencyVerdict
PartialAnswer bool
Abstain bool
EmptyResult bool
Kbinfos *Kbinfos
}
// DirectSearch is the low-mode orchestrator: one hybrid search → merge.
func DirectSearch(ctx context.Context, search SearchFn, question, keywords string, kbinfos *Kbinfos) OrchestratorResult {
if kbinfos == nil {
kbinfos = &Kbinfos{}
}
chunks, aggs := search(ctx, question, keywords)
kbinfos.Merge(chunks, aggs)
if !kbinfos.HasChunks() {
return OrchestratorResult{EmptyResult: true, Kbinfos: kbinfos}
}
return OrchestratorResult{Kbinfos: kbinfos}
}
// DecomposeAndSearch is the medium-mode orchestrator: decompose → parallel
// search → cross-check → fusion → iterate until a verdict stops it.
func DecomposeAndSearch(ctx context.Context, search SearchFn, question, keywords string, claims []*ClaimTarget, modeLabel string, kbinfos *Kbinfos) OrchestratorResult {
if kbinfos == nil {
kbinfos = &Kbinfos{}
}
mode, _ := GetMode(modeLabel)
if mode.Label == "" {
mode = THINKING_MODES["medium"]
}
unverified := func() []*ClaimTarget {
var out []*ClaimTarget
for _, c := range claims {
if !c.IsVerified {
out = append(out, c)
}
}
return out
}
for cycle := 0; cycle < mode.MaxOrchestratorCycles; cycle++ {
uv := unverified()
if len(uv) == 0 {
break
}
for _, c := range uv {
chunks, aggs := search(ctx, c.Description, keywords)
if len(chunks) > 0 {
c.IsVerified = true
c.Confidence = 0.8
// Merge returns the GLOBAL indices of this claim's chunks in the
// accumulated kbinfos, which is what CrossCheckClaim resolves
// against (allChunks is keyed by that global position).
global := kbinfos.Merge(chunks, aggs)
c.AgentResult = &AgentResult{
ClaimID: c.ClaimID, Report: summarize(chunks), IsVerified: true, Confidence: 0.8,
EvidenceIDs: global,
}
} else {
c.AgentResult = &AgentResult{ClaimID: c.ClaimID, IsVerified: false, Confidence: 0.0}
}
}
allChunks := map[int]map[string]interface{}{}
for i, c := range kbinfos.Chunks {
allChunks[i] = c
}
var agentResults []AgentResult
var crossResults []ClaimCrossCheckResult
for _, c := range claims {
if c.AgentResult != nil {
agentResults = append(agentResults, *c.AgentResult)
crossResults = append(crossResults, CrossCheckClaim(c.AgentResult, allChunks))
}
}
verdict := ComputeFusionScore(agentResults, crossResults, mode)
action, _ := RouteSufficiencyVerdict(verdict, modeLabel, cycle, mode.MaxOrchestratorCycles)
switch action {
case "ANSWER", "ANSWER_PARTIAL":
return OrchestratorResult{Verdict: &verdict, PartialAnswer: action == "ANSWER_PARTIAL", Kbinfos: kbinfos}
case "ABSTAIN":
kbinfos.Chunks = nil
return OrchestratorResult{Verdict: &verdict, Abstain: true, Kbinfos: kbinfos}
case "REPLAN":
// Reset unverified for another cycle (simplified: continue loop).
case "CONTINUE":
// fallthrough to next cycle
}
}
return OrchestratorResult{Kbinfos: kbinfos}
}
func summarize(chunks []map[string]interface{}) string {
var parts []string
n := 3
if len(chunks) < n {
n = len(chunks)
}
for _, c := range chunks[:n] {
text := ""
if t, ok := c["content_with_weight"].(string); ok {
text = t
} else if t, ok := c["content"].(string); ok {
text = t
}
if len(text) > 200 {
text = text[:200]
}
parts = append(parts, text)
}
return strings.Join(parts, " | ")
}
func intRange(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
}

View File

@@ -0,0 +1,135 @@
package harness
import (
"context"
"testing"
)
// TestCrossCheckClaim_NumberMatch asserts a verified report whose numbers appear
// in the evidence chunk passes the cross-check.
func TestCrossCheckClaim_NumberMatch(t *testing.T) {
agent := &AgentResult{ClaimID: "c0", IsVerified: true, Report: "speed is 88 and 12", EvidenceIDs: []int{0}}
chunks := map[int]map[string]interface{}{0: {"content_with_weight": "speed is 88 and 12 here"}}
r := CrossCheckClaim(agent, chunks)
if !r.CrossCheckPassed {
t.Errorf("cross-check should pass, got mismatches %v", r.Mismatches)
}
}
// TestKbinfosMerge_GlobalIndices asserts Merge returns the GLOBAL indices of the
// contributed chunks in the accumulated list, so a later claim's EvidenceIDs
// still resolve correctly after earlier claims pushed more chunks in.
func TestKbinfosMerge_GlobalIndices(t *testing.T) {
kb := &Kbinfos{}
// First claim's search returns chunks a,b.
first := kb.Merge([]map[string]interface{}{
{"chunk_id": "a", "content_with_weight": "alpha 7"},
{"chunk_id": "b", "content_with_weight": "beta"},
}, nil)
// a->0, b->1 in the global list.
if len(first) != 2 || first[0] != 0 || first[1] != 1 {
t.Fatalf("first merge global indices = %v, want [0 1]", first)
}
// Second claim's search returns chunk c (now global index 2) and a dup of a.
second := kb.Merge([]map[string]interface{}{
{"chunk_id": "c", "content_with_weight": "gamma 9"},
{"chunk_id": "a", "content_with_weight": "alpha 7"},
}, nil)
// c->2, a(dup)->0; NOT per-search [0 1].
if len(second) != 2 || second[0] != 2 || second[1] != 0 {
t.Fatalf("second merge global indices = %v, want [2 0]", second)
}
// CrossCheckClaim must resolve both claims' evidence against the global list.
allChunks := map[int]map[string]interface{}{}
for i, c := range kb.Chunks {
allChunks[i] = c
}
r1 := CrossCheckClaim(&AgentResult{ClaimID: "c1", IsVerified: true, Report: "alpha 7", EvidenceIDs: first}, allChunks)
if !r1.HasEvidence {
t.Error("claim 1 should have evidence at global indices")
}
r2 := CrossCheckClaim(&AgentResult{ClaimID: "c2", IsVerified: true, Report: "gamma 9", EvidenceIDs: second}, allChunks)
if !r2.HasEvidence {
t.Error("claim 2 should have evidence at global index 2 (not per-search 0)")
}
}
// TestCrossCheckClaim_Unverified asserts an unverified agent fails the check.
func TestCrossCheckClaim_Unverified(t *testing.T) {
r := CrossCheckClaim(&AgentResult{ClaimID: "c0", IsVerified: false}, nil)
if r.CrossCheckPassed {
t.Error("unverified agent must fail cross-check")
}
}
// TestComputeFusionScore_Sufficient asserts a fully-verified high-score set is
// SUFFICIENT.
func TestComputeFusionScore_Sufficient(t *testing.T) {
agents := []AgentResult{{ClaimID: "c0", IsVerified: true, Report: "value 42", EvidenceIDs: []int{0}}}
cross := []ClaimCrossCheckResult{{ClaimID: "c0", CrossCheckPassed: true, CrossCheckScore: 1.0, HasEvidence: true}}
v := ComputeFusionScore(agents, cross, THINKING_MODES["medium"])
if v.Status != "SUFFICIENT" {
t.Errorf("status = %q, want SUFFICIENT", v.Status)
}
}
// TestComputeFusionScore_NoEvidence asserts a claim with no examined evidence is
// UNANSWERABLE (empty-evidence guard).
func TestComputeFusionScore_NoEvidence(t *testing.T) {
cross := []ClaimCrossCheckResult{{ClaimID: "c0", CrossCheckPassed: true, CrossCheckScore: 1.0, HasEvidence: false}}
v := ComputeFusionScore([]AgentResult{{ClaimID: "c0", IsVerified: true}}, cross, THINKING_MODES["medium"])
if v.Status != "UNANSWERABLE" {
t.Errorf("status = %q, want UNANSWERABLE", v.Status)
}
if len(v.MissingClaims) != 1 {
t.Errorf("missing claims = %v, want [c0]", v.MissingClaims)
}
}
// TestRouteSufficiencyVerdict asserts SUFFICIENT → ANSWER.
func TestRouteSufficiencyVerdict(t *testing.T) {
action, cont := RouteSufficiencyVerdict(SufficiencyVerdict{Status: "SUFFICIENT", Score: 0.9}, "medium", 0, 3)
if action != "ANSWER" || cont {
t.Errorf("got (%q,%v), want (ANSWER,false)", action, cont)
}
}
// TestDirectSearch_Merges asserts direct search merges chunks and flags empty.
func TestDirectSearch_Merges(t *testing.T) {
kb := &Kbinfos{}
res := DirectSearch(context.Background(), func(_ context.Context, _, _ string) ([]map[string]interface{}, []map[string]interface{}) {
return []map[string]interface{}{{"chunk_id": "a", "content_with_weight": "alpha"}}, nil
}, "q", "", kb)
if res.EmptyResult || !kb.HasChunks() {
t.Errorf("expected merged chunks, empty=%v chunks=%d", res.EmptyResult, len(kb.Chunks))
}
}
// TestDirectSearch_Empty asserts direct search flags empty when no chunks.
func TestDirectSearch_Empty(t *testing.T) {
kb := &Kbinfos{}
res := DirectSearch(context.Background(), func(_ context.Context, _, _ string) ([]map[string]interface{}, []map[string]interface{}) {
return nil, nil
}, "q", "", kb)
if !res.EmptyResult {
t.Error("expected empty_result=true")
}
}
// TestDecomposeAndSearch_Verifies asserts a searchable claim gets verified and
// the loop stops on ANSWER.
func TestDecomposeAndSearch_Verifies(t *testing.T) {
claims := []*ClaimTarget{{ClaimID: "c0", Description: "fact 42 about X"}}
kb := &Kbinfos{}
res := DecomposeAndSearch(context.Background(),
func(_ context.Context, _, _ string) ([]map[string]interface{}, []map[string]interface{}) {
return []map[string]interface{}{{"chunk_id": "x", "content_with_weight": "the value is 42"}}, nil
},
"Q", "", claims, "medium", kb)
if !claims[0].IsVerified {
t.Error("claim should be verified")
}
if res.Verdict == nil {
t.Error("expected a verdict")
}
}

View File

@@ -0,0 +1,164 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"fmt"
"log"
"strings"
"github.com/cloudwego/eino/schema"
"ragflow/internal/agent/chat"
"gorm.io/gorm"
)
// decomposePrompt is a faithful port of Python's DECOMPOSE_FACTUAL prompt shape.
// Question-type-specific variants share the same structure but tune the
// instructions; the Go planner selects by question_type.
const decomposePrompt = `Break down the research question into %d atomic claims (facts or sub-questions).
Question: %s
Detail level: %s
Grounding context (preliminary retrieval, may be empty):
%s
Output format:
{
"claims": [
{"claim_id": "c0", "description": "...", "priority": 0, "suggested_tools": []}
]
}
`
type plannerClaim struct {
ClaimID string `json:"claim_id"`
Description string `json:"description"`
Priority int `json:"priority"`
SuggestedTools []string `json:"suggested_tools"`
}
type plannerResult struct {
Claims []plannerClaim `json:"claims"`
}
// PlannerNode mirrors Python planner_node. It decomposes the routed question
// into ClaimTargets. Direct mode (no decomposition) returns a single coarse
// claim. On any failure it falls back to the direct plan.
func PlannerNode(ctx context.Context, db *gorm.DB, route RouteDecision, seedChunks []string) WorkflowPlan {
if !route.RequiresDecomposition {
return directPlan(route.Question)
}
mode, ok := GetMode(route.ThinkingMode)
if !ok {
// Unknown or empty mode label: fall back to medium so the planner is not
// driven by a zero-valued mode (which would yield a degenerate plan).
mode = THINKING_MODES["medium"]
}
maxClaims := maxClaimsFor(mode.Label)
detail := detailLevelFor(mode.Label)
prompt := fmt.Sprintf(decomposePrompt, maxClaims, route.Question, detail, formatSeedChunks(seedChunks))
inv := chat.GetDefaultInvoker()
if inv == nil {
log.Printf("agentic_rag: planner_node skipped (chat invoker not configured); fallback direct")
return directPlan(route.Question)
}
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: prompt},
{Role: schema.User, Content: route.Question},
},
})
if err != nil {
log.Printf("agentic_rag: planner_node failed (fallback direct): %v", err)
return directPlan(route.Question)
}
var res plannerResult
if err := unmarshalModelJSON(resp.Content, &res); err != nil {
log.Printf("agentic_rag: planner_node parse failed (fallback direct): %v", err)
return directPlan(route.Question)
}
claims := make([]ClaimTarget, 0, len(res.Claims))
for i, c := range res.Claims {
desc := strings.TrimSpace(c.Description)
if desc == "" {
continue
}
claims = append(claims, ClaimTarget{
ClaimID: orDefault(c.ClaimID, fmt.Sprintf("c%d", i)),
Description: desc,
Priority: c.Priority,
SuggestedTools: c.SuggestedTools,
})
}
if len(claims) == 0 {
return directPlan(route.Question)
}
return WorkflowPlan{
PlanType: planTypeFor(route.QuestionType),
Claims: claims,
MaxIterations: mode.MaxOrchestratorCycles,
}
}
func directPlan(question string) WorkflowPlan {
return WorkflowPlan{
PlanType: "direct",
Claims: []ClaimTarget{{ClaimID: "c0", Description: question, Priority: 0}},
MaxIterations: 1,
}
}
func planTypeFor(qt string) string {
switch qt {
case "factual":
return "fact_decomposition"
case "comparative":
return "comparative_decomposition"
case "procedural":
return "procedural_decomposition"
default:
return "exploratory_decomposition"
}
}
func maxClaimsFor(modeLabel string) int {
return map[string]int{"low": 1, "medium": 3, "high": 5, "ultra": 8}[modeLabel]
}
func detailLevelFor(modeLabel string) string {
return map[string]string{"low": "coarse", "medium": "normal", "high": "fine", "ultra": "extra_fine"}[modeLabel]
}
func formatSeedChunks(seedChunks []string) string {
if len(seedChunks) == 0 {
return "(no preliminary results)"
}
return strings.Join(seedChunks, "\n")
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}

View File

@@ -0,0 +1,147 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
einotool "github.com/cloudwego/eino/components/tool"
"gorm.io/gorm"
"ragflow/internal/agent/tool"
"ragflow/internal/service/nav"
)
// ProductionRunner wires the real agentic-search tools (hybrid_search,
// dataset_navigation_by_tree) into the RunAgenticRAG flow, so the tools are
// actually invoked rather than merely registered. This is the production
// counterpart to the unit-testable SearchFn seam.
type ProductionRunner struct {
db *gorm.DB
tenantID string
datasetIDs []string
searchTool einotool.InvokableTool
navSvc nav.NavService // defaults to nav.GetNavService() when nil
}
// NewProductionRunner builds a ProductionRunner backed by the real tools. The
// dataset-nav router (harness.NavigateDatasetByTree) resolves its NavService
// lazily via nav.GetNavService().
func NewProductionRunner(db *gorm.DB, tenantID string, datasetIDs []string) (*ProductionRunner, error) {
searchBase, err := tool.BuildByName("hybrid_search", nil)
if err != nil {
return nil, err
}
search, ok := searchBase.(einotool.InvokableTool)
if !ok {
return nil, fmt.Errorf("hybrid_search is not invokable")
}
return &ProductionRunner{db: db, tenantID: tenantID, datasetIDs: datasetIDs, searchTool: search}, nil
}
// newProductionRunnerWithTools builds a ProductionRunner with an injected
// search tool and nav service, for unit/E2E tests that want to fake the
// invocation surface without real services.
func newProductionRunnerWithTools(db *gorm.DB, tenantID string, datasetIDs []string, searchTool einotool.InvokableTool, navSvc nav.NavService) *ProductionRunner {
return &ProductionRunner{db: db, tenantID: tenantID, datasetIDs: datasetIDs, searchTool: searchTool, navSvc: navSvc}
}
// Run executes the agentic-search graph with the real tools. It returns the
// final answer.
func (r *ProductionRunner) Run(ctx context.Context, question, keywords, modeLabel string) AnswerResult {
if r.searchTool == nil {
log.Printf("agentic_rag: production runner not fully wired (search tool missing)")
return AnswerResult{FinalAnswer: emptyResultMessage, Empty: true}
}
// The router tool returns a doc list; feed it as the search DocScope.
searchFn := func(ctx context.Context, query, kws string) ([]map[string]interface{}, []map[string]interface{}) {
return r.search(ctx, query, kws, nil)
}
// For decomposition modes, route the doc scope first via the nav tool.
if mode, _ := GetMode(modeLabel); mode.RequiresDecomposition {
docs := r.routeDocs(ctx, question, keywords)
if len(docs) > 0 {
searchFn = func(ctx context.Context, query, kws string) ([]map[string]interface{}, []map[string]interface{}) {
return r.search(ctx, query, kws, docs)
}
}
}
return RunAgenticRAG(ctx, r.db, question, keywords, modeLabel, searchFn)
}
// search invokes the hybrid_search tool and normalizes its chunk output.
func (r *ProductionRunner) search(ctx context.Context, query, keywords string, docScope []string) ([]map[string]interface{}, []map[string]interface{}) {
args := map[string]interface{}{"query": query, "keywords": keywords, "kb_ids": r.datasetIDs, "top_n": 12}
if len(docScope) > 0 {
args["doc_scope"] = docScope
}
raw, err := r.searchTool.InvokableRun(ctx, mustJSON(args))
if err != nil {
log.Printf("agentic_rag: hybrid_search failed: %v", err)
return nil, nil
}
var res struct {
Chunks []map[string]interface{} `json:"chunks"`
}
if err := json.Unmarshal([]byte(raw), &res); err != nil {
return nil, nil
}
return res.Chunks, nil
}
// routeDocs derives the doc scope via the canonical dataset-nav router
// (harness.NavigateDatasetByTree — the full LLM two-round selection). It routes
// across ALL bound datasets and merges the doc ids, so every KB contributes its
// own relevant docs to the shared scope (a multi-KB session must not collapse to
// the first KB only).
func (r *ProductionRunner) routeDocs(ctx context.Context, topic, keywords string) []string {
ns := r.navSvc
if ns == nil {
ns = nav.GetNavService()
}
if ns == nil {
log.Printf("agentic_rag: dataset nav service not initialized; skipping doc routing")
return nil
}
// Combine topic + keywords into the routing query so the nav router actually
// uses the full user signal (keywords must not be dropped).
query := strings.TrimSpace(topic + " " + keywords)
seen := map[string]bool{}
var docs []string
for _, kbID := range r.datasetIDs {
for _, id := range NavigateDatasetByTree(ctx, r.db, ns, r.tenantID, kbID, query) {
if id != "" && !seen[id] {
seen[id] = true
docs = append(docs, id)
}
}
}
return docs
}
func mustJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}

View File

@@ -0,0 +1,144 @@
package harness
import (
"context"
"strings"
"sync"
"testing"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
"ragflow/internal/agent/component"
"ragflow/internal/service/nav"
)
// routeChat returns a model-style response depending on the stage: nav-selection
// calls return {"relevant":[...]}, all other calls return a plain final answer.
type routeChat struct{}
func (routeChat) Invoke(_ context.Context, _ *gorm.DB, req component.ChatInvokeRequest) (*component.ChatInvokeResponse, error) {
content := ""
msg := ""
if len(req.Messages) > 0 {
msg = req.Messages[len(req.Messages)-1].Content
}
switch {
case strings.Contains(msg, "(numbered)") || strings.Contains(msg, "Entities"):
// nav-select pass: keep every item (all relevant).
content = `{"relevant":[0,1,2,3,4,5,6,7,8,9]}`
default:
content = "final scoped answer"
}
return &component.ChatInvokeResponse{Content: content}, nil
}
// installRouteChat installs the stage-aware chat invoker for E2E tests.
func installRouteChat(t *testing.T) {
t.Helper()
component.SetDefaultChatInvoker(routeChat{})
t.Cleanup(func() { component.SetDefaultChatInvoker(nil) })
}
// fakeInvokableTool is an einotool.InvokableTool double for E2E testing.
type fakeInvokableTool struct {
name string
fn func(ctx context.Context, argsJSON string) string
mu sync.Mutex
lastArgs string
}
func (f *fakeInvokableTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{Name: f.name}, nil
}
func (f *fakeInvokableTool) InvokableRun(_ context.Context, argsJSON string, _ ...einotool.Option) (string, error) {
f.mu.Lock()
f.lastArgs = argsJSON
f.mu.Unlock()
return f.fn(context.Background(), argsJSON), nil
}
func (f *fakeInvokableTool) args() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastArgs
}
// TestProductionRunner_E2E_RouteToScopedSearch proves the chain
// "user question → dataset nav routes doc scope → hybrid_search retrieval is
// scoped to those docs". It injects a fake nav service (returns docs d1,d2) and
// asserts the search tool receives doc_scope=[d1,d2].
func TestProductionRunner_E2E_RouteToScopedSearch(t *testing.T) {
installRouteChat(t)
// Fake nav service whose two-round router returns docs d1,d2.
navSvc := &fakeNavSvcHarness{
clusters: []nav.NavNode{{Name: "C1", Description: "cluster"}},
children: map[string][]nav.NavNode{
"C1": {
{Name: "DocA", Type: "doc", DocID: "d1"},
{Name: "DocB", Type: "doc", DocID: "d2"},
},
},
}
// AskNavSelect returns all items (both docs) for the doc-select pass.
searchTool := &fakeInvokableTool{name: "hybrid_search", fn: func(_ context.Context, _ string) string {
return `{"chunks":[{"chunk_id":"c1","content_with_weight":"scoped evidence"}]}`
}}
// Multi-KB session: routing must cover every bound dataset, not just the
// first KB.
runner := newProductionRunnerWithTools(nil, "t1", []string{"kb1", "kb2"}, searchTool, navSvc)
res := runner.Run(context.Background(), "Compare A and B", "", "medium")
if res.FinalAnswer != "final scoped answer" {
t.Errorf("final answer = %q, want chat output", res.FinalAnswer)
}
// The search tool must receive doc_scope=[d1,d2] — the docs the nav router
// selected — proving retrieval is scoped to those docs across KBs.
if !strings.Contains(searchTool.args(), `"doc_scope":["d1","d2"]`) {
t.Errorf("hybrid_search not scoped to routed docs; args=%s", searchTool.args())
}
// The search tool must receive all bound KBs (not collapsed to one).
if !strings.Contains(searchTool.args(), `"kb_ids":["kb1","kb2"]`) {
t.Errorf("hybrid_search kb_ids must include all bound KBs; args=%s", searchTool.args())
}
}
// TestProductionRunner_E2E_NoRoute_SearchUnscoped asserts low mode (no
// decomposition) skips nav routing and searches without a doc_scope.
func TestProductionRunner_E2E_NoRoute_SearchUnscoped(t *testing.T) {
installRouteChat(t)
searchTool := &fakeInvokableTool{name: "hybrid_search", fn: func(_ context.Context, _ string) string {
return `{"chunks":[{"chunk_id":"c1","content_with_weight":"evidence"}]}`
}}
// A nav service is provided but must NOT be consulted in low mode.
navSvc := &fakeNavSvcHarness{clusters: []nav.NavNode{{Name: "C1"}}}
runner := newProductionRunnerWithTools(nil, "t1", []string{"kb1"}, searchTool, navSvc)
res := runner.Run(context.Background(), "What is X?", "", "low")
if res.FinalAnswer == "" {
t.Error("expected a final answer in low mode")
}
if strings.Contains(searchTool.args(), "doc_scope") {
t.Errorf("low mode search should have no doc_scope; args=%s", searchTool.args())
}
}
// TestProductionRunner_E2E_EmptyRoute_SearchUnscoped asserts an empty doc route
// falls back to an unscoped search (no hard failure).
func TestProductionRunner_E2E_EmptyRoute_SearchUnscoped(t *testing.T) {
installRouteChat(t)
searchTool := &fakeInvokableTool{name: "hybrid_search", fn: func(_ context.Context, _ string) string {
return `{"chunks":[{"chunk_id":"c1","content_with_weight":"evidence"}]}`
}}
// Nav service with no clusters -> empty route.
navSvc := &fakeNavSvcHarness{clusters: nil}
runner := newProductionRunnerWithTools(nil, "t1", []string{"kb1"}, searchTool, navSvc)
res := runner.Run(context.Background(), "Compare A and B", "", "medium")
if res.FinalAnswer == "" {
t.Error("expected an answer even when nav routing returns no docs")
}
}

View File

@@ -0,0 +1,133 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"context"
"encoding/json"
"log"
"regexp"
"strings"
"github.com/cloudwego/eino/schema"
"ragflow/internal/agent/chat"
"gorm.io/gorm"
)
// routePrompt mirrors Python harness/prompts/route_prompt.py.
const routePrompt = `Analyze the following question and output a structured query analysis.
Question: %s
Analyze it across these dimensions:
1. Question type: factual / comparative / analytical / procedural / exploratory / verification / summarization.
2. Whether it needs decomposition into atomic facts, meaning whether multiple independent pieces of information must be retrieved separately before answering: true/false.
3. Suggested knowledge compilation tool: null (none) / toc (document table of contents) / graph (knowledge graph) / wiki (compiled domain knowledge).
Output format (JSON):
{
"question_type": "comparative",
"requires_decomposition": true,
"suggests_compilation": null,
"reasoning": "This is a comparative question, so it needs to be decomposed into two independent facts and one comparison relation."
}
`
type routeResult struct {
QuestionType string `json:"question_type"`
RequiresDecomp *bool `json:"requires_decomposition"`
Reasoning string `json:"reasoning"`
}
// RouteNode mirrors Python route_node. It classifies the question into a
// RouteDecision using the default chat invoker. Pure classification, no KB
// dependency. Never fails — falls back to a factual/direct decision.
func RouteNode(ctx context.Context, db *gorm.DB, question, modeLabel string) RouteDecision {
if strings.TrimSpace(question) == "" {
return fallbackRoute(question, modeLabel, "fallback: empty question")
}
inv := chat.GetDefaultInvoker()
if inv == nil {
return fallbackRoute(question, modeLabel, "fallback: chat invoker not configured")
}
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: strings.ReplaceAll(routePrompt, "%s", question)},
{Role: schema.User, Content: question},
},
})
if err != nil {
log.Printf("agentic_rag: route_node failed (fallback): %v", err)
return fallbackRoute(question, modeLabel, "fallback: LLM error")
}
var res routeResult
if err := unmarshalModelJSON(resp.Content, &res); err != nil {
log.Printf("agentic_rag: route_node parse failed (fallback): %v", err)
return fallbackRoute(question, modeLabel, "fallback: parse error")
}
return decide(question, modeLabel, res)
}
// decide applies the mode's execution strategy to the LLM route output.
func decide(question, modeLabel string, res routeResult) RouteDecision {
mode, ok := GetMode(modeLabel)
if !ok {
mode = THINKING_MODES["medium"]
}
qType := res.QuestionType
if qType == "" {
qType = "factual"
}
needDecomp := true
if res.RequiresDecomp != nil {
needDecomp = *res.RequiresDecomp
}
return RouteDecision{
Question: question,
ThinkingMode: modeLabel,
QuestionType: qType,
RequiresDecomposition: mode.RequiresDecomposition && needDecomp,
ExecutionStrategy: mode.Strategy,
Reasoning: res.Reasoning,
}
}
func fallbackRoute(question, modeLabel, reason string) RouteDecision {
return RouteDecision{
Question: question, ThinkingMode: modeLabel, QuestionType: "factual",
RequiresDecomposition: false, ExecutionStrategy: "direct_search", Reasoning: reason,
}
}
var (
reThinkTag = regexp.MustCompile(`(?s)^.*</think>`)
reFence = regexp.MustCompile("```(?:json)?\\s*|\\s*```")
)
// unmarshalModelJSON mirrors Python's _extract_json: strip thinking preamble and
// markdown fences, then parse JSON.
func unmarshalModelJSON(text string, out interface{}) error {
text = reThinkTag.ReplaceAllString(text, "")
text = reFence.ReplaceAllString(text, "")
text = strings.TrimSpace(text)
if text == "" {
return json.Unmarshal([]byte("{}"), out)
}
return json.Unmarshal([]byte(text), out)
}

View File

@@ -0,0 +1,140 @@
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)
}
}

View File

@@ -0,0 +1,250 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package harness
import (
"fmt"
"regexp"
"strings"
)
// Sufficiency scoring (code-only, mirrors Python sufficiency.py): cross-check an
// agent result against the evidence chunks, fuse agent confidence + cross-check
// pass rate, then route to a 5-way verdict.
var reNumber = regexp.MustCompile(`\d+\.?\d*`)
var reEntities = regexp.MustCompile(`\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b`)
// extractNumbers returns numeric values found in text.
func extractNumbers(text string) []string {
return reNumber.FindAllString(text, -1)
}
// extractNamedEntities returns capitalized multi-word sequences.
func extractNamedEntities(text string) []string {
seen := map[string]bool{}
var out []string
for _, e := range reEntities.FindAllString(text, -1) {
if !seen[e] {
seen[e] = true
out = append(out, e)
}
}
return out
}
// CrossCheckClaim performs a code-level cross-check of an agent result against
// the accumulated evidence chunks (number matching + entity presence).
func CrossCheckClaim(agent *AgentResult, allChunks map[int]map[string]interface{}) ClaimCrossCheckResult {
if agent == nil {
return ClaimCrossCheckResult{ClaimID: "", CrossCheckPassed: false, Mismatches: []string{"nil agent result"}}
}
if !agent.IsVerified {
return ClaimCrossCheckResult{ClaimID: agent.ClaimID, CrossCheckPassed: false, Mismatches: []string{"agent self-reported as unverified"}}
}
numbers := extractNumbers(agent.Report)
entities := extractNamedEntities(agent.Report)
var matches, mismatches []string
for _, eid := range agent.EvidenceIDs {
chunk, ok := allChunks[eid]
if !ok {
mismatches = append(mismatches, fmt.Sprintf("evidence_id=%d: chunk not found", eid))
continue
}
text := ""
if c, ok := chunk["content_with_weight"].(string); ok {
text = strings.ToLower(c)
} else if c, ok := chunk["content"].(string); ok {
text = strings.ToLower(c)
}
for _, num := range numbers {
if strings.Contains(text, num) {
matches = append(matches, fmt.Sprintf("number %s found in chunk %d", num, eid))
} else {
mismatches = append(mismatches, fmt.Sprintf("number %s not found in chunk %d", num, eid))
}
}
for _, ent := range entities {
if strings.Contains(text, strings.ToLower(ent)) {
matches = append(matches, fmt.Sprintf("entity '%s' found in chunk %d", ent, eid))
} else {
mismatches = append(mismatches, fmt.Sprintf("entity '%s' not found in chunk %d", ent, eid))
}
}
}
// HasEvidence is true when at least one evidence id resolved to a chunk with
// content. A claim with no resolvable evidence is never considered verified.
hasEvidence := len(matches)+len(mismatches) > 0
total := len(matches) + len(mismatches)
crossScore := 0.0
if total > 0 {
crossScore = float64(len(matches)) / float64(total)
}
// Entity presence now contributes to matches too, so it can raise the score;
// use a float comparison to avoid integer-division truncation bias.
crossPassed := hasEvidence && float64(len(mismatches)) < float64(len(matches))/2.0
return ClaimCrossCheckResult{
ClaimID: agent.ClaimID, CrossCheckPassed: crossPassed, CrossCheckScore: crossScore,
EvidenceMatches: matches, Mismatches: mismatches, HasEvidence: hasEvidence,
}
}
// ComputeFusionScore fuses agent confidence + cross-check pass rate into a
// SufficiencyVerdict for the given mode.
func ComputeFusionScore(agentResults []AgentResult, crossResults []ClaimCrossCheckResult, mode ExecutionStrategy) SufficiencyVerdict {
verified := 0
for _, r := range agentResults {
if r.IsVerified {
verified++
}
}
agentScore := 0.0
if len(agentResults) > 0 {
agentScore = float64(verified) / float64(len(agentResults))
}
passed := 0
for _, r := range crossResults {
if r.CrossCheckPassed {
passed++
}
}
crossScore := 0.0
if len(crossResults) > 0 {
crossScore = float64(passed) / float64(len(crossResults))
}
fusionScore := agentScore
if crossScore > fusionScore {
fusionScore = crossScore // low/medium default: max
}
switch mode.Label {
case "ultra":
fusionScore = min(agentScore, crossScore)
case "high":
fusionScore = (agentScore + crossScore) / 2
}
hasConflicts := false
for _, r := range crossResults {
if len(r.Mismatches) > 0 {
hasConflicts = true
break
}
}
// Empty-evidence guard: if no claim examined any evidence chunk, the answer
// cannot be grounded at all — this is UNANSWERABLE, not merely incomplete.
anyEvidence := false
for _, r := range crossResults {
if r.HasEvidence {
anyEvidence = true
break
}
}
status := "INSUFFICIENT"
switch {
case !anyEvidence:
status = "UNANSWERABLE"
case hasConflicts && fusionScore < mode.PartialThreshold:
status = "CONFLICTING"
case fusionScore >= mode.SufficiencyThreshold:
status = "SUFFICIENT"
case fusionScore >= mode.PartialThreshold:
status = "USEFUL_BUT_INCOMPLETE"
case func() bool {
for _, r := range crossResults {
if r.CrossCheckPassed {
return true
}
}
return false
}():
status = "INSUFFICIENT"
default:
status = "UNANSWERABLE"
}
var missing []string
for _, r := range crossResults {
if !r.CrossCheckPassed || !r.HasEvidence {
missing = append(missing, r.ClaimID)
}
}
assessments := make([]map[string]interface{}, 0, len(crossResults))
for _, r := range crossResults {
assessments = append(assessments, map[string]interface{}{
"claim_id": r.ClaimID, "is_verified": r.CrossCheckPassed && r.HasEvidence, "score": r.CrossCheckScore,
"mismatches": r.Mismatches, "has_evidence": r.HasEvidence,
})
}
return SufficiencyVerdict{
Status: status, Score: fusionScore, AgentScore: agentScore, CrossScore: crossScore,
ClaimAssessments: assessments, HasConflicts: hasConflicts, MissingClaims: missing,
Feedback: buildFeedback(missing, crossResults), OverallReason: fmt.Sprintf("%s score=%.2f missing=%v", status, fusionScore, missing),
}
}
func buildFeedback(missing []string, results []ClaimCrossCheckResult) string {
if len(missing) == 0 {
return "all claims verified"
}
var hints []string
for _, r := range results {
if !r.CrossCheckPassed {
hints = append(hints, fmt.Sprintf("claim %s: %d mismatch(es)", r.ClaimID, len(r.Mismatches)))
}
}
return "missing: " + strings.Join(hints, "; ")
}
// RouteSufficiencyVerdict returns (action, shouldContinue) from the verdict.
func RouteSufficiencyVerdict(v SufficiencyVerdict, modeLabel string, cycle, maxCycles int) (string, bool) {
mode, _ := GetMode(modeLabel)
if mode.Label == "" {
mode = THINKING_MODES["medium"]
}
switch v.Status {
case "SUFFICIENT":
return "ANSWER", false
case "USEFUL_BUT_INCOMPLETE":
if mode.RequiresSelectiveGen {
return "ANSWER_PARTIAL", false
}
return "CONTINUE", false
case "INSUFFICIENT":
if cycle >= int(float64(maxCycles)*0.8) {
return "ANSWER_PARTIAL", false
}
return "CONTINUE", true
case "CONFLICTING":
if mode.AllowsReplan && cycle < int(float64(maxCycles)*0.5) {
return "REPLAN", true
}
return "ANSWER_PARTIAL", false
case "UNANSWERABLE":
if mode.FallbackToDirectLLM {
return "FALLBACK_LLM", false
}
return "ABSTAIN", false
default:
return "CONTINUE", true
}
}

View File

@@ -0,0 +1,151 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Package harness ports Python's rag/advanced_rag/harness (agentic-RAG):
// route → planner → orchestrator → formalize_answer. This is a phased port; see
// tasks/agentic_search_port_plan.md §P4.
package harness
// RouteDecision mirrors Python RouteDecision.
type RouteDecision struct {
Question string
ThinkingMode string
QuestionType string // factual | comparative | analytical | procedural | exploratory | verification | summarization
RequiresDecomposition bool
ExecutionStrategy string // direct_search | decompose_and_search | agentic_research | deep_research
Reasoning string
}
// ClaimTarget mirrors Python ClaimTarget.
type ClaimTarget struct {
ClaimID string
Description string
Priority int
SuggestedTools []string
IsVerified bool
Confidence float64
AgentResult *AgentResult
}
// WorkflowPlan mirrors Python WorkflowPlan.
type WorkflowPlan struct {
PlanType string // direct | fact_decomposition | ...
Claims []ClaimTarget
MaxIterations int
}
// ExecutionStrategy mirrors Python ExecutionStrategy and is instantiated by the
// four thinking modes.
type ExecutionStrategy struct {
Label string
Strategy string
RequiresDecomposition bool
MaxOrchestratorCycles int
MaxAgentCycles int
MaxParallelAgents int
AvailableTools []string
SufficiencyThreshold float64
PartialThreshold float64
FallbackToDirectLLM bool
RequiresSelectiveGen bool
AllowsReplan bool
}
// AgentResult mirrors Python AgentResult.
type AgentResult struct {
ClaimID string
Report string
IsVerified bool
Confidence float64
EvidenceIDs []int
}
// ClaimCrossCheckResult mirrors Python ClaimCrossCheckResult.
type ClaimCrossCheckResult struct {
ClaimID string
CrossCheckPassed bool
CrossCheckScore float64
EvidenceMatches []string
Mismatches []string
// HasEvidence reports whether any evidence chunk was actually examined. When
// false the claim had no resolvable evidence, so it cannot be considered
// verified regardless of the other fields.
HasEvidence bool
}
// SufficiencyVerdict mirrors Python SufficiencyVerdict.
type SufficiencyVerdict struct {
Status string // SUFFICIENT | USEFUL_BUT_INCOMPLETE | INSUFFICIENT | CONFLICTING | UNANSWERABLE
Score float64
AgentScore float64
CrossScore float64
ClaimAssessments []map[string]interface{}
HasConflicts bool
MissingClaims []string
Feedback string
OverallReason string
}
// OrchestratorContext mirrors Python OrchestratorContext.
type OrchestratorContext struct {
Question string
Claims []*ClaimTarget
Mode string
Iteration int
}
// THINKING_MODES mirrors Python config.THINKING_MODES.
var THINKING_MODES = map[string]ExecutionStrategy{
"low": {
Label: "low", Strategy: "direct_search", RequiresDecomposition: false,
MaxOrchestratorCycles: 1, MaxAgentCycles: 0, MaxParallelAgents: 1,
AvailableTools: []string{"hybrid_search"}, SufficiencyThreshold: 0.85, PartialThreshold: 0.50,
},
"medium": {
Label: "medium", Strategy: "decompose_and_search", RequiresDecomposition: true,
MaxOrchestratorCycles: 3, MaxAgentCycles: 0, MaxParallelAgents: 1,
AvailableTools: []string{"hybrid_search"}, SufficiencyThreshold: 0.75, PartialThreshold: 0.40,
RequiresSelectiveGen: true,
},
"high": {
Label: "high", Strategy: "agentic_research", RequiresDecomposition: true,
MaxOrchestratorCycles: 3, MaxAgentCycles: 2, MaxParallelAgents: 2,
AvailableTools: []string{
"hybrid_search", "web_search", "ontology_navigate", "dataset_navigation_by_tree",
"graph_explore", "inspector_open_context", "inspector_compare",
},
SufficiencyThreshold: 0.65, PartialThreshold: 0.30,
RequiresSelectiveGen: true, AllowsReplan: true,
},
"ultra": {
Label: "ultra", Strategy: "deep_research", RequiresDecomposition: true,
MaxOrchestratorCycles: 4, MaxAgentCycles: 2, MaxParallelAgents: 3,
AvailableTools: []string{
"hybrid_search", "bm25_search", "web_search", "structured_query",
"ontology_navigate", "dataset_navigation_by_tree", "mindmap_navigate",
"graph_explore", "wiki_query", "inspector_open_context", "inspector_compare",
"inspector_grep_within", "inspector_request_adjacent",
},
SufficiencyThreshold: 0.55, PartialThreshold: 0.20, FallbackToDirectLLM: true,
RequiresSelectiveGen: true, AllowsReplan: true,
},
}
// GetMode mirrors Python get_mode(label).
func GetMode(label string) (ExecutionStrategy, bool) {
m, ok := THINKING_MODES[label]
return m, ok
}

View File

@@ -0,0 +1,345 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package tool
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// Agentic search tool names mirror Python rag/advanced_rag/harness/tools/search.py.
const (
toolHybridSearch = "hybrid_search"
toolVectorSearch = "vector_search"
toolBM25Search = "bm25_search"
toolWebSearch = "web_search"
toolStructuredQuery = "structured_query"
)
// hybridSearchArgs is the shared JSON schema for the three retrieval tools.
type hybridSearchArgs struct {
Query string `json:"query"`
KbIDs []string `json:"kb_ids,omitempty"`
TopN int `json:"top_n,omitempty"`
DocScope []string `json:"doc_scope,omitempty"`
Keywords string `json:"keywords,omitempty"`
UseCompiled bool `json:"use_compiled,omitempty"`
}
type agenticSearchResult struct {
Chunks []map[string]interface{} `json:"chunks"`
DocAggs []map[string]interface{} `json:"doc_aggs,omitempty"`
}
// AgenticSearchTool is the hybrid/vector/bm25 retrieval tool. The search mode
// selects the vector-similarity weight used by the underlying retrieval service:
//
// hybrid: 0.3 (hybrid of keyword + vector)
// vector: 1.0 (vector-only)
// bm25: 0.0 (keyword-only)
//
// It backs onto GetRetrievalService() (the same singleton the agent Retrieval
// tool uses), so DocScope and KB scoping carry through automatically.
type AgenticSearchTool struct {
mode string // hybrid_search | vector_search | bm25_search
weight float64
defaults hybridSearchArgs
}
// NewAgenticSearchTool returns the retrieval tool for the given mode.
func NewAgenticSearchTool(mode string) *AgenticSearchTool {
weight := 0.3
switch mode {
case toolVectorSearch:
weight = 1.0
case toolBM25Search:
weight = 0.0
}
return &AgenticSearchTool{mode: mode, weight: weight, defaults: hybridSearchArgs{TopN: 12}}
}
func (a *AgenticSearchTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: a.mode,
Desc: fmt.Sprintf("Search the bound knowledge base(s) for the query (mode=%s). Returns relevant passages.", a.mode),
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"query": {
Type: schema.String, Required: true, Desc: "The search query.",
},
"kb_ids": {Type: schema.Array, Desc: "Optional dataset ids to restrict to."},
"top_n": {Type: schema.Number, Desc: "Number of passages to return (default 12)."},
"doc_scope": {Type: schema.Array, Desc: "Optional doc ids to restrict to."},
"keywords": {Type: schema.String, Desc: "Comma-separated keywords to narrow results."},
"use_compiled": {Type: schema.Boolean, Desc: "Whether to enrich with compiled products."},
}),
}, nil
}
// InvokableRun executes the retrieval. It returns JSON with "chunks" (array of
// chunk maps). Never returns a hard error for retrieval failures — it returns an
// empty result so the agent can fall back.
func (a *AgenticSearchTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
var args hybridSearchArgs
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("%s: parse arguments: %w", a.mode, err)
}
if args.TopN <= 0 {
args.TopN = a.defaults.TopN
}
svc := GetRetrievalService()
tenantID := canvasTenantID(ctx)
datasetIDs := args.KbIDs
if len(datasetIDs) == 0 {
datasetIDs = canvasDatasetIDs(ctx, nil)
}
if svc == nil || tenantID == "" || len(datasetIDs) == 0 {
return jsonChunksEmpty(), nil
}
weight := a.weight
req := RetrievalRequest{
Query: strings.TrimSpace(args.Query + " " + args.Keywords),
DatasetIDs: datasetIDs,
TopN: args.TopN,
TopK: args.TopN * 4,
SimilarityThreshold: 0.2,
KeywordsSimilarityWeight: &weight,
DocScope: args.DocScope,
}
chunks, err := svc.Search(ctx, nil, req)
if err != nil {
return jsonChunksEmpty(), nil // agent falls back on failure
}
// Keyword narrowing (mirrors Python _narrow_by_keywords).
if args.Keywords != "" {
chunks = narrowByKeywords(chunks, args.Keywords)
}
return marshalSearchResult(chunks), nil
}
// narrowByKeywords narrows each chunk to keyword-bearing sentences (+/-1
// neighbour) and drops keyword-less chunks. A simplified port of Python's
// _narrow_by_keywords.
func narrowByKeywords(chunks []RetrievalChunk, keywords string) []RetrievalChunk {
kwds := splitKeywords(keywords)
if len(kwds) == 0 {
return chunks
}
seen := map[string]struct{}{}
out := make([]RetrievalChunk, 0, len(chunks))
for _, c := range chunks {
nc, ok := narrowContent(c.Content, kwds)
if !ok {
continue
}
hash := md5Hex(nc)
if _, dup := seen[hash]; dup {
continue
}
seen[hash] = struct{}{}
c.Content = nc
out = append(out, c)
}
return out
}
// splitKeywords normalizes a keyword string into a list of terms. When fewer
// than 3 comma terms exist, falls back to space-split bigrams (mirrors Python).
func splitKeywords(keywords string) []string {
if strings.TrimSpace(keywords) == "" {
return nil
}
kwds := make([]string, 0, 8)
for _, k := range strings.Split(keywords, ",") {
if k = strings.TrimSpace(k); k != "" {
kwds = append(kwds, strings.ToLower(k))
}
}
if len(kwds) < 3 {
words := make([]string, 0, 8)
for _, w := range strings.Split(keywords, " ") {
if w = strings.TrimSpace(w); w != "" {
words = append(words, strings.ToLower(w))
}
}
bigrams := make([]string, 0, len(words))
for i := 0; i+1 < len(words); i++ {
bigrams = append(bigrams, words[i]+" "+words[i+1])
}
if len(bigrams) > 0 {
return bigrams
}
}
return kwds
}
var sentEnd = regexp.MustCompile(`[。!?;!?;]+|\.`)
// splitSentences splits text into sentences at sentence terminators, keeping a
// digit-guarded period intact ("3.14" / "v1.2" are not split). Implemented with
// a simple splitter because RE2 (Go) does not support lookbehind/lookahead.
func splitSentences(content string) []string {
raw := sentEnd.Split(content, -1)
sents := make([]string, 0, len(raw))
for _, s := range raw {
if strings.TrimSpace(s) == "" {
continue
}
// Re-join a trailing digit-period-digit that the splitter cut apart:
// if s ends with a digit and content had ".<digit>" following, reattach.
sents = append(sents, s)
}
return rejoinDigitPeriods(sents)
}
// rejoinDigitPeriods merges "…1" + "2…" back into "…1.2…" when a decimal point
// separated two digit groups.
func rejoinDigitPeriods(sents []string) []string {
out := make([]string, 0, len(sents))
for i := 0; i < len(sents); i++ {
cur := sents[i]
// If current ends with a digit and next begins with a digit, the split
// point was a decimal point — merge them.
for i+1 < len(sents) && hasTrailingDigit(cur) && hasLeadingDigit(sents[i+1]) {
cur = strings.TrimRight(cur, " \t") + "." + sents[i+1]
i++
}
out = append(out, cur)
}
return out
}
func hasTrailingDigit(s string) bool {
s = strings.TrimRight(s, " \t")
return len(s) > 0 && s[len(s)-1] >= '0' && s[len(s)-1] <= '9'
}
func hasLeadingDigit(s string) bool {
s = strings.TrimLeft(s, " \t")
return len(s) > 0 && s[0] >= '0' && s[0] <= '9'
}
// narrowContent returns the keyword-bearing sentences (+/-1 neighbour) with the
// keyword highlighted, or (_, false) if no keyword occurs.
func narrowContent(content string, kwds []string) (string, bool) {
if strings.TrimSpace(content) == "" {
return "", false
}
sents := splitSentences(content)
if len(sents) == 0 {
return "", false
}
keep := map[int]bool{}
matched := false
for i, s := range sents {
low := strings.ToLower(s)
for _, kw := range kwds {
if kw != "" && strings.Contains(low, kw) {
matched = true
if i > 0 {
keep[i-1] = true
}
keep[i] = true
if i+1 < len(sents) {
keep[i+1] = true
}
break
}
}
}
if !matched {
return "", false
}
var b strings.Builder
for i := 0; i < len(sents); i++ {
if keep[i] {
b.WriteString(sents[i])
}
}
return "..." + highlightKeywords(b.String(), kwds) + "...", true
}
// highlightKeywords wraps keyword occurrences in <em>.
func highlightKeywords(text string, kwds []string) string {
if len(kwds) == 0 {
return text
}
// Sort by length desc so longer terms match first.
terms := make([]string, len(kwds))
copy(terms, kwds)
for i := 1; i < len(terms); i++ {
for j := i; j > 0 && len(terms[j]) > len(terms[j-1]); j-- {
terms[j], terms[j-1] = terms[j-1], terms[j]
}
}
pattern := "("
for i, t := range terms {
if t == "" {
continue
}
if i > 0 {
pattern += "|"
}
pattern += regexp.QuoteMeta(t)
}
pattern += ")"
re := regexp.MustCompile(`(?i)` + pattern)
return re.ReplaceAllString(text, "<em>${1}</em>")
}
func md5Hex(s string) string {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return fmt.Sprintf("%08x", h)
}
func jsonChunksEmpty() string {
return `{"chunks":[]}`
}
func marshalSearchResult(chunks []RetrievalChunk) string {
type outChunk struct {
ID string `json:"id"`
Content string `json:"content"`
DocumentID string `json:"doc_id"`
DocName string `json:"docnm_kwd"`
Score float64 `json:"similarity"`
}
out := make([]outChunk, 0, len(chunks))
for _, c := range chunks {
out = append(out, outChunk{
ID: c.ID, Content: c.Content, DocumentID: c.DocumentID,
DocName: c.DocumentName, Score: c.Score,
})
}
b, err := json.Marshal(map[string]interface{}{"chunks": out})
if err != nil {
return jsonChunksEmpty()
}
return string(b)
}

View File

@@ -0,0 +1,78 @@
package tool
import (
"strings"
"testing"
)
// TestSplitKeywords_BigramFallback asserts comma terms are used when >=3, and
// space-split bigrams otherwise.
func TestSplitKeywords_BigramFallback(t *testing.T) {
got := splitKeywords("alpha, beta, gamma")
if len(got) != 3 {
t.Fatalf("comma kwds = %d, want 3", len(got))
}
got = splitKeywords("a b c d")
// 4 words -> 3 bigrams
if len(got) != 3 {
t.Fatalf("bigram kwds = %d, want 3", len(got))
}
if got[0] != "a b" {
t.Errorf("bigram[0] = %q, want \"a b\"", got[0])
}
}
// TestNarrowContent_KeepsKeywordSentence asserts narrowing keeps the
// keyword-bearing sentence and its neighbours, and highlights the keyword.
func TestNarrowContent_KeepsKeywordSentence(t *testing.T) {
content := "The introduction is boring. The key insight about rocket engines is here. The conclusion is short."
nc, ok := narrowContent(content, []string{"rocket"})
if !ok {
t.Fatal("expected keyword match")
}
if !strings.Contains(nc, "key insight") {
t.Errorf("narrowed content missing keyword sentence: %q", nc)
}
if !strings.Contains(nc, "<em>rocket</em>") {
t.Errorf("narrowed content missing highlight: %q", nc)
}
}
// TestNarrowContent_NoKeyword asserts narrowing returns false when no keyword
// occurs.
func TestNarrowContent_NoKeyword(t *testing.T) {
if _, ok := narrowContent("no match here at all", []string{"zzz"}); ok {
t.Fatal("expected no match")
}
}
// TestSplitSentences_DigitPeriod asserts "3.14" is not split into two sentences.
func TestSplitSentences_DigitPeriod(t *testing.T) {
sents := splitSentences("pi is 3.14 and e is 2.71. end.")
found := false
for _, s := range sents {
if strings.Contains(s, "3.14") {
found = true
}
}
if !found {
t.Fatalf("digit-period sentence lost: %v", sents)
}
}
// TestNarrowByKeywords_DropsKeywordless asserts chunks without keywords are
// dropped and deduped by narrowed content hash.
func TestNarrowByKeywords_DropsKeywordless(t *testing.T) {
chunks := []RetrievalChunk{
{ID: "c1", Content: "alpha talks about beta in detail here."},
{ID: "c2", Content: "unrelated content entirely."},
{ID: "c3", Content: "alpha talks about beta in detail here."}, // dup of c1
}
out := narrowByKeywords(chunks, "beta")
if len(out) != 1 {
t.Fatalf("narrowed chunks = %d, want 1 (drops keywordless + dedups)", len(out))
}
if out[0].ID != "c1" {
t.Errorf("kept chunk = %q, want c1", out[0].ID)
}
}

View File

@@ -0,0 +1,59 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package tool
import (
"context"
"ragflow/internal/agent/runtime"
)
// canvasTenantID derives the tenant id from canvas state, falling back to
// user_id. Shared by agentic search and dataset-navigation tools.
func canvasTenantID(ctx context.Context) string {
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
if err != nil || state == nil {
return ""
}
if tenantID, _ := state.Sys["tenant_id"].(string); tenantID != "" {
return tenantID
}
userID, _ := state.Sys["user_id"].(string)
return userID
}
// canvasDatasetIDs returns the explicit dataset ids (all of them, preserving
// multi-KB sessions), else the canvas sys dataset_id as a single-element list.
func canvasDatasetIDs(ctx context.Context, explicit []string) []string {
if len(explicit) > 0 {
out := make([]string, 0, len(explicit))
for _, id := range explicit {
if id != "" {
out = append(out, id)
}
}
return out
}
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
if err != nil || state == nil {
return nil
}
if id, _ := state.Sys["dataset_id"].(string); id != "" {
return []string{id}
}
return nil
}

View File

@@ -0,0 +1,220 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package tool
import (
"context"
"encoding/json"
"fmt"
"strings"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"ragflow/internal/service/nav"
)
// datasetNavigationToolName mirrors Python's dataset_navigation_by_tree router
// tool. It navigates the dataset nav tree and returns the doc_ids to read.
const datasetNavigationToolName = "dataset_navigation_by_tree"
const datasetNavigationToolDescription = "Navigate a dataset's navigation tree by topic and return the document ids that are likely relevant."
// datasetNavigationArgs is the JSON schema the model sends into InvokableRun.
type datasetNavigationArgs struct {
Topic string `json:"topic"`
Keywords string `json:"keywords,omitempty"`
DatasetIDs []string `json:"dataset_ids,omitempty"`
DocScope string `json:"doc_scope,omitempty"`
MaxDocs int `json:"max_docs,omitempty"`
}
// datasetNavigationResult is the JSON shape returned to the model.
type datasetNavigationResult struct {
Docs []string `json:"docs,omitempty"`
Error string `json:"_ERROR,omitempty"`
NotFound bool `json:"not_found,omitempty"`
}
// datasetNavigationDefaultMaxDocs caps the number of doc_ids returned.
const datasetNavigationDefaultMaxDocs = 8
// DatasetNavigationByTree is the dataset-navigation router tool. Minimal closed
// loop: one-level drill-down from the root clusters and deduplicated doc ids
// (max MaxDocs). LLM-guided multi-level selection is deferred.
type DatasetNavigationByTree struct {
defaults datasetNavigationArgs
}
// NewDatasetNavigationByTree returns a DatasetNavigationByTree implementing
// eino's tool.InvokableTool interface.
func NewDatasetNavigationByTree() *DatasetNavigationByTree {
return NewDatasetNavigationByTreeWithDefaults(datasetNavigationArgs{})
}
// NewDatasetNavigationByTreeWithDefaults returns a DatasetNavigationByTree with
// node-level defaults.
func NewDatasetNavigationByTreeWithDefaults(defaults datasetNavigationArgs) *DatasetNavigationByTree {
if defaults.MaxDocs <= 0 {
defaults.MaxDocs = datasetNavigationDefaultMaxDocs
}
return &DatasetNavigationByTree{defaults: defaults}
}
// Info returns the tool's metadata for the chat model.
func (d *DatasetNavigationByTree) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: datasetNavigationToolName,
Desc: datasetNavigationToolDescription,
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"topic": {
Type: schema.String,
Desc: "The topic to navigate to. Use the core subject from the original request.",
Required: true,
},
"keywords": {
Type: schema.String,
Desc: "Optional additional keywords to disambiguate the topic.",
},
}),
}, nil
}
// InvokableRun executes the tool. It navigates the nav tree via the registered
// NavService (internal/service datasetnav) and returns a deduplicated doc_id
// list (max MaxDocs).
func (d *DatasetNavigationByTree) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
var args datasetNavigationArgs
if argumentsInJSON != "" {
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("dataset_navigation: parse arguments: %w", err)
}
}
args = d.mergeDefaults(args)
if args.Topic == "" {
return "", fmt.Errorf("dataset_navigation: topic is required")
}
// Per-request max_docs overrides the node default; default to a sane cap.
maxDocs := args.MaxDocs
if maxDocs <= 0 {
maxDocs = datasetNavigationDefaultMaxDocs
}
ns := nav.GetNavService()
if ns == nil {
return datasetNavigationJSON(datasetNavigationResult{
Error: "dataset navigation service not initialized (SetNavService must be called at bootstrap)",
}), nil
}
tenantID := canvasTenantID(ctx)
datasetIDs := canvasDatasetIDs(ctx, args.DatasetIDs)
if tenantID == "" || len(datasetIDs) == 0 {
return datasetNavigationJSON(datasetNavigationResult{
NotFound: true,
Error: "dataset navigation requires a tenant and dataset context",
}), nil
}
// Route RELEVANT docs by querying the nav tree with the topic (semantic KNN).
// The topic is the routing signal — we must not return arbitrary doc ids.
query := strings.TrimSpace(args.Topic + " " + args.Keywords)
seen := map[string]struct{}{}
var docs []string
collect := func(id string) {
if id == "" {
return
}
if _, ok := seen[id]; ok {
return
}
if len(docs) >= maxDocs {
return
}
seen[id] = struct{}{}
docs = append(docs, id)
}
// Primary: semantic search over each dataset's nav tree.
for _, datasetID := range datasetIDs {
hits, err := ns.Search(ctx, tenantID, datasetID, query, nil, maxDocs)
if err != nil {
continue
}
for _, h := range hits {
collect(h.DocID)
for _, id := range h.DocIDs {
collect(id)
}
}
if len(docs) >= maxDocs {
break
}
}
// Fallback: if semantic routing found nothing (e.g. no embedder), walk the
// root clusters so the tool still returns a useful (if coarse) doc set.
if len(docs) == 0 {
for _, datasetID := range datasetIDs {
clusters, _, err := ns.ListClusters(ctx, tenantID, datasetID, 0, 100)
if err != nil {
continue
}
for _, c := range clusters {
children, _, err := ns.ListChildren(ctx, tenantID, datasetID, c.Name, 0, 100)
if err != nil {
continue
}
for _, ch := range children {
collect(ch.DocID)
if len(docs) >= maxDocs {
break
}
}
if len(docs) >= maxDocs {
break
}
}
if len(docs) >= maxDocs {
break
}
}
}
if len(docs) == 0 {
return datasetNavigationJSON(datasetNavigationResult{NotFound: true}), nil
}
return datasetNavigationJSON(datasetNavigationResult{Docs: docs}), nil
}
func (d *DatasetNavigationByTree) mergeDefaults(args datasetNavigationArgs) datasetNavigationArgs {
if len(args.DatasetIDs) == 0 && len(d.defaults.DatasetIDs) != 0 {
args.DatasetIDs = append([]string(nil), d.defaults.DatasetIDs...)
}
if args.MaxDocs <= 0 {
args.MaxDocs = d.defaults.MaxDocs
}
return args
}
func datasetNavigationJSON(r datasetNavigationResult) string {
b, err := json.Marshal(r)
if err != nil {
return fmt.Sprintf(`{"_ERROR":"dataset_navigation: marshal result: %s"}`, err)
}
return string(b)
}

View File

@@ -0,0 +1,134 @@
package tool
import (
"context"
"sync"
"testing"
"ragflow/internal/agent/runtime"
"ragflow/internal/service/nav"
)
// navRoutingFake is a nav.NavService that records Search calls (topic) and
// returns a controlled doc list, so a test can assert the router actually
// queries by topic rather than walking arbitrary clusters.
type navRoutingFake struct {
mu sync.Mutex
searched []string // topics passed to Search
hits []nav.NavHit
clusters []nav.NavNode
children map[string][]nav.NavNode
}
func (f *navRoutingFake) UpsertDoc(context.Context, nav.UpsertDocInput) error { return nil }
func (f *navRoutingFake) RemoveDoc(context.Context, string, string, string) error {
return nil
}
func (f *navRoutingFake) Search(_ context.Context, _, _ string, query string, _ []float32, _ int) ([]nav.NavHit, error) {
f.mu.Lock()
f.searched = append(f.searched, query)
f.mu.Unlock()
return f.hits, nil
}
func (f *navRoutingFake) ListClusters(context.Context, string, string, int, int) ([]nav.NavNode, int64, error) {
return f.clusters, int64(len(f.clusters)), nil
}
func (f *navRoutingFake) ListChildren(_ context.Context, _, _, name string, _, _ int) ([]nav.NavNode, int64, error) {
return f.children[name], int64(len(f.children[name])), nil
}
func (f *navRoutingFake) searchedTopics() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.searched...)
}
// TestDatasetNavigation_UsesTopicRouting asserts the router queries the nav tree
// with the topic (semantic search), rather than blindly walking clusters and
// returning arbitrary doc ids.
func TestDatasetNavigation_UsesTopicRouting(t *testing.T) {
fake := &navRoutingFake{
hits: []nav.NavHit{
{Type: "nav_doc", DocID: "d1", Name: "rocket"},
{Type: "nav_doc", DocID: "d2", Name: "engine"},
},
}
prev := nav.GetNavService()
nav.SetNavService(fake)
defer func() { nav.SetNavService(prev) }()
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["tenant_id"] = "tenant-1"
ctx := runtime.WithState(context.Background(), state)
tool := NewDatasetNavigationByTree()
out, err := tool.InvokableRun(ctx, `{"topic":"rocket propulsion","keywords":"engine","dataset_ids":["kb1"]}`)
if err != nil {
t.Fatalf("InvokableRun: %v", err)
}
// The topic (plus keywords) must have been used as the Search query.
topics := fake.searchedTopics()
if len(topics) == 0 {
t.Fatal("Search was never called; router must route by topic")
}
if topics[0] != "rocket propulsion engine" {
t.Errorf("search query = %q, want topic+keywords", topics[0])
}
// The returned docs come from the relevant hits, not arbitrary walk.
if !containsStr(out, "d1") || !containsStr(out, "d2") {
t.Errorf("routed docs missing hits: %s", out)
}
}
// TestCanvasDatasetIDs_MultiKB asserts all explicit dataset ids are preserved
// (a multi-KB session must not collapse to the first KB).
func TestCanvasDatasetIDs_MultiKB(t *testing.T) {
ids := canvasDatasetIDs(context.Background(), []string{"kb1", "kb2", "kb3"})
if len(ids) != 3 || ids[0] != "kb1" || ids[1] != "kb2" || ids[2] != "kb3" {
t.Errorf("canvasDatasetIDs = %v, want all three KBs", ids)
}
}
// TestDatasetNavigation_MultiKB asserts the router searches EVERY bound dataset
// (not just the first), so docs in other KBs stay reachable.
func TestDatasetNavigation_MultiKB(t *testing.T) {
fake := &navRoutingFake{hits: []nav.NavHit{{Type: "nav_doc", DocID: "d1", Name: "topic"}}}
prev := nav.GetNavService()
nav.SetNavService(fake)
defer func() { nav.SetNavService(prev) }()
state := runtime.NewCanvasState("run-1", "task-1")
state.Sys["tenant_id"] = "tenant-1"
ctx := runtime.WithState(context.Background(), state)
tool := NewDatasetNavigationByTree()
_, err := tool.InvokableRun(ctx, `{"topic":"X","dataset_ids":["kb1","kb2","kb3"]}`)
if err != nil {
t.Fatalf("InvokableRun: %v", err)
}
// Search must have been called once per dataset (3 calls), not collapsed to
// the first KB.
if got := len(fake.searchedTopics()); got != 3 {
t.Errorf("Search called %d times, want 3 (once per dataset)", got)
}
}
// TestCanvasDatasetIDs_DedupEmpty asserts empty ids are dropped.
func TestCanvasDatasetIDs_DedupEmpty(t *testing.T) {
ids := canvasDatasetIDs(context.Background(), []string{"kb1", "", "kb2"})
if len(ids) != 2 || ids[0] != "kb1" || ids[1] != "kb2" {
t.Errorf("canvasDatasetIDs = %v, want [kb1 kb2]", ids)
}
}
func containsStr(s, sub string) bool {
return len(s) > 0 && len(sub) > 0 && (s == sub || containsSub(s, sub))
}
func containsSub(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -31,32 +31,36 @@ import (
type Factory func(params map[string]any) (einotool.BaseTool, error)
var registry = map[string]Factory{
"akshare": buildAkShareTool,
"arxiv": buildArxivTool,
"bgpt": buildBGPTTool,
"code_exec": noConfig("code_exec", func() einotool.BaseTool { return NewCodeExecTool() }),
"crawler": noConfig("crawler", func() einotool.BaseTool { return NewCrawlerTool() }),
"deepl": noConfig("deepl", func() einotool.BaseTool { return NewDeepLTool() }),
"duckduckgo": buildDuckDuckGoTool,
"email": buildEmailTool,
"execute_sql": buildExeSQLTool,
"exesql": buildExeSQLTool,
"github": buildGitHubTool,
"google": buildGoogleTool,
"google_scholar": buildGoogleScholarTool,
"google_scholar_search": buildGoogleScholarTool,
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
"keenable": buildKeenableTool,
"pubmed": buildPubMedTool,
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
"querit": buildQueritTool,
"querit_search": buildQueritTool,
"queritsearch": buildQueritTool,
"retrieval": buildRetrievalTool,
"search_my_dataset": buildRetrievalTool,
"search_my_dateset": buildRetrievalTool,
"searxng": buildSearXNGTool,
"tavily": buildTavilyTool,
"akshare": buildAkShareTool,
"arxiv": buildArxivTool,
"bgpt": buildBGPTTool,
"code_exec": noConfig("code_exec", func() einotool.BaseTool { return NewCodeExecTool() }),
"crawler": noConfig("crawler", func() einotool.BaseTool { return NewCrawlerTool() }),
"dataset_navigation_by_tree": noConfig("dataset_navigation_by_tree", func() einotool.BaseTool { return NewDatasetNavigationByTree() }),
"hybrid_search": noConfig("hybrid_search", func() einotool.BaseTool { return NewAgenticSearchTool(toolHybridSearch) }),
"vector_search": noConfig("vector_search", func() einotool.BaseTool { return NewAgenticSearchTool(toolVectorSearch) }),
"bm25_search": noConfig("bm25_search", func() einotool.BaseTool { return NewAgenticSearchTool(toolBM25Search) }),
"deepl": noConfig("deepl", func() einotool.BaseTool { return NewDeepLTool() }),
"duckduckgo": buildDuckDuckGoTool,
"email": buildEmailTool,
"execute_sql": buildExeSQLTool,
"exesql": buildExeSQLTool,
"github": buildGitHubTool,
"google": buildGoogleTool,
"google_scholar": buildGoogleScholarTool,
"google_scholar_search": buildGoogleScholarTool,
"jin10": noConfig("jin10", func() einotool.BaseTool { return NewJin10Tool() }),
"keenable": buildKeenableTool,
"pubmed": buildPubMedTool,
"qweather": noConfig("qweather", func() einotool.BaseTool { return NewQWeatherTool() }),
"querit": buildQueritTool,
"querit_search": buildQueritTool,
"queritsearch": buildQueritTool,
"retrieval": buildRetrievalTool,
"search_my_dataset": buildRetrievalTool,
"search_my_dateset": buildRetrievalTool,
"searxng": buildSearXNGTool,
"tavily": buildTavilyTool,
// Agent DSL tool lists carry the Python Canvas component_name verbatim.
// BuildByName lower-cases names, so register those component names too.
"tavilysearch": buildTavilyTool,

View File

@@ -162,6 +162,7 @@ func nlpRequestFromRetrieval(req RetrievalRequest, tenantIDs []string, topN int)
Question: req.Query,
TenantIDs: append([]string(nil), tenantIDs...),
KbIDs: append([]string(nil), req.DatasetIDs...),
DocIDs: append([]string(nil), compactStrings(req.DocScope)...),
Page: 1,
PageSize: topN,
Aggs: boolPtr(false),

View File

@@ -55,6 +55,9 @@ type RetrievalRequest struct {
KeywordsSimilarityWeight *float64
UseKG bool
SimilarityThreshold float64
// DocScope restricts retrieval to a set of document ids (the doc_id list
// routed by the dataset_navigation_by_tree tool). Empty = no doc filter.
DocScope []string
// TenantID is the calling tenant (== user_id in RAGFlow's data model).
// Optional for the nlp adapter; the KG adapter uses it to resolve the
// tenant's default chat + embedding models. Reads from