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

6
.gitignore vendored
View File

@@ -222,12 +222,15 @@ backup
/target
# Do not include in PR (local dev / build artifacts)
# Do not include in PR (local dev / build artifacts / working notes)
ragflow.egg-info/
uv-aarch64*.tar.gz
uv-aarch64-unknown-linux-gnu.tar.gz
docker/launch_backend_service_windows.sh
# Scratch/working notes (agent task planning & self-review docs; not PR material)
tasks/*.md
# C++ build directories
internal/binding/cpp/build/
internal/binding/cpp/cmake-build-release/
@@ -239,6 +242,7 @@ internal/binding/cpp/cmake-build-debug/
# Go server build output
bin/*
!bin/.gitkeep
/ragflow_server
.claude/settings.local.json
.run/

View File

@@ -38,6 +38,7 @@ import (
dataset "ragflow/internal/service/dataset"
"ragflow/internal/service/document"
"ragflow/internal/service/file"
"ragflow/internal/service/nav"
"ragflow/internal/service/nlp"
"ragflow/internal/storage"
"ragflow/internal/syncer"
@@ -835,6 +836,16 @@ func startServer(ctx context.Context) {
compilationTemplateGroupHandler := handler.NewCompilationTemplateGroupHandler(service.NewCompilationTemplateGroupService())
datasetArtifactHandler := handler.NewDatasetArtifactHandler(service.NewDatasetArtifactService(), datasetsService, file.NewFileCommitService())
// Install the production eino-based chat invoker as the shared chat default,
// so agentic-search harness LLM calls work in production. Without this,
// chat.GetDefaultInvoker() stays nil and the harness falls back gracefully.
component.InstallDefaultChatInvoker()
// Install the dataset-nav ES-backed service (internal/service/nav +
// internal/service/nlp). The embedder resolves the tenant's embedding model
// on demand so Search/UpsertDoc can embed queries/summaries automatically.
nav.SetNavService(nlp.NewNavService(service.NewNavEmbedder(modelProviderService, "")))
// Initialize router
r := router.NewRouter(authHandler,
userHandler,

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

View File

@@ -43,7 +43,6 @@ var builtinCompilationTemplateKinds = []struct {
{Kind: "mind_map", Name: "Mind map - Radial concept hierarchy"},
{Kind: "wiki", Name: "Wiki — Graph-based wiki"},
{Kind: "knowledge_graph", Name: "Knowledge graph"},
{Kind: "datasetnav", Name: "Dataset nav — top-down drill-down"},
{Kind: "page_index", Name: "Page index"},
{Kind: "session_essence", Name: "Session essence"},
{Kind: "session_graph", Name: "Session graph"},

View File

@@ -25,6 +25,18 @@ import (
"gorm.io/gorm"
)
// TestBuiltinCompilationTemplateKinds_NoDatasetnav locks the removal of the
// datasetnav template from the built-in catalogue: dataset navigation is not an
// independent compile kind (see component_test.TestKnowledgeCompiler_Datasetnav_NoVariant),
// so it must not appear in the seeded template kinds.
func TestBuiltinCompilationTemplateKinds_NoDatasetnav(t *testing.T) {
for _, k := range builtinCompilationTemplateKinds {
if k.Kind == "datasetnav" || k.Kind == "dataset_nav" {
t.Fatalf("builtin compilation template kind %q must not exist (datasetnav is a by-product, not a compile kind)", k.Kind)
}
}
}
func TestSeedBuiltinCompilationTemplatesForTenant(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {

View File

@@ -18,11 +18,10 @@ import (
type Variant string
const (
VariantStructure Variant = "structure"
VariantWiki Variant = "wiki"
VariantTree Variant = "tree"
VariantMindmap Variant = "mindmap"
VariantDatasetnav Variant = "datasetnav"
VariantStructure Variant = "structure"
VariantWiki Variant = "wiki"
VariantTree Variant = "tree"
VariantMindmap Variant = "mindmap"
)
// Sentinel errors.
@@ -189,10 +188,13 @@ func ParseParam(m map[string]any) (Param, error) {
// knowledge_graph ->
//
// The canonical variant names are also accepted as identity (so a template kind
// may equal the variant, e.g. "datasetnav" which has no Python kind, and the
// internal unit tests can drive each variant through a template id). Unknown
// kinds return ErrUnknownVariant; the caller turns that into a hard failure
// rather than silently emitting uncompiled rows.
// may equal the variant). Unknown kinds return ErrUnknownVariant; the caller
// turns that into a hard failure rather than silently emitting uncompiled rows.
//
// Note: "datasetnav"/"dataset_nav" intentionally has NO mapping here — dataset
// navigation is not an independent compile kind in Python; it is a by-product
// written after tree/page_index compilation via internal/service datasetnav
// (see tasks/agentic_search_port_plan.md).
func KindToVariant(kind string) (Variant, error) {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "tree":
@@ -201,8 +203,6 @@ func KindToVariant(kind string) (Variant, error) {
return VariantMindmap, nil
case "wiki":
return VariantWiki, nil
case "datasetnav", "dataset_nav":
return VariantDatasetnav, nil
case "page_index", "session_essence", "session_graph", "timeline",
"knowledge_graph", "structure", "knowledgegraph", "graph":
return VariantStructure, nil

View File

@@ -1,22 +1,24 @@
// Package knowledge_compiler implements the KnowledgeCompiler ingestion
// component: a single runtime.Component that dispatches to one of the
// knowledge-compile variants (structure / wiki / tree / mindmap / datasetnav)
// based on the `variant` param. See PORT_PLAN.md for the full design.
// knowledge-compile variants (structure / wiki / tree / mindmap) based on the
// `variant` param. See PORT_PLAN.md for the full design.
package knowledge_compiler
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"ragflow/internal/agent/runtime"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
"ragflow/internal/ingestion/component/knowledge_compiler/datasetnav"
"ragflow/internal/ingestion/component/knowledge_compiler/mindmap"
"ragflow/internal/ingestion/component/knowledge_compiler/structure"
"ragflow/internal/ingestion/component/knowledge_compiler/tree"
"ragflow/internal/ingestion/component/knowledge_compiler/wiki"
"ragflow/internal/ingestion/component/schema"
"ragflow/internal/service/nav"
"ragflow/internal/tokenizer"
"gorm.io/gorm"
@@ -110,6 +112,9 @@ func (c *KnowledgeCompilerComponent) Invoke(ctx context.Context, db *gorm.DB, in
// template's id and kind. All products are buffered (no streaming sink), so
// the post-run loop below covers every row (M1).
var out common.Outputs
// navByProducts accumulates every tree/structure by-product job so each is
// written after the spec loop (not just the last one).
var navByProducts []navByProduct
for _, spec := range specs {
variant, err := common.KindToVariant(spec.Kind)
if err != nil {
@@ -150,8 +155,6 @@ func (c *KnowledgeCompilerComponent) Invoke(ctx context.Context, db *gorm.DB, in
o, err = tree.Run(ctx, deps, specParam, specIn)
case common.VariantMindmap:
o, err = mindmap.Run(ctx, deps, specParam, specIn)
case common.VariantDatasetnav:
o, err = datasetnav.Run(ctx, deps, specParam, specIn)
default:
return nil, fmt.Errorf("%w: %q", common.ErrUnknownVariant, variant)
}
@@ -159,6 +162,13 @@ func (c *KnowledgeCompilerComponent) Invoke(ctx context.Context, db *gorm.DB, in
return nil, err
}
// Accumulate tree/structure by-product jobs so each is written after the
// spec loop (a multi-spec batch must not drop any spec's products). nav is
// a derived artifact; its failures are logged and never abort the pipeline.
if variant == common.VariantTree || variant == common.VariantStructure {
navByProducts = append(navByProducts, navByProduct{deps: deps, variant: variant, products: o.Products})
}
for i := range o.Products {
if o.Products[i].Meta == nil {
o.Products[i].Meta = map[string]any{}
@@ -171,6 +181,17 @@ func (c *KnowledgeCompilerComponent) Invoke(ctx context.Context, db *gorm.DB, in
out.Products = append(out.Products, o.Products...)
}
// Write each dataset-nav by-product after all specs ran. tree by-product
// (Python compiler.py:475) and structure/page_index by-product (Python
// runner.py:189). Failures are logged and never abort the pipeline.
for _, job := range navByProducts {
if job.variant == common.VariantTree {
upsertTreeNav(ctx, job.deps, in.DocID, job.products)
} else {
upsertStructureNav(ctx, job.deps, in.DocID, job.products)
}
}
// Convert the compiled products into chunk-aligned docs (matching
// conf/infinity_mapping.json) and merge them into the upstream input
// chunks. The component stays DB-independent and no longer routes through a
@@ -182,6 +203,142 @@ func (c *KnowledgeCompilerComponent) Invoke(ctx context.Context, db *gorm.DB, in
return mergeChunks(inputs, compiled), nil
}
// navByProduct is one deferred dataset-nav by-product job: the compile variant
// that produced it and the products to summarize from.
type navByProduct struct {
deps common.Deps
variant common.Variant
products []common.Product
}
// upsertTreeNav writes the dataset-nav by-product after tree compilation. It
// finds the tree root product (Meta kind=root) whose Content is the document
// summary, and places it into the ES-backed nav tree. Any failure (missing nav
// service, missing root, embedding/upsert error) is logged and skipped — the
// nav artifact must never block the compile pipeline.
func upsertTreeNav(ctx context.Context, deps common.Deps, docID string, products []common.Product) {
ns := nav.GetNavService()
if ns == nil {
log.Printf("knowledge_compiler: datasetnav by-product skipped (NavService not initialized)")
return
}
var summary string
var vec []float32
for i := range products {
if kind, _ := products[i].Meta["kind"].(string); kind == "root" {
summary = products[i].Content
vec = products[i].Vector
break
}
}
if strings.TrimSpace(summary) == "" {
log.Printf("knowledge_compiler: datasetnav by-product skipped (tree has no root summary)")
return
}
if len(vec) == 0 {
if deps.Embed == nil {
log.Printf("knowledge_compiler: datasetnav by-product skipped (no embedder, no precomputed vector)")
return
}
embeddings, err := deps.Embed.Encode(ctx, []string{summary})
if err != nil {
log.Printf("knowledge_compiler: datasetnav by-product skipped (embedding failed): %v", err)
return
}
if len(embeddings) == 0 {
log.Printf("knowledge_compiler: datasetnav by-product skipped (embedding produced no vector)")
return
}
vec = embeddings[0]
}
if err := ns.UpsertDoc(ctx, nav.UpsertDocInput{
TenantID: deps.TenantID,
KbID: deps.DatasetID,
DocID: docID,
Summary: summary,
Embedd: vec,
}); err != nil {
// Log and continue: nav is a derived artifact, never abort compile.
log.Printf("knowledge_compiler: datasetnav by-product upsert failed (continuing): %v", err)
}
}
// upsertStructureNav writes the dataset-nav by-product after structure/page_index
// compilation (mirroring Python runner.py:189 _upsert_dataset_nav_from_page_index).
// It finds the graph product (Meta kind=graph), summarizes its entity
// descriptions into a document-level summary, and places it into the nav tree.
// Failures are logged and never abort the compile pipeline.
func upsertStructureNav(ctx context.Context, deps common.Deps, docID string, products []common.Product) {
ns := nav.GetNavService()
if ns == nil {
log.Printf("knowledge_compiler: datasetnav by-product skipped (NavService not initialized)")
return
}
var graph *common.Product
for i := range products {
if kind, _ := products[i].Meta["kind"].(string); kind == "graph" {
graph = &products[i]
break
}
}
if graph == nil {
return
}
summary := pageIndexSummary(graph.Content)
if strings.TrimSpace(summary) == "" {
log.Printf("knowledge_compiler: datasetnav by-product skipped (structure graph has no entity descriptions)")
return
}
// Embed the SUMMARY text, not the graph JSON — the nav doc's vector must
// represent the summary semantics for the KNN router to match correctly.
if deps.Embed == nil {
log.Printf("knowledge_compiler: datasetnav by-product skipped (no embedder)")
return
}
embeddings, err := deps.Embed.Encode(ctx, []string{summary})
if err != nil || len(embeddings) == 0 {
log.Printf("knowledge_compiler: datasetnav by-product skipped (structure embedding failed): %v", err)
return
}
vec := embeddings[0]
if err := ns.UpsertDoc(ctx, nav.UpsertDocInput{
TenantID: deps.TenantID,
KbID: deps.DatasetID,
DocID: docID,
Summary: summary,
Embedd: vec,
}); err != nil {
log.Printf("knowledge_compiler: datasetnav by-product upsert failed (continuing): %v", err)
}
}
// pageIndexSummary concatenates entity descriptions from a structure graph JSON
// ({"entities": [{"name","description"}, ...]}), producing a document-level
// summary for dataset navigation.
func pageIndexSummary(graphJSON string) string {
var graph struct {
Entities []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"entities"`
}
if err := json.Unmarshal([]byte(graphJSON), &graph); err != nil {
return ""
}
var b strings.Builder
for _, e := range graph.Entities {
desc := strings.Join(strings.Fields(e.Description), " ")
if desc == "" {
continue
}
if e.Name != "" {
b.WriteString(e.Name + ": ")
}
b.WriteString(desc + "\n")
}
return b.String()
}
// resolveTemplateSpecs resolves the configured compilation template spec(s) to
// their TemplateInfo rows. Priority: compilation_template_id >
// compilation_template_group_id. The group path resolves the group to its
@@ -260,11 +417,10 @@ func kindOrVariant(p common.Product) string {
// key that distinguishes compiled knowledge units from ordinary chunks and
// routes retrieval-side filters (e.g. "compile_kwd": ["artifact_page"]).
var variantCompileKWD = map[common.Variant]string{
common.VariantStructure: "structure",
common.VariantWiki: "artifact_page",
common.VariantTree: "tree",
common.VariantMindmap: "mindmap",
common.VariantDatasetnav: "dataset_nav",
common.VariantStructure: "structure",
common.VariantWiki: "artifact_page",
common.VariantTree: "tree",
common.VariantMindmap: "mindmap",
}
// productsToChunkDocs converts the internal compiled Product rows into
@@ -520,35 +676,6 @@ func applyVariantColumns(doc *schema.ChunkDoc, p common.Product) error {
return err
}
}
case common.VariantDatasetnav:
// nav_cluster / nav_doc rows: type_kwd discriminates the row kind.
if v := metaString(p.Meta, "type"); v != "" {
if err := doc.SetExtraValue("type_kwd", v); err != nil {
return err
}
}
if v := metaString(p.Meta, "name"); v != "" {
if err := doc.SetExtraValue("title_kwd", v); err != nil {
return err
}
setTitleTokens(doc, v)
}
if v, ok := metaInt(p.Meta, "depth"); ok {
if err := doc.SetExtraValue("depth_int", v); err != nil {
return err
}
}
if v, ok := metaInt(p.Meta, "size"); ok {
if err := doc.SetExtraValue("doc_count_int", v); err != nil {
return err
}
}
if v := metaStringSlice(p.Meta, "doc_ids"); len(v) > 0 {
if err := doc.SetExtraValue("doc_ids_kwd", v); err != nil {
return err
}
}
}
return nil

View File

@@ -13,6 +13,7 @@ import (
"testing"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
"ragflow/internal/service/nav"
)
// mockChat answers the structure variant's three LLM call shapes under the
@@ -110,7 +111,7 @@ func (mockChat) Chat(_ context.Context, req common.ChatRequest) (*common.ChatRes
}
// proseChat returns generic, non-empty prose for the non-structure variants
// (wiki/tree/mindmap/datasetnav), which all just need a summary/outline text.
// (wiki/tree/mindmap), which all just need a summary/outline text.
type proseChat struct{}
func (proseChat) Chat(_ context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
@@ -239,6 +240,138 @@ func TestKnowledgeCompiler_UnknownVariant(t *testing.T) {
}
}
// TestKnowledgeCompiler_Datasetnav_NoVariant locks the removal of the standalone
// datasetnav variant: dataset navigation is NOT an independent compile kind in
// Python (it is a by-product written after tree/page_index compile via
// internal/service datasetnav), so "datasetnav"/"dataset_nav" must now fail as
// an unknown variant rather than silently compile nothing.
func TestKnowledgeCompiler_Datasetnav_NoVariant(t *testing.T) {
for _, kind := range []string{"datasetnav", "dataset_nav"} {
_, err := common.KindToVariant(kind)
if !errors.Is(err, common.ErrUnknownVariant) {
t.Fatalf("KindToVariant(%q) err = %v, want ErrUnknownVariant", kind, err)
}
}
}
// fakeNavSvc records the most recent UpsertDoc call so a test can assert the
// tree by-product hook feeds the nav service the root document summary.
type fakeNavSvc struct {
mu sync.Mutex
called bool
summary string
docID string
kbID string
}
func (f *fakeNavSvc) UpsertDoc(_ context.Context, in nav.UpsertDocInput) error {
f.mu.Lock()
defer f.mu.Unlock()
f.called = true
f.summary = in.Summary
f.docID = in.DocID
f.kbID = in.KbID
return nil
}
func (f *fakeNavSvc) RemoveDoc(context.Context, string, string, string) error { return nil }
func (f *fakeNavSvc) Search(context.Context, string, string, string, []float32, int) ([]nav.NavHit, error) {
return nil, nil
}
func (f *fakeNavSvc) ListClusters(context.Context, string, string, int, int) ([]nav.NavNode, int64, error) {
return nil, 0, nil
}
func (f *fakeNavSvc) ListChildren(context.Context, string, string, string, int, int) ([]nav.NavNode, int64, error) {
return nil, 0, nil
}
// TestKnowledgeCompiler_TreeNavByProduct asserts the tree compile-complete hook
// writes the dataset-nav by-product: after tree.Run produces a root product,
// upsertTreeNav calls NavService.UpsertDoc with the root summary and the
// doc/dataset context, and never aborts on failure.
func TestKnowledgeCompiler_TreeNavByProduct(t *testing.T) {
fake := &fakeNavSvc{}
nav.SetNavService(fake)
t.Cleanup(func() { nav.SetNavService(nil) })
deps := common.Deps{
TenantID: "t1",
DatasetID: "kb1",
Chat: proseChat{},
Embed: mockEmbedder{dim: 8},
}
products := []common.Product{
{Content: "section one body", Meta: map[string]any{"kind": "summary", "level": 0}},
{Content: "overall doc theme root summary", Vector: []float32{0.1, 0.2}, Meta: map[string]any{"kind": "root", "level": -1}},
}
upsertTreeNav(context.Background(), deps, "d1", products)
fake.mu.Lock()
defer fake.mu.Unlock()
if !fake.called {
t.Fatal("upsertTreeNav did not call NavService.UpsertDoc")
}
if fake.docID != "d1" {
t.Errorf("doc_id = %q, want d1", fake.docID)
}
if fake.kbID != "kb1" {
t.Errorf("kb_id = %q, want kb1", fake.kbID)
}
if !strings.Contains(fake.summary, "overall doc theme root summary") {
t.Errorf("summary = %q, want the tree root summary", fake.summary)
}
}
// TestKnowledgeCompiler_TreeNavByProduct_NilServiceDoesNotAbort asserts the
// by-product hook tolerates a missing NavService without erroring (nav is a
// derived artifact; its absence must never abort compilation).
func TestKnowledgeCompiler_TreeNavByProduct_NilServiceDoesNotAbort(t *testing.T) {
nav.SetNavService(nil)
t.Cleanup(func() { nav.SetNavService(nil) })
// Should not panic and must return normally.
upsertTreeNav(context.Background(), common.Deps{}, "d1",
[]common.Product{{Content: "x", Meta: map[string]any{"kind": "root"}}})
}
// TestKnowledgeCompiler_StructureNavByProduct asserts the structure/page_index
// by-product hook summarizes the graph product entities and feeds NavService.
func TestKnowledgeCompiler_StructureNavByProduct(t *testing.T) {
fake := &fakeNavSvc{}
nav.SetNavService(fake)
t.Cleanup(func() { nav.SetNavService(nil) })
graphJSON := `{"entities":[{"name":"Engine","description":"a propulsion device"},{"name":"Fuel","description":"combustion source"}]}`
products := []common.Product{
{Content: graphJSON, Vector: []float32{0.1, 0.2}, Meta: map[string]any{"kind": "graph", "compile_kwd": "page_index"}},
}
// The by-product embeds the SUMMARY (not the graph JSON vector), so an
// embedder must be present.
upsertStructureNav(context.Background(), common.Deps{TenantID: "t1", DatasetID: "kb1", Embed: mockEmbedder{dim: 8}}, "d1", products)
fake.mu.Lock()
defer fake.mu.Unlock()
if !fake.called {
t.Fatal("upsertStructureNav did not call NavService.UpsertDoc")
}
if fake.docID != "d1" {
t.Errorf("doc_id = %q, want d1", fake.docID)
}
if !strings.Contains(fake.summary, "Engine: a propulsion device") {
t.Errorf("summary = %q, want entity descriptions", fake.summary)
}
}
// TestPageIndexSummary asserts entity descriptions are concatenated into a
// document summary.
func TestPageIndexSummary(t *testing.T) {
got := pageIndexSummary(`{"entities":[{"name":"A","description":"one thing"},{"name":"B","description":""}]}`)
if !strings.Contains(got, "A: one thing") {
t.Errorf("summary = %q, want entity A", got)
}
if strings.Contains(got, "B") {
t.Errorf("summary should skip empty-description entity, got %q", got)
}
}
func TestKnowledgeCompiler_Alias_Mindmap(t *testing.T) {
installMockDeps(t)
// "mind_map" is the deprecated alias for "mindmap"; both resolve to the
@@ -388,20 +521,6 @@ func TestKnowledgeCompiler_Mindmap_EndToEnd(t *testing.T) {
}
}
func TestKnowledgeCompiler_Datasetnav_EndToEnd(t *testing.T) {
installProseDeps(t)
chunks := runVariant(t, "datasetnav", nil)
foundRoot := false
for _, c := range chunks {
if kind, _ := c["kc_kind"].(string); kind == "root" {
foundRoot = true
}
}
if !foundRoot {
t.Fatalf("datasetnav: no 'root' chunk; got %d chunks", len(chunks))
}
}
// TestKnowledgeCompiler_EmitsChunks verifies that after compiling, the
// component returns the knowledge units merged into the chunk stream (no
// separate products/writer seam). The output shape is the chunker's
@@ -786,140 +905,6 @@ func TestKnowledgeCompiler_Wiki_HistoricalDedupScopedByDataset(t *testing.T) {
}
}
// fakeDatasetnavLock records the key it was asked to acquire, so a test can
// assert the datasetnav rebuild lock is scoped to the dataset, not the document.
type fakeDatasetnavLock struct {
mu sync.Mutex
lastKey string
}
func (f *fakeDatasetnavLock) Acquire(_ context.Context, key string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastKey = key
return true, nil
}
func (f *fakeDatasetnavLock) Release(_ context.Context, _ string) error { return nil }
// TestKnowledgeCompiler_Datasetnav_LockKeyedByDataset is a regression for the
// Medium bug: the distributed rebuild lock used to be keyed by doc_id, so two
// runs against the same dataset (with different per-document doc_id values)
// took different locks and could interleave. This test supplies both doc_id
// ("d1") and dataset_id ("ds1") and asserts the lock key uses the dataset.
func TestKnowledgeCompiler_Datasetnav_LockKeyedByDataset(t *testing.T) {
lk := &fakeDatasetnavLock{}
common.SetDepsResolver(func(tenantID, llmID, embeddingModel string) (common.Deps, error) {
return common.Deps{
Chat: proseChat{},
Embed: mockEmbedder{dim: 8},
TenantID: tenantID,
Redis: lk,
}, nil
})
t.Cleanup(func() { common.SetDepsResolver(nil) })
installVariantTemplateResolver(t, "datasetnav")
c, err := NewKnowledgeCompilerComponent("KnowledgeCompiler", map[string]any{
"compilation_template_id": "tpl-datasetnav", "llm_id": "llm1", "embedding_model": "emb1",
})
if err != nil {
t.Fatalf("NewKnowledgeCompilerComponent: %v", err)
}
if _, err := c.Invoke(context.Background(), nil, map[string]any{
"chunks": []any{
map[string]any{"id": "c1", "text": "The quick brown fox jumps over the lazy dog near the river bank."},
map[string]any{"id": "c2", "text": "A red fox and a lazy dog rest beside a calm river at dawn."},
},
"doc_id": "d1",
"dataset_id": "ds1",
"tenant_id": "t1",
}); err != nil {
t.Fatalf("Invoke: %v", err)
}
lk.mu.Lock()
key := lk.lastKey
lk.mu.Unlock()
want := "datasetnav:t1:ds1"
if key != want {
t.Fatalf("datasetnav lock key = %q, want %q (lock must be scoped to the dataset, not the document)", key, want)
}
}
// recordingNavChat echoes each nav-group summary back verbatim (so the child
// summaries are identifiable) and records the root-synthesis user prompt so a
// test can assert which child summaries reached the root overview.
type recordingNavChat struct {
mu sync.Mutex
rootPrompt string
}
func (r *recordingNavChat) Chat(_ context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
if strings.HasPrefix(req.UserPrompt, "Compose a navigation overview") {
r.mu.Lock()
r.rootPrompt = req.UserPrompt
r.mu.Unlock()
return &common.ChatResponse{Content: "root overview"}, nil
}
// Nav-group summary: echo the group text so the marker survives into the
// root prompt when the root is built from all child summaries.
return &common.ChatResponse{Content: strings.TrimSpace(req.UserPrompt)}, nil
}
// TestKnowledgeCompiler_Datasetnav_RootIncludesAllSummaries is a regression for
// root-overview completeness: the root synthesis must be built from ALL child
// summaries, not from a partial buffer. The Go implementation buffers every
// product in Outputs.Products (no streaming sink), so the root always sees the
// full child set even when the result set is large. Here nav_radius>1 forces
// each chunk into its own nav node, and we assert every child marker appears in
// the recorded root-synthesis prompt.
func TestKnowledgeCompiler_Datasetnav_RootIncludesAllSummaries(t *testing.T) {
chat := &recordingNavChat{}
common.SetDepsResolver(func(tenantID, llmID, embeddingModel string) (common.Deps, error) {
return common.Deps{
Chat: chat,
Embed: mockEmbedder{dim: 8},
TenantID: tenantID,
}, nil
})
t.Cleanup(func() { common.SetDepsResolver(nil) })
installVariantTemplateResolver(t, "datasetnav")
c, err := NewKnowledgeCompilerComponent("KnowledgeCompiler", map[string]any{
"compilation_template_id": "tpl-datasetnav", "llm_id": "llm1", "embedding_model": "emb1",
// nav_radius > 1 guarantees no two chunks group together (cosine <= 1),
// so each chunk becomes its own nav node.
"extra": map[string]any{"nav_radius": 1.1},
})
if err != nil {
t.Fatalf("NewKnowledgeCompilerComponent: %v", err)
}
markers := []string{"NAVMARKA", "NAVMARKB", "NAVMARKC", "NAVMARKD", "NAVMARKE"}
chunks := make([]any, len(markers))
for i, m := range markers {
chunks[i] = map[string]any{"id": m, "text": m + " unique section body text"}
}
if _, err := c.Invoke(context.Background(), nil, map[string]any{
"chunks": chunks,
"doc_id": "d1",
"tenant_id": "t1",
}); err != nil {
t.Fatalf("Invoke: %v", err)
}
chat.mu.Lock()
root := chat.rootPrompt
chat.mu.Unlock()
if root == "" {
t.Fatalf("root synthesis prompt was never recorded (root node not built)")
}
for _, m := range markers {
if !strings.Contains(root, m) {
t.Fatalf("root overview is missing child summary %q; the root must be built from ALL child summaries.\nroot prompt:\n%s", m, root)
}
}
}
// fencedChat wraps otherwise-valid extraction JSON in a ```json ... ``` fence,
// the most common way LLMs wrap JSON even when JSONMode is requested.
type fencedChat struct{}

View File

@@ -1,776 +0,0 @@
// Package datasetnav implements the "datasetnav" variant of KnowledgeCompiler,
// mirroring Python's dataset_nav.py incremental clustering: each input chunk
// (the in-run equivalent of a document leaf) is embedded and placed into the
// nearest nav_cluster via layered KNN descent — merged when the similarity
// clears _MERGE_THRESHOLD, given a fresh sibling cluster above _MIN_SIM, or
// given a new root-level cluster otherwise. Clusters that exceed the fanout
// or doc-count caps split via the same 2-means procedure as Python.
//
// The Go port keeps the whole tree in memory for one Invoke (no ES reads or
// writes): cross-run incremental upsert/removal, which in Python is an
// ES-backed read-modify-write, is the caller's concern. The per-run algorithm
// (thresholds, LLM merge/summary prompts, readable cluster names, split) is
// faithful to the Python original.
package datasetnav
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
)
// Thresholds and caps, mirrored from dataset_nav.py.
const (
mergeThresholdDefault = 0.80 // merge doc into cluster
recurseThreshold = 0.65 // continue descending into children
minSim = 0.50 // minimum similarity to be considered related
maxFanout = 64 // max child count before triggering rebalance
maxDocsPerCluster = 50 // max docs per leaf cluster before triggering split
)
// datasetnavLock is the minimal locking surface used to serialize dataset-level
// rebuilds. The production Redis client implements this via SET NX + TTL; tests
// inject an in-memory lock (or nil for no-op).
type datasetnavLock interface {
Acquire(ctx context.Context, key string) (bool, error)
Release(ctx context.Context, key string) error
}
// navCluster is one internal tree node (Python's nav_cluster row).
type navCluster struct {
Name string
Desc string
Parent string // parent cluster name; "root" for depth-0 clusters
Depth int
DocIDs []string
Vector []float32
}
// navDoc is one document leaf (Python's nav_doc row).
type navDoc struct {
ChunkID string
Text string
Parent string // owning cluster name
Depth int
Vector []float32
}
// navChild is one child reference under a cluster.
type navChild struct {
name string // cluster name or doc chunk id
isDoc bool
vec []float32
}
// navTree holds the in-memory tree built over one Invoke.
type navTree struct {
clusters map[string]*navCluster
order []string // cluster names in creation order
docs []*navDoc
children map[string][]navChild // parent cluster name → children
}
func newNavTree() *navTree {
return &navTree{clusters: map[string]*navCluster{}, children: map[string][]navChild{}}
}
func (t *navTree) addCluster(c *navCluster) {
t.clusters[c.Name] = c
t.order = append(t.order, c.Name)
}
// Run executes the datasetnav variant.
func Run(ctx context.Context, deps common.Deps, param common.Param, inputs common.Inputs) (common.Outputs, error) {
if deps.Embed == nil {
return common.Outputs{}, fmt.Errorf("datasetnav: embedder required")
}
docID := firstNonEmpty(inputs.DocID, deps.DatasetID)
if docID == "" {
docID = "unknown"
}
llmID := firstNonEmpty(param.LLMID, inputs.LLMID)
tenantID := deps.TenantID
// The rebuild lock is scoped to the dataset, not the document (mirrors
// Python's _nav_lock_key(kb_id)): concurrent runs against the same dataset
// must share one lock. Fall back to docID only when no dataset id is known.
datasetID := firstNonEmpty(deps.DatasetID, docID)
texts, chunkIDs := chunkTexts(inputs.Chunks)
if len(texts) == 0 {
return common.Outputs{}, nil
}
if l, ok := deps.Redis.(datasetnavLock); ok && l != nil {
lockKey := "datasetnav:" + tenantID + ":" + datasetID
acquired, err := l.Acquire(ctx, lockKey)
if err != nil {
return common.Outputs{}, err
}
if !acquired {
return common.Outputs{}, fmt.Errorf("datasetnav: lock not acquired for %s", lockKey)
}
defer l.Release(ctx, lockKey)
}
// Embed every chunk text once (Python embeds each doc summary on upsert).
vectors, err := deps.Embed.Encode(ctx, texts)
if err != nil {
return common.Outputs{}, err
}
if len(vectors) != len(texts) {
return common.Outputs{}, fmt.Errorf("datasetnav: embedding count mismatch (%d vs %d)", len(vectors), len(texts))
}
// Sequential placement, mirroring the lock-serialized per-document upserts
// in Python (also what makes the in-run tree deterministic).
tree := newNavTree()
var clusterDescs []string
mergeThreshold := mergeThresholdFor(param)
for i, text := range texts {
if err := ctx.Err(); err != nil {
return common.Outputs{}, err
}
desc, err := tree.place(ctx, deps, llmID, chunkIDs[i], text, vectors[i], mergeThreshold)
if err != nil {
return common.Outputs{}, err
}
clusterDescs = append(clusterDescs, desc...)
}
// Emit cluster rows in creation order, then nav_doc leaves in placement
// order, then the root overview node (a Go-side convenience: Python's tree
// has no root row, but downstream consumers expect one overview product).
var products []common.Product
clusterProductID := map[string]string{}
// Pre-compute every cluster id (deterministic from StableRowID) before
// resolving parent edges, so a child emitted before its (later-created)
// parent still gets the correct ParentID instead of "" (reparenting bug).
// Cluster ids are tenant-scoped (as the nav_doc ids already are) so two
// tenants whose datasetID falls back to the same docID cannot collide.
for _, name := range tree.order {
clusterProductID[name] = common.StableRowID("dataset_nav", tenantID, datasetID, "cluster", name)
}
for _, name := range tree.order {
c := tree.clusters[name]
pid := ""
if c.Parent != "" && c.Parent != "root" {
pid = clusterProductID[c.Parent]
}
id := clusterProductID[c.Name]
products = append(products, common.Product{
ID: id,
DocID: docID,
TenantID: tenantID,
Variant: common.VariantDatasetnav,
Content: payloadJSON(map[string]any{"type": "nav_cluster", "description": c.Desc}),
Vector: c.Vector,
ParentID: pid,
Meta: map[string]any{
"kind": "nav_cluster",
"type": "nav_cluster",
"name": c.Name,
"parent_name": c.Parent,
"depth": c.Depth,
"doc_ids": append([]string{}, c.DocIDs...),
"size": len(c.DocIDs),
},
})
}
for _, d := range tree.docs {
products = append(products, common.Product{
// Scope nav_doc ids by tenant and dataset (not just chunk id) so
// synthetic/positional chunk ids from different docs/datasets do not
// collide and overwrite each other across tenants (M5).
ID: common.StableRowID("dataset_nav", tenantID, datasetID, "doc", d.ChunkID),
DocID: docID,
TenantID: tenantID,
Variant: common.VariantDatasetnav,
Content: payloadJSON(map[string]any{"type": "nav_doc", "description": d.Text}),
Vector: d.Vector,
ParentID: clusterProductID[d.Parent],
Meta: map[string]any{
"kind": "nav_doc",
"type": "nav_doc",
"name": navDocName(d.Parent, d.Text),
"parent_name": d.Parent,
"depth": d.Depth,
"doc_ids": []string{d.ChunkID},
},
})
}
// Root overview built from EVERY cluster description (all products are
// buffered in the same slice, so every node contributes to the root).
rootSummary, err := summarize(ctx, deps, llmID, "Compose a navigation overview from these section summaries:\n\n"+formatNavSummaries(clusterDescs))
if err == nil && rootSummary != "" {
emb, e2 := deps.Embed.Encode(ctx, []string{rootSummary})
if e2 == nil && len(emb) > 0 {
products = append(products, common.Product{
ID: common.StableRowID(tenantID, docID, string(common.VariantDatasetnav), "root"),
DocID: docID,
TenantID: tenantID,
Variant: common.VariantDatasetnav,
Content: rootSummary,
Vector: emb[0],
Meta: map[string]any{
"kind": "root",
"type": "nav_cluster",
"name": "root",
"depth": 0,
"size": len(tree.docs),
},
})
}
}
out := common.Outputs{
Products: products,
}
return out, nil
}
// place inserts one document (chunk) into the tree, mirroring
// upsert_dataset_nav_doc's placement logic. It returns the descriptions of
// any clusters created for this document (for the root overview).
func (t *navTree) place(ctx context.Context, deps common.Deps, llmID, chunkID, text string, vec []float32, mergeThreshold float64) ([]string, error) {
bestName, bestParent, sim := t.findBestCluster(vec)
switch {
case bestName != "" && sim >= mergeThreshold:
// ── Merge into the best cluster (mirrors _llm_merge + re-embed) ──
c := t.clusters[bestName]
newDesc, err := llmMerge(ctx, deps, llmID, c.Desc, text)
if err != nil {
return nil, err
}
if newDesc != c.Desc {
c.Desc = newDesc
emb, err := deps.Embed.Encode(ctx, []string{newDesc})
if err != nil {
return nil, err
}
if len(emb) > 0 {
c.Vector = emb[0]
}
}
if !containsString(c.DocIDs, chunkID) {
c.DocIDs = append(c.DocIDs, chunkID)
}
t.addDoc(c, chunkID, text, vec, c.Depth+1)
if err := t.maybeSplit(ctx, deps, llmID, bestName); err != nil {
return nil, err
}
return nil, nil
case bestName != "" && sim >= minSim:
// ── Create a sibling/child cluster (mirrors the _MIN_SIM branch) ──
parentForNew := bestParent
if parentForNew == "" {
parentForNew = bestName
}
parentDepth := 1 // Python's default when the parent row is absent
if p, ok := t.clusters[parentForNew]; ok {
parentDepth = p.Depth
}
title, desc, err := llmCreateSummary(ctx, deps, llmID, []string{text})
if err != nil {
return nil, err
}
name := readableClusterName(title, text)
nc := &navCluster{
Name: name,
Desc: desc,
Parent: parentForNew,
// Python stamps the new cluster at the PARENT's depth (not +1);
// the nav_doc goes one deeper. Mirrored, quirk included.
Depth: parentDepth,
DocIDs: []string{chunkID},
Vector: vec,
}
t.addCluster(nc)
t.children[parentForNew] = append(t.children[parentForNew], navChild{name: name, vec: vec})
t.addDoc(nc, chunkID, text, vec, parentDepth+1)
return []string{desc}, nil
default:
// ── Create a root-level cluster (mirrors the else branch) ──
title, desc, err := llmCreateSummary(ctx, deps, llmID, []string{text})
if err != nil {
return nil, err
}
name := readableClusterName(title, text)
nc := &navCluster{
Name: name,
Desc: desc,
Parent: "root",
Depth: 0,
DocIDs: []string{chunkID},
Vector: vec,
}
t.addCluster(nc)
t.addDoc(nc, chunkID, text, vec, 1)
return []string{desc}, nil
}
}
func (t *navTree) addDoc(c *navCluster, chunkID, text string, vec []float32, depth int) {
t.docs = append(t.docs, &navDoc{ChunkID: chunkID, Text: text, Parent: c.Name, Depth: depth, Vector: vec})
t.children[c.Name] = append(t.children[c.Name], navChild{name: chunkID, isDoc: true, vec: vec})
}
// findBestCluster mirrors _find_best_cluster: top-1 root cluster by cosine,
// then descend into the best-matching child while similarity stays above
// _RECURSE_THRESHOLD. Returns (best cluster name, its parent name, sim).
func (t *navTree) findBestCluster(vec []float32) (string, string, float64) {
var best *navCluster
bestSim := 0.0
for _, c := range t.clusters {
if c.Depth != 0 {
continue
}
s := cosine(vec, c.Vector)
if best == nil || s > bestSim {
best, bestSim = c, s
}
}
if best == nil {
return "", "", 0.0
}
bestName, bestParent, sim := best.Name, best.Parent, bestSim
visited := map[string]bool{bestName: true}
for sim >= recurseThreshold {
var child *navCluster
childSim := 0.0
for _, k := range t.children[bestName] {
if k.isDoc || visited[k.name] {
continue
}
c := t.clusters[k.name]
if c == nil {
continue
}
if s := cosine(vec, c.Vector); child == nil || s > childSim {
child, childSim = c, s
}
}
if child == nil || childSim < recurseThreshold {
break
}
bestParent = best.Parent // the (old) best's parent, mirroring Python
bestName = child.Name
sim = childSim
best = child
visited[bestName] = true
}
return bestName, bestParent, sim
}
// maybeSplit mirrors _maybe_split_cluster: when a cluster exceeds the fanout
// or doc-count caps, split its children into two 2-means groups and reparent
// each group under a fresh sub-cluster.
func (t *navTree) maybeSplit(ctx context.Context, deps common.Deps, llmID, clusterName string) error {
kids := t.children[clusterName]
var clusterKids, docKids []navChild
for _, k := range kids {
if k.isDoc {
docKids = append(docKids, k)
} else {
clusterKids = append(clusterKids, k)
}
}
if len(clusterKids)+len(docKids) <= maxFanout && len(docKids) <= maxDocsPerCluster {
return nil
}
if len(kids) < 4 {
return nil // Python requires >= 4 embeddings to attempt a split
}
embs := make([][]float32, len(kids))
for i, k := range kids {
embs[i] = k.vec
}
labels := twoMeans(embs)
parent := t.clusters[clusterName]
depth := 0
if parent != nil {
depth = parent.Depth + 1
}
// navChild collects the new sub-cluster names so the split parent keeps a
// children entry pointing at them; without it findBestCluster can no longer
// descend into the split subtree (M3).
var subChildren []navChild
for gi := 0; gi < 2; gi++ {
var kidIdx []int
for i, lb := range labels {
if lb == gi {
kidIdx = append(kidIdx, i)
}
}
if len(kidIdx) == 0 {
continue
}
var docIDs, descs []string
for _, i := range kidIdx {
k := kids[i]
if k.isDoc {
if !containsString(docIDs, k.name) {
docIDs = append(docIDs, k.name)
}
for _, d := range t.docs {
if d.ChunkID == k.name {
descs = append(descs, d.Text)
}
}
continue
}
if c := t.clusters[k.name]; c != nil {
for _, d := range c.DocIDs {
if !containsString(docIDs, d) {
docIDs = append(docIDs, d)
}
}
descs = append(descs, c.Desc)
}
}
var title, desc string
if len(descs) > 0 {
var err error
title, desc, err = llmCreateSummary(ctx, deps, llmID, descs)
if err != nil {
return err
}
} else {
title, desc = fmt.Sprintf("Group %d", gi+1), fmt.Sprintf("Group %d", gi+1)
}
gname := readableClusterName(title, desc)
emb, err := deps.Embed.Encode(ctx, []string{desc})
if err != nil {
return err
}
var gvec []float32
if len(emb) > 0 {
gvec = emb[0]
}
nc := &navCluster{Name: gname, Desc: desc, Parent: clusterName, Depth: depth, DocIDs: docIDs, Vector: gvec}
t.addCluster(nc)
subChildren = append(subChildren, navChild{name: gname})
// Reparent the group's children to the new sub-cluster.
for _, i := range kidIdx {
k := kids[i]
if k.isDoc {
for _, d := range t.docs {
if d.ChunkID == k.name {
d.Parent = gname
d.Depth = depth + 1
}
}
} else if c := t.clusters[k.name]; c != nil {
c.Parent = gname
c.Depth = depth + 1
}
t.children[gname] = append(t.children[gname], k)
}
}
// The split parent's children become the new sub-clusters so findBestCluster
// can still descend into the split subtree (M3). When no sub-cluster was
// actually created the parent keeps an empty children list.
t.children[clusterName] = subChildren
return nil
}
// twoMeans mirrors the runtime k-means-like split in _maybe_split_cluster:
// centroids seeded with the first and middle embeddings, 10 refinement
// rounds, squared-euclidean assignment, then a final relabel pass.
func twoMeans(embs [][]float32) []int {
n := len(embs)
if n == 0 {
return nil
}
centroids := [][]float32{append([]float32{}, embs[0]...), append([]float32{}, embs[n/2]...)}
assign := func(e []float32) int {
if sqDist(e, centroids[0]) < sqDist(e, centroids[1]) {
return 0
}
return 1
}
for iter := 0; iter < 10; iter++ {
var groups [2][][]float32
for _, e := range embs {
g := assign(e)
groups[g] = append(groups[g], e)
}
for gi := 0; gi < 2; gi++ {
if len(groups[gi]) == 0 {
continue
}
dim := len(groups[gi][0])
avg := make([]float32, dim)
for _, e := range groups[gi] {
for d := 0; d < dim; d++ {
avg[d] += e[d]
}
}
for d := range avg {
avg[d] /= float32(len(groups[gi]))
}
centroids[gi] = avg
}
}
labels := make([]int, n)
for i, e := range embs {
labels[i] = assign(e)
}
return labels
}
func sqDist(a, b []float32) float64 {
var s float64
for i := 0; i < len(a) && i < len(b); i++ {
d := float64(a[i] - b[i])
s += d * d
}
return s
}
func cosine(a, b []float32) float64 {
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
na += float64(a[i]) * float64(a[i])
nb += float64(b[i]) * float64(b[i])
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// ---- LLM helpers (prompts mirrored verbatim from dataset_nav.py) ----
var navLLMTemperature = 0.1
// llmMerge mirrors _llm_merge: fuse the existing cluster description with the
// new doc summary. The reply may be a JSON object ({"merged"|"result"}) or
// bare text; anything unusable keeps the old description.
func llmMerge(ctx context.Context, deps common.Deps, llmID, clusterDesc, docSummary string) (string, error) {
if deps.Chat == nil {
return clusterDesc, nil
}
prompt := "Merge the following two descriptions of the same topic into " +
"a single concise summary (1-3 sentences):\n\n" +
"Existing: " + clusterDesc + "\n\n" +
"New: " + docSummary + "\n\n" +
"Return ONLY the merged text, no commentary."
resp, err := deps.Chat.Chat(ctx, common.ChatRequest{LLMID: llmID, UserPrompt: prompt, Temperature: &navLLMTemperature})
if err != nil {
return "", err
}
text := strings.TrimSpace(resp.Content)
if m := parseWholeJSON(text); m != nil {
if s, ok := m["merged"].(string); ok && strings.TrimSpace(s) != "" {
return s, nil
}
if s, ok := m["result"].(string); ok && strings.TrimSpace(s) != "" {
return s, nil
}
return clusterDesc, nil
}
if text != "" {
return text, nil
}
return clusterDesc, nil
}
// llmCreateSummary mirrors _llm_create_summary: derive a readable (name,
// summary) pair from doc summaries, with the same fallbacks.
func llmCreateSummary(ctx context.Context, deps common.Deps, llmID string, docSummaries []string) (string, string, error) {
fallbackSummary := ""
if len(docSummaries) > 0 {
fallbackSummary = docSummaries[0]
}
if deps.Chat == nil {
return fallbackTitle(fallbackSummary), fallbackSummary, nil
}
prompt := "Given the document excerpts below, produce a short human-readable topic " +
"name and a concise description of their common topic.\n\n" +
strings.Join(docSummaries, "\n---\n") + "\n\n" +
`Return ONLY JSON: {"name": "<2-6 word topic title>", "summary": "<1-3 sentence description>"}`
resp, err := deps.Chat.Chat(ctx, common.ChatRequest{LLMID: llmID, UserPrompt: prompt, Temperature: &navLLMTemperature})
if err != nil {
return "", "", err
}
text := strings.TrimSpace(resp.Content)
if m := parseWholeJSON(text); m != nil {
summary := fallbackSummary
if s, ok := m["summary"].(string); ok && strings.TrimSpace(s) != "" {
summary = strings.TrimSpace(s)
} else if s, ok := m["result"].(string); ok && strings.TrimSpace(s) != "" {
summary = strings.TrimSpace(s)
}
name := cleanTitle(firstStringOf(m["name"]))
if name == "" {
name = fallbackTitle(summary)
}
return name, summary, nil
}
if text != "" {
return fallbackTitle(text), text, nil
}
return fallbackTitle(fallbackSummary), fallbackSummary, nil
}
// parseWholeJSON parses the response ONLY when it is a complete JSON object
// (mirrors gen_json's dict path; bare text takes the string fallback path).
func parseWholeJSON(s string) map[string]any {
if !strings.HasPrefix(s, "{") {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil
}
return m
}
// cleanTitle mirrors _clean_title: whitespace-normalized, capped at 48 chars.
func cleanTitle(title string) string {
return truncateRunes(strings.Join(strings.Fields(title), " "), 48)
}
// fallbackTitle mirrors _fallback_title: first 6 words, else "Cluster".
func fallbackTitle(summary string) string {
words := strings.Fields(summary)
if len(words) > 6 {
words = words[:6]
}
if t := strings.Join(words, " "); t != "" {
return t
}
return "Cluster"
}
// readableClusterName mirrors _readable_cluster_name: "<title> <8-hex>".
func readableClusterName(title, seed string) string {
t := cleanTitle(title)
if t == "" {
t = "Cluster"
}
return t + " " + common.ContentHash(seed)[:8]
}
// navDocName mirrors _make_nav_doc_row's name field:
// f"{parent_kwd}_{xxh64(summary)[:12]}".
func navDocName(parent, summary string) string {
return parent + "_" + common.ContentHash(summary)[:12]
}
// summarize is the plain-text LLM helper used for the root overview.
func summarize(ctx context.Context, deps common.Deps, llmID, text string) (string, error) {
if deps.Chat == nil {
return "", nil
}
resp, err := deps.Chat.Chat(ctx, common.ChatRequest{
LLMID: llmID,
SystemPrompt: navSystemPrompt,
UserPrompt: text,
})
if err != nil {
return "", err
}
return strings.TrimSpace(resp.Content), nil
}
// formatNavSummaries renders cluster descriptions as a bullet list for the
// root overview prompt.
func formatNavSummaries(summaries []string) string {
var b strings.Builder
for _, s := range summaries {
b.WriteString("- ")
b.WriteString(s)
b.WriteString("\n")
}
return b.String()
}
// mergeThresholdFor returns the merge threshold (nav_radius override kept for
// tuning/tests; default _MERGE_THRESHOLD).
func mergeThresholdFor(param common.Param) float64 {
if t, ok := param.Extra["nav_radius"].(float64); ok && t > 0 {
return t
}
return mergeThresholdDefault
}
// chunkTexts returns the non-empty chunk texts and their ids in parallel
// order (Python skips docs without a summary; we skip empty chunks).
func chunkTexts(chunks []common.Chunk) ([]string, []string) {
var texts, ids []string
for i, c := range chunks {
t := firstNonEmpty(c.Text, c.Content)
if strings.TrimSpace(t) == "" {
continue
}
id := c.ID
if id == "" {
id = fmt.Sprintf("chunk-%d", i)
}
texts = append(texts, t)
ids = append(ids, id)
}
return texts, ids
}
func payloadJSON(v map[string]any) string {
var b strings.Builder
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return "{}"
}
return strings.TrimSpace(b.String())
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
func firstStringOf(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
const navSystemPrompt = `You are a navigation assistant. Summarize the provided text into a concise label and overview that helps a user navigate to the relevant content. Output the summary only.`

View File

@@ -1,289 +0,0 @@
package datasetnav
import (
"context"
"fmt"
"strings"
"testing"
"ragflow/internal/ingestion/component/knowledge_compiler/common"
)
// scriptedEmbedder maps texts to fixed vectors (fallback: halves).
type scriptedEmbedder struct {
vecs map[string][]float32
}
func (s scriptedEmbedder) Dimensions() int { return 2 }
func (s scriptedEmbedder) Encode(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i, t := range texts {
if v, ok := s.vecs[t]; ok {
out[i] = v
} else {
out[i] = []float32{0.5, 0.5}
}
}
return out, nil
}
// navFakeChat answers the three nav LLM calls deterministically: merge keeps
// the existing description, create-summary returns canned JSON, the root
// overview is a fixed string.
type navFakeChat struct {
mergeCalls int
summaryCalls int
}
func (m *navFakeChat) Chat(_ context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
switch {
case strings.Contains(req.UserPrompt, "Merge the following two descriptions"):
m.mergeCalls++
existing := ""
if rest, ok := strings.CutPrefix(req.UserPrompt, "Merge the following two descriptions of the same topic into a single concise summary (1-3 sentences):\n\nExisting: "); ok {
existing, _, _ = strings.Cut(rest, "\n\nNew: ")
}
return &common.ChatResponse{Content: fmt.Sprintf(`{"merged": %q}`, existing)}, nil
case strings.Contains(req.UserPrompt, "Given the document excerpts below"):
m.summaryCalls++
// Distinct summaries per call: readable cluster names hash the
// description, so identical canned summaries would collide (the same
// name-overwrite hazard exists in Python's _nav_cluster_id).
return &common.ChatResponse{Content: fmt.Sprintf(`{"name": "Topic", "summary": "a topic summary %d"}`, m.summaryCalls)}, nil
case strings.HasPrefix(req.UserPrompt, "Compose a navigation overview"):
return &common.ChatResponse{Content: "root overview"}, nil
default:
return &common.ChatResponse{Content: "ok"}, nil
}
}
func TestTwoMeans(t *testing.T) {
embs := [][]float32{{1, 0}, {0.9, 0.1}, {0, 1}, {0.1, 0.9}}
labels := twoMeans(embs)
if len(labels) != 4 {
t.Fatalf("labels = %v", labels)
}
if labels[0] != labels[1] || labels[2] != labels[3] || labels[0] == labels[2] {
t.Fatalf("expected {x,x,y,y} grouping, got %v", labels)
}
}
func TestReadableClusterName(t *testing.T) {
got := readableClusterName(" My Topic ", "seed text")
if !strings.HasPrefix(got, "My Topic ") {
t.Fatalf("readableClusterName = %q, want cleaned title + hash suffix", got)
}
if got := readableClusterName("", "seed"); !strings.HasPrefix(got, "Cluster ") {
t.Fatalf("empty title must fall back to Cluster: %q", got)
}
}
func TestFallbackTitle(t *testing.T) {
if got := fallbackTitle("one two three four five six seven eight"); got != "one two three four five six" {
t.Fatalf("fallbackTitle = %q, want first 6 words", got)
}
if got := fallbackTitle(""); got != "Cluster" {
t.Fatalf("empty fallbackTitle = %q, want Cluster", got)
}
}
func TestPlace_MergeSiblingRootBranches(t *testing.T) {
vecs := map[string][]float32{
"A": {1, 0},
"A2": {0.99, 0.1}, // cosine ≈ 0.995 with A → merge
"B": {0.7, 0.714}, // cosine ≈ 0.7 with A → sibling
"C": {0, 1}, // cosine 0 with A → new root
}
deps := common.Deps{Chat: &navFakeChat{}, Embed: scriptedEmbedder{vecs: vecs}}
tree := newNavTree()
if _, err := tree.place(context.Background(), deps, "llm", "cA", "A", vecs["A"], mergeThresholdDefault); err != nil {
t.Fatal(err)
}
if _, err := tree.place(context.Background(), deps, "llm", "cA2", "A2", vecs["A2"], mergeThresholdDefault); err != nil {
t.Fatal(err)
}
if _, err := tree.place(context.Background(), deps, "llm", "cB", "B", vecs["B"], mergeThresholdDefault); err != nil {
t.Fatal(err)
}
if _, err := tree.place(context.Background(), deps, "llm", "cC", "C", vecs["C"], mergeThresholdDefault); err != nil {
t.Fatal(err)
}
if len(tree.order) != 3 {
t.Fatalf("clusters = %d, want 3 (A-root, B-sibling, C-root): %v", len(tree.order), tree.order)
}
// A2 merged into A's cluster.
var clusterA *navCluster
for _, name := range tree.order {
if tree.clusters[name].Depth == 0 && containsString(tree.clusters[name].DocIDs, "cA") {
clusterA = tree.clusters[name]
}
}
if clusterA == nil || len(clusterA.DocIDs) != 2 || clusterA.DocIDs[1] != "cA2" {
t.Fatalf("A2 must merge into cluster A (doc_ids=%v)", clusterA)
}
// B's cluster: sibling under the virtual root (Python depth quirk: parent
// depth default 1 → cluster depth 1, parent "root").
var clusterB *navCluster
for _, name := range tree.order {
c := tree.clusters[name]
if containsString(c.DocIDs, "cB") {
clusterB = c
}
}
if clusterB == nil || clusterB.Parent != "root" || clusterB.Depth != 1 {
t.Fatalf("B sibling cluster = %+v, want parent root depth 1", clusterB)
}
// C's cluster: a fresh root-level cluster.
var clusterC *navCluster
for _, name := range tree.order {
c := tree.clusters[name]
if containsString(c.DocIDs, "cC") {
clusterC = c
}
}
if clusterC == nil || clusterC.Parent != "root" || clusterC.Depth != 0 {
t.Fatalf("C root cluster = %+v, want parent root depth 0", clusterC)
}
if len(tree.docs) != 4 {
t.Fatalf("nav docs = %d, want 4", len(tree.docs))
}
}
func TestMaybeSplit(t *testing.T) {
vecs := map[string][]float32{}
deps := common.Deps{Chat: &navFakeChat{}, Embed: scriptedEmbedder{vecs: vecs}}
tree := newNavTree()
parent := &navCluster{Name: "P", Desc: "parent", Parent: "root", Depth: 0, Vector: []float32{1, 0}}
tree.addCluster(parent)
// 51 docs (> maxDocsPerCluster) alternating between two axes.
for i := 0; i < 51; i++ {
id := fmt.Sprintf("d%d", i)
v := []float32{1, 0}
if i%2 == 1 {
v = []float32{0, 1}
}
parent.DocIDs = append(parent.DocIDs, id)
tree.addDoc(parent, id, "text "+id, v, 1)
}
if err := tree.maybeSplit(context.Background(), deps, "llm", "P"); err != nil {
t.Fatal(err)
}
// Expect two new sub-clusters under P, children reparented, P left with
// no direct children.
if len(tree.order) != 3 {
t.Fatalf("clusters after split = %d, want 3 (P + 2 groups): %v", len(tree.order), tree.order)
}
// The split parent must now point at exactly the two new sub-clusters so
// findBestCluster can descend into the split subtree (M3).
if got := len(tree.children["P"]); got != 2 {
t.Fatalf("split parent children = %d, want 2 (the two new sub-clusters)", got)
}
subDocs := 0
for _, name := range tree.order[1:] {
c := tree.clusters[name]
if c.Parent != "P" || c.Depth != 1 {
t.Errorf("sub-cluster %s parent/depth = %s/%d, want P/1", name, c.Parent, c.Depth)
}
subDocs += len(c.DocIDs)
}
if subDocs != 51 {
t.Errorf("sub-cluster doc_ids total = %d, want 51", subDocs)
}
for _, d := range tree.docs {
if d.Parent == "P" {
t.Errorf("doc %s not reparented off P", d.ChunkID)
}
if d.Depth != 2 {
t.Errorf("doc %s depth = %d, want 2", d.ChunkID, d.Depth)
}
}
}
func TestMaybeSplit_BelowCaps(t *testing.T) {
tree := newNavTree()
parent := &navCluster{Name: "P", Desc: "p", Parent: "root", Depth: 0, Vector: []float32{1, 0}}
tree.addCluster(parent)
for i := 0; i < 3; i++ {
tree.addDoc(parent, fmt.Sprintf("d%d", i), "t", []float32{1, 0}, 1)
}
if err := tree.maybeSplit(context.Background(), common.Deps{}, "llm", "P"); err != nil {
t.Fatal(err)
}
if len(tree.order) != 1 {
t.Fatalf("below caps must not split: %v", tree.order)
}
}
func TestRun_EndToEnd(t *testing.T) {
vecs := map[string][]float32{
"alpha one": {1, 0},
"alpha two": {0.99, 0.1},
"beta one": {0, 1},
}
deps := common.Deps{Chat: &navFakeChat{}, Embed: scriptedEmbedder{vecs: vecs}, TenantID: "t1", DatasetID: "ds1"}
p := common.Param{}.Defaults()
p.Variant = common.VariantDatasetnav
inputs := common.Inputs{
DocID: "d1",
Chunks: []common.Chunk{
{ID: "c1", Text: "alpha one"},
{ID: "c2", Text: "alpha two"},
{ID: "c3", Text: "beta one"},
},
}
out, err := Run(context.Background(), deps, p, inputs)
if err != nil {
t.Fatalf("Run: %v", err)
}
var clusters, docs, roots int
parentByID := map[string]string{}
for _, p := range out.Products {
switch p.Meta["kind"] {
case "nav_cluster":
clusters++
parentByID[p.Meta["name"].(string)] = p.Meta["parent_name"].(string)
case "nav_doc":
docs++
if p.ParentID == "" {
t.Errorf("nav_doc must link to its cluster product")
}
case "root":
roots++
}
}
if clusters != 2 || docs != 3 || roots != 1 {
t.Fatalf("products = %d clusters + %d docs + %d roots, want 2+3+1", clusters, docs, roots)
}
}
func TestLLMMergeFallbacks(t *testing.T) {
// Non-JSON text reply is used verbatim (mirrors gen_json's str path).
deps := common.Deps{Chat: chatFunc(func(req common.ChatRequest) (*common.ChatResponse, error) {
return &common.ChatResponse{Content: "merged text"}, nil
})}
got, err := llmMerge(context.Background(), deps, "llm", "old", "new")
if err != nil || got != "merged text" {
t.Fatalf("llmMerge text path = %q, %v", got, err)
}
// Empty reply keeps the old description.
deps2 := common.Deps{Chat: chatFunc(func(req common.ChatRequest) (*common.ChatResponse, error) {
return &common.ChatResponse{Content: ""}, nil
})}
got, _ = llmMerge(context.Background(), deps2, "llm", "old", "new")
if got != "old" {
t.Fatalf("empty reply must keep old desc, got %q", got)
}
// Nil chat keeps the old description without a call.
got, _ = llmMerge(context.Background(), common.Deps{}, "llm", "old", "new")
if got != "old" {
t.Fatalf("nil chat must keep old desc, got %q", got)
}
}
type chatFunc func(common.ChatRequest) (*common.ChatResponse, error)
func (f chatFunc) Chat(_ context.Context, req common.ChatRequest) (*common.ChatResponse, error) {
return f(req)
}

View File

@@ -0,0 +1,92 @@
package service
import (
"context"
"testing"
"ragflow/internal/service/nav"
)
// fakeArtifactNav is an in-memory nav.NavService used to lock the limited
// DeleteNav/DeleteNavNode semantics (direct doc children only; no sub-cluster
// or cascade deletion).
type fakeArtifactNav struct {
clusters []nav.NavNode
children map[string][]nav.NavNode
removedDocs []string
}
func (f *fakeArtifactNav) UpsertDoc(context.Context, nav.UpsertDocInput) error { return nil }
func (f *fakeArtifactNav) RemoveDoc(_ context.Context, _, _, docID string) error {
f.removedDocs = append(f.removedDocs, docID)
return nil
}
func (f *fakeArtifactNav) Search(context.Context, string, string, string, []float32, int) ([]nav.NavHit, error) {
return nil, nil
}
func (f *fakeArtifactNav) ListClusters(context.Context, string, string, int, int) ([]nav.NavNode, int64, error) {
return f.clusters, int64(len(f.clusters)), nil
}
func (f *fakeArtifactNav) ListChildren(_ context.Context, _, _, name string, _, _ int) ([]nav.NavNode, int64, error) {
return f.children[name], int64(len(f.children[name])), nil
}
// TestDeleteNav_RemovesOnlyDirectDocChildren documents the limited semantic of
// the deprecated DeleteNav: it removes the nav_doc rows directly under root
// clusters, and does NOT touch sub-clusters (Python's full cascade is not
// implemented in the minimal loop).
func TestDeleteNav_RemovesOnlyDirectDocChildren(t *testing.T) {
fake := &fakeArtifactNav{
clusters: []nav.NavNode{{Name: "C1", Description: "root"}},
children: map[string][]nav.NavNode{
"C1": {{Name: "Sub", Type: "cluster"}, {Name: "DocA", Type: "doc", DocID: "d1"}},
"Sub": {{Name: "DocB", Type: "doc", DocID: "d2"}},
},
}
prev := nav.GetNavService()
nav.SetNavService(fake)
defer func() { nav.SetNavService(prev) }()
svc := NewDatasetArtifactService()
n, err := svc.DeleteNav(context.Background(), "t1", "kb1")
if err != nil {
t.Fatalf("DeleteNav: %v", err)
}
// Only the direct doc child (d1) under the root cluster is removed; the
// sub-cluster's doc (d2) is NOT reached (no subtree traversal).
if n != 1 {
t.Errorf("deleted = %d, want 1 (direct doc child only)", n)
}
if len(fake.removedDocs) != 1 || fake.removedDocs[0] != "d1" {
t.Errorf("removed docs = %v, want [d1] only (no cascade into Sub)", fake.removedDocs)
}
}
// TestDeleteNavNode_RemovesDirectChildren documents that DeleteNavNode drains
// only the immediate doc children of a named cluster.
func TestDeleteNavNode_RemovesDirectChildren(t *testing.T) {
fake := &fakeArtifactNav{
children: map[string][]nav.NavNode{
"C1": {
{Name: "Sub2", Type: "cluster"},
{Name: "DocA", Type: "doc", DocID: "d1"},
{Name: "DocB", Type: "doc", DocID: "d2"},
},
},
}
prev := nav.GetNavService()
nav.SetNavService(fake)
defer func() { nav.SetNavService(prev) }()
svc := NewDatasetArtifactService()
n, err := svc.DeleteNavNode(context.Background(), "t1", "kb1", "C1")
if err != nil {
t.Fatalf("DeleteNavNode: %v", err)
}
if n != 2 {
t.Errorf("deleted = %d, want 2 (direct doc children d1,d2)", n)
}
if len(fake.removedDocs) != 2 {
t.Errorf("removed docs = %v, want [d1 d2]", fake.removedDocs)
}
}

View File

@@ -21,6 +21,7 @@ import (
"ragflow/internal/engine"
"ragflow/internal/engine/types"
"ragflow/internal/service/nav"
)
// Compile keyword constants used by the knowledge-compilation artifacts stored
@@ -601,115 +602,120 @@ func (s *DatasetArtifactService) DeleteDocumentGraph(ctx context.Context, tenant
return len(ids), nil
}
// NavigationItem is a single navigation cluster.
// NavigationItem is a single navigation cluster (REST response shape, kept
// stable for frontend compatibility).
type NavigationItem struct {
Name string `json:"name"`
Title string `json:"title"`
Count int `json:"count"`
}
// ListNavClusters returns the navigation clusters of a dataset.
func (s *DatasetArtifactService) ListNavClusters(ctx context.Context, tenantID, datasetID string) ([]NavigationItem, int64, error) {
chunks, total, err := s.searchCompiled(ctx, tenantID, datasetID,
map[string]interface{}{"compile_kwd": []string{CompileKwdDatasetNav}},
[]string{"nav_cluster_kwd", "title_kwd", "count_int"}, 0, 10000, nil)
if err != nil {
return nil, 0, err
}
items := make([]NavigationItem, 0, len(chunks))
for _, c := range chunks {
count := intValue(c["count_int"])
items = append(items, NavigationItem{
Name: firstStringValue(c["nav_cluster_kwd"]),
Title: firstStringValue(c["title_kwd"]),
Count: count,
})
}
return items, total, nil
}
// DeleteNav deletes all navigation clusters of a dataset.
func (s *DatasetArtifactService) DeleteNav(ctx context.Context, tenantID, datasetID string) (int, error) {
docEngine := engine.Get()
if docEngine == nil {
return 0, fmt.Errorf("document engine is not initialized")
}
chunks, _, err := s.searchCompiled(ctx, tenantID, datasetID,
map[string]interface{}{"compile_kwd": []string{CompileKwdDatasetNav}}, []string{"id"}, 0, 10000, nil)
if err != nil {
return 0, err
}
if len(chunks) == 0 {
return 0, nil
}
ids := make([]string, 0, len(chunks))
for _, c := range chunks {
if id, ok := c["id"].(string); ok {
ids = append(ids, id)
}
}
cond := map[string]interface{}{"id": ids, "kb_id": datasetID}
if _, err := docEngine.DeleteChunks(ctx, cond, wikiIndexName(tenantID), datasetID); err != nil {
return 0, err
}
return len(ids), nil
}
// DeleteNavNode deletes a single navigation cluster by name.
func (s *DatasetArtifactService) DeleteNavNode(ctx context.Context, tenantID, datasetID, name string) (int, error) {
docEngine := engine.Get()
if docEngine == nil {
return 0, fmt.Errorf("document engine is not initialized")
}
chunks, _, err := s.searchCompiled(ctx, tenantID, datasetID,
map[string]interface{}{"compile_kwd": []string{CompileKwdDatasetNav}, "nav_cluster_kwd": []string{name}},
[]string{"id"}, 0, 10000, nil)
if err != nil {
return 0, err
}
if len(chunks) == 0 {
return 0, nil
}
ids := make([]string, 0, len(chunks))
for _, c := range chunks {
if id, ok := c["id"].(string); ok {
ids = append(ids, id)
}
}
cond := map[string]interface{}{"id": ids, "kb_id": datasetID}
if _, err := docEngine.DeleteChunks(ctx, cond, wikiIndexName(tenantID), datasetID); err != nil {
return 0, err
}
return len(ids), nil
}
// NavChildItem is a single child entry under a navigation cluster.
// NavChildItem is a single child entry under a navigation cluster (REST
// response shape, kept stable for frontend compatibility).
type NavChildItem struct {
Name string `json:"name"`
Title string `json:"title"`
Count int `json:"count"`
}
// ListNavChildren returns the children of a navigation cluster.
func (s *DatasetArtifactService) ListNavChildren(ctx context.Context, tenantID, datasetID, name string) ([]NavChildItem, int64, error) {
chunks, total, err := s.searchCompiled(ctx, tenantID, datasetID,
map[string]interface{}{"compile_kwd": []string{CompileKwdDatasetNav}, "nav_cluster_kwd": []string{name}},
[]string{"nav_child_kwd", "title_kwd", "count_int"}, 0, 10000, nil)
// ListNavClusters returns the navigation clusters of a dataset. It is DEPRECATED
// and now delegates to the ES-backed NavService (internal/service datasetnav):
// the previous implementation queried nav_cluster_kwd/count_int fields that
// Python never writes, so it could never read the real nav tree. Do not add
// field-level patches here — route everything through NavService.
func (s *DatasetArtifactService) ListNavClusters(ctx context.Context, tenantID, datasetID string) ([]NavigationItem, int64, error) {
ns := nav.GetNavService()
if ns == nil {
return nil, 0, fmt.Errorf("datasetnav: NavService not initialized (SetNavService must be called at bootstrap)")
}
nodes, total, err := ns.ListClusters(ctx, tenantID, datasetID, 0, 10000)
if err != nil {
return nil, 0, err
}
items := make([]NavChildItem, 0, len(chunks))
for _, c := range chunks {
count := intValue(c["count_int"])
items = append(items, NavChildItem{
Name: firstStringValue(c["nav_child_kwd"]),
Title: firstStringValue(c["title_kwd"]),
Count: count,
})
items := make([]NavigationItem, 0, len(nodes))
for _, n := range nodes {
items = append(items, NavigationItem{Name: n.Name, Title: n.Description, Count: n.DocCount})
}
return items, total, nil
}
// ListNavChildren returns the children of a navigation cluster. DEPRECATED —
// delegates to NavService.ListChildren.
func (s *DatasetArtifactService) ListNavChildren(ctx context.Context, tenantID, datasetID, name string) ([]NavChildItem, int64, error) {
ns := nav.GetNavService()
if ns == nil {
return nil, 0, fmt.Errorf("datasetnav: NavService not initialized (SetNavService must be called at bootstrap)")
}
nodes, total, err := ns.ListChildren(ctx, tenantID, datasetID, name, 0, 10000)
if err != nil {
return nil, 0, err
}
items := make([]NavChildItem, 0, len(nodes))
for _, n := range nodes {
items = append(items, NavChildItem{Name: n.Name, Title: n.Description, Count: n.DocCount})
}
return items, total, nil
}
// DeleteNav removes the direct nav_doc children of every root cluster of a
// dataset. DEPRECATED — this is the minimal-loop approximation of Python
// delete_nav: it drains only the immediate nav_doc rows under root clusters and
// does NOT implement Python's full subtree traversal or empty-cluster cascade
// cleanup. Prefer the NavService (future work) for a complete delete. Returns
// the number of nav_doc rows removed.
func (s *DatasetArtifactService) DeleteNav(ctx context.Context, tenantID, datasetID string) (int, error) {
ns := nav.GetNavService()
if ns == nil {
return 0, fmt.Errorf("datasetnav: NavService not initialized (SetNavService must be called at bootstrap)")
}
clusters, _, err := ns.ListClusters(ctx, tenantID, datasetID, 0, 10000)
if err != nil {
return 0, err
}
deleted := 0
for _, c := range clusters {
children, _, err := ns.ListChildren(ctx, tenantID, datasetID, c.Name, 0, 10000)
if err != nil {
continue
}
for _, ch := range children {
if ch.DocID != "" {
if err := ns.RemoveDoc(ctx, tenantID, datasetID, ch.DocID); err != nil {
return deleted, err
}
deleted++
}
}
}
return deleted, nil
}
// DeleteNavNode deletes the direct nav_doc children of a named cluster.
// DEPRECATED — the minimal loop only drains immediate doc children (returns the
// count); it does NOT delete sub-clusters recursively nor perform Python's
// empty-cluster cascade. A full tree-node delete is future NavService work.
func (s *DatasetArtifactService) DeleteNavNode(ctx context.Context, tenantID, datasetID, name string) (int, error) {
ns := nav.GetNavService()
if ns == nil {
return 0, fmt.Errorf("datasetnav: NavService not initialized (SetNavService must be called at bootstrap)")
}
// Minimal loop has no per-node delete; drain direct children's docs.
children, _, err := ns.ListChildren(ctx, tenantID, datasetID, name, 0, 10000)
if err != nil {
return 0, err
}
deleted := 0
for _, ch := range children {
if ch.DocID != "" {
if err := ns.RemoveDoc(ctx, tenantID, datasetID, ch.DocID); err != nil {
return deleted, err
}
deleted++
}
}
return deleted, nil
}
// SkillTreeItem is a single skill-tree page summary.
type SkillTreeItem struct {
Kwd string `json:"kwd"`

View File

@@ -0,0 +1,96 @@
//
// 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 nav defines the dataset-navigation service interface. It is kept as a
// dependency-light leaf package so that both the agent tool layer
// (internal/agent/tool) and the concrete implementation
// (internal/service/nlp) can depend on it without creating an import cycle —
// mirroring how RetrievalService lives in the agent tool package. See
// tasks/agentic_search_port_plan.md.
package nav
import (
"context"
"sync"
)
// NavNode mirrors Python's _nav_item (dataset_api_service.py).
type NavNode struct {
Name string // name
Description string // content_with_weight payload description
DocCount int // doc_count_int (cluster) or 1 (leaf)
Type string // "cluster" | "doc"
DocID string // leaf doc_id; empty for cluster
HasChildren bool // is_cluster
}
// NavHit is one KNN hit on a nav row.
type NavHit struct {
Type string // "nav_doc" | "nav_cluster"
DocID string
DocIDs []string
Name string
Score float64
}
// UpsertDocInput carries the per-document summary to place into the nav tree.
type UpsertDocInput struct {
TenantID string
KbID string
DocID string
Summary string // document summary text (tree product or page_index summary)
Embedd []float32 // optional precomputed embedding
}
// NavService is the single read/write entrypoint for a dataset's navigation
// tree. It is the only nav consumer used by agent tools, REST handlers and the
// tree/structure compile-complete hooks.
type NavService interface {
// UpsertDoc places one document summary into the nav tree (incremental,
// ES-backed read-modify-write; deterministic placement in the minimal loop).
UpsertDoc(ctx context.Context, in UpsertDocInput) error
// RemoveDoc removes a document's nav rows for the given doc. The minimal-loop
// implementation deletes the nav_doc row(s) for the doc; empty-cluster
// cascade cleanup is NOT yet implemented.
RemoveDoc(ctx context.Context, tenantID, kbID, docID string) error
// Search runs query KNN over nav rows and returns the routed doc ids.
Search(ctx context.Context, tenantID, kbID, query string, embd []float32, topK int) ([]NavHit, error)
// ListClusters returns the depth-0 clusters (parent_kwd=root).
ListClusters(ctx context.Context, tenantID, kbID string, page, pageSize int) ([]NavNode, int64, error)
// ListChildren returns the direct children of a cluster (parent_kwd=name).
ListChildren(ctx context.Context, tenantID, kbID, name string, page, pageSize int) ([]NavNode, int64, error)
}
var (
svcMu sync.RWMutex
svcInst NavService
)
// SetNavService installs the (production or test) NavService singleton.
func SetNavService(s NavService) {
svcMu.Lock()
defer svcMu.Unlock()
svcInst = s
}
// GetNavService returns the installed NavService. It may return nil until
// SetNavService is called during server bootstrap.
func GetNavService() NavService {
svcMu.RLock()
defer svcMu.RUnlock()
return svcInst
}

View File

@@ -0,0 +1,80 @@
//
// 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 service
import (
"context"
"fmt"
"strings"
)
// NavEmbedder is the production implementation of nlp.NavEmbedder. It resolves
// the tenant's embedding model on each call and returns float32 vectors (the
// dataset-nav index stores q_<dim>_vec as float). It lives in the service
// package (not nlp) so it can import model_service without an import cycle.
type NavEmbedder struct {
modelSvc *ModelProviderService
// embdModelName is the composite embedding model name (e.g.
// "embedding_model@..." ). Empty falls back to resolving the tenant default.
embdModelName string
}
// NewNavEmbedder builds the production embedder used by NavService.
func NewNavEmbedder(modelSvc *ModelProviderService, embdModelName string) *NavEmbedder {
return &NavEmbedder{modelSvc: modelSvc, embdModelName: embdModelName}
}
// Encode embeds texts for the tenant and returns float32 vectors.
func (e *NavEmbedder) Encode(ctx context.Context, tenantID string, texts []string) ([][]float32, error) {
if e.modelSvc == nil {
return nil, fmt.Errorf("datasetnav: embedding model service not initialized")
}
name := e.embdModelName
if name == "" {
name = tenantID // composite name falls back to tenant default resolution
}
model, err := e.modelSvc.GetEmbeddingModel(ctx, tenantID, name)
if err != nil {
return nil, fmt.Errorf("datasetnav: resolve embedding model for tenant %s: %w", tenantID, err)
}
nonEmpty := make([]string, 0, len(texts))
for _, t := range texts {
if strings.TrimSpace(t) != "" {
nonEmpty = append(nonEmpty, t)
}
}
if len(nonEmpty) == 0 {
return nil, nil
}
embeds, err := model.ModelDriver.Embed(ctx, model.ModelName, nonEmpty, model.APIConfig, nil, nil)
if err != nil {
return nil, err
}
out := make([][]float32, 0, len(embeds))
for _, e := range embeds {
out = append(out, toF32(e.Embedding))
}
return out, nil
}
func toF32(v []float64) []float32 {
out := make([]float32, len(v))
for i, x := range v {
out[i] = float32(x)
}
return out
}

View File

@@ -0,0 +1,574 @@
//
// 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 nlp
import (
"context"
"encoding/json"
"fmt"
"strings"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
"ragflow/internal/service/nav"
)
// Dataset-nav constants. These mirror Python's dataset_nav.py: nav rows live in
// the document index as compile_kwd="dataset_nav" rows with available_int=0
// (invisible to the default retriever, which filters available_int=1), and the
// tree is threaded through parent_kwd ("root" for depth-0 clusters).
const (
navCompileKwd = "dataset_nav"
navRootParent = "root"
navMergeThreshold = 0.80 // sim >= this -> merge doc into cluster
navRecurse = 0.65 // descend while sim >= this
navMinSim = 0.50 // sim >= this -> new sibling cluster
navMaxDepth = 6 // max descent levels during best-cluster search
)
// NavEmbedder embeds text. tenantID lets a production implementation resolve
// the tenant's embedding model. Kept as an interface so tests inject a stub.
type NavEmbedder interface {
Encode(ctx context.Context, tenantID string, texts []string) ([][]float32, error)
}
// NavService is the concrete, ES-backed implementation of nav.NavService.
type NavService struct {
embed NavEmbedder
engine engine.DocEngine // optional; falls back to engine.Get() when nil
}
// NewNavService builds the ES-backed NavService. embed may be nil when the
// caller guarantees every UpsertDocInput carries a precomputed Embedd.
func NewNavService(embed NavEmbedder) *NavService {
return &NavService{embed: embed}
}
func (s *NavService) docEngine() (engine.DocEngine, error) {
if s.engine != nil {
return s.engine, nil
}
de := engine.Get()
if de == nil {
return nil, fmt.Errorf("document engine is not initialized")
}
return de, nil
}
// navIndexName returns the tenant document index name (ragflow_<tenantID>).
func (s *NavService) navIndexName(tenantID string) string {
return fmt.Sprintf("ragflow_%s", tenantID)
}
// navFilter builds the common nav filter that pins compile_kwd.
func navFilter(extra map[string]interface{}) map[string]interface{} {
f := map[string]interface{}{"compile_kwd": []string{navCompileKwd}}
for k, v := range extra {
f[k] = v
}
return f
}
// navSearch runs a filtered read over the tenant index for the dataset.
func (s *NavService) navSearch(ctx context.Context, tenantID, kbID string, filter map[string]interface{}, selectFields []string, offset, limit int, matchExprs []interface{}) ([]map[string]interface{}, int64, error) {
de, err := s.docEngine()
if err != nil {
return nil, 0, err
}
merged := make(map[string]interface{}, len(filter)+1)
for k, v := range filter {
merged[k] = v
}
merged["kb_id"] = []string{kbID}
req := &types.SearchRequest{
IndexNames: []string{s.navIndexName(tenantID)},
KbIDs: []string{kbID},
Offset: offset,
Limit: limit,
SelectFields: selectFields,
Filter: merged,
MatchExprs: matchExprs,
}
res, err := de.Search(ctx, req)
if err != nil {
return nil, 0, err
}
if res == nil {
return nil, 0, nil
}
return res.Chunks, res.Total, nil
}
// ListClusters returns the depth-0 clusters (parent_kwd=root).
func (s *NavService) ListClusters(ctx context.Context, tenantID, kbID string, page, pageSize int) ([]nav.NavNode, int64, error) {
if pageSize <= 0 {
pageSize = 100
}
offset := page * pageSize
chunks, total, err := s.navSearch(ctx, tenantID, kbID,
navFilter(map[string]interface{}{
"type_kwd": []string{"nav_cluster"},
"parent_kwd": []string{navRootParent},
}),
[]string{"title_kwd", "content_with_weight", "doc_count_int", "type_kwd"}, offset, pageSize, nil)
if err != nil {
return nil, 0, err
}
nodes := make([]nav.NavNode, 0, len(chunks))
for _, c := range chunks {
nodes = append(nodes, s.nodeFromRow(c, "cluster"))
}
return nodes, total, nil
}
// ListChildren returns the direct children of a cluster (parent_kwd=name).
func (s *NavService) ListChildren(ctx context.Context, tenantID, kbID, name string, page, pageSize int) ([]nav.NavNode, int64, error) {
if pageSize <= 0 {
pageSize = 100
}
offset := page * pageSize
chunks, total, err := s.navSearch(ctx, tenantID, kbID,
navFilter(map[string]interface{}{"parent_kwd": []string{name}}),
[]string{"title_kwd", "content_with_weight", "doc_count_int", "type_kwd", "doc_id"}, offset, pageSize, nil)
if err != nil {
return nil, 0, err
}
nodes := make([]nav.NavNode, 0, len(chunks))
for _, c := range chunks {
typ := firstStringValue(c["type_kwd"])
nodeType := "doc"
if typ == "nav_cluster" {
nodeType = "cluster"
}
nodes = append(nodes, s.nodeFromRow(c, nodeType))
}
return nodes, total, nil
}
// nodeFromRow converts an engine row into a NavNode.
func (s *NavService) nodeFromRow(row map[string]interface{}, fallbackType string) nav.NavNode {
node := nav.NavNode{
Name: firstStringValue(row["title_kwd"]),
DocCount: intValue(row["doc_count_int"]),
Type: fallbackType,
DocID: firstStringValue(row["doc_id"]),
}
if t := firstStringValue(row["type_kwd"]); t != "" {
if t == "nav_cluster" {
node.Type = "cluster"
} else {
node.Type = "doc"
}
}
if payload, ok := row["content_with_weight"].(string); ok {
var m map[string]interface{}
if err := json.Unmarshal([]byte(payload), &m); err == nil {
if d, ok := m["description"].(string); ok {
node.Description = d
}
}
}
node.HasChildren = node.Type == "cluster"
if node.DocCount <= 0 {
node.DocCount = 1
}
return node
}
// Search runs query KNN over nav rows and returns routed doc ids.
func (s *NavService) Search(ctx context.Context, tenantID, kbID, query string, embd []float32, topK int) ([]nav.NavHit, error) {
if topK <= 0 {
topK = 8
}
vec := embd
if len(vec) == 0 {
if s.embed == nil {
return nil, fmt.Errorf("datasetnav: no embedding available for Search")
}
embeddings, err := s.embed.Encode(ctx, tenantID, []string{query})
if err != nil {
return nil, err
}
if len(embeddings) == 0 {
return nil, fmt.Errorf("datasetnav: embedding produced no vector")
}
vec = embeddings[0]
}
f64 := f32ToF64Slice(vec)
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
navFilter(nil),
[]string{"type_kwd", "title_kwd", "doc_id", "doc_ids_kwd", "_score"}, 0, topK,
[]interface{}{&types.MatchDenseExpr{
VectorColumnName: fmt.Sprintf("q_%d_vec", len(f64)),
EmbeddingData: f64,
EmbeddingDataType: "float",
DistanceType: "cosine",
TopN: topK,
ExtraOptions: map[string]interface{}{"similarity": 0.0},
}})
if err != nil {
return nil, err
}
hits := make([]nav.NavHit, 0, len(chunks))
for _, c := range chunks {
h := nav.NavHit{
Type: firstStringValue(c["type_kwd"]),
Name: firstStringValue(c["title_kwd"]),
DocID: firstStringValue(c["doc_id"]),
}
if sc, ok := c["_score"].(float64); ok {
h.Score = sc
} else if sc, ok := c["_score"].(float32); ok {
h.Score = float64(sc)
}
if ds, ok := c["doc_ids_kwd"].([]interface{}); ok {
for _, d := range ds {
if dd, ok := d.(string); ok {
h.DocIDs = append(h.DocIDs, dd)
}
}
}
hits = append(hits, h)
}
return hits, nil
}
// UpsertDoc places one document summary into the nav tree. Minimal closed loop:
// deterministic placement (KNN find best cluster -> merge if sim>=0.80, else a
// new root-level cluster). No LLM, no split/rebalance, no cascade cleanup.
func (s *NavService) UpsertDoc(ctx context.Context, in nav.UpsertDocInput) error {
if strings.TrimSpace(in.Summary) == "" {
return nil
}
de, err := s.docEngine()
if err != nil {
return err
}
if s.embed == nil && len(in.Embedd) == 0 {
return fmt.Errorf("datasetnav: embedder required for UpsertDoc")
}
vec := in.Embedd
if len(vec) == 0 {
embeddings, err := s.embed.Encode(ctx, in.TenantID, []string{in.Summary})
if err != nil {
return err
}
if len(embeddings) == 0 {
return nil
}
vec = embeddings[0]
}
// storeGet: skip if a nav_doc for this doc already exists with same summary.
existing, _, err := s.navSearch(ctx, in.TenantID, in.KbID,
navFilter(map[string]interface{}{"doc_id": []string{in.DocID}}),
[]string{"content_with_weight"}, 0, 1, nil)
if err != nil {
return err
}
if len(existing) > 0 {
if payload, ok := existing[0]["content_with_weight"].(string); ok {
var m map[string]interface{}
if err := json.Unmarshal([]byte(payload), &m); err == nil {
if d, _ := m["description"].(string); d == in.Summary {
return nil // unchanged
}
}
}
// Changed summary: remove the old nav_doc first (no cascade in minimal loop).
if _, err := s.deleteNavDoc(ctx, in.TenantID, in.KbID, in.DocID); err != nil {
return err
}
}
bestName, sim, bestDepth, err := s.findBestCluster(ctx, in.TenantID, in.KbID, vec)
if err != nil {
return err
}
idx := s.navIndexName(in.TenantID)
if bestName != "" && sim >= navMergeThreshold {
parent := bestName
if err := s.appendDocToCluster(ctx, de, in.TenantID, in.KbID, bestName, in.DocID); err != nil {
return err
}
_, err = de.InsertChunks(ctx, []map[string]interface{}{{
"compile_kwd": navCompileKwd,
"available_int": 0,
"type_kwd": "nav_doc",
"title_kwd": in.DocID,
"parent_kwd": parent,
// The nav_doc sits one level below its (possibly nested) parent
// cluster, so its depth is parentDepth+1 — not a hard-coded 1.
"depth_int": bestDepth + 1,
"doc_id": in.DocID,
"doc_count_int": 1,
"content_with_weight": payloadJSONNav(map[string]interface{}{"type": "nav_doc", "description": in.Summary}),
"q_" + fmt.Sprintf("%d", len(vec)) + "_vec": f32ToF64Slice(vec),
}}, idx, in.KbID)
return err
}
// A similar-but-not-mergeable cluster creates a sibling sub-cluster (Python
// _MIN_SIM=0.50); otherwise a fresh root cluster. This keeps the nav tree
// from degrading into one root per document.
parent := navRootParent
depth := 0
if bestName != "" && sim >= navMinSim {
parent = bestName
// A sibling of the (possibly nested) best cluster is one level deeper
// than it, so depth = parentDepth+1 — not a hard-coded 1.
depth = bestDepth + 1
}
name := navDocName(in.DocID, in.Summary)
_, err = de.InsertChunks(ctx, []map[string]interface{}{{
"compile_kwd": navCompileKwd,
"available_int": 0,
"type_kwd": "nav_cluster",
"title_kwd": name,
"parent_kwd": parent,
"depth_int": depth,
"doc_count_int": 1,
"doc_ids_kwd": []string{in.DocID},
"content_with_weight": payloadJSONNav(map[string]interface{}{"type": "nav_cluster", "description": in.Summary}),
"q_" + fmt.Sprintf("%d", len(vec)) + "_vec": f32ToF64Slice(vec),
}}, idx, in.KbID)
return err
}
// findBestCluster finds the best-matching cluster via level-by-level descent
// (mirroring Python _find_best_cluster). It KNNs the current level's clusters
// and, when the best match is >= recurse threshold, descends into that cluster's
// children. Returns the best cluster name, similarity, and its depth (0 = root)
// so callers can assign consistent child depth_int values.
func (s *NavService) findBestCluster(ctx context.Context, tenantID, kbID string, vec []float32) (string, float64, int, error) {
f64 := f32ToF64Slice(vec)
parent := navRootParent
bestName := ""
bestSim := 0.0
bestDepth := 0
for level := 0; level < navMaxDepth; level++ {
// KNN among clusters whose parent is the current level.
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
navFilter(map[string]interface{}{
"type_kwd": []string{"nav_cluster"},
"parent_kwd": []string{parent},
}),
[]string{"title_kwd", "_score"}, 0, 1,
[]interface{}{&types.MatchDenseExpr{
VectorColumnName: fmt.Sprintf("q_%d_vec", len(f64)),
EmbeddingData: f64,
EmbeddingDataType: "float",
DistanceType: "cosine",
TopN: 1,
ExtraOptions: map[string]interface{}{"similarity": 0.0},
}})
if err != nil {
return "", 0, 0, err
}
if len(chunks) == 0 {
break
}
name := firstStringValue(chunks[0]["title_kwd"])
sim := rowScore(chunks[0])
// Keep the STRONGEST match seen so far across all levels, so a strong
// ancestor is never displaced by a weaker descendant. Record its depth
// so the caller can set consistent child depth_int values.
if sim > bestSim {
bestName, bestSim, bestDepth = name, sim, level
}
// Descend only while the current match is strong enough that a deeper
// child could be a better target.
if sim < navRecurse {
break
}
parent = name
}
return bestName, bestSim, bestDepth, nil
}
// rowScore extracts the engine's _score field as float64.
func rowScore(row map[string]interface{}) float64 {
if sc, ok := row["_score"].(float64); ok {
return sc
}
if sc, ok := row["_score"].(float32); ok {
return float64(sc)
}
return 0
}
// appendDocToCluster appends a doc id to a cluster's doc_ids_kwd and bumps its
// doc_count_int. Implemented as a read-modify-write.
func (s *NavService) appendDocToCluster(ctx context.Context, de engine.DocEngine, tenantID, kbID, clusterName, docID string) error {
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
navFilter(map[string]interface{}{"type_kwd": []string{"nav_cluster"}, "title_kwd": []string{clusterName}}),
[]string{"doc_ids_kwd", "doc_count_int"}, 0, 1, nil)
if err != nil {
return err
}
if len(chunks) == 0 {
return nil
}
ids := []string{}
if raw, ok := chunks[0]["doc_ids_kwd"].([]interface{}); ok {
for _, d := range raw {
if dd, ok := d.(string); ok {
ids = append(ids, dd)
}
}
}
found := false
for _, id := range ids {
if id == docID {
found = true
break
}
}
if !found {
ids = append(ids, docID)
}
count := intValue(chunks[0]["doc_count_int"])
if !found {
count++
}
// Pin the update to the nav_cluster row only: a regular chunk sharing the
// same title_kwd must never be clobbered. The read-modify-write here is
// expected to run under a per-dataset lock held by the UpsertDoc caller;
// without it, concurrent appends to the same cluster can lose updates.
return de.UpdateChunks(ctx,
map[string]interface{}{
"compile_kwd": []string{navCompileKwd},
"type_kwd": []string{"nav_cluster"},
"title_kwd": []string{clusterName},
"kb_id": kbID,
},
map[string]interface{}{"doc_ids_kwd": ids, "doc_count_int": count},
s.navIndexName(tenantID), kbID)
}
// deleteNavDoc deletes a nav_doc row by doc_id.
func (s *NavService) deleteNavDoc(ctx context.Context, tenantID, kbID, docID string) (int64, error) {
de, err := s.docEngine()
if err != nil {
return 0, err
}
chunks, _, err := s.navSearch(ctx, tenantID, kbID,
navFilter(map[string]interface{}{"doc_id": []string{docID}}),
[]string{"id"}, 0, 100, nil)
if err != nil {
return 0, err
}
ids := make([]string, 0, len(chunks))
for _, c := range chunks {
if id, ok := c["id"].(string); ok {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return 0, nil
}
return de.DeleteChunks(ctx, map[string]interface{}{"id": ids, "kb_id": kbID}, s.navIndexName(tenantID), kbID)
}
// RemoveDoc removes a document's nav_doc (no cascade cleanup in minimal loop).
func (s *NavService) RemoveDoc(ctx context.Context, tenantID, kbID, docID string) error {
_, err := s.deleteNavDoc(ctx, tenantID, kbID, docID)
return err
}
// f32ToF64Slice converts a float32 vector to float64.
func f32ToF64Slice(v []float32) []float64 {
out := make([]float64, len(v))
for i, x := range v {
out[i] = float64(x)
}
return out
}
// payloadJSONNav marshals a nav payload map into the content_with_weight JSON.
func payloadJSONNav(v map[string]interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
// navDocName builds a deterministic cluster name for the minimal loop.
func navDocName(docID, summary string) string {
return docID + "_" + contentHash8(summary)
}
// contentHash8 is a stable 8-char hash.
func contentHash8(s string) string {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return fmt.Sprintf("%08x", h)
}
// firstStringValue returns the first string value of a (possibly list-wrapped)
// engine field.
func firstStringValue(v interface{}) string {
switch tv := v.(type) {
case string:
return tv
case []string:
if len(tv) > 0 {
return tv[0]
}
case []interface{}:
if len(tv) > 0 {
if s, ok := tv[0].(string); ok {
return s
}
}
}
return ""
}
// intValue returns the integer value of an engine field.
func intValue(v interface{}) int {
switch tv := v.(type) {
case float64:
return int(tv)
case float32:
return int(tv)
case int:
return tv
case int64:
return int(tv)
case []float64:
if len(tv) > 0 {
return int(tv[0])
}
case []interface{}:
if len(tv) > 0 {
switch n := tv[0].(type) {
case float64:
return int(n)
case int:
return n
}
}
}
return 0
}

View File

@@ -0,0 +1,139 @@
//go:build integration
// +build integration
package nlp
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
"ragflow/internal/server"
"ragflow/internal/service/nav"
"go.uber.org/zap"
)
// repoRootOf walks up from the package directory to the repository root (the
// dir containing go.mod). Kept local to this test package so it needs no shared
// helper from another package.
func repoRootOf(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("repository root (go.mod) not found above cwd")
}
dir = parent
}
}
// findNavRow reads the nav row for a doc directly from the document engine.
func findNavRow(t *testing.T, tenantID, kbID, docID string) map[string]interface{} {
t.Helper()
de := engine.Get()
if de == nil {
t.Skip("no live document engine")
}
idx := "ragflow_" + tenantID
req := &types.SearchRequest{
IndexNames: []string{idx},
Filter: map[string]interface{}{"doc_id": []string{docID}, "compile_kwd": []string{"dataset_nav"}},
SelectFields: []string{"available_int", "compile_kwd", "type_kwd"},
Limit: 10,
}
res, err := de.Search(context.Background(), req)
if err != nil {
t.Fatalf("search nav row: %v", err)
}
for _, row := range res.Chunks {
return row
}
t.Fatalf("no nav row found for doc %s", docID)
return nil
}
// TestDatasetNav_AvailableIntZero_Isolation is an integration test against a real
// ES/Infinity backend (requires conf/service_conf.yaml + a live document store).
// It verifies acceptance criterion #6: a nav row written with available_int=0 is
// invisible to the default retriever (which filters available_int=1) but IS
// reachable through NavService.Search.
//
// Run with: bash build.sh --test-integration ./internal/service/nlp/...
func TestDatasetNav_AvailableIntZero_Isolation(t *testing.T) {
server.SetLogger(zap.NewNop())
configPath := filepath.Join(repoRootOf(t), "conf", "service_conf.yaml")
if err := server.Init(configPath); err != nil {
t.Fatalf("init service config: %v", err)
}
if err := engine.Init(); err != nil {
t.Fatalf("init document engine: %v", err)
}
if engine.Get() == nil {
t.Skip("no live document engine configured")
}
tenantID := "navint_t1"
kbID := "navint_kb1"
docID := "navint_doc1"
ns := NewNavService(stubNavEmbedder{})
if err := ns.UpsertDoc(context.Background(), nav.UpsertDocInput{
TenantID: tenantID, KbID: kbID, DocID: docID, Summary: "rocket propulsion integration evidence",
}); err != nil {
t.Fatalf("upsert nav doc: %v", err)
}
t.Cleanup(func() { _ = ns.RemoveDoc(context.Background(), tenantID, kbID, docID) })
// NavService.Search must find the nav row (reads nav rows directly).
hits, err := ns.Search(context.Background(), tenantID, kbID, "rocket propulsion", nil, 5)
if err != nil {
t.Fatalf("nav search: %v", err)
}
if len(hits) == 0 {
t.Fatal("NavService.Search returned no hits; nav row is not reachable")
}
// The written nav row must carry compile_kwd=dataset_nav and available_int=0,
// so the default retriever (available_int=1 filter) will not surface it.
row := findNavRow(t, tenantID, kbID, docID)
// compile_kwd may come back list-wrapped by the engine, so use firstStrOrSlice.
if ck := firstStrOrSlice(row["compile_kwd"]); !strings.Contains(ck, "dataset_nav") {
t.Errorf("nav row compile_kwd = %q, want dataset_nav", ck)
}
avail := intValue(row["available_int"])
if avail != 0 {
t.Errorf("nav row available_int = %d, want 0 (so it is hidden from the default retriever)", avail)
}
}
// firstStrOrSlice returns the first string of a value that may be a plain string
// or a list-wrapped string (engine fields are often returned as []interface{}).
func firstStrOrSlice(v interface{}) string {
switch tv := v.(type) {
case string:
return tv
case []string:
if len(tv) > 0 {
return tv[0]
}
case []interface{}:
if len(tv) > 0 {
if s, ok := tv[0].(string); ok {
return s
}
}
}
return ""
}

View File

@@ -0,0 +1,390 @@
package nlp
import (
"context"
"math"
"testing"
"ragflow/internal/engine/types"
"ragflow/internal/service/nav"
"gorm.io/gorm"
)
// memNavEngine is an in-memory DocEngine double sufficient for the datasetnav
// minimal closed loop. It stores rows in a slice and supports the filtered
// search / insert / update / delete operations NavService uses. Dense-vector
// KNN is approximated by a deterministic cosine over a synthetic q_<dim>_vec.
type memNavEngine struct {
rows []map[string]interface{}
nextID int
}
func newMemNavEngine() *memNavEngine { return &memNavEngine{} }
func (m *memNavEngine) InsertChunks(_ context.Context, chunks []map[string]interface{}, _ string, datasetID string) ([]string, error) {
ids := make([]string, 0, len(chunks))
for _, c := range chunks {
m.nextID++
id := "nav" + strconvItoa(m.nextID)
cp := make(map[string]interface{}, len(c)+2)
for k, v := range c {
cp[k] = v
}
cp["id"] = id
cp["kb_id"] = datasetID
m.rows = append(m.rows, cp)
ids = append(ids, id)
}
return ids, nil
}
func (m *memNavEngine) UpdateChunks(_ context.Context, cond map[string]interface{}, newValue map[string]interface{}, _ string, _ string) error {
for _, r := range m.rows {
if matchNavRow(r, cond) {
for k, v := range newValue {
r[k] = v
}
}
}
return nil
}
func (m *memNavEngine) DeleteChunks(_ context.Context, cond map[string]interface{}, _ string, _ string) (int64, error) {
out := m.rows[:0]
var deleted int64
for _, r := range m.rows {
if matchNavRow(r, cond) {
deleted++
continue
}
out = append(out, r)
}
m.rows = out
return deleted, nil
}
func (m *memNavEngine) Search(_ context.Context, req *types.SearchRequest) (*types.SearchResult, error) {
var matched []map[string]interface{}
hasDense := len(req.MatchExprs) > 0
var queryVec []float64
if hasDense {
if de, ok := req.MatchExprs[0].(*types.MatchDenseExpr); ok {
queryVec = de.EmbeddingData
}
}
for _, r := range m.rows {
if !matchNavRow(r, req.Filter) {
continue
}
if hasDense {
col := "q_" + strconvItoa(len(queryVec)) + "_vec"
rv, ok := r[col].([]float64)
if !ok {
continue
}
r["_score"] = cosineNav(rv, queryVec)
}
matched = append(matched, r)
}
if hasDense {
for i := 1; i < len(matched); i++ {
for j := i; j > 0 && scoreNavOf(matched[j]) > scoreNavOf(matched[j-1]); j-- {
matched[j], matched[j-1] = matched[j-1], matched[j]
}
}
}
offset, limit := req.Offset, req.Limit
if offset > len(matched) {
offset = len(matched)
}
end := offset + limit
if limit <= 0 || end > len(matched) {
end = len(matched)
}
return &types.SearchResult{Chunks: matched[offset:end], Total: int64(len(matched))}, nil
}
func (m *memNavEngine) DropChunkStore(context.Context, string, string) error { return nil }
func (m *memNavEngine) ChunkStoreExists(context.Context, string, string) (bool, error) {
return true, nil
}
func (m *memNavEngine) Close() error { return nil }
func (m *memNavEngine) Ping(context.Context) error { return nil }
func (m *memNavEngine) GetType() string { return "mem" }
func (m *memNavEngine) SupportsPageRank() bool { return false }
func (m *memNavEngine) CreateChunkStore(context.Context, string, string, int, string) error {
return nil
}
func (m *memNavEngine) GetChunk(context.Context, string, string, []string) (interface{}, error) {
return nil, nil
}
func (m *memNavEngine) CreateMetadataStore(context.Context, string) error { return nil }
func (m *memNavEngine) InsertMetadata(context.Context, []map[string]interface{}, string) ([]string, error) {
return nil, nil
}
func (m *memNavEngine) UpdateMetadata(context.Context, string, string, map[string]interface{}, string) error {
return nil
}
func (m *memNavEngine) DeleteMetadata(context.Context, map[string]interface{}, string) (int64, error) {
return 0, nil
}
func (m *memNavEngine) DeleteMetadataKeys(context.Context, string, string, []string, string) error {
return nil
}
func (m *memNavEngine) DropMetadataStore(context.Context, string) error { return nil }
func (m *memNavEngine) MetadataStoreExists(context.Context, string) (bool, error) {
return true, nil
}
func (m *memNavEngine) SearchMetadata(context.Context, *types.SearchMetadataRequest) (*types.SearchMetadataResult, error) {
return nil, nil
}
func (m *memNavEngine) IndexDocument(context.Context, string, string, interface{}) error {
return nil
}
func (m *memNavEngine) DeleteDocument(context.Context, string, string) error { return nil }
func (m *memNavEngine) BulkIndex(context.Context, string, []interface{}) (interface{}, error) {
return nil, nil
}
func (m *memNavEngine) GetFields([]map[string]interface{}, []string) map[string]map[string]interface{} {
return nil
}
func (m *memNavEngine) GetAggregation([]map[string]interface{}, string) []map[string]interface{} {
return nil
}
func (m *memNavEngine) GetHighlight([]map[string]interface{}, []string, string) map[string]string {
return nil
}
func (m *memNavEngine) RunSQL(context.Context, string, string, []string, string) ([]map[string]interface{}, error) {
return nil, nil
}
func (m *memNavEngine) GetChunkIDs([]map[string]interface{}) []string { return nil }
func (m *memNavEngine) KNNScores(context.Context, []map[string]interface{}, []float64, int) (map[string]interface{}, error) {
return nil, nil
}
func (m *memNavEngine) GetScores(map[string]interface{}) map[string]float64 { return nil }
func (m *memNavEngine) FilterDocIdsByMetaPushdown(context.Context, *gorm.DB, []string, []map[string]interface{}, string) []string {
return nil
}
func matchNavRow(row map[string]interface{}, cond map[string]interface{}) bool {
for k, v := range cond {
rv, ok := row[k]
if !ok {
return false
}
switch want := v.(type) {
case []string:
ok = false
for _, w := range want {
if rv == w {
ok = true
break
}
}
if !ok {
return false
}
default:
if rv != v {
return false
}
}
}
return true
}
func scoreNavOf(r map[string]interface{}) float64 {
switch s := r["_score"].(type) {
case float64:
return s
case float32:
return float64(s)
}
return 0
}
func cosineNav(a, b []float64) float64 {
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
func strconvItoa(n int) string {
if n == 0 {
return "0"
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}
// stubNavEmbedder returns a fixed deterministic vector per distinct text.
type stubNavEmbedder struct{}
func (stubNavEmbedder) Encode(_ context.Context, _ string, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i, t := range texts {
dim := 8
v := make([]float32, dim)
for d := 0; d < dim; d++ {
v[d] = float32(int(t[0]) + d)
}
out[i] = v
}
return out, nil
}
func newTestNav(eng *memNavEngine) *NavService {
ns := NewNavService(stubNavEmbedder{})
ns.engine = eng
return ns
}
// TestNavService_UpsertDoc_WritesNavRow asserts acceptance #1: after UpsertDoc
// the row carries both compile_kwd=dataset_nav and available_int=0.
func TestNavService_UpsertDoc_WritesNavRow(t *testing.T) {
eng := newMemNavEngine()
ns := newTestNav(eng)
if err := ns.UpsertDoc(context.Background(), navUpsertInput("t1", "kb1", "d1", "alpha")); err != nil {
t.Fatalf("UpsertDoc: %v", err)
}
if len(eng.rows) == 0 {
t.Fatal("expected at least one nav row")
}
row := eng.rows[0]
if row["compile_kwd"] != "dataset_nav" {
t.Errorf("compile_kwd = %v, want dataset_nav", row["compile_kwd"])
}
if row["available_int"] != 0 {
t.Errorf("available_int = %v, want 0", row["available_int"])
}
if row["type_kwd"] != "nav_cluster" {
t.Errorf("type_kwd = %v, want nav_cluster", row["type_kwd"])
}
if row["parent_kwd"] != "root" {
t.Errorf("parent_kwd = %v, want root", row["parent_kwd"])
}
}
// TestNavService_ListClusters_FiltersRoot asserts acceptance #3.
func TestNavService_ListClusters_FiltersRoot(t *testing.T) {
eng := newMemNavEngine()
ns := newTestNav(eng)
if err := ns.UpsertDoc(context.Background(), navUpsertInput("t1", "kb1", "d1", "aaa")); err != nil {
t.Fatal(err)
}
clusters, total, err := ns.ListClusters(context.Background(), "t1", "kb1", 0, 10)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(clusters) != 1 {
t.Fatalf("expected 1 root cluster, got total=%d len=%d", total, len(clusters))
}
if clusters[0].Type != "cluster" {
t.Errorf("cluster type = %s, want cluster", clusters[0].Type)
}
if clusters[0].DocCount < 1 {
t.Errorf("cluster doc_count = %d, want >=1", clusters[0].DocCount)
}
}
// TestNavService_Search_ReturnsHit asserts acceptance #5.
func TestNavService_Search_ReturnsHit(t *testing.T) {
eng := newMemNavEngine()
ns := newTestNav(eng)
if err := ns.UpsertDoc(context.Background(), navUpsertInput("t1", "kb1", "d1", "aaa")); err != nil {
t.Fatal(err)
}
hits, err := ns.Search(context.Background(), "t1", "kb1", "aaa", nil, 5)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("expected hits")
}
if hits[0].Name == "" {
t.Error("hit name empty")
}
}
// TestNavService_Acceptance4_ListChildren asserts acceptance #4: ListChildren
// returns only the rows whose parent_kwd=name. Two docs with identical stub
// vectors merge into one root cluster: the first becomes the cluster itself,
// the second merges in as a nav_doc (parent_kwd=clusterName). So the cluster
// doc_count reflects both docs, and exactly one nav_doc sits under it.
func TestNavService_Acceptance4_ListChildren(t *testing.T) {
eng := newMemNavEngine()
ns := newTestNav(eng)
// Two docs that merge into one root cluster (same first char -> identical
// stub vectors -> sim=1.0 >= merge threshold).
if err := ns.UpsertDoc(context.Background(), navUpsertInput("t1", "kb1", "d1", "aaa one")); err != nil {
t.Fatal(err)
}
if err := ns.UpsertDoc(context.Background(), navUpsertInput("t1", "kb1", "d2", "aaa two")); err != nil {
t.Fatal(err)
}
clusters, _, err := ns.ListClusters(context.Background(), "t1", "kb1", 0, 10)
if err != nil {
t.Fatal(err)
}
if len(clusters) != 1 {
t.Fatalf("expected 1 cluster, got %d", len(clusters))
}
if clusters[0].DocCount != 2 {
t.Fatalf("cluster doc_count = %d, want 2 (both docs merged into the cluster)", clusters[0].DocCount)
}
name := clusters[0].Name
children, total, err := ns.ListChildren(context.Background(), "t1", "kb1", name, 0, 10)
if err != nil {
t.Fatal(err)
}
// Exactly one nav_doc (for d2) sits under the cluster; d1 is the cluster.
if total != 1 || len(children) != 1 {
t.Fatalf("expected 1 child under cluster, got total=%d len=%d", total, len(children))
}
if children[0].DocID != "d2" {
t.Errorf("child doc_id = %q, want d2", children[0].DocID)
}
if children[0].Type != "doc" {
t.Errorf("child type = %q, want doc", children[0].Type)
}
}
// TestNavService_NavDocDepth asserts a nav_doc merged under a root cluster
// (depth 0) gets depth_int = parentDepth+1 = 1, not a hard-coded value.
func TestNavService_NavDocDepth(t *testing.T) {
eng := newMemNavEngine()
ns := newTestNav(eng)
if err := ns.UpsertDoc(context.Background(), navUpsertInput("t1", "kb1", "d1", "aaa one")); err != nil {
t.Fatal(err)
}
if err := ns.UpsertDoc(context.Background(), navUpsertInput("t1", "kb1", "d2", "aaa two")); err != nil {
t.Fatal(err)
}
// The nav_doc for d2 sits under the root cluster; its depth_int must be 1.
for _, row := range eng.rows {
if row["doc_id"] == "d2" {
if d, ok := row["depth_int"].(int); !ok || d != 1 {
t.Errorf("nav_doc d2 depth_int = %v, want 1 (parentDepth 0 + 1)", row["depth_int"])
}
}
}
}
func navUpsertInput(tenant, kb, doc, summary string) nav.UpsertDocInput {
return nav.UpsertDocInput{TenantID: tenant, KbID: kb, DocID: doc, Summary: summary}
}