diff --git a/go.mod b/go.mod index 77393b10f6..d460ccd1e3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index dc57fd0fbc..25e50f4434 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/harness/agentic.go b/internal/agent/harness/agentic.go new file mode 100644 index 0000000000..01ea4b009b --- /dev/null +++ b/internal/agent/harness/agentic.go @@ -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 + } +} diff --git a/internal/agent/harness/agentic_rag.go b/internal/agent/harness/agentic_rag.go index d843ff96ac..bf19d6e135 100644 --- a/internal/agent/harness/agentic_rag.go +++ b/internal/agent/harness/agentic_rag.go @@ -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)", diff --git a/internal/agent/harness/agentic_test.go b/internal/agent/harness/agentic_test.go new file mode 100644 index 0000000000..8db254804c --- /dev/null +++ b/internal/agent/harness/agentic_test.go @@ -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) + } +} diff --git a/internal/agent/harness/answer.go b/internal/agent/harness/answer.go index 6443cb7824..df05dd5eec 100644 --- a/internal/agent/harness/answer.go +++ b/internal/agent/harness/answer.go @@ -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 "" } diff --git a/internal/agent/harness/answer_test.go b/internal/agent/harness/answer_test.go index 0520a8b6d5..f6a8752e44 100644 --- a/internal/agent/harness/answer_test.go +++ b/internal/agent/harness/answer_test.go @@ -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) { diff --git a/internal/agent/harness/compilation.go b/internal/agent/harness/compilation.go new file mode 100644 index 0000000000..883fc303d7 --- /dev/null +++ b/internal/agent/harness/compilation.go @@ -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 +} diff --git a/internal/agent/harness/compilation_test.go b/internal/agent/harness/compilation_test.go new file mode 100644 index 0000000000..c504c0f572 --- /dev/null +++ b/internal/agent/harness/compilation_test.go @@ -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") + } +} diff --git a/internal/agent/harness/cross_check_test.go b/internal/agent/harness/cross_check_test.go new file mode 100644 index 0000000000..99613867f7 --- /dev/null +++ b/internal/agent/harness/cross_check_test.go @@ -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 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 +} diff --git a/internal/agent/harness/inspector_test.go b/internal/agent/harness/inspector_test.go new file mode 100644 index 0000000000..4c1fdf05a6 --- /dev/null +++ b/internal/agent/harness/inspector_test.go @@ -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 +} diff --git a/internal/agent/harness/kg_explore.go b/internal/agent/harness/kg_explore.go new file mode 100644 index 0000000000..7b41f2bf4d --- /dev/null +++ b/internal/agent/harness/kg_explore.go @@ -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 +} diff --git a/internal/agent/harness/kg_explore_test.go b/internal/agent/harness/kg_explore_test.go new file mode 100644 index 0000000000..a544cb883e --- /dev/null +++ b/internal/agent/harness/kg_explore_test.go @@ -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) + } +} diff --git a/internal/agent/harness/orchestrator.go b/internal/agent/harness/orchestrator.go index 7c9cbc8b1c..f64907bbea 100644 --- a/internal/agent/harness/orchestrator.go +++ b/internal/agent/harness/orchestrator.go @@ -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 } diff --git a/internal/agent/harness/orchestrator_test.go b/internal/agent/harness/orchestrator_test.go index daefd5d8d0..1474ae4aa6 100644 --- a/internal/agent/harness/orchestrator_test.go +++ b/internal/agent/harness/orchestrator_test.go @@ -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) } diff --git a/internal/agent/harness/pipeline.go b/internal/agent/harness/pipeline.go new file mode 100644 index 0000000000..2ad343eada --- /dev/null +++ b/internal/agent/harness/pipeline.go @@ -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 + } +} diff --git a/internal/agent/harness/production.go b/internal/agent/harness/production.go index de488746a0..86094025bf 100644 --- a/internal/agent/harness/production.go +++ b/internal/agent/harness/production.go @@ -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 diff --git a/internal/agent/harness/research_agent.go b/internal/agent/harness/research_agent.go new file mode 100644 index 0000000000..cbfc5b12bc --- /dev/null +++ b/internal/agent/harness/research_agent.go @@ -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 `` 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)(.*?)`) + 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: +{"name": "tool_name", "arguments": {"parameter_name": "value"}} + +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": ["
from "], + "discovered_claims": ["New research directions discovered during research"] +} + +Maximum %d rounds. Output one 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): , ```json … ```, or bare {"name":…}. +func parseToolCall(text string) map[string]interface{} { + // + 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." + } +} diff --git a/internal/agent/harness/review_fixes_test.go b/internal/agent/harness/review_fixes_test.go new file mode 100644 index 0000000000..89a9ce4831 --- /dev/null +++ b/internal/agent/harness/review_fixes_test.go @@ -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 := `{"name": "generate_report", "arguments": { + "report": "Paris has 2 million people", + "is_verified": true, + "confidence": 0.9, + "evidence_ids": [0, 3], + "gaps": [] + }}` + 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) + } +} diff --git a/internal/agent/harness/split_sentences_test.go b/internal/agent/harness/split_sentences_test.go new file mode 100644 index 0000000000..3140a56597 --- /dev/null +++ b/internal/agent/harness/split_sentences_test.go @@ -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) + } +} diff --git a/internal/agent/harness/sufficiency.go b/internal/agent/harness/sufficiency.go index beb4290133..a30d9c2b72 100644 --- a/internal/agent/harness/sufficiency.go +++ b/internal/agent/harness/sufficiency.go @@ -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= 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 +// `(? 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 `(? 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 } } diff --git a/internal/agent/harness/sufficiency_ladder.go b/internal/agent/harness/sufficiency_ladder.go new file mode 100644 index 0000000000..9e059ca578 --- /dev/null +++ b/internal/agent/harness/sufficiency_ladder.go @@ -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} +} diff --git a/internal/agent/harness/sufficiency_ladder_test.go b/internal/agent/harness/sufficiency_ladder_test.go new file mode 100644 index 0000000000..5609536b74 --- /dev/null +++ b/internal/agent/harness/sufficiency_ladder_test.go @@ -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) + } +} diff --git a/internal/agent/harness/sufficiency_llm.go b/internal/agent/harness/sufficiency_llm.go new file mode 100644 index 0000000000..53aa133896 --- /dev/null +++ b/internal/agent/harness/sufficiency_llm.go @@ -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 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)]*>.*?
`) + 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 +} diff --git a/internal/agent/harness/types.go b/internal/agent/harness/types.go index 2f3393d1c8..e406949801 100644 --- a/internal/agent/harness/types.go +++ b/internal/agent/harness/types.go @@ -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, }, } diff --git a/internal/service/chat_pipeline.go b/internal/service/chat_pipeline.go index 3481ca861e..7c6d198cf3 100644 --- a/internal/service/chat_pipeline.go +++ b/internal/service/chat_pipeline.go @@ -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,