Port agentic search high/ultra research loop to Go (#18242)

Implement two-level research loop, sufficiency ladder, AutoRater, grounded review, pipeline, inspector tools, and graph exploration for high/ultra agentic search modes.
This commit is contained in:
Zhichang Yu
2026-08-14 10:16:46 +08:00
committed by GitHub
parent 5e871986e2
commit 620f807cb5
28 changed files with 4782 additions and 181 deletions

2
go.mod
View File

@@ -20,7 +20,7 @@ require (
github.com/browserbase/stagehand-go/v3 v3.21.0
github.com/cenkalti/backoff/v5 v5.0.3
github.com/cespare/xxhash/v2 v2.3.0
github.com/cloudwego/eino v0.9.13
github.com/cloudwego/eino v0.9.14
github.com/denisenkom/go-mssqldb v0.12.3
github.com/elastic/go-elasticsearch/v8 v8.19.1
github.com/eric642/e2b-go-sdk v0.1.3

4
go.sum
View File

@@ -167,8 +167,8 @@ github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/cloudwego/eino v0.9.13 h1:iD/ETS+lxnNp1VeNPqWVGPWdND6Dbf4LyINbLUlDRcM=
github.com/cloudwego/eino v0.9.13/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
github.com/cloudwego/eino v0.9.14 h1:suNVibjtkPMiW7csFBdBqN3FRG0nRlCqoGKE7t0UDwY=
github.com/cloudwego/eino v0.9.14/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=

View File

@@ -0,0 +1,301 @@
//
// 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"
"sync"
"gorm.io/gorm"
)
// AgenticResearch is the high/ultra two-level loop (mirrors Python
// orchestrator/agentic.py agentic_research): the orchestrator assigns claims,
// the research agent researches each in parallel batches, and the decision
// ladder decides sufficiency each round.
//
// It drives the SAME pipeline (shared Kbinfos, compilation gating, doc routing)
// as the linear Run flow, so high/ultra is a strict superset of medium, not a
// parallel implementation.
func AgenticResearch(ctx context.Context, db *gorm.DB, pipeline *Pipeline, question string, claims []*ClaimTarget, mode ExecutionStrategy) OrchestratorResult {
kbinfos := pipeline.kbinfos
// Stagnation guard: stop early when the score stops improving.
const stagnationCycles = 2
const stagnationGain = 0.05
prevScore := -1.0
var pendingFollowups []string
for cycle := 0; cycle < mode.MaxOrchestratorCycles; cycle++ {
unverified := unverifiedClaims(claims)
if len(unverified) > 0 {
// Consume follow-ups ONCE per round, shared by every claim in the batch.
followups := pendingFollowups
pendingFollowups = nil
// Research in batches of MaxParallelAgents.
batchSize := mode.MaxParallelAgents
if batchSize < 1 {
batchSize = 1
}
for i := 0; i < len(unverified); i += batchSize {
end := i + batchSize
if end > len(unverified) {
end = len(unverified)
}
batch := unverified[i:end]
results := researchBatch(ctx, db, pipeline, batch, mode, followups)
for j, c := range batch {
r := results[j]
c.IsVerified = r.IsVerified
c.Confidence = r.Confidence
c.AgentResult = &r
// Ultra: dynamic claim expansion from discovered_claims.
if mode.AllowsDynamicClaims {
for _, dc := range r.DiscoveredClaims {
if dc == "" || claimDescExists(claims, dc) {
continue
}
claims = append(claims, &ClaimTarget{
ClaimID: fmt.Sprintf("c_dyn_%d", len(claims)),
Description: dc,
})
}
}
}
}
}
// ── Step A.5: note the discovered entity so graph_explore is eligible ──
// (mirrors agentic.py ctx.note_entity(_discovered_entity(tools))). Gates
// graph_explore via HasDiscoveredEntity on the pipeline.
if ent := discoveredEntity(kbinfos.Chunks); ent != "" {
pipeline.noteEntity(ent)
}
// ── Step B: sufficiency check ──
allChunks := map[int]map[string]interface{}{}
for i, c := range kbinfos.Chunks {
allChunks[i] = c
}
var agentResults []AgentResult
for _, c := range claims {
if c.AgentResult != nil {
agentResults = append(agentResults, *c.AgentResult)
}
}
var crossResults []ClaimCrossCheckResult
for _, r := range agentResults {
crossResults = append(crossResults, CrossCheckClaim(&r, allChunks))
}
claimValues := make([]ClaimTarget, 0, len(claims))
for _, c := range claims {
if c != nil {
claimValues = append(claimValues, *c)
}
}
verdict := ComputeFusionScore(agentResults, crossResults, mode, question, claimValues, allChunks)
// LLM Sufficient Context AutoRater (primary judge), invoked every round.
var citedIDs []int
for _, r := range agentResults {
citedIDs = append(citedIDs, r.EvidenceIDs...)
}
boost := LLMSufficiencyBoost(ctx, db, question, &verdict, kbinfos, citedIDs)
if boost != nil && len(boost.Followups) > 0 {
pendingFollowups = boost.Followups
}
// LLM groundedness review (draft review): ungrounded claims merge into
// hard_violations.
var reports []ClaimReport
for _, r := range agentResults {
if r.Report != "" {
reports = append(reports, ClaimReport{ClaimID: r.ClaimID, Report: r.Report})
}
}
grounded := LLMGroundedVerify(ctx, db, question, reports, kbinfos, citedIDs)
validIDs := map[string]bool{}
for _, r := range agentResults {
validIDs[r.ClaimID] = true
}
hvSet := map[string]bool{}
for _, id := range verdict.HardViolations {
hvSet[id] = true
}
for cid, g := range grounded {
if validIDs[cid] && (!g.Grounded || len(g.Ungrounded) > 0) {
hvSet[cid] = true
}
}
verdict.HardViolations = sortedKeys(hvSet)
action, shouldContinue, _ := RouteSufficiencyVerdict(verdict, mode.Label, cycle, mode.MaxOrchestratorCycles, boost)
// Stagnation guard: override CONTINUE with a partial answer when the
// score has not meaningfully improved.
if shouldContinue && (verdict.Status == "INSUFFICIENT" || verdict.Status == "USEFUL_BUT_INCOMPLETE") {
if prevScore >= 0 && cycle >= stagnationCycles && verdict.Score-prevScore < stagnationGain {
action = "ANSWER_PARTIAL"
shouldContinue = false
} else {
prevScore = verdict.Score
}
}
switch action {
case "ANSWER":
finalizeAgentResults(kbinfos, claims)
return OrchestratorResult{Verdict: &verdict, Kbinfos: kbinfos}
case "ANSWER_PARTIAL":
finalizeAgentResults(kbinfos, claims)
return OrchestratorResult{Verdict: &verdict, PartialAnswer: true, Kbinfos: kbinfos}
case "ABSTAIN":
kbinfos.Chunks = nil
return OrchestratorResult{Verdict: &verdict, Abstain: true, Kbinfos: kbinfos}
case "FALLBACK_LLM":
finalizeAgentResults(kbinfos, claims)
return OrchestratorResult{Verdict: &verdict, PartialAnswer: true, ForceLLM: true, Kbinfos: kbinfos}
}
}
// Max cycles reached.
finalizeAgentResults(kbinfos, claims)
return OrchestratorResult{Verdict: nil, PartialAnswer: true, Kbinfos: kbinfos}
}
// discoveredEntity picks a salient discovered name from the gathered evidence
// (mirrors Python _discovered_entity): prefer an explicit entity/keyword tag on a
// chunk, fall back to a source document name. Used only to gate graph_explore.
func discoveredEntity(chunks []map[string]interface{}) string {
for _, c := range chunks {
for _, key := range []string{"entities_kwd", "important_kwd"} {
switch val := c[key].(type) {
case []string:
if len(val) > 0 {
if first := strings.TrimSpace(val[0]); first != "" {
return first
}
}
case []interface{}:
if len(val) > 0 {
if s, ok := val[0].(string); ok {
if first := strings.TrimSpace(s); first != "" {
return first
}
}
}
case string:
if s := strings.TrimSpace(val); s != "" {
if fields := strings.Fields(s); len(fields) > 0 {
return fields[0]
}
}
}
}
}
for _, c := range chunks {
if name := strings.TrimSpace(chunkDoc(c)); name != "" {
return name
}
}
return ""
}
// researchBatch runs ResearchAgentLoop for each claim in parallel.
func researchBatch(ctx context.Context, db *gorm.DB, pipeline *Pipeline, batch []*ClaimTarget, mode ExecutionStrategy, followups []string) []AgentResult {
results := make([]AgentResult, len(batch))
var wg sync.WaitGroup
for i, c := range batch {
wg.Add(1)
go func(idx int, claim *ClaimTarget) {
defer wg.Done()
results[idx] = ResearchAgentLoop(ctx, db, pipeline, *claim, mode, followups)
}(i, c)
}
wg.Wait()
return results
}
func unverifiedClaims(claims []*ClaimTarget) []*ClaimTarget {
var out []*ClaimTarget
for _, c := range claims {
if c != nil && !c.IsVerified {
out = append(out, c)
}
}
return out
}
func claimDescExists(claims []*ClaimTarget, desc string) bool {
for _, c := range claims {
if c != nil && c.Description == desc {
return true
}
}
return false
}
// finalizeAgentResults mirrors Python _finalize + _merge_agent_results: build the
// pre_summary and trim evidence to only cited chunks.
func finalizeAgentResults(kbinfos *Kbinfos, claims []*ClaimTarget) {
var combined []string
seenEvidence := map[int]bool{}
for _, c := range claims {
if c == nil || c.AgentResult == nil {
continue
}
if c.AgentResult.Report != "" {
status := "❌"
if c.IsVerified {
status = "✅"
}
report := c.AgentResult.Report
if len(report) > 500 {
report = report[:500]
}
combined = append(combined, fmt.Sprintf("【%s】%s %s", c.ClaimID, status, report))
}
for _, eid := range c.AgentResult.EvidenceIDs {
seenEvidence[eid] = true
}
}
if len(combined) > 0 {
kbinfos.PreSummary = strings.Join(combined, "\n\n")
}
// Trim evidence to cited chunks only (preserve order, never empty).
if len(seenEvidence) > 0 && len(seenEvidence) < len(kbinfos.Chunks) {
keep := make([]int, 0, len(seenEvidence))
for i := range kbinfos.Chunks {
if seenEvidence[i] {
keep = append(keep, i)
}
}
newChunks := make([]map[string]interface{}, 0, len(keep))
for _, i := range keep {
newChunks = append(newChunks, kbinfos.Chunks[i])
}
kbinfos.Chunks = newChunks
}
}

View File

@@ -99,7 +99,7 @@ func RunAgenticRAGWithRoute(ctx context.Context, db *gorm.DB, question, keywords
}
// ── formalize_answer ──
res := FormalizeAnswer(ctx, db, state.Question, state.Kbinfos, state.PartialAnswer, state.Abstain, state.EmptyResult)
res := FormalizeAnswer(ctx, db, state.Question, state.Kbinfos, state.PartialAnswer, state.Abstain, state.EmptyResult, orch.Caveat, orch.ForceLLM)
// 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)",

View File

@@ -0,0 +1,81 @@
//
// 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 "testing"
// TestDiscoveredEntity_EntityTag asserts the entity/keyword tag takes priority.
func TestDiscoveredEntity_EntityTag(t *testing.T) {
chunks := []map[string]interface{}{
{"entities_kwd": []string{"Paris", "France"}, "docnm_kwd": "Document A"},
}
if got := discoveredEntity(chunks); got != "Paris" {
t.Errorf("discoveredEntity = %q, want Paris", got)
}
}
// TestDiscoveredEntity_ImportantKwdString asserts a string important_kwd returns
// its first word.
func TestDiscoveredEntity_ImportantKwdString(t *testing.T) {
chunks := []map[string]interface{}{
{"important_kwd": "Quantum Computing"},
}
if got := discoveredEntity(chunks); got != "Quantum" {
t.Errorf("discoveredEntity = %q, want Quantum", got)
}
}
// TestDiscoveredEntity_DocNameFallback asserts the doc name fallback when no
// entity/keyword tag is present.
func TestDiscoveredEntity_DocNameFallback(t *testing.T) {
chunks := []map[string]interface{}{
{"content_with_weight": "some text", "docnm_kwd": "Annual Report 2023"},
}
if got := discoveredEntity(chunks); got != "Annual Report 2023" {
t.Errorf("discoveredEntity = %q, want 'Annual Report 2023'", got)
}
}
// TestDiscoveredEntity_Empty asserts empty evidence yields empty.
func TestDiscoveredEntity_Empty(t *testing.T) {
if got := discoveredEntity(nil); got != "" {
t.Errorf("discoveredEntity(nil) = %q, want empty", got)
}
if got := discoveredEntity([]map[string]interface{}{{"content_with_weight": "x"}}); got != "" {
t.Errorf("discoveredEntity(no name) = %q, want empty", got)
}
}
// TestNoteEntity asserts empty names never clear a prior discovery.
func TestNoteEntity(t *testing.T) {
p := &Pipeline{}
if p.HasDiscoveredEntity() {
t.Fatal("fresh pipeline must have no discovered entity")
}
p.noteEntity("Paris")
if !p.HasDiscoveredEntity() {
t.Fatal("noteEntity(Paris) must set discovered entity")
}
p.noteEntity("") // fruitless round must not clear
if !p.HasDiscoveredEntity() {
t.Error("empty noteEntity must not clear prior discovery")
}
p.noteEntity("France")
if p.lastEntity != "France" {
t.Errorf("lastEntity = %q, want France", p.lastEntity)
}
}

View File

@@ -59,18 +59,33 @@ type AnswerResult struct {
// 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 {
//
// forceLLM forces the direct-LLM path even when there is no evidence: used by the
// FALLBACK_LLM verdict (FallbackToDirectLLM), where the mode is unanswerable but
// we still want the model to attempt an answer or plainly state it cannot — not
// a canned "no evidence" string. Mirrors Python, which only short-circuits on
// empty evidence when an explicit empty_response is configured.
func FormalizeAnswer(ctx context.Context, db *gorm.DB, question string, kb *Kbinfos, partial, abstain, empty bool, caveat string, forceLLM bool) AnswerResult {
if abstain {
return AnswerResult{FinalAnswer: abstainMessage, Abstained: true}
}
if empty || kb == nil || len(kb.Chunks) == 0 {
if !forceLLM && (empty || kb == nil || len(kb.Chunks) == 0) {
return AnswerResult{FinalAnswer: emptyResultMessage, Empty: true}
}
var b strings.Builder
b.WriteString("Question:\n" + question + "\n")
// Research summary (merged claim reports from the high/ultra loop) precedes
// the raw evidence, mirroring Python formalize_answer.
if kb.PreSummary != "" {
b.WriteString("\nResearch Summary:\n" + kb.PreSummary + "\n")
}
if partial {
b.WriteString(partialAnswerPreamble + "\n")
preamble := partialAnswerPreamble
if caveat != "" {
preamble = partialAnswerPreamble + " " + caveat
}
b.WriteString(preamble + "\n")
}
b.WriteString("\nEvidence:\n")
for i, c := range kb.Chunks {
@@ -110,6 +125,11 @@ func chunkText(c map[string]interface{}) string {
if t, ok := c["content"].(string); ok {
return t
}
// "text" is the fallback used by the AutoRater evidence renderer (Python
// _evidence_md: content_with_weight or text).
if t, ok := c["text"].(string); ok {
return t
}
return ""
}

View File

@@ -9,7 +9,7 @@ import (
// 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)
res := FormalizeAnswer(context.Background(), nil, "Q", &Kbinfos{}, false, true, false, "", false)
if !res.Abstained || res.FinalAnswer != abstainMessage {
t.Errorf("abstain result = %+v, want abstain message", res)
}
@@ -18,7 +18,7 @@ func TestFormalizeAnswer_Abstain(t *testing.T) {
// 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)
res := FormalizeAnswer(context.Background(), nil, "Q", &Kbinfos{}, false, false, false, "", false)
if !res.Empty || res.FinalAnswer != emptyResultMessage {
t.Errorf("empty result = %+v, want empty message", res)
}
@@ -29,7 +29,7 @@ func TestFormalizeAnswer_Empty(t *testing.T) {
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)
res := FormalizeAnswer(context.Background(), nil, "What is X?", kb, false, false, false, "", false)
if res.FinalAnswer != "here is the final answer" {
t.Errorf("final answer = %q, want chat output", res.FinalAnswer)
}
@@ -40,12 +40,25 @@ func TestFormalizeAnswer_Generates(t *testing.T) {
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)
res := FormalizeAnswer(context.Background(), nil, "Q", kb, true, false, false, "", false)
if !strings.Contains(res.FinalAnswer, "partial ans") {
t.Errorf("final answer = %q", res.FinalAnswer)
}
}
// TestFormalizeAnswer_ForceLLM asserts forceLLM skips the empty short-circuit and
// still calls the model, so the FALLBACK_LLM verdict reaches the direct LLM.
func TestFormalizeAnswer_ForceLLM(t *testing.T) {
installChat(t, "direct llm fallback answer")
res := FormalizeAnswer(context.Background(), nil, "Q", &Kbinfos{}, false, false, true, "", true)
if res.Empty {
t.Error("forceLLM must not return the empty short-circuit")
}
if res.FinalAnswer != "direct llm fallback answer" {
t.Errorf("final answer = %q, want chat output (direct LLM invoked)", res.FinalAnswer)
}
}
// TestRunAgenticRAG_LowMode asserts low mode does a single direct search and
// produces an answer.
func TestRunAgenticRAG_LowMode(t *testing.T) {

View File

@@ -0,0 +1,210 @@
//
// 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"
"strings"
"gorm.io/gorm"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/service/nav"
)
// buildCompilationMap reports which compiled artifacts each bound KB carries,
// mirroring Python _get_compilation_map + _add_template_group_compilations +
// _has_dataset_nav_rows. The Pipeline uses this map to gate compilation-requiring
// tools (ontology_navigate / mindmap_navigate / graph_explore / wiki_query) and to
// decide which tree/timeline/mindmap/wiki artifact a route may target.
//
// The map is keyed by KB id; each value is the set of compilation kinds that KB
// carries, using the SAME canonical tokens the tools filter on:
// - "toc" / "tree" → ontology_navigate catalog kinds
// - "mindmap" → mindmap_navigate
// - "graph" / "knowledge_graph" → graph_explore
// - "wiki" → wiki_query
// - "page_index" / "timeline" → ontology_navigate catalog kinds
func buildCompilationMap(ctx context.Context, db *gorm.DB, tenantID string, datasetIDs []string) map[string]map[string]bool {
out := map[string]map[string]bool{}
if db == nil || len(datasetIDs) == 0 {
return out
}
kbDAO := dao.KnowledgebaseDAO{}
kbs, err := kbDAO.GetByIDs(ctx, db, datasetIDs)
if err != nil {
return out
}
tplDAO := dao.CompilationTemplateDAO{}
navSvc := nav.GetNavService()
for _, kb := range kbs {
if kb == nil {
continue
}
comps := map[string]bool{}
pc := kb.ParserConfig
// 1. Top-level parser_config toggles (mirrors _get_compilation_map).
if boolVal(pc, "toc") {
comps["toc"] = true
}
if boolVal(pc, "knowledge_graph") {
comps["knowledge_graph"] = true
}
if boolVal(pc, "wiki") {
comps["wiki"] = true
}
if boolVal(pc, "mindmap") {
comps["mindmap"] = true
}
if boolVal(pc, "page_index") {
comps["page_index"] = true
}
// 2. Template-group-derived compilations (mirrors _add_template_group_compilations).
for _, groupID := range parserConfigTemplateGroupIDs(pc) {
templates, err := tplDAO.ListByGroup(ctx, db, groupID)
if err != nil {
continue
}
for _, t := range templates {
if t == nil {
continue
}
addCompilationKind(comps, t.Kind)
}
}
// 3. Dataset-navigation rows → "tree" (mirrors _has_dataset_nav_rows).
if hasDatasetNavRows(ctx, navSvc, tenantID, kb.ID) {
comps["tree"] = true
}
if len(comps) > 0 {
out[kb.ID] = comps
}
}
return out
}
// addCompilationKind maps a template kind onto the canonical compilation tokens,
// mirroring _compilation_kind_for_agentic_map + the comps accumulation in
// _add_template_group_compilations.
func addCompilationKind(comps map[string]bool, rawKind string) {
// normalizeCompilationKind has already collapsed page_index/pageindex to
// "timeline", so those tokens never reach the switch (mirrors Python, where
// the same collapse makes its page_index/pageindex elif branch unreachable).
norm := normalizeCompilationKind(rawKind)
switch norm {
case "knowledge_graph":
comps["knowledge_graph"] = true
case "tree":
comps["tree"] = true
case "timeline":
comps["page_index"] = true
case "mindmap", "mind_map":
comps["mindmap"] = true
case "wiki":
comps["wiki"] = true
}
}
// normalizeCompilationKind mirrors Python _compilation_kind_for_agentic_map:
// pageindex/page_index collapse to "timeline"; otherwise lower + '-'→'_'.
func normalizeCompilationKind(kind string) string {
norm := strings.ToLower(strings.TrimSpace(kind))
norm = strings.ReplaceAll(norm, "-", "_")
switch norm {
case "pageindex", "page_index":
return "timeline"
default:
return norm
}
}
// parserConfigTemplateGroupIDs mirrors Python _parser_config_compilation_template_group_ids:
// read "compilation_template_group_id" (top-level or under "ext"), accept a
// single string or a list, dedup, drop empties.
func parserConfigTemplateGroupIDs(pc entity.JSONMap) []string {
if pc == nil {
return nil
}
var raw interface{}
if v, ok := pc["compilation_template_group_id"]; ok {
raw = v
} else if ext, ok := pc["ext"].(map[string]interface{}); ok {
raw = ext["compilation_template_group_id"]
}
if raw == nil {
return nil
}
var list []string
switch v := raw.(type) {
case string:
list = []string{v}
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
list = append(list, s)
}
}
case []string:
list = v
}
seen := map[string]bool{}
var out []string
for _, id := range list {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
// hasDatasetNavRows reports whether the KB has dataset-navigation rows (mirrors
// Python _has_dataset_nav_rows). It asks the nav service for top-level clusters;
// a non-empty list means the tree artifact is present.
func hasDatasetNavRows(ctx context.Context, ns nav.NavService, tenantID, kbID string) bool {
if ns == nil || tenantID == "" || kbID == "" {
return false
}
clusters, _, err := ns.ListClusters(ctx, tenantID, kbID, 0, 1)
if err != nil {
return false
}
return len(clusters) > 0
}
func boolVal(m entity.JSONMap, key string) bool {
if m == nil {
return false
}
switch v := m[key].(type) {
case bool:
return v
case string:
return strings.EqualFold(v, "true") || v == "1"
case float64:
return v != 0
}
return false
}

View File

@@ -0,0 +1,103 @@
//
// 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 (
"testing"
"ragflow/internal/entity"
)
// TestNormalizeCompilationKind asserts pageindex/page_index collapse to timeline
// and '-'→'_' normalization.
func TestNormalizeCompilationKind(t *testing.T) {
cases := map[string]string{
"pageindex": "timeline",
"page_index": "timeline",
"PAGE_INDEX": "timeline",
"mind_map": "mind_map",
"Mind-Map": "mind_map",
"knowledge_graph": "knowledge_graph",
"tree": "tree",
}
for in, want := range cases {
if got := normalizeCompilationKind(in); got != want {
t.Errorf("normalizeCompilationKind(%q) = %q, want %q", in, got, want)
}
}
}
// TestAddCompilationKind asserts each template kind maps onto the canonical token.
func TestAddCompilationKind(t *testing.T) {
comps := map[string]bool{}
addCompilationKind(comps, "knowledge_graph")
addCompilationKind(comps, "mind_map")
addCompilationKind(comps, "timeline")
addCompilationKind(comps, "wiki")
addCompilationKind(comps, "tree")
for _, want := range []string{"knowledge_graph", "mindmap", "page_index", "wiki", "tree"} {
if !comps[want] {
t.Errorf("missing %q in comps %v", want, comps)
}
}
if comps["mind_map"] {
t.Error("mind_map must be normalized to mindmap, not kept verbatim")
}
}
// TestParserConfigTemplateGroupIDs asserts single-string, list, and ext-nested
// forms all resolve, with dedup and empty-drop.
func TestParserConfigTemplateGroupIDs(t *testing.T) {
// top-level single string
pc := entity.JSONMap{"compilation_template_group_id": "g1"}
if got := parserConfigTemplateGroupIDs(pc); len(got) != 1 || got[0] != "g1" {
t.Errorf("single string: got %v", got)
}
// top-level list with dup + empty
pc = entity.JSONMap{"compilation_template_group_id": []interface{}{"g1", "g1", "", "g2"}}
if got := parserConfigTemplateGroupIDs(pc); len(got) != 2 {
t.Errorf("list dedup: got %v", got)
}
// ext-nested
pc = entity.JSONMap{"ext": map[string]interface{}{"compilation_template_group_id": []interface{}{"g3"}}}
if got := parserConfigTemplateGroupIDs(pc); len(got) != 1 || got[0] != "g3" {
t.Errorf("ext-nested: got %v", got)
}
// absent → nil
if got := parserConfigTemplateGroupIDs(entity.JSONMap{}); got != nil {
t.Errorf("absent: got %v, want nil", got)
}
}
// TestBoolVal asserts bool/string/float forms.
func TestBoolVal(t *testing.T) {
if !boolVal(entity.JSONMap{"x": true}, "x") {
t.Error("bool true must be true")
}
if !boolVal(entity.JSONMap{"x": "true"}, "x") {
t.Error(`string "true" must be true`)
}
if boolVal(entity.JSONMap{"x": "false"}, "x") {
t.Error(`string "false" must be false`)
}
if boolVal(entity.JSONMap{"x": 1.0}, "x") != true {
t.Error("float 1.0 must be true")
}
if boolVal(entity.JSONMap{}, "x") {
t.Error("absent key must be false")
}
}

View File

@@ -0,0 +1,159 @@
//
// 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 "testing"
// TestFilterRelevantNumbers asserts 0<n<1 noise is dropped and duplicates are
// collapsed.
func TestFilterRelevantNumbers(t *testing.T) {
got := filterRelevantNumbers([]float64{0.5, 0.75, 1976, 48, 48, 1976, 0})
// 0.5/0.75 dropped (in (0,1)); 48/1976 deduped; 0 kept.
want := []float64{1976, 48, 0}
if len(got) != len(want) {
t.Fatalf("filterRelevantNumbers = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("filterRelevantNumbers[%d] = %v, want %v", i, got[i], want[i])
}
}
}
// TestBoundedPhraseMatch asserts "Ann" does not match "Annual".
func TestBoundedPhraseMatch(t *testing.T) {
if boundedPhraseMatch("Annual report 2023", "ann") {
t.Error(`"ann" must not match inside "Annual"`)
}
if !boundedPhraseMatch("Ann is the CEO", "ann") {
t.Error(`"ann" must match as a standalone word`)
}
if !boundedPhraseMatch("value 1976 was", "1976") {
t.Error(`"1976" must match bounded`)
}
if boundedPhraseMatch("value 19760 was", "1976") {
t.Error(`"1976" must not match inside "19760"`)
}
}
// TestDetectNumericConflict asserts close-but-different figures (ratio ≤1.3) are
// flagged while far-apart figures are not.
func TestDetectNumericConflict(t *testing.T) {
conflict := detectNumericConflict([]string{
"2,161,000 from Wikipedia Demographics of Paris",
"2,145,906 from INSEE",
})
if len(conflict) != 1 {
t.Fatalf("close figures must conflict, got %v", conflict)
}
// Far-apart figures are different quantities → no conflict.
none := detectNumericConflict([]string{"100 from A", "9000 from B"})
if len(none) != 0 {
t.Errorf("far-apart figures must not conflict, got %v", none)
}
}
// TestCrossCheckClaim_UnionMatching asserts a fact verified by ONE chunk is
// verified (union semantics, not per-chunk).
func TestCrossCheckClaim_UnionMatching(t *testing.T) {
allChunks := map[int]map[string]interface{}{
0: {"content_with_weight": "Paris population is 2161000 in 2019"},
1: {"content_with_weight": "unrelated text about trains"},
}
r := CrossCheckClaim(&AgentResult{
ClaimID: "c1", IsVerified: true, Report: "Paris has 2161000 residents",
EvidenceIDs: []int{0, 1},
}, allChunks)
if !r.CrossCheckPassed {
t.Errorf("number found in chunk 0 must verify the claim (union), mismatches=%v", r.Mismatches)
}
}
// TestCrossCheckClaim_NoEvidenceFails asserts a claim with no evidence ids fails.
func TestCrossCheckClaim_NoEvidenceFails(t *testing.T) {
r := CrossCheckClaim(&AgentResult{
ClaimID: "c1", IsVerified: true, Report: "value 42",
}, map[int]map[string]interface{}{})
if r.CrossCheckPassed || r.CrossCheckScore != 0.0 {
t.Errorf("no evidence → fail with score 0, got passed=%v score=%v", r.CrossCheckPassed, r.CrossCheckScore)
}
}
// TestCrossCheckClaim_NumericConflictCaps asserts a numeric conflict caps the
// claim below the pass floor.
func TestCrossCheckClaim_NumericConflictCaps(t *testing.T) {
allChunks := map[int]map[string]interface{}{
0: {"content_with_weight": "population 2161000"},
1: {"content_with_weight": "population 2145906"},
}
r := CrossCheckClaim(&AgentResult{
ClaimID: "c1", IsVerified: true, Report: "population",
EvidenceIDs: []int{0, 1},
Numbers: []string{"2,161,000 from A", "2,145,906 from B"},
}, allChunks)
if r.CrossCheckPassed {
t.Error("numeric conflict must cap the claim below pass")
}
}
// TestComputeFusionScore_HardViolations asserts a weak self-verified claim
// becomes a hard_violation and the status is INSUFFICIENT.
func TestComputeFusionScore_HardViolations(t *testing.T) {
agents := []AgentResult{
{ClaimID: "c0", IsVerified: true, Confidence: 0.9, EvidenceIDs: []int{0}},
{ClaimID: "c1", IsVerified: true, Confidence: 0.9, EvidenceIDs: []int{1}},
}
// c1 cross-check score 0 (below floor) → hard violation.
cross := []ClaimCrossCheckResult{
{ClaimID: "c0", CrossCheckPassed: true, CrossCheckScore: 1.0, HasEvidence: true},
{ClaimID: "c1", CrossCheckPassed: false, CrossCheckScore: 0.0, HasEvidence: true},
}
allChunks := map[int]map[string]interface{}{
0: {"content_with_weight": "alpha 42"},
1: {"content_with_weight": "beta"},
}
v := ComputeFusionScore(agents, cross, THINKING_MODES["high"], "Q", nil, allChunks)
if len(v.HardViolations) != 1 || v.HardViolations[0] != "c1" {
t.Errorf("hard_violations = %v, want [c1]", v.HardViolations)
}
if v.Status != "INSUFFICIENT" {
t.Errorf("status = %q, want INSUFFICIENT (hard veto)", v.Status)
}
}
// TestComputeFusionScore_NoiseExclusion asserts an unrelated self-unverified
// claim (cross<0.2) is excluded from hard violations but surfaced in missing.
func TestComputeFusionScore_NoiseExclusion(t *testing.T) {
agents := []AgentResult{
{ClaimID: "c0", IsVerified: true, Confidence: 0.9, EvidenceIDs: []int{0}},
{ClaimID: "noise", IsVerified: false, Confidence: 0.0},
}
cross := []ClaimCrossCheckResult{
{ClaimID: "c0", CrossCheckPassed: true, CrossCheckScore: 1.0, HasEvidence: true},
{ClaimID: "noise", CrossCheckPassed: false, CrossCheckScore: 0.05, HasEvidence: true},
}
allChunks := map[int]map[string]interface{}{
0: {"content_with_weight": "alpha 42"},
}
v := ComputeFusionScore(agents, cross, THINKING_MODES["high"], "Q", nil, allChunks)
if len(v.HardViolations) != 0 {
t.Errorf("noise claim must not be a hard violation, got %v", v.HardViolations)
}
if !containsStr(v.MissingClaims, "noise") {
t.Errorf("noise claim must be surfaced in missing, got %v", v.MissingClaims)
}
}

View File

@@ -0,0 +1,163 @@
//
// 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"
)
// LLM groundedness review (draft review), mirroring Python grounded_llm.py +
// rag/prompts/grounded_select.md. It reviews each claim's draft report against
// the cited evidence semantically, catching over-claims/relation errors that the
// lexical code-level cross-check cannot see.
// groundedSelectPrompt mirrors rag/prompts/grounded_select.md.
const groundedSelectPrompt = `You are an answer-groundedness reviewer. For each claim's report (the draft answer), determine whether every assertion is supported by the provided evidence.
A claim's report is GROUNDED only if each of its assertions can be inferred from the evidence. The assertion does NOT need to use the same words as the evidence (semantic paraphrase is fine), but it must NOT:
- assert a fact that is absent from the evidence (likely model prior-injection / hallucination);
- assert a relation or value that contradicts the evidence;
- over-claim beyond what the evidence supports (e.g. the evidence only says a medication was prescribed, but the report claims "the patient is well").
Question: %s
Claim reports to verify:
%s
Evidence (each chunk labeled with an integer ID):
%s
For each claim, classify its assertions:
- SUPPORTED: the evidence explicitly supports it, including a semantic paraphrase.
- UNGROUNDED: the evidence lacks the content, or the claimed relation/value contradicts the evidence, or the assertion over-claims beyond the evidence.
Output format (JSON):
{
"claims": [
{
"claim_id": "c1",
"grounded": true,
"ungrounded_assertions": []
},
{
"claim_id": "c2",
"grounded": false,
"ungrounded_assertions": [
{"assertion": "the patient had no adverse reactions", "reason": "the evidence only mentions the medication was prescribed, not the patient's reaction"}
]
}
]
}
Requirements:
1. Include EVERY claim in the output (do not skip any claim_id).
2. ` + "`grounded`" + ` is true only if ALL of the claim's assertions are supported.
3. ` + "`ungrounded_assertions`" + ` is empty when ` + "`grounded`" + ` is true; otherwise list each ungrounded assertion with a one-line ` + "`reason`" + `.
4. Prefer identifying genuine over-claims or contradictions over surface-level wording differences — a semantic paraphrase IS supported.`
// GroundedVerdict is a single claim's draft-review result.
type GroundedVerdict struct {
Grounded bool
Ungrounded []string
}
// ClaimReport is one claim's draft report submitted for grounded review.
type ClaimReport struct {
ClaimID string
Report string
}
type groundedClaimResult struct {
ClaimID string `json:"claim_id"`
Grounded bool `json:"grounded"`
UngroundedAssertions []struct {
Assertion string `json:"assertion"`
Reason string `json:"reason"`
} `json:"ungrounded_assertions"`
}
type groundedReviewResult struct {
Claims []groundedClaimResult `json:"claims"`
}
// LLMGroundedVerify mirrors Python llm_grounded_verify: for each claim report,
// decide whether it is semantically grounded in the cited evidence. Returns an
// empty map when the LLM review is unavailable (no model, no evidence, or a
// failure) — callers treat that as "no new signal".
func LLMGroundedVerify(ctx context.Context, db *gorm.DB, question string, reports []ClaimReport, kb *Kbinfos, evidenceIDs []int) map[string]GroundedVerdict {
if len(reports) == 0 {
return nil
}
inv := chat.GetDefaultInvoker()
if inv == nil {
return nil
}
evidenceMD := renderEvidenceMD(kb, evidenceIDs, narrowKeywords(question))
if evidenceMD == "" {
return nil
}
var reportsMD strings.Builder
for _, r := range reports {
if r.Report != "" {
reportsMD.WriteString(fmt.Sprintf("Claim %s: %s\n", r.ClaimID, r.Report))
}
}
if reportsMD.Len() == 0 {
reportsMD.WriteString("(no claims)")
}
prompt := fmt.Sprintf(groundedSelectPrompt, question, reportsMD.String(), evidenceMD)
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: prompt},
},
})
if err != nil {
return nil
}
var res groundedReviewResult
if err := unmarshalModelJSON(resp.Content, &res); err != nil {
return nil
}
out := map[string]GroundedVerdict{}
for _, item := range res.Claims {
cid := strings.TrimSpace(item.ClaimID)
if cid == "" {
continue
}
var ungrounded []string
for _, u := range item.UngroundedAssertions {
s := strings.TrimSpace(u.Assertion)
if s == "" {
s = strings.TrimSpace(u.Reason)
}
if s != "" {
ungrounded = append(ungrounded, s)
}
}
out[cid] = GroundedVerdict{Grounded: item.Grounded, Ungrounded: ungrounded}
}
return out
}

View File

@@ -0,0 +1,265 @@
//
// 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 (
"crypto/md5"
"fmt"
"regexp"
"sort"
"strings"
)
// Inspector tools (mirrors Python harness/tools/inspector.py): operate on the
// chunks already accumulated in the shared Kbinfos, rather than issuing new
// retrieval. They are stateless reads of the in-memory evidence pool.
// InspectorOpenContext expands context around a chunk (mirrors open_context):
// the chunk plus its 2 neighbours on each side.
func InspectorOpenContext(chunks []map[string]interface{}, chunkID string) []map[string]interface{} {
idx := findChunkIndex(chunks, chunkID)
if idx < 0 {
return nil
}
start := idx - 2
if start < 0 {
start = 0
}
end := idx + 2
if end > len(chunks) {
end = len(chunks)
}
return append([]map[string]interface{}(nil), chunks[start:end]...)
}
// InspectorCompareSources returns the chunks matching the given ids (mirrors
// compare_sources).
func InspectorCompareSources(chunks []map[string]interface{}, chunkIDs []string) []map[string]interface{} {
if len(chunkIDs) == 0 {
return nil
}
want := map[string]bool{}
for _, id := range chunkIDs {
want[id] = true
}
var out []map[string]interface{}
for _, c := range chunks {
if want[chunkIDOf(c)] {
out = append(out, c)
}
}
return out
}
// InspectorGrepWithin narrows chunks of one document to their keyword-bearing
// sentences (mirrors grep_within + _narrow_by_keywords). It returns copies so the
// shared kbinfos citation pool is never mutated.
func InspectorGrepWithin(chunks []map[string]interface{}, docID, pattern string) []map[string]interface{} {
kwds := keywordList(pattern)
var scoped []map[string]interface{}
for _, c := range chunks {
if docIDOf(c) == docID {
scoped = append(scoped, c)
}
}
if len(kwds) == 0 || len(scoped) == 0 {
return scoped
}
var out []map[string]interface{}
dedup := map[string]bool{}
for _, c := range scoped {
cp := cloneChunk(c)
content := chunkText(cp)
narrowed := narrowContent(content, kwds)
if narrowed == "" {
continue
}
key := md5.Sum([]byte(narrowed))
if dedup[fmt.Sprintf("%x", key)] {
continue
}
dedup[fmt.Sprintf("%x", key)] = true
cp["content_with_weight"] = narrowed
if _, ok := cp["content"]; ok {
cp["content"] = narrowed
}
delete(cp, "highlight")
out = append(out, cp)
}
return out
}
// InspectorRequestAdjacent returns count neighbours before or after a chunk
// (mirrors request_adjacent).
func InspectorRequestAdjacent(chunks []map[string]interface{}, chunkID, direction string, count int) []map[string]interface{} {
idx := findChunkIndex(chunks, chunkID)
if idx < 0 {
return nil
}
if count <= 0 {
count = 3
}
var start, end int
if direction == "prev" {
start = idx - count
if start < 0 {
start = 0
}
end = idx
} else {
start = idx + 1
end = start + count
if end > len(chunks) {
end = len(chunks)
}
}
if start >= len(chunks) || start >= end {
return nil
}
return append([]map[string]interface{}(nil), chunks[start:end]...)
}
// findChunkIndex mirrors Python _find_chunk_index.
func findChunkIndex(chunks []map[string]interface{}, chunkID string) int {
for i, c := range chunks {
if chunkIDOf(c) == chunkID {
return i
}
}
return -1
}
// chunkIDOf mirrors Python _chunk_id: chunk_id or id.
func chunkIDOf(c map[string]interface{}) string {
if v, ok := c["chunk_id"].(string); ok && v != "" {
return v
}
if v, ok := c["id"].(string); ok && v != "" {
return v
}
return ""
}
// docIDOf returns the chunk's doc_id.
func docIDOf(c map[string]interface{}) string {
if v, ok := c["doc_id"].(string); ok {
return v
}
return ""
}
// keywordList mirrors _narrow_by_keywords' keyword parsing: comma-separated; when
// fewer than 3 comma terms, split on spaces into bigrams.
func keywordList(keywords string) []string {
comma := splitTrim(keywords, ",")
if len(comma) < 3 {
words := splitTrim(keywords, " ")
var bigrams []string
for i := 0; i+1 < len(words); i++ {
bigrams = append(bigrams, words[i]+" "+words[i+1])
}
return bigrams
}
return comma
}
func splitTrim(s, sep string) []string {
var out []string
for _, part := range strings.Split(s, sep) {
part = strings.ToLower(strings.TrimSpace(part))
if part != "" {
out = append(out, part)
}
}
return out
}
// narrowContent mirrors Python _narrow_content: keep keyword-bearing sentences
// (+/- 1 neighbour), highlight matches, wrap in "...". Returns "" when no
// keyword occurs anywhere in the content.
func narrowContent(content string, kwds []string) string {
sents := splitSentences(content)
if len(sents) == 0 {
return ""
}
keep := map[int]bool{}
matched := false
for i, s := range sents {
low := strings.ToLower(s)
for _, kw := range kwds {
if 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 ""
}
idx := make([]int, 0, len(keep))
for i := range keep {
idx = append(idx, i)
}
sort.Ints(idx)
var b strings.Builder
for _, i := range idx {
b.WriteString(sents[i])
}
narrowed := strings.TrimSpace(b.String())
return "..." + highlightKeywords(narrowed, kwds) + "..."
}
// highlightKeywords mirrors Python _highlight_keywords: wrap the longest keyword
// matches in *asterisks*, case-insensitive.
func highlightKeywords(text string, kwds []string) string {
terms := make([]string, 0, len(kwds))
seen := map[string]bool{}
for _, k := range kwds {
if k != "" && !seen[k] {
seen[k] = true
terms = append(terms, k)
}
}
if len(terms) == 0 {
return text
}
sort.Slice(terms, func(i, j int) bool { return len(terms[i]) > len(terms[j]) })
parts := make([]string, len(terms))
for i, t := range terms {
parts[i] = regexp.QuoteMeta(t)
}
re := regexp.MustCompile("(?i)" + strings.Join(parts, "|"))
return re.ReplaceAllString(text, "*$0*")
}
// cloneChunk returns a shallow copy of a chunk map so inspector narrowing never
// mutates the shared kbinfos citation pool.
func cloneChunk(c map[string]interface{}) map[string]interface{} {
cp := make(map[string]interface{}, len(c))
for k, v := range c {
cp[k] = v
}
return cp
}

View File

@@ -0,0 +1,108 @@
//
// 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 "testing"
func sampleChunks() []map[string]interface{} {
return []map[string]interface{}{
{"chunk_id": "c0", "content_with_weight": "alpha", "doc_id": "d1"},
{"chunk_id": "c1", "content_with_weight": "beta", "doc_id": "d1"},
{"chunk_id": "c2", "content_with_weight": "gamma", "doc_id": "d1"},
{"chunk_id": "c3", "content_with_weight": "delta", "doc_id": "d2"},
{"chunk_id": "c4", "content_with_weight": "epsilon", "doc_id": "d2"},
}
}
// TestInspectorOpenContext asserts the chunk plus 2 neighbours on each side
// (Python slice chunks[idx-2:idx+2] is right-open, so 4 chunks: idx-2..idx+1).
func TestInspectorOpenContext(t *testing.T) {
out := InspectorOpenContext(sampleChunks(), "c2")
if len(out) != 4 { // c0..c3 (idx-2..idx+1)
t.Fatalf("open_context around c2 = %d chunks, want 4", len(out))
}
if chunkIDOf(out[2]) != "c2" {
t.Errorf("middle chunk = %s, want c2", chunkIDOf(out[2]))
}
// Near the boundary, clamp.
if out := InspectorOpenContext(sampleChunks(), "c0"); len(out) != 2 {
t.Errorf("open_context around c0 = %d, want 2 (clamped)", len(out))
}
}
// TestInspectorCompareSources asserts only requested ids are returned.
func TestInspectorCompareSources(t *testing.T) {
out := InspectorCompareSources(sampleChunks(), []string{"c1", "c3"})
if len(out) != 2 || chunkIDOf(out[0]) != "c1" || chunkIDOf(out[1]) != "c3" {
t.Errorf("compare_sources = %v", out)
}
}
// TestInspectorGrepWithin asserts chunks are narrowed to keyword sentences and
// copies never mutate the original. Keywords are comma-separated (≥3 to avoid
// the space-bigram fallback).
func TestInspectorGrepWithin(t *testing.T) {
chunks := []map[string]interface{}{
{"chunk_id": "c0", "content_with_weight": "Paris has 2 million people. It is in France. The Eiffel Tower is famous.", "doc_id": "d1"},
{"chunk_id": "c1", "content_with_weight": "Tokyo has 14 million people. It is in Japan.", "doc_id": "d1"},
}
orig := chunks[0]["content_with_weight"].(string)
out := InspectorGrepWithin(chunks, "d1", "france, eiffel, tower")
if len(out) != 1 {
t.Fatalf("grep_within = %d chunks, want 1 (only c0 has France/Eiffel)", len(out))
}
if got := chunkText(out[0]); !contains(got, "*France*") {
t.Errorf("narrowed content must highlight France, got %q", got)
}
// Original must be untouched.
if chunks[0]["content_with_weight"].(string) != orig {
t.Error("grep_within must not mutate the shared chunk pool")
}
}
// TestInspectorRequestAdjacent asserts next/prev direction and count clamp.
func TestInspectorRequestAdjacent(t *testing.T) {
next := InspectorRequestAdjacent(sampleChunks(), "c1", "next", 2)
if len(next) != 2 || chunkIDOf(next[0]) != "c2" || chunkIDOf(next[1]) != "c3" {
t.Errorf("request_adjacent next = %v", next)
}
prev := InspectorRequestAdjacent(sampleChunks(), "c4", "prev", 10)
if len(prev) != 4 { // c0..c3, clamped
t.Errorf("request_adjacent prev = %d, want 4 (clamped)", len(prev))
}
}
// TestKeywordList asserts comma vs space-bigram parsing.
func TestKeywordList(t *testing.T) {
// >=3 comma terms → kept as-is.
if got := keywordList("a, b, c"); len(got) != 3 {
t.Errorf("comma list = %v", got)
}
// <3 comma terms → space bigrams.
if got := keywordList("alpha beta"); len(got) != 1 || got[0] != "alpha beta" {
t.Errorf("space bigram = %v", got)
}
}
func contains(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

@@ -0,0 +1,537 @@
//
// 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"
"sort"
"strings"
"github.com/cloudwego/eino/schema"
"ragflow/internal/agent/chat"
"ragflow/internal/engine"
"ragflow/internal/engine/types"
"ragflow/internal/service"
)
// graph_explore (mirrors Python harness/tools/navigation.py graph_explore): walk
// the compiled knowledge graph. Unlike ontology_navigate/mindmap_navigate (which
// read the single merged "graph" JSON), the KG store keeps one searchable row per
// entity/relation, so we *search* our way to a small subgraph: dense-seed
// entities by the question, hop _KG_HOPS out over relations, then ask the chat
// model whether that subgraph answers the question.
//
// Seeds use the tenant embedding model via service.NavEmbedder (dense KNN,
// similarity>=kgSeedSim, re-ranked by mention_count_int desc); when the embedding
// model is unavailable it degrades to keyword match (mirrors Python _kg_search's
// `embed_mdl is None` path).
const (
kgScopeDataset = "dataset"
kgScopeDoc = "doc"
kgSeeds = 2 // top-N entities matched directly to the question
kgSeedPool = 64 // KNN candidate pool before the mention_count_int re-sort
kgSeedSim = 0.8 // dense seed similarity floor (Python _KG_SEED_SIM)
kgHops = 2 // relation hops out from the seeds
kgNeighbors = 128 // cap on neighbour entity rows resolved per hop
kgRelLimit = 32 // relations fetched per endpoint filter
)
type kgEntity struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Aliases []string `json:"aliases"`
SourceChunkIDs []string `json:"source_chunk_ids"`
DocID string `json:"doc_id"`
}
type kgRelation struct {
From string `json:"from"`
To string `json:"to"`
Type string `json:"type"`
SourceChunkIDs []string `json:"source_chunk_ids"`
DocID string `json:"doc_id"`
}
// ExploreResult is the graph_explore output: exactly one of Answer / Chunks is
// populated.
type ExploreResult struct {
Answer string
Chunks []map[string]interface{}
}
// ExploreGraph implements graph_explore.
func ExploreGraph(ctx context.Context, tenantID string, datasetIDs []string, query, keywords string, docScope []string) (ExploreResult, error) {
empty := ExploreResult{}
text := strings.TrimSpace(query + " " + keywords)
if text == "" || len(datasetIDs) == 0 {
return empty, nil
}
de := engine.Get()
if de == nil {
return empty, fmt.Errorf("graph_explore: engine not configured")
}
scopeKwd := kgScopeDataset
if len(docScope) > 0 {
scopeKwd = kgScopeDoc
}
var entities []kgEntity
var relations []kgRelation
seenNames := map[string]bool{}
addEntities := func(new []kgEntity, scopeKey string) []string {
var added []string
for _, e := range new {
key := scopeKey + ":" + strings.ToLower(e.Name)
if seenNames[key] {
continue
}
seenNames[key] = true
entities = append(entities, e)
added = append(added, e.Name)
}
return added
}
// Encode the seed text ONCE (not per dataset): a single embedding request
// serves every KB. A nil vector means the embedding model is unavailable and
// the seed search falls back to keyword match.
seedVec := encodeSeedVector(ctx, tenantID, text)
for _, kbID := range datasetIDs {
// (1) Seeds: dense KNN (similarity>=_KG_SEED_SIM) over the scoped entity
// rows, re-ranked by mention_count_int desc; falls back to keyword match
// when the embedding model is unavailable.
seedRows := kgSeedSearch(ctx, de, tenantID, kbID, docScope, text, scopeKwd, seedVec)
var seeds []kgEntity
for _, r := range seedRows {
if e, ok := kgParseEntity(r); ok {
seeds = append(seeds, e)
}
}
frontier := addEntities(seeds, kbID)
// (2) Expand kgHops out, collecting relations and neighbour entities.
for hop := 0; hop < kgHops; hop++ {
if len(frontier) == 0 {
break
}
terms := endpointTerms(frontier)
relRows := kgSearch(ctx, de, tenantID, kbID, docScope, "relation", "", kgRelLimit, scopeKwd,
map[string]interface{}{"from_entity_kwd": terms}, "", 0)
relRows = append(relRows, kgSearch(ctx, de, tenantID, kbID, docScope, "relation", "", kgRelLimit, scopeKwd,
map[string]interface{}{"to_entity_kwd": terms}, "", 0)...)
seenRel := map[string]bool{}
var hopRelations []kgRelation
for _, r := range relRows {
rel, ok := kgParseRelation(r)
if !ok {
continue
}
k := rel.From + "|" + rel.To + "|" + rel.Type
if seenRel[k] {
continue
}
seenRel[k] = true
hopRelations = append(hopRelations, rel)
}
relations = append(relations, hopRelations...)
// Neighbour entity names not yet visited.
seenLower := map[string]bool{}
for k := range seenNames {
if strings.HasPrefix(k, kbID+":") {
seenLower[strings.TrimPrefix(k, kbID+":")] = true
}
}
neighSet := map[string]string{}
for _, r := range hopRelations {
for _, n := range []string{r.From, r.To} {
n = strings.TrimSpace(n)
if n == "" || seenLower[strings.ToLower(n)] {
continue
}
neighSet[strings.ToLower(n)] = n
}
}
if len(neighSet) == 0 {
break
}
neighFiltered := make([]string, 0, len(neighSet))
for _, n := range neighSet {
neighFiltered = append(neighFiltered, n)
}
limit := kgNeighbors
if len(neighFiltered) < limit {
limit = len(neighFiltered)
}
neighRows := kgSearch(ctx, de, tenantID, kbID, docScope, "entity", "", limit, scopeKwd,
map[string]interface{}{"name_kwd": endpointTerms(neighFiltered)}, "", 0)
var neighbours []kgEntity
for _, r := range neighRows {
if e, ok := kgParseEntity(r); ok {
neighbours = append(neighbours, e)
}
}
frontier = addEntities(neighbours, kbID)
}
}
if len(entities) == 0 && len(relations) == 0 {
return empty, nil
}
// (3) Does the subgraph answer the question?
answer, relevant := askStructureAnswer(ctx, query, entities, relations)
// (4a) Sufficient — return the answer, no chunks.
if answer != "" {
return ExploreResult{Answer: answer}, nil
}
// (4b) Insufficient — return source passages behind the relevant nodes.
evidence := collectEvidenceIDs(entities, relations, relevant)
var chunks []map[string]interface{}
for docID, ids := range evidence {
if docID != "" && len(ids) > 0 {
chunks = append(chunks, loadChunksByIDs(ctx, tenantID, ids)...)
}
}
return ExploreResult{Chunks: chunks}, nil
}
// encodeSeedVector encodes the seed text once for the whole ExploreGraph call.
// Returns nil when the tenant embedding model is unavailable (or encoding fails).
func encodeSeedVector(ctx context.Context, tenantID, text string) []float64 {
embedder := service.NewNavEmbedder(service.NewModelProviderService(), "")
vecs, err := embedder.Encode(ctx, tenantID, []string{text})
if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 {
return nil
}
vec := make([]float64, len(vecs[0]))
for i, v := range vecs[0] {
vec[i] = float64(v)
}
return vec
}
// kgSeedSearch searches the compiled KG entity rows for seeds (mirrors Python
// _kg_search dense branch): dense KNN over name_kwd with similarity>=0.8,
// re-ranked by mention_count_int desc, top kgSeeds. Falls back to keyword match
// when seedVec is nil (embedding model unavailable).
func kgSeedSearch(ctx context.Context, de engine.DocEngine, tenantID, kbID string, docIDs []string, text, scopeKwd string, seedVec []float64) []map[string]interface{} {
if seedVec != nil {
dense := &types.MatchDenseExpr{
VectorColumnName: fmt.Sprintf("q_%d_vec", len(seedVec)),
EmbeddingData: seedVec,
EmbeddingDataType: "float",
DistanceType: "cosine",
TopN: kgSeedPool,
ExtraOptions: map[string]interface{}{"similarity": kgSeedSim},
}
rows := kgSearchRaw(ctx, de, tenantID, kbID, docIDs, "entity", scopeKwd, nil, []interface{}{dense}, "mention_count_int", kgSeedPool)
return topMentionCount(rows, kgSeeds)
}
// Text fallback (mirrors Python _kg_search `embed_mdl is None` path).
return kgSearch(ctx, de, tenantID, kbID, docIDs, "entity", text, kgSeeds, scopeKwd, nil, "mention_count_int", kgSeedPool)
}
// topMentionCount re-ranks rows by mention_count_int desc and returns topN.
func topMentionCount(rows []map[string]interface{}, topN int) []map[string]interface{} {
sort.SliceStable(rows, func(i, j int) bool {
return mentionCount(rows[i]) > mentionCount(rows[j])
})
if len(rows) > topN {
rows = rows[:topN]
}
return rows
}
func mentionCount(row map[string]interface{}) int {
switch v := row["mention_count_int"].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case float32:
return int(v)
}
return 0
}
// kgSearchRaw is the low-level KG row search with explicit match exprs and any
// extra filter keys (e.g. from_entity_kwd/to_entity_kwd/name_kwd).
func kgSearchRaw(ctx context.Context, de engine.DocEngine, tenantID, kbID string, docIDs []string, kind, scopeKwd string, extra map[string]interface{}, matchExprs []interface{}, orderDesc string, limit int) []map[string]interface{} {
idx := fmt.Sprintf("ragflow_%s", tenantID)
condition := map[string]interface{}{"knowledge_graph_kwd": kind}
if scopeKwd != "" {
condition["scope_kwd"] = scopeKwd
}
if len(docIDs) > 0 {
condition["doc_id"] = docIDs
}
for k, v := range extra {
condition[k] = v
}
fields := []string{"content_with_weight", "source_chunk_ids", "doc_id", "docnm_kwd", "name_kwd", "mention_count_int", "from_entity_kwd", "to_entity_kwd"}
req := &types.SearchRequest{
IndexNames: []string{idx},
KbIDs: []string{kbID},
SelectFields: fields,
Filter: condition,
Limit: limit,
MatchExprs: matchExprs,
}
if orderDesc != "" {
req.OrderBy = &types.OrderByExpr{}
req.OrderBy.Desc(orderDesc)
}
res, err := de.Search(ctx, req)
if err != nil {
return nil
}
return res.Chunks
}
// kgSearch searches the compiled KG rows of one KB (mirrors Python _kg_search),
// using keyword match. It only builds the MatchTextExpr (with the pool-based
// TopN) and delegates the request construction to kgSearchRaw.
func kgSearch(ctx context.Context, de engine.DocEngine, tenantID, kbID string, docIDs []string, kind, text string, topN int, scopeKwd string, extra map[string]interface{}, orderDesc string, pool int) []map[string]interface{} {
var matchExprs []interface{}
if text != "" {
knnTopN := topN
if pool > knnTopN {
knnTopN = pool
}
matchExprs = []interface{}{&types.MatchTextExpr{
Fields: []string{"content_ltks", "content_sm_ltks"},
MatchingText: text,
TopN: knnTopN,
}}
}
return kgSearchRaw(ctx, de, tenantID, kbID, docIDs, kind, scopeKwd, extra, matchExprs, orderDesc, topN)
}
func kgParseEntity(row map[string]interface{}) (kgEntity, bool) {
name := ""
payload := map[string]interface{}{}
if s, ok := row["content_with_weight"].(string); ok {
_ = json.Unmarshal([]byte(s), &payload)
}
if v, ok := payload["name"].(string); ok && v != "" {
name = v
} else if v, ok := payload["term"].(string); ok && v != "" {
name = v
} else if v, ok := payload["title"].(string); ok && v != "" {
name = v
}
name = strings.TrimSpace(name)
if name == "" {
return kgEntity{}, false
}
e := kgEntity{
Name: name,
Type: strOr(payload["type"], "other"),
Description: strOr(payload["description"], ""),
SourceChunkIDs: strSliceField(row["source_chunk_ids"]),
DocID: strOr(row["doc_id"], ""),
}
if aliases, ok := payload["aliases"].([]interface{}); ok {
for _, a := range aliases {
if s, ok := a.(string); ok && strings.TrimSpace(s) != "" {
e.Aliases = append(e.Aliases, strings.TrimSpace(s))
}
}
}
return e, true
}
func kgParseRelation(row map[string]interface{}) (kgRelation, bool) {
src := strings.TrimSpace(strOr(row["from_entity_kwd"], ""))
tgt := strings.TrimSpace(strOr(row["to_entity_kwd"], ""))
if src == "" || tgt == "" {
return kgRelation{}, false
}
typ := "related"
if payload, ok := row["content_with_weight"].(string); ok {
var p map[string]interface{}
if json.Unmarshal([]byte(payload), &p) == nil {
if t, ok := p["type"].(string); ok && t != "" {
typ = t
} else if t, ok := p["relation"].(string); ok && t != "" {
typ = t
}
}
}
return kgRelation{
From: src, To: tgt, Type: typ,
SourceChunkIDs: strSliceField(row["source_chunk_ids"]),
DocID: strOr(row["doc_id"], ""),
}, true
}
// endpointTerms mirrors Python _endpoint_terms: original + lowercased forms, so
// hop queries match both merged (lowercased) and per-doc (original-case)
// endpoint fields.
func endpointTerms(names []string) []string {
set := map[string]bool{}
for _, n := range names {
n = strings.TrimSpace(n)
if n == "" {
continue
}
set[n] = true
set[strings.ToLower(n)] = true
}
out := make([]string, 0, len(set))
for n := range set {
out = append(out, n)
}
sort.Strings(out)
return out
}
// collectEvidenceIDs mirrors Python _collect_evidence_ids: group source chunk ids
// of relevant entities AND relations by doc.
func collectEvidenceIDs(entities []kgEntity, relations []kgRelation, relevantNames []string) map[string][]string {
wanted := map[string]bool{}
for _, n := range relevantNames {
if s := strings.TrimSpace(n); s != "" {
wanted[strings.ToLower(s)] = true
}
}
byDoc := map[string][]string{}
seen := map[string]bool{}
add := func(docID string, ids []string) {
for _, cid := range ids {
if cid == "" {
continue
}
key := docID + "|" + cid
if seen[key] {
continue
}
seen[key] = true
byDoc[docID] = append(byDoc[docID], cid)
}
}
for _, e := range entities {
names := map[string]bool{strings.ToLower(e.Name): true}
for _, a := range e.Aliases {
names[strings.ToLower(a)] = true
}
if intersects(names, wanted) {
add(e.DocID, e.SourceChunkIDs)
}
}
for _, r := range relations {
if wanted[strings.ToLower(r.From)] || wanted[strings.ToLower(r.To)] {
add(r.DocID, r.SourceChunkIDs)
}
}
return byDoc
}
func intersects(a, b map[string]bool) bool {
for k := range a {
if b[k] {
return true
}
}
return false
}
// askStructureAnswer asks the chat model whether the subgraph answers the query
// (mirrors Python _ask_structure), returning (answer, relevant_names).
func askStructureAnswer(ctx context.Context, query string, entities []kgEntity, relations []kgRelation) (string, []string) {
inv := chat.GetDefaultInvoker()
if inv == nil {
return "", nil
}
rendered := renderKGSubgraph(entities, relations)
resp, err := inv.Invoke(ctx, nil, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: strings.ReplaceAll(navSystemPrompt, "{noun}", "knowledge graph")},
{Role: schema.User, Content: fmt.Sprintf("Question:\n%s\n\nKnowledge graph:\n%s\n\nOutput JSON:", query, rendered)},
},
})
if err != nil {
return "", nil
}
var v structureNavVerdict
if err := unmarshalModelJSON(resp.Content, &v); err != nil {
return "", nil
}
answer := ""
if v.IsSufficient {
answer = strings.TrimSpace(v.Answer)
}
return answer, v.RelevantEntities
}
func renderKGSubgraph(entities []kgEntity, relations []kgRelation) 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)
}
}
b.WriteString("\n\nRelations:")
for i, r := range relations {
if i >= maxStructureRelations {
break
}
b.WriteString("\n- " + r.From + " -[" + r.Type + "]-> " + r.To)
}
return b.String()
}
func strOr(v interface{}, def string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return def
}
func strSliceField(v interface{}) []string {
switch x := v.(type) {
case []string:
return x
case []interface{}:
var out []string
for _, item := range x {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}

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 "testing"
// TestKGParseEntity asserts the name/alias/source extraction from a KG row.
func TestKGParseEntity(t *testing.T) {
row := map[string]interface{}{
"content_with_weight": `{"name":"Paris","type":"LOCATION","aliases":["City of Light"]}`,
"source_chunk_ids": []interface{}{"ck-1", "ck-2"},
"doc_id": "doc-1",
}
e, ok := kgParseEntity(row)
if !ok {
t.Fatal("entity must parse")
}
if e.Name != "Paris" || e.Type != "LOCATION" {
t.Errorf("name/type = %q/%q", e.Name, e.Type)
}
if len(e.Aliases) != 1 || e.Aliases[0] != "City of Light" {
t.Errorf("aliases = %v", e.Aliases)
}
if len(e.SourceChunkIDs) != 2 || e.DocID != "doc-1" {
t.Errorf("source_chunk_ids/doc_id = %v/%q", e.SourceChunkIDs, e.DocID)
}
}
// TestKGParseRelation asserts endpoint + type extraction, and rejection of
// dangling endpoints.
func TestKGParseRelation(t *testing.T) {
row := map[string]interface{}{
"content_with_weight": `{"type":"capital_of"}`,
"from_entity_kwd": "Paris",
"to_entity_kwd": "France",
}
r, ok := kgParseRelation(row)
if !ok {
t.Fatal("relation must parse")
}
if r.From != "Paris" || r.To != "France" || r.Type != "capital_of" {
t.Errorf("from/to/type = %q/%q/%q", r.From, r.To, r.Type)
}
if _, ok := kgParseRelation(map[string]interface{}{"from_entity_kwd": "", "to_entity_kwd": "X"}); ok {
t.Error("dangling endpoint must be rejected")
}
}
// TestEndpointTerms asserts original + lowercased variants are produced.
func TestEndpointTerms(t *testing.T) {
terms := endpointTerms([]string{"Paris", "France"})
got := map[string]bool{}
for _, t := range terms {
got[t] = true
}
for _, want := range []string{"Paris", "paris", "France", "france"} {
if !got[want] {
t.Errorf("endpoint terms missing %q, got %v", want, terms)
}
}
}
// TestCollectEvidenceIDs asserts relevant entities AND relations contribute
// source chunk ids grouped by doc.
func TestCollectEvidenceIDs(t *testing.T) {
entities := []kgEntity{
{Name: "Paris", SourceChunkIDs: []string{"ck-1"}, DocID: "doc-1"},
{Name: "Berlin", SourceChunkIDs: []string{"ck-2"}, DocID: "doc-1"},
}
relations := []kgRelation{
{From: "Paris", To: "France", SourceChunkIDs: []string{"ck-3"}, DocID: "doc-1"},
}
got := collectEvidenceIDs(entities, relations, []string{"Paris", "France"})
ids := got["doc-1"]
if len(ids) != 2 { // ck-1 (Paris entity) + ck-3 (Paris→France relation)
t.Errorf("expected 2 evidence ids (Paris entity + Paris→France relation), got %v", ids)
}
// Berlin is not relevant → its ck-2 must be excluded.
for _, id := range ids {
if id == "ck-2" {
t.Error("Berlin's chunk must be excluded (not relevant)")
}
}
}
// TestMentionCountReRank asserts topMentionCount re-sorts by mention_count_int
// desc and truncates to topN.
func TestMentionCountReRank(t *testing.T) {
rows := []map[string]interface{}{
{"name_kwd": "low", "mention_count_int": float64(3)},
{"name_kwd": "high", "mention_count_int": float64(9)},
{"name_kwd": "mid", "mention_count_int": float64(5)},
}
top := topMentionCount(rows, 2)
if len(top) != 2 {
t.Fatalf("topMentionCount = %d, want 2", len(top))
}
if mentionCount(top[0]) != 9 || mentionCount(top[1]) != 5 {
t.Errorf("re-rank order wrong: [%d, %d]", mentionCount(top[0]), mentionCount(top[1]))
}
}
// TestNormalizeWebResults asserts Tavily "results" and agent "chunks" envelopes
// both normalize to the shared evidence shape.
func TestNormalizeWebResults(t *testing.T) {
tavily := `{"results":[{"url":"https://a","content":"hello world","title":"A"}]}`
out := normalizeWebResults([]byte(tavily))
if len(out) != 1 || out[0]["content_with_weight"] != "hello world" {
t.Fatalf("tavily envelope: got %v", out)
}
if out[0]["url"] != "https://a" {
t.Errorf("url = %v", out[0]["url"])
}
// A result without a URL must be dropped.
noURL := `{"results":[{"content":"no url here"}]}`
if out := normalizeWebResults([]byte(noURL)); len(out) != 0 {
t.Errorf("result without URL must be dropped, got %v", out)
}
}

View File

@@ -31,6 +31,9 @@ type SearchFn func(ctx context.Context, query, keywords string) ([]map[string]in
type Kbinfos struct {
Chunks []map[string]interface{}
DocAggs []map[string]interface{}
// PreSummary is the merged claim-report summary produced by AgenticResearch
// (Python kbinfos["pre_summary"]). The final-answer call reads it when set.
PreSummary string
}
func (k *Kbinfos) HasChunks() bool { return len(k.Chunks) > 0 }
@@ -129,6 +132,13 @@ type OrchestratorResult struct {
Abstain bool
EmptyResult bool
Kbinfos *Kbinfos
// Caveat is the decision-ladder explanation for a partial answer (e.g.
// "evidence partially supports the answer"), surfaced in the final answer.
Caveat string
// ForceLLM is set by the FALLBACK_LLM verdict: even with no gathered
// evidence, finalization must still call the direct LLM (rather than return
// the canned "no evidence" string).
ForceLLM bool
}
// DirectSearch is the low-mode orchestrator: one hybrid search → merge.
@@ -199,17 +209,27 @@ func DecomposeAndSearch(ctx context.Context, search SearchFn, question, keywords
crossResults = append(crossResults, CrossCheckClaim(c.AgentResult, allChunks))
}
}
verdict := ComputeFusionScore(agentResults, crossResults, mode)
action, _ := RouteSufficiencyVerdict(verdict, modeLabel, cycle, mode.MaxOrchestratorCycles)
claimTargets := make([]ClaimTarget, 0, len(claims))
for _, c := range claims {
if c != nil {
claimTargets = append(claimTargets, *c)
}
}
verdict := ComputeFusionScore(agentResults, crossResults, mode, question, claimTargets, allChunks)
action, _, caveat := RouteSufficiencyVerdict(verdict, modeLabel, cycle, mode.MaxOrchestratorCycles, nil)
switch action {
case "ANSWER", "ANSWER_PARTIAL":
return OrchestratorResult{Verdict: &verdict, PartialAnswer: action == "ANSWER_PARTIAL", Kbinfos: kbinfos}
case "ANSWER":
return OrchestratorResult{Verdict: &verdict, Kbinfos: kbinfos}
case "ANSWER_PARTIAL":
return OrchestratorResult{Verdict: &verdict, PartialAnswer: true, Caveat: caveat, Kbinfos: kbinfos}
case "FALLBACK_LLM":
// ultra route: no grounded answer, hand off to the direct LLM. Force
// finalization to call the model even with empty evidence.
return OrchestratorResult{Verdict: &verdict, PartialAnswer: true, Caveat: caveat, ForceLLM: true, 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
}

View File

@@ -62,22 +62,25 @@ func TestCrossCheckClaim_Unverified(t *testing.T) {
}
}
// TestComputeFusionScore_Sufficient asserts a fully-verified high-score set is
// SUFFICIENT.
// TestComputeFusionScore_Sufficient asserts a fully-verified high-confidence set
// is SUFFICIENT.
func TestComputeFusionScore_Sufficient(t *testing.T) {
agents := []AgentResult{{ClaimID: "c0", IsVerified: true, Report: "value 42", EvidenceIDs: []int{0}}}
agents := []AgentResult{{ClaimID: "c0", IsVerified: true, Confidence: 0.9, Report: "value 42", EvidenceIDs: []int{0}}}
cross := []ClaimCrossCheckResult{{ClaimID: "c0", CrossCheckPassed: true, CrossCheckScore: 1.0, HasEvidence: true}}
v := ComputeFusionScore(agents, cross, THINKING_MODES["medium"])
v := ComputeFusionScore(agents, cross, THINKING_MODES["medium"], "", nil, nil)
if v.Status != "SUFFICIENT" {
t.Errorf("status = %q, want SUFFICIENT", v.Status)
}
if v.AgentConfidence != 0.9 {
t.Errorf("agent_confidence = %v, want 0.9", v.AgentConfidence)
}
}
// TestComputeFusionScore_NoEvidence asserts a claim with no examined evidence is
// UNANSWERABLE (empty-evidence guard).
// TestComputeFusionScore_NoEvidence asserts a claim whose cross-check failed
// (nothing passed) is UNANSWERABLE.
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"])
cross := []ClaimCrossCheckResult{{ClaimID: "c0", CrossCheckPassed: false, CrossCheckScore: 0.0, HasEvidence: false}}
v := ComputeFusionScore([]AgentResult{{ClaimID: "c0", IsVerified: true, Confidence: 0.9}}, cross, THINKING_MODES["medium"], "", nil, nil)
if v.Status != "UNANSWERABLE" {
t.Errorf("status = %q, want UNANSWERABLE", v.Status)
}
@@ -88,7 +91,7 @@ func TestComputeFusionScore_NoEvidence(t *testing.T) {
// TestRouteSufficiencyVerdict asserts SUFFICIENT → ANSWER.
func TestRouteSufficiencyVerdict(t *testing.T) {
action, cont := RouteSufficiencyVerdict(SufficiencyVerdict{Status: "SUFFICIENT", Score: 0.9}, "medium", 0, 3)
action, cont, _ := RouteSufficiencyVerdict(SufficiencyVerdict{Status: "SUFFICIENT", Score: 0.9, AgentConfidence: 0.9}, "medium", 0, 3, nil)
if action != "ANSWER" || cont {
t.Errorf("got (%q,%v), want (ANSWER,false)", action, cont)
}

View File

@@ -0,0 +1,491 @@
//
// 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"
"strings"
"sync"
einotool "github.com/cloudwego/eino/components/tool"
"gorm.io/gorm"
"ragflow/internal/agent/tool"
"ragflow/internal/service/nav"
)
// ToolResult is the normalized result of executing one agent tool, mirroring
// Python harness/types.py ToolResult.
type ToolResult struct {
Chunks []map[string]interface{}
Docs []string // doc ids produced by a routing tool (dataset_navigation_*)
Answer string // direct answer (ontology_navigate / structured_query)
Error string
// EvidenceIndices holds the GLOBAL indices of Chunks after they were merged
// into the shared kbinfos (populated by Execute, under mu). Consumers must
// use these rather than re-indexing the shared slice, which is mutated
// concurrently by parallel claim research.
EvidenceIndices []int
}
// docScopeConsumers are the tools that retrieve *within* a document set. When a
// routing tool has produced a relevant-doc set, these inherit it as doc_scope
// unless the caller passed one explicitly.
var docScopeConsumers = map[string]bool{
"ontology_navigate": true,
"mindmap_navigate": true,
"graph_explore": true,
"hybrid_search": true,
"vector_search": true,
"bm25_search": true,
"structured_query": true,
}
// Pipeline is the unified tool-execution dispatcher (mirrors Python
// harness/pipeline.py). It routes a tool name + args to the concrete tool,
// injects doc_scope for within-document tools, merges evidence into the shared
// Kbinfos, and filters the tool list by compilation availability.
//
// It composes a *ProductionRunner (which owns the real tool instances) so the
// agent loop drives the SAME retrieval path as the linear Run flow.
type Pipeline struct {
db *gorm.DB
tenantID string
datasetIDs []string
runner *ProductionRunner
kbinfos *Kbinfos
// compilation[kbID] = set of compiled-artifact kinds. Empty map disables
// compilation gating.
compilation map[string]map[string]bool
// routedDocs are the latest relevant-doc ids produced by a routing tool.
routedDocs []string
// lastEntity is the most recently discovered entity/document name (Step A.5).
// It gates graph_explore eligibility (mirrors OrchestratorContext.last_entity).
lastEntity string
trace []string
// mu guards kbinfos.Merge + routedDocs, which the parallel claim research in
// AgenticResearch mutates concurrently (Python relies on the single-threaded
// event loop; Go must serialize).
mu sync.Mutex
}
// NewPipeline builds a Pipeline over the given production runner and evidence
// store. compilation may be nil (no gating).
func NewPipeline(db *gorm.DB, tenantID string, datasetIDs []string, runner *ProductionRunner, kbinfos *Kbinfos, compilation map[string]map[string]bool) *Pipeline {
if kbinfos == nil {
kbinfos = &Kbinfos{}
}
return &Pipeline{
db: db, tenantID: tenantID, datasetIDs: datasetIDs, runner: runner,
kbinfos: kbinfos, compilation: compilation,
}
}
// HasRoutedScope mirrors Python gating.py tool_fits_context has_routed_scope
// (agent.py:167 uses bool(pipeline._routed_docs)). ontology/mindmap/graph tools
// are only callable once a routing tool has produced a doc set.
func (p *Pipeline) HasRoutedScope() bool { return len(p.scope()) > 0 }
// scope returns a snapshot of the routed docs under mu (mirrors Python
// pipeline._routed_docs; the parallel claim research mutates it concurrently).
func (p *Pipeline) scope() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.routedDocs...)
}
// chunksSnapshot returns a snapshot of the accumulated evidence chunks under mu.
func (p *Pipeline) chunksSnapshot() []map[string]interface{} {
p.mu.Lock()
defer p.mu.Unlock()
return append([]map[string]interface{}(nil), p.kbinfos.Chunks...)
}
// noteEntity records the most recently discovered entity (mirrors
// OrchestratorContext.note_entity). Ignores empty values so a fruitless round
// cannot clear a prior discovery.
func (p *Pipeline) noteEntity(name string) {
if p == nil {
return
}
if name = strings.TrimSpace(name); name != "" {
p.mu.Lock()
p.lastEntity = name
p.mu.Unlock()
}
}
// HasDiscoveredEntity reports whether graph_explore is eligible (mirrors
// gating.py:81 `graph_explore and not context.last_entity`).
func (p *Pipeline) HasDiscoveredEntity() bool {
if p == nil {
return false
}
p.mu.Lock()
defer p.mu.Unlock()
return p.lastEntity != ""
}
// Kbinfos returns the shared evidence store.
func (p *Pipeline) Kbinfos() *Kbinfos { return p.kbinfos }
// Execute dispatches a tool call and merges the result into kbinfos. It mirrors
// Python Pipeline.execute: doc_scope injection + kbinfos merge + routing-tool
// doc tracking.
func (p *Pipeline) Execute(ctx context.Context, toolName string, args map[string]interface{}) ToolResult {
res := p.executeTool(ctx, toolName, args)
p.mu.Lock()
defer p.mu.Unlock()
if len(res.Docs) > 0 {
// A routing tool yielded relevant doc ids — remember them so downstream
// within-document tools inherit the scope.
p.routedDocs = res.Docs
}
if len(res.Chunks) > 0 {
res.EvidenceIndices = p.kbinfos.Merge(res.Chunks, nil)
}
p.trace = append(p.trace, toolName)
return res
}
// executeTool runs one concrete tool. It injects doc_scope for within-document
// tools and returns the normalized result (without merging).
func (p *Pipeline) executeTool(ctx context.Context, toolName string, args map[string]interface{}) ToolResult {
if args == nil {
args = map[string]interface{}{}
}
// doc_scope inheritance: within-document tools take the routed docs unless
// the caller passed an explicit doc_scope.
if docScopeConsumers[toolName] {
if scope := p.scope(); len(scope) > 0 {
if _, has := args["doc_scope"]; !has {
args["doc_scope"] = scope
}
}
}
switch toolName {
case "hybrid_search", "vector_search", "bm25_search":
return p.runSearchTool(ctx, toolName, args)
case "web_search":
return p.runWebTool(ctx, args)
case "dataset_navigation_by_tree":
return p.runNavTool(ctx, args)
case "wiki_query":
return p.runWikiTool(ctx, args)
case "ontology_navigate", "mindmap_navigate":
return p.runStructureTool(ctx, toolName, args)
case "graph_explore":
return p.runGraphExploreTool(ctx, args)
case "inspector_open_context", "inspector_compare", "inspector_grep_within", "inspector_request_adjacent":
return p.runInspectorTool(toolName, args)
case "structured_query":
return p.runSQLTool(ctx, args)
default:
return ToolResult{Error: "Unknown tool: " + toolName}
}
}
// runSearchTool runs hybrid/vector/bm25 retrieval.
func (p *Pipeline) runSearchTool(ctx context.Context, toolName string, args map[string]interface{}) ToolResult {
if p.runner == nil {
return ToolResult{}
}
// Default kb_ids to the pipeline's bound datasets.
if _, has := args["kb_ids"]; !has {
args["kb_ids"] = p.datasetIDs
}
var inv einotool.InvokableTool
if toolName == "hybrid_search" {
inv = p.runner.searchTool
} else {
base, err := tool.BuildByName(toolName, nil)
if err != nil {
return ToolResult{Error: err.Error()}
}
t, ok := base.(einotool.InvokableTool)
if !ok {
return ToolResult{Error: toolName + " is not invokable"}
}
inv = t
}
if inv == nil {
return ToolResult{}
}
raw, err := inv.InvokableRun(ctx, mustJSON(args))
if err != nil {
return ToolResult{Error: err.Error()}
}
var res struct {
Chunks []map[string]interface{} `json:"chunks"`
}
if err := json.Unmarshal([]byte(raw), &res); err != nil {
return ToolResult{}
}
return ToolResult{Chunks: res.Chunks}
}
// runNavTool runs the dataset-navigation router and returns its doc ids.
func (p *Pipeline) runNavTool(ctx context.Context, args map[string]interface{}) ToolResult {
if p.runner == nil {
return ToolResult{}
}
topic := stringValue(args["topic"])
keywords := stringValue(args["keywords"])
query := strings.TrimSpace(topic + " " + keywords)
if query == "" {
return ToolResult{}
}
ns := p.runner.navSvc
if ns == nil {
ns = nav.GetNavService()
}
if ns == nil {
return ToolResult{}
}
seen := map[string]bool{}
var docs []string
for _, kbID := range p.datasetIDs {
for _, id := range NavigateDatasetByTree(ctx, p.db, ns, p.tenantID, kbID, query) {
if id != "" && !seen[id] {
seen[id] = true
docs = append(docs, id)
}
}
}
return ToolResult{Docs: docs}
}
// runWebTool runs web_search via the runner's configured web provider. Returns
// an empty result when no provider is configured (mirrors Python web_search
// has_web() guard).
func (p *Pipeline) runWebTool(ctx context.Context, args map[string]interface{}) ToolResult {
if p.runner == nil || p.runner.webTool == nil {
return ToolResult{}
}
query := stringValue(args["query"])
keywords := stringValue(args["keywords"])
raw, err := p.runner.webTool.InvokableRun(ctx, mustJSON(map[string]interface{}{"query": query, "keywords": keywords}))
if err != nil {
return ToolResult{Error: err.Error()}
}
chunks := normalizeWebResults([]byte(raw))
return ToolResult{Chunks: chunks}
}
// runGraphExploreTool runs graph_explore via ExploreGraph (kg_explore.go).
func (p *Pipeline) runGraphExploreTool(ctx context.Context, args map[string]interface{}) ToolResult {
// Gate: graph_explore is only offered once research has surfaced an entity
// to expand from (mirrors gating.py:81 tool_fits_context).
if !p.HasDiscoveredEntity() {
return ToolResult{}
}
topic := stringValue(args["query"])
if topic == "" {
topic = stringValue(args["topic"])
}
keywords := stringValue(args["keywords"])
var scope []string
switch v := args["doc_scope"].(type) {
case []string:
scope = v
case []interface{}:
for _, x := range v {
if s, ok := x.(string); ok {
scope = append(scope, s)
}
}
}
if len(scope) == 0 {
scope = p.scope()
}
out, err := ExploreGraph(ctx, p.tenantID, p.datasetIDs, topic, keywords, scope)
if err != nil {
return ToolResult{Error: err.Error()}
}
return ToolResult{Chunks: out.Chunks, Answer: out.Answer}
}
// runSQLTool forwards structured_query to the runner's SQL retrieval path.
func (p *Pipeline) runSQLTool(ctx context.Context, args map[string]interface{}) ToolResult {
if p.runner == nil {
return ToolResult{}
}
return p.runner.runSQLTool(ctx, args)
}
// runInspectorTool dispatches the four inspector tools over the shared kbinfos.
func (p *Pipeline) runInspectorTool(toolName string, args map[string]interface{}) ToolResult {
chunks := p.chunksSnapshot()
switch toolName {
case "inspector_open_context":
return ToolResult{Chunks: InspectorOpenContext(chunks, stringValue(args["chunk_id"]))}
case "inspector_compare":
var ids []string
switch v := args["chunk_ids"].(type) {
case []string:
ids = v
case []interface{}:
for _, x := range v {
if s, ok := x.(string); ok {
ids = append(ids, s)
}
}
}
return ToolResult{Chunks: InspectorCompareSources(chunks, ids)}
case "inspector_grep_within":
return ToolResult{Chunks: InspectorGrepWithin(chunks, stringValue(args["doc_id"]), stringValue(args["pattern"]))}
case "inspector_request_adjacent":
count := 3
if n, ok := args["count"].(float64); ok {
count = int(n)
}
return ToolResult{Chunks: InspectorRequestAdjacent(chunks, stringValue(args["chunk_id"]), stringValue(args["direction"]), count)}
}
return ToolResult{Error: "Unknown inspector tool: " + toolName}
}
// runWikiTool runs wiki_query via the runner's wiki service.
func (p *Pipeline) runWikiTool(ctx context.Context, args map[string]interface{}) ToolResult {
if p.runner == nil {
return ToolResult{}
}
query := stringValue(args["query"])
keywords := stringValue(args["keywords"])
chunks, _ := p.runner.wikiSearch(ctx, query, keywords)
return ToolResult{Chunks: chunks}
}
// runStructureTool runs ontology_navigate / mindmap_navigate via NavigateStructure.
func (p *Pipeline) runStructureTool(ctx context.Context, toolName string, args map[string]interface{}) ToolResult {
if !p.HasRoutedScope() {
return ToolResult{} // gated: requires a routed doc scope
}
topic := stringValue(args["topic"])
keywords := stringValue(args["keywords"])
var scope []string
switch v := args["doc_scope"].(type) {
case []string:
scope = v
case []interface{}:
for _, x := range v {
if s, ok := x.(string); ok {
scope = append(scope, s)
}
}
}
if len(scope) == 0 {
scope = p.scope()
}
raw, err := NavigateStructure(ctx, p.tenantID, toolName, structureNavArgs{
Topic: topic, Keywords: keywords, DocScope: scope,
})
if err != nil {
return ToolResult{Error: err.Error()}
}
var res struct {
Chunks []map[string]interface{} `json:"chunks"`
}
if err := json.Unmarshal([]byte(raw), &res); err != nil {
return ToolResult{}
}
return ToolResult{Chunks: res.Chunks}
}
// GetChunks retrieves raw chunks by global evidence id (mirrors Python
// Pipeline.get_chunks).
func (p *Pipeline) GetChunks(evidenceIDs []int) map[int]map[string]interface{} {
chunks := p.chunksSnapshot()
out := map[int]map[string]interface{}{}
for _, eid := range evidenceIDs {
if eid >= 0 && eid < len(chunks) {
out[eid] = chunks[eid]
}
}
return out
}
// implementedTools is the whitelist of tools the Pipeline can actually dispatch.
// Tools listed in a mode's AvailableTools but not here (structured_query,
// inspector_*) are NOT yet implemented in the Go harness, so they are filtered
// out to keep the LLM from calling an unknown tool.
var implementedTools = map[string]bool{
"hybrid_search": true,
"vector_search": true,
"bm25_search": true,
"web_search": true,
"dataset_navigation_by_tree": true,
"wiki_query": true,
"ontology_navigate": true,
"mindmap_navigate": true,
"graph_explore": true,
"structured_query": true,
"inspector_open_context": true,
"inspector_compare": true,
"inspector_grep_within": true,
"inspector_request_adjacent": true,
}
// AvailableTools filters a mode's tool list to (a) tools the Pipeline can
// actually dispatch, and (b) compilation-availability (mirrors Python
// filter_available_tools). When the compilation map is empty, compilation gating
// is disabled but the implementation whitelist still applies.
func (p *Pipeline) AvailableTools(modeTools []string) []string {
var out []string
for _, name := range modeTools {
if !implementedTools[name] {
continue
}
if len(p.compilation) > 0 {
if req, ok := toolCompilationRequirement(name); ok && !p.compilationSatisfied(req) {
continue
}
}
out = append(out, name)
}
return out
}
func (p *Pipeline) compilationSatisfied(wanted []string) bool {
for _, comps := range p.compilation {
for _, w := range wanted {
if comps[w] {
return true
}
}
}
return false
}
// toolCompilationRequirement returns the compilation artifact a tool needs
// (mirrors Python TOOL_REGISTRY requires_compilation / compilation_type).
func toolCompilationRequirement(name string) ([]string, bool) {
switch name {
case "ontology_navigate":
return []string{"toc", "tree", "page_index", "timeline", "raptor"}, true
case "mindmap_navigate":
return []string{"mindmap"}, true
case "graph_explore":
return []string{"graph", "knowledge_graph"}, true
case "wiki_query":
return []string{"wiki"}, true
default:
return nil, false
}
}

View File

@@ -22,12 +22,17 @@ import (
"fmt"
"log"
"strings"
"sync"
einotool "github.com/cloudwego/eino/components/tool"
"gorm.io/gorm"
"ragflow/internal/agent/tool"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
modelModule "ragflow/internal/entity/models"
"ragflow/internal/service"
"ragflow/internal/service/nav"
"ragflow/internal/service/wikisearch"
)
@@ -47,6 +52,16 @@ type ProductionRunner struct {
// runner never exposes web fallback (P8: no web provider configured => the
// agent does not attempt web search and no failing tool call is made).
webTool einotool.InvokableTool
// sqlKBs are the tabular (structured) KBs — those whose parser_config carries
// a field_map. structured_query only runs over these (mirrors RAGTools.sql_kbs).
sqlKBs []*entity.Knowledgebase
// fieldMap is the merged field_map across the tabular KBs (mirrors
// RAGTools.field_map).
fieldMap map[string]interface{}
// chatPipeline drives structured_query (useSQL). Lazily constructed, guarded
// by chatPipelineOnce against concurrent runSQLTool calls.
chatPipeline *service.ChatPipelineService
chatPipelineOnce sync.Once
}
// NewProductionRunner builds a ProductionRunner backed by the real tools. The
@@ -55,7 +70,7 @@ type ProductionRunner struct {
// API key is present), the runner also wires the web fallback tool so
// high/ultra modes can fill an empty KB result from the web; otherwise no web
// tool is attached and no web call is ever attempted (P8/R2).
func NewProductionRunner(db *gorm.DB, tenantID string, datasetIDs []string) (*ProductionRunner, error) {
func NewProductionRunner(ctx context.Context, db *gorm.DB, tenantID string, datasetIDs []string) (*ProductionRunner, error) {
searchBase, err := tool.BuildByName("hybrid_search", nil)
if err != nil {
return nil, err
@@ -68,9 +83,76 @@ func NewProductionRunner(db *gorm.DB, tenantID string, datasetIDs []string) (*Pr
if common.GetEnv(common.EnvTavilyAPIKey) != "" {
r.webTool = tool.NewTavilyTool()
}
// Partition the bound KBs into tabular (field_map-bearing) vs general,
// mirroring Python RAGTools._exclude_sql_kb. Runs under the caller's context
// so request deadlines/cancellation propagate to the lookup.
r.loadSQLKBs(ctx)
return r, nil
}
// loadSQLKBs partitions the bound KBs into tabular (structured_query) vs general
// and merges their field_map, mirroring Python RAGTools._exclude_sql_kb.
func (r *ProductionRunner) loadSQLKBs(ctx context.Context) {
if r == nil || r.db == nil {
return
}
kbs, err := dao.NewKnowledgebaseDAO().GetByIDs(ctx, r.db, r.datasetIDs)
if err != nil {
// Distinguish a database failure from "no tabular KBs": structured_query
// silently reporting no tabular KBs would otherwise mask this.
log.Printf("agentic_rag: loadSQLKBs lookup failed for %v: %v", r.datasetIDs, err)
return
}
r.fieldMap = map[string]interface{}{}
for _, kb := range kbs {
if kb == nil {
continue
}
if fm, ok := kb.ParserConfig["field_map"].(map[string]interface{}); ok && len(fm) > 0 {
for k, v := range fm {
r.fieldMap[k] = v
}
r.sqlKBs = append(r.sqlKBs, kb)
}
}
}
// runSQLTool runs structured_query: translate the query to SQL over the tabular
// KBs and return the answer + referenced chunks. Returns empty when there are no
// tabular KBs or no chat model is configured (mirrors Python structured_query's
// sql_kbs guard).
func (r *ProductionRunner) runSQLTool(ctx context.Context, args map[string]interface{}) ToolResult {
if r == nil || len(r.sqlKBs) == 0 || len(r.fieldMap) == 0 {
return ToolResult{}
}
query := stringValue(args["query"])
if strings.TrimSpace(query) == "" {
return ToolResult{}
}
// Resolve the tenant's default chat model (empty llmID → default).
driver, modelName, apiConfig, _, err := service.NewModelProviderService().GetChatModelConfig(ctx, r.tenantID, "")
if err != nil {
return ToolResult{}
}
chatModel := modelModule.NewChatModel(driver, &modelName, apiConfig)
r.chatPipelineOnce.Do(func() {
r.chatPipeline = service.NewChatPipelineService()
})
ans, err := r.chatPipeline.StructuredQuery(ctx, &entity.Chat{TenantID: r.tenantID}, r.sqlKBs, query, chatModel, r.fieldMap)
if err != nil || ans == nil {
return ToolResult{}
}
answer := stringValue(ans["answer"])
ref, _ := ans["reference"].(map[string]interface{})
var chunks []map[string]interface{}
if ref != nil {
if c, ok := ref["chunks"].([]map[string]interface{}); ok {
chunks = c
}
}
return ToolResult{Answer: answer, Chunks: chunks}
}
// 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.
@@ -108,9 +190,51 @@ func (r *ProductionRunner) Run(ctx context.Context, question, keywords, modeLabe
if route.SuggestsCompilation == "wiki" && r.wikiAvailable(ctx) {
searchFn = r.wikiPreferredSearchFn(searchFn)
}
// high/ultra (agentic_research / deep_research) drive the two-level research
// loop through the Pipeline (real tools + compilation gating + doc routing),
// not the SearchFn closure used by low/medium. This is the P5 strategy
// dispatch: high/ultra is a strict superset of medium.
if route.ExecutionStrategy == "agentic_research" || route.ExecutionStrategy == "deep_research" {
return r.runAgentic(ctx, question, keywords, modeLabel, route)
}
return RunAgenticRAGWithRoute(ctx, r.db, question, keywords, modeLabel, route, searchFn)
}
// runAgentic drives the high/ultra two-level loop over a Pipeline. It shares the
// same route/planner as RunAgenticRAGWithRoute but researches claims via the
// research agent (inner tool loop) rather than a single hybrid search.
func (r *ProductionRunner) runAgentic(ctx context.Context, question, keywords, modeLabel string, route RouteDecision) AnswerResult {
mode, _ := GetMode(modeLabel)
if mode.Label == "" {
mode = THINKING_MODES["high"]
}
kbinfos := &Kbinfos{}
// pre_search grounds the planner (same as the medium path).
chunks, aggs := r.hybridSearchFn(ctx, question, keywords, modeLabel)(ctx, question, keywords)
seed := extractChunkTexts(chunks)
kbinfos.Merge(chunks, aggs)
plan := PlannerNode(ctx, r.db, route, seed)
claims := make([]*ClaimTarget, len(plan.Claims))
for i := range plan.Claims {
claims[i] = &plan.Claims[i]
}
compilation := buildCompilationMap(ctx, r.db, r.tenantID, r.datasetIDs)
pipeline := NewPipeline(r.db, r.tenantID, r.datasetIDs, r, kbinfos, compilation)
orch := AgenticResearch(ctx, r.db, pipeline, question, claims, mode)
return FormalizeAnswer(ctx, r.db, question, orch.Kbinfos, orch.PartialAnswer, orch.Abstain, orch.EmptyResult, orch.Caveat, orch.ForceLLM)
}
// buildCompilationMap reports which compiled artifacts each bound KB carries,
// so the Pipeline can gate compilation-requiring tools (ontology/mindmap/graph/
// wiki). It mirrors Python _get_compilation_map (parser_config toggles + dataset
// nav rows). A nil/empty map disables gating (all tools pass through).
// modeAllowsWeb reports whether the mode's AvailableTools include web_search, so
// web fallback is only reachable in the modes that are supposed to have it
// (high/ultra). Unknown modes are treated as not allowing web.
@@ -334,42 +458,7 @@ func (r *ProductionRunner) webFallbackFn(hybrid SearchFn) SearchFn {
if err != nil {
return nil, nil
}
var res struct {
Chunks []map[string]interface{} `json:"chunks"`
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal([]byte(raw), &res); err != nil {
return nil, nil
}
// Normalize web evidence into the same agentic evidence shape as KB
// chunks. Accept both the agent "chunks" envelope and the Tavily
// "results" envelope (tavily.go returns {"results":[...]}); each result
// contributes content + a doc_id reference so the answer can retain the
// source URL.
src := res.Chunks
if len(src) == 0 {
src = res.Results
}
out := make([]map[string]interface{}, 0, len(src))
for _, c := range src {
url := firstNonEmpty(stringValue(c["url"]), stringValue(c["link"]), stringValue(c["source"]))
if url == "" {
continue
}
content := firstNonEmpty(stringValue(c["content"]), stringValue(c["raw_content"]), stringValue(c["text"]))
if content == "" {
continue
}
docID := stringValue(c["doc_id"])
if docID == "" {
docID = url + "|" + stringValue(c["source"])
}
out = append(out, map[string]interface{}{
"chunk_id": docID, "content_with_weight": content,
"doc_id": docID, "docnm_kwd": firstNonEmpty(stringValue(c["title"]), stringValue(c["source"])),
"dataset_id": stringValue(c["dataset_id"]), "url": url, "source": "web",
})
}
out := normalizeWebResults([]byte(raw))
if len(out) == 0 {
return nil, nil
}
@@ -377,6 +466,50 @@ func (r *ProductionRunner) webFallbackFn(hybrid SearchFn) SearchFn {
}
}
// normalizeWebResults parses the web provider's raw JSON and normalizes it into
// the same agentic evidence shape as KB chunks. Accepts both the agent "chunks"
// envelope and the Tavily "results" envelope (tavily.go returns
// {"results":[...]}); each result contributes content + a doc_id reference so
// the answer can retain the source URL.
func normalizeWebResults(raw []byte) []map[string]interface{} {
var res struct {
Chunks []map[string]interface{} `json:"chunks"`
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return nil
}
src := res.Chunks
if len(src) == 0 {
src = res.Results
}
out := make([]map[string]interface{}, 0, len(src))
for i, c := range src {
url := firstNonEmpty(stringValue(c["url"]), stringValue(c["link"]), stringValue(c["source"]))
if url == "" {
continue
}
content := firstNonEmpty(stringValue(c["content"]), stringValue(c["raw_content"]), stringValue(c["text"]))
if content == "" {
continue
}
docID := stringValue(c["doc_id"])
if docID == "" {
docID = url
}
// doc_id stays the source URL; chunk_id must be UNIQUE per snippet so
// Kbinfos.Merge (dedup by chunkKey) does not collapse several snippets
// from the same URL, or the same URL across two retrieval rounds.
chunkID := fmt.Sprintf("%s#%d", docID, i)
out = append(out, map[string]interface{}{
"chunk_id": chunkID, "content_with_weight": content,
"doc_id": docID, "docnm_kwd": firstNonEmpty(stringValue(c["title"]), stringValue(c["source"])),
"dataset_id": stringValue(c["dataset_id"]), "url": url, "source": "web",
})
}
return out
}
func stringValue(v interface{}) string {
if s, ok := v.(string); ok {
return s

View File

@@ -0,0 +1,461 @@
//
// 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"
"regexp"
"strings"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
"ragflow/internal/agent/chat"
)
// Research agent — inner tool-calling loop for high/ultra modes (mirrors Python
// harness/agent.py research_agent_loop + _research_text). The Go chat invoker has
// no native bind_tools, so we implement the *text-fallback* path: the tools are
// described in the prompt and the model emits `<tool_call>` JSON that the loop
// parses and routes to the harness Pipeline. generate_report is captured (not
// executed) and returned as the claim result.
// Tool-call extraction patterns, compiled once at package scope. The (?s) flag
// makes `.` match newlines so a multi-line JSON body (as the prompt shows for
// generate_report) still parses.
var (
reToolCallTag = regexp.MustCompile(`(?s)<tool_call>(.*?)</tool_call>`)
reToolCallFence = regexp.MustCompile("(?s)```(?:json)?\\s*(\\{.*?\\})\\s*```")
reToolCallBare = regexp.MustCompile(`\{\s*"name"\s*:`)
)
// researchAgentTextPrompt mirrors rag/prompts/research_agent_prompt.py
// RESEARCH_AGENT_TEXT_PROMPT.
const researchAgentTextPrompt = `You are a research assistant. For the given research task, use the available tools to search for information.
Research task: %s
Current phase: %s
Phase hint: %s
Available tools:
%s
Rules:
1. Go coarse-to-fine: first narrow the corpus with navigation tools, then search only if needed.
2. After a navigation tool returns passages, judge whether they already answer the task.
If they do, call generate_report immediately instead of searching further.
3. Only use hybrid_search / broad search tools when navigation did not yield sufficient evidence.
4. Use think_tool to analyze results after each step.
5. When you are confident enough to answer the research task, call generate_report.
ATTRIBUTE FIDELITY (CRITICAL):
Answer the EXACT attribute/relation the research task asks for. Do NOT substitute a similar
but different attribute (e.g. do not report HOMETOWN as BIRTHPLACE, do not swap "first" for
"largest", "age at death" for "birth year"). In SEARCH queries you MAY use synonymous,
translated, or corpus-specific terms as long as they still TARGET the exact requested
attribute. The REPORT must state the exact attribute asked for and never present a different
attribute's value as the answer. If the exact attribute cannot be found in evidence, mark the
claim unverified and list it in gaps.
SOURCE ANCHORING (CRITICAL):
If the research task names a specific source, treat THAT named source as authoritative and
retrieve the value from it. Do NOT substitute another source's value as the answer. If the
named source is found, its value wins. Only if it cannot be located may you fall back, and
then say so in the report and lower confidence.
Tool call format: output exactly one JSON tool call per round:
<tool_call>{"name": "tool_name", "arguments": {"parameter_name": "value"}}</tool_call>
generate_report argument format:
{
"report": "Research result report, factual and unformatted",
"is_verified": true/false,
"confidence": 0.0-1.0,
"evidence_ids": [0, 3],
"gaps": ["Information that was not found"],
"grounded": ["answer-critical fact verbatim from evidence"],
"numbers": ["<figure> from <source/context>"],
"discovered_claims": ["New research directions discovered during research"]
}
Maximum %d rounds. Output one <tool_call> tag in each round and no other text.`
// navChunkTools mirror Python _NAV_CHUNK_TOOLS: navigation tools that return
// passages, after which we judge sufficiency before broadening into a search.
var navChunkTools = map[string]bool{"ontology_navigate": true, "mindmap_navigate": true}
// researchReport is the normalized generate_report output (mirrors Python
// _generate_report_schema fields).
type researchReport struct {
Report string `json:"report"`
IsVerified bool `json:"is_verified"`
Confidence float64 `json:"confidence"`
EvidenceIDs []int `json:"evidence_ids"`
Gaps []string `json:"gaps"`
Grounded []string `json:"grounded"`
Numbers []string `json:"numbers"`
DiscoveredClaims []string `json:"discovered_claims"`
}
// researchSession accumulates evidence ids across a single claim's tool loop
// (mirrors Python ResearchToolSession).
type researchSession struct {
pipeline *Pipeline
evidenceIDs []int
seenIDs map[int]bool
}
// runTool executes one tool call through the pipeline and records evidence.
func (s *researchSession) runTool(ctx context.Context, db *gorm.DB, name string, args map[string]interface{}) (string, bool) {
res := s.pipeline.Execute(ctx, name, args)
if res.Error != "" {
return "[tool error] " + res.Error, false
}
// Record the GLOBAL indices Execute captured under mu, so evidence_ids stay
// stable across concurrent claims (re-indexing the shared slice after the
// lock is released would race with other goroutines' merges).
if len(res.EvidenceIndices) > 0 {
s.recordEvidence(res.EvidenceIndices)
}
return formatToolResult(res), len(res.Chunks) > 0
}
// recordEvidence records the global evidence indices Execute already resolved,
// de-duplicating against this session's seen set.
func (s *researchSession) recordEvidence(indices []int) {
for _, idx := range indices {
if s.seenIDs[idx] {
continue
}
s.seenIDs[idx] = true
s.evidenceIDs = append(s.evidenceIDs, idx)
}
}
// formatToolResult mirrors Python _fmt_tool_result: surface the direct answer
// and up to 6 chunks ordered by similarity, truncated.
func formatToolResult(res ToolResult) string {
parts := []string{}
if res.Answer != "" {
parts = append(parts, "Answer: "+res.Answer)
}
chunks := append([]map[string]interface{}(nil), res.Chunks...)
// Sort by similarity descending (best matches first).
for i := 0; i < len(chunks); i++ {
for j := i + 1; j < len(chunks); j++ {
if chunkSim(chunks[j]) > chunkSim(chunks[i]) {
chunks[i], chunks[j] = chunks[j], chunks[i]
}
}
}
for i, c := range chunks {
if i >= 6 {
break
}
text := chunkText(c)
if text == "" {
continue
}
if len(text) > 300 {
text = text[:300]
}
parts = append(parts, text)
}
if len(parts) == 0 {
return "[no results found]"
}
return strings.Join(parts, "\n\n")
}
func chunkSim(c map[string]interface{}) float64 {
if v, ok := c["similarity"].(float64); ok {
return v
}
if v, ok := c["similarity"].(float32); ok {
return float64(v)
}
return 0
}
// ResearchAgentLoop runs the inner tool loop for one claim (mirrors Python
// research_agent_loop → _research_text). It returns the claim result as an
// AgentResult.
func ResearchAgentLoop(ctx context.Context, db *gorm.DB, pipeline *Pipeline, claim ClaimTarget, mode ExecutionStrategy, followups []string) AgentResult {
inv := chat.GetDefaultInvoker()
if inv == nil {
return AgentResult{ClaimID: claim.ClaimID, IsVerified: false, Confidence: 0, Gaps: []string{"no chat invoker configured"}}
}
phase := determinePhase(pipeline)
phaseHint := phaseHintFor(phase)
toolList := formatToolList(pipeline.AvailableTools(mode.AvailableTools))
system := fmt.Sprintf(researchAgentTextPrompt,
claim.Description, phase, phaseHint, toolList, mode.MaxAgentCycles)
history := []schema.Message{}
if len(followups) > 0 {
history = append(history, schema.Message{Role: schema.User, Content: "Previous evidence was incomplete. Run targeted searches specifically for the following missing pieces:\n- " + strings.Join(followups, "\n- ")})
}
session := &researchSession{pipeline: pipeline, seenIDs: map[int]bool{}}
for cycle := 0; cycle < mode.MaxAgentCycles; cycle++ {
if err := ctx.Err(); err != nil {
return AgentResult{ClaimID: claim.ClaimID, IsVerified: false, Confidence: 0, Gaps: []string{"research cancelled: " + err.Error()}}
}
msgs := make([]schema.Message, 0, len(history)+1)
msgs = append(msgs, schema.Message{Role: schema.System, Content: system})
msgs = append(msgs, history...)
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: msgs,
Temperature: floatPtr(0.3),
})
if err != nil {
if ctx.Err() != nil {
return AgentResult{ClaimID: claim.ClaimID, IsVerified: false, Confidence: 0, Gaps: []string{"research cancelled: " + ctx.Err().Error()}}
}
continue
}
ans := resp.Content
history = append(history, schema.Message{Role: schema.Assistant, Content: ans})
toolCall := parseToolCall(ans)
if toolCall == nil {
history = append(history, schema.Message{Role: schema.User, Content: "Please call a tool. Do not output plain text."})
continue
}
name, _ := toolCall["name"].(string)
if name == "generate_report" {
args, _ := toolCall["arguments"].(map[string]interface{})
return reportToAgentResult(claim.ClaimID, args, session.evidenceIDs)
}
if name == "think_tool" {
history = append(history, schema.Message{Role: schema.User, Content: "[continue]"})
continue
}
args, _ := toolCall["arguments"].(map[string]interface{})
resultText, gotEvidence := session.runTool(ctx, db, name, args)
// Post-navigation sufficiency gate: steer to finalize when nav passages
// already answer (best-effort; the LLM judge is skipped for determinism).
msg := resultText
if navChunkTools[name] && gotEvidence {
msg += "\n\n[sufficiency check] These passages may answer the task. If so, call generate_report now — do not run further searches."
}
history = append(history, schema.Message{Role: schema.User, Content: msg})
}
// Max cycles reached without generate_report — force a final report.
return forceGenerateReport(ctx, db, inv, claim.ClaimID, history, session)
}
// forceGenerateReport mirrors Python _force_generate_report.
func forceGenerateReport(ctx context.Context, db *gorm.DB, inv chat.Invoker, claimID string, history []schema.Message, session *researchSession) AgentResult {
if err := ctx.Err(); err != nil {
return AgentResult{ClaimID: claimID, IsVerified: false, Confidence: 0, Gaps: []string{"research cancelled: " + err.Error()}}
}
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: append(append([]schema.Message{}, history...),
schema.Message{Role: schema.User, Content: "We've reached the research limit. Please output a final report as JSON."}),
Temperature: floatPtr(0.3),
})
if err != nil {
return AgentResult{ClaimID: claimID, IsVerified: false, Confidence: 0, Gaps: []string{"forced report failed: " + err.Error()}}
}
var rep researchReport
if err := unmarshalModelJSON(resp.Content, &rep); err != nil {
return AgentResult{ClaimID: claimID, IsVerified: false, Confidence: 0, Gaps: []string{"forced report — data may be incomplete"}}
}
return reportToAgentResult(claimID, map[string]interface{}{
"report": rep.Report, "is_verified": rep.IsVerified, "confidence": rep.Confidence,
"evidence_ids": rep.EvidenceIDs, "gaps": rep.Gaps, "grounded": rep.Grounded,
"numbers": rep.Numbers, "discovered_claims": rep.DiscoveredClaims,
}, session.evidenceIDs)
}
// reportToAgentResult normalizes a generate_report argument map into an
// AgentResult, backfilling evidence ids when the model omitted them.
func reportToAgentResult(claimID string, args map[string]interface{}, sessionEvidenceIDs []int) AgentResult {
report, _ := args["report"].(string)
isVerified, _ := args["is_verified"].(bool)
confidence := floatVal(args["confidence"])
evidenceIDs := intSlice(args["evidence_ids"])
if len(evidenceIDs) == 0 {
evidenceIDs = append([]int(nil), sessionEvidenceIDs...)
}
gaps := strSlice(args["gaps"])
grounded := strSlice(args["grounded"])
numbers := strSlice(args["numbers"])
discovered := strSlice(args["discovered_claims"])
return AgentResult{
ClaimID: claimID, Report: report, IsVerified: isVerified, Confidence: confidence,
EvidenceIDs: evidenceIDs, Gaps: gaps, Grounded: grounded, Numbers: numbers,
DiscoveredClaims: discovered,
}
}
// parseToolCall extracts a tool-call JSON from the model output (mirrors Python
// _parse_tool_call): <tool_call>…</tool_call>, ```json … ```, or bare {"name":…}.
func parseToolCall(text string) map[string]interface{} {
// <tool_call>…</tool_call>
if m := reToolCallTag.FindStringSubmatch(text); m != nil {
if v := tryJSON(m[1]); v != nil {
return v
}
}
// ```json … ```
if m := reToolCallFence.FindStringSubmatch(text); m != nil {
if v := tryJSON(m[1]); v != nil {
return v
}
}
// bare {"name": …}
if m := reToolCallBare.FindStringIndex(text); m != nil {
if v := tryJSON(text[m[0]:]); v != nil {
return v
}
}
return nil
}
func tryJSON(s string) map[string]interface{} {
var out map[string]interface{}
if err := json.Unmarshal([]byte(strings.TrimSpace(s)), &out); err == nil {
return out
}
return nil
}
func floatVal(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
}
return 0
}
func intSlice(v interface{}) []int {
switch x := v.(type) {
case []int:
return x
case []interface{}:
var out []int
for _, item := range x {
switch n := item.(type) {
case int:
out = append(out, n)
case float64:
out = append(out, int(n))
}
}
return out
}
return nil
}
func strSlice(v interface{}) []string {
switch x := v.(type) {
case []string:
return x
case []interface{}:
var out []string
for _, item := range x {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
func floatPtr(v float64) *float64 { return &v }
// determinePhase mirrors Python determine_current_phase (simplified: no verdict).
func determinePhase(p *Pipeline) string {
if p == nil || !p.kbinfos.HasChunks() {
return "locate"
}
return "explore"
}
func phaseHintFor(phase string) string {
switch phase {
case "locate":
return "Prefer navigation tools to locate document regions before directly searching keywords."
case "explore":
return "Prefer retrieval tools to gather detailed information within the located region."
default:
return ""
}
}
// formatToolList mirrors Python _fmt_tool_list: name + description + params.
func formatToolList(tools []string) string {
var lines []string
for _, name := range tools {
lines = append(lines, "- "+name+": "+toolDescription(name))
}
return strings.Join(lines, "\n")
}
// toolDescription returns a one-line description for prompt tool listing.
func toolDescription(name string) string {
switch name {
case "hybrid_search":
return "Hybrid (vector + keyword) search over the knowledge base."
case "vector_search":
return "Dense-vector semantic search over the knowledge base."
case "bm25_search":
return "Lexical BM25 keyword search over the knowledge base."
case "dataset_navigation_by_tree":
return "Navigate the document tree to locate relevant documents."
case "ontology_navigate":
return "Navigate a document's structure/outline to find relevant sections."
case "mindmap_navigate":
return "Navigate a mindmap-structured document."
case "wiki_query":
return "Query the compiled wiki pages."
case "graph_explore":
return "Explore the knowledge graph around a discovered entity."
case "web_search":
return "Search the web for external/current facts."
case "inspector_open_context":
return "Expand context around an already-returned chunk (2 neighbours each side)."
case "inspector_compare":
return "List the document sources of given chunk ids."
case "inspector_grep_within":
return "Find a keyword within a document and narrow its chunks to matching sentences."
case "inspector_request_adjacent":
return "Get adjacent chunks before/after a given chunk."
default:
return "Search tool."
}
}

View File

@@ -0,0 +1,106 @@
//
// 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 "testing"
// TestExtractCJKSubstrings_NoPanic guards the RE2-compatible CJK regex: the
// previous `\u4e00` escape would panic at compile time (review fix R14).
func TestExtractCJKSubstrings_NoPanic(t *testing.T) {
got := extractCJKSubstrings("巴黎是法国的首都,位于欧洲。")
if len(got) == 0 {
t.Fatal("CJK substrings must be extracted (and must not panic)")
}
for _, s := range got {
if !isCJK(s) {
t.Errorf("extracted %q is not CJK", s)
}
}
}
// TestExtractNamedEntities_LatinNil asserts Latin text yields nil (NER delegated
// to the LLM grounded review).
func TestExtractNamedEntities_LatinNil(t *testing.T) {
if got := extractNamedEntities("Paris is the capital"); got != nil {
t.Errorf("Latin text must yield nil (NER delegated), got %v", got)
}
}
// TestParseToolCall_Multiline asserts a multi-line generate_report body parses
// (review fix R13: the (?s) flag makes `.` span newlines).
func TestParseToolCall_Multiline(t *testing.T) {
text := `<tool_call>{"name": "generate_report", "arguments": {
"report": "Paris has 2 million people",
"is_verified": true,
"confidence": 0.9,
"evidence_ids": [0, 3],
"gaps": []
}}</tool_call>`
call := parseToolCall(text)
if call == nil {
t.Fatal("multi-line tool call must parse")
}
if call["name"] != "generate_report" {
t.Errorf("name = %v, want generate_report", call["name"])
}
args, _ := call["arguments"].(map[string]interface{})
if args["report"] != "Paris has 2 million people" {
t.Errorf("report = %v", args["report"])
}
}
// TestParseToolCall_Fence asserts the fenced-JSON path still works.
func TestParseToolCall_Fence(t *testing.T) {
text := "```json\n{\"name\":\"hybrid_search\",\"arguments\":{\"query\":\"rocket\"}}\n```"
call := parseToolCall(text)
if call == nil || call["name"] != "hybrid_search" {
t.Fatalf("fenced tool call = %v", call)
}
}
// TestNormalizeWebResults_UniqueChunkID asserts several snippets from the SAME
// URL get distinct chunk_id values so Kbinfos.Merge does not collapse them
// (review fix R11).
func TestNormalizeWebResults_UniqueChunkID(t *testing.T) {
raw := []byte(`{"results":[
{"url":"https://a","content":"snippet one","title":"A"},
{"url":"https://a","content":"snippet two","title":"A"}
]}`)
out := normalizeWebResults(raw)
if len(out) != 2 {
t.Fatalf("normalizeWebResults = %d results, want 2 (distinct snippets)", len(out))
}
if chunkIDOf(out[0]) == chunkIDOf(out[1]) {
t.Errorf("chunk_id collision: %q == %q", chunkIDOf(out[0]), chunkIDOf(out[1]))
}
// doc_id stays the URL.
if docIDOf(out[0]) != "https://a" || docIDOf(out[1]) != "https://a" {
t.Errorf("doc_id must stay the URL, got %q / %q", docIDOf(out[0]), docIDOf(out[1]))
}
}
// TestChunkText_TextFallback asserts the "text" field is used when neither
// content_with_weight nor content is present (review fix: _evidence_md parity).
func TestChunkText_TextFallback(t *testing.T) {
if got := chunkText(map[string]interface{}{"text": "plain text field"}); got != "plain text field" {
t.Errorf("chunkText(text) = %q, want the text field", got)
}
// content_with_weight still wins over text.
if got := chunkText(map[string]interface{}{"content_with_weight": "cw", "text": "plain"}); got != "cw" {
t.Errorf("chunkText must prefer content_with_weight, got %q", got)
}
}

View File

@@ -0,0 +1,72 @@
//
// 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 (
"reflect"
"testing"
)
// TestSplitSentences_TerminatorsKept asserts terminators stay attached and empty
// segments are dropped.
func TestSplitSentences_TerminatorsKept(t *testing.T) {
got := splitSentences("Paris has people. It is in France! Really?")
want := []string{"Paris has people.", "It is in France!", "Really?"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSentences = %#v, want %#v", got, want)
}
}
// TestSplitSentences_DecimalGuard asserts "3.14" / "v1.2" do not split.
func TestSplitSentences_DecimalGuard(t *testing.T) {
got := splitSentences("Value is 3.14 today. Version v1.2 released.")
if len(got) != 2 {
t.Fatalf("splitSentences = %#v, want 2 sentences (decimals not split)", got)
}
if got[0] != "Value is 3.14 today." {
t.Errorf("first = %q, want decimal intact", got[0])
}
}
// TestSplitSentences_CJK asserts Chinese terminators split and are kept.
func TestSplitSentences_CJK(t *testing.T) {
got := splitSentences("巴黎有两百万人。它在法国!")
want := []string{"巴黎有两百万人。", "它在法国!"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitSentences(CJK) = %#v, want %#v", got, want)
}
}
// TestSplitSentences_TableAtomic asserts a markdown table is one atomic sentence.
func TestSplitSentences_TableAtomic(t *testing.T) {
text := "Intro here.\n| a | b |\n| - | - |\n| 1 | 2 |\nAfter table."
got := splitSentences(text)
if len(got) != 3 {
t.Fatalf("splitSentences(table) = %#v, want 3 (table atomic)", got)
}
// The table block must be a single sentence.
if got[1] != "| a | b |\n| - | - |\n| 1 | 2 |" {
t.Errorf("table sentence = %q, want atomic table", got[1])
}
}
// TestSplitSentences_Empty asserts empty input yields nil.
func TestSplitSentences_Empty(t *testing.T) {
if got := splitSentences(""); got != nil {
t.Errorf("splitSentences(\"\") = %#v, want nil", got)
}
}

View File

@@ -19,36 +19,193 @@ package harness
import (
"fmt"
"regexp"
"sort"
"strconv"
"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.
//
// Cross-check alignment (Sufficient Context redesign, mirrors sufficiency.py):
// - number extraction + noise filtering (drop 0<n<1, dedup)
// - union evidence matching (a fact verified by ONE chunk is verified), not
// the old per-chunk loop
// - bounded word/phrase matching (Ann must not match Annual)
// - numeric multi-source conflict detection (close-but-different figures)
// - cross score = matches/total, pass at >= 0.5
//
// NER is intentionally NOT implemented in Go (no maintained native spaCy-level
// NER library; see plan §cross-check). Entity extraction is delegated to the
// LLM grounded review (grounded_llm.go). TODO(candle): adopt
// github.com/huggingface/candle via Rust↔Go binding + model conversion to
// restore spaCy-parity multilingual NER (en/zh/de/fr/es/pt/ja).
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)
}
// reCJKRun matches a run of CJK characters (RE2 requires \x{...} escapes; the
// Python-style \u escapes would panic at compile time).
var reCJKRun = regexp.MustCompile(`[\x{4e00}-\x{9fff}]+`)
// 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)
// extractNumbers returns numeric values found in text (mirrors Python
// extract_numbers).
func extractNumbers(text string) []float64 {
var out []float64
for _, m := range reNumber.FindAllString(text, -1) {
if f, err := strconv.ParseFloat(m, 64); err == nil {
out = append(out, f)
}
}
return out
}
// filterRelevantNumbers drops numbers with no factual-claim signal (mirrors
// Python _filter_relevant_numbers): values in (0,1) are ratios/confidences, and
// duplicates are checked once.
func filterRelevantNumbers(numbers []float64) []float64 {
var kept []float64
for _, n := range numbers {
if n > 0 && n < 1 {
continue
}
dup := false
for _, k := range kept {
if k == n {
dup = true
break
}
}
if !dup {
kept = append(kept, n)
}
}
return kept
}
// isCJK reports whether text contains Chinese/Japanese characters (mirrors
// Python _is_cjk).
func isCJK(text string) bool {
for _, r := range text {
if r >= '\u4e00' && r <= '\u9fff' {
return true
}
}
return false
}
// extractNamedEntities is a degraded stub: CJK substrings are still extracted
// (they need no word boundaries), but Latin-script NER is delegated to the LLM
// grounded review. See the TODO(candle) note in the package comment.
func extractNamedEntities(text string) []string {
if text == "" {
return nil
}
// CJK entities: every Han run is a candidate; the LLM review disambiguates.
// This keeps number-adjacent Chinese evidence verifiable without spaCy.
if isCJK(text) {
return extractCJKSubstrings(text)
}
return nil
}
// extractCJKSubstrings returns runs of CJK characters as entity candidates.
// This is intentionally coarse (no NER): it only preserves the substring-match
// path for CJK evidence that the union matcher below can still use.
func extractCJKSubstrings(text string) []string {
seen := map[string]bool{}
var out []string
for _, m := range reCJKRun.FindAllString(text, -1) {
// Skip single-char connective tissue noise; keep meaningful runs.
if len([]rune(m)) < 2 || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
// boundedPhraseMatch mirrors Python's bounded word/phrase match
// `(?<![\w])needle(?![\w])`: the needle must not be adjacent to a word char on
// either side, so "Ann" does not match "Annual".
func boundedPhraseMatch(text, needle string) bool {
text = strings.ToLower(text)
needle = strings.ToLower(needle)
start := 0
for {
idx := strings.Index(text[start:], needle)
if idx < 0 {
return false
}
idx += start
before := idx > 0 && isWordChar(rune(text[idx-1]))
after := idx+len(needle) < len(text) && isWordChar(rune(text[idx+len(needle)]))
if !before && !after {
return true
}
start = idx + 1
}
}
// isWordChar mirrors Python \w (word chars are also bounded by the match).
func isWordChar(r rune) bool {
if r >= '0' && r <= '9' {
return true
}
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
return true
}
return r == '_'
}
// detectNumericConflict mirrors Python _detect_numeric_conflict: flag pairs of
// disclosed figures that are close-but-not-equal (ratio in (1, 1.3]) — the
// signature of a multi-source口径 conflict.
func detectNumericConflict(disclosed []string) []string {
type fig struct {
val float64
text string
}
reLead := regexp.MustCompile(`([\d][\d,]*(?:\.\d+)?)`)
var figures []fig
for _, entry := range disclosed {
m := reLead.FindStringSubmatch(entry)
if m == nil {
continue
}
v, err := strconv.ParseFloat(strings.ReplaceAll(m[1], ",", ""), 64)
if err != nil {
continue
}
text := entry
if rs := []rune(text); len(rs) > 80 {
text = string(rs[:80]) // Python entry[:80] is a character truncation
}
figures = append(figures, fig{val: v, text: text})
}
var conflicts []string
for i := 0; i < len(figures); i++ {
for j := i + 1; j < len(figures); j++ {
a, b := figures[i].val, figures[j].val
if a <= 0 || b <= 0 {
continue
}
hi, lo := a, b
if lo > hi {
hi, lo = b, a
}
ratio := hi / lo
if ratio > 1 && ratio <= 1.3 {
conflicts = append(conflicts, fmt.Sprintf("%s vs %s", figures[i].text, figures[j].text))
}
}
}
return conflicts
}
// CrossCheckClaim performs a code-level cross-check of an agent result against
// the accumulated evidence chunks (number matching + entity presence).
// the accumulated evidence chunks (number matching + CJK 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"}}
@@ -56,14 +213,16 @@ func CrossCheckClaim(agent *AgentResult, allChunks map[int]map[string]interface{
if !agent.IsVerified {
return ClaimCrossCheckResult{ClaimID: agent.ClaimID, CrossCheckPassed: false, Mismatches: []string{"agent self-reported as unverified"}}
}
numbers := extractNumbers(agent.Report)
rawNumbers := extractNumbers(agent.Report)
numbers := filterRelevantNumbers(rawNumbers)
entities := extractNamedEntities(agent.Report)
var matches, mismatches []string
// Gather evidence chunk texts (union) — a fact supported by ONE chunk is
// verified; the old per-chunk loop demanded every chunk confirm every fact.
var chunkTexts []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 := ""
@@ -72,73 +231,193 @@ func CrossCheckClaim(agent *AgentResult, allChunks map[int]map[string]interface{
} 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))
chunkTexts = append(chunkTexts, text)
}
// Numeric multi-source conflict detection: several close-but-different
// disclosed figures cap the claim below the pass floor.
if disclosed := agent.Numbers; len(disclosed) > 0 {
if conflict := detectNumericConflict(disclosed); len(conflict) > 0 {
var m []string
for _, c := range conflict {
m = append(m, "numeric source conflict: "+c)
}
return ClaimCrossCheckResult{ClaimID: agent.ClaimID, CrossCheckPassed: false, CrossCheckScore: 0.0, Mismatches: m, HasEvidence: len(chunkTexts) > 0}
}
}
// 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
anywhere := func(needle string) bool {
for _, t := range chunkTexts {
if strings.Contains(t, needle) {
return true
}
}
return false
}
var matches, mismatches []string
for _, num := range numbers {
// Numbers extracted as floats ("1976" → 1976.0) while chunk text spells
// "1976" — match both raw and integral forms, bounded.
var forms []string
if num == float64(int64(num)) {
forms = []string{strconv.FormatFloat(num, 'f', -1, 64), strconv.FormatInt(int64(num), 10)}
} else {
forms = []string{strconv.FormatFloat(num, 'f', -1, 64)}
}
found := false
for _, f := range forms {
if anyBounded(chunkTexts, f) {
found = true
break
}
}
if found {
matches = append(matches, fmt.Sprintf("number %s found in evidence", strconv.FormatFloat(num, 'f', -1, 64)))
} else {
mismatches = append(mismatches, fmt.Sprintf("number %s not found in any evidence chunk", strconv.FormatFloat(num, 'f', -1, 64)))
}
}
for _, ent := range entities {
found := false
if isCJK(ent) {
found = anywhere(strings.ToLower(ent))
} else {
found = anyBounded(chunkTexts, strings.ToLower(ent))
}
if found {
matches = append(matches, fmt.Sprintf("entity '%s' found in evidence", ent))
} else {
mismatches = append(mismatches, fmt.Sprintf("entity '%s' not found in any evidence chunk", ent))
}
}
total := len(matches) + len(mismatches)
crossScore := 0.0
if total > 0 {
crossScore = float64(len(matches)) / float64(total)
hasEvidence := len(chunkTexts) > 0
if total == 0 {
// No evidence examined → fail; ids but nothing extractable → neutral 0.5.
if len(agent.EvidenceIDs) == 0 {
return ClaimCrossCheckResult{ClaimID: agent.ClaimID, CrossCheckPassed: false, CrossCheckScore: 0.0, Mismatches: []string{"no evidence"}, HasEvidence: false}
}
return ClaimCrossCheckResult{ClaimID: agent.ClaimID, CrossCheckPassed: false, CrossCheckScore: 0.5, Mismatches: []string{"nothing extractable to cross-check"}, HasEvidence: hasEvidence}
}
// 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
crossScore := float64(len(matches)) / float64(total)
crossPassed := crossScore >= 0.5
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
// anyBounded reports whether needle appears in any chunk text with word
// boundaries (mirrors Python `(?<![\w])needle(?![\w])`).
func anyBounded(chunkTexts []string, needle string) bool {
for _, t := range chunkTexts {
if boundedPhraseMatch(t, needle) {
return true
}
}
return false
}
// sortedKeys returns sorted map keys (stable ordering for hard_violations).
func sortedKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// ComputeFusionScore extracts sufficiency *signals* (no longer a weighted
// fusion), mirroring Python compute_fusion_score. The LLM AutoRater is the
// primary judge; this function only produces the code-level inputs the decision
// ladder consumes:
// - hard_violations: claims with a proven evidence gap (weak cross-check OR a
// missing required entity) that veto "good enough";
// - agent_confidence: mean self-confidence over the trusted subset;
// - has_conflicts / missing_claims: surfaced for the ladder / caveat.
//
// question / claims / allChunks drive the required-entity AND-semantics veto
// (NER-degraded: see extractNamedEntities TODO). When omitted, only the
// report-based cross-check signals are produced.
func ComputeFusionScore(agentResults []AgentResult, crossResults []ClaimCrossCheckResult, mode ExecutionStrategy, question string, claims []ClaimTarget, allChunks map[int]map[string]interface{}) SufficiencyVerdict {
// ── Required-entity gaps (Sufficient Context paper, AND semantics) ──
// NER-degraded: with spaCy NER absent in Go, entity extraction yields only
// CJK substrings, so this veto is partial for Latin-script questions. The
// LLM grounded review (grounded_llm.go) covers the rest. TODO(candle).
requiredGaps := map[string][]string{}
if question != "" || len(claims) > 0 {
requiredGaps = requiredEntityGaps(question, claims, allChunks)
}
gappedIDs := map[string]bool{}
for cid := range requiredGaps {
gappedIDs[cid] = true
}
// Signal A: agent self-assessed confidence (continuous). Only self-verified,
// non-gapped claims count; a gapped claim's confidence is zeroed so it cannot
// mask an evidence gap.
var verified []AgentResult
for _, r := range agentResults {
if r.IsVerified {
verified++
if r.IsVerified && !gappedIDs[r.ClaimID] {
verified = append(verified, r)
}
}
agentScore := 0.0
if len(agentResults) > 0 {
agentScore = float64(verified) / float64(len(agentResults))
if len(verified) > 0 {
total := 0.0
for _, r := range verified {
total += r.Confidence
}
agentScore = total / float64(len(verified))
}
passed := 0
// Signal B: cross-check score, excluding unrelated/ungrounded noise claims
// (cross<0.2 AND agent self-unverified) so an invented claim doesn't punish
// an otherwise sufficient verdict.
noiseThreshold := 0.2
agentVerified := map[string]bool{}
for _, r := range agentResults {
agentVerified[r.ClaimID] = r.IsVerified
}
var noiseIDs []string
var kept []ClaimCrossCheckResult
for _, r := range crossResults {
if r.CrossCheckPassed {
passed++
if r.CrossCheckScore < noiseThreshold && !r.CrossCheckPassed && !agentVerified[r.ClaimID] {
noiseIDs = append(noiseIDs, r.ClaimID)
continue
}
kept = append(kept, r)
}
if len(noiseIDs) > 0 && len(kept) > 0 {
crossResults = kept
}
// ── Hard-veto floor ──
minCrossFloor := 0.5
selfVerifiedIDs := map[string]bool{}
for _, r := range agentResults {
if r.IsVerified {
selfVerifiedIDs[r.ClaimID] = true
}
}
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
weakSet := map[string]bool{}
for _, r := range crossResults {
if selfVerifiedIDs[r.ClaimID] && r.CrossCheckScore < minCrossFloor {
weakSet[r.ClaimID] = true
}
}
for cid := range gappedIDs {
weakSet[cid] = true
}
weak := sortedKeys(weakSet)
// Conflict detection based on the kept (non-noisy) claims.
hasConflicts := false
for _, r := range crossResults {
if len(r.Mismatches) > 0 {
@@ -147,61 +426,127 @@ func ComputeFusionScore(agentResults []AgentResult, crossResults []ClaimCrossChe
}
}
// 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
// Cross-check status (code-only preliminary view, no AutoRater).
anyPassed := false
for _, r := range crossResults {
if r.HasEvidence {
anyEvidence = true
if r.CrossCheckPassed {
anyPassed = true
break
}
}
status := "INSUFFICIENT"
switch {
case !anyEvidence:
case !anyPassed:
status = "UNANSWERABLE"
case hasConflicts && fusionScore < mode.PartialThreshold:
case hasConflicts:
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
}():
case len(weak) > 0:
status = "INSUFFICIENT"
case agentScore >= mode.SufficiencyThreshold:
status = "SUFFICIENT"
default:
status = "UNANSWERABLE"
status = "USEFUL_BUT_INCOMPLETE"
}
var missing []string
for _, r := range crossResults {
if !r.CrossCheckPassed || !r.HasEvidence {
if !r.CrossCheckPassed {
missing = append(missing, r.ClaimID)
}
}
missing = append(missing, noiseIDs...)
for _, c := range weak {
if !containsStr(missing, c) {
missing = append(missing, c)
}
}
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,
"claim_id": r.ClaimID, "is_verified": r.CrossCheckPassed, "score": r.CrossCheckScore,
"mismatches": r.Mismatches, "has_evidence": r.HasEvidence,
})
}
return SufficiencyVerdict{
Status: status, Score: fusionScore, AgentScore: agentScore, CrossScore: crossScore,
Status: status, Score: agentScore, AgentScore: agentScore, CrossScore: 0,
ClaimAssessments: assessments, HasConflicts: hasConflicts, MissingClaims: missing,
Feedback: buildFeedback(missing, crossResults), OverallReason: fmt.Sprintf("%s score=%.2f missing=%v", status, fusionScore, missing),
Feedback: buildFeedback(missing, crossResults), OverallReason: fmt.Sprintf("%s agent_conf=%.2f hard_veto=%d", status, agentScore, len(weak)),
HardViolations: weak, AgentConfidence: agentScore,
}
}
// requiredEntityGaps mirrors Python required_entity_gaps. NER-degraded: entity
// extraction yields only CJK substrings (see extractNamedEntities TODO), so the
// AND-semantics veto is partial for Latin-script questions.
func requiredEntityGaps(question string, claims []ClaimTarget, allChunks map[int]map[string]interface{}) map[string][]string {
var chunkTexts []string
for _, chunk := range allChunks {
text := ""
if c, ok := chunk["content_with_weight"].(string); ok {
text = c
} else if c, ok := chunk["content"].(string); ok {
text = c
}
if text != "" {
chunkTexts = append(chunkTexts, strings.ToLower(text))
}
}
qEntities := extractNamedEntities(question)
gaps := map[string][]string{}
for _, claim := range claims {
if claim.ClaimID == "" || claim.Description == "" {
continue
}
descEntities := extractNamedEntities(claim.Description)
descLower := map[string]bool{}
for _, e := range descEntities {
descLower[strings.ToLower(e)] = true
}
var required []string
required = append(required, descEntities...)
for _, e := range qEntities {
if descLower[strings.ToLower(e)] {
required = append(required, e)
}
}
var missing []string
for _, e := range required {
if !entityPresent(e, chunkTexts) {
missing = append(missing, e)
}
}
if len(missing) > 0 {
gaps[claim.ClaimID] = missing
}
}
return gaps
}
// entityPresent mirrors Python _entity_present: CJK → substring, else bounded.
func entityPresent(ent string, chunkTexts []string) bool {
if isCJK(ent) {
lower := strings.ToLower(ent)
for _, t := range chunkTexts {
if strings.Contains(t, lower) {
return true
}
}
return false
}
return anyBounded(chunkTexts, strings.ToLower(ent))
}
func containsStr(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func buildFeedback(missing []string, results []ClaimCrossCheckResult) string {
if len(missing) == 0 {
return "all claims verified"
@@ -215,36 +560,80 @@ func buildFeedback(missing []string, results []ClaimCrossCheckResult) string {
return "missing: " + strings.Join(hints, "; ")
}
// RouteSufficiencyVerdict returns (action, shouldContinue) from the verdict.
func RouteSufficiencyVerdict(v SufficiencyVerdict, modeLabel string, cycle, maxCycles int) (string, bool) {
// AutoRating is the LLM Sufficient Context AutoRater output (mirrors the dict
// returned by Python llm_sufficiency_boost).
type AutoRating struct {
IsSufficient bool
Confidence float64
Missing []string
Contradictions []string
Followups []string
}
// RouteSufficiencyVerdict returns (action, shouldContinue, caveat) from the
// verdict via the decision ladder + orchestrator action mapping. It mirrors
// Python route_sufficiency_verdict (sufficiency.py:791), which is the
// orchestrator-facing wrapper that calls sufficiency_ladder then maps the
// ladder action onto orchestrator actions.
//
// The AutoRater (auto) is the primary judge; when nil, the verdict's code-level
// status is used as a fallback so the loop still terminates (medium mode, or a
// missing LLM judge).
func RouteSufficiencyVerdict(v SufficiencyVerdict, modeLabel string, cycle, maxCycles int, auto *AutoRating) (string, bool, string) {
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":
// AutoRater signals, with sane defaults when it was not invoked.
autoSufficient := v.Status == "SUFFICIENT"
autoConfidence := 1.0
missing := v.MissingClaims
contradictions := []string{}
if auto != nil {
autoSufficient = auto.IsSufficient
autoConfidence = auto.Confidence
missing = auto.Missing
contradictions = auto.Contradictions
} else if v.HasConflicts {
contradictions = []string{v.Feedback}
}
hardViolations := map[string][]string{}
for _, id := range v.HardViolations {
hardViolations[id] = []string{}
}
out := SufficiencyLadder(LadderInput{
AutoSufficient: autoSufficient,
AutoConfidence: autoConfidence,
Missing: missing,
Contradictions: contradictions,
AgentConfidence: v.AgentConfidence,
CHigh: mode.CHigh,
CLow: mode.CLow,
LLMFloor: mode.LLMFloor,
AllowsReconcile: mode.AllowsReconcile,
Cycle: cycle,
MaxCycles: maxCycles,
HardViolations: hardViolations,
})
// Map ladder action onto orchestrator actions (mirrors sufficiency.py:842-858).
switch out.Action {
case ActionAnswerWithCaveat:
return "ANSWER_PARTIAL", false, out.Caveat
case ActionReconcile:
// medium has no reconcile loop → degrade to CONTINUE (keep searching).
return "CONTINUE", true, out.Caveat
case ActionUnanswerable:
if mode.FallbackToDirectLLM {
return "FALLBACK_LLM", false
return "FALLBACK_LLM", false, out.Caveat
}
return "ABSTAIN", false
default:
return "CONTINUE", true
return "ABSTAIN", false, out.Caveat
case ActionGap:
return "CONTINUE", true, out.Caveat
default: // ActionAnswer
return "ANSWER", out.ShouldContinue, out.Caveat
}
}

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 (
"sort"
"strings"
)
// Action constants (shared with orchestrators), mirroring Python
// sufficiency_ladder.py. The ladder decides *presentation*: full answer vs.
// caveated answer vs. re-investigate vs. unanswerable.
const (
ActionAnswer = "ANSWER"
ActionAnswerWithCaveat = "ANSWER_WITH_CAVEAT"
ActionGap = "GAP"
ActionReconcile = "RECONCILE"
ActionUnanswerable = "UNANSWERABLE"
)
// LadderInput is the decision-ladder input set, mirroring the keyword arguments
// of Python sufficiency_ladder(). AutoSufficient/AutoConfidence are the LLM
// AutoRater's judgment; Missing/Contradictions are its concrete gaps; and
// AgentConfidence is the aggregated agent self-confidence (risk gate).
type LadderInput struct {
AutoSufficient bool
AutoConfidence float64
Missing []string
Contradictions []string
AgentConfidence float64
CHigh float64
CLow float64
LLMFloor float64
AllowsReconcile bool
Cycle int
MaxCycles int
HardViolations map[string][]string // claimID -> gaps (or empty slices as markers)
}
// LadderOutput is the decision-ladder result.
type LadderOutput struct {
Action string
ShouldContinue bool
Caveat string
Missing []string
}
// AggregateAgentConfidence mirrors Python aggregate_agent_confidence: mean
// self-confidence over the trusted (self-verified, non-violating) claims.
// HardViolations keys are claim IDs that must be excluded regardless of their
// self-reported verification.
func AggregateAgentConfidence(results []AgentResult, hardViolations map[string][]string) float64 {
violated := map[string]bool{}
for id := range hardViolations {
violated[id] = true
}
total := 0.0
count := 0
for _, r := range results {
if !r.IsVerified || violated[r.ClaimID] {
continue
}
total += r.Confidence
count++
}
if count == 0 {
return 0.0
}
return total / float64(count)
}
// SufficiencyLadder evaluates the monotonic decision ladder and returns the
// action. It is a pure function (no LLM dependency), mirroring Python
// sufficiency_ladder() decision-by-decision.
func SufficiencyLadder(in LadderInput) LadderOutput {
violations := map[string][]string{}
for id, gaps := range in.HardViolations {
violations[id] = gaps
}
// 1. Hard veto floor: code-proven evidence gap beats the LLM's "good enough".
if len(violations) > 0 {
ids := make([]string, 0, len(violations))
for id := range violations {
ids = append(ids, id)
}
sort.Strings(ids)
first := ids
if len(first) > 6 {
first = first[:6]
}
return LadderOutput{
Action: ActionAnswerWithCaveat,
ShouldContinue: false,
Caveat: "hard evidence gap in claim(s): " + strings.Join(first, ", "),
Missing: in.Missing,
}
}
// 2. AutoRater says insufficient.
if !in.AutoSufficient {
if len(in.Missing) > 0 {
return LadderOutput{Action: ActionGap, ShouldContinue: true, Missing: in.Missing}
}
return LadderOutput{Action: ActionUnanswerable, ShouldContinue: false, Missing: in.Missing}
}
// 3. AutoRater is not confident in its own sufficiency call.
if in.AutoConfidence < in.LLMFloor {
if in.AllowsReconcile && in.Cycle < in.MaxCycles-1 {
return LadderOutput{
Action: ActionReconcile,
ShouldContinue: true,
Caveat: "AutoRater itself is unsure; re-investigating",
}
}
return LadderOutput{
Action: ActionAnswerWithCaveat,
ShouldContinue: false,
Caveat: "AutoRater sufficiency judgment is low-confidence",
}
}
// 4. Sufficient + confident: agent confidence sets the presentation.
caveat := ""
shouldContinue := false
action := ActionAnswer
switch {
case in.AgentConfidence >= in.CHigh:
action = ActionAnswer
case in.AgentConfidence >= in.CLow:
action = ActionAnswerWithCaveat
caveat = "evidence partially supports the answer"
case in.AllowsReconcile && in.Cycle < in.MaxCycles-1:
action = ActionReconcile
shouldContinue = true
default:
action = ActionAnswerWithCaveat
caveat = "evidence partially supports the answer"
}
if len(in.Contradictions) > 0 {
// Never silently pick one side of a contradiction; surface it.
caveat = "evidence contains conflicting figures"
action = ActionAnswerWithCaveat
shouldContinue = false
}
return LadderOutput{Action: action, ShouldContinue: shouldContinue, Caveat: caveat, Missing: in.Missing}
}

View File

@@ -0,0 +1,123 @@
//
// 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 "testing"
// TestSufficiencyLadder_HardVeto asserts a code-proven evidence gap vetoes a full
// answer even when the AutoRater says sufficient.
func TestSufficiencyLadder_HardVeto(t *testing.T) {
out := SufficiencyLadder(LadderInput{
AutoSufficient: true, AutoConfidence: 0.9, AgentConfidence: 0.9,
CHigh: 0.7, CLow: 0.4, LLMFloor: 0.5, AllowsReconcile: true,
Cycle: 0, MaxCycles: 3,
HardViolations: map[string][]string{"c2": {}},
})
if out.Action != ActionAnswerWithCaveat || out.ShouldContinue {
t.Errorf("hard veto → caveated non-continuing answer, got action=%s continue=%v", out.Action, out.ShouldContinue)
}
}
// TestSufficiencyLadder_InsufficientGap asserts !auto_sufficient with missing
// pieces → GAP (continue searching).
func TestSufficiencyLadder_InsufficientGap(t *testing.T) {
out := SufficiencyLadder(LadderInput{
AutoSufficient: false, AutoConfidence: 0.8, Missing: []string{"population figure"},
AgentConfidence: 0.3, CHigh: 0.7, CLow: 0.4, LLMFloor: 0.5,
AllowsReconcile: true, Cycle: 0, MaxCycles: 3,
})
if out.Action != ActionGap || !out.ShouldContinue {
t.Errorf("insufficient with missing → GAP + continue, got action=%s continue=%v", out.Action, out.ShouldContinue)
}
}
// TestSufficiencyLadder_UnanswerableNoMissing asserts !auto_sufficient without
// missing pieces → UNANSWERABLE.
func TestSufficiencyLadder_UnanswerableNoMissing(t *testing.T) {
out := SufficiencyLadder(LadderInput{
AutoSufficient: false, AutoConfidence: 0.8, AgentConfidence: 0.1,
CHigh: 0.7, CLow: 0.4, LLMFloor: 0.5, AllowsReconcile: true,
Cycle: 0, MaxCycles: 3,
})
if out.Action != ActionUnanswerable {
t.Errorf("insufficient + no missing → UNANSWERABLE, got %s", out.Action)
}
}
// TestSufficiencyLadder_Reconcile asserts low AutoRater confidence with reconcile
// enabled → RECONCILE (continue).
func TestSufficiencyLadder_Reconcile(t *testing.T) {
out := SufficiencyLadder(LadderInput{
AutoSufficient: true, AutoConfidence: 0.3, AgentConfidence: 0.6,
CHigh: 0.7, CLow: 0.4, LLMFloor: 0.5, AllowsReconcile: true,
Cycle: 0, MaxCycles: 3,
})
if out.Action != ActionReconcile || !out.ShouldContinue {
t.Errorf("low AutoRater confidence + reconcile → RECONCILE, got action=%s continue=%v", out.Action, out.ShouldContinue)
}
}
// TestSufficiencyLadder_AnswerConfidenceTiers asserts agent confidence drives
// ANSWER vs ANSWER_WITH_CAVEAT.
func TestSufficiencyLadder_AnswerConfidenceTiers(t *testing.T) {
// high confidence → ANSWER.
hi := SufficiencyLadder(LadderInput{
AutoSufficient: true, AutoConfidence: 0.9, AgentConfidence: 0.9,
CHigh: 0.7, CLow: 0.4, LLMFloor: 0.5, AllowsReconcile: true,
Cycle: 0, MaxCycles: 3,
})
if hi.Action != ActionAnswer {
t.Errorf("high confidence → ANSWER, got %s", hi.Action)
}
// mid confidence → ANSWER_WITH_CAVEAT.
mid := SufficiencyLadder(LadderInput{
AutoSufficient: true, AutoConfidence: 0.9, AgentConfidence: 0.5,
CHigh: 0.7, CLow: 0.4, LLMFloor: 0.5, AllowsReconcile: true,
Cycle: 0, MaxCycles: 3,
})
if mid.Action != ActionAnswerWithCaveat {
t.Errorf("mid confidence → ANSWER_WITH_CAVEAT, got %s", mid.Action)
}
}
// TestSufficiencyLadder_ContradictionSurfacesCaveat asserts contradictions force a
// caveated answer regardless of confidence.
func TestSufficiencyLadder_ContradictionSurfacesCaveat(t *testing.T) {
out := SufficiencyLadder(LadderInput{
AutoSufficient: true, AutoConfidence: 0.9, AgentConfidence: 0.9,
Contradictions: []string{"2,161,000 vs 2,145,906"},
CHigh: 0.7, CLow: 0.4, LLMFloor: 0.5, AllowsReconcile: true,
Cycle: 0, MaxCycles: 3,
})
if out.Action != ActionAnswerWithCaveat {
t.Errorf("contradiction → ANSWER_WITH_CAVEAT, got %s", out.Action)
}
}
// TestAggregateAgentConfidence asserts the mean excludes unverified claims and
// hard-violated claims.
func TestAggregateAgentConfidence(t *testing.T) {
results := []AgentResult{
{ClaimID: "a", IsVerified: true, Confidence: 0.9},
{ClaimID: "b", IsVerified: true, Confidence: 0.5},
{ClaimID: "c", IsVerified: false, Confidence: 0.9}, // unverified → excluded
}
got := AggregateAgentConfidence(results, map[string][]string{"b": {}}) // b violated → excluded
if got != 0.9 {
t.Errorf("AggregateAgentConfidence = %v, want 0.9 (only a counts)", got)
}
}

View File

@@ -0,0 +1,499 @@
//
// 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"
"regexp"
"sort"
"strings"
"github.com/cloudwego/eino/schema"
"gorm.io/gorm"
"ragflow/internal/agent/chat"
)
// LLM Sufficient Context AutoRater (mirrors Python sufficiency_llm.py +
// rag/prompts/sufficiency_select.md). It is the *primary* sufficiency judge in
// the decision-ladder design: it decides whether the retrieved evidence supports
// a plausible answer, and on "insufficient" returns concrete missing information
// that becomes follow-up search queries.
// sufficiencySelectPrompt mirrors rag/prompts/sufficiency_select.md.
const sufficiencySelectPrompt = `You are an information retrieval evaluation expert. Determine whether the retrieved content is sufficient to answer the user's question(s), following the "Sufficient Context" criterion:
The CONTEXT is sufficient to answer the question if and only if a PLAUSIBLE answer can be inferred from it — that is, the retrieved content either directly contains or logically entails an answer to the question. The answer does NOT need to be proven correct; it only needs to be a reasonable, supportable answer. If the context cannot be used to infer any plausible answer, it is INSUFFICIENT.
Each retrieved chunk is labeled with an integer ID on a line like ` + "`ID: 3`" + `.
User question(s):
%s
Retrieved content:
%s
Reasoning procedure (do this step-by-step before answering):
1. Identify the REQUIRED ENTITIES or key facts that a plausible answer to the question must involve.
2. For each required entity, check whether the retrieved content provides evidence about it. Record this in "coverage".
3. Check for multi-hop inference: if answering requires combining facts not present in the context, or inferring a connection the context does not state, that is NOT inferable from the context.
4. Check whether the context is ambiguous: if it could support multiple mutually exclusive plausible answers and nothing in the context lets you distinguish them, mark it insufficient.
5. Note any internally conflicting figures/statements in the context ("contradictions").
6. Decide whether a plausible answer can be inferred; give your confidence in that decision.
Output format (JSON):
{
"Sufficient Context": true/false,
"is_sufficient": true/false,
"required_entities": ["Entity 1", "Entity 2"],
"coverage": {"Entity 1": true, "Entity 2": false},
"missing_information": ["Missing information 1", "Missing information 2"],
"contradictions": ["conflicting figures/statements if any"],
"confidence": 0.0,
"reasoning": "Step-by-step reasoning for the judgment",
"useful_chunk_ids": [0, 3, 7]
}
Requirements:
1. ` + "`Sufficient Context`" + ` / ` + "`is_sufficient`" + ` must be true if and only if a plausible answer can be inferred from the context (per the definition above). A missing detail that a reasonable answer would still require makes it false.
2. If not sufficient, list the concrete ` + "`missing_information`" + `.
3. ` + "`coverage`" + ` must mark, for each required entity, whether the context provides evidence about it. Missing required entities belong in ` + "`missing_information`" + `.
4. ` + "`confidence`" + ` (0-1): how confident you are in your sufficiency decision. 0.9-1.0 if the context clearly supports or clearly fails a plausible answer; 0.5-0.7 if evidence is partial or ambiguous; below 0.5 if you cannot tell.
5. ` + "`contradictions`" + `: list any internally conflicting figures/statements that would make a single answer ambiguous. Empty array when none.
6. ` + "`useful_chunk_ids`" + ` must contain ONLY the integer IDs (taken from the ` + "`ID:`" + ` labels above) of chunks that provide information useful for answering the question(s). Exclude irrelevant or redundant chunks. Use an empty array when none are useful.
7. The ` + "`missing_information`" + ` should only be filled when insufficient, otherwise an empty array.
8. The ` + "`reasoning`" + ` should be concise and clear.`
const (
maxEvidenceChunksLLM = 24
maxChunkCharsLLM = 800
maxEvidenceCharsLLM = 24000
)
type sufficiencySelectResult struct {
SufficientContext bool `json:"Sufficient Context"`
IsSufficient bool `json:"is_sufficient"`
RequiredEntities []string `json:"required_entities"`
Coverage map[string]bool `json:"coverage"`
MissingInfo []string `json:"missing_information"`
Contradictions []string `json:"contradictions"`
Confidence float64 `json:"confidence"`
Reasoning string `json:"reasoning"`
UsefulChunkIDs []int `json:"useful_chunk_ids"`
}
var reNarrowTokens = regexp.MustCompile(`[a-zA-Z0-9]+|[\x{4e00}-\x{9fff}]+`)
// narrowKeywords mirrors Python _narrow_keywords: language-agnostic keywords for
// snippet narrowing (numeric tokens, latin len>=4, CJK character bigrams).
func narrowKeywords(question string) []string {
tokens := reNarrowTokens.FindAllString(strings.ToLower(question), -1)
var kw []string
for _, t := range tokens {
if isDigits(t) {
kw = append(kw, t)
} else if containsLatin(t) {
if len(t) >= 4 {
kw = append(kw, t)
}
} else {
// CJK run → character bigrams
rs := []rune(t)
for i := 0; i+1 < len(rs); i++ {
kw = append(kw, string(rs[i:i+2]))
}
}
}
return kw
}
func isDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return len(s) > 0
}
func containsLatin(s string) bool {
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return true
}
}
return false
}
// renderEvidenceMD renders the cited evidence chunks with "ID: n" markers,
// mirroring Python _evidence_md. Prefers the chunks referenced by evidenceIDs;
// falls back to a bounded prefix of the pool when none are given.
func renderEvidenceMD(kb *Kbinfos, evidenceIDs []int, keywords []string) string {
if kb == nil || len(kb.Chunks) == 0 {
return ""
}
var picked []int
if len(evidenceIDs) > 0 {
seen := map[int]bool{}
for _, eid := range evidenceIDs {
if eid >= 0 && eid < len(kb.Chunks) && !seen[eid] {
seen[eid] = true
picked = append(picked, eid)
}
if len(picked) >= maxEvidenceChunksLLM {
break
}
}
}
if len(picked) == 0 {
n := len(kb.Chunks)
if n > maxEvidenceChunksLLM {
n = maxEvidenceChunksLLM
}
for i := 0; i < n; i++ {
picked = append(picked, i)
}
}
var blocks []string
used := 0
for _, idx := range picked {
c := kb.Chunks[idx]
raw := chunkText(c)
title := chunkDoc(c)
if len(keywords) > 0 {
if narrowed := narrowSnippetSafe(raw, keywords); narrowed != "" {
raw = narrowed
}
}
if len(raw) > maxChunkCharsLLM {
raw = raw[:maxChunkCharsLLM]
}
if used+len(raw) > maxEvidenceCharsLLM {
break
}
blocks = append(blocks, fmt.Sprintf("ID: %d | %s\n%s", idx, title, raw))
used += len(raw) + 8
}
return strings.Join(blocks, "\n\n")
}
// narrowSnippetSafe mirrors Python _narrow_snippet_safe: keep keyword-bearing
// sentences (plus one neighbour) only when keywords cover a meaningful share.
// Returns "" to signal "keep the whole chunk".
func narrowSnippetSafe(content string, kw []string) string {
sents := splitSentences(content)
if len(sents) <= 3 {
return ""
}
lower := make([]string, len(sents))
for i, s := range sents {
lower[i] = strings.ToLower(s)
}
var hitIdx []int
for i, s := range lower {
for _, k := range kw {
if strings.Contains(s, k) {
hitIdx = append(hitIdx, i)
break
}
}
}
if len(hitIdx) < 2 {
return ""
}
keep := map[int]bool{}
for _, i := range hitIdx {
for j := i - 1; j <= i+1; j++ {
if j >= 0 && j < len(sents) {
keep[j] = true
}
}
}
// Preserve order.
var out []string
for i := 0; i < len(sents); i++ {
if keep[i] {
out = append(out, sents[i])
}
}
return strings.Join(out, " ")
}
// Sentence splitting mirrors Python tools/search.py _split_sentences:
// - terminators are KEPT on their sentence (。!?;!?; plus a digit-guarded
// English period so "3.14" / "v1.2" do not split);
// - table blocks (HTML <table> and markdown tables) are ATOMIC — never split
// internally.
//
// Go's RE2 lacks lookbehind, so the digit-guard is handled by a manual scan.
var (
reHTMLTable = regexp.MustCompile(`(?is)<table\b[^>]*>.*?</table>`)
reMDTable = regexp.MustCompile("(?m)^[ \t]*\\|?[^\n]*\\|[^\n]*\r?\n[ \t]*\\|?[ \t]*:?-{1,}:?[ \t]*(?:\\|[ \t]*:?-{1,}:?[ \t]*)+\\|?[ \t]*\r?\n(?:[ \t]*\\|?[^\n]*\\|[^\n]*\r?\n?)*")
)
func splitSentences(text string) []string {
if text == "" {
return nil
}
// Collect non-overlapping table spans (HTML + markdown), in order.
var spans [][2]int
spans = append(spans, matchSpans(reHTMLTable, text)...)
spans = append(spans, matchSpans(reMDTable, text)...)
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
var merged [][2]int
lastEnd := -1
for _, s := range spans {
if s[0] < lastEnd {
continue
}
merged = append(merged, s)
lastEnd = s[1]
}
var sents []string
pos := 0
for _, m := range merged {
if m[0] > pos {
sents = append(sents, splitPlainSentences(text[pos:m[0]])...)
}
if block := strings.TrimSpace(text[m[0]:m[1]]); block != "" {
sents = append(sents, block)
}
pos = m[1]
}
if pos < len(text) {
sents = append(sents, splitPlainSentences(text[pos:])...)
}
return sents
}
func matchSpans(re *regexp.Regexp, text string) [][2]int {
matches := re.FindAllStringIndex(text, -1)
out := make([][2]int, 0, len(matches))
for _, m := range matches {
out = append(out, [2]int{m[0], m[1]})
}
return out
}
// splitPlainSentences splits plain text (no table blocks) into sentences,
// keeping each terminator attached and guarding decimal periods. Operates on
// runes; rune indices == byte indices for the ASCII terminators we emit.
func splitPlainSentences(text string) []string {
rs := []rune(text)
var sents []string
start := 0
for i := 0; i < len(rs); i++ {
r := rs[i]
if !isSentTerminator(r) {
continue
}
// ASCII period guarded against decimals (digit on BOTH sides).
if r == '.' && i > 0 && i+1 < len(rs) && isASCIIDigit(rs[i-1]) && isASCIIDigit(rs[i+1]) {
continue
}
// Consume a run of terminators (e.g. "。!?" or "...").
j := i + 1
for j < len(rs) && isSentTerminator(rs[j]) && rs[j] != '.' {
j++
}
seg := strings.TrimSpace(string(rs[start:j]))
if seg != "" {
sents = append(sents, seg)
}
start = j
i = j - 1
}
if start < len(rs) {
if tail := strings.TrimSpace(string(rs[start:])); tail != "" {
sents = append(sents, tail)
}
}
return sents
}
func isSentTerminator(r rune) bool {
switch r {
case '。', '', '', '', '!', '?', ';', '.':
return true
}
return false
}
func isASCIIDigit(r rune) bool { return r >= '0' && r <= '9' }
// LLMSufficiencyBoost mirrors Python llm_sufficiency_boost: run the AutoRater on
// the cited evidence and return an AutoRating. Returns nil when no LLM judge is
// available or the verdict is already clear (SUFFICIENT/UNANSWERABLE).
func LLMSufficiencyBoost(ctx context.Context, db *gorm.DB, question string, verdict *SufficiencyVerdict, kb *Kbinfos, evidenceIDs []int) *AutoRating {
if verdict == nil {
return nil
}
switch verdict.Status {
case "USEFUL_BUT_INCOMPLETE", "INSUFFICIENT", "CONFLICTING":
// boost applicable
default:
return nil
}
inv := chat.GetDefaultInvoker()
if inv == nil {
return nil
}
evidenceMD := renderEvidenceMD(kb, evidenceIDs, narrowKeywords(question))
if evidenceMD == "" {
return nil
}
prompt := fmt.Sprintf(sufficiencySelectPrompt, question, evidenceMD)
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: prompt},
},
})
if err != nil {
return nil
}
var res sufficiencySelectResult
if err := unmarshalModelJSON(resp.Content, &res); err != nil {
return nil
}
isSuff := res.IsSufficient || res.SufficientContext
missing := filterNonEmpty(res.MissingInfo)
contradictions := filterNonEmpty(res.Contradictions)
confidence := clamp01(res.Confidence)
rating := &AutoRating{
IsSufficient: isSuff,
Confidence: confidence,
Missing: missing,
Contradictions: contradictions,
}
// Phase-2: when the AutoRater says insufficient with concrete gaps, generate
// complementary follow-up search queries for the next round (mirrors Python
// gen_followups → multi_queries_gen). This is the missing-piece feedback loop.
if !isSuff && len(missing) > 0 {
rating.Followups = genFollowups(ctx, db, question, missing, evidenceMD)
}
return rating
}
// multiQueriesGenPrompt mirrors rag/prompts/multi_queries_gen.md.
const multiQueriesGenPrompt = `You are a query optimization expert.
The user's original query failed to retrieve sufficient information;
please generate multiple complementary improved questions and corresponding queries.
Original query:
%s
Original question:
%s
Currently, retrieved content:
%s
Missing information:
%s
Please generate 2-3 complementary queries to help find the missing information. These queries should:
1. Focus on different missing information points.
2. Use different expressions.
3. Avoid being identical to the original query.
4. Remain concise and clear.
Output format (JSON):
{
"reasoning": "Explanation of query generation strategy",
"questions": [
{"question": "Improved question 1", "query": "Improved query 1"}
]
}
Requirements:
1. Questions array contains 1-3 questions and corresponding queries.
2. Each question length is between 5-200 characters.
3. Each query length is between 1-5 keywords.
4. Each query MUST be in the same language as the retrieved content in.
5. DO NOT generate question and query that is similar to the original query.
6. Reasoning explains the generation strategy.`
type multiQueriesResult struct {
Reasoning string `json:"reasoning"`
Questions []multiQueriesItem `json:"questions"`
}
type multiQueriesItem struct {
Question string `json:"question"`
Query string `json:"query"`
}
// genFollowups mirrors Python gen_followups → multi_queries_gen: generate
// complementary follow-up search queries for the missing information. Returns
// the "query or question" strings the research agent injects as targeted
// follow-up searches (mirrors agentic.py:98).
func genFollowups(ctx context.Context, db *gorm.DB, question string, missing []string, evidenceMD string) []string {
inv := chat.GetDefaultInvoker()
if inv == nil {
return nil
}
// Fit evidence (mirrors _fit_evidence: bounded truncation already done by
// renderEvidenceMD, so reuse it verbatim).
missingStr := "\n - " + strings.Join(missing, "\n - ")
prompt := fmt.Sprintf(multiQueriesGenPrompt, question, question, evidenceMD, missingStr)
resp, err := inv.Invoke(ctx, db, chat.Request{
Messages: []schema.Message{
{Role: schema.System, Content: prompt},
},
})
if err != nil {
return nil
}
var res multiQueriesResult
if err := unmarshalModelJSON(resp.Content, &res); err != nil {
return nil
}
var out []string
for _, q := range res.Questions {
v := strings.TrimSpace(q.Query)
if v == "" {
v = strings.TrimSpace(q.Question)
}
if v != "" {
out = append(out, v)
}
}
return out
}
func filterNonEmpty(in []string) []string {
var out []string
for _, s := range in {
if strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}

View File

@@ -68,15 +68,36 @@ type ExecutionStrategy struct {
FallbackToDirectLLM bool
RequiresSelectiveGen bool
AllowsReplan bool
// Decision-ladder thresholds (Sufficient Context redesign). CHigh/CLow gate
// full vs. caveated answer from aggregated agent confidence; LLMFloor is the
// minimum AutoRater confidence before a reconcile/re-investigation.
CHigh float64
CLow float64
LLMFloor float64
// AllowsReconcile mirrors Python allows_reconcile: whether the mode may force
// a re-investigation when confidence is low (medium=false).
AllowsReconcile bool
// AllowsDynamicClaims mirrors Python allows_dynamic_claims: ultra only.
AllowsDynamicClaims bool
}
// AgentResult mirrors Python AgentResult.
type AgentResult struct {
ClaimID string
Report string
IsVerified bool
Confidence float64
EvidenceIDs []int
ClaimID string
Report string
IsVerified bool
Confidence float64
EvidenceIDs []int
Gaps []string
DiscoveredClaims []string
// Grounded lists the key assertions the agent claims are evidence-backed
// (Python schema field `grounded`). Unused at the code level while the
// lexical grounded-fact check is disabled (_ENABLE_NER_GROUNDED=False);
// reserved for the grounded review path.
Grounded []string
// Numbers lists the distinct figures the agent *disclosed* in its report,
// used for multi-source numeric-conflict detection (Python `numbers`).
Numbers []string
}
// ClaimCrossCheckResult mirrors Python ClaimCrossCheckResult.
@@ -103,6 +124,12 @@ type SufficiencyVerdict struct {
MissingClaims []string
Feedback string
OverallReason string
// Decision-ladder inputs (Sufficient Context redesign):
// - HardViolations: claim IDs with a code-proven evidence gap that veto a
// full answer even when the AutoRater says sufficient.
// - AgentConfidence: mean self-confidence over the trusted claim subset.
HardViolations []string
AgentConfidence float64
}
// OrchestratorContext mirrors Python OrchestratorContext.
@@ -125,6 +152,7 @@ var THINKING_MODES = map[string]ExecutionStrategy{
MaxOrchestratorCycles: 3, MaxAgentCycles: 0, MaxParallelAgents: 1,
AvailableTools: []string{"hybrid_search"}, SufficiencyThreshold: 0.75, PartialThreshold: 0.40,
RequiresSelectiveGen: true,
CHigh: 0.75, CLow: 0.45, LLMFloor: 0.55, AllowsReconcile: false,
},
"high": {
Label: "high", Strategy: "agentic_research", RequiresDecomposition: true,
@@ -135,6 +163,7 @@ var THINKING_MODES = map[string]ExecutionStrategy{
},
SufficiencyThreshold: 0.65, PartialThreshold: 0.30,
RequiresSelectiveGen: true, AllowsReplan: true,
CHigh: 0.70, CLow: 0.40, LLMFloor: 0.50, AllowsReconcile: true,
},
"ultra": {
Label: "ultra", Strategy: "deep_research", RequiresDecomposition: true,
@@ -147,6 +176,7 @@ var THINKING_MODES = map[string]ExecutionStrategy{
},
SufficiencyThreshold: 0.55, PartialThreshold: 0.20, FallbackToDirectLLM: true,
RequiresSelectiveGen: true, AllowsReplan: true,
CHigh: 0.65, CLow: 0.35, LLMFloor: 0.45, AllowsReconcile: true, AllowsDynamicClaims: true,
},
}

View File

@@ -3142,6 +3142,23 @@ Please correct the error and write SQL again using the exact field names above,
//
// - err: non-nil when something went wrong; caller should log and fall
// through.
//
// StructuredQuery is the exported narrow entrypoint for the agentic-search
// structured_query tool: translate a natural-language question to SQL over the
// given tabular KBs and return the answer + reference chunks. It forwards to the
// internal useSQL with quote=false (the agent tool returns the answer directly,
// not a cited natural-language response).
func (s *ChatPipelineService) StructuredQuery(
ctx context.Context,
chat *entity.Chat,
kbs []*entity.Knowledgebase,
question string,
chatModel *modelModule.ChatModel,
fieldMap map[string]interface{},
) (ans map[string]interface{}, err error) {
return s.useSQL(ctx, chat, kbs, question, chatModel, fieldMap, false)
}
func (s *ChatPipelineService) useSQL(
ctx context.Context,
chat *entity.Chat,