Files
ragflow/internal/agent/harness/orchestrator.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

248 lines
7.3 KiB
Go

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