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

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

140 lines
4.1 KiB
Go

//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 ""
}