Refact agentic search (#19811)

This commit is contained in:
Yingfeng authored and GitHub committed 2026-09-18 00:07:49 +08:00
1 parent 34723377be
commit 15b6668dd8
82 files changed
+11824 -11449

No files matched your search

+39 -39
View File
@@ -39,8 +39,8 @@ import (
"ragflow/internal/ingestion/knowledge_compile"
ingestion "ragflow/internal/ingestion/service"
"ragflow/internal/mcp"
"ragflow/internal/rag/advanced_rag"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/agentic-rag"
agenticruntime "ragflow/internal/rag/agentic-rag/runtime"
"ragflow/internal/router"
"ragflow/internal/server/local"
"ragflow/internal/service"
@@ -105,7 +105,7 @@ type engineDocEngine interface {
Search(ctx context.Context, req *et.SearchRequest) (*et.SearchResult, error)
}
// docChunkPager is the production harness.DocChunkLister. It pages one
// docChunkPager is the production agenticruntime.DocChunkLister. It pages one
// document's chunks in reading order straight off the chunk index, mirroring
// Python settings.retriever.chunk_list with sort_by_position=True
// (rag/nlp/search.py:824). This is what the document-level tools
@@ -118,8 +118,8 @@ type docChunkPager struct {
docEngine engineDocEngine
}
// DocChunks implements harness.DocChunkLister.
func (p *docChunkPager) DocChunks(ctx context.Context, req harness.DocChunksRequest) ([]map[string]any, error) {
// DocChunks implements agenticruntime.DocChunkLister.
func (p *docChunkPager) DocChunks(ctx context.Context, req agenticruntime.DocChunksRequest) ([]map[string]any, error) {
if p == nil || p.docEngine == nil {
return nil, nil
}
@@ -156,7 +156,7 @@ func (p *docChunkPager) DocChunks(ctx context.Context, req harness.DocChunksRequ
rows := make([]map[string]any, 0, len(resp.Chunks))
for _, ck := range resp.Chunks {
row := make(map[string]any, 8)
// The harness chunk rows need the canonical keys its citation pipeline
// The runtime chunk rows need the canonical keys its citation pipeline
// reads: chunk_id (dedup), docnm_kwd (doc title), doc_id and the text
// under content/content_with_weight (chunkText prefers content first).
if id, ok := ck["id"].(string); ok && id != "" {
@@ -1126,20 +1126,20 @@ func startServer(ctx context.Context, args *serverArgs) error {
agenttool.SetMemoryRetrievalService(retrievalbridge.NewMemoryAdapter(memoryService))
common.Info("agent: retrieval service adapter installed")
// Wire the agentic-RAG harness as the Go chat pipeline's evidence engine
// Wire the agentic-RAG runtime as the Go chat pipeline's evidence engine
// (internal/service/chat_pipeline.retrieveViaHarness): it searches through the
// runtime retrieval singleton below and runs each request on a model resolved
// from the caller's ModelID. Activating the agentic loop here enables the full
// planner/SCA path for reasoning chats.
runtime.SetRetrievalService(retrievalbridge.NewRuntimeAdapter())
advanced_rag.SetAgenticLoop(advanced_rag.NewAgenticLoop())
agentic_rag.SetAgenticLoop(agentic_rag.NewAgenticLoop())
service.SetHarnessRetriever(func(ctx context.Context, req service.HarnessRequest) (service.HarnessResult, error) {
// Resolve the tenant's actual chat model, mirroring Python where RAGTools
// receives a fully-resolved LLMBundle: chat.LLMID may be a UUID/tenant_model
// id that the default invoker cannot split into a provider, so resolving here
// avoids falling through to a dummy driver. If resolution fails we leave model
// nil so the harness degrades to a direct search (no dummy fallback).
var model harness.SessionModel
// nil so the runtime degrades to a direct search (no dummy fallback).
var model agenticruntime.SessionModel
// outerModel mirrors Python RAGTools.chat_mdl: the fully-resolved chat
// model that drives the outer rag_agent react loop (tools=[rag,
// summarize_document], terminal_tools={"rag"}). Wired into RAGTools.Outer
@@ -1155,14 +1155,14 @@ func startServer(ctx context.Context, args *serverArgs) error {
// MaxLength mirrors Python LLMBundle.max_length (the model's
// context window in tokens); message-fitting nodes (calculate,
// structure_qa) use it as their chat.FitMessages budget.
model = &harness.InvokerSessionModel{Invoker: inv, DB: dao.DB, MaxLength: contentLen}
model = &agenticruntime.InvokerSessionModel{Invoker: inv, DB: dao.DB, MaxLength: contentLen}
resolvedModelName = modelName
// Reuse the same resolved driver/name/api as the invoker so
// the outer loop and the inner tool calls share one model.
outerModel = modelModule.NewChatModel(driver, &modelName, apiCfg)
}
} else {
common.Warn("harness: failed to resolve chat model for reasoning; harness will degrade to direct search", zap.Error(mErr))
common.Warn("runtime: failed to resolve chat model for reasoning; runtime will degrade to direct search", zap.Error(mErr))
}
}
@@ -1202,7 +1202,7 @@ func startServer(ctx context.Context, args *serverArgs) error {
// is what hybrid_search applies to its vector leg (search.py:143), so
// getting it wrong silently degrades search_chunks to keyword-only.
hasEmbedder := hasEmbedderFor(kbs)
deps := advanced_rag.RAGTools{
deps := agentic_rag.RAGTools{
Model: model,
ModelName: resolvedModelName,
Outer: outerModel,
@@ -1220,7 +1220,7 @@ func startServer(ctx context.Context, args *serverArgs) error {
// (chat-level doc_ids + meta_data_filter) restrict every agentic
// retrieval to the user-selected documents instead of the whole kb.
DocScope: req.DocIDs,
DocIDVerifier: advanced_rag.NewDocIDLookup(),
DocIDVerifier: agentic_rag.NewDocIDLookup(),
// DocChunks pages one document's chunks in reading order off the
// chunk index (Python retriever.chunk_list), backing the
// document-level tools (summarize_document / fetch_full_document).
@@ -1231,13 +1231,13 @@ func startServer(ctx context.Context, args *serverArgs) error {
// dense seed leg wired to the bound dataset's own embedding model.
// The scope config lets each dataset be scanned under its own tenant
// and a doc scope be grouped by real owner, like graph_explore.
Expand: harness.NewCompiledExpander(
Expand: agenticruntime.NewCompiledExpander(
newDatasetCompiledStore(docEngine, modelProviderService, kbs),
harness.CompiledScopeConfig{
agenticruntime.CompiledScopeConfig{
DatasetIDs: req.DatasetIDs,
TenantID: req.TenantID,
KBs: kbs,
DocTenantResolver: advanced_rag.NewDocTenantResolver(),
DocTenantResolver: agentic_rag.NewDocTenantResolver(),
},
),
// WebSearch backs the web_search tool (Python RAGTools.web_search).
@@ -1296,19 +1296,19 @@ func startServer(ctx context.Context, args *serverArgs) error {
// isThink=true delta, i.e. think-block content rather than answer text.
if req.AnswerSink != nil {
answerSink := req.AnswerSink
deps.AnswerSink = &advanced_rag.AnswerSink{
deps.AnswerSink = &agentic_rag.AnswerSink{
OnDelta: req.AnswerSink,
}
deps.Steps.Text = func(line string) { answerSink(line, true) }
}
if req.ThinkSink != nil {
// One type on both sides — harness.ThinkEvent is an alias of
// One type on both sides — agenticruntime.ThinkEvent is an alias of
// service.ThinkEvent — so the sink passes straight through: there is
// nothing to copy field by field, and no way to forget a field that
// was added on one side only.
deps.Steps.Events = req.ThinkSink
}
r := advanced_rag.Rag(ctx, deps, harness.RunRequest{
r := agentic_rag.Rag(ctx, deps, agenticruntime.RunRequest{
Question: req.Question,
ThinkingMode: req.ThinkingMode,
DatasetIDs: req.DatasetIDs,
@@ -1324,7 +1324,7 @@ func startServer(ctx context.Context, args *serverArgs) error {
}
return res, nil
})
common.Info("agent: harness chat retriever wired (runtime retrieval + agentic loop)")
common.Info("agent: runtime chat retriever wired (runtime retrieval + agentic loop)")
// Initialize handler layer
authHandler := handler.NewAuthHandler()
@@ -1429,8 +1429,8 @@ func startServer(ctx context.Context, args *serverArgs) error {
datasetArtifactHandler := handler.NewDatasetArtifactHandler(service.NewDatasetArtifactService(), datasetsService, file.NewFileCommitService())
// Install the production eino-based chat invoker as the shared chat default,
// so agentic-search harness LLM calls work in production. Without this,
// chat.GetDefaultInvoker() stays nil and the harness falls back gracefully.
// so agentic-search runtime LLM calls work in production. Without this,
// chat.GetDefaultInvoker() stays nil and the runtime falls back gracefully.
component.InstallDefaultChatInvoker()
// Install the dataset-nav ES-backed service (internal/service/nav +
@@ -1831,7 +1831,7 @@ func hasEmbedderFor(kbs []*entity.Knowledgebase) bool {
return len(kbs) > 0 && kbs[0] != nil && kbs[0].EmbdID != ""
}
// embedderForDatasets builds the embedding handle the agentic harness uses for
// embedderForDatasets builds the embedding handle the agentic runtime uses for
// query-side encoding outside the main retrieval leg: claim recall's KNN leg
// (Python recall_dataset_claims, navigation.py:1837-1909, embedding with
// tools.embed_mdl) and the structure-drill seed vector. The model is the FIRST
@@ -1954,9 +1954,9 @@ func baseModelName(embdID string) string {
// Harness seams (compiled expansion + open-web search)
// ---------------------------------------------------------------------------
// engineCompiledStore implements harness.CompiledStore over the document
// engineCompiledStore implements agenticruntime.CompiledStore over the document
// engine, backing search_chunks' compiled-structure expansion (Python
// tools/compiled_expansion.py). The harness owns the expansion strategy; this
// tools/compiled_expansion.py). The runtime owns the expansion strategy; this
// adapter only maps its (scope, filters, free text) request onto one engine
// search, mirroring Python's settings.docStoreConn.search calls.
type engineCompiledStore struct {
@@ -1973,7 +1973,7 @@ type engineCompiledStore struct {
embedTenantID string
}
var _ harness.CompiledStore = (*engineCompiledStore)(nil)
var _ agenticruntime.CompiledStore = (*engineCompiledStore)(nil)
// compiledQueryEncoder is the query-side embedding seam used for the compiled
// dense seed leg. *service.NavEmbedder implements it via EncodeQueries.
@@ -1990,9 +1990,9 @@ const (
)
// newEngineCompiledStore returns nil when no engine is available so
// harness.NewCompiledExpander disables expansion instead of searching nowhere.
// agenticruntime.NewCompiledExpander disables expansion instead of searching nowhere.
// A nil embed (or empty embedTenantID) keeps the keyword-only leg.
func newEngineCompiledStore(e engineDocEngine, embed compiledQueryEncoder, embedTenantID string) harness.CompiledStore {
func newEngineCompiledStore(e engineDocEngine, embed compiledQueryEncoder, embedTenantID string) agenticruntime.CompiledStore {
if e == nil {
return nil
}
@@ -2005,7 +2005,7 @@ func newEngineCompiledStore(e engineDocEngine, embed compiledQueryEncoder, embed
// expansion; using the tenant-default model instead would compare the query
// against rows embedded by a different model — different vector spaces, so the
// dense hits would be noise.
func newDatasetCompiledStore(e engineDocEngine, modelSvc *service.ModelProviderService, kbs []*entity.Knowledgebase) harness.CompiledStore {
func newDatasetCompiledStore(e engineDocEngine, modelSvc *service.ModelProviderService, kbs []*entity.Knowledgebase) agenticruntime.CompiledStore {
if e == nil {
return nil
}
@@ -2047,7 +2047,7 @@ var compiledChunkSelectFields = []string{
"content_with_weight", "source_chunk_ids", "similarity",
}
// SearchCompiled implements harness.CompiledStore.
// SearchCompiled implements agenticruntime.CompiledStore.
func (s *engineCompiledStore) SearchCompiled(ctx context.Context, kbID, tenantID string, docIDs []string, filters map[string][]string, matchText string, topN int) ([]map[string]any, error) {
if s == nil || s.engine == nil || kbID == "" || strings.TrimSpace(tenantID) == "" {
return nil, nil
@@ -2130,7 +2130,7 @@ func (s *engineCompiledStore) denseExpr(ctx context.Context, text string, topN i
}
}
// LoadChunks implements harness.CompiledStore: fetch the referenced source
// LoadChunks implements agenticruntime.CompiledStore: fetch the referenced source
// chunks by id (Python compiled_expansion.py:_load_chunks_for_doc searches the
// tenant index with an {"id": chunk_ids} condition).
func (s *engineCompiledStore) LoadChunks(ctx context.Context, kbID, tenantID string, chunkIDs []string) ([]map[string]any, error) {
@@ -2150,7 +2150,7 @@ func (s *engineCompiledStore) LoadChunks(ctx context.Context, kbID, tenantID str
return res.Chunks, nil
}
// Vectorize implements harness.CompiledStore. The ported expander assigns
// Vectorize implements agenticruntime.CompiledStore. The ported expander assigns
// compiled chunks the similarity stored on the row and blends by that field, so
// expansion itself never needs a query embedding.
func (s *engineCompiledStore) Vectorize(context.Context, string) ([]float64, error) {
@@ -2168,7 +2168,7 @@ func compiledDocScopeKey(filters map[string][]string) string {
return "doc_id"
}
// compiledEngineFilter maps the harness' OR-list filters onto the engine filter
// compiledEngineFilter maps the runtime' OR-list filters onto the engine filter
// shape. available_int is a numeric column, so its string values are coerced to
// ints (Python passes the literal 1).
func compiledEngineFilter(filters map[string][]string) map[string]interface{} {
@@ -2192,20 +2192,20 @@ func compiledEngineFilter(filters map[string][]string) map[string]interface{} {
}
// harnessWebSearcherFunc adapts the chat pipeline's web-search callback to the
// harness.WebSearcher seam consumed by the web_search tool. The callback is a
// plain func so internal/service does not have to depend on the harness package.
// agenticruntime.WebSearcher seam consumed by the web_search tool. The callback is a
// plain func so internal/service does not have to depend on the runtime package.
type harnessWebSearcherFunc func(ctx context.Context, queries []string) ([]string, error)
// Search implements harness.WebSearcher.
// Search implements agenticruntime.WebSearcher.
func (f harnessWebSearcherFunc) Search(ctx context.Context, queries []string) ([]string, error) {
return f(ctx, queries)
}
var _ harness.WebSearcher = harnessWebSearcherFunc(nil)
var _ agenticruntime.WebSearcher = harnessWebSearcherFunc(nil)
// harnessWebSearcher wraps a non-nil callback, returning a nil WebSearcher when
// the pipeline did not supply one — which is what hides the web_search tool.
func harnessWebSearcher(fn func(context.Context, []string) ([]string, error)) harness.WebSearcher {
func harnessWebSearcher(fn func(context.Context, []string) ([]string, error)) agenticruntime.WebSearcher {
if fn == nil {
return nil
}
@@ -870,4 +870,4 @@ The Salesforce data source is used to connect to Salesforce and synchronize reco
- **Objects**: Specify the Salesforce objects to synchronize. Multiple objects can be specified, for example, `Account,Contact,Opportunity,Case,Knowledge__kav`.
- **API version**: Set the Salesforce API version to use. The default value in the current UI is `v59.0`.
- **Batch size**: Set the number of records processed in each batch. The default value is **2**.
- **Sync deleted files**: After this is enabled, content deleted from the external system is removed from the knowledge base index.
- **Sync deleted files**: After this is enabled, content deleted from the external system is removed from the knowledge base index.
+1 -1
View File
@@ -18,7 +18,7 @@
// and its package-level singleton. It lives here (leaf, importing only eino
// schema + gorm) so that both internal/agent/component (which owns the
// production eino-based invoker) and internal/agent/tool /
// internal/rag/advanced_rag/harness (which need to call the LLM for
// internal/rag/agentic-rag/harness (which need to call the LLM for
// routing/selection) can depend on it without forming an import cycle. The production invoker is registered at boot via
// SetDefaultInvoker.
package chat
+1 -1
View File
@@ -29,7 +29,7 @@ import (
//
// This lives in the chat package rather than internal/agent/component so that
// both component (which owns the production eino invoker) and
// internal/rag/advanced_rag/harness (which composes answers) can fit prompts
// internal/rag/agentic-rag/harness (which composes answers) can fit prompts
// without forming an import cycle — component cannot import harness, because
// harness's own tests import component.
File diff suppressed because it is too large. Load diff
@@ -1,268 +0,0 @@
//
// 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"
"strings"
)
// scanTermsMax bounds the act words one direction may declare. Ten because a source words
// one deed many ways (斩 / 杀 / 劈 / 挥为两段 …), and a phrasing no term covers is a passage
// no query ever names.
const scanTermsMax = 10
// The completeness pass's budget. One pattern contributes at most patternWindowsPerAct
// windows, each quoted to patternWindowChars, and the whole block stays under
// patternFindingsMaxChars — the block is EVIDENCE the session reads (each window with the
// chunk id a member cites), so it is worth its characters in a way a list of queries the
// session may or may not run is not (see RunCompletenessPass).
const (
patternWindowsPerAct = 3
patternWindowChars = 120
patternFindingsMaxChars = 6000
)
// MemberShaped reports whether the table declares a slot whose answer is a NAME, or a set
// of them (the planner's vocabulary is entity / person / dataset), as opposed to numbers,
// dates and phrases.
//
// It gates the completeness pass, and the difference it makes is measured (2026-09-16,
// FRAMES, mode high). The pass exists to find names nobody thought of, so its store legs
// are only worth their time when names are what the answer is made of. The planner is told
// to declare act words for "a SET or a COUNT of things that someone DID" — and it did, on
// questions whose answer is a NUMBER: `won / victory / champion / title / trophy /
// championship / rings` for "how many times had Brazil won the World Cup" and `writer /
// creator / author` for "the population of the birthplace of a novel's writer". Both were
// enumerated (7–10 store legs each, sessions handed 8 passages a turn from a 100-passage
// reading list) and both were among the run's most expensive questions — 433k tokens for
// the first, 200-320k for the second, together about 18% of the run. No name either pass
// could return changes a count of EVENTS. The declared terms STAY recorded — the record can
// still report what was asked for — but nothing is run for a question no name can change.
func MemberShaped(table State) bool {
for _, v := range table.State {
switch strings.ToLower(strings.TrimSpace(v.Type)) {
case "entity", "person", "dataset", "list", "set":
return true
}
}
return false
}
// EnumerationShaped reports whether this table asks for an ENUMERATION — a set of NAMED members — as
// opposed to one value, one date or one number.
//
// It is the gate for every piece of the enumeration machinery (the method seed, the completeness pass,
// the member roll call), and it is the conjunction of three things the PLANNER itself declared, so
// nothing here reads a slot's text, the question, or a candidate:
//
// - SetShaped: the table asked for a count, a set or a list — the answer is a SET, not one value;
// - MemberShaped: a slot of this table carries NAMES (entity / person / dataset / list / set);
// - act words were declared for the deed being enumerated (a slot's Terms).
//
// The conjunction is what separates a set of named members from a count of EVENTS, which the planner
// declares act words for too ("how many times had Brazil won the World Cup" → won / victory /
// champion): that question is set-shaped but holds no names, so it is a VALUE question and pays
// nothing for a set's bookkeeping.
//
// Measured (2026-09-16, FRAMES, 4 questions in flight, 300s deadline): with the pass gated on
// MemberShaped alone it ran on 7 of the 20 questions — every one of them a single-value question —
// and the run ended 0.833 with two timeouts against 0.875 with none. The two strategies need
// different machinery, so they get it: a value question keeps the plain research loop, and only a
// table that declared all three things pays for the enumeration.
func EnumerationShaped(table State) bool {
if !SetShaped(table) || !MemberShaped(table) {
return false
}
for _, v := range table.State {
if len(v.Terms) > 0 {
return true
}
}
return false
}
// PatternRunner gives one completeness pattern to the corpus and returns what matched.
//
// A one-method interface on purpose: the runtime must be able to run the block
// ScanPatterns renders WITHOUT asking the model, and the only thing it needs from the tool
// layer is one query. The implementation (searchExecutor.RunPattern) goes through the same
// grep path a retrieve call uses, so a pattern gets a pattern's treatment — operands
// recalled individually, the pattern deciding which candidates are windows, the windows
// narrowed to the match.
type PatternRunner interface {
RunPattern(ctx context.Context, pattern string) []map[string]any
}
// CompletenessPass is what the runtime's pattern run produced.
type CompletenessPass struct {
// Text is the block the sessions are seeded with: one line per declared pattern, with
// the windows that came back and the chunk id each one is cited by. Empty when nothing
// ran (no declaration, no runner, or a table that holds no name — see MemberShaped).
Text string
// Asked is how many patterns were given to the corpus, Answered how many came back
// with at least one window, and Admitted how many windows entered the pool.
Asked int
Answered int
Admitted int
}
// RunCompletenessPass runs the direction's declared act patterns over the corpus and
// returns the block the sessions are seeded with.
//
// The patterns used to be rendered into the seed as QUERIES TO MAKE, and the measurement is
// why they are now run HERE instead: measured (2026-09-16, 三国/关羽) a round declared ten
// act words with several aliases each — 2175 characters of patterns, in the seed of every
// session — and not one session ran a single one of them. The run's whole query log holds
// zero `.*` queries; the sessions improvised space-separated word lists instead
// (`关羽 斩华雄 温酒`, `关公 砍死 斩 杀`, `云长手起刀落`), which is exactly the shape the
// patterns exist to replace. A list of queries in a prompt is ADVICE, and the completeness
// of an enumeration cannot rest on advice — every run of this question missed 2-4 members
// and the missing names differed every run.
//
// Run here, what comes back is EVIDENCE: the windows are admitted to the shared pool (so
// they are citable, and the record and the answer can point at them) and the seed states
// them as facts, so the session's job becomes "read these windows and write the members
// they name" rather than "think of the queries I should have made".
//
// Gated on MemberShaped: a table of counts and dates declares act words too (see
// MemberShaped), and spending store legs on a question whose answer is a number is a cost
// with no possible payoff.
func RunCompletenessPass(ctx context.Context, runner PatternRunner, kb *Kbinfos, table State) CompletenessPass {
pass := CompletenessPass{}
if runner == nil || !EnumerationShaped(table) {
return pass
}
patterns := ScanPatterns(table)
if len(patterns) == 0 {
return pass
}
var b strings.Builder
b.WriteString("## The completeness pass ALREADY RAN for this direction\n\n")
b.WriteString("Each act pattern below was given to the corpus by the runtime, and the matched " +
"windows are in the pool under the chunk ids shown. Every pattern WAS asked, so " +
"`(no window)` is the corpus answering about those WORDS — re-word it (see the " +
"method's relation step) rather than running it again. A name inside a window is a " +
"member you can cite with that chunk id: record it as one.\n\n")
for _, pattern := range patterns {
if ctx.Err() != nil {
// The clock ran out mid-pass. The patterns nobody got to ask would render as
// `(no window)` — a statement about the CORPUS the runtime never earned, and the
// one reading the block forbids. So the pass yields nothing at all and the seed
// keeps the pattern list (the fallback, see enumerationSeed); whatever windows
// did come back are already in the pool, so no evidence is lost.
return CompletenessPass{}
}
pass.Asked++
chunks := runner.RunPattern(ctx, pattern)
var lines []string
for _, c := range chunks {
if len(lines) >= patternWindowsPerAct {
break
}
id := ChunkIDOf(c)
quote := truncateRunes(strings.Join(strings.Fields(ChunkTextOf(c)), " "), patternWindowChars)
if id == "" || quote == "" {
continue
}
if kb != nil {
kb.Admit(func(p *PoolAdmitter) {
if p.Add(c) {
pass.Admitted++
}
})
}
lines = append(lines, fmt.Sprintf(" chunk_id=%s \"%s\"", id, quote))
}
if len(lines) == 0 {
fmt.Fprintf(&b, "- %s → (no window)\n", pattern)
continue
}
pass.Answered++
fmt.Fprintf(&b, "- %s →\n%s\n", pattern, strings.Join(lines, "\n"))
if b.Len() >= patternFindingsMaxChars {
b.WriteString("(further patterns omitted; what came back is in the pool)\n")
break
}
}
if pass.Asked == 0 || ctx.Err() != nil {
// Same guard at the end: a search cancelled mid-flight returns what it had, so the
// LAST pattern's emptiness is not the corpus answering (see the loop-head guard).
return CompletenessPass{}
}
pass.Text = b.String()
return pass
}
// ScanPatterns renders the completeness queries of an enumeration: for each act word a
// direction DECLARED, the actor and the act asked for TOGETHER, as a pattern.
//
// It is a string builder, not a mechanism. The grep path already reads a pattern the way
// this needs — grepPatternOf compiles it, GrepPatternOperands searches each literal operand
// on its own with the wide recall a pattern gets (patternRecallTopN), matchGrepPattern
// decides which candidates are windows, and the windows are narrowed to what carries the
// match (GrepOutCharsPerChunk / GrepOutTotalChars). So completeness comes from asking the
// CORPUS the right question once per act word — which is the one thing a session that only
// knows the names it can REMEMBER cannot do: measured (2026-09-16, 三国/关羽) every run of
// one question missed 2-4 members, the missing names differed every run, and in one run
// 车胄 / 夏侯存 / 程远志 / 管亥 appeared ZERO times in the whole log.
//
// The shape is `actor.*act`, one clause per pair, never `A|B.*T`: `|` binds loosest in Go's
// regexp, so that would read as "A" OR "B.*T" and lose the actor on every clause but the
// last. A subject that is itself an alternation (关羽|关公|云长) is used as declared; an empty
// subject falls back to the act word alone.
//
// Queries are grouped so that one call stays inside the operand budget the keyword leg
// already enforces (GrepTermsMax): each clause contributes its two literal operands.
func ScanPatterns(table State) []string {
perQuery := max(1, GrepTermsMax/2)
var out []string
for _, v := range table.State {
actors := []string{""}
if s := strings.TrimSpace(v.Subject); s != "" {
actors = nil
for _, a := range strings.Split(s, "|") {
if a = strings.TrimSpace(a); a != "" {
actors = append(actors, a)
}
}
if len(actors) == 0 {
actors = []string{""}
}
}
var clauses []string
for _, t := range v.Terms {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if actors[0] == "" {
clauses = append(clauses, regexp.QuoteMeta(t))
continue
}
for _, a := range actors {
clauses = append(clauses, regexp.QuoteMeta(a)+".*"+regexp.QuoteMeta(t))
}
}
for i := 0; i < len(clauses); i += perQuery {
out = append(out, strings.Join(clauses[i:min(i+perQuery, len(clauses))], "|"))
}
}
return out
}
@@ -1,201 +0,0 @@
package harness
import (
"context"
"strings"
"testing"
)
// TestScanPatternsAskForActorAndActTogether pins the shape of the completeness query.
//
// One clause per (actor, act) pair, never `A|B.*T`: `|` binds loosest in Go's regexp, so
// that would read as "A" OR "B.*T" and silently drop the actor from every clause but the
// last. And the actor is what makes the query do work the model's memory cannot: measured
// (2026-09-16, 三国/关羽) every run of one question missed 2-4 members, the missing names
// differed every run, and in one run four of them appeared ZERO times in the whole log.
func TestScanPatternsAskForActorAndActTogether(t *testing.T) {
table := NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"斩", "杀"}, Subject: "关羽|关公|云长"},
{ID: 1, Type: "person"},
}, 0, nil)
got := ScanPatterns(table)
if len(got) == 0 {
t.Fatal("no patterns for a table that declared two act words and three aliases")
}
clauses := 0
for _, q := range got {
parts := strings.Split(q, "|")
if len(parts) > GrepTermsMax/2 {
t.Errorf("query %q carries %d clauses, want at most %d (each clause is two operands)", q, len(parts), GrepTermsMax/2)
}
for _, part := range parts {
clauses++
if !strings.Contains(part, ".*") {
t.Errorf("clause %q does not ask for actor AND act together", part)
}
}
}
if clauses != 6 {
t.Errorf("clauses = %d, want 6 (3 aliases × 2 act words)", clauses)
}
if !strings.Contains(strings.Join(got, "|"), "云长.*斩") {
t.Errorf("patterns %v, want every declared alias paired with every act word", got)
}
// No subject declared: the act word itself, which is all the direction gave.
bare := ScanPatterns(NewState([]Variable{{ID: 0, Type: "count", Terms: []string{"斩"}}}, 0, nil))
if len(bare) != 1 || bare[0] != "斩" {
t.Errorf("patterns = %v, want the act word alone when no actor was declared", bare)
}
// No declaration, no queries: a table that named no act word pays nothing.
if p := ScanPatterns(NewState([]Variable{{ID: 0, Type: "date", Candidate: strPtr("1858")}}, 0, nil)); len(p) != 0 {
t.Errorf("patterns = %v, want none without a declaration", p)
}
}
// stubPatternRunner answers one completeness pattern with the chunks a test staged for it.
type stubPatternRunner struct {
calls []string
byPattern map[string][]map[string]any
}
func (s *stubPatternRunner) RunPattern(_ context.Context, pattern string) []map[string]any {
s.calls = append(s.calls, pattern)
return s.byPattern[pattern]
}
// TestCompletenessPassRunsWhatItRendered pins the fix for a measured failure: the patterns
// were RENDERED, and nobody ran them.
//
// Measured (2026-09-16, 三国/关羽, one round): ten act words with several aliases each came
// to 2175 characters of patterns, present in the seed of every session, and the run's query
// log holds ZERO `.*` queries — the sessions improvised space-separated word lists instead
// (`关羽 斩华雄 温酒`, `关公 砍死 斩 杀`). A list of queries in a prompt is advice, and the
// completeness of an enumeration cannot rest on advice. So the runtime asks the corpus
// itself, admits the windows, and seeds what came back.
func TestCompletenessPassRunsWhatItRendered(t *testing.T) {
table := NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"斩", "劈"}, Subject: "关羽|云长|关公"},
{ID: 1, Type: "dataset"},
}, 0, nil)
patterns := ScanPatterns(table)
if len(patterns) < 2 {
t.Fatalf("patterns = %v, want at least two queries (the declaration has six clauses)", patterns)
}
window := map[string]any{"chunk_id": "c1", "content_with_weight": "云长手起刀落,斩孔秀于马下"}
runner := &stubPatternRunner{byPattern: map[string][]map[string]any{patterns[0]: {window}}}
kb := &Kbinfos{}
pass := RunCompletenessPass(context.Background(), runner, kb, table)
if len(runner.calls) != len(patterns) {
t.Fatalf("ran %d pattern(s), want every declared one (%d): %v", len(runner.calls), len(patterns), runner.calls)
}
if pass.Asked != len(patterns) || pass.Answered != 1 || pass.Admitted != 1 {
t.Errorf("pass = asked %d / answered %d / admitted %d, want %d / 1 / 1",
pass.Asked, pass.Answered, pass.Admitted, len(patterns))
}
if !strings.Contains(pass.Text, "c1") || !strings.Contains(pass.Text, "斩孔秀于马下") {
t.Errorf("block %q, want the window with the chunk id a member cites", pass.Text)
}
if !strings.Contains(pass.Text, "(no window)") {
t.Errorf("block %q, want the empty pattern stated as asked-and-empty — otherwise the session cannot tell "+
"\"the corpus has no such passage\" from \"nobody looked\"", pass.Text)
}
for _, p := range patterns {
if !strings.Contains(pass.Text, p) {
t.Errorf("block %q does not name the pattern %q it ran", pass.Text, p)
}
}
// The window is EVIDENCE: it must be in the pool, under the id the block cites, or the
// session can read it and cite nothing.
if kb.PoolSize() != 1 || ChunkIDOf(kb.Chunks[0]) != "c1" {
t.Errorf("pool = %d chunk(s) %v, want the matched window admitted", kb.PoolSize(), kb.Chunks)
}
// A table that holds NO NAME pays nothing: the planner declares act words for "a count
// of things someone DID" too, and no name a pass could return changes a count of events
// (measured 2026-09-16, FRAMES: two such questions, ~18% of the run's tokens).
countsOnly := NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"won", "trophy"}, Subject: "Brazil"},
}, 0, nil)
quiet := &stubPatternRunner{}
if pass := RunCompletenessPass(context.Background(), quiet, kb, countsOnly); pass.Asked != 0 || len(quiet.calls) != 0 {
t.Errorf("ran %v for a table with no name slot, want nothing", quiet.calls)
}
// And a run with no runner is not a panic but a fallback: the seed keeps the list.
if pass := RunCompletenessPass(context.Background(), nil, kb, table); pass.Text != "" {
t.Errorf("pass without a runner = %q, want nothing", pass.Text)
}
}
// TestEnumerationSeedCarriesFindingsInsteadOfTheList pins what the session reads when the
// pass has run: the windows, not the queries it would have to think of.
func TestEnumerationSeedCarriesFindingsInsteadOfTheList(t *testing.T) {
loader := StringPromptLoader{"action_set": "SET / COUNT directions — the member list IS the work"}
table := NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Subject: "关羽|云长"},
{ID: 1, Type: "dataset"},
}, 0, nil)
// No pass yet: the fallback is the pattern list, marked as queries to make.
seed := enumerationSeed(table, loader, "")
if !strings.Contains(seed, "关羽.*斩|云长.*斩") {
t.Fatalf("fallback seed %q, want the rendered patterns", seed)
}
findings := "## The completeness pass ALREADY RAN for this direction\n\n- 关羽.*斩 →\n chunk_id=c1 \"斩孔秀\""
seed = enumerationSeed(table, loader, findings)
if !strings.Contains(seed, "chunk_id=c1") {
t.Errorf("seed %q, want the pass's windows", seed)
}
if !strings.Contains(seed, "SET / COUNT directions") {
t.Errorf("seed %q, want the method still travelling with the windows", seed)
}
if strings.Contains(seed, "one call per line") {
t.Errorf("seed %q still lists patterns to make — the windows replaced them", seed)
}
}
// TestEnumerationShapedSeparatesSetsFromValues pins the split the two strategies need: a set of NAMED
// members is not a count of events, and neither is a question about one named thing.
//
// Measured (2026-09-16, FRAMES, 4 questions in flight, 300s deadline): with the completeness pass
// gated on MemberShaped alone it ran on 7 of the 20 questions — every one of them single-value — and
// the run finished 0.833 with two timeouts against 0.875 with none. The gate is the conjunction of
// the planner's OWN three statements, so nothing is read from a slot's text or from the question.
func TestEnumerationShapedSeparatesSetsFromValues(t *testing.T) {
set := NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"斩", "杀"}, Subject: "关羽|云长"},
{ID: 1, Type: "dataset"},
}, 0, nil)
if !EnumerationShaped(set) {
t.Error("a count over named members with declared act words IS an enumeration")
}
cases := []struct {
name string
table State
}{
{"a count of events (no slot holds names)", NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"won", "trophy"}, Subject: "Brazil"},
}, 0, nil)},
{"a named thing with act words but no count/set/list", NewState([]Variable{
{ID: 0, Type: "person", Terms: []string{"wrote"}},
{ID: 1, Type: "date"},
}, 0, nil)},
{"a count with nothing declared to enumerate", NewState([]Variable{
{ID: 0, Type: "count", Candidate: strPtr("18")},
{ID: 1, Type: "dataset"},
}, 0, nil)},
{"a single value", NewState([]Variable{
{ID: 0, Type: "date", Candidate: strPtr("1858")},
}, 0, nil)},
}
for _, tc := range cases {
if EnumerationShaped(tc.table) {
t.Errorf("%s must NOT be an enumeration: the enumeration machinery (method seed, completeness pass, roll call) costs a value question its budget", tc.name)
}
}
}
@@ -1,357 +0,0 @@
//
// 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 advanced_rag
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/cloudwego/eino/schema"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/advanced_rag/slots"
)
// Member roll call: the enumeration's WRITE-BACK, done by the runtime.
//
// Measured (2026-09-16, 三国/关羽): the evidence was never the limit. The corpus holds nineteen named
// kills, the pool held 244 passages, and runs of ONE build answered 18 / 16 / 15 / 14 / 12 members —
// eleven of them the same every single time, the rest a different handful of the other nine. What
// varies is what a session WRITES: each session patched 12-14 names, and the round's union was
// whichever list happened to be longest. Two failures were measured on the way: a session probed a
// name with the verb glued to it (`斩夏侯存`) — the corpus does not word it that way, the probe came
// back empty, and the session read that as "the corpus does not carry this member"; and
// action_set.md's own note says sessions spend every turn searching and patch last, when the budget
// is already gone.
//
// So the write-back stops being a session's memory. The runtime takes the candidates it ALREADY
// holds — the completeness pass's windows (each with the chunk id it came from) and the ledger of
// terms a probe reached (Kbinfos.ReachedTerms) — asks the model about EVERY one of them in ONE call,
// and writes the members itself. The judgement stays the model's: nothing here decides what a kill
// is, or which words mean one. What the model can no longer do is forget to write a member down, or
// skip a candidate in silence — and a verdict names a LINE, so the chunk id always comes from the
// line the runtime handed it, never from the reply.
// MemberRollCallPrompt is the whole instruction: one verdict per line, both lists.
//
// It is written around the QUESTION rather than around any one kind of answer: the same step runs on
// "which awards did X win" and on "who did X kill", so what a line has to do is state a member of
// the set the question asks for — the question itself is in the user message. Nothing here decides
// what counts as a member; that judgement is the model's, and it is the same judgement it makes
// inside a session. What changes is only WHO WRITES IT DOWN.
const MemberRollCallPrompt = "You are given numbered evidence lines from one fixed corpus, each with the chunk id it came from.\n" +
"For EVERY line decide one thing: does this line itself state a member of the set the question " +
"asks for — and if it does, what is that member called?\n" +
"Judge only what the line says, never what you know from elsewhere. A mention that is not a member " +
"(a plan, a promise, a denial, a pursuit, something that did not happen, somebody else's action, or " +
"a member the line does not name) is NOT one.\n" +
"Answer with JSON only, and put EVERY line number in exactly one of the two lists:\n" +
`{"members": [{"i": <line number>, "name": "<the member the line states>"}], "not_members": [<line numbers>]}` + "\n" +
"Use the line's own words for the name. Never invent a name, and never answer for a line you were not given."
// rollCallCandidate is one line put in front of the model.
type rollCallCandidate struct {
ChunkID string
Quote string
}
// RollCallResult is what one write-back produced, for the run log: how many candidates were asked
// about, how many the model took a position on, and how many members the runtime actually wrote.
type RollCallResult struct {
Asked int
Answered int
Written int
}
// The bounds are the ones the retrieval layer already enforces, restated where they are spent:
// Kbinfos caps the pool and the completeness pass caps its block, so a candidate list built from
// both arrives bounded; rollCallMaxCands keeps ONE prompt to a sane size, and rollCallQuoteChars
// keeps one line to the sentence that names the victim.
const (
rollCallQuoteChars = 300
rollCallMaxCands = 150
)
// RollCallMembers asks the model about every candidate the run already holds and writes the members
// it names. It returns nothing and writes nothing when there is no candidate the table does not
// already mention — an empty roll call costs no call at all.
func RollCallMembers(ctx context.Context, deps RAGTools, st *AgenticState, logger *log.Logger) RollCallResult {
if logger == nil {
logger = _LOG
}
res := RollCallResult{}
if st == nil || deps.Model == nil || st.KB == nil || len(st.SlotTable.State) == 0 {
return res
}
// Same gate as the completeness pass (see harness.EnumerationShaped): the roll call belongs to the
// ENUMERATION strategy, so a single-value question — even one that contains a count — pays
// nothing for it.
if !harness.EnumerationShaped(st.SlotTable) {
return res
}
// And the stronger gate, which is the one that keeps the step off every question that is not a
// set: the members go into a slot that ALREADY holds members, so a table whose slots hold values
// (one name, one date, one number, one phrase) is not enumerated at all and this step spends
// nothing — not even the call (see rollCallTargetSlot).
slotID := rollCallTargetSlot(&st.SlotTable)
if slotID < 0 {
return res
}
cands := rollCallCandidates(st)
if len(cands) == 0 {
return res
}
res.Asked = len(cands)
// Bounded like every other single-purpose call in this graph (see RewriteTimeoutS): the answer
// composition still has to fit inside the request's clock, so this step may not spend it.
t := min(RollCallTimeoutS, max(10.0, st.RemainingS()-10.0))
callCtx, cancel := context.WithTimeout(ctx, time.Duration(t*float64(time.Second)))
defer cancel()
reply, err := deps.Model.Complete(callCtx, []schema.Message{
*schema.SystemMessage(MemberRollCallPrompt),
*schema.UserMessage(rollCallQuestion(st, cands)),
}, nil)
if err != nil {
// A failed call costs the members it would have written, not the answer: the table still
// holds everything the sessions wrote.
logger.Printf("[RollCall] %d candidate(s) could not be checked: %v", res.Asked, err)
return res
}
members, answered := parseRollCallVerdicts(reply.Content, cands)
res.Answered = answered
if answered < res.Asked {
// Said out loud because it is the one number that says whether "every line gets a verdict"
// held: an unjudged line is a candidate nobody looked at, which is the failure this whole
// step exists to remove.
logger.Printf("[RollCall] %d of %d candidate(s) got no verdict", res.Asked-answered, res.Asked)
}
if len(members) == 0 {
logger.Printf("[RollCall] %d candidate(s) asked, %d answered, 0 member(s) named", res.Asked, res.Answered)
return res
}
before := len(memberUnion(&st.SlotTable))
value := slots.Members(members...)
rendered := slots.Render(value)
// 1.0, and the number is a statement about the EVIDENCE rather than a claim: every member here
// was just matched to the line that names it, so the list is accountable in a way a session's
// prose list is not (see slots.Union for why a member list outranks prose).
strength := 1.0
branch := harness.NewState([]harness.Variable{{
ID: slotID,
Value: &value,
Candidate: &rendered,
CandidateStrength: &strength,
}}, st.SlotTable.Depth+1, nil)
if merged := MergeSlotPatch(st.SlotTable, branch); merged != nil {
st.SlotTable = *merged
}
res.Written = len(memberUnion(&st.SlotTable)) - before
if raised := reconcileCountSlots(&st.SlotTable); len(raised) > 0 {
logger.Printf("[RollCall] count slot(s) %v set to the enumerated members' size", raised)
}
// The answer reads the record, and the record was rendered BEFORE this write-back: refresh it,
// or the members the roll call just proved would be invisible to the answer that must list them.
st.KB.Record = RenderSlotRecord(st.SlotTable, st.CollectedAnswer)
logger.Printf("[RollCall] %d candidate(s) asked, %d answered, %d member(s) written into slot %d",
res.Asked, res.Answered, res.Written, slotID)
return res
}
// rollCallCandidates collects what the run already holds and is not in a slot yet: the completeness
// pass's windows, and the terms a probe REACHED (a name whose passage came back — a member with its
// evidence, whatever the session did with it afterwards).
func rollCallCandidates(st *AgenticState) []rollCallCandidate {
known := map[string]bool{}
for _, name := range memberUnion(&st.SlotTable) {
known[strings.ToLower(strings.TrimSpace(name))] = true
}
seen := map[string]bool{}
out := make([]rollCallCandidate, 0, 32)
add := func(id, quote string) {
id, quote = strings.TrimSpace(id), strings.TrimSpace(quote)
if id == "" || quote == "" || seen[id+"\x00"+quote] {
return
}
seen[id+"\x00"+quote] = true
out = append(out, rollCallCandidate{ChunkID: id, Quote: truncateRunes(quote, rollCallQuoteChars)})
}
if block, ok := st.KB.PatternFindings(); ok {
for _, w := range parseBlockWindows(block) {
add(w[0], w[1])
}
}
for _, rt := range st.KB.ReachedTerms() {
name := strings.TrimSpace(rt.Term)
if name == "" || rt.ChunkID == "" || known[strings.ToLower(name)] {
continue
}
quote := name
if c := st.KB.ChunkByID(rt.ChunkID); c != nil {
if text := strings.Join(strings.Fields(harness.ChunkTextOf(c)), " "); text != "" {
quote = text
}
}
add(rt.ChunkID, quote)
}
if len(out) > rollCallMaxCands {
out = out[:rollCallMaxCands]
}
return out
}
// parseBlockWindows reads the windows out of a completeness-pass block, whose lines render as
// ` chunk_id=<id> "<quote>"`.
func parseBlockWindows(block string) [][2]string {
var out [][2]string
for _, line := range strings.Split(block, "\n") {
i := strings.Index(line, "chunk_id=")
if i < 0 {
continue
}
rest := line[i+len("chunk_id="):]
end := strings.IndexAny(rest, " \t\"")
if end <= 0 {
continue
}
q1 := strings.Index(rest, "\"")
q2 := strings.LastIndex(rest, "\"")
if q1 < 0 || q2 <= q1 {
continue
}
out = append(out, [2]string{rest[:end], rest[q1+1 : q2]})
}
return out
}
// rollCallQuestion renders the call's user message: the question, the actor's declared forms, and
// every candidate on its own numbered line.
func rollCallQuestion(st *AgenticState, cands []rollCallCandidate) string {
actor := ""
for _, v := range st.SlotTable.State {
if s := strings.TrimSpace(v.Subject); s != "" {
actor = s
break
}
}
var b strings.Builder
fmt.Fprintf(&b, "Question: %s\n", st.Question)
if actor != "" {
fmt.Fprintf(&b, "Actor (the forms this direction named): %s\n", actor)
}
b.WriteString("Evidence lines:\n")
for i, c := range cands {
fmt.Fprintf(&b, "[%d] chunk_id=%s \"%s\"\n", i, c.ChunkID, c.Quote)
}
b.WriteString("\nAnswer with the JSON object and nothing else.")
return b.String()
}
// parseRollCallVerdicts turns a reply into members, taking each member's chunk id from the LINE the
// model judged: a verdict only has to get the line number right, so a fabricated citation cannot
// reach the record. answered counts the lines a position was taken on, in either list.
func parseRollCallVerdicts(content string, cands []rollCallCandidate) ([]slots.Member, int) {
obj, _ := extractJSONObject(content).(map[string]any)
if obj == nil {
return nil, 0
}
answered := map[int]bool{}
seenName := map[string]bool{}
var members []slots.Member
for _, raw := range rollCallItems(obj["members"]) {
entry, _ := raw.(map[string]any)
if entry == nil {
continue
}
i, ok := rollCallInt(entry["i"])
name := strings.TrimSpace(anyString(entry["name"]))
if !ok || name == "" || i < 0 || i >= len(cands) {
continue
}
answered[i] = true
if key := strings.ToLower(name); !seenName[key] {
seenName[key] = true
members = append(members, slots.Member{Name: name, ChunkID: cands[i].ChunkID, Quote: cands[i].Quote})
}
}
for _, raw := range rollCallItems(obj["not_members"]) {
if i, ok := rollCallInt(raw); ok && i >= 0 && i < len(cands) {
answered[i] = true
}
}
return members, len(answered)
}
// rollCallTargetSlot is the slot the members go into: the member slot that ALREADY holds the most
// members — the enumeration's own list slot, the one the record and the answer read. -1 when the
// table holds no members at all.
//
// The restraint is deliberate, and it is what keeps this step off every other kind of question: a
// table whose slots hold VALUES (a name, a date, a number, a phrase) is not enumerating anything, so
// there is nothing to add to and nothing is written — no prose is replaced, no single answer is
// turned into a list. Whether a table IS enumerating is therefore the model's own earlier decision
// (it wrote members), not a reading of a slot's type here.
func rollCallTargetSlot(table *harness.State) int {
if table == nil {
return -1
}
best, bestN := -1, -1
for _, v := range table.State {
if v.Typed().Kind != slots.KindMembers {
continue
}
if n := len(v.Typed().Names()); n > bestN {
best, bestN = v.ID, n
}
}
return best
}
// rollCallItems normalises a JSON list, whichever shape the decoder produced for it.
func rollCallItems(v any) []any {
switch t := v.(type) {
case []any:
return t
case []map[string]any:
out := make([]any, 0, len(t))
for _, m := range t {
out = append(out, m)
}
return out
}
return nil
}
// rollCallInt reads a line number, which a model may hand back as a number or as a string.
func rollCallInt(v any) (int, bool) {
switch t := v.(type) {
case float64:
return int(t), t == float64(int(t))
case int:
return t, true
case string:
var n int
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%d", &n); err == nil {
return n, true
}
}
return 0, false
}
@@ -1,203 +0,0 @@
//
// 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 advanced_rag
import (
"bytes"
"context"
"log"
"strings"
"testing"
"github.com/cloudwego/eino/schema"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/advanced_rag/slots"
)
// rollCallModel answers with one canned reply and counts the calls, so a test can assert that the
// roll call did NOT spend a call when it had nothing to ask.
type rollCallModel struct {
calls int
reply string
}
func (m *rollCallModel) Complete(_ context.Context, _ []schema.Message, _ []harness.ToolSpec) (*harness.ModelReply, error) {
m.calls++
return &harness.ModelReply{Content: m.reply}, nil
}
func memberSlotValue(names ...string) *slots.Value {
items := make([]slots.Member, 0, len(names))
for _, n := range names {
items = append(items, slots.Member{Name: n, ChunkID: "seed-" + n})
}
v := slots.Members(items...)
return &v
}
// TestRollCallWritesWhatTheModelNames pins the write-back the runtime owns: the candidates are the
// evidence the run ALREADY holds (the completeness pass's windows, and the names a probe reached),
// the model answers about every one of them, and the members land in the table whatever a session
// did or forgot.
//
// Measured (2026-09-16, 三国/关羽): runs of one build answered 18 / 16 / 15 / 14 / 12 members with the
// same corpus in hand — the same eleven names every time plus a different handful of the other nine —
// because what varied was what a session patched.
func TestRollCallWritesWhatTheModelNames(t *testing.T) {
kb := &harness.Kbinfos{}
kb.StorePatternFindings("## The completeness pass ALREADY RAN for this direction\n\n" +
"- 关羽.*斩 →\n" +
" chunk_id=w1 \"云长手起刀落,斩杨龄于马下\"\n" +
" chunk_id=w2 \"关公所历关隘五处,斩将六员\"\n")
// A name a probe reached, with the passage that carries it: a member with its evidence,
// whatever the session did with it.
kb.Admit(func(p *harness.PoolAdmitter) {
p.Add(map[string]any{"chunk_id": "w3", "content_with_weight": "只一合被云长砍死"})
})
kb.RecordReachedTerm("夏侯存", "w3")
table := harness.NewState([]harness.Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Subject: "关羽|云长"},
{ID: 1, Type: "dataset", Value: memberSlotValue("华雄"), Candidate: strPtr("华雄"), CandidateStrength: testFloatPtr(0.9)},
}, 0, nil)
st := &AgenticState{Question: "三国演义中,关羽杀了多少有姓名的人物?", KB: kb, SlotTable: table}
model := &rollCallModel{reply: `{"members": [{"i": 0, "name": "杨龄"}, {"i": 2, "name": "夏侯存"}], "not_members": [1]}`}
deps := RAGTools{Model: model}
var buf bytes.Buffer
res := RollCallMembers(context.Background(), deps, st, log.New(&buf, "", 0))
if model.calls != 1 {
t.Fatalf("model calls = %d, want exactly one", model.calls)
}
if res.Asked != 3 || res.Answered != 3 || res.Written != 2 {
t.Errorf("roll call = asked %d / answered %d / written %d, want 3 / 3 / 2", res.Asked, res.Answered, res.Written)
}
got := map[string]bool{}
for _, n := range memberUnion(&st.SlotTable) {
got[n] = true
}
for _, want := range []string{"华雄", "杨龄", "夏侯存"} {
if !got[want] {
t.Errorf("members %v, want %q — the roll call's verdicts plus what the slot already held", got, want)
}
}
// The chunk id comes from the LINE the model judged, so a citation cannot be invented.
for _, v := range st.SlotTable.State {
if v.ID != 1 {
continue
}
byName := map[string]string{}
for _, m := range v.Typed().Items {
byName[m.Name] = m.ChunkID
}
if byName["杨龄"] != "w1" || byName["夏侯存"] != "w3" {
t.Errorf("member evidence = %v, want each name cited by the line it was judged on", byName)
}
}
// The count is derived from the members, so it has to follow them, and the record — which the
// answer reads — must show what was just written.
if c := st.SlotTable.State[0].Candidate; c == nil || *c != "3" {
t.Errorf("count slot = %v, want 3 (the enumerated members' size)", c)
}
if !strings.Contains(kb.Record, "杨龄") {
t.Errorf("record %q does not mention the member the roll call wrote", kb.Record)
}
if !strings.Contains(buf.String(), "3 candidate(s) asked, 3 answered, 2 member(s) written") {
t.Errorf("log = %q, want the accounting line", buf.String())
}
}
// TestRollCallIgnoresVerdictsForLinesItNeverHandedOver pins the one thing a reply cannot do: cite a
// passage of its own choosing. A verdict names a LINE, and a line number outside the list is dropped.
func TestRollCallIgnoresVerdictsForLinesItNeverHandedOver(t *testing.T) {
kb := &harness.Kbinfos{}
kb.StorePatternFindings("- 关羽.*斩 →\n chunk_id=w1 \"云长手起刀落,斩杨龄于马下\"\n")
table := harness.NewState([]harness.Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Subject: "关羽"},
{ID: 1, Type: "dataset", Value: memberSlotValue("华雄"), Candidate: strPtr("华雄")},
}, 0, nil)
st := &AgenticState{Question: "关羽杀了多少有姓名的人物?", KB: kb, SlotTable: table}
deps := RAGTools{Model: &rollCallModel{
reply: `{"members": [{"i": 9, "name": "张凯"}, {"i": 0, "name": "杨龄"}], "not_members": [7]}`,
}}
res := RollCallMembers(context.Background(), deps, st, log.New(&bytes.Buffer{}, "", 0))
if res.Asked != 1 || res.Answered != 1 || res.Written != 1 {
t.Errorf("roll call = asked %d / answered %d / written %d, want 1 / 1 / 1", res.Asked, res.Answered, res.Written)
}
for _, n := range memberUnion(&st.SlotTable) {
if n == "张凯" {
t.Error("a verdict for a line that was never handed over was written into the table")
}
}
}
// TestRollCallSkipsWhenThereIsNothingToAsk pins that the step costs nothing when the evidence and the
// ledger have nothing the table does not already mention: no candidates, no call.
func TestRollCallSkipsWhenThereIsNothingToAsk(t *testing.T) {
kb := &harness.Kbinfos{}
table := harness.NewState([]harness.Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Subject: "关羽"},
{ID: 1, Type: "dataset", Value: memberSlotValue("华雄"), Candidate: strPtr("华雄")},
}, 0, nil)
st := &AgenticState{Question: "关羽杀了多少有姓名的人物?", KB: kb, SlotTable: table}
model := &rollCallModel{reply: `{"members": [], "not_members": []}`}
if res := RollCallMembers(context.Background(), RAGTools{Model: model}, st, log.New(&bytes.Buffer{}, "", 0)); res.Asked != 0 {
t.Errorf("asked = %d, want 0", res.Asked)
}
if model.calls != 0 {
t.Errorf("model calls = %d, want none: an empty roll call must not spend a call", model.calls)
}
}
// TestRollCallLeavesAValueTableAlone pins the restraint that keeps this step off every question that
// is not enumerating anything: when no slot holds members, nothing is written and no prose is
// replaced by a list. A table whose slots hold VALUES (a name, a date, a number, a phrase) is not
// enumerating, and a single-value answer must stay what it is — which is what makes this step safe on
// the questions that are not sets.
func TestRollCallLeavesAValueTableAlone(t *testing.T) {
kb := &harness.Kbinfos{}
kb.StorePatternFindings("- 关羽.*斩 →\n chunk_id=w1 \"云长手起刀落,斩杨龄于马下\"\n")
prose := "据网文统计共14人:华雄、颜良、文丑……"
table := harness.NewState([]harness.Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Subject: "关羽"},
{ID: 1, Type: "dataset", Candidate: &prose, CandidateStrength: testFloatPtr(0.95)},
}, 0, nil)
st := &AgenticState{Question: "关羽杀了多少有姓名的人物?", KB: kb, SlotTable: table}
model := &rollCallModel{reply: `{"members": [{"i": 0, "name": "杨龄"}], "not_members": []}`}
res := RollCallMembers(context.Background(), RAGTools{Model: model}, st, log.New(&bytes.Buffer{}, "", 0))
if res.Asked != 0 || res.Written != 0 {
t.Fatalf("roll call = asked %d / written %d, want nothing: the table holds no members", res.Asked, res.Written)
}
if model.calls != 0 {
t.Errorf("model calls = %d, want none — a table of values must not spend a call", model.calls)
}
v := st.SlotTable.State[1]
if v.Typed().Kind == slots.KindMembers {
t.Errorf("slot now holds a member list, want the value it held untouched")
}
if v.Candidate == nil || *v.Candidate != prose {
t.Errorf("slot candidate = %v, want the prose it held", v.Candidate)
}
}
func testFloatPtr(f float64) *float64 { return &f }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -14,7 +14,7 @@
// limitations under the License.
//
package advanced_rag
package agentic_rag
import (
"context"
@@ -30,7 +30,7 @@ import (
"github.com/cloudwego/eino/schema"
"ragflow/internal/entity/models"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/agentic-rag/runtime"
)
type muxChunk struct {
@@ -68,8 +68,8 @@ func TestOuterStreamMuxRoutesThinkThenAnswer(t *testing.T) {
}
}
// Python drops outer text once the terminal tool fired, because what follows is
// the aggregate tool result and the answer already streamed from inside.
// Outer text is dropped once the terminal tool fired, because what follows is the aggregate
// tool result and the answer already streamed from inside.
func TestOuterStreamMuxDropsTextAfterTerminalFired(t *testing.T) {
mux, got := newTestMux()
mux.markTerminal()
@@ -144,8 +144,8 @@ func TestQuestionKeywordsSeparatesNumbers(t *testing.T) {
}
func TestCacheSimilarCollapsesReask(t *testing.T) {
// Python's observed re-ask: "legal population" → "estimated population of
// Paris in 2019" (overlap 0.75 while numbers match).
// An observed re-ask: "legal population" → "estimated population of Paris in 2019"
// (overlap 0.75 while numbers match).
a := questionKeywords("population of Paris 2019")
b := questionKeywords("legal population of Paris in 2019")
@@ -194,7 +194,7 @@ func TestCacheLookupMissesUnrelatedQuestion(t *testing.T) {
}
func TestCacheReuseBlockedAfterInsufficientRound(t *testing.T) {
// Python :848-852 — when the last round was not SUFFICIENT the caller is
// when the last round was not SUFFICIENT the caller is
// asking again for more evidence, so the cached answer must not be reused.
c := NewRAGCache()
c.Store("population of Paris 2019", "2.1 million")
@@ -211,10 +211,9 @@ func TestCacheReuseBlockedAfterInsufficientRound(t *testing.T) {
}
func TestRagDefaultsToPerTurnCache(t *testing.T) {
// Go mirrors Python's default-on, per-turn _rag_cache: Rag auto-builds a
// RAGCache when deps.Cache is nil, so caching is never off. Two independent
// instances never share, matching Python's per-turn RAGTools._rag_cache
// (rebuilt every turn).
// Caching is default-on and per-turn: Rag auto-builds a RAGCache when deps.Cache is nil, so
// caching is never off, and two independent instances never share (the cache is rebuilt
// every turn).
a := NewRAGCache()
b := NewRAGCache()
a.Store("population of Paris 2019", "2.1 million")
@@ -286,7 +285,7 @@ func TestResolveEffectiveQuestionHandlesEmpty(t *testing.T) {
// TestResearchStatusTrailerStopsAfterTwoUnanswerable asserts that once the
// shared counter has reached 2, Rag() appends the "STOP calling rag again"
// trailing sentence (Python rag:902-929) for an INSUFFICIENT verdict — the
// trailing sentence for an INSUFFICIENT verdict — the
// visible effect of the consecutive-unanswerable guard across multiple outer
// rag() calls.
func TestResearchStatusTrailerStopsAfterTwoUnanswerable(t *testing.T) {
@@ -359,7 +358,7 @@ type streamingModel struct {
fail bool
}
func (m *streamingModel) Complete(_ context.Context, _ []schema.Message, _ []harness.ToolSpec) (*harness.ModelReply, error) {
func (m *streamingModel) Complete(_ context.Context, _ []schema.Message, _ []runtime.ToolSpec) (*runtime.ModelReply, error) {
if m.fail {
return nil, errors.New("boom")
}
@@ -367,10 +366,10 @@ func (m *streamingModel) Complete(_ context.Context, _ []schema.Message, _ []har
for _, p := range m.pieces {
out += p
}
return &harness.ModelReply{Content: out}, nil
return &runtime.ModelReply{Content: out}, nil
}
func (m *streamingModel) StreamComplete(_ context.Context, _ []schema.Message, _ []harness.ToolSpec, onDelta func(string, bool) error) (*harness.ModelReply, error) {
func (m *streamingModel) StreamComplete(_ context.Context, _ []schema.Message, _ []runtime.ToolSpec, onDelta func(string, bool) error) (*runtime.ModelReply, error) {
if m.fail {
return nil, errors.New("stream boom")
}
@@ -383,7 +382,7 @@ func (m *streamingModel) StreamComplete(_ context.Context, _ []schema.Message, _
}
out += p
}
return &harness.ModelReply{Content: out}, nil
return &runtime.ModelReply{Content: out}, nil
}
func TestComposeAnswerStreamForwardsDeltas(t *testing.T) {
@@ -444,8 +443,7 @@ func TestNilAnswerSinkIsInert(t *testing.T) {
func TestGetCitationGuidelinesUsesDefaultWithoutOverride(t *testing.T) {
got := GetCitationGuidelines("")
// The default is the embedded citation_prompt.md (mirrors Python
// load_prompt("citation_prompt")). It must contain the citation-rules body.
// The default is the embedded citation_prompt.md. It must contain the citation-rules body.
if !strings.Contains(got, "# Citation Requirements:") ||
!strings.Contains(got, "Place citations at the end of sentences") {
t.Fatalf("got %q, want the default citation rules", got)
@@ -453,9 +451,8 @@ func TestGetCitationGuidelinesUsesDefaultWithoutOverride(t *testing.T) {
}
func TestGetCitationGuidelinesHonoursOverride(t *testing.T) {
// Python renders the user's template and STILL appends the
// illustrative-IDs caveat after it (generator.py:227-228 concatenates the
// suffix onto whatever template rendered).
// The user's template is rendered and the illustrative-IDs caveat is STILL appended after
// it.
got := GetCitationGuidelines("Cite as [n].")
if !strings.HasPrefix(got, "Cite as [n].") {
t.Fatalf("got %q, want the override first", got)
@@ -518,7 +515,7 @@ func TestFitEvidenceTrimsOversizedEvidence(t *testing.T) {
// round's rag calls run concurrently, so each publishes its own evidence and
// answer and none may be lost. Run with -race.
func TestOuterReactSessionPublishMergesPerCallResults(t *testing.T) {
session := &outerReactSession{kb: &harness.Kbinfos{}, resp: &RunResponse{}}
session := &outerReactSession{kb: &runtime.Kbinfos{}, resp: &RunResponse{}}
const calls = 4
var wg sync.WaitGroup
@@ -528,7 +525,7 @@ func TestOuterReactSessionPublishMergesPerCallResults(t *testing.T) {
defer wg.Done()
session.publish(
&RunResponse{Answer: fmt.Sprintf("answer-%d", i), Verdict: fmt.Sprintf("verdict-%d", i)},
&harness.Kbinfos{
&runtime.Kbinfos{
Chunks: []map[string]any{{"chunk_id": fmt.Sprintf("c%d", i)}},
DocAggs: []map[string]any{{"doc_id": fmt.Sprintf("d%d", i)}},
Memory: []map[string]any{{"id": fmt.Sprintf("m%d", i)}},
@@ -565,14 +562,14 @@ func TestOuterReactSessionPublishMergesPerCallResults(t *testing.T) {
// images to a COPY — never to the request a concurrent call is reading.
// Run with -race.
func TestOuterReactSessionToolCallKeepsSharedRequestIntact(t *testing.T) {
spec := harness.GetMode("naive")
spec := runtime.GetMode("naive")
session := &outerReactSession{
ctx: context.Background(),
spec: spec,
kb: &harness.Kbinfos{},
kb: &runtime.Kbinfos{},
resp: &RunResponse{Mode: spec},
logger: log.New(io.Discard, "", 0),
req: harness.RunRequest{
req: runtime.RunRequest{
Question: "original question",
Images: []string{"data:image/png;base64,AAAA"},
},
@@ -607,14 +604,14 @@ func TestOuterReactSessionNarratesToolCalls(t *testing.T) {
// summarize_document with no doc_id is the early-return branch: it needs no
// retrieval wiring, so the narration contract is testable in isolation.
var buf strings.Builder
spec := harness.GetMode("naive")
spec := runtime.GetMode("naive")
session := &outerReactSession{
ctx: context.Background(),
spec: spec,
kb: &harness.Kbinfos{},
kb: &runtime.Kbinfos{},
resp: &RunResponse{Mode: spec},
logger: log.New(&buf, "", 0),
req: harness.RunRequest{Question: "q"},
req: runtime.RunRequest{Question: "q"},
}
if _, err := session.ToolCall("summarize_document", map[string]interface{}{}); err != nil {
@@ -636,15 +633,15 @@ func TestOuterReactSessionNarratesToolCalls(t *testing.T) {
// an infra failure — and it carries the call's arguments like every other step.
func TestOuterReactSessionUnknownToolNarrates(t *testing.T) {
var buf strings.Builder
events := make(chan harness.ThinkEvent, 4)
spec := harness.GetMode("naive")
events := make(chan runtime.ThinkEvent, 4)
spec := runtime.GetMode("naive")
session := &outerReactSession{
ctx: context.Background(), spec: spec, kb: &harness.Kbinfos{},
ctx: context.Background(), spec: spec, kb: &runtime.Kbinfos{},
resp: &RunResponse{Mode: spec}, logger: log.New(&buf, "", 0),
req: harness.RunRequest{Question: "q"},
req: runtime.RunRequest{Question: "q"},
}
ctx := harness.WithSteps(context.Background(), harness.StepReporter{
Events: func(ev harness.ThinkEvent) { events <- ev },
ctx := runtime.WithSteps(context.Background(), runtime.StepReporter{
Events: func(ev runtime.ThinkEvent) { events <- ev },
})
session.ctx = ctx
@@ -656,14 +653,14 @@ func TestOuterReactSessionUnknownToolNarrates(t *testing.T) {
t.Errorf("think-log narration:\n%s", out)
}
var result harness.ThinkEvent
var result runtime.ThinkEvent
for len(events) > 0 {
ev := <-events
if ev.Kind == harness.ThinkKindToolResult {
if ev.Kind == runtime.ThinkKindToolResult {
result = ev
}
}
if result.Status != harness.StatusMiss || result.Reason != harness.ReasonUnwired {
if result.Status != runtime.StatusMiss || result.Reason != runtime.ReasonUnwired {
t.Errorf("result event = %#v, want a miss/unwired outcome", result)
}
if result.Args != `{"query":"tomorrow"}` {
@@ -716,7 +713,7 @@ func TestOuterLoopEndLine(t *testing.T) {
}
// TestOuterLoopAbsentLineStaysOutOfTheThinkBlock pins where that line goes: the
// developer log, not the think block. It describes the harness' wiring rather than
// developer log, not the think block. It describes the runtime' wiring rather than
// the question or the research, and a deployment without an outer loop would print
// it on every single run — while the reader-visible trace is simply and honestly
// missing its "[Tool loop]" section.
@@ -726,10 +723,10 @@ func TestOuterLoopEndLine(t *testing.T) {
// needs the log to tell a failed probe from a deployment that has no outer model).
func TestOuterLoopAbsentLineStaysOutOfTheThinkBlock(t *testing.T) {
var think, logged strings.Builder
ctx := harness.WithSteps(context.Background(), harness.StepReporter{
ctx := runtime.WithSteps(context.Background(), runtime.StepReporter{
Text: func(line string) { think.WriteString(line) },
})
Rag(ctx, RAGTools{Logger: log.New(&logged, "", 0)}, harness.RunRequest{
Rag(ctx, RAGTools{Logger: log.New(&logged, "", 0)}, runtime.RunRequest{
Question: "anything",
ThinkingMode: "low",
DatasetIDs: []string{"kb1"},
@@ -759,19 +756,19 @@ func TestRagToolResultLine(t *testing.T) {
cases := []struct {
name string
resp *RunResponse
kb *harness.Kbinfos
kb *runtime.Kbinfos
label string
want string
}{
{"grounded", &RunResponse{Answer: "cited answer"}, &harness.Kbinfos{Chunks: twoChunks}, label,
{"grounded", &RunResponse{Answer: "cited answer"}, &runtime.Kbinfos{Chunks: twoChunks}, label,
`The rag tool returned a cited answer grounded in 2 passages for "曹操是谁".`},
{"grounded-singular", &RunResponse{Answer: "a"}, &harness.Kbinfos{Chunks: twoChunks[:1]}, label,
{"grounded-singular", &RunResponse{Answer: "a"}, &runtime.Kbinfos{Chunks: twoChunks[:1]}, label,
`The rag tool returned a cited answer grounded in 1 passage for "曹操是谁".`},
{"no-evidence", &RunResponse{Answer: "answer"}, &harness.Kbinfos{}, label,
{"no-evidence", &RunResponse{Answer: "answer"}, &runtime.Kbinfos{}, label,
`The rag tool returned no answer for "曹操是谁": research gathered no evidence.`},
{"evidence-but-no-answer", &RunResponse{}, &harness.Kbinfos{Chunks: twoChunks}, label,
{"evidence-but-no-answer", &RunResponse{}, &runtime.Kbinfos{Chunks: twoChunks}, label,
`The rag tool gathered 2 passages but composed no answer for "曹操是谁".`},
{"unlabelled", &RunResponse{Answer: "a"}, &harness.Kbinfos{Chunks: twoChunks[:1]}, "",
{"unlabelled", &RunResponse{Answer: "a"}, &runtime.Kbinfos{Chunks: twoChunks[:1]}, "",
"The rag tool returned a cited answer grounded in 1 passage."},
}
for _, tc := range cases {
@@ -847,18 +844,17 @@ func TestRagFlightSharesConcurrentIdenticalCalls(t *testing.T) {
// TestOuterReactSessionToolCallWaitsOnInFlightRag pins the ToolCall-level
// behavior: a rag call whose question already has an in-progress execution
// blocks and replays that execution's answer WITHOUT running its own graph —
// no second publish, no second call record (Python's asyncio.gather runs both
// duplicates fully and the terminal fold discards the loser; this wait is the
// approved Go-side single-flight, see ragFlight).
// no second publish, no second call record (running both duplicates fully and discarding the
// loser is wasted work; this wait is the single-flight, see ragFlight).
func TestOuterReactSessionToolCallWaitsOnInFlightRag(t *testing.T) {
spec := harness.GetMode("naive")
spec := runtime.GetMode("naive")
session := &outerReactSession{
ctx: context.Background(),
spec: spec,
kb: &harness.Kbinfos{},
kb: &runtime.Kbinfos{},
resp: &RunResponse{Mode: spec},
logger: log.New(io.Discard, "", 0),
req: harness.RunRequest{Question: "original question"},
req: runtime.RunRequest{Question: "original question"},
}
owner, wait := session.beginRagFlight("the question")
@@ -934,14 +930,14 @@ func TestRAGCacheConsecutiveUnanswerableIsSerialized(t *testing.T) {
// publish() records completion order, so the session must hand back THAT call's
// evidence — otherwise the answer's [ID:n] markers address another call's chunks.
func TestOuterReactSessionSelectsWinningCallEvidence(t *testing.T) {
session := &outerReactSession{kb: &harness.Kbinfos{}, resp: &RunResponse{}}
winner := &harness.Kbinfos{
session := &outerReactSession{kb: &runtime.Kbinfos{}, resp: &RunResponse{}}
winner := &runtime.Kbinfos{
Chunks: []map[string]any{{"chunk_id": "w0"}, {"chunk_id": "w1"}},
DocAggs: []map[string]any{{"doc_id": "dw"}},
Memory: []map[string]any{{"id": "mw"}},
PreSummary: "winner summary",
}
loser := &harness.Kbinfos{Chunks: []map[string]any{{"chunk_id": "l0"}}}
loser := &runtime.Kbinfos{Chunks: []map[string]any{{"chunk_id": "l0"}}}
// The losing call finishes FIRST, i.e. completion order ≠ index order.
session.publish(&RunResponse{Answer: "answer-loser"}, loser)
@@ -965,8 +961,8 @@ func TestOuterReactSessionSelectsWinningCallEvidence(t *testing.T) {
// an answer no rag call produced (the terminal tool was summarize_document, or
// the outer model answered itself) leaves the union in place.
func TestOuterReactSessionSelectEvidenceKeepsUnionWithoutAMatch(t *testing.T) {
session := &outerReactSession{kb: &harness.Kbinfos{}, resp: &RunResponse{}}
session.publish(&RunResponse{Answer: "answer-a"}, &harness.Kbinfos{Chunks: []map[string]any{{"chunk_id": "a0"}}})
session := &outerReactSession{kb: &runtime.Kbinfos{}, resp: &RunResponse{}}
session.publish(&RunResponse{Answer: "answer-a"}, &runtime.Kbinfos{Chunks: []map[string]any{{"chunk_id": "a0"}}})
session.selectEvidence("a summarize_document result")
session.selectEvidence("")
@@ -1,11 +1,11 @@
package advanced_rag
package agentic_rag
import (
"context"
"log"
"time"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/agentic-rag/runtime"
)
// step reports one user-visible reasoning stage of the run: it writes the
@@ -17,7 +17,7 @@ import (
// that must never reach the user — no allowlist of tags, and rewording a log
// line can never hide (or leak) a step.
func step(ctx context.Context, logger *log.Logger, stage, format string, args ...any) {
harness.StepsFrom(ctx).Stage(logger, stage, format, args...)
runtime.StepsFrom(ctx).Stage(logger, stage, format, args...)
}
// trunc caps s at n RUNES, returning s unchanged when it already fits.
+362
View File
@@ -0,0 +1,362 @@
//
// 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 agentic_rag
import (
"context"
"fmt"
"log"
"sort"
"strings"
"time"
"ragflow/internal/rag/agentic-rag/runtime"
"ragflow/internal/rag/agentic-rag/slots"
)
// The enumeration's LAST node: the runtime asks the model about every window the
// enumeration found, and writes the members its verdicts name.
//
// Why this node exists at all: the evidence is not the limit — with the same corpus in
// hand, runs of one build answer different member counts. What varies is what a session
// WROTE: each session patches a handful of names and the round's union is whichever list
// happens to be longest. So the write-back stops being a session's memory and becomes the
// runtime's own step — every window gets a verdict, and a window nobody answered is
// reported as UNKNOWN rather than read as "the corpus does not say it".
//
// The judgement itself stays the model's (see runtime.CoverageResolvePrompt): nothing here
// decides what a kill is, or which words mean one.
func RunCoverageResolve(ctx context.Context, deps RAGTools, st *AgenticState, logger *log.Logger) runtime.ResolveStats {
if logger == nil {
logger = _LOG
}
stats := runtime.ResolveStats{}
if st == nil || deps.Model == nil || st.KB == nil || len(st.SlotTable.State) == 0 {
return stats
}
// The node belongs to the ENUMERATION strategy: a question whose answer is one value
// even one that contains a count — pays nothing for it (see runtime.Coverage.Ok).
cov := runtime.CoverageOf(st.SlotTable)
// Two ways in, and the second is why this node is the last one: the planner declared a set, OR
// the table's own slots hold items (see Coverage.ItemsInValue). A session patching members into
// a slot the planner typed "person" does not make the QUESTION a set — the record and the
// sessions keep reading the declaration — but it does mean this node has a set to complete.
if !cov.Ok() && !cov.ItemsInValue() {
// Said out loud. A node that returns in silence is indistinguishable from one that judged
// and found nothing, and this was the whole reason a run could enumerate ten names while
// the corpus stated sixteen (see enrollEnumeration).
logger.Printf("[Coverage] resolve skipped: this table is not an enumeration (set=%v itemKind=%q acts=%d items=%v) — nothing to judge here.", cov.Set, cov.ItemKind, len(cov.Acts), cov.ItemsInValue())
return stats
}
enrollEnumeration(ctx, deps, st, cov, logger)
// And the members go into a slot that ALREADY holds members, so a table whose slots
// hold values (one name, one date, one number, one phrase) is not enumerated at all
// and this step spends nothing — not even a call.
slotID := coverageTargetSlot(&st.SlotTable)
if slotID < 0 {
return stats
}
set, reached, claimed := coverageResolveCandidates(st.KB, &st.SlotTable, cov)
if set.Empty() {
// Nothing to judge: no enumeration ran (no clock left, or the executor cannot search)
// and no probe reached a name the slots do not already hold.
logger.Printf("[Coverage] resolve has nothing to judge: %d enumeration window(s), %d reached name(s), %d unjudged claim(s).", len(set.Windows), reached, claimed)
return stats
}
if reached+claimed > 0 {
// The split is worth a line: it is the difference between a node that judges only what
// its own enumeration recalled and one that also judges what the run's search touched
// and what the sessions ASSERTED but never showed a passage for.
logger.Printf("[Coverage] resolve candidates: %d window(s) + %d reached name(s) + %d unjudged claim(s) = %d line(s)",
len(set.Windows)-reached-claimed, reached, claimed, len(set.Windows))
}
// Bounded like every other single-purpose call in this graph: the answer composition
// still has to fit inside the request's clock, so this step may not spend it.
t := nodeClock(CoverageResolveTimeoutS, 10.0, st.RemainingS()-10.0)
callCtx, cancel := context.WithTimeout(ctx, time.Duration(t*float64(time.Second)))
defer cancel()
members, stats := runtime.ResolveCoverage(callCtx, deps.Model, st.Question, cov, set)
if stats.Failed > 0 {
// Said out loud because a failed call is members nobody checked — the failure this
// node exists to make visible, not to hide. It counts CALLS, so a window that had to
// be re-asked shows up here twice; what the caller must read is the UNKNOWN count,
// which is windows.
logger.Printf("[Coverage] %d of %d resolve call(s) failed; %d window(s) left UNKNOWN", stats.Failed, stats.Batches, stats.Unknown)
}
if stats.Unknown > 0 {
logger.Printf("[Coverage] %d of %d window(s) got no verdict — UNKNOWN, not absent", stats.Unknown, stats.Asked)
}
if len(members) == 0 {
logger.Printf("[Coverage] %d window(s) asked, %d answered, 0 member(s) named", stats.Asked, stats.Answered)
return stats
}
items := make([]slots.Item, 0, len(members))
for _, m := range members {
items = append(items, slots.Item{Value: m.Name, ChunkID: m.ChunkID, Quote: m.Quote})
}
value := slots.Items(items...)
rendered := slots.Render(value)
// 1.0, and the number is a statement about the EVIDENCE rather than a claim: every
// member here was just matched to the line that names it, so the list is accountable
// in a way a session's prose list is not (see slots.Union).
strength := 1.0
branch := runtime.NewState([]runtime.Variable{{
ID: slotID,
Value: &value,
Candidate: &rendered,
CandidateStrength: &strength,
}}, st.SlotTable.Depth+1, nil)
if merged := MergeSlotPatch(st.SlotTable, branch); merged != nil {
st.SlotTable = *merged
}
if raised := syncCountSlots(&st.SlotTable); len(raised) > 0 {
logger.Printf("[Coverage] count slot(s) %v set to the enumerated members' size", raised)
}
// The answer reads the record, and the record was rendered BEFORE this write-back:
// refresh it, or the members just proved would be invisible to the answer that must
// list them.
st.KB.Record = RenderSlotRecord(st.SlotTable, st.CollectedAnswer)
// The passages behind the items travel with the run (see Kbinfos.NoteCitedChunks): the compose
// prompt puts them in front of the answer, which must cite one passage per member and otherwise
// sees only the top-scoring handful.
st.KB.NoteCitedChunks(runtime.AnchoredItemChunks(&st.SlotTable))
if stats.Unknown > 0 {
// An enumeration that did not finish must not read as one that did. The windows with
// no verdict are members nobody judged, so the list above is a LOWER BOUND, and the
// record says so: a count the corpus cannot support is worse than a count that names
// its own gap, and only the record reaches the answer that states the number.
st.KB.Record += fmt.Sprintf("\n- NOTE: %d of the %d enumerated passage(s) got no verdict, so the members above are a LOWER BOUND — state the count as what it is and do not present it as exact. Nobody read: %s\n",
stats.Unknown, stats.Asked, strings.Join(cappedList(stats.Unjudged, 12), ", "))
}
logger.Printf("[Coverage] resolve done: asked=%d answered=%d unknown=%d member(s)=%d written into slot %d",
stats.Asked, stats.Answered, stats.Unknown, len(items), slotID)
return stats
}
// coverageTargetSlot is the slot the members go into: the member slot that already holds
// the most members — the enumeration's own list slot, the one the record and the answer
// read. -1 when the table holds no members at all.
func coverageTargetSlot(table *runtime.State) int {
if table == nil {
return -1
}
best, bestN := -1, -1
for _, v := range table.State {
if v.Typed().Kind != slots.KindItems {
continue
}
if n := len(v.Typed().ItemValues()); n > bestN {
best, bestN = v.ID, n
}
}
return best
}
// coverageResolveCandidates assembles the lines the point-of-naming node judges: the
// enumeration's windows, then the names a probe already REACHED and no slot holds yet.
//
// The second source is not bookkeeping — it is the difference between "the corpus was asked
// about this direction" and "the run's own search touched this name". A reached name has the
// pool passage that carries it, so it is a member with its evidence whatever the session did
// with it afterwards, and a node that judges only its own enumeration drops exactly those
// names: the run holds the passage, names it nowhere, and the count comes out short of what
// the corpus states.
//
// Both sources are deduped before they are asked about: a name that is already SETTLED, and a
// name a window of the SAME passage already quotes, are not put in front of the model twice.
// Settled means anchored (see runtime.AnchoredItems): a name a session wrote without a passage is
// a claim nobody has ruled on, so it stays a candidate — which is what gives the run a way to
// refute a claim it holds the refuting passage for.
//
// Returns the union, how many lines the reached terms contributed, and how many unanchored claims
// were handed over.
func coverageResolveCandidates(kb *runtime.Kbinfos, table *runtime.State, cov runtime.Coverage) (runtime.CoverageSet, int, int) {
var set runtime.CoverageSet
if kb == nil {
return set, 0, 0
}
set, _ = kb.CoverageSet()
// Only anchored items are settled: a name written without a passage has not been judged, so it
// stays a candidate here.
known := map[string]bool{}
for _, name := range runtime.AnchoredItems(table) {
known[strings.ToLower(strings.TrimSpace(name))] = true
}
added := 0
for _, rt := range kb.ReachedTerms() {
term := strings.TrimSpace(rt.Term)
key := strings.ToLower(term)
if term == "" || rt.ChunkID == "" || known[key] {
continue
}
if windowQuotesTerm(set.Windows, rt.ChunkID, term) {
continue
}
// The words are what makes the name answerable: a member no line can point at is a
// member nobody can cite, so a reached term whose passage is no longer in the pool is
// left to the record's own ledger instead of being handed over as a bare name.
quote := ledgerQuote(kb, rt.ChunkID, term)
if quote == "" {
continue
}
set.Windows = append(set.Windows, runtime.CoverageWindow{ChunkID: rt.ChunkID, Quote: quote})
known[key] = true
added++
}
// Third source: the names a session asserted with no passage. They are handed over as candidates
// with whatever the pool can show about them, so a claim the run can refute is refuted rather
// than counted or silently dropped.
claimed := 0
for _, name := range runtime.UnanchoredItems(table) {
term := strings.TrimSpace(name)
key := strings.ToLower(term)
if term == "" || known[key] {
continue
}
windows := claimWindows(kb, term, cov, coverageClaimWindowsPerName)
if len(windows) == 0 {
continue
}
for _, w := range windows {
if windowQuotesTerm(set.Windows, w.ChunkID, term) {
continue
}
set.Windows = append(set.Windows, w)
}
known[key] = true
claimed++
}
return set, added, claimed
}
// cappedList returns at most n entries, marking that more were left out.
func cappedList(items []string, n int) []string {
if len(items) <= n {
return items
}
return append(items[:n], "…")
}
// enrollEnumeration runs the direction's window enumeration when the table holds items but no
// enumeration stands behind them.
//
// The planner's type words are a hint; the sessions' own output is the fact (see CoverageOf, which
// reads the value). When the two disagree it is this node that pays for it, because it is the LAST
// one that can complete the set: enrolling here is what turns "the names a session remembered" back
// into "the windows the corpus states".
func enrollEnumeration(ctx context.Context, deps RAGTools, st *AgenticState, cov runtime.Coverage, logger *log.Logger) {
if _, done := st.KB.CoverageSet(); done {
return
}
if cov.ItemKind == "" || len(cov.Operands()) == 0 || deps.Tools == nil {
return
}
if st.RemainingS() < CoverageEnrollHeadroomS {
logger.Printf("[Coverage] enumeration not enrolled: %.0fs left (needs %.0fs for it and the answer beside it); judging what the run already holds.", st.RemainingS(), CoverageEnrollHeadroomS)
return
}
if !runtime.CoverageActsMeetActor(st.KB, cov) {
// The recall would be filtered by a conjunction the run has never seen hold: an act word and
// the actor in ONE passage. Spending it here spends the answer's clock to return nothing —
// measured, this exact pass cost 724 recalled passages and produced zero windows.
logger.Printf("[Coverage] enumeration not enrolled: the direction's words have never met in the passages already held (no act word and actor in one passage), so no window could survive its own filter.")
return
}
runner, ok := deps.Tools.Exec.(runtime.CoverageRunner)
if !ok {
logger.Printf("[Coverage] enumeration not enrolled: the executor cannot enumerate (it does not implement runtime.CoverageRunner).")
return
}
set := runner.EnumerateCoverage(ctx, cov, st.KB)
if len(set.Operands) == 0 {
return
}
logger.Printf("[Coverage] enumeration enrolled at the resolve node: %d operand(s) asked, %d passage(s) recalled, %d window(s) found (no type word had asked for it).",
len(set.Operands), set.Recalled, len(set.Windows))
st.KB.MarkCoverage(cov)
st.KB.StoreCoverageSet(set)
}
// coverageClaimWindowsPerName bounds the passages ONE claimed name is shown with: enough to
// contain the one that states the deed (a name is usually mentioned in several places, and only
// one of them is the killing), and no more.
const coverageClaimWindowsPerName = 2
// claimWindows finds the pool passages that mention a claimed name, the ones carrying the deed's
// words or the actor first. A claim no passage mentions is returned nothing: it stays unjudged.
func claimWindows(kb *runtime.Kbinfos, name string, cov runtime.Coverage, max int) []runtime.CoverageWindow {
type scored struct {
window runtime.CoverageWindow
score int
}
var found []scored
for _, c := range kb.ChunksFrom(0, kb.PoolSize()) {
id := runtime.ChunkIDOf(c)
if id == "" {
continue
}
text := runtime.ChunkTextOf(c)
if !strings.Contains(text, name) {
continue
}
score := 0
act := ""
for _, a := range cov.Acts {
if a != "" && strings.Contains(text, a) {
act = a
score++
break
}
}
for _, form := range cov.Actors() {
if form != "" && strings.Contains(text, form) {
score++
break
}
}
quote := ledgerQuote(kb, id, name)
if quote == "" {
continue
}
// The act word the passage carries rides the window, so the ranking that offered it is
// visible in the log (see CoverageWindow.Act).
found = append(found, scored{runtime.CoverageWindow{ChunkID: id, Quote: quote, Act: act}, score})
}
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
out := make([]runtime.CoverageWindow, 0, max)
for _, f := range found {
if len(out) >= max {
break
}
out = append(out, f.window)
}
return out
}
// windowQuotesTerm reports whether a window of this passage already carries the term: that line
// names the member once, and a second line quoting the same words would ask about it twice.
func windowQuotesTerm(windows []runtime.CoverageWindow, chunkID, term string) bool {
for _, w := range windows {
if w.ChunkID == chunkID && strings.Contains(w.Quote, term) {
return true
}
}
return false
}
@@ -0,0 +1,201 @@
//
// 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 agentic_rag
import (
"context"
"strings"
"testing"
"ragflow/internal/rag/agentic-rag/runtime"
"ragflow/internal/rag/agentic-rag/slots"
)
// TestCoverageResolveCandidatesJudgeWhatTheRunTouched pins the naming node's TWO sources: the
// enumeration's windows, and the names a probe already REACHED.
//
// A reached name has the pool passage that carries it, so it is a member with its evidence
// whatever the session did with it afterwards. A node that judges only the enumeration's own
// windows drops exactly those names: the run holds the passage, names it nowhere, and the count
// comes out short of what the corpus states.
func TestCoverageResolveCandidatesJudgeWhatTheRunTouched(t *testing.T) {
kb := &runtime.Kbinfos{}
kb.Admit(func(p *runtime.PoolAdmitter) {
p.Add(map[string]any{"chunk_id": "c1", "content_with_weight": "夏侯存军至,见了云长,被云长砍死。"})
})
kb.RecordReachedTerm("夏侯存", "c1")
kb.RecordReachedTerm("文丑", "c1") // a slot already declares it: not asked again
kb.RecordReachedTerm("杨龄", "nowhere") // no passage in the pool: no words behind it
kb.StoreCoverageSet(runtime.CoverageSet{
Operands: []string{"云长", "斩"},
Windows: []runtime.CoverageWindow{{ChunkID: "w1", Quote: "云长手起刀落,斩孔秀于马下"}},
})
value := slots.Items(slots.Item{Value: "文丑", ChunkID: "c9", Quote: "云长诛文丑"})
table := runtime.NewState([]runtime.Variable{{ID: 0, Type: "person", Value: &value}}, 0, nil)
set, added, claims := coverageResolveCandidates(kb, &table, runtime.Coverage{})
if added != 1 || len(set.Windows) != 2 || claims != 0 {
t.Fatalf("candidates = %d window(s) (+%d reached, %d claimed), want the enumeration's window plus the one reached name",
len(set.Windows), added, claims)
}
if got := set.Windows[1]; got.ChunkID != "c1" || !strings.Contains(got.Quote, "夏侯存") {
t.Errorf("line = %+v, want the reached name with the words behind it", got)
}
for _, w := range set.Windows {
if strings.Contains(w.Quote, "文丑") {
t.Errorf("line = %+v, want a name a slot already declares left out", w)
}
if strings.TrimSpace(w.Quote) == "" || w.ChunkID == "" {
t.Errorf("line = %+v, want both the passage and its words (a bare name cannot be cited)", w)
}
}
}
// TestCoverageResolveCandidatesSkipWhatIsAlreadyQuoted pins the dedup that keeps the node from
// asking about one passage twice: a window of the same chunk already carries those words.
func TestCoverageResolveCandidatesSkipWhatIsAlreadyQuoted(t *testing.T) {
kb := &runtime.Kbinfos{}
kb.Admit(func(p *runtime.PoolAdmitter) {
p.Add(map[string]any{"chunk_id": "c1", "content_with_weight": "云长提华雄之头,掷于地上,其酒尚温。"})
})
kb.RecordReachedTerm("华雄", "c1")
kb.StoreCoverageSet(runtime.CoverageSet{
Windows: []runtime.CoverageWindow{{ChunkID: "c1", Quote: "云长提华雄之头,掷于地上"}},
})
set, added, claims := coverageResolveCandidates(kb, nil, runtime.Coverage{})
if added != 0 || len(set.Windows) != 1 || claims != 0 {
t.Fatalf("candidates = %d window(s) (+%d reached, %d claimed), want the window that already quotes it, once",
len(set.Windows), added, claims)
}
}
// TestCoverageResolveCandidatesReopenAClaimNobodyJudged pins what "already known" means: JUDGED,
// not written down.
//
// A name a session wrote with no passage has been asserted, not ruled on, so it stays a candidate
// for the point-of-naming node whenever the run holds a passage about it. Treating the claim as
// settled is how 于禁 (taken at 水淹七军 and released, never killed) stayed in the table
// unexamined while the passage that refutes it sat in the pool; once the name carries a passage,
// the same call leaves it alone.
func TestCoverageResolveCandidatesReopenAClaimNobodyJudged(t *testing.T) {
kb := &runtime.Kbinfos{}
kb.Admit(func(p *runtime.PoolAdmitter) {
p.Add(map[string]any{"chunk_id": "c1", "content_with_weight": "于禁拜伏于地,乞哀请命。关公曰:汝怎敢抗吾?"})
})
kb.RecordReachedTerm("于禁", "c1")
claimed := slots.Items(slots.Item{Value: "于禁"})
table := runtime.NewState([]runtime.Variable{{ID: 1, Type: "person", Value: &claimed}}, 0, nil)
set, added, claims := coverageResolveCandidates(kb, &table, runtime.Coverage{})
if added != 1 || len(set.Windows) != 1 {
t.Fatalf("candidates = %d window(s) (+%d), want the unjudged claim handed to a verdict",
len(set.Windows), added)
}
// Reached, not claimed: the ledger already knew this name, so the claim source left it alone
// rather than asking about it twice.
if claims != 0 {
t.Fatalf("claimed = %d, want the claim covered by the reached ledger", claims)
}
anchored := slots.Items(slots.Item{Value: "于禁", ChunkID: "c1", Quote: "于禁拜伏于地"})
settled := runtime.NewState([]runtime.Variable{{ID: 1, Type: "person", Value: &anchored}}, 0, nil)
if _, again, _ := coverageResolveCandidates(kb, &settled, runtime.Coverage{}); again != 0 {
t.Fatalf("added = %d, want a member that already carries a passage left alone", again)
}
}
// TestCoverageResolveCandidatesHandAClaimToTheVerdict pins the THIRD source: a name a session
// asserted with no passage is handed over as a candidate, with whatever the pool can show about
// it, so the node that decides what a member is gets to decide about it too.
//
// The alternatives both lose. Left in the table, the claim is counted without ever being read
// (于禁 taken at 水淹七军 and released, never killed, counted beside sixteen real members). Dropped
// outright, a name the corpus does state is lost. Handed over, it is a candidate like any other —
// and the passage that carries the deed is offered first, because a name is mentioned in many
// places and only one of them is the killing.
func TestCoverageResolveCandidatesHandAClaimToTheVerdict(t *testing.T) {
kb := &runtime.Kbinfos{}
kb.Admit(func(p *runtime.PoolAdmitter) {
p.Add(map[string]any{"chunk_id": "m1", "content_with_weight": "关公问曰:谁人敢去?于禁在阵前绰刀出马,关公便拨马回阵。"})
p.Add(map[string]any{"chunk_id": "k1", "content_with_weight": "于禁拜伏于地,乞哀请命。关公大怒曰:汝竟敢抗吾?喝令推出斩之。"})
})
// 于禁 has passages in the pool; 刘延 does not (in this fixture): one claim can be judged,
// the other has nothing to be judged against and must not be invented into a list.
claimed := slots.Items(slots.Item{Value: "于禁"}, slots.Item{Value: "刘延"})
table := runtime.NewState([]runtime.Variable{{ID: 1, Type: "person", Value: &claimed}}, 0, nil)
set, reached, claims := coverageResolveCandidates(kb, &table, runtime.Coverage{Acts: []string{"斩"}, Actor: "关羽"})
if reached != 0 || claims != 1 {
t.Fatalf("reached=%d claimed=%d, want exactly one claim handed over", reached, claims)
}
if len(set.Windows) != 2 || set.Windows[0].ChunkID != "k1" {
t.Fatalf("windows = %+v, want the passage carrying the deed offered first", set.Windows)
}
// The choice is marked, not silent: the window says which act word the passage carries, so a
// reader of the log can see why it was offered first (see CoverageWindow.Act).
if set.Windows[0].Act != "斩" {
t.Fatalf("windows[0].Act = %q, want the act word the passage carries", set.Windows[0].Act)
}
if set.Windows[1].Act != "" {
t.Fatalf("windows[1].Act = %q, want no act word claimed for a passage that carries none", set.Windows[1].Act)
}
}
// TestRunCoverageResolveWritesWhatTheRunTouched pins the WIRING the unit tests above cannot: the
// node's candidate set is the UNION, so a name no enumeration recalled but a probe DID reach is
// still judged, and still written.
//
// This is the case that loses members: the corpus states the deed, the run's own search brought
// the passage back, and the name lands in no list at all because the node only ever looked at its
// own enumeration.
func TestRunCoverageResolveWritesWhatTheRunTouched(t *testing.T) {
st := NewAgenticState("关羽杀了多少有姓名的人物?", "", 3, nil)
st.KB.Chunks = []map[string]any{
{"chunk_id": "c1", "content": "夏侯存军至,见了云长,大怒,便与云长交锋,只一合,被云长砍死。"},
}
// The run's own probe reached this name; no enumeration ever recalled it (no set stored).
st.KB.RecordReachedTerm("夏侯存", "c1")
declared := slots.Items(slots.Item{Value: "孔秀", ChunkID: "c2", Quote: "云长斩孔秀于马下"})
st.SlotTable = runtime.NewState([]runtime.Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Subject: "关羽"},
{ID: 1, Type: "person", Value: &declared},
}, 0, nil)
model := &scriptedModel{}
model.push(`{"members": [{"i": 0, "name": "夏侯存"}], "not_members": []}`)
stats := RunCoverageResolve(context.Background(), RAGTools{Model: model}, st, nil)
if stats.Asked != 1 {
t.Fatalf("asked = %d, want the ONE line the run held — the name its own search reached", stats.Asked)
}
if stats.Answered != 1 || stats.Unknown != 0 {
t.Errorf("stats = %+v, want that line judged", stats)
}
names := runtime.ItemValues(&st.SlotTable)
found := false
for _, name := range names {
if strings.EqualFold(strings.TrimSpace(name), "夏侯存") {
found = true
}
}
if !found {
t.Errorf("members = %v, want the reached name written into the table", names)
}
if !strings.Contains(st.KB.Record, "夏侯存") {
t.Errorf("record does not carry the member the resolve just proved:\n%s", st.KB.Record)
}
}
@@ -14,7 +14,7 @@
// limitations under the License.
//
package advanced_rag
package agentic_rag
import (
"context"
@@ -25,12 +25,10 @@ import (
"testing"
"github.com/cloudwego/eino/schema"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/agentic-rag/runtime"
)
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
// failingModel errors on every Complete call — the batched-answer LLM seam.
type failingModel struct {
@@ -38,7 +36,7 @@ type failingModel struct {
calls int
}
func (m *failingModel) Complete(context.Context, []schema.Message, []harness.ToolSpec) (*harness.ModelReply, error) {
func (m *failingModel) Complete(context.Context, []schema.Message, []runtime.ToolSpec) (*runtime.ModelReply, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls++
@@ -52,8 +50,8 @@ func (m *failingModel) Calls() int {
return m.calls
}
// claimRow builds one claim pseudo-chunk (Python "[evidence] <name> — <desc>"
// row) the way the pool stores it.
// claimRow builds one claim pseudo-chunk ("[evidence] <name> — <desc>") the way the pool
// stores it.
func claimRow(id, content string) map[string]any {
return map[string]any{
"chunk_id": id,
@@ -62,13 +60,10 @@ func claimRow(id, content string) map[string]any {
}
}
// ---------------------------------------------------------------------------
// PrefillSlotsFromEvidence(Python _prefill_slots_from_evidence)
// ---------------------------------------------------------------------------
// PrefillSlotsFromEvidence
// TestPrefillSlotsFromEvidence_CoverageBoundary pins the 0.6 word-coverage
// gate(Python _EVIDENCE_PREFILL_COVERAGE=0.6, :1738): exactly-at and above
// prefill, below does not; the candidate is the de-prefixed first line and the
// TestPrefillSlotsFromEvidence_CoverageBoundary pins the 0.6 word-coverage gate: exactly-at
// and above prefill, below does not; the candidate is the de-prefixed first line and the
// strength is the coverage itself.
func TestPrefillSlotsFromEvidence_CoverageBoundary(t *testing.T) {
// Clue terms: {alpha, tower, opened, paris, 1889} (>=3 code points only).
@@ -78,15 +73,15 @@ func TestPrefillSlotsFromEvidence_CoverageBoundary(t *testing.T) {
// Covers tower/opened = 2/5 = 0.4.
below := claimRow("claim_b", "[evidence] Eiffel Tower — landmark\nEvidence (verbatim): \"the tower opened here long ago\"")
newState := func() harness.State {
return harness.NewState([]harness.Variable{
newState := func() runtime.State {
return runtime.NewState([]runtime.Variable{
{ID: 1, Type: "aspect", QuestionClues: []string{clue}},
}, 0, nil)
}
// At the gate: prefilled.
st := newState()
if n := PrefillSlotsFromEvidence(&st, &harness.Kbinfos{Chunks: []map[string]any{atGate}}); n != 1 {
if n := PrefillSlotsFromEvidence(&st, &runtime.Kbinfos{Chunks: []map[string]any{atGate}}); n != 1 {
t.Fatalf("coverage exactly 0.6 must prefill, filled = %d", n)
}
v := st.State[0]
@@ -99,7 +94,7 @@ func TestPrefillSlotsFromEvidence_CoverageBoundary(t *testing.T) {
// Below the gate: untouched.
st = newState()
if n := PrefillSlotsFromEvidence(&st, &harness.Kbinfos{Chunks: []map[string]any{below}}); n != 0 {
if n := PrefillSlotsFromEvidence(&st, &runtime.Kbinfos{Chunks: []map[string]any{below}}); n != 0 {
t.Fatalf("coverage 0.4 must NOT prefill, filled = %d", n)
}
if st.State[0].Candidate != nil {
@@ -107,18 +102,18 @@ func TestPrefillSlotsFromEvidence_CoverageBoundary(t *testing.T) {
}
}
// TestPrefillSlotsFromEvidence_Guards mirrors the skips(Python :1714-1728,
// :1744): no claim rows in the pool, already-filled slots, blank clues, and a
// TestPrefillSlotsFromEvidence_Guards mirrors the skips(,
// 1744): no claim rows in the pool, already-filled slots, blank clues, and a
// first line that strips to nothing.
func TestPrefillSlotsFromEvidence_Guards(t *testing.T) {
st := harness.NewState([]harness.Variable{
st := runtime.NewState([]runtime.Variable{
{ID: 1, Type: "aspect", QuestionClues: []string{"eiffel tower paris"}},
{ID: 2, Type: "aspect", Candidate: strPtr("known")},
{ID: 3, Type: "aspect", QuestionClues: []string{" "}},
}, 0, nil)
// No claim rows: 0 regardless of content.
if n := PrefillSlotsFromEvidence(&st, &harness.Kbinfos{Chunks: []map[string]any{
if n := PrefillSlotsFromEvidence(&st, &runtime.Kbinfos{Chunks: []map[string]any{
claimRow("c1", "plain chunk"),
}}); n != 0 {
t.Fatalf("no claim_ rows must yield 0, got %d", n)
@@ -129,7 +124,7 @@ func TestPrefillSlotsFromEvidence_Guards(t *testing.T) {
}
// Row whose head strips to nothing: even full coverage fills nothing.
if n := PrefillSlotsFromEvidence(&st, &harness.Kbinfos{Chunks: []map[string]any{
if n := PrefillSlotsFromEvidence(&st, &runtime.Kbinfos{Chunks: []map[string]any{
claimRow("claim_x", "[evidence] — \nEvidence (verbatim): \"eiffel tower paris\""),
}}); n != 0 {
t.Fatalf("empty name must yield 0, got %d", n)
@@ -142,24 +137,24 @@ func TestPrefillSlotsFromEvidence_Guards(t *testing.T) {
}
}
// TestRunSlotResearchPass_PrefilledSlotsSkipSession pins the wiring(Python
// :1782-1790): when the pooled evidence covers every slot at >= 0.6, the
// pass fills them WITHOUT running a single action session (zero model calls).
// TestRunSlotResearchPass_PrefilledSlotsSkipSession pins the wiring: when the pooled evidence
// covers every slot at >= 0.6, the pass fills them WITHOUT running a single action session
// (zero model calls).
func TestRunSlotResearchPass_PrefilledSlotsSkipSession(t *testing.T) {
kb := &harness.Kbinfos{Chunks: []map[string]any{
kb := &runtime.Kbinfos{Chunks: []map[string]any{
claimRow("claim_a", "[evidence] Eiffel Tower — built in Paris by Gustave Eiffel, opened in 1889\nEvidence (verbatim): \"...\""),
}}
st := &AgenticState{
Question: "what is the tower",
SlotTable: harness.NewState([]harness.Variable{
SlotTable: runtime.NewState([]runtime.Variable{
{ID: 0, Type: "aspect", QuestionClues: []string{"eiffel tower opened paris 1889"}},
{ID: 1, Type: "aspect", QuestionClues: []string{"gustave eiffel designed paris tower"}},
}, 0, nil),
}
mdl := &scriptedModel{}
deps := harness.SessionDeps{Model: mdl, KB: kb}
deps := runtime.SessionDeps{Model: mdl, KB: kb}
res := RunSlotResearchPass(context.Background(), context.Background(), deps, st.Question, st, 120.0, nil)
res := RunSlotResearchPass(context.Background(), context.Background(), deps, st.Question, st, 120.0)
if res == nil {
t.Fatal("expected a result")
}
@@ -180,9 +175,7 @@ func TestRunSlotResearchPass_PrefilledSlotsSkipSession(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// BatchFillSlots(Python _batch_fill_slots)
// ---------------------------------------------------------------------------
// BatchFillSlots
// TestBatchFillSlots_ClustersOverlappingEvidence: two slots sharing >= 2
// evidence ids with Jaccard >= 0.15 are answered in ONE call; a slot with
@@ -196,7 +189,7 @@ func TestBatchFillSlots_ClustersOverlappingEvidence(t *testing.T) {
"doc_id": "d1",
})
}
slotTable := harness.NewState([]harness.Variable{
slotTable := runtime.NewState([]runtime.Variable{
{ID: 1, Type: "aspect", QuestionClues: []string{"who built it"}},
{ID: 2, Type: "aspect", QuestionClues: []string{"when opened"}},
{ID: 3, Type: "aspect", QuestionClues: []string{"unrelated"}},
@@ -208,7 +201,7 @@ func TestBatchFillSlots_ClustersOverlappingEvidence(t *testing.T) {
}
mdl := &scriptedModel{}
mdl.push(`{"1": "Gustave Eiffel", "2": "1889", "3": "invented"}`)
deps := harness.SessionDeps{Model: mdl, KB: &harness.Kbinfos{Chunks: chunks}}
deps := runtime.SessionDeps{Model: mdl, KB: &runtime.Kbinfos{Chunks: chunks}}
if got := BatchFillSlots(context.Background(), deps, &slotTable, slotEvidence); got != 2 {
t.Fatalf("filled = %d, want 2", got)
@@ -236,12 +229,12 @@ func TestBatchFillSlots_ClustersOverlappingEvidence(t *testing.T) {
}
}
// TestBatchFillSlots_ThresholdsBlockMerging pins both gates(Python
// :1574-1575): fewer than MIN_SHARED shared ids, or a Jaccard below
// MIN_SIM, keeps the slots in separate size-1 clusters — no LLM call at all.
// TestBatchFillSlots_ThresholdsBlockMerging pins both gates: fewer than MIN_SHARED shared ids,
// or a Jaccard below MIN_SIM, keeps the slots in separate size-1 clusters — no LLM call at
// all.
func TestBatchFillSlots_ThresholdsBlockMerging(t *testing.T) {
mk := func(e1, e2 []string) (harness.State, map[string]SlotEvidence) {
st := harness.NewState([]harness.Variable{
mk := func(e1, e2 []string) (runtime.State, map[string]SlotEvidence) {
st := runtime.NewState([]runtime.Variable{
{ID: 1, Type: "aspect", QuestionClues: []string{"a"}},
{ID: 2, Type: "aspect", QuestionClues: []string{"b"}},
}, 0, nil)
@@ -273,7 +266,7 @@ func TestBatchFillSlots_ThresholdsBlockMerging(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
st, ev := mk(tc.e1, tc.e2)
mdl := &scriptedModel{}
deps := harness.SessionDeps{Model: mdl, KB: &harness.Kbinfos{}}
deps := runtime.SessionDeps{Model: mdl, KB: &runtime.Kbinfos{}}
if got := BatchFillSlots(context.Background(), deps, &st, ev); got != 0 {
t.Fatalf("filled = %d, want 0", got)
}
@@ -285,23 +278,23 @@ func TestBatchFillSlots_ThresholdsBlockMerging(t *testing.T) {
}
// TestBatchFillSlots_MaxSlotsPerBatch pins _EVIDENCE_BATCH_MAX_SLOTS=4
// (Python :1576, :1625): the fifth identical-evidence slot cannot join the
// (,:1625): the fifth identical-evidence slot cannot join the
// full batch and is left unfilled this round.
func TestBatchFillSlots_MaxSlotsPerBatch(t *testing.T) {
vars := make([]harness.Variable, 0, 5)
vars := make([]runtime.Variable, 0, 5)
ev := map[string]SlotEvidence{}
for i := 1; i <= 5; i++ {
vars = append(vars, harness.Variable{ID: i, Type: "aspect", QuestionClues: []string{"same clue"}})
vars = append(vars, runtime.Variable{ID: i, Type: "aspect", QuestionClues: []string{"same clue"}})
ev[fmt.Sprint(i)] = SlotEvidence{EvidenceIDs: []string{"c1", "c2"}}
}
st := harness.NewState(vars, 0, nil)
kb := &harness.Kbinfos{Chunks: []map[string]any{
st := runtime.NewState(vars, 0, nil)
kb := &runtime.Kbinfos{Chunks: []map[string]any{
{"chunk_id": "c1", "content_with_weight": "shared text one", "doc_id": "d1"},
{"chunk_id": "c2", "content_with_weight": "shared text two", "doc_id": "d1"},
}}
mdl := &scriptedModel{}
mdl.push(`{"1": "a", "2": "b", "3": "c", "4": "d", "5": "e"}`)
deps := harness.SessionDeps{Model: mdl, KB: kb}
deps := runtime.SessionDeps{Model: mdl, KB: kb}
if got := BatchFillSlots(context.Background(), deps, &st, ev); got != 4 {
t.Fatalf("filled = %d, want 4 (MAX_SLOTS)", got)
@@ -319,12 +312,12 @@ func TestBatchFillSlots_MaxSlotsPerBatch(t *testing.T) {
}
}
// TestBatchFillSlots_LLMFailureBestEffort mirrors Python :1681-1683: a failed
// TestBatchFillSlots_LLMFailureBestEffort mirrors: a failed
// model call (or an unparsable reply, or no model at all) fills nothing and
// never panics — the pass continues without the batch.
func TestBatchFillSlots_LLMFailureBestEffort(t *testing.T) {
mk := func() (harness.State, map[string]SlotEvidence) {
st := harness.NewState([]harness.Variable{
mk := func() (runtime.State, map[string]SlotEvidence) {
st := runtime.NewState([]runtime.Variable{
{ID: 1, Type: "aspect", QuestionClues: []string{"a"}},
{ID: 2, Type: "aspect", QuestionClues: []string{"b"}},
}, 0, nil)
@@ -334,7 +327,7 @@ func TestBatchFillSlots_LLMFailureBestEffort(t *testing.T) {
}
return st, ev
}
kb := &harness.Kbinfos{Chunks: []map[string]any{
kb := &runtime.Kbinfos{Chunks: []map[string]any{
{"chunk_id": "c1", "content_with_weight": "text one", "doc_id": "d1"},
{"chunk_id": "c2", "content_with_weight": "text two", "doc_id": "d1"},
}}
@@ -342,7 +335,7 @@ func TestBatchFillSlots_LLMFailureBestEffort(t *testing.T) {
t.Run("model-error", func(t *testing.T) {
st, ev := mk()
mdl := &failingModel{}
deps := harness.SessionDeps{Model: mdl, KB: kb}
deps := runtime.SessionDeps{Model: mdl, KB: kb}
if got := BatchFillSlots(context.Background(), deps, &st, ev); got != 0 {
t.Fatalf("filled = %d, want 0", got)
}
@@ -359,14 +352,14 @@ func TestBatchFillSlots_LLMFailureBestEffort(t *testing.T) {
st, ev := mk()
mdl := &scriptedModel{}
mdl.push("sorry, I cannot answer that") // no JSON object
deps := harness.SessionDeps{Model: mdl, KB: kb}
deps := runtime.SessionDeps{Model: mdl, KB: kb}
if got := BatchFillSlots(context.Background(), deps, &st, ev); got != 0 {
t.Fatalf("filled = %d, want 0", got)
}
})
t.Run("no-model", func(t *testing.T) {
st, ev := mk()
deps := harness.SessionDeps{KB: kb} // Model nil — Python :1674 `return filled`
deps := runtime.SessionDeps{KB: kb} // Model nil — nothing can be filled
if got := BatchFillSlots(context.Background(), deps, &st, ev); got != 0 {
t.Fatalf("filled = %d, want 0", got)
}
@@ -374,10 +367,10 @@ func TestBatchFillSlots_LLMFailureBestEffort(t *testing.T) {
}
// TestBatchFillSlots_SkipDiagnostics pins the never-firing diagnostics
// (Python :1606-1610): fewer than two evidence-carrying unresolved slots
// (): fewer than two evidence-carrying unresolved slots
// logs the skip line and short-circuits before any model call.
func TestBatchFillSlots_SkipDiagnostics(t *testing.T) {
st := harness.NewState([]harness.Variable{
st := runtime.NewState([]runtime.Variable{
{ID: 1, Type: "aspect", QuestionClues: []string{"a"}},
{ID: 2, Type: "aspect", QuestionClues: []string{"b"}},
}, 0, nil)
@@ -385,7 +378,7 @@ func TestBatchFillSlots_SkipDiagnostics(t *testing.T) {
"1": {EvidenceIDs: []string{"c1"}}, // only ONE slot carries evidence
}
mdl := &scriptedModel{}
deps := harness.SessionDeps{Model: mdl, KB: &harness.Kbinfos{}}
deps := runtime.SessionDeps{Model: mdl, KB: &runtime.Kbinfos{}}
if got := BatchFillSlots(context.Background(), deps, &st, ev); got != 0 {
t.Fatalf("filled = %d, want 0", got)
}
+691
View File
@@ -0,0 +1,691 @@
//
// 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 agentic_rag
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
"github.com/cloudwego/eino/schema"
"ragflow/internal/agent/chat"
"ragflow/internal/rag/agentic-rag/runtime"
"ragflow/internal/rag/prompts"
"ragflow/internal/tokenizer"
)
// The answer stage: the terminal node's prompt, its budgeted evidence, and the
// composition paths (streaming, one-shot, naive, and the failure fallbacks).
// ComposeFallbackDraft: an
// intermediate draft synthesized from the snippet pool when a research pass
// produced no report (budget exhaustion).
//
// This is an LLM call, not a concatenation. The draft is what the
// sufficient-context agent reviews and what later lands in PreSummary as
// "Research findings (authoritative — use these facts verbatim)", so the model is asked
// for a specific shape:
//
// - the FOUND facts, stated plainly;
// - a final "MISSING:" line naming what is still unknown.
//
// The MISSING line is load-bearing: it is what tells the next round (and the
// final answer) which unknowns remain. A plain list of truncated snippets
// cannot express it.
func ComposeFallbackDraft(ctx context.Context, deps RAGTools, st *AgenticState) string {
// Nil-safe: the draft node runs on every round, including a state whose KB was never
// populated.
if st == nil || st.KB == nil || len(st.KB.Chunks) == 0 {
return ""
}
// strongest evidence first — the pool is in insertion order, so
// sort by similarity/score before spending the 16-slot budget on it.
chunks := rankByScore(st.KB.Chunks)
// "[i] text" joined by "\n", 1-indexed, 16 chunks x 1200
// chars. Empty chunk text still produces its "[i] " line — nothing is skipped or
// trimmed.
var ev strings.Builder
n := min(len(chunks), draftChunkCap)
for i := 0; i < n; i++ {
if ctx.Err() != nil {
break
}
if i > 0 {
ev.WriteString("\n")
}
fmt.Fprintf(&ev, "[%d] %s", i+1, truncateRunes(runtime.ChunkTextOf(chunks[i]), draftChunkChars))
}
evidence := ev.String()
if evidence == "" {
return ""
}
// no model -> the raw evidence, capped at 4000.
mdl := deps.Model // the innermost chat model.
if mdl == nil {
return truncateRunes(evidence, draftFallbackChars)
}
// The design injects the latest research_feedback as a "focus", but that state field is
// vestigial: it is declared and initialised to [] and never appended anywhere, so the
// focus is always empty. There is therefore no feedback field here — populating one
// would invent behaviour that does not exist.
//
// The prompt asks for the FOUND list plus a final
// "MISSING:" line is what gives the SCA precise gaps.
system := "You are a research assistant writing an INTERMEDIATE DRAFT toward answering the user's " +
"question, using ONLY the retrieved evidence snippets below.\n" +
"Requirements:\n" +
"1. First list concrete FACTS FOUND in the snippets (exact numbers, dates, names preserved).\n" +
"2. Then output a line starting with 'MISSING:' naming precisely which part(s) of the " +
"question the snippets do NOT answer yet.\n" +
"3. No conclusions beyond the evidence; no general knowledge.\n" +
"Keep it under 250 words."
if containsNonASCII(st.Question) {
// _compose_fallback_draft — appended directly, with no separator.
system += "Write your draft in the same language as the question."
}
user := "Question: " + st.Question + "\n\nRetrieved evidence:\n" + evidence
reply, err := mdl.Complete(ctx, []schema.Message{
*schema.SystemMessage(system),
*schema.UserMessage(user),
}, nil)
if err != nil {
// a failed composition degrades to the raw evidence
// (capped at 4000, unlike the composed draft's 6000).
_LOG.Printf("[Draft] fallback composition failed; using snippet text: %v", err)
return truncateRunes(evidence, draftFallbackChars)
}
answer := ""
if reply != nil {
answer = strings.TrimSpace(reply.Content)
}
if answer == "" {
answer = evidence
}
// (ans or evidence)[:6000].
return truncateRunes(answer, draftMaxChars)
}
// NaiveRAG: answer with one retrieve pass
// and no agentic graph at all.
//
// Used when the thinking mode is unrecognised — instead of failing the request
// (the label comes from user input) this degrades to plain retrieval plus one
// composed answer.
//
// The composition itself lives in ComposeNaiveAnswer: a fixed short prompt, flat
// "[i] content" evidence over
// the first 8 chunks at 1500 chars, message_fit_in, temperature 0.3, and the
// raw evidence as the failure fallback).
func NaiveRAG(ctx context.Context, deps RAGTools, req runtime.RunRequest, kb *runtime.Kbinfos, resp *RunResponse, logger *log.Logger) {
if logger == nil {
logger = _LOG
}
question := strings.TrimSpace(req.Question)
logger.Printf("[Naive RAG] single-pass retrieval for question_len=%d", len(question))
// tools.retrieve(question) — one PLAIN pass. Unlike runDirect
// (low mode) this extracts no weighted keywords and expands no compiled
// structure: naive is deliberately plain retrieval, and a failure degrades
// to "no evidence" rather than erroring.
var chunks []map[string]any
if question != "" {
// The retrieve call reads the full run config — including the tag boost. Projecting
// only a subset here would silently drop that tuning for the naive path.
var aggs []map[string]any
chunks, aggs = runtime.RetrieveSearch(ctx,
searchDepsFor(ctx, deps, req, req.DatasetIDs, req.TenantID, kb, logger),
runtime.SearchParams{
Question: question,
TopN: req.TopN,
})
// accumulate onto the shared pool so the composed
// answer can be cited and callers see the same shape as the agentic path.
kb.Merge(chunks, aggs)
}
// no evidence -> yield the configured empty response.
if len(chunks) == 0 {
resp.Answer = deps.EmptyResponse
return
}
// compose from the flat "[i] content" evidence under the
// fixed short naiveAnswerSystem — NOT FinalAnswerSystem / kb_prompt. This is
// why the naive path must not fall through to composeFinalAnswer.
out := ComposeNaiveAnswer(ctx, AnswerDeps{
Model: deps.Model,
EmptyResponse: deps.EmptyResponse,
MaxLength: deps.MaxLength,
Logger: logger,
}, chunks, question)
resp.Answer = out.Answer
}
// runDirectFallback retrieves once when no model is configured: the loop cannot
// plan, research, or review without one, so the caller still gets evidence
// rather than an error.
func runDirectFallback(ctx context.Context, deps RAGTools, req runtime.RunRequest, kb *runtime.Kbinfos, logger *log.Logger) {
chunks, aggs := runtime.HybridSearch(ctx,
searchDepsFor(ctx, deps, req, req.DatasetIDs, req.TenantID, kb, logger),
runtime.SearchParams{
Question: req.Question,
Keywords: req.Keywords,
UseCompiled: req.UseCompiled,
TopN: req.TopN,
// The retrieve channel.
Channel: runtime.ChannelRetrieve,
})
kb.Merge(chunks, aggs)
}
// Final answer composition (the graph's last node).
//
// Every thinking mode ends here: it turns the gathered evidence into a grounded, cited
// answer in the user's language. The evidence-block and citation-rule prompts it renders
// (kb_prompt / citation_prompt) live in internal/rag/prompts; the system-prompt texts live
// in the runtime package (report_prompt.go).
const (
// evidenceBudgetTokens is the token ceiling of the evidence block.
evidenceBudgetTokens = 8000
// evidencePoolQuota: claim pseudo-chunk
// cap across the whole first prefetch.
evidencePoolQuota = 24
// rawSnippetQuota: the raw-chunk
// admission budget inside the fan-out, so chunk channels cannot crowd out
// the denser evidence rows.
rawSnippetQuota = 30
// citeChunkCap caps chunks rendered as citation reference.
citeChunkCap = 6
// answerTimeoutS bounds the answer-composition call.
answerTimeoutS = 150.0
// naiveEvidenceChunkCap caps evidence chunks in the naive path.
naiveEvidenceChunkCap = 8
// naiveEvidenceCharCap caps each naive evidence chunk.
naiveEvidenceCharCap = 1500
// answerErrorFallback is the stream-failure message.
answerErrorFallback = "I'm sorry, I encountered an error while composing the answer."
// graphFailureFallback: last-resort message
// (run_agentic_rag), used only when the graph failed AND produced nothing.
graphFailureFallback = "I couldn't complete the search due to an internal error."
// AgenticRecursionLimit: for the agentic
// graph: the maximum number of node visits before the graph aborts. Go has
// no graph runtime, so the loop counts its own node visits against it.
AgenticRecursionLimit = 60
// agenticRoundVisits is the number of node visits one research round costs:
// rag_agent → draft → sca.
agenticRoundVisits = 3
// lowRecursionLimitBase is the floor of `max(25, max_loops * 8)` for the non-agentic
// graph.
lowRecursionLimitBase = 25
)
// FinalAnswerSystem and PartialAnswerPreamble are defined in the runtime package
// (runtime/report_prompt.go) and re-used here via the runtime import rather than
// duplicated.
// AnswerDeps are the dependencies of ComposeAnswer.
type AnswerDeps struct {
// Model drives the composition call.
Model runtime.SessionModel
// CiteRules overrides DEFAULT_CITE_RULES. Empty uses the default.
CiteRules string
// SystemPrompt is the dialog-level UI configuration. Appended AFTER the agentic
// contract — see the
// precedence note in composeSystem.
SystemPrompt string
// EmptyResponse is returned verbatim when there is no evidence, skipping the LLM
// entirely.
EmptyResponse string
// MaxTokens caps the evidence block. <=0 uses evidenceBudgetTokens.
MaxTokens int
// MaxLength is the chat model's context window. It bounds the prompt fit; <=0 falls back
// to chat.EffectiveContextLength's 8192 default.
MaxLength int
// Logger is optional; nil uses the default logger.
Logger *log.Logger
// UserImages are the vision-gated base64 data URIs that survive
// gateImageAttachments. The direct fallback is called with the original multimodal
// messages, so they are attached to the final-answer user message and the compose model
// sees the images even on the non-outer path. Empty for text-only models / no images.
UserImages []string
}
// ComposeAnswerWith is ComposeAnswer with the third no-evidence term: `no_evidence =
// abstain or empty_result or not chunks`, used both for the empty_response short circuit
// and for the degradation instructions in the prompt. The extra `emptyResult` term is the
// agentic loop's own "nothing was found" signal, distinct from "we abstained" and from
// "the pool happens to be empty".
type finalizeAnnouncedKey struct{}
// markFinalizeAnnounced records that [Finalize] has been reported for this run's
// composition.
func markFinalizeAnnounced(ctx context.Context) context.Context {
return context.WithValue(ctx, finalizeAnnouncedKey{}, true)
}
// finalizeAnnounced reports whether [Finalize] was reported for this composition.
func finalizeAnnounced(ctx context.Context) bool {
v, _ := ctx.Value(finalizeAnnouncedKey{}).(bool)
return v
}
func ComposeAnswerWith(ctx context.Context, deps AnswerDeps, kb *runtime.Kbinfos, question string, partial, abstain, emptyResult bool) AnswerResult {
logger := deps.Logger
if logger == nil {
logger = _LOG
}
chunks := []map[string]any{}
if kb != nil {
chunks = kb.Chunks
}
started := time.Now()
// The kickoff step is suppressed when the graph already reported [Finalize]
// (finalizeAnnounced): the verdict and the evidence have been said, and a line
// repeating their numbers only made the block longer. The paths that have no
// [Finalize] — the low graph's last node, every fallback compose — keep it.
if !finalizeAnnounced(ctx) {
step(ctx, logger, "Composing the answer", "Composing the answer from %s.",
runtime.CountOf(len(chunks), "gathered passage"))
}
// The step kept below either way is the no-evidence short circuit: "no model was
// called" is a fact no other line carries.
// 1. No-evidence short circuit.
// _compose_answer_from_evidence: no_evidence = abstain or empty_result or not chunks.
noEvidence := abstain || emptyResult || len(chunks) == 0
if noEvidence && deps.EmptyResponse != "" {
step(ctx, logger, "Composing the answer", "No supporting evidence was found, so the configured empty response is returned without calling the model.")
return AnswerResult{Answer: deps.EmptyResponse, NoEvidence: true}
}
if deps.Model == nil {
return AnswerResult{Answer: "", Failed: true, NoEvidence: len(chunks) == 0}
}
// 2. Build the prompt: ranked evidence under its token budget, plus the
// question and any research findings.
preSummary := ""
if kb != nil {
preSummary = kb.PreSummary
}
// Both the prompt and this log go through composedRecord, so the line below
// reports the record block the answer actually carried.
record := composedRecord(kb)
prompt := deps.answerPromptWithEvidence(kb, question, partial, noEvidence)
// 3. Call the model.
callCtx, cancel := context.WithTimeout(ctx, deadlineToDuration(answerTimeoutS))
defer cancel()
logger.Printf("[Formalize][record] question=%q record_len=%d draft_summary_len=%d evidence_len=%d using=%s\nrecord=%q",
trunc(question, 160), len(record), len(preSummary), len(prompt.user), recordSource(record),
truncateRunes(record, 3000))
logger.Printf("[Formalize][pre_summary] question=%q pre_summary_len=%d evidence_len=%d\npre_summary=%q",
trunc(question, 160), len(preSummary), len(prompt.user), truncateRunes(preSummary, 3000))
// The composed prompt is fitted ONCE before the call, bounded by the smaller of the
// model's window and the evidence budget: msg[0] is the system turn, msg[-1] the user
// turn. The fit must not synthesize an extra system turn.
systemTurn, userTurn := fitComposePrompt(prompt.system, prompt.user, deps.MaxLength)
userMsg := schema.UserMessage(userTurn)
if len(deps.UserImages) > 0 {
// The direct fallback is called with the original multimodal messages: attach the
// vision-gated images to the final-answer user message so the compose model can see
// them.
userMsg = multimodalUserMsg(prompt.user, deps.UserImages)
}
// The compose call samples at the configured temperature, defaulting to 0.3; the graph
// runs with no per-run override, so 0.3 applies.
reply, err := modelWithTemperature(deps.Model, answerTemperature).Complete(callCtx, []schema.Message{
*schema.SystemMessage(systemTurn),
*userMsg,
}, nil)
if err != nil {
logger.Printf("[Composing the answer] composition failed: %v", err)
return AnswerResult{Answer: answerErrorFallback, Failed: true}
}
answer := cleanAnswer(reply.Content)
logComposeDone(logger, started, answer, len(chunks))
return AnswerResult{Answer: answer, Partial: partial}
}
// fitComposePrompt is the compose-time fit: min(model window, evidence budget). The fit
// normalizes a non-positive budget to 8192, returns the
// pair untouched when it already fits, then trims by TOKENS (trim_content):
// the system share branch (>0.8 of the total) preserves the user turn first,
// otherwise the system turn is preserved first and the user turn gets the
// remainder. form_message is exactly the [system, user]
// pair, so no extra system turn is synthesized here.
func fitComposePrompt(system, user string, maxLength int) (string, string) {
budget := maxLength
if budget > evidenceBudgetTokens {
budget = evidenceBudgetTokens
}
if budget <= 0 {
// message_fit_in normalizes a non-positive max_length to 8192
// .
budget = 8192
}
ll := tokenizer.NumTokensFromString(system)
ll2 := tokenizer.NumTokensFromString(user)
if ll+ll2 < budget {
return system, user
}
if ll+ll2 <= 0 {
// message_fit_in's degenerate branch: token counts are zero — keep the
// content unchanged rather than trimming blindly.
return system, user
}
if float64(ll)/float64(ll+ll2) > 0.8 {
// System-dominated prompt: the USER turn is preserved first.
preservedLast := min(ll2, budget)
user = tokenizer.TrimContentToTokenLimit(user, preservedLast)
remaining := max(0, budget-preservedLast)
system = tokenizer.TrimContentToTokenLimit(system, remaining)
return system, user
}
preservedSystem := min(ll, budget)
system = tokenizer.TrimContentToTokenLimit(system, preservedSystem)
remaining := max(0, budget-preservedSystem)
user = tokenizer.TrimContentToTokenLimit(user, remaining)
return system, user
}
// answerPrompt is the terminal node's input: the ranked evidence under its token
// budget plus the question and any research findings. Shared by the one-shot and
// the streaming compose so both render exactly the same prompt.
type answerPrompt struct {
system string
user string
partial bool
}
// noEvidenceWithSummary / noEvidenceWithoutSummary are the no-evidence instructions.
// Unlike the empty_response short circuit they apply when composition still goes ahead (no
// empty_response configured), and they tell the model to degrade honestly rather than
// guess.
const (
noEvidenceWithSummary = "The retrieved passages are limited. Answer as completely as possible " +
"from the Research Summary below, using the known facts; where a " +
"specific number/entity is missing, say what is known and avoid " +
"flatly refusing to answer.\n"
noEvidenceWithoutSummary = "No supporting evidence was retrieved. State clearly that the available " +
"sources are insufficient, and do not answer from general knowledge.\n"
)
// ComposeAnswerStream is ComposeAnswer for models that can emit incrementally:
// it renders the same prompt, forwards each piece as it arrives, and returns the
// assembled answer. A streaming failure is returned so the caller can fall back
// to the one-shot call.
//
// emptyResult is the state's empty_result term of
// `no_evidence = abstain or empty_result or not chunks` — the graph compose
// path forwards the state's value (always True there; see formalizeAnswerNode).
func ComposeAnswerStream(ctx context.Context, deps AnswerDeps, model runtime.StreamingSessionModel, kb *runtime.Kbinfos, question string, partial, emptyResult bool, onDelta func(delta string, isThink bool) error) (AnswerResult, error) {
logger := deps.Logger
if logger == nil {
logger = _LOG
}
if model == nil {
return AnswerResult{Answer: "", Failed: true}, nil
}
chunks := []map[string]any{}
if kb != nil {
chunks = kb.Chunks
}
// Same no-evidence rule as the one-shot path: the prompt must carry the degradation
// instruction even when no empty_response short-circuits.
noEvidence := emptyResult || len(chunks) == 0
started := time.Now()
// Same gated kickoff step as the one-shot path above (finalizeAnnounced). The
// streaming path has no separate `abstain` signal (Go threads only
// partial/emptyResult), so the abstain note term cannot fire here.
if !finalizeAnnounced(ctx) {
step(ctx, logger, "Composing the answer", "Composing the answer from %s.",
runtime.CountOf(len(chunks), "gathered passage"))
}
prompt := deps.answerPromptWithEvidence(kb, question, partial, noEvidence)
if noEvidence && deps.EmptyResponse != "" {
return AnswerResult{Answer: deps.EmptyResponse, NoEvidence: true}, nil
}
// The record the compose prompt actually carries, content included (first 3000 chars), so
// a run that answered without the slot facts is diagnosable from the log alone.
preSummary := ""
if kb != nil {
preSummary = kb.PreSummary
}
// Both the prompt and this log go through composedRecord, so the line below
// reports the record block the answer actually carried.
record := composedRecord(kb)
logger.Printf("[Formalize][record] question=%q record_len=%d draft_summary_len=%d evidence_len=%d using=%s\nrecord=%q",
trunc(question, 160), len(record), len(preSummary), len(prompt.user), recordSource(record),
truncateRunes(record, 3000))
callCtx, cancel := context.WithTimeout(ctx, deadlineToDuration(answerTimeoutS))
defer cancel()
// Same prompt fit as the one-shot path: composition runs once and streams from the
// fitted messages.
systemTurn, userTurn := fitComposePrompt(prompt.system, prompt.user, deps.MaxLength)
userMsg := schema.UserMessage(userTurn)
if len(deps.UserImages) > 0 {
// Same as ComposeAnswerWith: attach the vision-gated images so the
// compose model sees them on the non-outer path.
userMsg = multimodalUserMsg(prompt.user, deps.UserImages)
}
// Temperature: the production streaming carrier
// (runtime.InvokerSessionModel.StreamComplete) samples at 0.3 internally —
// the same value answer_conf carries here; SessionModel's streaming
// surface has no per-call temperature, so other carriers run at their own default
// (a known limitation, flagged in the port notes).
reply, err := model.StreamComplete(callCtx, []schema.Message{
*schema.SystemMessage(systemTurn),
*userMsg,
}, nil, func(delta string, isThink bool) error {
if onDelta == nil || delta == "" {
return nil
}
return onDelta(delta, isThink)
})
if err != nil {
logger.Printf("[Composing the answer] streaming composition failed: %v", err)
return AnswerResult{Answer: "", Failed: true}, err
}
if reply == nil {
return AnswerResult{Answer: "", Failed: true}, errors.New("streaming composition returned no reply")
}
answer := cleanAnswer(reply.Content)
logComposeDone(logger, started, answer, len(chunks))
return AnswerResult{Answer: answer, Partial: partial}, nil
}
// composeSystem builds the system prompt, applying the precedence rules.
//
// LANGUAGE IS DELIBERATELY LEFT OVERRIDABLE: "answer in the same language as the
// question" is exactly the rule a user setting "answer in English" means to
// replace, so it must not be listed as protected. What stays protected is the
// evidence contract: citing sources, answering the exact attribute asked for,
// and never substituting prior knowledge for missing evidence.
// citeChunkIDsAt returns the citation id of every chunk that rendered a block, in
// render order, from the render's source indices.
//
// It takes the SOURCE INDICES rather than the chunk list because the renderer emits
// no block for a chunk without content: one id per RENDERED block, or the published
// list runs ahead of the numbering at every skip and each marker past it lands on
// the wrong passage. A chunk without an id keeps its slot as an empty string —
// dropped, it would shift the later blocks the same way (citePoolIdx resolves "" to
// -1, so that block's own citation is dropped, but the blocks after it stay put).
func citeChunkIDsAt(chunks []map[string]any, sources []int) []string {
out := make([]string, len(sources))
for i, src := range sources {
if src >= 0 && src < len(chunks) {
out[i] = runtime.ChunkIDOf(chunks[src])
}
}
return out
}
// logComposeDone reports the compose's own cost and size.
//
// It is a LOG line, not a step: the compose finishes after the answer has already
// started streaming, and the chat pipeline drops think lines emitted past that point
// (so a thought block never reopens after the answer) — a "composed …" step visible
// in the non-streaming path but not the streaming one would be worse than none.
//
// The count is in RUNES, not bytes: a Chinese answer is two to three times its rune
// count in bytes, and this number is read as "how much text came out".
func logComposeDone(logger *log.Logger, started time.Time, answer string, chunks int) {
logger.Printf("[Composing the answer] composed %s from %s in %.1fs",
runtime.CountOf(utf8.RuneCountInString(answer), "char"),
runtime.CountOf(chunks, "gathered passage"),
time.Since(started).Seconds())
}
// zeroBasedEvidenceRule states the evidence numbering for the compose model.
//
// The blocks this call renders are numbered from 0 because the client resolves a
// marker by indexing reference.chunks with its number, and the answer reaches that
// client as the model streams it. Saying so guards against the "the first source is
// 1" habit: a model that falls back on it cites the second passage and leaves the
// first one unreachable (its markers are still openable, they just point one block
// off).
//
// It belongs at the call site, not in CitationPrompt: that text is shared with the
// agent canvas, which numbers its blocks by hash id (component/prompts/citation.go).
const zeroBasedEvidenceRule = "\n\n# Evidence ids\n" +
"The evidence blocks below are numbered from 0: the FIRST block is [ID:0]. " +
"Cite the id printed at the start of the block you used, exactly as printed — " +
"do not renumber the blocks yourself."
func (d AnswerDeps) composeSystem() string {
// The citation rules are the citation_prompt template with an optional user-defined
// override (RAGConfig.CiteRules), plus the 0-based evidence-numbering rule the
// compose path renders with (see zeroBasedEvidenceRule).
rules := prompts.CitationPrompt(d.CiteRules) + zeroBasedEvidenceRule
system := strings.ReplaceAll(runtime.FinalAnswerSystem, "{cite_rules}", rules)
if sp := strings.TrimSpace(d.SystemPrompt); sp != "" {
system = fmt.Sprintf("%s\n\n# Assistant configuration (set by the user)\n%s\n\nFollow the configuration above for language, tone, style, format and any other presentational instruction, including where it overrides the language rule above. Where it conflicts with the citation rules, attribute fidelity, or the requirement to answer only from the provided evidence, those three take precedence.",
system, sp)
}
return system
}
// cleanAnswer strips a leading thinking preamble from the composed answer.
func cleanAnswer(s string) string {
return strings.TrimSpace(reAnswerThink.ReplaceAllString(s, ""))
}
// ComposeNaiveAnswer: answer composition
// (lines 1555-1568): one retrieve pass, then a single composed answer over the
// top chunks.
//
// Unlike ComposeAnswer this does NOT use kb_prompt and does NOT use
// FinalAnswerSystem — the naive path renders a flat "[i] content" list truncated to the
// first 1500 chars of each chunk, capped at 8 chunks, and sends it under a short fixed
// system prompt.
//
// The messages are run through message_fit_in against
// deps.MaxLength before the call.
func ComposeNaiveAnswer(ctx context.Context, deps AnswerDeps, chunks []map[string]any, question string) AnswerResult {
logger := deps.Logger
if logger == nil {
logger = _LOG
}
if len(chunks) == 0 {
if deps.EmptyResponse != "" {
return AnswerResult{Answer: deps.EmptyResponse, NoEvidence: true}
}
return AnswerResult{NoEvidence: true}
}
// a flat "[i] content" list over the first 8 chunks, each
// truncated to 1500 characters.
var parts []string
for i, c := range chunks {
if i >= naiveEvidenceChunkCap {
break
}
content := runtime.ChunkTextOf(c)
if len(content) > naiveEvidenceCharCap {
content = content[:naiveEvidenceCharCap]
}
parts = append(parts, fmt.Sprintf("[%d] %s", i+1, content))
}
evidence := strings.Join(parts, "\n\n")
fallback := evidence
if len(fallback) > naiveFallbackChars {
fallback = fallback[:naiveFallbackChars]
}
if deps.Model == nil {
// Calling a nil model would panic; return the evidence the same way a failed call
// does.
return AnswerResult{Answer: fallback, Failed: true}
}
callCtx, cancel := context.WithTimeout(ctx, deadlineToDuration(answerTimeoutS))
defer cancel()
// message_fit_in(form_message(system, user), max_length).
fitted, fitErr := chat.FitMessages(naiveAnswerSystem, []schema.Message{
*schema.UserMessage(fmt.Sprintf("Question: %s\n\nEvidence:\n%s", question, evidence)),
}, deps.MaxLength)
if fitErr != "" {
logger.Printf("[Naive RAG] prompt fitting failed: %s", fitErr)
return AnswerResult{Answer: fallback, Failed: true}
}
// async_chat(msg[0]["content"], msg[1:], answer_conf) — the
// fitted system text is the first entry and the rest is the history.
// Splitting explicitly keeps the call shape: Complete takes the two parts re-joined.
system := naiveAnswerSystem
history := fitted
if len(fitted) > 0 && fitted[0].Role == schema.System {
system = fitted[0].Content
history = fitted[1:]
}
messages := make([]schema.Message, 0, 1+len(history))
messages = append(messages, *schema.SystemMessage(system))
messages = append(messages, history...)
reply, err := modelWithTemperature(deps.Model, naiveAnswerTemperature).Complete(callCtx, messages, nil)
if err != nil {
// on failure yield the raw evidence, not an error.
logger.Printf("[Naive RAG] composition failed: %v", err)
return AnswerResult{Answer: fallback, Failed: true}
}
// str(ans or "").strip or empty_response — an empty answer
// degrades to the configured empty response.
answer := cleanAnswer(reply.Content)
if answer == "" {
answer = deps.EmptyResponse
}
return AnswerResult{Answer: answer}
}
+407
View File
@@ -0,0 +1,407 @@
//
// 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 agentic_rag
import (
"context"
"fmt"
"strings"
"unicode/utf8"
"github.com/cloudwego/eino/schema"
"ragflow/internal/rag/agentic-rag/runtime"
)
// The fan-out stage: decompose the question into first-hop sub-questions, parse what
// the model wrote, and fetch them programmatically.
//
// Its parsing is shape-guarded rather than answer-keyed (see fanoutLooksLikeQuery):
// the model is asked for sub-questions and hands back whatever it produced, so what
// a line IS decides what the stage does with it.
// fanoutLooksLikeQuery: reject
// prose/answer lines before they can enter the retrieval + slot pipeline.
//
// Fan-outs are used verbatim as BM25/hybrid queries and as the slot table's
// fanout_hint, so an answered fact ("The woman was **X**") must never survive
// here: it both poisons retrieval and asserts a hallucinated entity as a known
// aspect.
func fanoutLooksLikeQuery(line string, loose bool) bool {
s := strings.TrimSpace(line)
if s == "" {
return false
}
low := strings.ToLower(s)
for _, mark := range fanoutAnswerMarks {
if strings.Contains(low, mark) {
return false
}
}
// The cap counts characters, not bytes; a byte-based cap would reject a legitimate CJK
// fan-out well below the 160-character limit.
if utf8.RuneCountInString(s) > fanoutMaxChars {
return false
}
words := len(strings.Fields(s))
if loose && !strings.HasSuffix(s, "?") && words > fanoutLooseMaxWords {
return false
}
return words <= fanoutMaxWords
}
// fanoutLineBreak reports whether r is a line boundary.
// Splitting on "\n" alone misses a lone "\r" and the other Unicode line breaks a model
// could emit, so the loose path would fuse several fan-out lines into one.
func fanoutLineBreak(r rune) bool {
switch r {
case '\n', '\r', '\v', '\f', 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029:
return true
}
return false
}
// parseFanouts: extract fan-outs from a
// model reply, validating every entry's shape.
func parseFanouts(text string) []string {
var raw []string
loose := false
if data, ok := extractJSONObject(text).(map[string]any); ok {
for _, f := range asSliceOfAny(data["fanouts"]) {
if s := strings.TrimSpace(fmt.Sprint(f)); s != "" {
raw = append(raw, s)
}
}
} else {
// Loose fallback: the model answered in prose. Only lines that still
// look like a search query are kept — answer sentences and source
// lists are dropped.
loose = true
// Split on every line boundary, not just "\n".
for _, ln := range strings.FieldsFunc(text, fanoutLineBreak) {
if strings.TrimSpace(ln) == "" {
continue
}
// The bullet / numbering cutset is stripped from BOTH ends, then whitespace is
// trimmed, so a trailing period/digit never leaks into the retrieval query.
raw = append(raw, strings.TrimSpace(strings.Trim(ln, "-•0123456789. ")))
}
}
kept := make([]string, 0, len(raw))
for _, q := range raw {
if fanoutLooksLikeQuery(q, loose) {
kept = append(kept, q)
}
}
if len(raw) > 0 && len(kept) == 0 {
_LOG.Printf("[Planner] discarding %d fan-out candidate(s): none look like search queries", len(raw))
}
kept = dedupe(kept)
if len(kept) > MaxFanouts {
kept = kept[:MaxFanouts]
}
return kept
}
// ExpandFanouts: ONE chat call (no
// tools) producing 2-5 first-hop fan-outs, plus one strict retry when the
// reply was not parseable JSON (_expand_fanouts).
//
// Falls back to the raw question alone on any failure — a fan-out failure never
// blocks the pipeline.
func ExpandFanouts(ctx context.Context, deps RAGTools, question string) []string {
if question == "" {
return nil
}
if deps.Model == nil {
return []string{question}
}
reply, err := deps.Model.Complete(ctx, []schema.Message{
*schema.SystemMessage(fanoutPrompt),
*schema.UserMessage("Question: " + question),
}, nil)
if err != nil {
_LOG.Printf("[Planner] fan-out expansion failed; falling back to raw question: %v", err)
return []string{question}
}
fanouts := parseFanouts(reply.Content)
if len(fanouts) == 0 {
// The model answered the question instead of decomposing it (no JSON,
// or JSON that failed the shape guard). One strict retry, then give up.
_LOG.Printf("[Planner] fan-out expansion produced no usable sub-question; retrying with a strict JSON instruction")
if retry, rerr := deps.Model.Complete(ctx, []schema.Message{
*schema.SystemMessage(fanoutPrompt + fanoutStrictRetry),
*schema.UserMessage("Question: " + question),
}, nil); rerr != nil {
_LOG.Printf("[Planner] strict fan-out retry failed: %v", rerr)
} else {
fanouts = parseFanouts(retry.Content)
}
}
if len(fanouts) == 0 {
fanouts = []string{question}
}
_LOG.Printf("[Planner] fan-out expansion: %d sub-question(s): %v", len(fanouts), fanouts)
return fanouts
}
// Phase 2: programmatic fan-out search.
// FanoutSearch: the
// programmatic multi-query prefetch that seeds the snippet pool.
//
// It is a DUAL-CHANNEL search, and the two channels are deliberately different:
//
// - channel A (exact): a keyword BM25 round over a wide pool (top_n=60) whose
// hits are then narrowed by the query's own terms — the precision path. A
// chunk only survives if it literally contains a query term.
// - channel B (semantic bypass): a dense hybrid round (top_n=30) that
// deliberately SKIPS narrowing, so a paraphrase-only hit can still enter the
// pool. These are quarantined to a small per-fan-out quota
// (fanoutSemanticQuota) because bypassing narrowing is what makes them
// low-precision.
//
// Collapsing them into one call loses the split, and with it the ability to
// admit a paraphrase-only match without letting it displace an exact one.
//
// Admission order is the point: every fan-out's channel A is admitted before any
// fan-out's channel B, so an exact hit from a later fan-out outranks a semantic
// hit from an earlier one.
//
// Returns the number of NEW snippets admitted to the pool.
func FanoutSearch(ctx context.Context, deps RAGTools, st *AgenticState, queries []string, topN, capacity int) int {
if len(queries) == 0 || st.KB == nil {
return 0
}
sd := deps.Search
if sd.Backend == nil {
return 0
}
// Dedup against what kbinfos ALREADY holds, then cap admissions at the remaining room —
// the pool is a shared, cross-round ceiling.
seen := make(map[string]bool, len(st.KB.Chunks))
for _, c := range st.KB.Chunks {
seen[runtime.ChunkIDOf(c)] = true
}
maxTotal := capacity
if maxTotal <= 0 {
maxTotal = MaxSnippetPool
}
room := maxTotal - len(seen)
if room <= 0 {
return 0
}
// The planner's output is already []string, but blank entries still have to be dropped.
qs := make([]string, 0, len(queries))
for _, q := range queries {
if q = strings.TrimSpace(q); q != "" {
qs = append(qs, q)
}
}
if len(qs) == 0 {
return 0
}
capPerQuery := topN
if capPerQuery < 1 {
capPerQuery = 1
}
// The fan-outs could run in parallel, but each one only RETRIEVES — kbinfos is mutated
// once, back in the caller. This keeps that "retrieve, then mutate once" structure, hence
// sequential: the per-request search cache and Kbinfos are shared mutable state that the
// retrievals never touch concurrently.
type fanoutPair struct {
exact []map[string]any
semantic []map[string]any
}
pairs := make([]fanoutPair, 0, len(qs))
for _, q := range qs {
select {
case <-ctx.Done():
return 0
default:
}
exact, semantic := fanoutSearchQuery(ctx, sd, q, capPerQuery)
pairs = append(pairs, fanoutPair{exact: exact, semantic: semantic})
}
added := 0
rawAdded := 0
admit := func(batch []map[string]any) bool {
for _, c := range batch {
if added >= room {
return true
}
id := runtime.ChunkIDOf(c)
isEvidence := strings.HasPrefix(id, "claim_")
if id != "" {
if seen[id] {
continue
}
seen[id] = true
}
// Raw passages get their own budget;
// anything left above it stays free for evidence rows, which are
// far denser answer material. Evidence rows (claim_ prefix) bypass
// the quota and never count against it.
if !isEvidence && rawAdded >= rawSnippetQuota {
continue
}
st.KB.Chunks = append(st.KB.Chunks, c)
added++
if !isEvidence {
rawAdded++
}
}
return added >= room
}
// Channel 0: claim/evidence rows lead the pool —
// they are the compact, verbatim-bearing proxy for the chunks they source.
// Gated on the dataset having compiled rows at all; capped at
// evidencePoolQuota rows across the whole prefetch; best effort.
channel0 := [][]map[string]any{}
channel0Rows := 0
for _, q := range qs {
select {
case <-ctx.Done():
return 0
default:
}
if !runtime.DatasetHasCompilation(ctx, sd) {
break
}
// top_n=max(2, top_n) per query, so a single-fanout
// question still recalls at least two claim rows.
recallN := capPerQuery
if recallN < 2 {
recallN = 2
}
hits := runtime.RecallDatasetClaims(ctx, sd, q, recallN)
if len(hits) == 0 {
continue
}
pseudo := runtime.ClaimPseudoChunks(hits)
kept := make([]map[string]any, 0, len(pseudo))
for _, pc := range pseudo {
if channel0Rows >= evidencePoolQuota {
break
}
kept = append(kept, pc)
channel0Rows++
}
if len(kept) == 0 {
break
}
channel0 = append(channel0, kept)
if admit(kept) {
return added
}
}
// Evidence top-up: an evidence row carries a
// verbatim quote but not its surrounding passage, so pull exactly the chunks
// it cites instead of running another global recall. Deduped against the
// pool (a chunk the claim already quotes verbatim adds nothing new) and
// capped at evidenceTopUp ids. Best effort.
if len(channel0) > 0 {
var wanted []string
inWanted := map[string]bool{}
for _, pseudo := range channel0 {
for _, c := range pseudo {
ids, _ := c["source_chunk_ids"].([]string)
for _, cid := range ids {
cid = strings.TrimSpace(cid)
if cid == "" || seen[cid] || inWanted[cid] {
continue
}
inWanted[cid] = true
wanted = append(wanted, cid)
}
}
}
if len(wanted) > evidenceTopUp {
wanted = wanted[:evidenceTopUp]
}
if len(wanted) > 0 {
fetched := runtime.LoadChunksForIDs(ctx, sd, wanted)
if len(fetched) > 0 && admit(fetched) {
return added
}
}
}
// Channel A across every fan-out first, then channel B.
for _, p := range pairs {
if admit(p.exact) {
return added
}
}
for _, p := range pairs {
if admit(p.semantic) {
return added
}
}
return added
}
// fanoutSearchQuery runs one fan-out's two channels. It only retrieves; the
// caller admits the results.
func fanoutSearchQuery(ctx context.Context, sd runtime.SearchDeps, fq string, capPerQuery int) (exact, semantic []map[string]any) {
terms := runtime.QueryToTerms(fq)
keyed := runtime.FanoutKeyedTerms(terms)
termList := keyed
if len(termList) == 0 {
termList = terms
}
// Channel A — exact: BM25Search is the dedicated keyword-only entry point (vector weight
// 0).
candidates, _ := runtime.BM25Search(ctx, sd, runtime.SearchParams{
Question: fq,
Keywords: strings.Join(termList, " "),
TopN: fanoutBM25TopN,
})
if len(candidates) > 0 {
res := runtime.NarrowByTerms(candidates, termList, nil, fq,
runtime.NarrowContext{Before: 0, After: 1},
fanoutNarrowMaxOutPerChunk, fanoutNarrowMaxOutTotal)
exact = res.Kept
if len(exact) > capPerQuery {
exact = exact[:capPerQuery]
}
}
// Channel B — semantic bypass: HybridSearch gives the vector leg weight 0.3 whenever an
// embedder is configured. NO narrowing (a paraphrase-only hit has no query term to match, so
// narrowing would erase it).
hits, _ := runtime.HybridSearch(ctx, sd, runtime.SearchParams{
Question: fq,
TopN: fanoutHybridTopN,
})
exactIDs := make(map[string]bool, len(exact))
for _, c := range exact {
exactIDs[runtime.ChunkIDOf(c)] = true
}
for _, c := range hits {
if exactIDs[runtime.ChunkIDOf(c)] {
continue
}
semantic = append(semantic, c)
if len(semantic) >= fanoutSemanticQuota {
break
}
}
return exact, semantic
}
+607
View File
@@ -0,0 +1,607 @@
//
// 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 agentic_rag
import (
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
"time"
"ragflow/internal/rag/agentic-rag/runtime"
"ragflow/internal/rag/agentic-rag/runtime/orchestrator"
)
// The SCA stage: the review view, its scores, the gaps it leaves for the rewrite, and
// the feedback phrase the next round is handed.
// SelectSCAView: rank the stored pool
// down to the SCA review view (storage ≠ review).
//
// Score = retrieval relevance + surface-term coverage + freshness bonus for
// chunks admitted in later rounds. Returns (view, identity) where identity is a
// stable hash of the selected chunk ids — the caller uses it to detect an
// unproductive round (same view twice despite new storage ⇒ nothing new).
func SelectSCAView(chunks []map[string]any, focusTerms []string) ([]map[string]any, string) {
terms := dedupe(focusTerms)
lowered := make([]string, 0, len(terms))
for _, t := range terms {
if len(t) >= 3 {
lowered = append(lowered, strings.ToLower(t))
}
}
type scored struct {
idx int
chunk map[string]any
score float64
}
ranked := make([]scored, 0, len(chunks))
for i, c := range chunks {
text := strings.ToLower(strings.Join([]string{
anyString(c["content"]), anyString(c["content_with_weight"]),
anyString(c["title"]), anyString(c["question_toks"]),
}, " "))
cov := 0
for _, t := range lowered {
if strings.Contains(text, t) {
cov++
}
}
covRatio := 0.5
if len(lowered) > 0 {
covRatio = float64(cov) / float64(len(lowered))
}
// The pool's relevance reading, from the one place that defines it
// (see similarityOrScore).
rel := similarityOrScore(c)
fresh := min(float64(i)/20.0, 0.2) // late arrivals (gap-pursuit evidence) get seen
ranked = append(ranked, scored{i, c, rel*0.45 + min(covRatio, 1.0)*0.45 + fresh})
}
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
// Evidence rows first: an atomic proposition carrying a
// verbatim quote is what the reviewer should read before wading through raw
// passages. Both groups keep their relevance order; only the grouping is
// lifted before the view cap is applied, so claim evidence can no longer be
// pushed out of the view by later, loosely-related raw chunks.
var evidence, rest []map[string]any
for _, r := range ranked {
if strings.HasPrefix(runtime.ChunkIDOf(r.chunk), evidenceChunkPrefix) {
evidence = append(evidence, r.chunk)
} else {
rest = append(rest, r.chunk)
}
}
limit := min(len(ranked), SCAViewCap)
view := make([]map[string]any, 0, limit)
view = append(view, evidence...)
view = append(view, rest...)
view = view[:min(len(view), limit)]
ids := make([]string, 0, limit)
for _, c := range view {
ids = append(ids, runtime.ChunkIDOf(c))
}
// identity is a hash of the SORTED id set, so this reordering cannot break
// the unproductive-round detector.
sort.Strings(ids)
return view, joinHash(ids)
}
// joinHash builds a stable identity from the selected chunk ids: a deterministic digest
// rather than a randomised-per-process hash.
func joinHash(ids []string) string {
return fmt.Sprintf("%x", strings.Join(ids, "|"))
}
// SCAGapsToRewrite
//
// Preference order:
// 1. Unsatisfied sub_queries (the precise "what is missing / where to search
// next" signal from Q-CARE) — (missing_fact, search_hint).
// 2. Per-claim missing_information items — (what, search_hint).
//
// Returns [(what, search_hint), ...] (deduped, non-empty).
func SCAGapsToRewrite(sca map[string]any) []orchestrator.MissingPiece {
var gaps []orchestrator.MissingPiece
seen := map[string]bool{}
add := func(what, hint string) {
what = strings.TrimSpace(what)
hint = strings.TrimSpace(hint)
if what == "" && hint == "" {
return
}
key := what + "|" + hint
if seen[key] {
return
}
seen[key] = true
if what == "" {
what = hint
}
if hint == "" {
hint = what
}
gaps = append(gaps, orchestrator.MissingPiece{What: what, SearchHint: hint})
}
if raw, ok := sca["sub_queries"].([]any); ok {
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if b, ok := m["satisfied"].(bool); ok && b {
continue
}
what := anyString(m["missing_fact"])
if what == "" {
what = anyString(m["sub_query"])
}
add(what, anyString(m["search_hint"]))
}
}
if len(gaps) == 0 {
// Fall back to per-claim missing_information.
switch claims := sca["claims"].(type) {
case map[string]orchestrator.ClaimVerdict:
keys := make([]string, 0, len(claims))
for k := range claims {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, mi := range claims[k].MissingInformation {
add(mi.What, mi.SearchHint)
}
}
case map[string]any:
for _, g := range claims {
if gm, ok := g.(map[string]any); ok {
for _, mi := range asSliceOfAny(gm["missing_information"]) {
if mm, ok := mi.(map[string]any); ok {
add(anyString(mm["what"]), anyString(mm["search_hint"]))
} else {
add(anyString(mi), "")
}
}
}
}
}
}
if len(gaps) > MaxSCAGaps {
gaps = gaps[:MaxSCAGaps]
}
return gaps
}
// Phase 1: fan-out expansion.
// scaNode mirrors the `sca` node: the Phase-3 quality-control review.
func scaNode(ctx context.Context, deps RAGTools, st *AgenticState, logger *log.Logger) {
ctx, done := runtime.Phase(ctx, "sca")
defer done()
chunks := st.KB.Chunks
view, viewID := SelectSCAView(chunks, ViewTerms(st))
if st.SCAViewID != "" && viewID == st.SCAViewID {
// Same evidence review twice despite new storage — further rounds cannot
// change the verdict; stop instead of looping.
logger.Printf("[SCA] review view UNCHANGED since last round (%d stored / %d viewed); closing out.", len(chunks), len(view))
st.Verdict = VerdictInsufficient
st.NoProgress = true
st.SCA = map[string]any{}
return
}
draftText := strings.TrimSpace(st.Draft)
claims, grownView := buildSCAClaims(draftText, view, chunks, st.SlotEvidence)
// Review against the GROWN view (sca appends to the same list).
view = grownView
// sca — the only case that skips the review outright is
// "nothing retrieved AND nothing drafted". A non-empty view always reaches
// the SCA (its default claim indexes the whole view, see buildSCAClaims).
if draftText == "" && len(view) == 0 {
logger.Printf("[SCA] nothing retrieved nor drafted; marking INSUFFICIENT to trigger a targeted re-search.")
st.Verdict = VerdictInsufficient
st.SCA = map[string]any{}
st.SCAViewID = viewID
return
}
// The SCA renders claim evidence from kbinfos BY INDEX, so review against a
// view-only copy — the indexed ids then point at exactly the selected
// chunks. The full pool is restored afterwards.
orig := st.KB.Chunks
st.KB.Chunks = view
defer func() { st.KB.Chunks = orig }()
review := func() orchestrator.SCAResult {
t := nodeClock(SCATimeoutS, 15.0, st.RemainingS()-10.0)
callCtx, cancel := context.WithTimeout(ctx, time.Duration(t*float64(time.Second)))
defer cancel()
return orchestrator.SufficientContextAgent(callCtx, orchestrator.SCADeps{
KB: st.KB,
Model: &jsonModelAdapter{inner: deps.Model, maxLength: deps.MaxLength},
Prompts: deps.SCAPrompts,
}, st.Question, claims)
}
res := review()
if len(scaResultToMap(res)) == 0 && st.RemainingS() >= SCARetryHeadroomS {
// One retry: a failed review is a fact about the moment, not about the question, and on a
// count question this is the only stage that asks whether the list is complete. Spent only
// while there is clock left for the answer beside it.
logger.Printf("[SCA] review unavailable on the first attempt (%.0fs left); retrying once.", st.RemainingS())
res = review()
}
st.SCAViewID = viewID
st.SCA = scaResultToMap(res)
if len(st.SCA) == 0 {
// sca — an unavailable SCA (timeout, unparsable reply, no model) is NOT a
// verdict: it is the absence of one. Marking it INSUFFICIENT used to be
// the fallback, and it read as a judgement everywhere downstream — the
// answer was labelled PARTIAL on no evidence (formalizeAnswerNode) and
// the loop was told to research again by a review that never ran
// (measured 2026-09-15: review timed out at 35s and the run closed out
// with zero seconds left, so the "insufficiency" was never actionable
// either). VerdictUnknown keeps the two apart; the loop's own record
// (growth + gaps) decides whether another round is worth its budget, and
// the deliverable is not dressed up as partly-unverified.
logger.Printf("[SCA] unavailable (no review could be completed); recording UNKNOWN — this is not a sufficiency judgement, so the round's own record drives the loop.")
st.SCA = map[string]any{}
st.Verdict = VerdictUnknown
// The fact goes where the answer can read it (see composedRecord). The partial label keeps
// its evidence-based rule above: they are different claims.
st.KB.NoteSufficiencyUnchecked()
return
}
if res.IsSufficient {
st.Verdict = VerdictSufficient
} else {
st.Verdict = VerdictInsufficient
}
step(ctx, logger, "SCA", "Evidence check: %s (confidence %.2f, reviewed %d of %s).",
st.Verdict, res.Confidence, len(view), runtime.CountOf(len(chunks), "passage"))
}
// unresolvedClueGaps is the gap fallback: the first two question_clues of every unresolved
// slot become (what, hint) gaps, so a rewrite can still be issued when the SCA produced no
// structured gaps.
func unresolvedClueGaps(st *AgenticState) []orchestrator.MissingPiece {
var gaps []orchestrator.MissingPiece
for _, us := range st.UnresolvedSlots {
clues, ok := us["question_clues"].([]string)
if !ok {
continue
}
for i, qc := range clues {
if i >= 2 {
break
}
qc = strings.TrimSpace(qc)
if qc == "" {
continue
}
gaps = append(gaps, orchestrator.MissingPiece{What: qc, SearchHint: qc})
}
}
return gaps
}
// buildSCAClaims mirrors the sca node's claim construction (lines 971-997): the
// draft as claim "c0", plus one claim per slot carrying its evidence positions.
//
// It returns the (possibly grown) view alongside the claims: the slot-evidence chunks that
// the view missed are appended to the SAME list before review, so the caller must review
// against the returned slice, not the one it passed in.
func buildSCAClaims(draftText string, view, chunks []map[string]any, slotEvidence map[string]SlotEvidence) ([]orchestrator.ClaimDraft, []map[string]any) {
var claims []orchestrator.ClaimDraft
if draftText != "" {
claims = append(claims, orchestrator.ClaimDraft{ID: "c0", Draft: draftText})
}
viewIndexByID := map[string]int{}
for i, c := range view {
if id := runtime.ChunkIDOf(c); id != "" {
viewIndexByID[id] = i
}
}
sids := make([]string, 0, len(slotEvidence))
for sid := range slotEvidence {
sids = append(sids, sid)
}
sort.Strings(sids)
for _, sid := range sids {
meta := slotEvidence[sid]
if len(meta.EvidenceIDs) == 0 {
continue
}
// Resolve every evidence id to a POSITION in the view, appending chunks
// that are missing so the SCA sees the passages that actually produced
// the candidate (sca).
positions := make([]string, 0, len(meta.EvidenceIDs))
for _, eid := range meta.EvidenceIDs {
pos := -1
if p, ok := viewIndexByID[eid]; ok {
pos = p
} else if c := resolveEvidenceChunk(eid, chunks); c != nil {
if p, ok := viewIndexByID[runtime.ChunkIDOf(c)]; ok {
pos = p
} else {
view = append(view, c)
pos = len(view) - 1
if id := runtime.ChunkIDOf(c); id != "" {
viewIndexByID[id] = pos
}
}
}
if pos >= 0 {
positions = append(positions, strconv.Itoa(pos))
}
}
draft := truncateRunes(meta.Candidate, draftCandidateChars)
if draft == "" {
draft = fmt.Sprintf("(slot %s evidence)", sid)
}
claims = append(claims, orchestrator.ClaimDraft{ID: sid, Draft: draft, EvidenceIDs: positions})
}
if len(claims) == 0 {
// sca — an empty draft still gets ONE claim, carrying every
// position in the view, so the SCA judges the evidence itself instead of
// the node short-circuiting on "no draft".
draft := draftText
if strings.TrimSpace(draft) == "" {
draft = "(no draft)"
}
claims = []orchestrator.ClaimDraft{{ID: "c0", Draft: draft, EvidenceIDs: allViewPositions(view)}}
}
return claims, view
}
// resolveEvidenceChunk maps one slot-evidence id back to a chunk. The ids are
// heterogeneous by construction:
//
// - the retrieval tools emit the chunk id;
// - the navigation tools emit document ids (tool_executor.go:455);
// - the SCA view itself is keyed by POSITION (renderClaimContext /
// sufficient_context.go:305-309), which the numeric branch below covers.
//
// slot_evidence holds chunk ids throughout, so both spellings are resolved here before
// giving up.
func resolveEvidenceChunk(eid string, chunks []map[string]any) map[string]any {
eid = strings.TrimSpace(eid)
if eid == "" {
return nil
}
// Identity FIRST, position second: a chunk id that happens to be all digits (or a
// doc id like "3") would otherwise be read as an index and resolve to a DIFFERENT
// chunk — the ambiguity only disappears if exact ids win.
for _, c := range chunks {
if runtime.ChunkIDOf(c) == eid {
return c
}
if id := runtime.DocIDOf(c); id != "" && id == eid {
return c
}
}
if n, err := strconv.Atoi(eid); err == nil && n >= 0 && n < len(chunks) {
return chunks[n]
}
return nil
}
// allViewPositions is "every position in the view" (renderClaimContext keys evidence by
// index).
func allViewPositions(view []map[string]any) []string {
positions := make([]string, 0, len(view))
for i := range view {
positions = append(positions, strconv.Itoa(i))
}
return positions
}
// scaResultToMap flattens an SCAResult back into the map shape the loop keeps in
// AgenticState.SCA (SCAGapsToRewrite reads both shapes).
func scaResultToMap(res orchestrator.SCAResult) map[string]any {
if len(res.Claims) == 0 && res.Reasoning == "" && !res.IsSufficient {
return nil
}
out := map[string]any{
"is_sufficient": res.IsSufficient,
"confidence": res.Confidence,
"contradictions": toAnySlice(res.Contradictions),
"reasoning": res.Reasoning,
"claims": res.Claims,
}
if len(res.SubQueries) > 0 {
sqs := make([]any, 0, len(res.SubQueries))
for _, sq := range res.SubQueries {
sqs = append(sqs, map[string]any{
"sub_query": sq.SubQuery,
"satisfied": sq.Satisfied,
"missing_fact": sq.MissingFact,
"search_hint": sq.SearchHint,
})
}
out["sub_queries"] = sqs
}
return out
}
// verdictStatusHint is the human phrase folded into rag()'s "[Research status]" note for each
// sufficiency status.
//
// Only INSUFFICIENT is reachable: the verdict dict carries a single "status" key whose value
// is either "SUFFICIENT" or "INSUFFICIENT", and SUFFICIENT is skipped outright.
// The upstream dict's USEFUL_BUT_INCOMPLETE and CONFLICTING entries are
// therefore unreachable, so Go does not carry cases for them.
func verdictStatusHint(verdict string) string {
switch verdict {
case VerdictInsufficient:
return "evidence is not yet sufficient"
case VerdictUnknown:
// Not a judgement: the review did not complete, so nothing here may claim
// the evidence was found wanting (nor that it was sufficient).
return "the evidence review did not complete, so sufficiency is unverified"
default:
return "sufficiency status: " + verdict
}
}
// scaFeedback is the body rag folds into the answer as the "[Research status]" note whenever
// research stays unsatisfying.
//
// It composes as `status_hint + missing_txt + hard_txt + conf_txt + fb_txt`, but every term
// after the first is empty in practice: `verdict` is `{"status": ...}` and carries no
// missing_claims / hard_violations / agent_confidence / feedback keys anywhere, so the note is
// the status hint alone.
//
// The SCA's rich payload is not lost — it stays in AgenticState.SCA and still drives
// SCAGapsToRewrite (which reads the separate `sca` state key). Non-empty for
// ANY non-SUFFICIENT verdict (not only after two consecutive misses), so the caller can
// append the appropriate trailing sentence ("STOP" vs "call rag again").
func scaFeedback(_ map[string]any, verdict string) string {
if verdict == VerdictSufficient {
return ""
}
return verdictStatusHint(verdict)
}
// renderResearchContext mirrors the query_rewrite node's context build
// (lines 1047-1066): the attempted-query ledger with outcomes, plus the first
// lines of the evidence pool.
func renderResearchContext(st *AgenticState) string {
var historyLines []string
for _, e := range st.Attempted {
if e == nil {
continue
}
q := truncateRunes(anyString(e["q"]), 120)
r := anyString(e["r"])
if r == "" {
r = "?"
}
outcome := "no new passages"
switch {
case e["bound"] != nil:
// A research round's row carries what ITS session bound. There is no
// per-session "new to the pool" number to report: a round's sessions run
// concurrently over ONE pool (see RunSlotResearchPass).
if n, ok := toIntStrict(e["bound"]); ok {
outcome = fmt.Sprintf("%d passage(s) bound", n)
}
default:
// A prefetch/rewrite row: those DO know how many passages the fetch added.
if n, ok := toIntStrict(e["new"]); ok && n != 0 {
outcome = fmt.Sprintf("%d new passage(s)", n)
}
}
historyLines = append(historyLines, fmt.Sprintf("- %s (round %s: %s)", q, r, outcome))
}
// The passages behind the members already confirmed come FIRST, because they
// are the only place the rewriter can see (a) the wording this text uses for
// the relation and (b) the names that are still missing. Those passages are
// exactly the windows the seats were narrowed to (see
// Kbinfos.RecordReachedTerm), so a name inside one of them is readable.
//
// The pool-head lines below are the fallback for a round that has confirmed
// nothing yet. They are FIRST LINES only, so a name in the middle of a chunk
// is invisible through them — which is why they are the fallback and not the
// main channel.
var memberLines []string
for i, m := range memberLinesOf(st) {
if i >= MemberWindowMax {
break
}
memberLines = append(memberLines, "- "+m)
}
var poolLines []string
if len(memberLines) == 0 && st.KB != nil {
for i, c := range st.KB.Chunks {
if i >= PoolHeadLines {
break
}
// Read `content` FIRST here — unlike the content_with_weight-first order used
// elsewhere. The pool-head lines are rewriter prompt content, so this order is
// deliberate.
first := anyString(c["content"])
if first == "" {
first = anyString(c["content_with_weight"])
}
if idx := strings.IndexByte(first, '\n'); idx >= 0 {
first = first[:idx]
}
if first = strings.TrimSpace(first); first != "" {
poolLines = append(poolLines, "- "+truncateRunes(first, 140))
}
}
}
var parts []string
if len(historyLines) > 0 {
parts = append(parts, "Previously searched queries and their outcomes:\n"+strings.Join(historyLines, "\n"))
}
if len(memberLines) > 0 {
parts = append(parts, "Passages that carry names the searches ALREADY confirmed (read them for the wording this text uses for the relation, and for other names they mention — any of those can be asked about directly):\n"+strings.Join(memberLines, "\n"))
}
if len(poolLines) > 0 {
parts = append(parts, "Evidence currently at hand (first lines of top stored snippets):\n"+strings.Join(poolLines, "\n"))
}
// The round's own record of what its probes ASKED and never reached. The
// rewriter reads it for one reason: re-asking a name the corpus already came
// back empty on is the loop's most common waste, and the productive move from
// a dead name is a different ANGLE (the act, the relationship, the place),
// which the rewriter cannot choose unless it knows the name is dead.
if absent := st.KB.ProbedAbsentTerms(); len(absent) > 0 {
parts = append(parts, "Terms already asked for and NOT reached by any passage (do NOT re-ask these on their own; ask for the act / relationship / place instead):\n"+strings.Join(absent, "、"))
}
return strings.Join(parts, "\n\n")
}
// memberLinesOf renders one line per confirmed member: the name, and the window
// that carries it, looked up in the pool by the id the seat recorded.
//
// It reads the round's own record rather than re-searching anything: a seat IS a
// probe of one individual that came back with a passage, so the pair (name,
// passage) is already established by the time the rewrite runs.
func memberLinesOf(st *AgenticState) []string {
if st == nil || st.KB == nil {
return nil
}
byID := make(map[string]map[string]any, len(st.KB.Chunks))
for _, c := range st.KB.Chunks {
if id := runtime.ChunkIDOf(c); id != "" {
byID[id] = c
}
}
var out []string
seen := map[string]bool{}
for _, m := range st.KB.ReachedTerms() {
if seen[m.ChunkID] {
continue
}
c, ok := byID[m.ChunkID]
if !ok {
continue
}
text := strings.TrimSpace(runtime.ChunkTextOf(c))
if text == "" {
continue
}
seen[m.ChunkID] = true
out = append(out, fmt.Sprintf("%s: %s", m.Term, truncateRunes(text, MemberWindowChars)))
}
return out
}
File diff suppressed because it is too large. Load diff
+328
View File
@@ -0,0 +1,328 @@
//
// 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.
//
// The small helpers every stage shares live in THIS file: JSON extraction, scalar
// coercion, score extraction, string comparison, rune truncation. They live together so
// that a stage needing one of them takes the family from one place — and so that a second
// copy of any of them is not where two stages start disagreeing about the same value
// (`stringOf` and `anyString` used to be exactly that pair).
package agentic_rag
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"ragflow/internal/common"
"ragflow/internal/rag/agentic-rag/runtime"
)
// similarityOrScore is the ONE reading of a chunk's relevance: its `similarity`,
// falling through to `score` when that is missing or zero — a FALSY similarity must not
// win over a present score, so this is deliberately not "first key present wins".
// It was written three times (the SCA view, the draft's ordering, the citation
// reference) before it lived here.
func similarityOrScore(c map[string]any) float64 {
if v, ok := toFloat(c["similarity"]); ok && v != 0 {
return v
}
if v, ok := toFloat(c["score"]); ok && v != 0 {
return v
}
return 0.0
}
// rankByScore returns the chunks strongest-first. Stable, so equal-scored chunks keep
// their retrieval order.
func rankByScore(chunks []map[string]any) []map[string]any {
out := append([]map[string]any(nil), chunks...)
sort.SliceStable(out, func(i, j int) bool {
return similarityOrScore(out[i]) > similarityOrScore(out[j])
})
return out
}
// queryToTerms is the runtime keywords helper the SCA view reads: lowercase word
// tokens of length >= 3, de-duplicated, order preserved.
func queryToTerms(q string) []string {
if q == "" {
return nil
}
found := tokenPattern.FindAllString(strings.ToLower(q), -1)
out := make([]string, 0, len(found))
seen := make(map[string]bool, len(found))
for _, t := range found {
if len(t) < 3 || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
// truncateRunes caps a string to n runes without breaking multi-byte chars.
func truncateRunes(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// dedupe preserves order and drops empty / repeated entries.
func dedupe(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// asSliceOfAny coerces a JSON array value to []any.
//
// isPoisoned covers the values that equally must never reach a prompt (see its doc
// comment).
func asSliceOfAny(v any) []any {
if isPoisoned(v) {
_LOG.Printf("[StateGuard] dropping poisoned value of kind %s; treating as empty",
reflect.ValueOf(v).Kind())
return nil
}
switch x := v.(type) {
case nil:
return nil
case []any:
return x
case []string:
out := make([]any, len(x))
for i, s := range x {
out[i] = s
}
return out
case map[string]any:
out := make([]any, 0, len(x))
for _, vv := range x {
out = append(out, vv)
}
return out
default:
return []any{v}
}
}
// toIntStrict parses an int-like value (JSON numbers arrive as float64).
func toIntStrict(v any) (int, bool) {
switch x := v.(type) {
case int:
return x, true
case int64:
return int(x), true
case float64:
return int(x), true
case string:
var n int
if _, err := fmt.Sscanf(x, "%d", &n); err == nil {
return n, true
}
}
return 0, false
}
// toFloat parses a numeric value.
func toFloat(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case int:
return float64(x), true
case int64:
return float64(x), true
case string:
var f float64
if _, err := fmt.Sscanf(x, "%f", &f); err == nil {
return f, true
}
}
return 0, false
}
// anyString reads a string field from a chunk-like map.
func anyString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
default:
return fmt.Sprint(x)
}
}
// truncateEach caps every entry of a string slice.
func truncateEach(in []string, n int) []string {
if len(in) == 0 {
return nil
}
out := make([]string, len(in))
for i, s := range in {
out[i] = truncateRunes(s, n)
}
return out
}
// equalStringPtr reports whether two optional strings are equal.
func equalStringPtr(a, b *string) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// equalStrings reports set-equality (order-independent) of two string slices.
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
seen := make(map[string]int, len(a))
for _, s := range a {
seen[s]++
}
for _, s := range b {
if seen[s] == 0 {
return false
}
seen[s]--
}
return true
}
// isPoisoned reports whether a state value must be discarded before it reaches a
// prompt: channels and function values, neither of which renders as anything a model
// can read.
func isPoisoned(v any) bool {
if v == nil {
return false
}
switch reflect.ValueOf(v).Kind() {
case reflect.Chan, reflect.Func, reflect.UnsafePointer:
return true
}
return false
}
// extractJSONObject: return the
// first parseable JSON object in text, or nil when none parses.
//
// The fan-out model sometimes emits prose around the object, and a greedy
// brace-match would capture several objects and fail with "extra data"; each
// candidate is therefore validated before it is accepted, and an invalid one
// resumes the scan at its next "{".
func extractJSONObject(text string) any {
for i := 0; i < len(text); {
rel := strings.IndexByte(text[i:], '{')
if rel < 0 {
return nil
}
start := i + rel
depth := 0
scan:
for j := start; j < len(text); j++ {
switch text[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
var out map[string]any
if json.Unmarshal([]byte(text[start:j+1]), &out) == nil {
return out
}
break scan // not a valid object; try the next "{"
}
}
}
i = start + 1
}
return nil
}
// jsonModelAdapter adapts runtime.SessionModel to orchestrator.JSONModel.
//
// The rendered prompt is the system turn and "Output:\n" the user turn (fitted ONCE
// to the model's context window), then the first JSON value is parsed out of the reply.
// Malformed JSON is retried (up to genJSONMaxRetry calls) with the model's own
// bad answer and the parse error appended to the user turn, so a single
// formatting hiccup does not abort the SCA review or the query rewrite.
type jsonModelAdapter struct {
inner runtime.SessionModel
// maxLength is the chat model's context window. It bounds the message fit so an
// oversized prompt is trimmed instead of rejected by the provider. <=0 falls back
// to chat.EffectiveContextLength's 8192 default.
maxLength int
}
// parseGenJSONReply parses a cleaned model reply with gen_json's tolerance: a
// strict decode of ANY top-level JSON value first (json_repair accepts objects,
// arrays and scalars alike), then the brace-matched object extractor for fenced
// or damaged objects — the JSON-shaped gate, NOT the prose-salvaging ExtractJSON,
// so a non-JSON reply stays a parse failure and triggers the corrective retry.
// The boolean distinguishes a successful decode from a failure, because a
// legitimate `null` reply decodes to a nil value.
func parseGenJSONReply(cleaned string) (any, bool) {
var val any
if err := json.Unmarshal([]byte(strings.TrimSpace(cleaned)), &val); err == nil {
return val, true
}
if v := runtime.ExtractJSONObject(cleaned); v != nil {
return v, true
}
return nil, false
}
// stripGenJSONWrappers mirrors gen_json's answer cleanup:
//
// ans = re.sub(r"(^.*</think>|```json\n|```\n*$)", "", ans, flags=re.DOTALL)
//
// The think term (greedy up to the LAST </think>) is common.StripThinkTrailing;
// a "```json\n" fence may occur anywhere and is removed wholesale; a trailing
// "```" (plus newlines) is cut from the end.
func stripGenJSONWrappers(s string) string {
s = common.StripThinkTrailing(s)
s = strings.ReplaceAll(s, "```json\n", "")
return genJSONTailFenceRE.ReplaceAllString(s, "")
}
func toAnySlice(ss []string) []any {
out := make([]any, 0, len(ss))
for _, s := range ss {
out = append(out, s)
}
return out
}
+135
View File
@@ -0,0 +1,135 @@
//
// 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 agentic_rag
// The graph's CROSS-STAGE numbers live here: every budget, timeout and pool / session cap
// that more than one stage reasons about — "how long may this step take" and "how much may
// the pool hold" are answered in one place (see the call sites: nodeClock in every node,
// SessionWallS and SetBudgetExtensionS in the research pass, MaxSnippetPool in the
// prefetch).
//
// What is deliberately NOT here: the numbers ONE stage spends — the fan-out shape guards
// beside fanoutLooksLikeQuery, the batching thresholds beside BatchFillSlots, the draft caps
// beside ComposeFallbackDraft, the sampling temperatures beside the calls they configure.
// Moving those here would put a constant a long way from the only code that can explain it,
// and a stage's tuning is exactly the thing that changes with its own measurements. The rule
// is cohesion, not one file: a number shared across stages belongs here, a number one stage
// owns belongs with that stage.
//
// What is ALSO not here: the DECISIONS those numbers feed. A node asks for a clock with
// nodeClock and gets one; whether the round continues afterwards is a routing decision, and
// those live with the routes.
// nodeClock is THE shape every node's clock takes: the node's own budget, floored at
// floorS, and capped by the room the QUESTION still has (roomS is what is left after the
// reserve the callers below it need).
//
// The FLOOR WINS over a room that is merely small: with room 2s and a floor of 10s the
// call gets 10s, because a node that cannot finish inside its floor is not worth starting
// at all. A caller that needs the room protected therefore passes a smaller floor, or a
// roomS that already has the reserve subtracted.
//
// A room already GONE (roomS <= 0) yields zero, which every caller treats as "do not
// start" rather than "start and get cancelled".
func nodeClock(budgetS, floorS, roomS float64) float64 {
if roomS <= 0 {
return 0
}
return min(budgetS, max(floorS, roomS))
}
const (
TotalBudgetS = 180.0 // whole-graph wall-clock ceiling per question
MinRoundHeadroomS = 50.0 // need at least this much left to start a new round
PassTimeoutS = 120.0 // slot research pass wall-clock
// SetBudgetExtensionS is added ONCE to a question's research budget when its
// table IS an enumeration — a count/set/list slot, a NAME-carrying slot and the
// act words (see runtime.Coverage.Ok and RunSlotResearchPass).
//
// The question budget is sized for one pass (TotalBudgetS 180 ⊃ PassTimeoutS
// 120) and an enumeration needs a second one: its first pass spends the wall
// clock on batches of names, so a member that a cut session never patched has
// nowhere to be picked up: a spent pass leaves too little room for another round
// (MinRoundHeadroomS), and a member that was reached but never recorded is lost with
// it. The extension is what lets the round AFTER that one start at all.
SetBudgetExtensionS = 120.0
// setSessionSlackS is added to the session clock an ENUMERATION pass hands its
// sessions, so a session's own finalize/salvage step still fits inside the
// context the pass derived it from.
setSessionSlackS = 20.0
// downstreamReserveS is what the steps AFTER research need: the SCA review
// (SCATimeoutS), the draft and the composed answer. The budget extension is only
// bought when the caller's own context still holds it — otherwise the second
// research round would spend the time the answer needs, turning a missing member
// into a timed-out question, which is strictly worse.
downstreamReserveS = 90.0
// minBudgetExtensionS is the smallest extension worth buying: less than this and
// the following round could not start anyway (MinRoundHeadroomS), so the budget
// would be widened without anything being able to use it.
minBudgetExtensionS = 40.0
PrefetchTimeoutS = 90.0 // programmatic fan-out fetch
DraftTimeoutS = 60.0 // fallback draft synthesis
SCATimeoutS = 60.0 // sufficient-context review call
// SCARetryHeadroomS is the clock that must be left before a FAILED review is retried once:
// the second attempt plus the answer that follows it. Below it the retry is skipped and the
// unavailable review is recorded as such (see graph_sca), because a retry that eats the
// answer's own clock trades a missing verdict for a missing answer.
SCARetryHeadroomS = 120.0
RewriteTimeoutS = 45.0 // gap → query rewrite call
// CoverageResolveTimeoutS bounds the enumeration's last node (see RunCoverageResolve): it runs
// before the answer is composed, so it may not spend the clock the answer needs. It bounds the
// WHOLE resolve, which is batched and parallel, rather than one call carrying every
// window: one call that carries them all hits this clock and answers nothing at all.
CoverageResolveTimeoutS = 30.0
// SCAViewCap is the view the SCA is shown: large enough that the passage carrying the
// answer is not the one that gets cut.
SCAViewCap = 60
// CoverageEnrollHeadroomS is the clock the resolve node needs before it may run the
// direction's enumeration itself (see enrollEnumeration): the enumeration plus the answer
// that still has to be composed. Below it the node judges what it already has.
CoverageEnrollHeadroomS = 45.0
// MaxSnippetPool is the storage ceiling of the snippet pool across ALL
// rounds. Storage and REVIEW are decoupled: the SCA only reads a ranked
// view, so the pool may accumulate freely while prompts stay bounded.
MaxSnippetPool = 60
// DrillReserve: slots kept free after the FIRST prefetch so the research
// executor can top up evidence.
DrillReserve = 12
// FanoutTopN is the per-query result count for the programmatic fetch.
FanoutTopN = 8
// FanoutTopNRewrite is the reduced count used after a rewrite round.
FanoutTopNRewrite = 6
// MaxFanouts caps planner fan-outs.
MaxFanouts = 5
// MaxSCAGaps caps gaps handed to the rewriter.
MaxSCAGaps = 8
// PoolHeadLines caps the evidence-pool summary shown to the rewriter.
PoolHeadLines = 12
// Slot-table research constants: how many sessions run at once, how many run per
// round, and the caps on a fallback clue and a draft candidate.
slotSessionConcurrency = 2
slotSessionsPerRound = 3
slotFallbackClueChars = 160
// draftCandidateChars caps a claim's draft text handed to the SCA.
draftCandidateChars = 400
// draftClueTailChars / draftUnresolvedClueChars are the per-clue caps in the
// rendered draft (240 for a resolved slot's discovered-clue tail, 80 for an
// unresolved slot's question clues).
draftClueTailChars = 240
draftUnresolvedClueChars = 80
)
@@ -1,4 +1,4 @@
package harness
package runtime
import (
"context"
@@ -38,10 +38,10 @@ func (e *ladderExec) Execute(_ context.Context, name string, _ map[string]any) (
}, nil
}
// TestConsumeExchangeUsesIdPrefix pins Python _run_nav_chain(id_prefix=...): the
// prefix and the in-session ladder fallback share rule ids, so their tool_call
// ids must be tagged differently ("nav_<rule>" vs "ladder_<rule>") or the
// provider rejects the history as a duplicate id.
// TestConsumeExchangeUsesIdPrefix pins the id-prefix tagging: the prefix and the
// in-session ladder fallback share rule ids, so their tool_call ids must be tagged
// differently ("nav_<rule>" vs "ladder_<rule>") or the provider rejects the history as a
// duplicate id.
func TestConsumeExchangeUsesIdPrefix(t *testing.T) {
cases := []struct {
idPrefix string
@@ -65,16 +65,16 @@ func TestConsumeExchangeUsesIdPrefix(t *testing.T) {
}
}
// TestToolNodeContinuesLadderInCode pins Python action_session.py:_tool_node:
// when the rung the model just ran came back weak, the ladder keeps advancing IN
// CODE through the remaining (cheaper, wider) rungs, instead of leaving the model
// to rediscover the fallback one turn at a time. The continuation must also
// update where the ladder rests, so a later call in the SAME batch resumes from
// there rather than from the original resting point.
// TestToolNodeContinuesLadderInCode pins the in-code ladder continuation: when the rung
// the model just ran came back weak, the ladder keeps advancing IN CODE through the
// remaining (cheaper, wider) rungs, instead of leaving the model to rediscover the
// fallback one turn at a time. The continuation must also update where the ladder rests,
// so a later call in the SAME batch resumes from there rather than from the original
// resting point.
//
// Unreachable with the shipped NavRules (all ModeAuto), so this test installs an
// LLM rung to exercise it — the ported code is what makes a future LLM rung
// work without another change.
// Unreachable with the shipped NavRules (all ModeAuto), so this test installs an LLM rung
// to exercise it — the continuation code is what makes a future LLM rung work without
// another change.
func TestToolNodeContinuesLadderInCode(t *testing.T) {
// Install an LLM rung that falls through to "global" (retrieve) on an empty
// result, and remove it afterwards.
@@ -169,9 +169,9 @@ func TestToolNodeLeavesLadderAloneWithNoPendingRule(t *testing.T) {
}
}
// TestSessionGraphCompilesOnce pins the Python shape: _SESSION_GRAPH is built
// at import (:1936) and every session invokes THAT graph, so repeated calls must
// hand back the same runnable instead of compiling a fresh one per session.
// TestSessionGraphCompilesOnce pins the compile-once contract: the session graph is built
// once and every session invokes THAT graph, so repeated calls must hand back the same
// runnable instead of compiling a fresh one per session.
func TestSessionGraphCompilesOnce(t *testing.T) {
first, err := sessionGraph()
if err != nil {
@@ -290,19 +290,18 @@ func TestSessionGraphIsSharedAcrossConcurrentSessions(t *testing.T) {
}
}
// failingModel fails every Complete call, standing in for Python's
// TimeoutError / provider-exception branch of _run_action_node.
// failingModel fails every Complete call, standing in for the timeout /
// provider-exception branch of runActionNode.
type failingModel struct{ err error }
func (m *failingModel) Complete(_ context.Context, _ []schema.Message, _ []ToolSpec) (*ModelReply, error) {
return nil, m.err
}
// TestRunActionNodeConvergesOnLLMError pins Python _run_action_node:1215-1220:
// a timed-out or failed turn converges the session EMPTY ({"_done": True,
// "new_states": [], "found_answer": None}) so the graph ends through _route/END
// and run_action_session still returns the messages/evidence gathered so far.
// Returning the error instead aborted the whole eino run, which RunActionSession
// TestRunActionNodeConvergesOnLLMError pins the convergence contract: a timed-out or
// failed turn converges the session EMPTY (done, no new states, no found answer) so the
// graph ends through the router and the run still returns the messages/evidence gathered so
// far. Returning the error instead aborted the whole eino run, which RunActionSession
// reports as a failed session and answers with an empty Result.
func TestRunActionNodeConvergesOnLLMError(t *testing.T) {
s := &SessionState{
@@ -313,7 +312,7 @@ func TestRunActionNodeConvergesOnLLMError(t *testing.T) {
ParentState: State{State: []Variable{{ID: 0, Type: "aspect"}}},
}
if err := s.runActionNode(context.Background()); err != nil {
t.Fatalf("runActionNode = %v; Python converges the session instead of aborting the graph", err)
t.Fatalf("runActionNode = %v; the session must converge instead of aborting the graph", err)
}
if !s.Done {
t.Error("Done = false; the session must converge so _route returns END")
@@ -330,10 +329,9 @@ func TestRunActionNodeConvergesOnLLMError(t *testing.T) {
}
}
// TestFinalizeNodeHarvestsNarrativeWhenSalvageFails pins Python
// _finalize_node:1494-1518: a failed salvage call is logged and the node still
// runs the deterministic loose-clue harvest, so the last narration survives as a
// breadcrumb on the first unresolved slot.
// TestFinalizeNodeHarvestsNarrativeWhenSalvageFails pins the finalize behaviour: a failed
// salvage call is logged and the node still runs the deterministic loose-clue harvest, so
// the last narration survives as a breadcrumb on the first unresolved slot.
func TestFinalizeNodeHarvestsNarrativeWhenSalvageFails(t *testing.T) {
const narration = "The entity was founded in 1865 by a consortium of local merchants."
s := &SessionState{
@@ -361,9 +359,9 @@ func TestFinalizeNodeHarvestsNarrativeWhenSalvageFails(t *testing.T) {
}
}
// TestInitRetryTimeout pins Python _init_retry_timeout (:2080-2096): the slot
// table decomposition retry gets a longer window than the first attempt, floored
// at the first attempt's budget and clamped by the round deadline.
// TestInitRetryTimeout pins the retry window: the slot table decomposition retry gets a
// longer window than the first attempt, floored at the first attempt's budget and clamped
// by the round deadline.
func TestInitRetryTimeout(t *testing.T) {
cases := []struct {
name string
@@ -383,10 +381,9 @@ func TestInitRetryTimeout(t *testing.T) {
}
}
// TestInitializeStateKeepsEmptyFirstQueries pins Python initialize_state:2130
// (`[str(q).strip() for q in (...)] [:3]`): the first three entries are taken
// and stripped, and an entry that strips to "" is KEPT — filtering it made Go
// pick a later entry instead.
// TestInitializeStateKeepsEmptyFirstQueries pins the first_queries handling: the first
// three entries are taken and stripped, and an entry that strips to "" is KEPT — filtering
// it made Go pick a later entry instead.
func TestInitializeStateKeepsEmptyFirstQueries(t *testing.T) {
mdl := &fixedReplyModel{reply: &ModelReply{
Content: `{"slots":[{"id":0,"type":"aspect","clues":["a"]}],"first_queries":[" ", "b", "c", "d"]}`,
@@ -403,11 +400,11 @@ func TestInitializeStateKeepsEmptyFirstQueries(t *testing.T) {
}
}
// TestPyIntMatchesPython pins Python's int() semantics: bool is an int subclass,
// floats truncate toward zero, strings may carry whitespace/underscores, and
// anything Python cannot convert reports failure (which discards the whole
// decomposition in initialize_state).
func TestPyIntMatchesPython(t *testing.T) {
// TestAsIntConvertsSlotValues pins the conversion a slot table needs: a bool is an integer,
// floats truncate toward zero, a quoted integer may carry whitespace/underscores, and
// anything that cannot be converted reports failure — which discards the whole
// decomposition in initialize_state rather than guessing an id.
func TestAsIntConvertsSlotValues(t *testing.T) {
ok := []struct {
in any
want int
@@ -424,23 +421,24 @@ func TestPyIntMatchesPython(t *testing.T) {
{"1_000", 1000},
}
for _, c := range ok {
got, converted := pyInt(c.in)
got, converted := asInt(c.in)
if !converted || got != c.want {
t.Errorf("pyInt(%#v) = %d, %v; want %d, true", c.in, got, converted, c.want)
t.Errorf("asInt(%#v) = %d, %v; want %d, true", c.in, got, converted, c.want)
}
}
// Python raises for these: int("5.5") is a ValueError, int(None) a TypeError.
// These are hard failures, not coercions: a decimal string, a bare word, nil, and
// non-scalar containers.
for _, bad := range []any{"5.5", "abc", "", nil, []any{1}, map[string]any{}} {
if _, converted := pyInt(bad); converted {
t.Errorf("pyInt(%#v) reported success; Python raises", bad)
if _, converted := asInt(bad); converted {
t.Errorf("asInt(%#v) reported success; it must be refused", bad)
}
}
}
// TestPyStringListMatchesPython pins `[str(c) for c in (value or [])]`: falsy
// values yield nothing, a string yields its characters, a map its keys, and a
// non-iterable value is an error (TypeError in Python).
func TestPyStringListMatchesPython(t *testing.T) {
// TestAsStringListReadsListShapes pins the coercion of a list-ish slot value: a falsy value
// yields nothing, a list yields its items as text, a string yields its characters, a map its
// keys (sorted), and a value that is not list-like is an error rather than a guess.
func TestAsStringListReadsListShapes(t *testing.T) {
cases := []struct {
name string
in any
@@ -457,7 +455,7 @@ func TestPyStringListMatchesPython(t *testing.T) {
{"true is not iterable", true, nil, false},
}
for _, c := range cases {
got, converted := pyStringList(c.in)
got, converted := asStringList(c.in)
if converted != c.ok {
t.Errorf("%s: ok = %v, want %v", c.name, converted, c.ok)
continue
@@ -478,10 +476,9 @@ func TestPyStringListMatchesPython(t *testing.T) {
}
}
// TestInitializeStateDiscardsOnUnconvertibleValues pins Python's exception path:
// int(s["id"]) and the clues comprehension raise on bad values, _build_slot_table
// catches it, and the whole decomposition is dropped in favour of the planner
// fan-outs. Go signals the same by returning an empty root.
// TestInitializeStateDiscardsOnUnconvertibleValues pins the failure path: a bad id or a
// non-iterable clues list fails the parse, and the whole decomposition is dropped in favour
// of the planner fan-outs. Go signals that by returning an empty root.
func TestInitializeStateDiscardsOnUnconvertibleValues(t *testing.T) {
cases := []struct {
name string
@@ -496,7 +493,7 @@ func TestInitializeStateDiscardsOnUnconvertibleValues(t *testing.T) {
mdl := &fixedReplyModel{reply: &ModelReply{Content: c.body}}
res := InitializeState(context.Background(), SessionDeps{Model: mdl}, "q", nil, 60)
if len(res.Root.State) != 0 {
t.Errorf("%s: root has %d slot(s); Python discards the decomposition", c.name, len(res.Root.State))
t.Errorf("%s: root has %d slot(s); the decomposition must be discarded", c.name, len(res.Root.State))
}
}
}
@@ -513,18 +510,17 @@ func TestInitializeStateStringifiesType(t *testing.T) {
t.Fatalf("slots = %d, want 2", len(res.Root.State))
}
if got := res.Root.State[0].Type; got != "7" {
t.Errorf("type = %q, want %q (Python str(7))", got, "7")
t.Errorf("type = %q, want %q (a non-string type is stringified)", got, "7")
}
if got := res.Root.State[1].Type; got != "entity" {
t.Errorf("type = %q, want %q (falsy -> entity)", got, "entity")
}
}
// TestInitializeStateSpentBudgetTimesOutImmediately pins Python
// initialize_state:2106 (`min(_INIT_TIMEOUT_S, deadline_left or _INIT_TIMEOUT_S)`):
// 0 means "unset", but a NEGATIVE deadline is used as-is, so asyncio.timeout fires
// on the next tick and NEITHER attempt reaches the provider — the caller then
// falls back to the planner fan-outs.
// TestInitializeStateSpentBudgetTimesOutImmediately pins the deadline semantics: 0 means
// "unset", but a NEGATIVE deadline is used as-is, so the timeout fires on the next tick and
// NEITHER attempt reaches the provider — the caller then falls back to the planner
// fan-outs.
func TestInitializeStateSpentBudgetTimesOutImmediately(t *testing.T) {
mdl := &fixedReplyModel{reply: &ModelReply{
Content: `{"slots":[{"id":0,"type":"aspect","clues":["a"]}]}`,
@@ -557,9 +553,9 @@ func TestInitializeStateZeroDeadlineUsesFullBudget(t *testing.T) {
}
}
// TestConsumeExchangeCapsPayload pins Python _emit_nav_pair:1815-1828 — the
// in-session ladder passes its remaining context budget down, so the emitted
// tool payload is truncated; 0 keeps the prefix path uncapped.
// TestConsumeExchangeCapsPayload pins the payload cap: the in-session ladder passes its
// remaining context budget down, so the emitted tool payload is truncated; 0 keeps the
// prefix path uncapped.
func TestConsumeExchangeCapsPayload(t *testing.T) {
big := strings.Repeat("x", 5000)
payload := []any{map[string]any{"kind": "navigate_tree", "content": big}}
@@ -577,7 +573,7 @@ func TestConsumeExchangeCapsPayload(t *testing.T) {
}
}
// TestExtractJSONLenient mirrors Python action_session.extract_json's fallback
// TestExtractJSONLenient: fallback
// through json.loads(strict=False) and json_repair.loads: models routinely emit
// trailing commas, single-quoted strings, bare NaN/Infinity and stray control
// characters. ExtractJSON should salvage these instead of dropping the whole
@@ -618,10 +614,9 @@ func TestExtractJSONLenient(t *testing.T) {
}
func TestExtractJSONLenientUnrecoverable(t *testing.T) {
// The brace-matched loop tries each "{" candidate; the deep json_repair
// fallback salvages the FIRST candidate even when it is malformed (mirroring
// Python, where json_repair.loads({"a":}) yields {"a": null}). It must not
// panic, hang, or return nil for a salvageable object.
// The brace-matched loop tries each "{" candidate; the deep repair fallback salvages
// the FIRST candidate even when it is malformed (a deformed object yields null for the
// incomplete key). It must not panic, hang, or return nil for a salvageable object.
v := ExtractJSON(`{"a":} {"b": 2}`)
if v == nil {
t.Fatalf("first candidate should be salvaged, got nil")
@@ -632,15 +627,15 @@ func TestExtractJSONLenientUnrecoverable(t *testing.T) {
}
}
// TestExtractJSONWholeTextRepair mirrors Python json_repair.loads(text) on the
// WHOLE text — deformities that no single brace-matched candidate can fix.
// TestExtractJSONWholeTextRepair repairs the WHOLE text — deformities that no single
// brace-matched candidate can fix.
func TestExtractJSONWholeTextRepair(t *testing.T) {
cases := []struct {
name string
in string
want map[string]any
}{
// Unquoted object keys (Python json_repair quotes them).
// Unquoted object keys.
{"unquoted key", `{a: 1}`, map[string]any{"a": float64(1)}},
{"unquoted keys multiple", `{name: foo, age: 3}`, map[string]any{"name": "foo", "age": float64(3)}},
// Dropped separator between adjacent values.
@@ -700,11 +695,10 @@ func declaredTools() []ToolSpec {
}}
}
// TestNoPromptBasedToolInstruction pins the alignment with Python: Python has NO
// tool-call instruction of any kind — it binds tools natively
// (action_session.py:_acompletion) and reads msg.tool_calls (:1101-1114). A prompt-based
// protocol is a Go-only addition and must not be sent, least of all one whose
// "at most one tool per reply" clause contradicts the native tool list.
// TestNoPromptBasedToolInstruction pins the contract: there is NO prompt-based tool-call
// instruction of any kind — tools are bound natively and the reply's tool_calls are read
// directly. Such a protocol must not be sent, least of all one whose "at most one tool per
// reply" clause contradicts the native tool list.
func TestNoPromptBasedToolInstruction(t *testing.T) {
inv := &recordingInvoker{}
m := &InvokerSessionModel{Invoker: inv}
@@ -729,11 +723,10 @@ func TestNoPromptBasedToolInstruction(t *testing.T) {
}
}
// TestAssistantMessageCarriesToolCalls pins Python action_session.py:_run_action_node:
// the assistant message carries the model's tool_calls verbatim so the tool
// responses that follow can be paired by id. Passing nil leaves a dangling
// tool_call, and the next request is rejected with "tool call result does not
// follow tool call".
// TestAssistantMessageCarriesToolCalls pins the pairing contract: the assistant message
// carries the model's tool_calls verbatim so the tool responses that follow can be paired by
// id. Passing nil leaves a dangling tool_call, and the next request is rejected with "tool
// call result does not follow tool call".
func TestAssistantMessageCarriesToolCalls(t *testing.T) {
st := &SessionState{
Messages: []schema.Message{*schema.SystemMessage("s")},
@@ -759,8 +752,8 @@ func TestAssistantMessageCarriesToolCalls(t *testing.T) {
if len(assistant.ToolCalls) != 2 {
t.Fatalf("assistant tool_calls = %d, want 2 (pass-through, and both kept)", len(assistant.ToolCalls))
}
// Python synthesizes call_{i} when the provider omits the id
// (:1134/:1136); the tool responses reference the same ids.
// A call_{i} id is synthesized when the provider omits one, so the tool responses have
// ids to reference.
if assistant.ToolCalls[0].ID != "call_0" || assistant.ToolCalls[1].ID != "call_1" {
t.Errorf("ids = %q, %q; want call_0, call_1", assistant.ToolCalls[0].ID, assistant.ToolCalls[1].ID)
}
@@ -775,11 +768,10 @@ func TestAssistantMessageCarriesToolCalls(t *testing.T) {
}
}
// TestDisabledToolIsNotUnknown pins Python action_session.py:_parse_tool_calls —
// `if name not in _TOOL_MAP`. The check is against the STATIC full set, not the
// active surface, so a tool this session disabled (or hid for lack of a web
// provider) is still a known tool: the model must get the "unavailable, use
// this instead" note from ExecuteTool, not an "unknown tool" correction.
// TestDisabledToolIsNotUnknown pins the known-tool check: it is against the STATIC full
// set, not the active surface, so a tool this session disabled (or hid for lack of a web
// provider) is still a known tool: the model must get the "unavailable, use this instead"
// note from ExecuteTool, not an "unknown tool" correction.
func TestDisabledToolIsNotUnknown(t *testing.T) {
// Sanity: the name must be a real tool for the test to mean anything.
if _, ok := ToolMap["graph_explore"]; !ok {
@@ -798,14 +790,13 @@ func TestDisabledToolIsNotUnknown(t *testing.T) {
t.Fatalf("calls = %d, want 1", len(calls))
}
if calls[0].Unknown {
t.Error("a disabled tool must NOT be Unknown: Python judges against _TOOL_MAP, " +
"so the call gets the 'unavailable, use this instead' note")
t.Error("a disabled tool must NOT be Unknown: the check judges against the static " +
"tool map, so the call gets the 'unavailable, use this instead' note")
}
}
// TestUnknownToolStillFlagged pins the other side of :1124 — a name that is not
// a real tool at all IS flagged Unknown, so it is answered with a correction
// instead of being executed.
// TestUnknownToolStillFlagged pins the other side — a name that is not a real tool at all
// IS flagged Unknown, so it is answered with a correction instead of being executed.
func TestUnknownToolStillFlagged(t *testing.T) {
calls := nativeToHarnessCalls([]chat.ToolCall{{Name: "definitely_not_a_tool"}})
if len(calls) != 1 || !calls[0].Unknown {
@@ -813,9 +804,8 @@ func TestUnknownToolStillFlagged(t *testing.T) {
}
}
// TestNativeToolCallsPreserveAll pins the execution contract: every native tool
// call is mapped, not just the first — Python executes all of them
// (action_session.py:_tool_node `for c in pending`).
// TestNativeToolCallsPreserveAll pins the execution contract: every native tool call is
// mapped, not just the first — all of them are executed.
func TestNativeToolCallsPreserveAll(t *testing.T) {
calls := nativeToHarnessCalls([]chat.ToolCall{
{ID: "1", Name: "retrieve"},
@@ -977,9 +967,9 @@ func TestCompleteReportsNoCallWhenModelSendsNoNativeCall(t *testing.T) {
Function: ToolFunction{Name: "search", Description: "d", Parameters: map[string]any{}},
}}
// No native tool_calls => the reply stays tool-less. This confirms the
// harness still declares tools on the request (verified above) but does not
// hallucinate a call, matching Python, which has no other parsing path.
// No native tool_calls => the reply stays tool-less. This confirms the runtime still
// declares tools on the request (verified above) but does not hallucinate a call: there
// is no other parsing path.
reply, err := m.Complete(context.Background(), []schema.Message{*schema.UserMessage("q")}, tools)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -990,7 +980,7 @@ func TestCompleteReportsNoCallWhenModelSendsNoNativeCall(t *testing.T) {
}
// streamingCapturingInvoker implements chat.StreamingInvoker and records the
// streaming request, returning a native tool call so we can verify the harness
// streaming request, returning a native tool call so we can verify the runtime
// reads streamed calls out of the native tool_calls field.
type streamingCapturingInvoker struct {
capturingInvoker
@@ -1046,8 +1036,8 @@ func TestStreamCompleteIgnoresFencedBlockInContent(t *testing.T) {
Type: "function",
Function: ToolFunction{Name: "search", Description: "d", Parameters: map[string]any{}},
}}
// A fenced block in the content is NOT a tool call: Python only reads
// msg.tool_calls, and this port has no prompt-based parsing path.
// A fenced block in the content is NOT a tool call: only the reply's tool_calls are
// read, and there is no prompt-based parsing path.
inv := &streamingCapturingInvoker{
capturingInvoker: capturingInvoker{
hasTool: false,
@@ -1071,8 +1061,8 @@ func TestRenderPromptUsesLoader(t *testing.T) {
if got := prompts.Render(loader, "tpl", "", map[string]string{"name": "world"}); got != "hello world!" {
t.Errorf("prompts.Render = %q", got)
}
// Unknown template with empty fallback -> empty (Python has no degraded
// constant; a missing template fails fast rather than degrading).
// Unknown template with empty fallback -> empty (there is no degraded constant; a
// missing template fails fast rather than degrading).
if got := prompts.Render(loader, "missing", "", nil); got != "" {
t.Errorf("unknown template with empty fallback = %q", got)
}
@@ -1087,16 +1077,11 @@ func TestRenderPromptUsesLoader(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Tool spec / schema contract (moved here from action_session_schema_test.go)
// ---------------------------------------------------------------------------
// playbookAnchors are the 5-section contract every tool description must carry
// (mirrors PLAYBOOK_ANCHORS in the Python action-session schema test).
// playbookAnchors are the 5-section contract every tool description must carry.
var playbookAnchors = []string{"WHEN TO CALL", "DO NOT CALL", "ARGUMENTS", "OUTPUT", "IF IT FAILS"}
// executorSupportedParams lists the params the executor actually consumes per
@@ -1189,12 +1174,10 @@ func TestActionRunPromptHasPlaybook(t *testing.T) {
}
}
// TestParseTerminalEmptyAnswerNotFound pins Python _parse_terminal:1282:
// `answer = str(data.get("answer", "")).strip() or None` — an empty or
// whitespace <answer> payload is NOT a found answer. Python's _run_action_node
// then only ends the session when the <answer> carried a new_state patch; with
// neither an answer nor a patch it NUDGES and the session continues, so a bare
// <answer> block can never end a session with collected_answer="".
// TestParseTerminalEmptyAnswerNotFound pins the empty-answer rule: an empty or whitespace
// <answer> payload is NOT a found answer. The session then only ends when the <answer>
// carried a new_state patch; with neither an answer nor a patch it NUDGES and continues, so
// a bare <answer> block can never end a session with an empty collected answer..
func TestParseTerminalEmptyAnswerNotFound(t *testing.T) {
parent := NewState([]Variable{{ID: 0, Type: "answer"}}, 0, nil)
@@ -1223,8 +1206,8 @@ func TestParseTerminalEmptyAnswerNotFound(t *testing.T) {
t.Errorf("real answer: FoundAnswer = %v, want 74 (stripped)", found)
}
// Empty answer WITH a new_state patch: the patch is returned (Python
// :1283-1288 final_state), terminal type stays "answer", found stays nil.
// Empty answer WITH a new_state patch: the patch is returned as the final state,
// terminal type stays "answer", found stays nil.
states, found, _, _ = ParseTerminal(
"<answer>{\"answer\": \"\", \"new_state\": [{\"id\": 0, \"candidate\": \"74\", \"candidate_strength\": 0.9}]}</answer>",
parent)
@@ -1345,18 +1328,30 @@ func TestTurnFloorIsPaidOnlyByASetSession(t *testing.T) {
}
}
// TestSessionWallFollowsTheShape pins the clock an enumeration session gets.
// TestSessionWallFollowsTheEnumeration pins the clock an enumeration session gets.
//
// The wall clock is spent differently by the two shapes: an enumeration session is
// The wall clock is spent differently by the two strategies: an enumeration session is
// midway through a batch when its clock runs out, and a batch that is cut loses the
// members it had already reached — measured (2026-09-16, 三国/关羽) a session was
// cancelled one millisecond before the patch that recorded 管亥, a member it had
// already found and whose passages were already in the shared pool. A value session
// has no work in flight at its deadline, so it keeps the tighter clock.
func TestSessionWallFollowsTheShape(t *testing.T) {
set := State{State: []Variable{{ID: 0, Type: "count", Candidate: strPtr("13")}}}
//
// The clock follows the ENUMERATION (a set of named members whose deed the planner wrote
// the words for), not every table that contains a count: a count of events has no batch in
// flight either, and buying it the enumeration clock is a cost with nothing to spend it on.
func TestSessionWallFollowsTheEnumeration(t *testing.T) {
set := State{State: []Variable{
{ID: 0, Type: "count", Candidate: strPtr("13"), Terms: []string{"斩", "杀"}, Subject: "关羽"},
{ID: 1, Type: "person"},
}}
if got := SessionWallS(set); got != setActionTimeoutS {
t.Errorf("set-shaped direction wall = %.0f, want %.0f", got, setActionTimeoutS)
t.Errorf("enumeration direction wall = %.0f, want %.0f", got, setActionTimeoutS)
}
// A count of EVENTS: set-shaped, no names to enumerate.
countOnly := State{State: []Variable{{ID: 0, Type: "count", Candidate: strPtr("13"), Terms: []string{"won"}, Subject: "Brazil"}}}
if got := SessionWallS(countOnly); got != actionTimeoutS {
t.Errorf("count-of-events direction wall = %.0f, want %.0f", got, actionTimeoutS)
}
value := State{State: []Variable{{ID: 0, Type: "date", Candidate: strPtr("1858")}}}
if got := SessionWallS(value); got != actionTimeoutS {
@@ -1370,11 +1365,11 @@ func TestSessionWallFollowsTheShape(t *testing.T) {
// TestDigestShowsAPassageWholeEnoughToNameSomeone pins the seed digest's per-chunk
// cap.
//
// The digest is how a session SEES the pool without re-querying it, and its cap used
// to mirror Python's hard `[:300]` cut. A 300-code-point window is shorter than the
// sentence a deed is reported in: the passage names the actor early and the person at
// the end, so the cut removed exactly the clause that makes a member a member — and a
// model told to answer only from what it was shown then excluded them.
// The digest is how a session SEES the pool without re-querying it, and its cap used to
// be a hard 300-code-point cut. A 300-code-point window is shorter than the sentence a deed
// is reported in: the passage names the actor early and the person at the end, so the cut
// removed exactly the clause that makes a member a member — and a model told to answer only
// from what it was shown then excluded them.
//
// Measured (2026-09-14, fixrecall): with the cap at 1200 an enumeration reached
// seventeen members. Measured here (2026-09-16, 三国/关羽): 管亥 / 杨龄 / 程远志
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -32,8 +32,6 @@ import (
// Deterministic arithmetic over retrieved evidence.
//
// Mirrors Python harness/arithmetic.py.
//
// Some questions ask for a number no single source states — the combined population of
// three counties, how many listed films won an award, the years between two dates. Every
// input is in the evidence by then and only the arithmetic is missing, which an LLM does
@@ -41,10 +39,10 @@ import (
// writes ONE expression and we evaluate it.
//
// The expression is model-written and NOT trusted: Go has no eval and the model writes
// PYTHON syntax (`**`, `x if y else z`, list literals) that go/parser cannot read, so
// this file ships a small parser for a deliberately tiny Python subset and rejects
// everything outside the whitelist BEFORE evaluation. No reflection, no name lookup, no
// property access: an unlisted construct is a parse error, not a sandbox escape.
// syntax (`**`, `x if y else z`, list literals) that go/parser cannot read, so this file
// ships a small parser for a deliberately tiny expression language and rejects everything
// outside the whitelist BEFORE evaluation. No reflection, no name lookup, no property
// access: an unlisted construct is a parse error, not a sandbox escape.
//
// Public interface
//
@@ -55,11 +53,11 @@ import (
const (
// computeMaxChars caps the whole expression; every figure is inline, none
// is long (Python: _COMPUTE_MAX_CHARS).
// is long.
computeMaxChars = 400
// maxStringLiteral caps a string literal argument (Python: 256).
// maxStringLiteral caps a string literal argument.
maxStringLiteral = 256
// maxPowExponent caps `**` (Python: abs(exponent) > 64 is refused).
// maxPowExponent caps `**` at abs(exponent) > 64.
maxPowExponent = 64
)
@@ -104,7 +102,7 @@ func Compute(expression string) (string, string) {
return "", fmt.Sprintf("result is %T, not a number", value)
}
// formatNumber mirrors Python _format_number: render a computed number without
// formatNumber: render a computed number without
// float noise ("3.0" -> "3", 0.1+0.2 -> "0.3").
func formatNumber(value float64) string {
if value == math.Trunc(value) && math.Abs(value) < 1e15 {
@@ -115,9 +113,7 @@ func formatNumber(value float64) string {
return strings.TrimRight(s, ".")
}
// ---------------------------------------------------------------------------
// AST
// ---------------------------------------------------------------------------
type node interface{ pos() int }
@@ -153,7 +149,7 @@ type binaryNode struct {
func (n *binaryNode) pos() int { return n.p }
// chainNode mirrors Python ast.Compare, which is an N-ary node: `a < b < c` is
// chainNode: which is an N-ary node: `a < b < c` is
// `(a < b) and (b < c)`, with each operand evaluated exactly ONCE and the chain
// short-circuiting on the first false comparison.
//
@@ -192,9 +188,7 @@ type seqNode struct {
func (n *seqNode) pos() int { return n.p }
// ---------------------------------------------------------------------------
// Lexer + Pratt parser for a tiny Python subset
// ---------------------------------------------------------------------------
// Lexer + Pratt parser for a tiny expression language
// Supported: numeric and string literals, True/False/None, list/tuple/set
// literals, unary + - not, binary + - * / // % ** and comparison chains, `and`
@@ -293,9 +287,9 @@ func (p *parser) parseNot() (node, error) {
var cmpOps = []string{"==", "!=", "<=", ">=", "<", ">"}
// parseComparison collects the WHOLE comparison chain into one chainNode,
// mirroring Python's ast.Compare. Two or more operators are what make Python's
// chaining observable; a single comparison is just a one-op chain.
// parseComparison collects the WHOLE comparison chain into one chainNode. Two or more
// operators are what make chaining observable; a single comparison is just a one-op
// chain.
func (p *parser) parseComparison() (node, error) {
start := p.i
first, err := p.parseSum()
@@ -415,8 +409,8 @@ func (p *parser) parseUnary() (node, error) {
return p.parsePower()
}
// parsePower binds tighter than unary on the right and is right-associative,
// matching Python: `2 ** 3 ** 2` is 512 and `-2 ** 2` is -4.
// parsePower binds tighter than unary on the right and is right-associative:
// `2 ** 3 ** 2` is 512 and `-2 ** 2` is -4.
func (p *parser) parsePower() (node, error) {
base, err := p.parseAtom()
if err != nil {
@@ -453,11 +447,10 @@ func (p *parser) parseAtom() (node, error) {
if p.i >= len(p.src) {
return nil, p.errf("unclosed '('")
}
// A trailing (or interior) comma turns this into a TUPLE literal, which
// Python whitelists as ast.Tuple and uses as the default sequence in
// sum((1,2,3)) / len((1,2,3)) / min((1,2,3)) / letters((...)). A single
// element with no comma is a plain parenthesised expression (not a
// one-tuple) — Python's `(1+2)` == 3 vs `(1,)` == (1,).
// A trailing (or interior) comma turns this into a TUPLE literal — the sequence form
// used by sum((1,2,3)) / len((1,2,3)) / min((1,2,3)) / letters((...)). A single
// element with no comma is a plain parenthesised expression (not a one-tuple):
// `(1+2)` == 3 vs `(1,)` == (1,).
if p.src[p.i] == ',' {
items := []node{inner}
for {
@@ -652,18 +645,16 @@ func isIdentChar(c byte) bool {
return isIdentStart(c) || (c >= '0' && c <= '9')
}
// ---------------------------------------------------------------------------
// Whitelist check — runs BEFORE evaluation
// ---------------------------------------------------------------------------
// computeFunctions mirrors Python _COMPUTE_FUNCTIONS.
// computeFunctions
var computeFunctions = map[string]bool{
"abs": true, "round": true, "min": true, "max": true, "sum": true,
"len": true, "int": true, "float": true, "sorted": true,
"letters": true, "digit_sum": true, "date_diff": true,
}
// computeAlwaysNumeric mirrors Python _COMPUTE_ALWAYS_NUMERIC: functions whose
// computeAlwaysNumeric: functions whose
// result is a number whatever they are handed. `min`/`max`/`sum` are absent on
// purpose — min("b","a") is a string — and `sorted` returns a list, so neither
// may stand where a number is required.
@@ -672,7 +663,7 @@ var computeAlwaysNumeric = map[string]bool{
"letters": true, "digit_sum": true, "date_diff": true,
}
// checkExpression mirrors Python _check_expression: reject anything outside the
// checkExpression: reject anything outside the
// arithmetic whitelist. Returns "" when clean.
func checkExpression(n node) string {
switch t := n.(type) {
@@ -755,7 +746,7 @@ func checkExpression(n node) string {
return "unsupported expression"
}
// isNumeric mirrors Python _is_numeric: true when the node can ONLY evaluate to
// isNumeric: true when the node can ONLY evaluate to
// a number.
func isNumeric(n node) bool {
switch t := n.(type) {
@@ -772,7 +763,7 @@ func isNumeric(n node) bool {
}
return false
case *chainNode:
return true // Python _is_numeric: a comparison is a bool, and a bool is an int
return true // a comparison is a bool, and a bool is an int
case *ifExpNode:
return isNumeric(t.body) && isNumeric(t.orelse)
case *callNode:
@@ -792,7 +783,7 @@ func isNumeric(n node) bool {
return false
}
// isNumericSequence mirrors Python _is_numeric_sequence: a literal sequence whose
// isNumericSequence: a literal sequence whose
// every element is provably numeric.
func isNumericSequence(n node) bool {
seq, ok := n.(*seqNode)
@@ -807,16 +798,13 @@ func isNumericSequence(n node) bool {
return true
}
// ---------------------------------------------------------------------------
// Evaluation — no builtins, no reflection
// ---------------------------------------------------------------------------
// evalSafe evaluates n, converting a panic into an error.
//
// The helper functions (letters / digit_sum) reject bad argument types by
// panicking, mirroring Python's TypeError. Python catches those at the top of
// `compute` (`except Exception`); without a recover here the same input would
// crash the whole request instead of being a normal, logged refusal.
// The helper functions (letters / digit_sum) reject bad argument types by panicking. The
// panic is caught at the top; without a recover here the same input would crash the whole
// request instead of being a normal, logged refusal.
func evalSafe(n node) (value any, err error) {
defer func() {
if r := recover(); r != nil {
@@ -888,9 +876,8 @@ func evalNode(n node) (any, error) {
out = append(out, v)
}
if t.kind == "set" {
// Python set literals deduplicate members: len({1,1,2}) == 2.
// Without this, len/min/max/sorted on a set literal would count
// duplicates, diverging from Python.
// Set literals deduplicate members: len({1,1,2}) == 2. Without this,
// len/min/max/sorted on a set literal would count duplicates.
out = dedupSequence(out)
}
return out, nil
@@ -904,10 +891,9 @@ func evalNode(n node) (any, error) {
return nil, fmt.Errorf("unsupported node")
}
// evalChain evaluates a comparison chain the way Python's ast.Compare does:
// each comparison is applied to ADJACENT operands, results are combined with
// `and`, and each operand is evaluated exactly once. It short-circuits on the
// first false comparison.
// evalChain evaluates a comparison chain: each comparison is applied to ADJACENT operands,
// results are combined with `and`, and each operand is evaluated exactly once. It
// short-circuits on the first false comparison.
func evalChain(t *chainNode) (any, error) {
left, err := evalNode(t.operands[0])
if err != nil {
@@ -932,7 +918,7 @@ func evalChain(t *chainNode) (any, error) {
}
func evalBinary(t *binaryNode) (any, error) {
// `and` / `or` short-circuit, as in Python.
// `and` / `or` short-circuit.
if t.op == "and" || t.op == "or" {
left, err := evalNode(t.left)
if err != nil {
@@ -1007,7 +993,7 @@ func evalBinary(t *binaryNode) (any, error) {
if rf == 0 {
return nil, fmt.Errorf("modulo by zero")
}
// Python's % returns a value with the SIGN OF THE DIVISOR
// % returns a value with the SIGN OF THE DIVISOR
// (a - (b * floor(a / b))): -3 % 2 == 1, 3 % -2 == -1.
// math.Mod returns a value with the sign of the dividend
// (math.Mod(-3, 2) == -1), so adjust when signs disagree.
@@ -1057,13 +1043,13 @@ func evalCall(t *callNode) (any, error) {
scale := math.Pow(10, nd)
return math.RoundToEven(f*scale) / scale, nil
}
// Python's builtin round() is round-half-to-even (banker's rounding):
// round(2.5) == 2, round(3.5) == 4. math.Round rounds half away from
// zero (round(2.5) == 3), so RoundToEven mirrors Python.
// round() is round-half-to-even (banker's rounding): round(2.5) == 2, round(3.5) ==
// 4. math.Round rounds half away from zero (round(2.5) == 3), so RoundToEven is the
// right primitive.
return math.RoundToEven(f), nil
case "int":
// Python's int() accepts a number (truncating toward zero) or a
// whole-number string; int("1.5") is a ValueError, so reject decimals.
// int() accepts a number (truncating toward zero) or a whole-number string;
// int("1.5") is invalid, so reject decimals.
v := single(args)
switch n := v.(type) {
case int64, float64, bool:
@@ -1082,7 +1068,7 @@ func evalCall(t *callNode) (any, error) {
return nil, fmt.Errorf("int() expects a number or numeric string, got %T", v)
}
case "float":
// Python's float() accepts a number or any numeric string.
// float() accepts a number or any numeric string.
v := single(args)
switch n := v.(type) {
case int64, float64, bool:
@@ -1148,7 +1134,7 @@ func single(args []any) any {
return args[0]
}
// flattenArgs mirrors Python's "any number of args, or a single list of them".
// flattenArgs accepts "any number of args, or a single list of them".
func flattenArgs(args []any) []any {
if len(args) == 1 {
if list, ok := args[0].([]any); ok {
@@ -1201,11 +1187,10 @@ func sequenceLen(v any) int {
return 0
}
// dedupSequence mirrors Python set semantics for {a, b, c} literals: repeated
// dedupSequence: semantics for {a, b, c} literals: repeated
// members collapse to one. The key is a stable, panic-free rendering so
// unhashable elements (nested lists) never crash the evaluator; numeric
// int64/float64 compare by value, so {1, 1.0} dedups to one element, matching
// Python.
// int64/float64 compare by value, so {1, 1.0} dedups to one element.
func dedupSequence(in []any) []any {
seen := make(map[string]bool, len(in))
out := make([]any, 0, len(in))
@@ -1274,11 +1259,10 @@ func compareValues(op string, a, b any) (bool, error) {
}
func extrema(op string, values []any) (any, error) {
// Mirrors Python min()/max(): elements are ordered with the same comparison
// semantics as the rest of the evaluator (compareValues), so strings compare
// lexicographically and the result keeps its original type. The final numeric
// gate then rejects a non-numeric result, exactly as Python's
// isinstance(value, (int, float)) check does.
// min()/max() order elements with the same comparison semantics as the rest of the
// evaluator (compareValues), so strings compare lexicographically and the result keeps
// its original type. The final numeric gate then rejects a non-numeric result, matching
// the numeric-type check.
best := values[0]
for _, v := range values[1:] {
// Keep v when it is the smaller (min) or larger (max) element.
@@ -1294,10 +1278,9 @@ func extrema(op string, values []any) (any, error) {
}
func sortValues(values []any) ([]any, error) {
// Mirrors Python sorted(): order elements with the evaluator's comparison
// semantics (compareValues), so strings sort lexicographically. The result is
// a list, which the final numeric gate rejects — exactly as Python's
// isinstance(value, (int, float)) check refuses a list result.
// sorted() orders elements with the evaluator's comparison semantics (compareValues), so
// strings sort lexicographically. The result is a list, which the final numeric gate
// rejects — the numeric-type check refuses a list result.
out := append([]any{}, values...)
for i := 1; i < len(out); i++ {
for j := i; j > 0; {
@@ -1315,7 +1298,7 @@ func sortValues(values []any) ([]any, error) {
return out, nil
}
// letters mirrors Python _letters: count alphabetic characters across the given
// letters: count alphabetic characters across the given
// names. Spaces, hyphens, apostrophes, digits and punctuation do NOT count;
// letters carrying diacritics DO ("José" is 4).
//
@@ -1338,7 +1321,7 @@ func letters(items []any) int {
return total
}
// digitSum mirrors Python _digit_sum: add up the decimal digits inside the
// digitSum: add up the decimal digits inside the
// given values. Every digit is added SEPARATELY: digit_sum("L7 7BN") is 14 and
// digit_sum("2020") is 4. Only ASCII digits count.
func digitSum(items []any) int {
@@ -1364,7 +1347,7 @@ func digitSum(items []any) int {
return total
}
// dateDiff mirrors Python _date_diff: days between two ISO dates (inclusive of
// dateDiff: days between two ISO dates (inclusive of
// the earlier, exclusive of the later — a calendar span).
func dateDiff(a, b any) (int64, error) {
as, okA := a.(string)
@@ -1406,32 +1389,27 @@ func parseISODate(s string) (time.Time, error) {
}
t := time.Date(y, time.Month(mo), d, 0, 0, 0, 0, time.UTC)
// time.Date silently normalizes out-of-range days (Feb 30 -> Mar 1, month 13
// -> next Jan). Python date() raises ValueError, so reject any date that does
// not survive the round trip unchanged.
// -> next Jan). A date that does not survive the round trip unchanged is rejected.
if t.Year() != y || int(t.Month()) != mo || t.Day() != d {
return time.Time{}, fmt.Errorf("day %d out of range for %q", d, s)
}
return t, nil
}
// ---------------------------------------------------------------------------
// ComputeFromFacts: decide whether the question asks for a derivable number and,
// if so, compute it. Mirrors Python arithmetic.compute_from_facts (line 356) and
// the _COMPUTE_SYSTEM prompt (line 267). The evaluator side of this lives above
// (Compute / the parser); the two together form the Go port of arithmetic.py.
// ---------------------------------------------------------------------------
// ComputeFromFacts decides whether the question asks for a derivable number and, if so,
// computes it. The evaluator side of this lives above (Compute / the parser); the two
// together form the compute tool.
// ComputeSystem mirrors arithmetic.py::_COMPUTE_SYSTEM verbatim in intent. It is
// inlined (rather than loaded from rag/prompts) because the Go harness has no
// Jinja env and the template carries no variables.
// ComputeSystem is the compute prompt. It is inlined (rather than loaded from rag/prompts)
// because the runtime has no template env and the text carries no variables.
//
// IMPORTANT: the prompt asks for a PYTHON expression, and the parser in
// arithmetic.go implements exactly the Python subset the prompt promises
// (`**`, `x if y else z`, list literals, the listed functions). Changing one
// without the other will silently refuse every model-written expression.
// IMPORTANT: the prompt asks for an arithmetic expression, and the parser in
// arithmetic.go implements exactly the expression subset the prompt promises
// (`**`, `x if y else z`, list literals, the listed functions). Changing one without the
// other will silently refuse every model-written expression.
const ComputeSystem = `You are given the ORIGINAL question and every fact discovered so far. Decide whether that question asks for a NUMBER that NO fact states outright but that FOLLOWS ARITHMETICALLY from figures the facts DO state — a sum, a difference, a count, an average, a percentage, a unit conversion, an elapsed span.
If it does, compute it by writing ONE Python expression with every figure substituted as a literal. The expression is evaluated on its own: no variables, no assignments, no imports, no attributes, no subscripts. The only functions available are abs, round, min, max, sum, len, int, float, sorted, letters, digit_sum and date_diff.
If it does, compute it by writing ONE arithmetic expression with every figure substituted as a literal. The expression is evaluated on its own: no variables, no assignments, no imports, no attributes, no subscripts. The only functions available are abs, round, min, max, sum, len, int, float, sorted, letters, digit_sum and date_diff.
combined population of three -> 12345 + 6789 + 101112
how many of the listed items -> len(["Alpha", "Beta", "Gamma"])
what percentage one figure is -> 100 * 4523 / 18092
@@ -1468,9 +1446,9 @@ Return "needed": false, with an empty expression, ONLY when:
"label" names what the number IS, as a short noun phrase ("combined population of the three counties"), so a later step can use the result without re-deriving it.
"uses" lists the INDEX NUMBERS of the facts whose figures you substituted.
Output ONLY JSON, no prose, no code fences:
{"needed": true/false, "expression": "<one Python expression, or empty>", "label": "<short noun phrase>", "uses": [<index number>, ...]}`
{"needed": true/false, "expression": "<one arithmetic expression, or empty>", "label": "<short noun phrase>", "uses": [<index number>, ...]}`
// ComputedFact mirrors Python compute_from_facts' return dict.
// ComputedFact: return dict.
type ComputedFact struct {
Needed bool
Label string
@@ -1495,12 +1473,10 @@ func ComputeFromFacts(ctx context.Context, model SessionModel, question string,
user := fmt.Sprintf("Facts discovered so far:\n%s\n\nOriginal question:\n%s\n\nOutput JSON:",
renderFacts(facts), question)
// Mirror Python compute_from_facts: budget = fit_budget or llm.max_length,
// then message_fit_in(form_message(system, user), budget). The chat seam now
// exposes llm.max_length via ContextLengthModel; when neither an explicit
// fitBudget nor a model context window is available the 8192 default (chat
// EffectiveContextLength) applies, exactly as Python's LLM.max_length
// defaults when the model config omits max_tokens.
// Budget = fit_budget or the model's context length, then the prompt is fitted to it.
// The chat seam exposes the context length via ContextLengthModel; when neither an
// explicit fitBudget nor a model context window is available the 8192 default
// (chat.EffectiveContextLength) applies, i.e. when the model config omits it.
budget := fitBudget
if budget <= 0 {
if cl, ok := model.(ContextLengthModel); ok {
@@ -1518,7 +1494,7 @@ func ComputeFromFacts(ctx context.Context, model SessionModel, question string,
return nil
}
// FitMessages may prepend/trim a system message; re-extract it so the model
// call is exactly [system, user...] as Python sends msg[0] then msg[1:].
// call is exactly [system, user...].
systemPrompt := ComputeSystem
history := fitted
if len(fitted) > 0 && fitted[0].Role == schema.System {
@@ -1528,8 +1504,8 @@ func ComputeFromFacts(ctx context.Context, model SessionModel, question string,
msgs := make([]schema.Message, 0, 1+len(history))
msgs = append(msgs, *schema.SystemMessage(systemPrompt))
msgs = append(msgs, history...)
// Python hardcodes {"temperature": 0.0} for this node — a mechanical rewrite,
// so it must be deterministic. When the model supports per-call temperature we
// The temperature is pinned to 0.0 for this node — a mechanical rewrite, so it must be
// deterministic. When the model supports per-call temperature we
// pass 0.0 exactly; otherwise we fall back to the plain Complete (it then runs
// at the model's default), the same documented seam as ExtractWeightedKeywords
// (which uses 0.1).
@@ -1549,9 +1525,9 @@ func ComputeFromFacts(ctx context.Context, model SessionModel, question string,
_LOG.Printf("[Compute] could not parse LLM JSON")
return nil
}
// Python evaluates `not data.get("needed")`, i.e. builtin bool(): a truthy
// non-bool (1, "true", a non-empty list) counts as needed. A strict bool
// assertion would reject those and wrongly report "nothing derivable".
// A truthy non-bool (1, "true", a non-empty list) counts as needed — i.e. builtin bool()
// semantics. A strict bool assertion would reject those and wrongly report "nothing
// derivable".
if !truthy(data["needed"]) {
return nil
}
@@ -1592,7 +1568,7 @@ func ComputeFromFacts(ctx context.Context, model SessionModel, question string,
}
}
// renderFacts mirrors Python _render_facts: one fact per line, prefixed with its
// renderFacts: one fact per line, prefixed with its
// index — the index is what "uses" refers back to.
func renderFacts(facts []string) string {
lines := make([]string, 0, len(facts))
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -24,9 +24,7 @@ import (
"github.com/cloudwego/eino/schema"
)
// ---------------------------------------------------------------------------
// The happy path: what the prompt promises the model
// ---------------------------------------------------------------------------
func TestComputeBasicArithmetic(t *testing.T) {
cases := []struct{ expr, want string }{
@@ -40,10 +38,10 @@ func TestComputeBasicArithmetic(t *testing.T) {
{"7 % 3", "1"}, // modulo
{"abs(1954 - 1998)", "44"}, // abs
{"round(3.14159, 2)", "3.14"}, // round with digits
{"round(2.5)", "2"}, // Python banker's rounding (half-to-even)
{"round(2.5)", "2"}, // banker's rounding (half-to-even)
{"round(3.5)", "4"}, // half-to-even, away from even
{"round(0.5)", "0"}, // half-to-even
{"-3 % 2", "1"}, // Python %: sign of the divisor
{"-3 % 2", "1"}, // % takes the sign of the divisor
{"3 % -2", "-1"}, // sign of the divisor
{"min(3, 1, 2)", "1"},
{"max(3, 1, 2)", "3"},
@@ -52,7 +50,7 @@ func TestComputeBasicArithmetic(t *testing.T) {
{"0.1 + 0.2", "0.3"}, // float noise suppressed
{"(2 + 3) * 4", "20"}, // grouping
{"2 ** 3 ** 2", "512"}, // right-associative
{"-2 ** 2", "-4"}, // Python precedence
{"-2 ** 2", "-4"}, // power binds tighter than unary minus
{"1 if 2 > 1 else 0", "1"}, // ternary
}
for _, c := range cases {
@@ -74,7 +72,7 @@ func TestComputeHelperFunctions(t *testing.T) {
{`letters(["José"])`, "4"}, // diacritics count
{`digit_sum("L7 7BN")`, "14"}, // 7 + 7
{`digit_sum("2020")`, "4"}, // each digit separately
{`date_diff("1941-07-28", "1959-07-17")`, "6563"}, // calendar span (Python docstring: Q317 = 6563)
{`date_diff("1941-07-28", "1959-07-17")`, "6563"}, // calendar span
{`date_diff("1959-07-17", "1941-07-28")`, "6563"}, // order-independent
{`sorted([3, 1, 2])[0]`, ""}, // subscripts refused (see below)
}
@@ -93,9 +91,7 @@ func TestComputeHelperFunctions(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Security: the AST whitelist
// ---------------------------------------------------------------------------
func TestComputeRefusesUnsafeExpressions(t *testing.T) {
cases := []struct{ expr, wantSubstr string }{
@@ -148,9 +144,8 @@ func TestComputeRefusesUnsafeExpressions(t *testing.T) {
}
// TestComputeRecoversFromHelperTypeErrors guards the panic→error conversion:
// letters()/digit_sum() reject bad argument types by panicking (mirroring
// Python's TypeError), and Compute must turn that into a normal refusal rather
// than crashing the request.
// letters()/digit_sum() reject bad argument types by panicking, and Compute must turn that
// into a normal refusal rather than crashing the request.
func TestComputeRecoversFromHelperTypeErrors(t *testing.T) {
for _, expr := range []string{`letters(123)`, `digit_sum(1.5)`} {
got, err := Compute(expr)
@@ -201,17 +196,15 @@ func TestComputeRefusesNonNumericResult(t *testing.T) {
}
}
// TestComputeBuiltinStringArgs pins Python's real builtin semantics: int()/float()
// accept numeric strings (mirroring the genuine Python builtins injected via
// _COMPUTE_FUNCTIONS), and min/max/sorted accept and order strings the same way
// Python does. The final result gate then rejects non-numeric results, matching
// Python's isinstance(value, (int, float)) check.
// TestComputeBuiltinStringArgs pins the builtin semantics: int()/float() accept numeric
// strings, and min/max/sorted accept and order strings. The final result gate then rejects
// non-numeric results (the numeric-type check).
func TestComputeBuiltinStringArgs(t *testing.T) {
// int()/float() accept strings -> these produce a number and are ACCEPTED,
// exactly like Python (previously Go rejected them — a true divergence).
// int()/float() accept strings -> these produce a number and are ACCEPTED
// (previously they were rejected — a true divergence).
accept := []struct{ expr, want string }{
{`int("12")`, "12"},
{`int(" 12 ")`, "12"}, // Python strips surrounding whitespace
{`int(" 12 ")`, "12"}, // surrounding whitespace is stripped
{`int(1.5)`, "1"}, // truncate toward zero
{`float("1.5")`, "1.5"},
{`float(" 1e3 ")`, "1000"},
@@ -221,10 +214,10 @@ func TestComputeBuiltinStringArgs(t *testing.T) {
t.Errorf("Compute(%q) = %q, err=%q; want %q", c.expr, got, err, c.want)
}
}
// These raise in Python (ValueError / TypeError) and must be refused.
// These are hard failures and must be refused.
for _, expr := range []string{`int("1.5")`, `float("abc")`, `int("0x10")`} {
if got, err := Compute(expr); err == "" {
t.Errorf("Compute(%q) = %q, want a refusal (Python raises)", expr, got)
t.Errorf("Compute(%q) = %q, want a refusal", expr, got)
}
}
}
@@ -238,16 +231,14 @@ func TestComputeRefusesDivisionByZero(t *testing.T) {
}
func TestComputeComparisonChains(t *testing.T) {
// Python's ast.Compare is an N-ary chain: `a < b < c` means
// (a < b) and (b < c), with each operand evaluated exactly once and
// short-circuiting on the first false comparison. A left-associative
// binary rewrite would instead compare the previous comparison's BOOLEAN
// A comparison is an N-ary chain: `a < b < c` means (a < b) and (b < c), with each operand
// evaluated exactly once and short-circuiting on the first false comparison. A
// left-associative binary rewrite would instead compare the previous comparison's BOOLEAN
// result against the next operand, which is wrong.
//
// At the top level a comparison yields a bool, and compute() rejects
// non-numeric final results ("result is bool, not a number"), exactly as
// in Python. But a bool used numerically inside a call (int/abs/round…)
// must evaluate with the chained semantics.
// At the top level a comparison yields a bool, and compute() rejects non-numeric final
// results ("result is bool, not a number"). But a bool used numerically inside a call
// (int/abs/round…) must evaluate with the chained semantics.
rejected := []string{
"3 > 2 > 1", "2 < 1 < 3", "1 < 2 < 3 < 4", "1 < 2 > 3",
"1 <= 1 <= 2", "3 == 3 == 3", "3 == 3 == 4", "5 > 4 > 3 > 2 > 1", "3 > 2",
@@ -286,9 +277,7 @@ func TestComputeRejectsTrailingInput(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
func TestFormatNumber(t *testing.T) {
cases := []struct {
@@ -309,9 +298,7 @@ func TestFormatNumber(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// The helper functions directly
// ---------------------------------------------------------------------------
func TestLettersAndDigitSum(t *testing.T) {
// "José" is 4 letters — diacritics count, spaces/punctuation do not.
@@ -321,7 +308,7 @@ func TestLettersAndDigitSum(t *testing.T) {
if got := letters([]any{"Ada Lovelace"}); got != 11 {
t.Errorf("letters = %d, want 11", got)
}
// "Any number of names, or one list of them" (Python _letters).
// "Any number of names, or one list of them".
if got := letters([]any{"ab", "cde"}); got != 5 {
t.Errorf("letters multi = %d, want 5", got)
}
@@ -343,7 +330,7 @@ func TestParseISODate(t *testing.T) {
}
for _, bad := range []string{
"1941-07", "not-a-date", "1941-07-28-01", // malformed
"2024-13-01", "2024-00-15", // month out of range (Python ValueError)
"2024-13-01", "2024-00-15", // month out of range
"2024-02-30", "2023-02-29", // day out of range for month
} {
if _, err := parseISODate(bad); err == nil {
@@ -352,9 +339,9 @@ func TestParseISODate(t *testing.T) {
}
}
// TestComputeSetLiteralDedups pins Python set semantics: {a, b, c} literals
// collapse duplicates (len({1,1,2}) == 2), and min/max operate on the unique
// members. Before the dedup fix, Go counted every member, diverging from Python.
// TestComputeSetLiteralDedups pins set semantics: {a, b, c} literals collapse duplicates
// (len({1,1,2}) == 2), and min/max operate on the unique members. Before the dedup fix, Go
// counted every member.
func TestComputeSetLiteralDedups(t *testing.T) {
cases := []struct{ expr, want string }{
{`len({1,1,2})`, "2"}, // three members, two unique
@@ -369,9 +356,7 @@ func TestComputeSetLiteralDedups(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Tool-outcome mapping
// ---------------------------------------------------------------------------
func TestExecutorCalculate(t *testing.T) {
// A model that writes a derivable expression.
@@ -387,7 +372,7 @@ func TestExecutorCalculate(t *testing.T) {
t.Fatalf("status = %s (%v), want ok", oc.Status, oc.Metrics)
}
entry := oc.Payload[0].(map[string]any)
// Python :961 — the success payload is exactly {"kind","expression","result"};
// the success payload is exactly {"kind","expression","result"};
// label/uses are deliberately NOT echoed.
if entry["result"] != "44" {
t.Errorf("result = %v, want 44", entry["result"])
@@ -399,9 +384,8 @@ func TestExecutorCalculate(t *testing.T) {
t.Errorf("payload must not echo label: %v", entry)
}
// A model that says no derivation is needed → POOR (not an error), so the
// model then answers from the facts it already has (mirrors Python
// action_session._exec_calculate: nothing derivable → status=POOR/no_doc).
// A model that says no derivation is needed → POOR (not an error), so the model then
// answers from the facts it already has (nothing derivable → status=POOR/no_doc).
mdl = &fakeModel{replies: []*ModelReply{{Content: `{"needed": false}`}}}
ex = &searchExecutor{deps: SearchDeps{Model: mdl}}
oc, _ = ex.Execute(context.Background(), "calculate", map[string]any{
@@ -422,18 +406,18 @@ func TestExecutorCalculate(t *testing.T) {
t.Errorf("unsafe expr: status = %s, want poor (refused)", oc.Status)
}
// No facts → POOR/no_doc, NOT bad_args: Python validates nothing and
// compute_from_facts' own `not facts` guard returns None (action_session.py:_exec_calculate).
// No facts → POOR/no_doc, NOT bad_args: nothing is validated and the
// compute_from_facts `not facts` guard returns None.
ex = &searchExecutor{deps: SearchDeps{Model: &fakeModel{}}}
if oc, _ := ex.Execute(context.Background(), "calculate", map[string]any{"question": "q"}); oc.Status != StatusPoor || oc.Reason != ReasonNoDoc {
t.Errorf("no facts: got (%s,%s), want (poor,no_doc)", oc.Status, oc.Reason)
}
}
// TestComputeFromFactsAcceptsNonBoolNeeded pins Python's `not data.get("needed")`
// guard: builtin bool() treats a NON-EMPTY STRING as truthy (even the literal
// "false"), so a model that emits needed as a string must still compute. A strict
// bool assertion would silently report "nothing derivable".
// TestComputeFromFactsAcceptsNonBoolNeeded pins the `not data.get("needed")` guard:
// builtin bool() treats a NON-EMPTY STRING as truthy (even the literal "false"), so a model
// that emits needed as a string must still compute. A strict bool assertion would silently
// report "nothing derivable".
func TestComputeFromFactsAcceptsNonBoolNeeded(t *testing.T) {
mdl := &fakeModel{replies: []*ModelReply{{
Content: `{"needed": "true", "expression": "2 + 2", "label": "sum"}`,
@@ -463,10 +447,8 @@ func TestToolStringListAcceptsAllShapes(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Tuple literals: Python whitelists ast.Tuple, used as the default sequence in
// sum((1,2,3)) / len((1,2,3)) / min / max / letters.
// ---------------------------------------------------------------------------
// Tuple literals: the sequence form used by sum((1,2,3)) / len((1,2,3)) / min / max /
// letters.
func TestComputeTupleLiterals(t *testing.T) {
cases := []struct{ expr, want string }{
@@ -487,8 +469,8 @@ func TestComputeTupleLiterals(t *testing.T) {
t.Errorf("Compute(%q) = %q, want %q", c.expr, got, c.want)
}
}
// A bare top-level tuple is not a number and must be refused (mirrors
// Python: a tuple result fails the isinstance(value, (int, float)) check).
// A bare top-level tuple is not a number and must be refused (a tuple result fails the
// numeric-type check).
if _, err := Compute("(1, 2, 3)"); err == "" {
t.Error("Compute(\"(1, 2, 3)\") returned a value, want a refusal")
}
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"fmt"
@@ -24,18 +24,16 @@ import (
// Field accessors for chunk maps.
//
// Mirrors Python harness/chunk_utils.py. Maintenance-helper data — doc ids,
// dataset ids, titles — has historically been stored under different field
// names depending on the backend and indexer, so every read goes through a
// Maintenance-helper data — doc ids, dataset ids, titles — has historically been stored under
// different field names depending on the backend and indexer, so every read goes through a
// helper that tolerates all known aliases.
//
// NOTE: tools/ keeps its own accessors rather than importing these, mirroring
// Python, where tools/navigation.py defines its own _chunk_id. That duplication
// is what keeps the dependency graph acyclic (tools must not import the root
// package the root package imports tools from).
// NOTE: tools/ keeps its own accessors rather than importing these. That duplication is what
// keeps the dependency graph acyclic (tools must not import the root package the root package
// imports tools from).
// ChunkAttr returns the first non-empty value among keys.
// Mirrors Python _chunk_attr: truthiness is `v not in (None, "")`.
// Truthiness is "not nil and not empty".
func ChunkAttr(c map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := c[k]; ok && v != nil {
@@ -47,41 +45,40 @@ func ChunkAttr(c map[string]any, keys ...string) string {
return ""
}
// ChunkTextOf mirrors Python _chunk_text. The root package already provides
// ChunkTextOf: The root package already provides
// chunkText with identical semantics; this exported form exists so callers
// outside the package share one implementation.
func ChunkTextOf(c map[string]any) string { return chunkText(c) }
// DocIDOf mirrors Python _doc_id: doc_id / docid / document_id.
// DocIDOf: doc_id / docid / document_id.
func DocIDOf(c map[string]any) string {
return ChunkAttr(c, "doc_id", "docid", "document_id")
}
// DatasetIDOf mirrors Python _dataset_id: dataset_id / kb_id / knowledgebase_id.
// DatasetIDOf: dataset_id / kb_id / knowledgebase_id.
func DatasetIDOf(c map[string]any) string {
return ChunkAttr(c, "dataset_id", "kb_id", "knowledgebase_id")
}
// DocTitleOf mirrors Python _doc_title exactly: docnm_kwd / doc_title / title /
// DocTitleOf: exactly: docnm_kwd / doc_title / title /
// document_name (the same four keys, in the same order). Go chunk retrieval
// carries the title under docnm_kwd, so no extra alias is needed.
func DocTitleOf(c map[string]any) string {
return ChunkAttr(c, "docnm_kwd", "doc_title", "title", "document_name")
}
// ChunkIDOf mirrors Python _chunk_id: chunk_id / id.
// ChunkIDOf: chunk_id / id.
func ChunkIDOf(c map[string]any) string { return ChunkAttr(c, "chunk_id", "id") }
// Snippet mirrors Python _snippet: trim both ends, cut to limit, right-trim ALL
// Snippet: trim both ends, cut to limit, right-trim ALL
// trailing whitespace (not just spaces), then add an ellipsis marker when the
// value was actually truncated. Python's .rstrip() with no argument strips any
// Unicode whitespace (space, tab, newline, ...), so a cut that ends mid-run of
// whitespace collapses to the same trailing slice before "...".
// value was actually truncated. The right-trim strips ANY Unicode whitespace (space, tab,
// newline, ...), so a cut that ends mid-run of whitespace collapses to the same trailing slice
// before "...".
//
// Python indexes strings by Unicode code point, so len(s) and s[:n] are
// character-based. Go's len/[:] are byte-based and would split a multibyte
// (e.g. CJK) rune and emit invalid UTF-8. We therefore convert to []rune so the
// limit and the cut operate on code points exactly like Python.
// Indexing is by Unicode code point, so the limit and the cut are character-based. Go's
// len/[:] are byte-based and would split a multibyte (e.g. CJK) rune and emit invalid UTF-8,
// hence the []rune conversion.
func Snippet(s string, limit int) string {
t := strings.TrimSpace(s)
r := []rune(t)
@@ -91,14 +88,14 @@ func Snippet(s string, limit int) string {
return strings.TrimRightFunc(string(r[:limit]), unicode.IsSpace) + "..."
}
// IsTableChunk mirrors Python _is_table_chunk / _is_table_text: a corpus-neutral
// IsTableChunk: / _is_table_text: a corpus-neutral
// table detector — HTML table markup, or >=3 pipe rows. Exported so the
// orchestrator and the bridge share one implementation.
func IsTableChunk(c map[string]any) bool {
return isTableText(ChunkTextOf(c))
}
// isTableText mirrors Python _is_table_text: table detection from raw text.
// isTableText: table detection from raw text.
func isTableText(text string) bool {
t := strings.ToLower(text)
if strings.Contains(t, "<table") || strings.Contains(t, "<tr") {
@@ -113,10 +110,10 @@ func isTableText(text string) bool {
return pipeRows >= 3
}
// XMLEscape mirrors Python _xml_escape: the four XML entities (&, <, >, ").
// Python intentionally does NOT escape the apostrophe — values are only ever
// embedded inside double-quoted XML/Markdown attributes, where a literal '
// is valid, so escaping it to &apos; would diverge from the Python output.
// XMLEscape: the four XML entities (&, <, >, ").
// The apostrophe is intentionally NOT escaped — values are only ever embedded inside
// double-quoted XML/Markdown attributes, where a literal ' is valid, so escaping it to &apos;
// would be wrong.
func XMLEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
@@ -168,10 +165,8 @@ func ChunkEvidenceIDs(chunks []map[string]any, limit int) []string {
return out
}
// MergeChunks deduplicates incoming chunks against an existing slice by
// chunkKey, appending only unseen ones. Mirrors Python's
// `seen = {_chunk_key(c) for c in kbinfos["chunks"]}` merge pattern used by
// direct.py and compiled_expansion.py.
// MergeChunks deduplicates incoming chunks against an existing slice by chunkKey, appending
// only unseen ones — the merge pattern used by the direct and compiled-expansion paths.
//
// Returns the merged slice and the global indices of the newly appended chunks.
func MergeChunks(existing, incoming []map[string]any) ([]map[string]any, []int) {
@@ -14,12 +14,12 @@
// limitations under the License.
//
package harness
package runtime
import "testing"
// TestSnippet covers Python _snippet parity: trim both ends, cut to limit,
// right-trim ALL trailing whitespace, then add "...".
// TestSnippet: trim both ends, cut to limit, right-trim ALL trailing whitespace, then add
// "...".
func TestSnippet(t *testing.T) {
cases := []struct {
in string
@@ -40,8 +40,8 @@ func TestSnippet(t *testing.T) {
{"hello\n\nworld", 6, "hello..."},
// Mixed trailing whitespace after the cut.
{"hello \t\n world", 6, "hello..."},
// CJK chars are multibyte in UTF-8; the limit is in code points
// (Python str semantics), so no rune is ever split. "中文" (2 code
// CJK chars are multibyte in UTF-8; the limit is in code points, so no rune is ever
// split. "中文" (2 code
// points) fits within n=2 and stays intact.
{" 中文 ", 2, "中文"},
// Truncation lands on a code-point boundary: first 3 of 4 CJK chars.
@@ -14,14 +14,14 @@
// limitations under the License.
//
package harness
package runtime
import "strings"
// Thinking-mode configuration: the single authority for mode behaviour.
//
// Mirrors Python harness/config.py. Every mode-dependent decision in the
// harness reads from this file instead of re-deriving it from a thinking_mode
// Every mode-dependent decision in the runtime reads from this file instead of re-deriving it
// from a thinking_mode
// string, so tuning a mode is a one-table edit.
//
// Unknown labels fall back to NAIVE rather than erroring: the label comes from
@@ -42,7 +42,7 @@ var allTools = []string{
// GraphExploreTool is the relational exploration tool reserved for ultra.
const GraphExploreTool = "graph_explore"
// ModeSpec mirrors Python config.ModeSpec (a frozen dataclass). Everything that
// ModeSpec: (a frozen dataclass). Everything that
// varies between thinking modes lives here.
//
// Tools is the set of tool names visible to the model in this mode. An empty
@@ -66,7 +66,7 @@ type ModeSpec struct {
Tools map[string]bool
}
// toolsOf builds the tool set from names (mirrors Python _tools()).
// toolsOf builds the tool set from names.
func toolsOf(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
@@ -75,12 +75,12 @@ func toolsOf(names ...string) map[string]bool {
return m
}
// allToolSet returns the default tool surface (mirrors Python _ALL_TOOLS).
// allToolSet returns the default tool surface.
func allToolSet() map[string]bool {
return toolsOf(allTools...)
}
// THINKING_MODES mirrors Python config.THINKING_MODES.
// THINKING_MODES
//
// - low: one hybrid-search pass through direct_search. No action session,
// so no tool loop — the model never sees tools in this mode.
@@ -140,7 +140,7 @@ func (m ModeSpec) ToolNames() []string {
return out
}
// GetMode mirrors Python get_mode(label): unknown labels fall back to NAIVE.
// GetMode: (label): unknown labels fall back to NAIVE.
// The label arrives from user input, so erroring here would fail the request.
func GetMode(label string) ModeSpec {
m, ok := THINKING_MODES[strings.ToLower(strings.TrimSpace(label))]
@@ -151,14 +151,14 @@ func GetMode(label string) ModeSpec {
}
// ThinkingModeCarrier is implemented by the RAGTools-like object that owns the
// request-scoped retrieval context (Python RAGTools.thinking_mode).
// request-scoped retrieval context (its thinking mode).
type ThinkingModeCarrier interface {
GetThinkingMode() string
}
// ResolveMode mirrors Python resolve_mode(tools): reads a RAGTools-like
// ResolveMode: (tools): reads a RAGTools-like
// object's thinking mode into its spec. Values that do not implement
// ThinkingModeCarrier fall back to NAIVE, matching Python's getattr default.
// ThinkingModeCarrier fall back to NAIVE.
func ResolveMode(tools any) ModeSpec {
if c, ok := tools.(ThinkingModeCarrier); ok {
return GetMode(c.GetThinkingMode())
@@ -0,0 +1,178 @@
//
// 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 runtime
import (
"strings"
"ragflow/internal/rag/agentic-rag/slots"
)
// CoverageActWordsMax bounds the act words ONE slot may declare. Ten because a source
// words one deed many ways (斩 / 杀 / 劈 / 挥为两段 …), and a phrasing no act word covers
// is a passage no query ever names — which is why the planner is asked for several.
const CoverageActWordsMax = 10
// Coverage is what a question ASKS FOR when its answer is a SET OF NAMED MEMBERS: an
// actor, the words the source uses for the deed, and the two declarations that say the
// answer is a set of names at all.
//
// It is the WHOLE of the enumeration strategy's interface — two fields carried from the
// planner's own declaration (Actor / Acts) and two readings of the same declaration
// (Set / Members). Everything the enumeration needs is answered by "is this three things
// true", and everything it does is answered by "one recall per operand, one window per
// place the deed is stated, one verdict per window" — no pattern language, no per-round
// bookkeeping, no reach ledger.
//
// Where the fields come from: the planner is asked to declare act words for "a SET or a
// COUNT of things that someone DID" and names the actor beside them (a slot's Terms and
// Subject). Those two declarations are the plan. A table that does not make them is not
// enumerated, and pays nothing (Coverage.Ok).
type Coverage struct {
// Actor is the actor's declared forms, alternatives joined by '|' (a name and its
// aliases).
// Empty is allowed: the deed's own words still enumerate ("who was killed" questions
// state the act without naming the actor).
Actor string
// Acts are the words the planner declared for the deed — the verbs the source uses for
// it. A phrasing no act word covers is a passage no query names, which is why the
// planner is asked for several.
Acts []string
// Set reports that the table asked for a count, a set or a list — the answer is a SET
// rather than one value.
Set bool
// ItemKind is what the answer's elements are, when the table declares it: "person", "entity",
// "dataset", or the bare "list"/"set". Empty when the answer is not a list of elements.
ItemKind string
// itemsInValue: a slot's value IS a list of items — the direction declared by the run's own
// output rather than by the planner's word.
//
// Read by the point-of-naming node ALONE (see RunCoverageResolve), which runs last: a plan-time
// type word must not be the only way a set can be completed. Deliberately NOT folded into Set —
// the two readers of Set ask what the planner DECLARED (RenderSlotRecord's set block and
// SessionState.parentSet), and read from the value they demote a value question's own draft
// answer: measured (2026-09-17, FRAMES q759) a one-person question's record lost its
// "Candidate answer:" line and gained "enumerated members across the slots above: 1".
itemsInValue bool
}
// CoverageItemKindItems is the element kind a slot declares BY ITS OWN VALUE: a slot holding a
// list of items says the answer is a set, and no planner word names the kind any further.
const CoverageItemKindItems = "items"
// CoverageItemKinds reads the planner's slot vocabulary as what the answer enumerates. A kind not
// in this table is not a list of elements (a count, a range, a date, a phrase), and the gate below
// pays nothing for it.
var CoverageItemKinds = map[string]string{
"entity": "entity",
"person": "person",
"dataset": "dataset",
"list": "list",
"set": "set",
}
// CoverageOf reads the declaration out of the planner's own table.
//
// Nothing here reads a slot's text, the question, or a candidate: the shape and the deed
// are what the PLANNER said about the answer, so the gate cannot be fooled by how a
// passage happens to be worded. A permissive reading instead spends set strategy on
// questions that assemble nothing.
func CoverageOf(table State) Coverage {
var c Coverage
for _, v := range table.State {
// The two readings are independent: "list" and "set" are BOTH set-shaped and
// element-carrying, so a slot may satisfy either or both (they were two predicates
// before, and this keeps their union exact).
kind := strings.ToLower(strings.TrimSpace(v.Type))
switch kind {
case "count", "set", "list":
c.Set = true
}
if k, ok := CoverageItemKinds[kind]; ok && c.ItemKind == "" {
c.ItemKind = k
}
// The VALUE declares the direction too, but to a NARROWER audience than Set: a slot holding
// a list of items says the answer is a set, and that is what lets the point-of-naming node
// complete a set the planner never typed (measured 2026-09-17, 三国/关羽: the members were
// patched into a slot typed "count" and the run judged only the ten names a session wrote).
// It is NOT Set: see the field's note for the question that read it too widely.
if v.Typed().Kind == slots.KindItems {
c.itemsInValue = true
if c.ItemKind == "" {
c.ItemKind = CoverageItemKindItems
}
}
if c.Actor == "" {
c.Actor = strings.TrimSpace(v.Subject)
}
for _, term := range v.Terms {
if term = strings.TrimSpace(term); term != "" {
c.Acts = appendUnique(c.Acts, term)
}
}
}
return c
}
// Ok reports whether this table asks for an enumeration: a SET of ELEMENTS whose deed the
// planner wrote the words for.
//
// The conjunction is the whole gate, and it is what separates a set of elements from a
// count of EVENTS — the planner declares act words for the latter too ("how many times did
// it happen" → the verb for it), and no element an enumeration could return changes a count
// of events. Held only by the element-carrying half, the enumeration runs on single-value
// questions, where it can only add cost and timeouts.
func (c Coverage) Ok() bool {
return c.Set && c.ItemKind != "" && len(c.Acts) > 0
}
// ItemsInValue reports whether the table's own slots HOLD items — the direction the run's output
// declares where the planner's word did not. The point-of-naming node reads it and nothing else
// does (see Coverage.itemsInValue for why the record and a session must not).
func (c Coverage) ItemsInValue() bool { return c.itemsInValue }
// Actors splits the declared actor into its alternatives, which are what the corpus has
// to be read with: one source words one person several ways.
func (c Coverage) Actors() []string {
var out []string
for _, part := range strings.FieldsFunc(c.Actor, func(r rune) bool {
return r == '|' || r == '|' || r == '/' || r == '、' || r == ',' || r == ','
}) {
if part = strings.TrimSpace(part); part != "" {
out = appendUnique(out, part)
}
}
return out
}
// Operands is the recall list: the actor's alternatives and the act words, deduped.
//
// ONE entry per operand is the point. Asking once per (actor, act) PAIR recalls the same
// operand over and over, so a common word fills its recall bound before the rarer ones are
// ever reached.
func (c Coverage) Operands() []string {
var out []string
for _, a := range c.Actors() {
out = appendUnique(out, a)
}
for _, act := range c.Acts {
if act = strings.TrimSpace(act); act != "" {
out = appendUnique(out, act)
}
}
return out
}
@@ -0,0 +1,315 @@
//
// 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 runtime
import (
"context"
"fmt"
"sort"
"strings"
"unicode/utf8"
)
// The enumeration's bounds. They are spent in ONE place each: coverageRecallTopN is what
// one OPERAND may recall (per-operand, not per-pair), the window bounds decide how much
// of a passage a verdict is asked about, and the two caps keep the seed and the call list
// finite.
const (
coverageRecallTopN = 200
coverageWindowsPerChunk = 3
coverageWindowBefore = 80
coverageWindowAfter = 60
// coverageWindowsMax bounds the lines the point-of-naming node is handed, over BOTH of its
// sources: this enumeration's windows and the names a probe already reached (the reached
// ledger's own cap, reachedTermsMax). It is spent against that node's capacity — workers ×
// clock / a slow call × batch — so the two move together: a cap above the capacity is a
// ceiling that can never be reached, and the member set then moves with the provider's
// latency instead of with the corpus.
coverageWindowsMax = 192
coverageSeedMaxChars = 8000
)
// CoverageWindow is one place the corpus states the deed: the chunk it is in, the words
// around the act, and which act word was found.
//
// The window is what a verdict is asked about (see ResolveCoverage), and its chunk id is
// where a member's citation comes from — the model names a member, the runtime names the
// passage, so a fabricated citation cannot reach the record.
type CoverageWindow struct {
ChunkID string
Quote string
Act string
}
// CoverageSet is what one enumeration of the corpus produced: the operands it asked
// about, the windows the deed was stated in, and how many distinct passages came back.
//
// It is EVIDENCE, not advice: the windows are admitted to the shared pool (so a session
// can read them and the answer can cite them) and the seed states them as facts, which is
// what makes the session's job "read these windows" rather than "think of the queries I
// should have made".
type CoverageSet struct {
Operands []string
Windows []CoverageWindow
Recalled int
// Truncated reports that the clock ran out mid-enumeration: what came back is real,
// what did not come back is unknown rather than absent.
Truncated bool
}
// Empty reports whether the enumeration found nothing worth a verdict.
func (s CoverageSet) Empty() bool { return len(s.Windows) == 0 }
// EnumerateCoverage asks the corpus about the actor and the act words and returns the
// windows where the deed is stated.
//
// ONE recall per OPERAND (the union of the actor's forms and the act words), then a TEXT
// test — a passage has to carry a declared act word, and one of the actor's forms when
// the actor was declared — which is the same set the per-pair patterns used to match,
// reached with a fraction of the searches (see Coverage.Operands).
//
// The evidence is admitted to the pool as it is found, so the enumeration is the whole of
// "检索充分": every member a verdict can name has its passage in hand before the question
// is asked, and no round has to re-enumerate to find it again.
func EnumerateCoverage(ctx context.Context, deps SearchDeps, cov Coverage, kb *Kbinfos) CoverageSet {
set := CoverageSet{Operands: cov.Operands()}
if len(set.Operands) == 0 {
return set
}
actors := cov.Actors()
byID := map[string]map[string]any{}
for _, operand := range set.Operands {
if ctx.Err() != nil {
// Out of clock: what was recalled is real evidence, what was not recalled is
// UNKNOWN, and the caller is told which (see CoverageSet.Truncated).
set.Truncated = true
break
}
// The recall's SECOND value is its doc aggregations, NOT an error: GrepSearch
// cannot fail, and DocAggs builds its slice with make() so it is non-nil whether or
// not anything came back. Read as an error, this branch fires on EVERY operand and
// discards each recall's passages as though the corpus had answered nothing — the
// opposite of what this set is for, and a seed that tells every session the
// direction is empty.
//
// A transport failure is therefore indistinguishable from an empty recall HERE;
// when that distinction is needed, GrepSearch has to grow an error. Until then the
// recall's result IS the evidence.
chunks, _ := GrepSearch(ctx, deps, SearchParams{
Question: operand,
Keywords: operand,
TopN: coverageRecallTopN,
KbIDs: deps.KbIDs,
// What is asked here is the ACTOR and the ACT WORDS, never a member's name,
// so the reach ledger must not read them as names a probe confirmed.
SkipReachLedger: true,
})
if len(chunks) >= coverageRecallTopN {
// The recall came back at its whole bound: what the corpus holds for this operand
// MAY be more than this, and nothing downstream can tell the difference — the member
// set would be the head of a ranking presented as a total. It is logged as a
// measurement so that "was the recall complete" is a fact in the run's own record
// instead of a guess (see CoverageSet.Truncated for the clock-and-cap case).
_LOG.Printf("[Coverage] operand %q: recall hit its bound (%d passage(s)) — the corpus may hold more", operand, coverageRecallTopN)
}
for _, c := range chunks {
if id := ChunkIDOf(c); id != "" {
if _, seen := byID[id]; !seen {
byID[id] = c
}
}
}
}
set.Recalled = len(byID)
// Sorted by chunk id: these windows are rendered into EVERY session's seed (and the
// cap below keeps the first N), so walking the map made both the seed's wording and
// which windows survived the cap differ from run to run.
ids := make([]string, 0, len(byID))
for id := range byID {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
c := byID[id]
text := strings.Join(strings.Fields(ChunkTextOf(c)), " ")
act := coverageFirstAct(text, cov.Acts)
if act == "" {
continue
}
if len(actors) > 0 && !coverageMentionsAny(text, actors) {
continue
}
if kb != nil {
kb.Admit(func(p *PoolAdmitter) { p.Add(c) })
}
for _, w := range coverageWindows(text, cov.Acts, coverageWindowsPerChunk) {
if len(set.Windows) >= coverageWindowsMax {
set.Truncated = true
break
}
set.Windows = append(set.Windows, CoverageWindow{ChunkID: ChunkIDOf(c), Quote: w.quote, Act: w.act})
}
}
return set
}
// CoverageActsMeetActor reports whether a passage ALREADY HELD carries both an act word and the
// actor — the conjunction every window the enumeration builds is filtered by (see EnumerateCoverage:
// a recalled passage is kept only when an act word is in it AND, when the direction declares an
// actor, the actor is named in it too).
//
// Read only by the last-resort enrollment (see enrollEnumeration), where the recall it would pay for
// comes out of the ANSWER's own clock: measured (2026-09-17, FRAMES, resolve node) 13 operands asked,
// 724 passages recalled, 0 windows found — the direction's words had never met in what the run held,
// so nothing a recall brought back could survive the filter.
//
// It is a probe, not a verdict: a recall may still return the first passage where they do meet, which
// is exactly what the enrollment is for. It answers only "have we ever seen these words together".
func CoverageActsMeetActor(kb *Kbinfos, cov Coverage) bool {
if kb == nil {
return false
}
actors := cov.Actors()
for _, c := range kb.Chunks {
text := ChunkTextOf(c)
if coverageFirstAct(text, cov.Acts) == "" {
continue
}
if len(actors) > 0 && !coverageMentionsAny(text, actors) {
continue
}
return true
}
return false
}
// Render is the seed text: the enumeration stated as a FACT with what came back.
//
// A list of queries in a prompt is advice, and advice may simply not be taken. A window is
// evidence: it carries the chunk id a verdict cites, so the session reads rather than
// guesses, and `(nothing)` is the corpus answering about these WORDS rather than nobody
// having looked.
func (s CoverageSet) Render() string {
var b strings.Builder
b.WriteString("## The enumeration already ran for this direction\n\n")
fmt.Fprintf(&b, "The corpus was asked about each operand below (%d operands, %d distinct passage(s)) — one search per operand. "+
"Every window is already in the pool under the chunk id shown, and a member named in a window is a member you can cite with that id.\n\n",
len(s.Operands), s.Recalled)
if len(s.Operands) > 0 {
// The operands are NAMED, not promised: the header said "below" while only the
// windows followed it, so a session could not tell an operand nobody searched
// from one the corpus came back empty on.
b.WriteString("- operands: " + strings.Join(s.Operands, " / ") + "\n\n")
}
if s.Empty() {
b.WriteString("(nothing)\n")
return b.String()
}
for _, w := range s.Windows {
line := fmt.Sprintf("- chunk_id=%s %q\n", w.ChunkID, w.Quote)
if b.Len()+len(line) > coverageSeedMaxChars {
b.WriteString("(further windows omitted; all of them are in the pool)\n")
break
}
b.WriteString(line)
}
if s.Truncated {
b.WriteString("(the enumeration ran out of clock; the corpus holds more than is shown here)\n")
}
return b.String()
}
// coverageWindows returns up to n act-centred windows of one passage, in order.
func coverageWindows(text string, acts []string, n int) []struct{ quote, act string } {
var out []struct{ quote, act string }
if n <= 0 {
return out
}
runes := []rune(text)
from := 0
for from < len(runes) && len(out) < n {
idx, length, act := coverageNextAct(runes, from, acts)
if idx < 0 {
break
}
lo := idx - coverageWindowBefore
if lo < 0 {
lo = 0
}
hi := idx + length + coverageWindowAfter
if hi > len(runes) {
hi = len(runes)
}
out = append(out, struct{ quote, act string }{quote: strings.TrimSpace(string(runes[lo:hi])), act: act})
from = idx + length
}
return out
}
// coverageNextAct finds the earliest declared act word at or after `from`, returning the
// rune index, its length and the word itself.
func coverageNextAct(runes []rune, from int, acts []string) (int, int, string) {
bestIdx, bestLen, bestAct := -1, 0, ""
for _, act := range acts {
needle := []rune(strings.TrimSpace(act))
if len(needle) == 0 {
continue
}
if idx := runeIndexFrom(runes, needle, from); idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
bestIdx, bestLen, bestAct = idx, len(needle), act
}
}
return bestIdx, bestLen, bestAct
}
// coverageFirstAct answers whether a passage states the deed at all.
func coverageFirstAct(text string, acts []string) string {
if _, _, act := coverageNextAct([]rune(text), 0, acts); act != "" {
return act
}
return ""
}
// coverageMentionsAny reports whether the passage carries one of the actor's forms.
func coverageMentionsAny(text string, forms []string) bool {
for _, f := range forms {
if strings.Contains(text, f) {
return true
}
}
return false
}
// runeIndexFrom is strings.Index over runes, starting at a rune index: the window
// arithmetic above is in runes, so a byte offset would not do, and the stdlib search
// beats a hand-written scan.
func runeIndexFrom(haystack, needle []rune, from int) int {
if from < 0 {
from = 0
}
if len(needle) == 0 || from >= len(haystack) {
return -1
}
tail := string(haystack[from:])
at := strings.Index(tail, string(needle))
if at < 0 {
return -1
}
return from + utf8.RuneCountInString(tail[:at])
}
@@ -0,0 +1,328 @@
//
// 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 runtime
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"github.com/cloudwego/eino/schema"
)
// CoverageResolvePrompt is the whole instruction of the enumeration's LAST node.
//
// It is written around the QUESTION rather than around one kind of answer: the same step
// runs on "which awards did X win" and on "who did X kill", so what a line has to do is
// state a member of the set the question asks for. Nothing here decides what counts as a
// member — that judgement stays the model's.
const CoverageResolvePrompt = "You are given numbered evidence lines from one fixed corpus, each with the chunk id it came from.\n" +
"For EVERY line decide one thing: does this line itself state a member of the set the question asks for — and if it does, what is that member called?\n" +
"Judge only what the line says, never what you know from elsewhere. A mention that is not a member (a plan, a promise, a denial, a pursuit, something that did not happen, somebody else's action, or a member the line does not name) is NOT one.\n" +
"Answer with JSON only, and put EVERY line number in exactly one of the two lists:\n" +
`{"members": [{"i": <line number>, "name": "<the member the line states>"}], "not_members": [<line numbers>]}` + "\n" +
"Use the line's own words for the name. Never invent a name, and never answer for a line you were not given."
// ResolvedMember is a member the last node named, with the line it was named from.
type ResolvedMember struct {
Name string
ChunkID string
Quote string
}
// ResolveStats is what one resolve produced, for the run log: how many calls it took,
// how many lines were put in front of the model, how many got a verdict, and how many
// would still be UNKNOWN if the answer were written now.
type ResolveStats struct {
Batches int
Asked int
Answered int
Unknown int
Failed int
// Unjudged are the chunk ids that got no verdict — the windows a failed call left, the lines
// the model skipped, the windows the cap kept out. The count alone says "the list may be
// short"; the ids say WHICH passages nobody read, which is what a reader can check.
Unjudged []string
}
// resolveJob is ONE window as a unit of work: the index it holds in the caller's window list
// (which is what a citation is resolved from) and the window itself.
type resolveJob struct {
idx int
w CoverageWindow
}
// batchResult is one CALL's outcome: the members it named, which of its jobs were judged (as
// a member or explicitly not), and whether the call failed. The indices are the CALLER's
// window indices, so a later pass can re-ask exactly the ones that got no verdict.
type batchResult struct {
members []ResolvedMember
judged []int
failed bool
}
// The resolve's bounds. A batch is small because ONE prompt carrying every candidate is the
// failure this node exists to remove: a single call that carries them all reaches its clock
// and answers nothing, losing a whole enumeration's worth of members to one timeout.
//
// These bound ONE budget, not three independent knobs: this node has to be able to judge
// coverageWindowsMax windows inside CoverageResolveTimeoutS, and its capacity is
// workers × (clock / the slowest call it can expect) × batch. At three workers and a
// five-second call that is 144 windows — BELOW the 160 the enumeration is allowed to hand it,
// i.e. a ceiling the point-of-naming step can never clear, which makes the member set a
// function of provider latency instead of a function of the corpus. Six workers clear the
// same cap with room to spare (6 × (30/5) × 8 = 288).
const (
coverageResolveBatch = 8
coverageResolveWorkers = 6
coverageResolveQuoteChars = 300
// coverageResolvePasses bounds the re-asking: the first pass puts every window in front of
// the model, and each pass after it re-asks ONLY the windows that got no verdict — a call
// that failed, or lines the reply left out. Work that already has a verdict is never sent
// again, so the set closes without paying twice for the windows that were answered.
coverageResolvePasses = 5
)
// ResolveCoverage is the enumeration's LAST node: every window gets a verdict, and the
// members are the lines that state one.
//
// Three things are deliberate:
//
// - EVERY window is asked about, in batches, in parallel. The node is not allowed to
// skip a candidate in silence: a batch that fails or a line the model does not answer
// is counted as UNKNOWN and reported, so "nobody looked" can never be read as "the
// corpus does not say it".
// - A member's citation is the LINE'S chunk id, never the reply's: the model only has
// to get a line number right, so a fabricated source cannot reach the record.
// - The member's NAME is kept as the model wrote it from the line, and members are
// deduped case-insensitively — one source spells one member several ways, and a set
// counts entities, not spellings.
func ResolveCoverage(ctx context.Context, model SessionModel, question string, cov Coverage, set CoverageSet) ([]ResolvedMember, ResolveStats) {
stats := ResolveStats{Asked: len(set.Windows)}
if model == nil || set.Empty() {
stats.Unknown = stats.Asked
return nil, stats
}
windows := set.Windows
// Windows beyond the cap are never put in front of the model, so they are
// UNKNOWN for the same reason a failed batch is: nobody judged them.
dropped := 0
if len(windows) > coverageWindowsMax {
dropped = len(windows) - coverageWindowsMax
windows = windows[:coverageWindowsMax]
}
stats.Asked = len(windows)
// EVERY window is a unit of work that must end with exactly one verdict, so the set is
// CLOSED: the first pass asks about all of them, and each pass after it re-asks only the
// ones that got none — a call that failed, or lines the reply left out. Answering is what
// makes a member set a function of the corpus instead of a function of which call happened
// to come back in time.
pending := make([]resolveJob, 0, len(windows))
for i, w := range windows {
pending = append(pending, resolveJob{idx: i, w: w})
}
judged := make([]bool, len(windows))
seen := map[string]int{}
var members []ResolvedMember
for pass := 0; pass < coverageResolvePasses && len(pending) > 0; pass++ {
if ctx.Err() != nil {
break
}
out := make([]batchResult, (len(pending)+coverageResolveBatch-1)/coverageResolveBatch)
sem := make(chan struct{}, coverageResolveWorkers)
var wg sync.WaitGroup
for b := range out {
lo := b * coverageResolveBatch
hi := min(lo+coverageResolveBatch, len(pending))
part := pending[lo:hi]
wg.Add(1)
go func(b int, part []resolveJob) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
out[b] = resolveCoverageBatch(ctx, model, question, cov, part)
}(b, part)
}
wg.Wait()
for _, r := range out {
stats.Batches++
if r.failed {
stats.Failed++
}
for _, idx := range r.judged {
if !judged[idx] {
judged[idx] = true
stats.Answered++
}
}
for _, m := range r.members {
key := strings.ToLower(strings.TrimSpace(m.Name))
if key == "" {
continue
}
if i, dup := seen[key]; dup {
// The member is already known; a second passage is worth keeping when the
// first came without one (evidence is what makes a member answerable).
if members[i].ChunkID == "" && m.ChunkID != "" {
members[i] = m
}
continue
}
seen[key] = len(members)
members = append(members, m)
}
}
next := pending[:0:0]
for _, j := range pending {
if !judged[j.idx] {
next = append(next, j)
}
}
pending = next
}
// Every window is UNKNOWN unless a verdict named it: the windows a failed call left, the
// lines the model skipped, and the windows the cap kept out.
stats.Unknown = dropped + (stats.Asked - stats.Answered)
for i, w := range windows {
if !judged[i] {
stats.Unjudged = appendUnique(stats.Unjudged, w.ChunkID)
}
}
if len(set.Windows) > coverageWindowsMax {
for _, w := range set.Windows[coverageWindowsMax:] {
stats.Unjudged = appendUnique(stats.Unjudged, w.ChunkID)
}
}
return members, stats
}
// resolveCoverageBatch puts ONE batch of jobs in front of the model and returns the members
// it named out of that batch. The verdicts it reports are keyed by the CALLER's window
// indices, so the caller can re-ask exactly the jobs that came back with none.
func resolveCoverageBatch(ctx context.Context, model SessionModel, question string, cov Coverage, part []resolveJob) batchResult {
res := batchResult{}
if ctx.Err() != nil {
return res
}
reply, err := model.Complete(ctx, []schema.Message{
*schema.SystemMessage(CoverageResolvePrompt),
*schema.UserMessage(coverageResolveQuestion(question, cov, part)),
}, nil)
if err != nil || reply == nil {
res.failed = true
return res
}
obj := coverageJSONObject(reply.Content)
if obj == nil {
res.failed = true
return res
}
for _, raw := range coverageAnyList(obj["members"]) {
entry, _ := raw.(map[string]any)
if entry == nil {
continue
}
i, ok := coverageInt(entry["i"])
name := strings.TrimSpace(anyString(entry["name"]))
if !ok || name == "" || i < 0 || i >= len(part) {
continue
}
res.judged = append(res.judged, part[i].idx)
res.members = append(res.members, ResolvedMember{
Name: name,
ChunkID: part[i].w.ChunkID,
Quote: truncateRunes(part[i].w.Quote, coverageResolveQuoteChars),
})
}
for _, raw := range coverageAnyList(obj["not_members"]) {
if i, ok := coverageInt(raw); ok && i >= 0 && i < len(part) {
res.judged = append(res.judged, part[i].idx)
}
}
return res
}
// coverageResolveQuestion renders one call's user message: the question, the actor's
// declared forms, and this batch's windows on their own numbered lines. The numbering is
// BATCH-LOCAL and starts at 0 in every call — that is the whole interface the model has to
// get right, and the caller maps a line number back to its window.
func coverageResolveQuestion(question string, cov Coverage, part []resolveJob) string {
var b strings.Builder
b.WriteString("Question: " + question + "\n")
if actors := cov.Actors(); len(actors) > 0 {
b.WriteString("Actor (the forms this direction named): " + strings.Join(actors, " / ") + "\n")
}
if len(cov.Acts) > 0 {
b.WriteString("Act words this direction declared: " + strings.Join(cov.Acts, " / ") + "\n")
}
b.WriteString("Evidence lines:\n")
for i, j := range part {
w := j.w
quote := truncateRunes(w.Quote, coverageResolveQuoteChars)
b.WriteString("- [" + strconv.Itoa(i) + "] chunk_id=" + w.ChunkID + " \"" + quote + "\"\n")
}
b.WriteString("\nAnswer with the JSON object and nothing else.")
return b.String()
}
// coverageJSONObject reads the JSON object out of a reply, which models wrap in thinking
// preamble and Markdown fences.
func coverageJSONObject(content string) map[string]any {
text := reFence.ReplaceAllString(content, "")
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start < 0 || end <= start {
return nil
}
var obj map[string]any
if err := json.Unmarshal([]byte(text[start:end+1]), &obj); err != nil {
return nil
}
return obj
}
// coverageAnyList reads a JSON list out of a decoded object. `encoding/json` decodes every
// JSON array into []any (a []map[string]any case here was unreachable), so anything else is
// treated as "no list" rather than guessed at.
func coverageAnyList(v any) []any {
if list, ok := v.([]any); ok {
return list
}
return nil
}
// coverageInt reads a line number, which a model may hand back as a number or a string.
func coverageInt(v any) (int, bool) {
switch t := v.(type) {
case float64:
return int(t), t == float64(int(t))
case int:
return t, true
case string:
var n int
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%d", &n); err == nil {
return n, true
}
}
return 0, false
}
@@ -0,0 +1,285 @@
//
// 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 runtime
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"github.com/cloudwego/eino/schema"
"ragflow/internal/rag/agentic-rag/slots"
)
// TestCoverageWindowsCentreOnTheAct pins the unit a verdict is asked about: the words
// around the deed, not the passage.
//
// One passage states the deed several times (a chapter lists several victims), and a
// window per occurrence is what lets each victim be named from its own words — the
// alternative, one passage per verdict, is how a three-kill chapter came back as one
// member.
func TestCoverageWindowsCentreOnTheAct(t *testing.T) {
text := "关公马快,赶上文丑,脑后一刀,将文丑斩下马来。云长手起刀落,斩孔秀于马下。"
got := coverageWindows(text, []string{"斩"}, 3)
if len(got) != 2 {
t.Fatalf("windows = %d, want one per occurrence of the act (%v)", len(got), got)
}
if !strings.Contains(got[0].quote, "斩下马来") || !strings.Contains(got[1].quote, "斩孔秀于马下") {
t.Errorf("windows = %v, want each centred on its own act", got)
}
if got[0].act != "斩" || got[1].act != "斩" {
t.Errorf("acts = %q/%q, want the act word that was found", got[0].act, got[1].act)
}
// The cap is the caller's: a chapter that states the deed nine times contributes the
// first few windows, and the pass-through count says the corpus holds more.
if got := coverageWindows(text, []string{"斩"}, 1); len(got) != 1 {
t.Errorf("windows = %d, want the cap respected", len(got))
}
// A passage that does not state the deed contributes nothing.
if got := coverageWindows("话说天下大势,分久必合。", []string{"斩", "杀"}, 3); len(got) != 0 {
t.Errorf("windows = %v, want nothing from a passage without the act", got)
}
if act := coverageFirstAct("云长提华雄之头,掷于地上。", []string{"斩", "杀"}); act != "" {
t.Errorf("act = %q, want no act in a passage that only reports the aftermath", act)
}
}
// TestResolveCoverageAsksEveryWindowInBatches pins the enumeration's LAST node: every
// window is asked about, a failed batch is counted as UNKNOWN rather than read as an
// absence, a member's citation comes from the LINE, and one source's two spellings of one
// entity are one member.
//
// The single call it replaces loses a whole enumeration's members to one timeout: every
// candidate in one prompt, one clock shared by all of them.
func TestResolveCoverageAsksEveryWindowInBatches(t *testing.T) {
set := CoverageSet{}
for i := 0; i < 9; i++ {
set.Windows = append(set.Windows, CoverageWindow{ChunkID: fmt.Sprintf("c%d", i), Quote: "云长斩之", Act: "斩"})
}
model := &stubCoverageModel{reply: func(user string) string {
// The first batch answers, the second fails: the difference must show up as UNKNOWN.
if strings.Contains(user, "chunk_id=c8") {
return ""
}
return `{"members": [{"i": 0, "name": "孔秀"}, {"i": 1, "name": "mona lisa"}, {"i": 2, "name": "Mona Lisa"}], "not_members": [3]}`
}}
model.failIf = func(user string) bool { return strings.Contains(user, "chunk_id=c8") }
members, stats := ResolveCoverage(context.Background(), model, "关羽杀了多少有姓名的人物?", Coverage{Actor: "关羽"}, set)
// 2 batches carry the first pass (9 windows, batch of 8), and the 5 windows left without a
// verdict are re-asked once per remaining pass (see coverageResolvePasses).
if want := 2 + (coverageResolvePasses - 1); stats.Batches != want || stats.Asked != 9 {
t.Fatalf("stats = %+v, want %d batch(es): 9 windows asked about, and the 5 that got no verdict asked again", stats, want)
}
// WHICH passages nobody read travels with the count: a reader can check the ids.
if got := strings.Join(stats.Unjudged, ","); got != "c4,c5,c6,c7,c8" {
t.Fatalf("unjudged = %q, want the ids of the windows that got no verdict", got)
}
// 4 of the 9 windows got a verdict (3 members + 1 not-a-member); the failed batch's
// window AND the four lines the model skipped stay UNKNOWN, not absent. The re-ask puts
// them in front of the model again, but a call that fails every time cannot judge them —
// so the budget has to be the caller's to report, which is what the counters are for.
// One failed call per pass over the batch that always fails (the first pass's batch plus one
// per re-ask pass), so the counter tracks CALLS, as its contract says.
if stats.Failed != coverageResolvePasses || stats.Answered != 4 || stats.Unknown != 5 {
t.Fatalf("stats = %+v, want the failed batch and the skipped lines UNKNOWN, not absent", stats)
}
if len(members) != 2 {
t.Fatalf("members = %+v, want the three named lines deduped to two entities", members)
}
if members[0].Name != "孔秀" || members[0].ChunkID != "c0" {
t.Errorf("member = %+v, want the line's own chunk id as its citation", members[0])
}
if strings.ToLower(members[1].Name) != "mona lisa" {
t.Errorf("member = %+v, want one member for two spellings", members[1])
}
}
// TestResolveCoverageReasksTheWindowsWithoutAVerdict pins the CLOSURE of the point-of-naming
// step: a line the reply left out is asked again, and work that already has a verdict is
// spared.
//
// The member set has to be a function of the corpus, not of which call came back. A reply that
// answers some of its lines and omits the rest leaves those members unjudged, and a pass that
// stopped there left windows that were RECALLED and SHOWN ending up in no list at all — the
// count then moves with the provider's latency rather than with the source.
func TestResolveCoverageReasksTheWindowsWithoutAVerdict(t *testing.T) {
set := CoverageSet{}
for i := 0; i < 3; i++ {
set.Windows = append(set.Windows, CoverageWindow{ChunkID: fmt.Sprintf("c%d", i), Quote: "云长斩之", Act: "斩"})
}
calls := 0
model := &stubCoverageModel{reply: func(string) string {
calls++
if calls == 1 {
// The first call answers ONE of its three lines and omits the other two.
return `{"members": [{"i": 0, "name": "孔秀"}], "not_members": []}`
}
// The line numbers are BATCH-LOCAL: this call carries the two lines nobody judged.
return `{"members": [], "not_members": [0, 1]}`
}}
members, stats := ResolveCoverage(context.Background(), model, "关羽杀了多少有姓名的人物?", Coverage{Actor: "关羽"}, set)
if stats.Asked != 3 || stats.Answered != 3 || stats.Unknown != 0 {
t.Fatalf("stats = %+v, want every window judged after the re-ask", stats)
}
if stats.Batches != 2 {
t.Errorf("Batches = %d, want the two unjudged lines asked again in a second call", stats.Batches)
}
if len(members) != 1 || members[0].Name != "孔秀" || members[0].ChunkID != "c0" {
t.Errorf("members = %+v, want the one member the first call named, cited by its line", members)
}
}
// TestResolveCoverageWithoutAWindowCostsNothing pins the cheap path: an enumeration that
// found nothing asks nothing.
func TestResolveCoverageWithoutAWindowCostsNothing(t *testing.T) {
model := &stubCoverageModel{reply: func(string) string { return "{}" }}
members, stats := ResolveCoverage(context.Background(), model, "q", Coverage{}, CoverageSet{})
if len(members) != 0 || stats.Asked != 0 || stats.Batches != 0 {
t.Errorf("members=%v stats=%+v, want no call and nothing named", members, stats)
}
if len(model.prompts) != 0 {
t.Errorf("model was asked %d time(s), want none", len(model.prompts))
}
}
// stubCoverageModel is a SessionModel that answers with a canned reply, and can fail one
// batch so the UNKNOWN path is exercised.
type stubCoverageModel struct {
mu sync.Mutex
prompts []string
failIf func(user string) bool
reply func(user string) string
}
func (m *stubCoverageModel) Complete(_ context.Context, messages []schema.Message, _ []ToolSpec) (*ModelReply, error) {
user := ""
if len(messages) > 0 {
user = messages[len(messages)-1].Content
}
m.mu.Lock()
m.prompts = append(m.prompts, user)
m.mu.Unlock()
if m.failIf != nil && m.failIf(user) {
return nil, fmt.Errorf("stub: transport failed")
}
return &ModelReply{Content: m.reply(user)}, nil
}
// TestEnumerateCoverageKeepsWhatTheRecallReturned pins the enumeration's recall loop: a
// passage the recall came back with IS the evidence, and nothing here is a clock
// exhaustion.
//
// GrepSearch's second value is its DOC AGGREGATIONS, not an error, and DocAggs builds that
// slice with make() — so it is non-nil whether or not anything came back. Read as an error,
// TestCoverageActsMeetActorPinsTheEnumerationFilter pins the probe against the SAME conjunction the
// enumeration's window builder uses: an act word AND, when the direction names one, the actor.
func TestCoverageActsMeetActorPinsTheEnumerationFilter(t *testing.T) {
kb := &Kbinfos{}
kb.Admit(func(p *PoolAdmitter) {
p.Add(map[string]any{"chunk_id": "c1", "content_with_weight": "Colin Beashel sailed the Soling class."})
p.Add(map[string]any{"chunk_id": "c2", "content_with_weight": "Their partner was crewing that year."})
})
cov := Coverage{Acts: []string{"crewing"}}
if !CoverageActsMeetActor(kb, cov) {
t.Fatal("want true: an act word is held and no actor was declared")
}
withActor := cov
withActor.Actor = "Colin Beashel"
if CoverageActsMeetActor(kb, withActor) {
t.Fatal("want false: the two words sit in DIFFERENT passages, so no window could carry both")
}
kb.Admit(func(p *PoolAdmitter) {
p.Add(map[string]any{"chunk_id": "c3", "content_with_weight": "Colin Beashel was crewing the Soling."})
})
if !CoverageActsMeetActor(kb, withActor) {
t.Fatal("want true once ONE passage carries both")
}
}
// TestCoverageOfKeepsTheDeclaredReadingNarrow pins the split the FRAMES regression forced: the value
// may declare the direction to the node that runs LAST, and to nothing else.
//
// Set is what the planner said, and two readers depend on that being all it is — the record's set
// block and a session's parentSet. Read from the value instead, a table whose slots happen to hold
// people reads as an enumeration: measured (2026-09-17, FRAMES q759) a one-person question's record
// lost its own draft answer and gained a member count to state.
func TestCoverageOfKeepsTheDeclaredReadingNarrow(t *testing.T) {
items := slots.Items(slots.Item{Value: "Lanee Butler", ChunkID: "c1", Quote: "Mistral (sailboard) | Lanee Butler"})
valueOnly := NewState([]Variable{
{ID: 0, Type: "person", Terms: []string{"斩"}, Value: &items},
}, 0, nil)
cov := CoverageOf(valueOnly)
if cov.Set || cov.Ok() {
t.Fatalf("set=%v ok=%v, want the DECLARED reading still false for a person slot", cov.Set, cov.Ok())
}
if !cov.ItemsInValue() {
t.Fatal("itemsInValue = false, want the value that holds items readable by the node with no plan-time word")
}
if cov.ItemKind != "person" {
t.Fatalf("itemKind = %q, want the planner's word kept — the value only names the kind when the type did not", cov.ItemKind)
}
// A slot whose type names no kind still gets one from the value.
nameless := NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Value: &items},
}, 0, nil)
if cov := CoverageOf(nameless); cov.ItemKind != CoverageItemKindItems || !cov.ItemsInValue() {
t.Fatalf("cov = %+v, want the value to name the kind when the type did not", cov)
}
// A declared shape is untouched: the planner's word still opens the gate.
declared := NewState([]Variable{
{ID: 0, Type: "list", Terms: []string{"斩"}},
}, 0, nil)
if cov := CoverageOf(declared); !cov.Ok() || cov.ItemsInValue() {
t.Fatalf("cov = %+v, want a declared list slot to open the gate on the declaration alone", cov)
}
}
// the branch fires on every operand: Truncated is set, every recall's passages are dropped,
// and the rendered seed tells each session the direction came back empty. Nothing caught
// that until this test, because the loop had no test of its own.
func TestEnumerateCoverageKeepsWhatTheRecallReturned(t *testing.T) {
deps, kb := newTestSearchDeps(&stubRetriever{chunks: []map[string]any{
{"chunk_id": "c1", "content": "关羽手起刀落,斩孔秀于马下。", "doc_id": "d1"},
}})
cov := Coverage{Actor: "关羽|关公", Acts: []string{"斩"}, Set: true, ItemKind: "person"}
if !cov.Ok() {
t.Fatalf("the fixture must be an enumeration: %+v", cov)
}
set := EnumerateCoverage(context.Background(), deps, cov, kb)
if set.Recalled == 0 {
t.Fatalf("Recalled = 0 over %d operand(s): the recalled passage(s) were dropped", len(set.Operands))
}
if len(set.Windows) == 0 {
t.Fatalf("Windows = 0: a passage naming the actor and the act word is a window (%+v)", set)
}
if set.Truncated {
t.Error("Truncated = true: nothing here ran out of clock, and saying so seeds every session with (nothing)")
}
if !strings.Contains(set.Windows[0].Quote, "斩孔秀") {
t.Errorf("window = %q, want the words around the act", set.Windows[0].Quote)
}
if !strings.Contains(set.Render(), "chunk_id=c1") {
t.Errorf("seed = %q, want the window's chunk id (a member is citable only through it)", set.Render())
}
}
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -24,20 +24,20 @@ import (
)
const (
// docFetchPageSize is Python's 128-chunk page.
// docFetchPageSize is the 128-chunk page size.
docFetchPageSize = 128
// docFetchMaxChunks is Python's hard 10000-chunk cap (max_count > 10000).
// docFetchMaxChunks is the hard 10000-chunk cap.
docFetchMaxChunks = 10000
// docFetchFallbackTokens bounds the fetch when the caller supplies no model
// window. Python always passes one, so this only guards a Go-only caller.
// window. Callers usually pass one, so this only guards a caller that does not.
docFetchFallbackTokens = 8192
// estimateCharsPerToken approximates the tokenizer when no model encoder is
// loaded; estimateTokens falls back to it only when tokenizer.NumTokensFromString
// returns 0 (encoder unavailable). Python uses num_tokens_from_string directly.
// returns 0 (encoder unavailable).
estimateCharsPerToken = 4
)
// fetchFullDocument mirrors Python RAGTools.fetch_full_document
// fetchFullDocument
// (agentic_rag.py:fetch_full_document): read a document end-to-end in reading order, in pages,
// stopping before the model window would overflow.
//
@@ -48,12 +48,12 @@ func fetchFullDocument(ctx context.Context, deps SearchDeps, docID string, maxTo
_LOG.Printf("[Fetch full document] skipped (doc_id=%q, datasets=%d)", docID, len(deps.KbIDs))
return nil, nil
}
// Python :765 — a session-wide document scope is authoritative.
// a session-wide document scope is authoritative.
if len(deps.DocScope) > 0 && !containsStr(deps.DocScope, docID) {
_LOG.Printf("[Fetch full document] doc_id %q is outside the session document scope", docID)
return nil, nil
}
// Python :768 — never read a document that is not in the bound datasets.
// never read a document that is not in the bound datasets.
if belongs, verified := docInDatasets(ctx, deps, docID); verified && !belongs {
_LOG.Printf("[Fetch full document] doc_id %q is not in any bound dataset — refusing to fetch", docID)
return nil, nil
@@ -67,7 +67,7 @@ func fetchFullDocument(ctx context.Context, deps SearchDeps, docID string, maxTo
var chunks []map[string]any
tokens := 0
budgetHit := false
// Python :769-776 — NOTE: the budget check breaks the OUTER loop, so a page
// NOTE: the budget check breaks the OUTER loop, so a page
// that overruns the window stops paging entirely. Kept as-is: the budget is
// a hard stop, and continuing would only add chunks that get dropped.
for offset := 0; offset < docFetchMaxChunks && !budgetHit; offset += docFetchPageSize {
@@ -116,7 +116,7 @@ func fetchFullDocument(ctx context.Context, deps SearchDeps, docID string, maxTo
return chunks, aggs
}
// summarizeDocument mirrors Python RAGTools.summarize_document
// summarizeDocument
// (agentic_rag.py:rag): load the whole document, fold it into the evidence the
// citation rules refer to, and return the newly rendered blocks.
func summarizeDocument(ctx context.Context, deps SearchDeps, docID string, maxTokens int) []string {
@@ -129,15 +129,15 @@ func summarizeDocument(ctx context.Context, deps SearchDeps, docID string, maxTo
budget = docFetchFallbackTokens
}
// Python :942-948 — the document becomes part of the evidence set, so its
// the document becomes part of the evidence set, so its
// [ID]s stay citable; only the blocks of the chunks read here are returned.
//
// The blocks are picked by the SOURCE CHUNK, not by the pre-merge chunk
// count: KBPrompt skips a chunk with no content and stops when the token
// budget is exhausted, so block N is not chunk N — a slice taken at the
// pre-merge count can drop readable blocks, or point past the end and return
// nil for a document that was read fine (Python's `blocks[start_idx:]` has the
// same flaw; Go hits it more often because Merge deduplicates a document
// nil for a document that was read fine — and this is easy to hit, because Merge
// deduplicates a document
// chunk that is already pooled, so the pre-merge count can even equal the
// post-merge count). Merge reports the pool position of every chunk this
// fetch contributed — deduplicated ones included — which makes the mapping
@@ -180,26 +180,25 @@ func summarizeDocument(ctx context.Context, deps SearchDeps, docID string, maxTo
return nil
}
// Python :952-959 — without do_refer the model is told not to cite, so the
// without do_refer the model is told not to cite, so the
// rules must not be handed to it.
if !deps.DoRefer {
return fresh
}
// The blocks are numbered by pool position: were this path ever reached with
// do_refer=true, the rules would need the same 0-based sentence the compose adds
// (advanced_rag.zeroBasedEvidenceRule) — CitationPrompt itself cannot carry it,
// (agentic_rag.zeroBasedEvidenceRule) — CitationPrompt itself cannot carry it,
// the canvas renders hash ids.
header := "# Citation rules\nApply the following rules VERBATIM to your final answer.\n\n" +
prompts.CitationPrompt(deps.CiteRules) + "\n\n----\n\n"
return append([]string{header}, fresh...)
}
// SummarizeDocument is the exported, Python-RAGTools.summarize_document
// equivalent used by the outer react loop (rag_agent) as a non-terminal tool.
// It reads the whole document identified by docID into the evidence set and
// returns the prompt blocks to feed back to the model. do_refer is taken from
// deps.DoRefer (Python lets the RAGTools context decide whether the model may
// cite the freshly read document), so callers control citation behavior.
// SummarizeDocument is the exported summarize_document tool used by the outer react loop
// (rag_agent) as a non-terminal tool. It reads the whole document identified by docID into the
// evidence set and returns the prompt blocks to feed back to the model. do_refer is taken from
// deps.DoRefer (the run config decides whether the model may cite the freshly read document),
// so callers control citation behavior.
// Returns nil when the document has no readable chunks (deps.DocChunks unset
// or the doc is unavailable).
func SummarizeDocument(ctx context.Context, deps SearchDeps, docID string, maxTokens int) []string {
@@ -210,8 +209,7 @@ func SummarizeDocument(ctx context.Context, deps SearchDeps, docID string, maxTo
}
// estimateTokens returns the token count of s. It prefers the precise
// tokenizer (tokenizer.NumTokensFromString, mirroring Python's
// num_tokens_from_string); when no encoder is loaded that returns 0, so we
// tokenizer (tokenizer.NumTokensFromString); when no encoder is loaded that returns 0, so we
// fall back to a character/estimateCharsPerToken approximation for a rough
// budget, like the previous Go-only behavior.
func estimateTokens(s string) int {
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"fmt"
@@ -27,8 +27,6 @@ import (
// In-memory grep+sed narrowing engine (term-driven, zero extra LLM rounds).
//
// Mirrors Python harness/grep_sed_narrow.py.
//
// It mirrors the Claude Code / Codex ``grep`` + ``sed`` workflow over chunks held in
// memory: terms already produced by the main-analysis LLM (entities, numbers, key
// phrases) become word-boundary regexes for locating (grep), and string transforms
@@ -46,28 +44,25 @@ import (
// Cost / safety caps. Mirrors grep_sed_narrow.py's module constants.
const (
// maxGrepTerms caps the terms compiled (Python _MAX_GREP_TERMS).
// maxGrepTerms caps the terms compiled.
maxGrepTerms = 16
// maxContext is the +/- window of lines kept around a hit (Python _MAX_CONTEXT).
// maxContext is the +/- window of lines kept around a hit.
maxContext = 2
// defaultOutCharsPerChunk caps output per chunk (Python _DEFAULT_OUT_CHARS_PER_CHUNK).
// defaultOutCharsPerChunk caps output per chunk.
defaultOutCharsPerChunk = 1200
// defaultOutTotalChars caps the total narrowed output (Python _DEFAULT_OUT_TOTAL_CHARS).
// defaultOutTotalChars caps the total narrowed output.
defaultOutTotalChars = 16000
// headFallbackChars is the head kept per chunk when there is no match
// (Python _HEAD_FALLBACK_CHARS).
// headFallbackChars is the head kept per chunk when there is no match.
headFallbackChars = 400
// contextCharBudget is the absolute per-side char budget during context
// expansion (Python _CONTEXT_CHAR_BUDGET).
// contextCharBudget is the absolute per-side char budget during context expansion.
contextCharBudget = 600
// minNarrowChars: chunks at or below this length are NOT narrowed — they are
// already 1-2 lines, and answers often live in short chunks (Python _MIN_NARROW_CHARS).
// minNarrowChars: chunks at or below this length are NOT narrowed — they are already
// 1-2 lines, and answers often live in short chunks.
minNarrowChars = 200
)
var (
// reTermEdgePunct strips leading/trailing punctuation from a term (Python
// `re.sub(r"^[\s.,:;!?'\"()\[\]{}]+|...$", "", t)`).
// reTermEdgePunct strips leading/trailing punctuation from a term.
reTermEdgePunct = regexp.MustCompile(`^[\s.,:;!?'"()\[\]{}]+|[\s.,:;!?'"()\[\]{}]+$`)
// reCJKTerm detects CJK / kana / hangul, which must NOT be wrapped in \b —
// a word boundary never matches between CJK characters.
@@ -75,8 +70,8 @@ var (
)
// EscapeTerm escapes a plain grep term into a safe, word-boundary regex fragment.
// Mirrors Python _escape_term: numbers/entities matched literally; 3+ char terms
// with alphanumeric edges get \b; CJK terms stay bare (never wrapped in \b).
// Numbers/entities are matched literally; 3+ char terms with alphanumeric edges get \b;
// CJK terms stay bare (never wrapped in \b).
func EscapeTerm(term string) string {
t := strings.TrimSpace(term)
if t == "" {
@@ -102,7 +97,7 @@ func isAlphaNumRune(r rune) bool {
}
// TermsToPatterns turns grep terms into a list of compiled regexes (one per
// term), capped at maxGrepTerms. Mirrors Python _terms_to_patterns.
// term), capped at maxGrepTerms.
func TermsToPatterns(terms []string) []*regexp.Regexp {
out := make([]*regexp.Regexp, 0, len(terms))
for _, term := range terms {
@@ -122,7 +117,7 @@ func TermsToPatterns(terms []string) []*regexp.Regexp {
return out
}
// GrepTermsMax caps the terms extracted from a query (Python _GREP_TERMS_MAX).
// GrepTermsMax caps the terms extracted from a query.
const GrepTermsMax = 10
// cjkPhraseRunes is the length at which a CJK token stops being a term and
@@ -130,32 +125,28 @@ const GrepTermsMax = 10
// name that is still read as one token (成吉思汗), so a longer run is prose.
const cjkPhraseRunes = 4
// GrepOutCharsPerChunk is the grep narrow's per-chunk output cap (Python
// _GREP_OUT_CHARS_PER_CHUNK).
// GrepOutCharsPerChunk is the grep narrow's per-chunk output cap.
const GrepOutCharsPerChunk = 700
// GrepOutTotalChars is the grep narrow's total output cap (Python
// _GREP_OUT_TOTAL_CHARS).
// GrepOutTotalChars is the grep narrow's total output cap.
const GrepOutTotalChars = 8000
// GrepTermsFromQuery mirrors Python _grep_terms_from_query — bare alnum words of
// GrepTermsFromQuery: bare alnum words of
// length>=2, deduped (order-preserving) and capped — and extends it to CJK.
//
// The Python original is ALNUM-ONLY, so a Chinese query yields no term at all and
// GrepSearch's own guard (`if not chunks or not terms: return res`) returns whole
// chunks instead of located windows. Measured on a Chinese question: the
// "Keyword-first locate" line was logged, a narrowed line never was, and every
// hit was a ~1200-char chunk. That is expensive (a name lives in one clause of
// those 1200 chars) and it is what made an enumeration unreadable to the model,
// An ALNUM-ONLY tokenizer yields no term at all for a Chinese query, and the grep leg's
// own guard returns whole chunks instead of located windows: the locate line is logged, a
// narrowed line never is, and every hit is a whole chunk. That is expensive (a name lives
// in one clause of a chunk) and it is what makes an enumeration unreadable to the model,
// which cannot see WHICH clause a name sits in.
//
// The derivation keeps Python's shape and adds CJK, with no regex:
// The derivation adds CJK support, with no regex:
//
// - an ALTERNATION is the caller's own term list — "颜良|文丑|荀正" is how the
// count protocol tells a session to batch its probes — so it is split on "|"
// and its pieces are kept at any length ("关羽|斩": the predicate is a term);
// - an ALTERNATION is the caller's own term list — it is how the count protocol
// tells a session to batch its probes — so it is split on "|" and its pieces are
// kept at any length (a predicate is a term too);
// - otherwise the query is split on whitespace and punctuation, Latin tokens
// keep Python's two-character floor, and CJK tokens keep a two-CJK-rune
// keep the two-character floor, and CJK tokens keep a two-CJK-rune
// floor (a lone Chinese character is a particle, not a term);
// - an unbroken CJK clause (a question written without separators) yields no
// token either way, so its two-rune windows are used, left to right: the
@@ -168,8 +159,8 @@ func GrepTermsFromQuery(query string) []string {
// probeItemsOf returns the terms a call proposed AS ITEMS — a string the model
// wrote to ask about one individual.
//
// Two shapes qualify: the pieces of a batch (`关羽 斩 华雄 颜良`), and a query that
// IS one word (`韩福`). Everything else is prose the model wrote to ask a
// Two shapes qualify: the pieces of a whitespace-separated batch, and a query that
// IS one word. Everything else is prose the model wrote to ask a
// question, whose words belong to the question rather than to a member list; and
// the two-rune windows an unbroken clause decomposes into are our guesses, not the
// caller's words at all (see GrepWordsFromQuery).
@@ -177,9 +168,8 @@ func GrepTermsFromQuery(query string) []string {
// The distinction matters at the reach ledger. The ledger is read back as the
// session's to-do list — "you probed this, it came back with a passage, it is in
// no slot" — and a to-do list built from a question's words tells the model
// nothing. Measured (2026-09-15): that line read `FOUND BUT NOT RECORDED=三国、
// 演义、关羽、五关…+15` in a run whose sessions were missing six members, none of
// which was on it, while the window fragments had cost a retrieval each.
// nothing: its entries are the question's own words, none of them the members the
// sessions are missing, while the window fragments cost a retrieval each.
func probeItemsOf(queries []string) map[string]bool {
out := make(map[string]bool)
for _, q := range queries {
@@ -208,10 +198,8 @@ func probeItemsOf(queries []string) map[string]bool {
// The windows exist to LOCATE a term inside an unbroken CJK clause — the thing
// grep is for, and a failed lookup there costs nothing. As terms to SEARCH FOR,
// they are our guesses rather than the caller's words, and each one would spend a
// retrieval of its own. Measured (2026-09-15): a pass built from windows probed
// 羽斩 / 杀的 / 的有 / 领名 / 单温 and reported 14 of 20 probes "absent" — fourteen
// retrievals spent on fragments nobody asked about, while every name the question
// was missing stayed out of the list entirely.
// retrieval of its own — retrievals spent on fragments nobody asked about, while the
// names the question is missing stay out of the list entirely.
//
// So a caller that READS these (the named-term seat pass) uses the words; a
// caller that LOCATES with them uses the terms.
@@ -296,15 +284,15 @@ func grepTermsFromQuery(query string, windows bool) []string {
return cjkWindowsOf(q, GrepTermsMax)
}
// trimTermEdges strips the punctuation a token can carry instead of the regex
// Python uses for the same job (reTermEdgePunct).
// trimTermEdges strips the punctuation a token can carry (the regex equivalent is
// reTermEdgePunct).
func trimTermEdges(t string) string {
return strings.Trim(t, " \t\r\n.,:;!?'\"()[]{}<>“”‘’()【】《》「」〈〉—…·_-")
}
// isTermSeparator reports whether a rune separates terms. "." and "," do;
// "-", "_" and "." INSIDE a Latin token do not (Python keeps
// [A-Za-z0-9_.-] as token material), so they are not listed here.
// "-", "_" and "." INSIDE a Latin token do not ([A-Za-z0-9_.-] is token material), so they
// are not listed here.
func isTermSeparator(r rune) bool {
switch r {
case ' ', '\t', '\r', '\n', '\v', '\f',
@@ -373,9 +361,9 @@ func cjkWindowsOf(query string, limit int) []string {
return out
}
// lineSpans returns line (start,end) spans, boundaries at "\n" (grep semantics).
// Mirrors Python _line_spans: line boundaries are exact (unlike lossy sentence
// splitting); start of line i is after the i-th "\n".
// lineSpans returns line (start,end) spans, boundaries at "\n" (grep semantics): line
// boundaries are exact (unlike lossy sentence splitting); start of line i is after the
// i-th "\n".
func lineSpans(content string) [][2]int {
var spans [][2]int
start := 0
@@ -394,15 +382,14 @@ func lineSpans(content string) [][2]int {
return spans
}
// NarrowContext is the +/- line-context window greed by term-grep (Python
// narrow_by_terms' `context` dict).
// NarrowContext is the +/- line-context window used by term-grep.
type NarrowContext struct {
Before int
After int
}
// execOnText runs term-grep + line-context expansion against one chunk's text.
// Mirrors Python _exec_on_text:
// Runs:
//
// - locate matches exactly (match.start()/end());
// - merge overlapping/adjacent ranges, expand to whole lines, add before/after
@@ -424,8 +411,8 @@ func execOnText(content string, patterns []*regexp.Regexp, before, after, outCha
}
}
if len(hitRanges) == 0 {
// Keep fact-dense sentences to avoid dropping numbers/entities (Python's
// no-hit path keeps dense sentences, else the raw head).
// Keep fact-dense sentences to avoid dropping numbers/entities; with none, the raw
// head is kept.
var kept []string
for _, s := range SplitSentences(content) {
if IsFactDenseSentence(s) {
@@ -544,8 +531,7 @@ func isSentenceTerminator(r rune) bool {
return false
}
// NarrowStats carries the accounting Python narrow_by_terms returns in its
// "stats" dict.
// NarrowStats carries the narrowing accounting.
type NarrowStats struct {
ChunksIn int
ChunksKpt int
@@ -555,8 +541,7 @@ type NarrowStats struct {
UsedTerms int
}
// NarrowResult is the outcome of NarrowByTerms / GrepSedNarrow, mirroring
// Python's {"kept": [...], "stats": {...}} dict.
// NarrowResult is the outcome of NarrowByTerms / GrepSedNarrow.
type NarrowResult struct {
Kept []map[string]any
Stats NarrowStats
@@ -752,8 +737,7 @@ func ReachTermsOf(query string) []string {
return GrepTermsFromQuery(query)
}
// NarrowByTerms narrows retrieval chunks by locating grep terms, mirroring
// Python narrow_by_terms:
// NarrowByTerms narrows retrieval chunks by locating grep terms:
//
// - terms are plain strings (entities / numbers / key phrases);
// - no usable terms -> keyword narrowing (zero LLM);
@@ -794,8 +778,8 @@ func NarrowByTerms(chunks []map[string]any, terms []string, fallbackTerms []stri
raw := ChunkTextOf(c)
if charLen(raw) <= minNarrowChars {
// Short chunk: kept whole. Flagged as matched so it still
// participates in the total-budget distribution; Python
// overwrites with the identical text and pops "highlight".
// participates in the total-budget distribution; the text is unchanged and
// "highlight" is popped.
kept = append(kept, withNarrowedText(c, raw))
flags = append(flags, true)
continue
@@ -806,7 +790,7 @@ func NarrowByTerms(chunks []map[string]any, terms []string, fallbackTerms []stri
kept = append(kept, withNarrowedText(c, text))
} else {
// No match: keep the original chunk untouched (full text and any
// "highlight" preserved) — mirrors Python _apply_narrow's else.
// "highlight" preserved.
kept = append(kept, cloneMap(c))
}
flags = append(flags, ok)
@@ -867,7 +851,7 @@ func NarrowByTerms(chunks []map[string]any, terms []string, fallbackTerms []stri
}
// NarrowWithFallbackKeyword applies keyword narrowing (zero LLM), returning the
// originals when keyword narrowing yields nothing (Python _fallback_narrow_by_keywords).
// originals when keyword narrowing yields nothing.
func NarrowWithFallbackKeyword(chunks []map[string]any, keywords string) []map[string]any {
if narrowed := NarrowByKeywords(chunks, keywords); len(narrowed) > 0 {
return narrowed
@@ -875,7 +859,7 @@ func NarrowWithFallbackKeyword(chunks []map[string]any, keywords string) []map[s
return chunks
}
// fallbackStopwords mirrors Python _FALLBACK_STOPWORDS.
// fallbackStopwords
var fallbackStopwords = map[string]bool{
"what": true, "which": true, "who": true, "where": true, "when": true, "how": true,
"the": true, "a": true, "an": true, "of": true, "in": true, "on": true,
@@ -888,8 +872,8 @@ var fallbackStopwords = map[string]bool{
}
// SplitFallbackTerms splits free text into fallback grep terms (zero LLM),
// mirroring Python split_fallback_terms: splits on sentence/comma boundaries,
// drops short/stopword tokens, keeps numbers and multi-word phrases.
// splitting on sentence/comma boundaries, dropping short/stopword tokens, keeping numbers
// and multi-word phrases.
func SplitFallbackTerms(texts ...string) []string {
var terms []string
seen := map[string]bool{}
@@ -918,8 +902,8 @@ func SplitFallbackTerms(texts ...string) []string {
var reFallbackSplit = regexp.MustCompile(`[\n。;;,.?!?]+`)
// GrepSedNarrow narrows chunks by grepping terms extracted directly from the
// claim (zero LLM), mirroring Python grep_sed_narrow. Terms are derived from the
// claim text via SplitFallbackTerms; no extra LLM call. Never raises.
// claim (zero LLM). Terms are derived from the claim text via SplitFallbackTerms; no extra
// LLM call. Never raises.
func GrepSedNarrow(chunks []map[string]any, claimSources []string, maxOutCharsPerChunk, maxOutTotalChars int) NarrowResult {
if len(chunks) == 0 {
return NarrowResult{Kept: chunks, Stats: NarrowStats{ChunksIn: 0}}
@@ -928,7 +912,7 @@ func GrepSedNarrow(chunks []map[string]any, claimSources []string, maxOutCharsPe
return NarrowByTerms(chunks, terms, nil, strings.Join(claimSources, " "), NarrowContext{}, maxOutCharsPerChunk, maxOutTotalChars)
}
// GrepSummaryFromClaims mirrors Python grep_sed_narrow's public convenience: given
// GrepSummaryFromClaims: public convenience: given
// claim/question texts, produce a compact narrowed evidence string (used by the
// compiled-structure grepper in search.go). Returns "" when nothing was kept.
func GrepSummaryFromClaims(chunks []map[string]any, claimSources []string) string {
@@ -951,9 +935,7 @@ func logGrepSed(s NarrowStats) {
s.ChunksIn, s.ChunksKpt, s.CharsIn, s.CharsOut, s.Matched, s.UsedTerms)
}
// ---------------------------------------------------------------------------
// Local helpers shared with the narrowing paths
// ---------------------------------------------------------------------------
func cloneMap(c map[string]any) map[string]any {
cp := make(map[string]any, len(c))
@@ -963,9 +945,8 @@ func cloneMap(c map[string]any) map[string]any {
return cp
}
// withNarrowedText returns a copy of the chunk with its narrowed text applied.
// Mirrors Python _apply_narrow's matched branch: it overwrites
// "content_with_weight", mirrors "content" only when that key already exists,
// withNarrowedText returns a copy of the chunk with its narrowed text applied: it
// overwrites "content_with_weight", mirrors "content" only when that key already exists,
// and drops "highlight" (the pre-narrow highlight spans no longer apply).
func withNarrowedText(c map[string]any, narrowed string) map[string]any {
cp := cloneMap(c)
@@ -977,13 +958,13 @@ func withNarrowedText(c map[string]any, narrowed string) map[string]any {
return cp
}
// charLen is the codepoint length, matching Python's len(str). Go's len(string)
// is bytes, which diverges for CJK; the narrowing caps are codepoint budgets.
// charLen is the codepoint length. Go's len(string) is bytes, which diverges for CJK; the
// narrowing caps are codepoint budgets.
func charLen(s string) int { return utf8.RuneCountInString(s) }
// truncHead keeps the first n codepoints (not bytes) of s, mirroring Python's
// s[:n]. Byte slicing would corrupt multi-byte CJK and mis-size output; rune
// slicing keeps it faithful to the Python engine.
// truncHead keeps the first n codepoints (not bytes) of s. Byte slicing would corrupt
// multi-byte CJK and mis-size output; rune slicing is faithful to the engine's codepoint
// budgets.
func truncHead(s string, n int) string {
if charLen(s) <= n {
return s
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"strings"
+158
View File
@@ -0,0 +1,158 @@
//
// 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 runtime
import (
"strings"
"unicode/utf8"
"ragflow/internal/rag/agentic-rag/slots"
)
// The table's items, read in one place: the count the answer reports, the size the record line
// states, and the elements a merge unions are three readings of the same fact.
// ItemValues is the values the table's item slots declare: every slot whose value IS items
// (slots.KindItems), deduped case-insensitively, minus the pieces that merely contain another
// item and minus the actor's own forms. Nothing is parsed out of a slot's text: a slot holds items
// because the model said so.
func ItemValues(table *State) []string {
if table == nil {
return nil
}
// The actor of the deed is not one of its elements, so his declared forms (Coverage.Actors)
// are dropped here. Two spellings of a victim's name still count as two: nothing declares them
// as one yet.
actorForms := CoverageOf(*table).Actors()
var items []string
seen := map[string]bool{}
for _, v := range table.State {
if v.Typed().Kind != slots.KindItems {
continue
}
for _, name := range v.Typed().ItemValues() {
name = strings.TrimSpace(name)
key := strings.ToLower(name)
if key == "" || seen[key] || IsActorForm(name, actorForms) {
continue
}
seen[key] = true
items = append(items, name)
}
}
out := make([]string, 0, len(items))
for _, item := range items {
contained := false
for _, other := range items {
if other == item || len([]rune(other)) >= len([]rune(item)) {
continue
}
if strings.Contains(strings.ToLower(item), strings.ToLower(other)) {
contained = true
break
}
}
if !contained {
out = append(out, item)
}
}
return out
}
// IsActorForm reports whether a value is the actor under one of the declared forms: an exact
// match, or a form of two runes or more carried inside a value no longer than the form plus one
// (关云长 carries 云长). A form nobody declared is not guessed: 关公 stays a member until something
// declares it as one of the actor's names.
func IsActorForm(value string, forms []string) bool {
v := strings.ToLower(strings.TrimSpace(value))
if v == "" {
return false
}
for _, form := range forms {
f := strings.ToLower(strings.TrimSpace(form))
if f == "" {
continue
}
if v == f {
return true
}
if utf8.RuneCountInString(f) >= 2 && strings.Contains(v, f) &&
utf8.RuneCountInString(v) <= utf8.RuneCountInString(f)+1 {
return true
}
}
return false
}
// AnchoredItems is ItemValues restricted to the items that carry the passage stating them: the set
// a count may be derived from, since an item nobody can point at cannot be cited either. The
// predicate lives on the value (slots.Value.Anchored).
func AnchoredItems(table *State) []string {
return itemValuesWhere(table, slots.Value.Anchored)
}
// UnanchoredItems is the complement of AnchoredItems: the values asserted with no passage in hand.
// They are not deleted — the record lists them beside the number as claims the answer has to
// account for — but they are not counted.
func UnanchoredItems(table *State) []string {
return itemValuesWhere(table, slots.Value.Unanchored)
}
// AnchoredItemChunks is the passage behind each anchored item, deduped and in table order: what an
// answer that must cite one passage per element needs in front of it (see withCitedChunks).
func AnchoredItemChunks(table *State) []string {
if table == nil {
return nil
}
var out []string
seen := map[string]bool{}
for _, v := range table.State {
for _, it := range v.Typed().Anchored() {
id := strings.TrimSpace(it.ChunkID)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
}
return out
}
// itemValuesWhere joins the items a picker selects ACROSS the table's slots, in ItemValues order,
// so every reading of the table agrees on WHICH values exist and in what order; only the picker
// differs.
func itemValuesWhere(table *State, pick func(slots.Value) []slots.Item) []string {
if table == nil {
return nil
}
kept := map[string]bool{}
for _, v := range table.State {
for _, it := range pick(v.Typed()) {
if val := strings.TrimSpace(it.Value); val != "" {
kept[strings.ToLower(val)] = true
}
}
}
out := make([]string, 0, len(kept))
for _, name := range ItemValues(table) {
if kept[strings.ToLower(strings.TrimSpace(name))] {
out = append(out, name)
}
}
return out
}
@@ -0,0 +1,113 @@
//
// 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 runtime
import (
"strings"
"testing"
"ragflow/internal/rag/agentic-rag/slots"
)
// TestAnchoredItemsSplitWhatTheTableCanQuote pins the split the count reads: a member carrying
// the chunk that states it is countable, a member without one is a claim. The split reads the
// ChunkID — the anchor the answer cites — not the Quote, because a quotation with no passage to
// look it up in is words nobody can check.
//
// Neither reading deletes a name: MemberNames still holds all three, which is what lets the record
// list the claims beside the number instead of dropping them.
func TestAnchoredItemsSplitWhatTheTableCanQuote(t *testing.T) {
value := slots.Items(
slots.Item{Value: "荀正", ChunkID: "c1", Quote: "关公大怒,直取荀正 交马一合,砍荀正于马下"},
slots.Item{Value: "于禁"},
slots.Item{Value: "刘延", Quote: "quoted words with no passage to look them up in"},
)
table := NewState([]Variable{{ID: 0, Type: "person", Value: &value}}, 0, nil)
anchored := AnchoredItems(&table)
if len(anchored) != 1 || anchored[0] != "荀正" {
t.Fatalf("anchored = %v, want only the member carrying a passage", anchored)
}
unanchored := UnanchoredItems(&table)
if len(unanchored) != 2 {
t.Fatalf("unanchored = %v, want the two claims", unanchored)
}
joined := strings.Join(unanchored, "、")
if !strings.Contains(joined, "于禁") || !strings.Contains(joined, "刘延") {
t.Fatalf("unanchored = %v, want both claimed names", unanchored)
}
if all := ItemValues(&table); len(all) != 3 {
t.Fatalf("all members = %v, want the three names (the split deletes none)", all)
}
}
// TestItemValuesDropsTheActorOfTheDeed pins the one identity rule the run has data for: the
// actor's own declared forms are not elements of the answer.
//
// A question that asks what someone did lists what he did it TO, so 关羽 cannot be one of the
// people he killed — yet the run probes his name as a term and a session can write it into a list
// (measured 2026-09-17: the probe ledger offered 关羽 among the "names" beside the victims). The
// forms come from the planner (Coverage.Actors, "关羽|云长"), which is also why the match is an
// IDENTITY rule and not a string rule: `关云长` carries the declared `云长` and is one item dropped;
// `关公` carries neither and is left alone, because guessing a form nobody declared is how an
// overloaded word starts dropping elements that are not the actor.
func TestItemValuesDropsTheActorOfTheDeed(t *testing.T) {
value := slots.Items(
slots.Item{Value: "关羽", ChunkID: "c1"},
slots.Item{Value: "关云长", ChunkID: "c2"},
slots.Item{Value: "关公"},
slots.Item{Value: "华雄", ChunkID: "c3"},
)
table := NewState([]Variable{
{ID: 0, Type: "count", Terms: []string{"斩"}, Subject: "关羽|云长"},
{ID: 1, Type: "person", Value: &value},
}, 0, nil)
got := ItemValues(&table)
if len(got) != 2 {
t.Fatalf("items = %v, want the actor's two declared forms gone and the rest kept", got)
}
for _, want := range []string{"关公", "华雄"} {
if !strings.Contains(strings.Join(got, "、"), want) {
t.Errorf("items = %v, want %q kept", got, want)
}
}
if a := AnchoredItems(&table); len(a) != 1 || a[0] != "华雄" {
t.Fatalf("anchored = %v, want only the anchored item that is not the actor", a)
}
}
// TestAnchoredItemsIsEmptyWithoutItems keeps the split safe on the shapes every other reader
// guards: a nil table, a table with no member slot, and a member slot whose items carry no name.
func TestAnchoredItemsIsEmptyWithoutItems(t *testing.T) {
if got := AnchoredItems(nil); got != nil {
t.Errorf("anchored(nil) = %v, want nothing", got)
}
text := slots.Text("华雄、颜良、文丑")
textOnly := NewState([]Variable{{ID: 0, Type: "person", Value: &text}}, 0, nil)
if got := AnchoredItems(&textOnly); len(got) != 0 {
t.Errorf("anchored(text slot) = %v, want nothing: text declares no members", got)
}
if got := UnanchoredItems(&textOnly); len(got) != 0 {
t.Errorf("unanchored(text slot) = %v, want nothing", got)
}
empty := slots.Items(slots.Item{Value: " "})
namedless := NewState([]Variable{{ID: 0, Type: "person", Value: &empty}}, 0, nil)
if got := AnchoredItems(&namedless); len(got) != 0 {
t.Errorf("anchored(blank name) = %v, want nothing", got)
}
}
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -25,20 +25,16 @@ import (
// The shared accumulation store and the retrieval abstraction.
//
// In Python this is not a harness module: it is the plain dict
// `self.kbinfos = {"chunks": [], "doc_aggs": [], "memory": []}` carried by the
// RAGTools instance in, threaded through retrieval, the
// action session, SCA, and the final answer via tools.kbinfos. The Go port
// reifies that dict into the typed Kbinfos struct (with the Merge helper) and
// keeps it in the harness package because chunkKey/chunkText and Merge are
// The accumulation store is the typed Kbinfos struct (with the Merge helper), which holds
// the chunks / doc_aggs / memory that retrieval, the action session, SCA and the final answer
// all share. It lives in the runtime package because chunkKey/chunkText and Merge are
// unexported helpers shared by tool_search.go, tool_exploration.go, and
// tool_compiled_expansion.go — a reification, not a 1:1 file mirror.
// tool_compiled_expansion.go.
//
// This file also owns the chunk identity helpers (chunkKey/chunkText) that the
// merge logic depends on.
// SearchParams mirrors Python tools/search.py::hybrid_search(tools, query,
// kb_ids, top_n, doc_scope, keywords, retrieval_query, use_compiled).
// SearchParams are the inputs of one search.
//
// Question is the search query; Keywords is used ONLY to narrow retrieved
// chunks (never to build the query) unless RetrievalQuery is empty.
@@ -68,33 +64,29 @@ type SearchParams struct {
// list, and it is read as a to-do list, so what enters it must be a NAME the
// caller probed. The runtime's completeness pass queries ask for the actor and
// the act words (see RunCompletenessPass), which are not names and are searched
// by construction: measured (2026-09-16, 三国/关羽) the record read
// `FOUND BUT NOT RECORDED=斩颜良、诛文丑、三国演义、关羽…+14` — every entry a query
// word, and not one of the members the round was actually missing.
// by construction: without the flag every entry is a query word, and not one of them
// is a member the round is actually missing.
SkipReachLedger bool
// Channel is retained for backward compatibility with callers that still
// poke the unified HybridSearch with an explicit channel. It is DEPRECATED:
// each Python entry point is now its own function (HybridSearch /
// VectorSearch / BM25Search / GrepSearch / RetrieveSearch) and selects its
// vector weight, similarity threshold and compiled-row exclusion
// internally. New code should call the specific function instead of setting
// Channel on HybridSearch. runSearch does NOT read this field.
// each entry point is now its own function (HybridSearch / VectorSearch /
// BM25Search / GrepSearch / RetrieveSearch) and selects its vector weight,
// similarity threshold and compiled-row exclusion internally. New code should
// call the specific function instead of setting Channel on HybridSearch.
// runSearch does NOT read this field.
Channel SearchChannel
}
// SearchFn performs one hybrid search and returns chunks + doc aggs, so the
// harness is decoupled from the concrete retrieval backend.
// Mirrors the Python side's settings.retriever.retrieval call.
// runtime is decoupled from the concrete retrieval backend.
// It abstracts the concrete retrieval backend.
type SearchFn func(ctx context.Context, p SearchParams) ([]map[string]any, []map[string]any)
// Kbinfos is the shared accumulation store.
//
// ONE Kbinfos is shared by a round's CONCURRENT sessions (SessionDeps.KB; see
// RunSlotResearchPass), so its mutable state is guarded by mu. Python needs no
// lock — its sessions are coroutines and every admission stretch is await-free
// (action_session.py:691-700), so asyncio cannot interleave two sessions'
// batches. Go runs the same sessions on goroutines, so the critical sections
// Python gets for free have to be locked explicitly: see Admit.
// RunSlotResearchPass), so its mutable state is guarded by mu. The sessions run on
// goroutines, so the critical sections have to be locked explicitly: see Admit.
type Kbinfos struct {
mu sync.Mutex
Chunks []map[string]any
@@ -113,8 +105,7 @@ type Kbinfos struct {
// that run inside an admit batch (see Admit) are the only place the two nest.
ledgerMu sync.Mutex
// Memory is the lossless store of raw retrieved chunks backing the (lossy)
// Chunks list that feeds the LLM. Mirrors Python kbinfos["memory"], which
// memory.add/grep maintain.
// Chunks list that feeds the LLM; the memory add/grep helpers maintain it.
Memory []map[string]any
// PreSummary is the merged claim-report summary produced by the action
// session; the final-answer call reads it when set.
@@ -125,8 +116,8 @@ type Kbinfos struct {
//
// The two are separate on purpose. A draft is written to be VERIFIED against
// the passages that produced it; the answer prompt is written to be ANSWERED
// from. Reusing one as the other handed the answer model the runtime's
// bookkeeping, and the measured result was an answer quoting it verbatim
// from. Reusing one as the other hands the answer model the runtime's bookkeeping,
// and the answer quotes the bookkeeping verbatim:
// ("slot 1 [entity] … (strength=0.90) [terminal=state, evidence_ids=[…]]").
Record string
// novelAdmitted counts the chunks the cap EXEMPTION below has let in beyond
@@ -146,20 +137,30 @@ type Kbinfos struct {
// question whose answer is a list of members (see MarkSetDirection, which
// also says why the retrieval executor reads it). Guarded by ledgerMu.
setDirection bool
// patternFindings is the completeness pass's block for this QUESTION, and
// patternPassed records that it ran (see StorePatternFindings): the windows the
// pass admits stay in the pool, so a later round reuses the block rather than
// asking the same corpus the same questions and putting back what is already
// there. Guarded by ledgerMu.
patternFindings string
patternPassed bool
// actWords / actorForms are the direction's declaration (see MarkCoverage). Guarded by ledgerMu.
actWords []string
actorForms []string
// citedChunks are the passages the enumerated items rest on (see NoteCitedChunks).
// Guarded by ledgerMu.
citedChunks []string
// sufficiencyUnchecked records that the completeness review never ran (see
// NoteSufficiencyUnchecked). Guarded by ledgerMu.
sufficiencyUnchecked bool
// coverageSet is what this QUESTION's enumeration found, and coverageReady records
// that it ran at all (see StoreCoverageSet): the windows it admits stay in the pool
// under the same chunk ids, so a later round reuses the same evidence rather than
// asking the corpus the same operand queries and putting back what is already there.
// Guarded by ledgerMu.
coverageSet CoverageSet
coverageReady bool
// CiteChunkIDs is the ordered id list of the chunks the final-answer call
// rendered as numbered evidence — Python's tools._rag_cite_chunk_ids
// (agentic_rag_graph.py:870). The renderer ranks the pool by similarity and
// caps it (citeChunkCap), so this list is NOT Chunks in pool order, and a
// "[ID:n]" the model writes refers to position n in THIS list — one entry per
// RENDERED block, so a chunk the renderer skipped (no content) holds no
// position here either. The chat pipeline resolves citations against it
// (agentic_rag_graph.py:870). The renderer puts the passages behind enumerated
// items first and fills the rest by similarity, capped (citeChunkCap), so this
// list is NOT Chunks in pool order, and a "[ID:n]" the model writes refers to
// position n in THIS list — one entry per RENDERED block, so a chunk the
// renderer skipped (no content) holds no position here either. The chat pipeline
// resolves citations against it
// (RunResponse.CiteChunkIDs → HarnessResult.CiteChunkIDs →
// decorateHarnessAnswer); without it, a marker was resolved against Chunks by
// position and landed on the wrong chunk — or past the end when the pool held
@@ -169,13 +170,13 @@ type Kbinfos struct {
// concurrent research slots have already joined), and read by the caller after
// the call returns — it is not part of the pool lock's protected state.
CiteChunkIDs []string
// cache is the per-request retrieval cache (Python tools.search_cache). It
// is initialised lazily via cacheOnce so a zero-value Kbinfos is usable.
// cache is the per-request retrieval cache. It is initialised lazily via
// cacheOnce so a zero-value Kbinfos is usable.
cache *searchCache
cacheOnce sync.Once
}
// HasChunks mirrors Python `bool(kbinfos.get("chunks"))`.
// HasChunks reports whether the pool holds any chunk.
func (k *Kbinfos) HasChunks() bool { return len(k.Chunks) > 0 }
// PoolSize is how many chunks the shared pool holds.
@@ -233,15 +234,13 @@ func (k *Kbinfos) ChunksFrom(from, limit int) []map[string]any {
// Admit runs fn as ONE critical section over the evidence pool.
//
// The granularity is the caller's, and it must span the stretch Python leaves
// await-free: ONE query's candidate batch, because the awaits sit in the OUTER
// query loop (action_session.py:691-700). Locking per chunk would let two
// sessions' batches interleave into pool orders Python cannot produce, and the
// pool order is observable — it drives the rendered prompt order and the `ID: n`
// numbering, extractRelevantEvidence's first-4 pick, and which chunks make it in
// under the cap.
// The granularity is the caller's, and it must span ONE query's candidate batch,
// because the awaits sit in the OUTER query loop. Locking per chunk would let two
// sessions' batches interleave into unsupported pool orders, and the pool order is
// observable — it drives the rendered prompt order and the `ID: n` numbering,
// extractRelevantEvidence's first-4 pick, and which chunks make it in under the cap.
//
// Inside fn do, per chunk, exactly what _admit_evidence does, in its order:
// Inside fn do, per chunk, exactly the admission steps, in this order:
//
// p.Full() → skip the chunk. The cap check precedes the per-call dedup
// (:638-646), so a rejected chunk is NOT marked seen and is
@@ -252,10 +251,8 @@ func (k *Kbinfos) ChunksFrom(from, limit int) []map[string]any {
// INVARIANT — fn must not call anything that takes k.mu itself (Admit, Merge,
// MergeDocAggs, RetireClaimsCoveredBy, PoolAdmitter.Full's siblings…): the mutex
// is NOT reentrant, so that call parks the goroutine forever on a lock it already
// holds, and a mutex wait ignores context cancellation, so the run never returns
// and nothing is logged. Measured (fixrecall2, 2026-09-15): a seat recorded its
// member from inside the admit batch and the run hung with no I/O, no CPU and no
// further log line.
// holds, and a mutex wait ignores context cancellation, so the run never returns and
// nothing is logged: the goroutine parks silently.
//
// What fn MAY call is the search record (RecordReachedTerm / RecordProbedAbsent):
// those live behind ledgerMu, precisely so the batch that answered a probe can
@@ -276,13 +273,10 @@ func (k *Kbinfos) Admit(fn func(p *PoolAdmitter)) {
// PoolAdmitter is the pool handle, valid only inside Kbinfos.Admit's callback.
type PoolAdmitter struct{ k *Kbinfos }
// Full mirrors the early stop at the top of _admit_evidence: at the cap the
// chunk is skipped, and nothing about it is recorded (so a rejected chunk is
// retried once room frees).
// Full is the early stop at the top of admission: at the cap the chunk is skipped, and
// nothing about it is recorded (so a rejected chunk is retried once room frees).
//
// The "pool FULL" line belongs here for the same reason it lives inside
// _admit_evidence (:639-645), and it is emitted once per PROCESS — Python
// documents _EVIDENCE_POOL_STATE as a "Per-process flag" (:62-64). sync.Once
// The "pool FULL" line belongs here, and it is emitted once per PROCESS. sync.Once
// because concurrent sessions call this under their own pool lock.
func (p *PoolAdmitter) Full() bool {
if p.k == nil || len(p.k.Chunks) < evidencePoolCap {
@@ -294,8 +288,8 @@ func (p *PoolAdmitter) Full() bool {
return true
}
// evidencePoolFullLogged mirrors Python _EVIDENCE_POOL_STATE["full_logged"]: a
// PER-PROCESS flag (action_session.py:62-64), so the line is emitted once per
// evidencePoolFullLogged: ["full_logged"]: a
// PER-PROCESS flag, so the line is emitted once per
// process — not once per rejected chunk, and NOT once per session. The pool never
// shrinks mid-process, so no reset is needed.
var evidencePoolFullLogged sync.Once
@@ -309,11 +303,8 @@ var evidencePoolFullLogged sync.Once
// and where?", and the per-name window it returns is the only place that answer
// ever lives — no later search can reconstruct WHICH names came back empty.
//
// Measured (fixrecall, 2026-09-14): a seven-name probe returned ten chunks
// carrying 庞德 / 成何 / 于禁 and NOT ONE carrying 车胄 / 荀正 / 管亥 / 杨龄 — four
// members the corpus does hold; and on the cap itself, "a round of batch name
// probing ended at 117 chunks, three short of the cap, with the question's
// members still arriving". A cap that drops those windows converts a successful
// A batch probe can come back with passages for some of the names it asked about and none
// for others the corpus does hold, so a cap that drops those windows converts a successful
// probe into "nothing new", which the model then reads as "not a member".
//
// The slack is what keeps the exemption from being open-ended: it is charged per
@@ -417,11 +408,9 @@ func (k *Kbinfos) RecordReachedTerm(term, chunkID string) {
//
// on a direction whose answer is a LIST, the caller's queries are facets of that
// list rather than rephrasings of one question, so a query that is dropped is not
// a spared repeat — it is a member nobody searched. Measured (2026-09-16): a
// medium-mode run asked 3-5 queries per call against a cap of 2 (search_chunks) /
// 3 (retrieve); nine calls were cut, and the names it later turned out to be
// missing had been named only in the dropped ones. On a VALUE direction the
// queries ARE rephrasings of one question and the small cap stays.
// a spared repeat — it is a member nobody searched, and the names the answer turns out to
// be missing were often named only in a dropped one. On a VALUE direction the queries ARE
// rephrasings of one question and the small cap stays.
func (k *Kbinfos) MarkSetDirection() {
if k == nil {
return
@@ -442,32 +431,102 @@ func (k *Kbinfos) IsSetDirection() bool {
return k.setDirection
}
// StorePatternFindings records the completeness pass's block for this question (see
// RunCompletenessPass / PatternFindings).
func (k *Kbinfos) StorePatternFindings(block string) {
// NoteSufficiencyUnchecked records that the review which judges completeness never ran, so the
// record the answer reads can say so. It is not a verdict, and not a reason to mark the answer
// partial.
func (k *Kbinfos) NoteSufficiencyUnchecked() {
if k == nil {
return
}
k.ledgerMu.Lock()
k.patternFindings = block
k.patternPassed = true
k.sufficiencyUnchecked = true
k.ledgerMu.Unlock()
}
// PatternFindings returns the block a previous round's completeness pass produced, and
// whether the pass has run at all.
//
// The unit is the REQUEST, not the round: the windows the pass admitted are in the pool
// under the same chunk ids, so a second round asking the same corpus the same questions
// would spend the same store legs to re-admit what is already there, and the seed would
// show the same windows it already showed (see StorePatternFindings).
func (k *Kbinfos) PatternFindings() (string, bool) {
// SufficiencyUnchecked reports whether the review that would have judged completeness
// never ran (see NoteSufficiencyUnchecked). Safe on a nil pool.
func (k *Kbinfos) SufficiencyUnchecked() bool {
if k == nil {
return "", false
return false
}
k.ledgerMu.Lock()
defer k.ledgerMu.Unlock()
return k.patternFindings, k.patternPassed
return k.sufficiencyUnchecked
}
// MarkCoverage records the direction's declaration — the actor's forms and the act words — for the
// readers that have the pool but not the slot table (see CoverageDecl). Where StoreCoverageSet
// holds what the corpus answered, this holds what the run was asking with.
func (k *Kbinfos) MarkCoverage(cov Coverage) {
if k == nil {
return
}
k.ledgerMu.Lock()
defer k.ledgerMu.Unlock()
k.actWords = append([]string(nil), cov.Acts...)
k.actorForms = cov.Actors()
}
// CoverageDecl is the direction's declaration (see MarkCoverage). Empty when no enumeration
// direction declared itself, and its probes are read by shape alone.
func (k *Kbinfos) CoverageDecl() (acts, actors []string) {
if k == nil {
return nil, nil
}
k.ledgerMu.Lock()
defer k.ledgerMu.Unlock()
return append([]string(nil), k.actWords...), append([]string(nil), k.actorForms...)
}
// NoteCitedChunks records the passages the enumerated items rest on (see
// runtime.AnchoredItemChunks): the compose prompt puts them in front of the answer, which has to
// cite one passage per element. Guarded by ledgerMu.
func (k *Kbinfos) NoteCitedChunks(ids []string) {
if k == nil {
return
}
k.ledgerMu.Lock()
k.citedChunks = append([]string(nil), ids...)
k.ledgerMu.Unlock()
}
// CitedChunks is the passages recorded by NoteCitedChunks, empty when no enumeration wrote items.
func (k *Kbinfos) CitedChunks() []string {
if k == nil {
return nil
}
k.ledgerMu.Lock()
defer k.ledgerMu.Unlock()
return append([]string(nil), k.citedChunks...)
}
// StoreCoverageSet records what this question's enumeration found (see
// EnumerateCoverage / CoverageSet).
func (k *Kbinfos) StoreCoverageSet(set CoverageSet) {
if k == nil {
return
}
k.ledgerMu.Lock()
k.coverageSet = set
k.coverageReady = true
k.ledgerMu.Unlock()
}
// CoverageSet returns the enumeration a previous round ran for this question, and whether
// it ran at all.
//
// The unit is the REQUEST, not the round: the windows the enumeration admitted are in the
// pool under the same chunk ids, so a second round asking the corpus the same operand
// queries would spend the same store legs to re-admit what is already there, and the seed
// would show the same windows it already showed (see StoreCoverageSet). The SET is kept
// rather than its rendering because the last node resolves the windows themselves.
func (k *Kbinfos) CoverageSet() (CoverageSet, bool) {
if k == nil {
return CoverageSet{}, false
}
k.ledgerMu.Lock()
defer k.ledgerMu.Unlock()
return k.coverageSet, k.coverageReady
}
// ReachedTerms returns a copy of the confirmed members and the chunk that carries
@@ -501,7 +560,7 @@ func EvidencePoolCap() int { return evidencePoolCap }
// (case-insensitively), preserving the caller's order.
//
// It is the coverage half of every "did we find that name?" question in the
// harness, and it has exactly two callers because there are exactly two sets it
// runtime, and it has exactly two callers because there are exactly two sets it
// can be asked about:
//
// - a SEARCH's own results — which of the terms this call named did this
@@ -622,17 +681,16 @@ func (n *Novelty) Admits(c map[string]any) bool {
return false
}
// Add appends c unless the LIVE pool already holds it, reporting whether it
// appended — Python's "new to the shared pool" return.
// Add appends c unless the LIVE pool already holds it, reporting whether it appended —
// i.e. whether the chunk was new to the shared pool.
//
// The test is against the live pool, not a snapshot taken when the call started
// (Python's `kb_seen`, :690). That snapshot is exact only because nothing can
// interleave with it; under Go's parallelism it would re-append a chunk another
// session just pooled, which Python can never do.
// The test is against the live pool, not a snapshot taken when the call started. A snapshot
// would re-append a chunk another session just pooled, which must not happen under
// parallelism.
//
// There is deliberately NO cap check here: the compiled-expansion path appends
// uncapped in Python too, which is what pushes the pool past _EVIDENCE_POOL_CAP
// (see the comment on evidencePoolCap). Callers that admit user-facing search
// There is deliberately NO cap check here: the compiled-expansion path appends uncapped,
// which is what pushes the pool past evidencePoolCap (see the comment on evidencePoolCap).
// Callers that admit user-facing search
// hits check Full() first.
func (p *PoolAdmitter) Add(c map[string]any) bool {
if p.k == nil {
@@ -655,13 +713,11 @@ func (p *PoolAdmitter) Index(c map[string]any) int {
return indexOfChunk(p.k.Chunks, chunkKey(c))
}
// ClaimCoveredIDs returns the chunk ids already represented VERBATIM by a claim
// pseudo-chunk in the LIVE pool (Python _claim_covered_ids,
// action_session.py:619-631): a claim carries its own verbatim quote plus the
// ids of the chunks it was distilled from, so admitting those passages again
// is duplicate payload — the answer material is already in the pool at a
// fraction of the size. Call it ONCE per Admit batch and test each chunk's id
// against the result (Python computes the set once per _admit_evidence call).
// ClaimCoveredIDs returns the chunk ids already represented VERBATIM by a claim pseudo-chunk
// in the LIVE pool: a claim carries its own verbatim quote plus the ids of the chunks it was
// distilled from, so admitting those passages again is duplicate payload — the answer material
// is already in the pool at a fraction of the size. Call it ONCE per Admit batch and test each
// chunk's id against the result (the set is computed once per admission call).
func (p *PoolAdmitter) ClaimCoveredIDs() map[string]bool {
if p.k == nil {
return nil
@@ -680,7 +736,7 @@ func (p *PoolAdmitter) ClaimCoveredIDs() map[string]bool {
return covered
}
// CoveredByClaim mirrors the _admit_evidence skip (action_session.py:672-676):
// CoveredByClaim mirrors the _admit_evidence skip:
// a non-table chunk whose id a pooled claim already quotes verbatim must NOT
// enter the pool again. Table chunks are exempt: their answer rows survive
// only in full text.
@@ -713,12 +769,12 @@ func (k *Kbinfos) Merge(chunks, aggs []map[string]any) []int {
}
// RetireClaimsCoveredBy removes claim pseudo-chunks whose source_chunk_ids
// intersect the given read ids (Python _exec_list_chunks:928-934): a deep read
// intersect the given read ids: a deep read
// COVERS its claims — once the full chunk text is in the pool, the claim's
// 1200-char quote of the same passage is duplicated tokens in every later
// prompt. The claim already did its job (it pointed here). Returns how many
// entries were retired. The claim_ prefix and the "source_chunk_ids listed
// under the read ids" test mirror Python verbatim.
// under the read ids" test are exact.
func (k *Kbinfos) RetireClaimsCoveredBy(readIDs []string) int {
if k == nil || len(readIDs) == 0 {
return 0
@@ -757,7 +813,7 @@ func (k *Kbinfos) RetireClaimsCoveredBy(readIDs []string) int {
}
// MergeDocAggs appends doc aggregations, deduplicating by doc_id: the first agg
// per doc_id wins (Python _merge_kbinfos' dseen set).
// per doc_id wins.
//
// Separate from Merge, whose early return on an empty chunk list would skip them:
// the search tool must record a search's aggregations even when every chunk it
@@ -782,8 +838,8 @@ func (k *Kbinfos) MergeDocAggs(aggs []map[string]any) {
}
}
// docAggKey mirrors Python's dedup key for doc_aggs: the raw doc_id, with a
// missing/empty doc_id mapping to "" (so only the first such agg is kept).
// docAggKey is the dedup key for doc_aggs: the raw doc_id, with a missing/empty doc_id
// mapping to "" (so only the first such agg is kept).
func docAggKey(d map[string]any) string {
if id, ok := d["doc_id"].(string); ok {
return id
@@ -801,12 +857,10 @@ func indexOfChunk(chunks []map[string]any, kk string) int {
return -1
}
// chunkText mirrors Python harness/chunk_utils._chunk_text: the searchable
// text of a chunk, preferring the weighted (reranked) "content_with_weight"
// over the raw "content", then falling back to "text". Python's root
// chunk_utils._chunk_text and grep_sed_narrow._chunk_text both read in this
// order; memory._chunk_text is a two-level variant (no "text") and is NOT what
// this mirrors.
// chunkText is the searchable text of a chunk, preferring the weighted (reranked)
// "content_with_weight" over the raw "content", then falling back to "text". Both the
// chunk-utils and grep-sed-narrow readers use this order; the memory variant is two-level
// (no "text") and is deliberately NOT what this mirrors.
func chunkText(c map[string]any) string {
if t, ok := c["content_with_weight"].(string); ok && t != "" {
return t
@@ -825,16 +879,15 @@ func chunkText(c map[string]any) string {
// id still dedup correctly (and never collide on a shared empty key).
//
// The content branch reuses chunkText — the SAME alias chain the rest of the
// harness reads (content_with_weight -> content -> text). Reading only the first
// runtime reads (content_with_weight -> content -> text). Reading only the first
// two made two id-less chunks that carry just "text" fall through to the
// doc-level fallback and share one key, so Merge/MemoryAdd discarded distinct
// evidence.
//
// This diverges from Python _chunk_key (`chunk_id or id or id(ck)`): Python's
// final fallback is `id(ck)` — the dict object's memory address — which means
// two equivalent chunks returned as distinct objects never share a key, so
// deduplication silently fails for id-less chunks. Go instead keys on the chunk
// text, so identical content still merges across retrieval calls.
// A memory-address fallback is the alternative: with `id(ck)` as the final key, two
// equivalent chunks returned as distinct objects never share a key, so deduplication silently
// fails for id-less chunks. Keying on the chunk text instead means identical content still
// merges across retrieval calls.
func chunkKey(c map[string]any) string {
if id, ok := c["chunk_id"].(string); ok && id != "" {
return "cid:" + id
@@ -1,4 +1,4 @@
package harness
package runtime
import (
"fmt"
@@ -7,7 +7,7 @@ import (
"time"
)
// TestMergeSkipsAllWhenNoChunks mirrors Python _merge_kbinfos's early return:
// TestMergeSkipsAllWhenNoChunks: early return:
// `if not result or not result.get("chunks"): return`. When the incoming chunk
// list is empty, neither chunks nor doc_aggs are merged, and the call returns
// nil (no global indices contributed).
@@ -25,9 +25,9 @@ func TestMergeSkipsAllWhenNoChunks(t *testing.T) {
}
}
// TestMergeCollapsesEmptyDocIDAggs mirrors Python's doc_agg dedup: the dseen set
// is built from the raw doc_id value, so a missing/None doc_id still enters the
// set and a second empty-doc_id agg is dropped. Only the first is kept.
// TestMergeCollapsesEmptyDocIDAggs: the dedup set is built from the raw doc_id value, so a
// missing/empty doc_id still enters the set and a second empty-doc_id agg is dropped. Only the
// first is kept.
func TestMergeCollapsesEmptyDocIDAggs(t *testing.T) {
kb := &Kbinfos{}
added := kb.Merge(
@@ -45,8 +45,8 @@ func TestMergeCollapsesEmptyDocIDAggs(t *testing.T) {
}
}
// TestMergeDedupsSameDocID mirrors Python's exact-match dedup for real doc_ids,
// independent of the empty bucket above.
// TestMergeDedupsSameDocID covers the exact-match dedup for real doc_ids, independent of the
// empty bucket above.
func TestMergeDedupsSameDocID(t *testing.T) {
kb := &Kbinfos{}
kb.Merge([]map[string]any{{"id": "c1"}}, []map[string]any{{"doc_id": "x"}, {"doc_id": "x"}})
@@ -117,9 +117,8 @@ func TestMemoryAddKeepsDistinctTextOnlyChunks(t *testing.T) {
// pool actually sees: one round's sessions run at the same time and share ONE
// Kbinfos (SessionDeps.KB; RunSlotResearchPass starts them as goroutines).
//
// Python gets both invariants for free — asyncio never preempts the await-free
// admit stretch (_admit_evidence is a plain def, action_session.py:615, and the
// per-query loop has no await, :691-700) — so Go has to lock the same stretch.
// Both invariants used to come for free from cooperative scheduling — an await-free admit
// stretch cannot be preempted — so the same stretch has to be locked here.
//
// The pool starts one chunk short of the cap, and both sessions offer the SAME
// first chunk. Whichever batch wins the lock pools it — once — and the cap then
@@ -175,8 +174,7 @@ func TestKbinfosAdmitIsAtomic(t *testing.T) {
}
// TestToolCacheIsConcurrencySafe exercises the cache the way a round does: the
// SAME instance handed to concurrent sessions (Python builds one dict per round,
// agentic_rag_graph.py:1407, and passes it to every session, action_session.py:2030).
// SAME instance handed to concurrent sessions (one cache per round, passed to every session).
// Run under -race this is what a bare map cannot survive — concurrent map writes
// are a fatal error in Go, not merely a race report.
func TestToolCacheIsConcurrencySafe(t *testing.T) {
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -29,8 +29,6 @@ import (
// Four-aspect keyword extraction with entity weighting.
//
// Mirrors Python harness/keywords.py.
//
// A keyword search matches only the surface forms you give it, so a single flat
// bag of terms is a poor retrieval driver: the model does not know how the corpus
// phrases a fact. This extraction asks the LLM for FOUR aspects — `entity` (what
@@ -43,16 +41,13 @@ import (
// retrieved chunks to their keyword-bearing sentences.
const (
// keywordEntityRepeat is copies of each entity term in the query
// (Python: _KEYWORD_ENTITY_REPEAT).
// keywordEntityRepeat is copies of each entity term in the query.
keywordEntityRepeat = 3
// keywordQualifierRepeat is copies of each qualifier, weighted up like entity
// (Python: _KEYWORD_QUALIFIER_REPEAT).
// keywordQualifierRepeat is copies of each qualifier, weighted up like entity.
keywordQualifierRepeat = 3
// keywordMaxChars caps both strings (Python: _KEYWORD_MAX_CHARS).
// keywordMaxChars caps both strings.
keywordMaxChars = 400
// keywordExtractionTemperature pins Python keywords.py:141's
// {"temperature": 0.1} for the extraction call.
// keywordExtractionTemperature pins the temperature for the extraction call.
keywordExtractionTemperature = 0.1
)
@@ -102,13 +97,13 @@ Output ONLY JSON, no prose, no code fences:
{"entity": ["<term>", ...], "aliases": ["<term>", ...], "fact_type": ["<term>", ...], "qualifiers": ["<term>", ...]}
Any category may be empty.`
// normKeyword mirrors Python _norm_keyword: normalise a term for cross-category
// normKeyword: normalise a term for cross-category
// dedup (lowercase, whitespace-collapsed).
func normKeyword(s string) string {
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
// parseAspects mirrors Python _parse_aspects: parse the LLM's JSON into one
// parseAspects: parse the LLM's JSON into one
// deduped list per aspect.
//
// ONE dedup set spans all four categories: a term the model emits as both an
@@ -160,7 +155,7 @@ func asAspectList(v any) []any {
var reThinkWrap = regexp.MustCompile(`(?s)^.*</think>`)
// StripThinkAndFences removes a leading thinking preamble and Markdown fences.
// Exported so the harness (compute.go, keywords.go) and the advanced_rag package's
// Exported so the runtime (compute.go, keywords.go) and the agentic_rag package's
// moved Formalize can share one implementation.
func StripThinkAndFences(s string) string {
s = reThinkWrap.ReplaceAllString(s, "")
@@ -168,7 +163,7 @@ func StripThinkAndFences(s string) string {
return strings.TrimSpace(s)
}
// ExtractWeightedKeywords mirrors Python extract_weighted_keywords: extract the
// ExtractWeightedKeywords: extract the
// four aspects and return (query, keywords).
//
// - query is the weighted search string: every entity term repeated x3 and
@@ -185,13 +180,10 @@ func ExtractWeightedKeywords(ctx context.Context, model SessionModel, question s
}
aspects := map[string][]string{}
if model != nil {
// Mirror Python keywords.py:extract_weighted_keywords — form_message(_KEYWORDS_SYSTEM,
// question) is run through message_fit_in(..., llm.max_length) before
// the call, so an over-long question is trimmed to the model context
// window exactly as in Python. The chat seam exposes llm.max_length via
// ContextLengthModel; when no context window is available the
// chat.EffectiveContextLength(0) default (8192) applies, exactly as
// Python's LLM.max_length default.
// The question is fitted to the model context window before the call, so an over-long
// question is trimmed rather than rejected. The context length is exposed via
// ContextLengthModel; when none is available the chat.EffectiveContextLength(0)
// default (8192) applies.
budget := 0
if cl, ok := model.(ContextLengthModel); ok {
budget = cl.ContextLength()
@@ -206,8 +198,7 @@ func ExtractWeightedKeywords(ctx context.Context, model SessionModel, question s
_LOG.Printf("[Keywords] prompt fitting failed: %s", fitErr)
}
// FitMessages may prepend/trim a system message; re-extract it so the
// model call is exactly [system, user...] as Python sends msg[0] then
// msg[1:].
// model call is exactly [system, user...].
systemPrompt := KeywordsSystem
if len(fitted) > 0 && fitted[0].Role == schema.System {
systemPrompt = fitted[0].Content
@@ -223,8 +214,8 @@ func ExtractWeightedKeywords(ctx context.Context, model SessionModel, question s
*schema.SystemMessage(systemPrompt),
*schema.UserMessage(userContent),
}
// Python hardcodes {"temperature": 0.1} (keywords.py:141) — a mechanical
// rewrite, not a reasoning task, so it must be stable. The value is a
// The temperature is pinned to 0.1 — a mechanical rewrite, not a reasoning task, so it
// must be stable. The value is a
// pinned constant: every production carrier implements TemperatureModel
// (compile-time assertion on InvokerSessionModel), so the temperature is
// always sent; a carrier without per-call temperature support falls back
@@ -234,7 +225,7 @@ func ExtractWeightedKeywords(ctx context.Context, model SessionModel, question s
if tm, ok := model.(TemperatureModel); ok {
reply, err = tm.CompleteWithTemperature(ctx, msgs, nil, keywordExtractionTemperature)
} else {
_LOG.Printf("[Keywords] model %T cannot carry per-call temperature; using its default (Python would send %v)", model, keywordExtractionTemperature)
_LOG.Printf("[Keywords] model %T cannot carry per-call temperature; using its default (wanted %v)", model, keywordExtractionTemperature)
reply, err = model.Complete(ctx, msgs, nil)
}
if err == nil {
@@ -274,9 +265,8 @@ func ExtractWeightedKeywords(ctx context.Context, model SessionModel, question s
query = keywords
}
// Python keywords.py has NO term-count cap — only the _KEYWORD_MAX_CHARS (400)
// hard cap on the final joined strings. We mirror exactly that: only the
// character cap is applied, never a per-term limit.
// There is NO term-count cap — only the keywordMaxChars (400) hard cap on the final joined
// strings: only the character cap is applied, never a per-term limit.
query = truncateRunes(query, keywordMaxChars)
keywords = truncateRunes(keywords, keywordMaxChars)
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -83,8 +83,8 @@ func TestExtractWeightedKeywordsWeighting(t *testing.T) {
// query: entity x3, qualifiers x3, then aliases + fact_type once each.
// NOTE: the alias "Brown County, Kansas" itself contains a comma, so a naive
// split on ", " over-counts — Python joins the same way and BM25 tokenises
// on whitespace, so this is expected. Assert by prefix/containment.
// split on ", " over-counts — the join works the same way and BM25 tokenises on
// whitespace, so this is expected. Assert by prefix/containment.
if !strings.HasPrefix(query, "Brown County, Brown County, Brown County, ") {
t.Errorf("query = %q, want the entity repeated x3 first", query)
}
@@ -148,9 +148,8 @@ func TestExtractWeightedKeywordsCapsLength(t *testing.T) {
}
func TestTemperatureModelExtension(t *testing.T) {
// Keyword extraction runs at 0.1 in Python. The Go seam exposes temperature
// through an OPTIONAL interface so existing SessionModel implementations
// keep working unchanged.
// Keyword extraction runs at 0.1. The seam exposes temperature through an OPTIONAL
// interface so existing SessionModel implementations keep working unchanged.
im := &InvokerSessionModel{}
var _ TemperatureModel = im // must implement the extension
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"regexp"
@@ -25,19 +25,16 @@ import (
// Raw-chunk memory store.
//
// Mirrors Python harness/memory.py, which maintains the lossless store backing
// the (lossy) kbinfos.Chunks list that feeds the LLM. Retrieval narrows chunks
// to the sentences that answer the current query, so the raw text has to be
// kept somewhere: a later gap query often needs a fact the earlier narrowing
// already threw away.
// The lossless store backing the (lossy) chunk list that feeds the LLM. Retrieval narrows
// chunks to the sentences that answer the current query, so the raw text has to be kept
// somewhere: a later gap query often needs a fact the earlier narrowing already threw away.
//
// The store lives on Kbinfos.Memory so it travels with the request.
const (
// grepMaxChunks is Python memory.grep's default cap (_GREP_MAX_CHUNKS = 6).
// MemoryGrep takes the limit explicitly and does NOT substitute this for a
// non-positive value (Python has no such guard), so callers that want the
// Python default pass it.
// grepMaxChunks is the default cap for the memory grep. MemoryGrep takes the limit
// explicitly and does NOT substitute this for a non-positive value (there is no such
// guard), so callers that want the default pass it.
grepMaxChunks = 6
// grepMaxSentences caps sentences kept per chunk (hit + context).
grepMaxSentences = 4
@@ -47,12 +44,11 @@ const (
// answers often live in short chunks.
shortChunkChars = 200
// MemorySearch tuning — mirror Python memory.search defaults.
// MemorySearch tuning — the search defaults.
memoryDefaultTopN = 6
// memoryMinRatio: a chunk is relevant when it shares >= 1 term AND >= this
// fraction of the query's significant terms (normalized overlap bar so CN /
// EN queries behave alike). Python's min_overlap param is declared but
// unused in search(), so only the ratio matters.
// memoryMinRatio: a chunk is relevant when it shares >= 1 term AND >= this fraction of
// the query's significant terms (normalized overlap bar so CN / EN queries behave alike).
// Only the ratio matters; the absolute min_overlap parameter is not consulted.
memoryMinRatio = 0.12
// memoryMaxTerms caps how many significant terms we extract from a query.
memoryMaxTerms = 18
@@ -72,7 +68,7 @@ var (
reDigits = regexp.MustCompile(`\d+`)
)
// memoryStopwords mirrors Python memory._STOPWORDS: dropped from Latin query
// memoryStopwords: dropped from Latin query
// terms so "what / the / is" style words do not dominate the overlap score.
var memoryStopwords = map[string]struct{}{
"what": {}, "which": {}, "how": {}, "many": {}, "much": {}, "does": {},
@@ -86,16 +82,15 @@ var memoryStopwords = map[string]struct{}{
"your": {},
}
// MemoryAdd mirrors Python memory.add: merges raw retrieved chunks into the
// MemoryAdd: merges raw retrieved chunks into the
// central store, losslessly, skipping chunks already present and those with no
// text.
func MemoryAdd(kb *Kbinfos, chunks []map[string]any) {
if kb == nil || len(chunks) == 0 {
return
}
// One critical section: memory.add is an await-free stretch in Python, so
// asyncio can never interleave two sessions' adds into it (the pool is shared
// across a round's concurrent sessions — see Kbinfos.Admit).
// One critical section: the add is a single stretch that cannot be interleaved (the pool
// is shared across a round's concurrent sessions — see Kbinfos.Admit).
kb.mu.Lock()
defer kb.mu.Unlock()
seen := make(map[string]struct{}, len(kb.Memory))
@@ -120,7 +115,7 @@ func MemoryAdd(kb *Kbinfos, chunks []map[string]any) {
}
}
// MemorySize mirrors Python memory.size.
// MemorySize
func MemorySize(kb *Kbinfos) int {
if kb == nil {
return 0
@@ -128,7 +123,7 @@ func MemorySize(kb *Kbinfos) int {
return len(kb.Memory)
}
// MemoryClear mirrors Python memory.clear.
// MemoryClear
func MemoryClear(kb *Kbinfos) {
if kb != nil {
kb.mu.Lock()
@@ -137,17 +132,16 @@ func MemoryClear(kb *Kbinfos) {
}
}
// MemoryGrep mirrors Python memory.grep: returns memory chunks containing any of
// MemoryGrep: returns memory chunks containing any of
// terms, narrowed to the matching sentence plus a small context window.
//
// terms are plain strings (entities / numbers / key phrases) as emitted by the
// analysis LLM. Each returned chunk carries a narrowed "content" so the caller
// can splice it straight into an evidence list. Empty on no-hit / no-memory.
//
// limit is the maximum number of chunks returned and is NOT normalized: Python's
// grep has no such guard, so a limit <= 0 makes the `len(hits) >= limit` check
// fire on the first hit (at most one chunk comes back). Callers wanting Python's
// default pass grepMaxChunks.
// limit is the maximum number of chunks returned and is NOT normalized: there is no such
// guard, so a limit <= 0 makes the `len(hits) >= limit` check fire on the first hit (at most
// one chunk comes back). Callers wanting the default pass grepMaxChunks.
func MemoryGrep(kb *Kbinfos, terms []string, limit int) []map[string]any {
if kb == nil || len(kb.Memory) == 0 || len(terms) == 0 {
return nil
@@ -184,10 +178,8 @@ func MemoryGrep(kb *Kbinfos, terms []string, limit int) []map[string]any {
"doc_id": c["doc_id"],
"chunk_id": c["chunk_id"],
})
// This branch continues past the shared cap below, so enforce the
// limit here too — otherwise a memory store full of matching short
// chunks comes back whole. Python's memory.grep enforces it in the
// same place.
// This branch continues past the shared cap below, so enforce the limit here too —
// otherwise a memory store full of matching short chunks comes back whole.
if len(hits) >= limit {
break
}
@@ -222,7 +214,7 @@ func MemoryGrep(kb *Kbinfos, terms []string, limit int) []map[string]any {
return hits
}
// compileTerms mirrors Python memory.grep's pattern construction: the escaped
// compileTerms: pattern construction: the escaped
// term (word-anchored when it is long enough and wholly alphanumeric) plus the
// leading-stem prefix pattern used as a fallback.
func compileTerms(terms []string) (patterns, prefixPatterns []*regexp.Regexp) {
@@ -246,7 +238,7 @@ func compileTerms(terms []string) (patterns, prefixPatterns []*regexp.Regexp) {
return patterns, prefixPatterns
}
// escapeTerm mirrors Python _escape_term: strip surrounding punctuation, escape
// escapeTerm: strip surrounding punctuation, escape
// regex metacharacters, then anchor on word boundaries — except for CJK, where
// a \b anchor would never match.
func escapeTerm(term string) string {
@@ -269,7 +261,7 @@ func escapeTerm(term string) string {
return escaped
}
// sentenceSpanWindow mirrors Python _sentence_span_window: the hit sentence plus
// sentenceSpanWindow: the hit sentence plus
// up to one neighbour on each side, clamped to a total length.
func sentenceSpanWindow(sents []string, idx int) []string {
if idx < 0 || idx >= len(sents) {
@@ -304,13 +296,13 @@ func containsStr(ss []string, s string) bool {
return false
}
// MemorySearch mirrors Python memory.search: relevance-ranked retrieval over the
// MemorySearch: relevance-ranked retrieval over the
// raw-chunk memory store (a retrieval-reuse cache, NOT a noise-injection source).
// Unlike MemoryGrep (loose keyword hit) it keeps only chunks whose overlap with the
// query's SIGNIFICANT terms clears a normalized bar, so a fact retrieved earlier can
// be reused instead of re-querying the index.
//
// Language-agnostic term extraction (mirrors Python _significant_terms):
// Language-agnostic term extraction:
// - numbers kept verbatim;
// - CJK runs split into character 3-grams (no word boundaries exist);
// - Latin alphanumeric runs lowercased, stopword-filtered, len >= 3.
@@ -364,7 +356,7 @@ func MemorySearch(kb *Kbinfos, query string, topN int, minRatio float64) []map[s
if len(scored) == 0 {
return nil
}
// Rank by hit count desc, then text length desc (Python: (-hits, -len)).
// Rank by hit count desc, then text length desc.
sort.SliceStable(scored, func(i, j int) bool {
if scored[i].hits != scored[j].hits {
return scored[i].hits > scored[j].hits
@@ -391,7 +383,7 @@ func MemorySearch(kb *Kbinfos, query string, topN int, minRatio float64) []map[s
return out
}
// significantTerms mirrors Python memory._significant_terms: language-agnostic
// significantTerms: language-agnostic
// significant-term extraction, de-duplicated and capped at memoryMaxTerms.
func significantTerms(text string) []string {
var out []string
@@ -494,10 +486,9 @@ func (m termMatcher) matches(text string) bool {
}
}
// IsStopword reports whether w is one of Python memory._STOPWORDS.
// IsStopword reports whether w is one of the shared stopwords.
//
// Go's fan-out needs it because Python aliases the same set as
// _FANOUT_STOPWORDS (agentic_rag_graph.py:_expand_fanouts) when filtering candidate terms.
// The fan-out needs it because the same set filters candidate terms.
func IsStopword(w string) bool {
_, ok := memoryStopwords[w]
return ok
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import "testing"
@@ -22,8 +22,8 @@ func memChunk(content, docID, chunkID string) map[string]any {
return map[string]any{"content": content, "doc_id": docID, "chunk_id": chunkID}
}
// Python memory.search returns the chunks that share enough of the query's
// significant terms; unrelated queries return nothing.
// The memory search returns the chunks that share enough of the query's significant terms;
// unrelated queries return nothing.
func TestMemorySearchReturnsRelevant(t *testing.T) {
kb := &Kbinfos{Memory: []map[string]any{
memChunk("Rifampicin is an antibiotic used to treat tuberculosis.", "d1", "c1"),
@@ -48,7 +48,7 @@ func TestMemorySearchReturnsRelevant(t *testing.T) {
}
// The normalized overlap bar must drop a chunk that matches too few of a long
// query's terms (mirrors Python's (hits/_n) >= min_ratio guard).
// query's terms (the (hits/_n) >= min_ratio guard).
func TestMemorySearchRatioBar(t *testing.T) {
kb := &Kbinfos{Memory: []map[string]any{
memChunk("Rifampicin is an antibiotic.", "d1", "c1"),
@@ -67,7 +67,7 @@ func TestMemorySearchRatioBar(t *testing.T) {
}
}
// CJK queries match via character 3-grams, just like the Python implementation.
// CJK queries match via character 3-grams.
func TestMemorySearchCJKTrigram(t *testing.T) {
kb := &Kbinfos{Memory: []map[string]any{
memChunk("利福平是一种抗生素,用于治疗结核病。", "d1", "c1"),
@@ -94,16 +94,15 @@ func TestMemorySearchRanking(t *testing.T) {
}
}
// chunkText must prefer "content_with_weight" over "content", mirroring Python
// harness/chunk_utils._chunk_text (content_with_weight or content or text), so a
// chunk carrying both yields the weighted text.
// chunkText must prefer "content_with_weight" over "content" (content_with_weight or content
// or text), so a chunk carrying both yields the weighted text.
func TestChunkTextPrefersWeighted(t *testing.T) {
c := map[string]any{
"content": "primary text",
"content_with_weight": "weighted text",
}
if got := chunkText(c); got != "weighted text" {
t.Errorf("chunkText = %q, want %q (Python prefers content_with_weight)", got, "weighted text")
t.Errorf("chunkText = %q, want %q (content_with_weight is preferred)", got, "weighted text")
}
// Falls back to content when content_with_weight is absent/empty.
@@ -133,13 +132,13 @@ func TestMemoryGrepLimitAppliesToShortChunks(t *testing.T) {
t.Errorf("hit content = %v, want the whole short chunk", hits[0]["content"])
}
// limit <= 0 is NOT normalized to the default: Python's grep has no such
// guard, so the `len(hits) >= limit` check fires on the first hit.
// limit <= 0 is NOT normalized to the default: there is no such guard, so the
// `len(hits) >= limit` check fires on the first hit.
if got := len(MemoryGrep(kb, []string{"rifampicin"}, 0)); got != 1 {
t.Errorf("limit=0 hits = %d, want 1 (Python grep does not normalize limit)", got)
t.Errorf("limit=0 hits = %d, want 1 (the limit is not normalized)", got)
}
// Python's default (6) is passed explicitly and caps at 6.
// The default (6) is passed explicitly and caps at 6.
if got := len(MemoryGrep(kb, []string{"rifampicin"}, grepMaxChunks)); got != grepMaxChunks {
t.Errorf("default-limit hits = %d, want %d", got, grepMaxChunks)
}
@@ -18,14 +18,13 @@
// action session: the Sufficient Context Agent (SCA) review and the gap→query
// rewriter.
//
// Mirrors Python rag/advanced_rag/harness/orchestrator/{sufficient_context,
// query_rewriter}.py.
// Covers the Sufficient Context Agent (SCA) review and the gap→query rewriter.
//
// Python's orchestrator/direct.py — the low-mode direct search — deliberately
// has no counterpart in this package: its Go implementation is runDirect
// The low-mode direct search deliberately has no counterpart in this package: its Go
// implementation is runDirect
// (agentic_rag.go, called from the low graph's direct_search node), the low graph
// node's body, because that step needs the full RAGTools config rather than a
// harness-level dependency bundle. One implementation only; its contract is
// runtime-level dependency bundle. One implementation only; its contract is
// pinned by TestBuildLowGraphRunsFormalizeThenDirectSearch.
package orchestrator
@@ -40,9 +39,8 @@ var _LOG = common.StdLogger()
// JSONModel generates one JSON object from a rendered prompt.
//
// Mirrors Python rag.prompts.generator.gen_json(prompt, "Output:\n", chat_mdl),
// which sends the prompt as the system turn, "Output:\n" as the user turn, and
// parses the first JSON value out of the reply.
// The prompt is sent as the system turn, "Output:\n" as the user turn, and the first JSON
// value is parsed out of the reply.
type JSONModel interface {
GenJSON(ctx context.Context, prompt string) (any, error)
}
@@ -21,21 +21,19 @@ import (
"fmt"
"strings"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/agentic-rag/runtime"
"ragflow/internal/rag/prompts"
)
// Query rewriter (Phase 4 of the pipeline).
//
// Mirrors Python orchestrator/query_rewriter.py::rewrite_gap_to_query.
//
// RewriteDeps are the dependencies of RewriteGapToQuery.
type RewriteDeps struct {
// Model generates the JSON object.
Model JSONModel
// Prompts supplies the "sca_query_rewrite" template.
Prompts harness.PromptLoader
Prompts runtime.PromptLoader
// BridgeValues are already-resolved values across the claims (e.g. the
// confirmed shows in a "which finale ran longest" question). The rewriter
// anchors the new query to these, so a missing hop is re-searched with the
@@ -48,7 +46,7 @@ type RewriteDeps struct {
ResearchContext string
}
// RewriteGapToQuery mirrors Python rewrite_gap_to_query.
// RewriteGapToQuery
//
// gaps are (what, search_hint) tuples — the forward gaps the SCA identified.
//
@@ -59,8 +57,8 @@ func RewriteGapToQuery(ctx context.Context, deps RewriteDeps, question string, g
if deps.Model == nil || len(gaps) == 0 {
return nil
}
// Python wraps the call in @in_phase("rewrite").
ctx, done := harness.Phase(ctx, harness.PhaseRewrite)
// The call runs inside the "rewrite" phase.
ctx, done := runtime.Phase(ctx, runtime.PhaseRewrite)
defer done()
var gapsLines []string
@@ -102,6 +102,4 @@ func TestRewriteGapToQueryReturnsNilOnFailure(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Prompt rendering
// ---------------------------------------------------------------------------
@@ -23,14 +23,12 @@ import (
"strings"
"unicode/utf8"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/agentic-rag/runtime"
"ragflow/internal/rag/prompts"
)
// Unified Sufficient Context Agent.
//
// Mirrors Python orchestrator/sufficient_context.py.
//
// The SCA performs ONE review pass over (1) each claim's intermediate draft and
// (2) the overall draft assembled from them, and returns a unified verdict:
// is_sufficient / confidence / contradictions / reasoning / claims / sub_queries.
@@ -53,7 +51,7 @@ const (
// evidence anchor, so the SCA can verify a draft against real retrieved text
// without a token blow-up.
scaEvidenceAnchorChars = 300
// scaMaxAnchorsPerClaim bounds anchors per claim (Python: `if len(anchors) >= 3`).
// scaMaxAnchorsPerClaim bounds anchors per claim.
scaMaxAnchorsPerClaim = 3
// feedbackMax bounds the missing pieces folded into the boost feedback string.
feedbackMax = 4
@@ -84,7 +82,7 @@ type SubQuery struct {
SearchHint string
}
// MissingPiece mirrors Python's {"what", "search_hint"}.
// MissingPiece is one gap: what is missing and a hint for searching for it.
type MissingPiece struct {
What string
SearchHint string
@@ -110,14 +108,14 @@ type SCAResult struct {
// SCADeps are the dependencies of the SCA.
type SCADeps struct {
KB *harness.Kbinfos
KB *runtime.Kbinfos
// Model generates the JSON verdict.
Model JSONModel
// Prompts supplies the "sca_select" template.
Prompts harness.PromptLoader
Prompts runtime.PromptLoader
}
// SufficientContextAgent mirrors Python sufficient_context_agent.
// SufficientContextAgent
//
// claims carries per-claim evidence (not the global union): rendering only the
// snippets each claim cited keeps the prompt small (~1-3 chunks per claim) so
@@ -130,8 +128,8 @@ func SufficientContextAgent(ctx context.Context, deps SCADeps, question string,
if len(claims) == 0 || deps.Model == nil {
return SCAResult{}
}
// Python wraps the review in @in_phase("sca").
ctx, done := harness.Phase(ctx, harness.PhaseSCA)
// The review runs inside the "sca" phase.
ctx, done := runtime.Phase(ctx, runtime.PhaseSCA)
defer done()
claimsContext := renderClaimContext(claims, deps.KB)
@@ -244,7 +242,7 @@ func SufficientContextAgent(ctx context.Context, deps SCADeps, question string,
}
}
// Boost mirrors Python to_boost: adapt the unified verdict into the
// Boost: adapt the unified verdict into the
// decision-ladder boost dict, preserving the contract
// (is_sufficient / confidence / missing / contradictions / feedback /
// followups) that the existing ladder consumes unchanged.
@@ -286,19 +284,17 @@ type Boost struct {
SubQueries []SubQuery
}
// ToGrounded mirrors Python to_grounded: adapt the unified verdict into the
// ToGrounded: adapt the unified verdict into the
// `grounded` dict consumed by replan and the ungrounded-veto path
// ({claim_id: {grounded, ungrounded, missing_information}}).
func (s SCAResult) ToGrounded() map[string]ClaimVerdict { return s.Claims }
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
// renderClaimContext mirrors Python _render_claim_context: each claim's report
// renderClaimContext: each claim's report
// PLUS a brief evidence anchor (the first line of each cited snippet), so the
// SCA can verify the draft is grounded in real retrieved text.
func renderClaimContext(claims []ClaimDraft, kb *harness.Kbinfos) string {
func renderClaimContext(claims []ClaimDraft, kb *runtime.Kbinfos) string {
if len(claims) == 0 {
return "(no claim drafts)"
}
@@ -342,8 +338,8 @@ func renderClaimContext(claims []ClaimDraft, kb *harness.Kbinfos) string {
// Apply the budget to the COMPLETE block before appending it, trimming it
// to what remains: accounting the block only AFTER appending let a single
// oversized claim blow straight past scaClaimsContextMax (the cap the
// comment claims bounds the whole rendered context). Cut by rune
// (Python's str[:N]) so the trim never splits a multibyte character.
// comment claims bounds the whole rendered context). Cut by rune so the trim never
// splits a multibyte character.
remaining := scaClaimsContextMax - used
if remaining <= 0 {
break
@@ -364,7 +360,7 @@ func renderClaimContext(claims []ClaimDraft, kb *harness.Kbinfos) string {
return strings.Join(blocks, "\n\n")
}
// renderOverallDraft mirrors Python _render_overall_draft: assemble the
// renderOverallDraft: assemble the
// problem-level "rough draft" by concatenating each claim's report, so the SCA
// can judge whether the context lets the model answer end-to-end — including
// cross-claim synthesis that per-claim review would miss.
@@ -382,20 +378,18 @@ func renderOverallDraft(claims []ClaimDraft) string {
return "(no overall draft)"
}
draft := strings.Join(parts, "\n")
// Truncate by RUNE, not byte: Python slices str[:N] by code point, and a byte
// slice through a multibyte (e.g. CJK) rune would emit invalid UTF-8 (or
// replacement characters) into the SCA prompt.
// Truncate by RUNE, not byte: a byte slice through a multibyte (e.g. CJK) rune would emit
// invalid UTF-8 (or replacement characters) into the SCA prompt.
if r := []rune(draft); len(r) > scaClaimsContextMax {
draft = string(r[:scaClaimsContextMax])
}
return draft
}
// scaChunkText mirrors Python sufficient_context.py:_render_claim_context: prefer the reranked
// "content_with_weight" over the raw "content", then fall back to "chunk". This
// differs from harness.chunkText (which prefers content and keeps a Go-only
// "text" fallback): the SCA evidence anchor must read the SAME text Python did
// so the groundedness guard resolves the same snippets.
// scaChunkText: _render_claim_context: prefer the reranked
// "content_with_weight" over the raw "content", then fall back to "chunk". This differs from
// retrieval.chunkText (which prefers content and keeps a "text" fallback): the SCA evidence anchor
// must read the SAME text so the groundedness guard resolves the same snippets.
func scaChunkText(c map[string]any) string {
if v, ok := c["content_with_weight"]; ok && v != nil {
if s := fmt.Sprint(v); s != "" {
@@ -413,7 +407,7 @@ func scaChunkText(c map[string]any) string {
return ""
}
// boundedExcerpt mirrors Python _bounded_excerpt: a bounded window around a
// boundedExcerpt: a bounded window around a
// term from the draft. Table text is returned whole.
func boundedExcerpt(text, hints string, maxChars int) string {
text = strings.TrimSpace(text)
@@ -426,9 +420,8 @@ func boundedExcerpt(text, hints string, maxChars int) string {
if maxChars < 80 {
maxChars = 80
}
// Work in runes (code points), exactly like Python's str[:] / len() / find():
// a byte-based slice would mis-count and split a multibyte (e.g. CJK) rune,
// emitting invalid UTF-8.
// Work in runes (code points): a byte-based slice would mis-count and split a multibyte
// (e.g. CJK) rune, emitting invalid UTF-8.
r := []rune(text)
lower := strings.ToLower(text)
start := -1
@@ -461,7 +454,7 @@ func boundedExcerpt(text, hints string, maxChars int) string {
return prefix + string(r[left:right]) + suffix
}
// isTableText mirrors Python _is_table_text: a corpus-neutral table detector —
// isTableText: a corpus-neutral table detector —
// HTML table markup, or >=3 pipe rows.
func isTableText(text string) bool {
t := strings.ToLower(text)
@@ -477,11 +470,9 @@ func isTableText(text string) bool {
return pipeRows >= 3
}
// ---------------------------------------------------------------------------
// Coercion helpers
// ---------------------------------------------------------------------------
// coerceDict mirrors Python _coerce_dict: tolerate model format drift. The
// coerceDict: tolerate model format drift. The
// reviewer occasionally replies with a bare array or a JSON string; previously
// any non-dict response was dropped, so the SCA produced NO signal on those
// rounds and replan/rewrite silently stopped.
@@ -497,7 +488,7 @@ func coerceDict(v any) map[string]any {
}
return nil
case string:
if parsed := harness.ExtractJSON(t); parsed != nil {
if parsed := runtime.ExtractJSON(t); parsed != nil {
return coerceDict(parsed)
}
return nil
@@ -505,7 +496,7 @@ func coerceDict(v any) map[string]any {
return nil
}
// clamp mirrors Python _clamp: coerce to [0,1], defaulting to 1.0 on failure.
// clamp: coerce to [0,1], defaulting to 1.0 on failure.
func clamp(v any) float64 {
f, ok := toFloat(v)
if !ok {
@@ -522,8 +513,8 @@ func clamp(v any) float64 {
// coerceBool reads an LLM-reported boolean, tolerating the string serialisations
// a drifted model reply may carry. It deliberately does NOT use raw truthiness:
// a non-empty string is truthy in both Go and Python, so bool("false") and
// bool("0") are TRUE — which would silently INVERT a verdict, marking
// a non-empty string is truthy in Go, so bool("false") and bool("0") are TRUE — which would
// silently INVERT a verdict, marking
// insufficient context sufficient or an ungrounded claim grounded.
//
// The recognised true/false spellings are mapped explicitly; an empty or
@@ -625,7 +616,7 @@ func toFloat(v any) (float64, bool) {
case int64:
return float64(n), true
case string:
return harness.ParseFloat(n)
return runtime.ParseFloat(n)
}
return 0, false
}
@@ -22,7 +22,7 @@ import (
"testing"
"unicode/utf8"
"ragflow/internal/rag/advanced_rag/harness"
"ragflow/internal/rag/agentic-rag/runtime"
)
// stubJSONModel returns a canned JSON value (or an error), and records the last
@@ -43,14 +43,12 @@ func (s *stubJSONModel) GenJSON(_ context.Context, prompt string) (any, error) {
return s.value, nil
}
// ---------------------------------------------------------------------------
// SCA
// ---------------------------------------------------------------------------
func TestSCAReturnsNoSignalWithoutClaims(t *testing.T) {
mdl := &stubJSONModel{value: map[string]any{"is_sufficient": true}}
got := SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", nil)
if mdl.calls != 0 {
@@ -78,7 +76,7 @@ func TestSCAParsesUnifiedVerdict(t *testing.T) {
},
}}
got := SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "OmiyaSoft made it"}})
@@ -121,7 +119,7 @@ func TestSCACoercesArrayAndStringDrift(t *testing.T) {
} {
mdl := &stubJSONModel{value: value}
got := SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "d"}})
if !got.IsSufficient {
@@ -139,7 +137,7 @@ func TestSCADerivesGapWhenInsufficientWithEmptyClaims(t *testing.T) {
"missing_information": []any{map[string]any{"what": "the year", "search_hint": "1999"}},
}}
got := SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "OmiyaSoft made it"}})
g, ok := got.Claims["_global"]
@@ -153,7 +151,7 @@ func TestSCADerivesGapWhenInsufficientWithEmptyClaims(t *testing.T) {
// No structured gap at all: fall back to the drafts themselves.
mdl = &stubJSONModel{value: map[string]any{"is_sufficient": false}}
got = SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "OmiyaSoft made it"}})
g, ok = got.Claims["_global"]
@@ -168,7 +166,7 @@ func TestSCADerivesGapWhenInsufficientWithEmptyClaims(t *testing.T) {
func TestSCAEvidenceAnchorsUseChunkIndices(t *testing.T) {
// EvidenceIDs are INDICES into Kbinfos.Chunks (not chunk_id hashes) — that
// indexing is what makes anchor lookup hit.
kb := &harness.Kbinfos{Chunks: []map[string]any{
kb := &runtime.Kbinfos{Chunks: []map[string]any{
{"content": "Culdcept was released in 1999 by OmiyaSoft."},
}}
contextText := renderClaimContext([]ClaimDraft{
@@ -245,9 +243,7 @@ func TestSCABoostAdaptsVerdict(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Query rewriter
// ---------------------------------------------------------------------------
func TestClampCoercesNumericDrift(t *testing.T) {
if got := clamp(float64(1.5)); got != 1.0 {
@@ -260,7 +256,7 @@ func TestClampCoercesNumericDrift(t *testing.T) {
if got := clamp("0.4"); got != 0.4 {
t.Errorf("clamp(\"0.4\") = %v, want 0.4", got)
}
// Unparseable -> Python's default of 1.0.
// Unparseable -> the default of 1.0.
if got := clamp("not a number"); got != 1.0 {
t.Errorf("clamp(bad) = %v, want 1.0", got)
}
@@ -297,7 +293,7 @@ func TestSCAParsesStringBooleans(t *testing.T) {
},
}}
got := SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "d"}})
if got.IsSufficient {
@@ -313,7 +309,7 @@ func TestSCAParsesStringBooleans(t *testing.T) {
// The "true" spelling still parses.
mdl = &stubJSONModel{value: map[string]any{"is_sufficient": "true"}}
got = SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "d"}})
if !got.IsSufficient {
@@ -336,7 +332,7 @@ func TestSCAIgnoresAbsentFields(t *testing.T) {
},
}}
got := SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "d"}})
@@ -372,7 +368,7 @@ func TestSCAMissingInformationIgnoresAbsentFields(t *testing.T) {
},
}}
got := SufficientContextAgent(context.Background(), SCADeps{
KB: &harness.Kbinfos{},
KB: &runtime.Kbinfos{},
Model: mdl,
}, "q", []ClaimDraft{{ID: "c1", Draft: "d"}})
mi := got.Claims["c1"].MissingInformation
@@ -381,8 +377,8 @@ func TestSCAMissingInformationIgnoresAbsentFields(t *testing.T) {
}
}
// TestRenderOverallDraftTruncatesByRune pins that the cap is applied by code
// point (Python's str[:N]), never mid-rune: a byte slice through a CJK rune
// TestRenderOverallDraftTruncatesByRune pins that the cap is applied by code point, never
// mid-rune: a byte slice through a CJK rune
// would put invalid UTF-8 into the SCA prompt.
func TestRenderOverallDraftTruncatesByRune(t *testing.T) {
draft := strings.Repeat("中", scaClaimsContextMax+50)
@@ -395,8 +391,8 @@ func TestRenderOverallDraftTruncatesByRune(t *testing.T) {
}
}
// TestRenderClaimContextBudgetIsRuneBased pins that the claim-context budget is
// counted in code points (Python's len(str)), not bytes: two CJK blocks of 20k
// TestRenderClaimContextBudgetIsRuneBased pins that the claim-context budget is counted in
// code points, not bytes: two CJK blocks of 20k
// runes are ~40k runes (under the 48k cap) but ~120KB bytes (far over it), so a
// byte budget would stop after the FIRST block and silently starve the SCA.
func TestRenderClaimContextBudgetIsRuneBased(t *testing.T) {
@@ -404,7 +400,7 @@ func TestRenderClaimContextBudgetIsRuneBased(t *testing.T) {
got := renderClaimContext([]ClaimDraft{
{ID: "c1", Draft: draft},
{ID: "c2", Draft: draft},
}, &harness.Kbinfos{})
}, &runtime.Kbinfos{})
if !strings.Contains(got, "Claim c1") || !strings.Contains(got, "Claim c2") {
t.Errorf("rune-based budget must keep both CJK blocks; got %d runes", utf8.RuneCountInString(got))
}
@@ -417,7 +413,7 @@ func TestRenderClaimContextBudgetIsRuneBased(t *testing.T) {
func TestRenderClaimContextTrimsOversizedBlock(t *testing.T) {
got := renderClaimContext([]ClaimDraft{
{ID: "c1", Draft: strings.Repeat("中", scaClaimsContextMax+5000)},
}, &harness.Kbinfos{})
}, &runtime.Kbinfos{})
if !utf8.ValidString(got) {
t.Error("trimmed block is not valid UTF-8")
}
@@ -14,17 +14,15 @@
// limitations under the License.
//
package harness
package runtime
import "strings"
// Porter stemming, a faithful port of nltk.stem.porter.PorterStemmer in its
// default NLTK_EXTENSIONS mode — the mode Python's text_processing.py:221
// exercises in production (`PorterStemmer().stem`, no mode argument). Python's
// _stem calls it for every stemmable keyword token, so the keyword narrowing in
// this package must produce the SAME stems (the previous suffix-stripping
// fallback diverged: "nominations"→"nominat" vs "nomin", "flies"→"fle" vs
// "fli", "dies"→"dy" vs "die", ...).
// Porter stemming, a faithful port of nltk.stem.porter.PorterStemmer in its default
// NLTK_EXTENSIONS mode — the mode used in production (`PorterStemmer().stem`, no mode
// argument). It is called for every stemmable keyword token, so the keyword narrowing in this
// package must produce the SAME stems (a suffix-stripping fallback diverged:
// "nominations"→"nominat" vs "nomin", "flies"→"fle" vs "fli", "dies"→"dy" vs "die", ...).
//
// The port follows the 1980 paper's five steps plus NLTK's extensions: the
// irregular-form pool consulted first, the <=2-letter circuit breaker, the
@@ -14,20 +14,14 @@
// limitations under the License.
//
package harness
package runtime
import "testing"
// TestPorterStemMatchesNLTK pins porterStem against nltk.stem.porter's
// NLTK_EXTENSIONS output (PorterStemmer().stem), collected with
//
// from nltk.stem import PorterStemmer
// print(PorterStemmer().stem(word))
//
// in production's environment. Every pair below is ground truth, not
// aspiration: the keyword-narrowing stem match (keywordForms/_sentence_stems)
// must produce Python-identical stems or the narrow keeps different sentences
// than Python does.
// TestPorterStemMatchesNLTK pins porterStem against nltk.stem.porter's NLTK_EXTENSIONS output
// (PorterStemmer().stem), collected in production's environment. Every pair below is ground
// truth, not aspiration: the keyword-narrowing stem match (keywordForms/_sentence_stems) must
// produce identical stems or the narrow keeps different sentences.
func TestPorterStemMatchesNLTK(t *testing.T) {
cases := []struct{ word, want string }{
// Step-1a/1b regulars.
@@ -84,8 +78,7 @@ func TestPorterStemMatchesNLTK(t *testing.T) {
}
// TestStemUsesPorter pins the seam: the package's stem() (the one
// keywordForms/_sentence_stems call) is the Porter stemmer, matching Python
// text_processing._stem's nltk branch.
// keywordForms/_sentence_stems call) is the Porter stemmer.
func TestStemUsesPorter(t *testing.T) {
if stem("nominations") != "nomin" {
t.Errorf("stem(nominations) = %q, want the Porter stem \"nomin\"", stem("nominations"))
@@ -13,12 +13,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package harness
package runtime
// Report synthesis prompts.
//
// Mirrors rag/advanced_rag/harness/prompts/report_prompt.py. {cite_rules} in
// FinalAnswerSystem is substituted by the caller (see advanced_rag.ComposeAnswer).
// FinalAnswerSystem is substituted by the caller (see agentic_rag.ComposeAnswer).
// FinalAnswerSystem mirrors report_prompt.FINAL_ANSWER_SYSTEM. {cite_rules} is
// substituted by the caller.
@@ -1,11 +1,10 @@
package harness
package runtime
import (
"fmt"
"strings"
"unicode/utf8"
"ragflow/internal/rag/advanced_rag/slots"
"ragflow/internal/tokenizer"
)
@@ -21,10 +20,9 @@ import (
// had already resolved, and it could not see that a name it found was still
// unrecorded.
//
// Measured (fixrecall2, 2026-09-15, mode=high): a session probed 15+ terms across
// 8 turns and recorded none of them until it was forced to, with 庞德 queried,
// found, and then dropped by the model's own reasoning — a decision the runtime
// could not see and therefore never asked about.
// Without them a session can probe term after term and record none of them until it is
// forced to, dropping a name it queried and found by its own reasoning — a decision the
// runtime cannot see and therefore never asks about.
//
// The record is also the basis of the continuation decision: the offer the model
// answers (offerContinuation) carries it, so "is anything still missing?" is a
@@ -61,25 +59,14 @@ func CollectSessionRecord(table State, kb *Kbinfos) SessionRecord {
}
r.Absent = kb.ProbedAbsentTerms()
}
// Members come from the slots that DECLARE members (slots.KindMembers), each
// Members come from the slots that DECLARE members (slots.KindItems), each
// item carrying its evidence. Nothing is split out of text: a slot holding a
// sentence, a date or a count contributes no members, and the text is never
// inspected to find out whether it might have been a list (see package slots for
// the measurement that made this fail closed).
seen := map[string]bool{}
for _, v := range table.State {
if v.Typed().Kind != slots.KindMembers {
continue
}
for _, name := range v.Typed().Names() {
key := strings.ToLower(name)
if seen[key] {
continue
}
seen[key] = true
r.Members = append(r.Members, name)
}
}
// ONE definition of the table's members (see MemberNames): the count the answer
// reports and the list this line shows read the same fact.
r.Members = ItemValues(&table)
joined := strings.ToLower(strings.Join(r.Members, "\x00"))
for _, term := range r.Reached {
// Substring containment, not equality: a candidate is often a clause
@@ -179,11 +166,9 @@ const subjectWordsMax = 8
//
// The pool is text the round has ALREADY paid for, and a session only ever sees
// the parts its own queries returned — everything else sat in hand, unread. That
// gap is where members are lost without anyone noticing. Measured (2026-09-15,
// 三国演义/关羽, mode=high): the passage naming 管亥 was fetched into the round's
// evidence at 21:09:13 and no session ever named it, because nothing had shown it;
// the sessions' own enumeration reached 11 and 12 members, and the five names the
// answer was missing were all in passages of exactly that kind.
// gap is where members are lost without anyone noticing: a passage can sit in the round's
// evidence, unread by every session, because nothing showed it — and the names an answer is
// missing are all in passages of exactly that kind.
//
// The framework does not name anybody here. It reads the unread passages that
// mention a word THIS SESSION has used — the direction it was sent on, the slot's
@@ -193,23 +178,18 @@ const subjectWordsMax = 8
// does not contain. The division of labour is the usual one: the runtime supplies
// a fact (this text exists, you have not read it), the model decides what is in it.
//
// Mentioning one of those words is a FILTER, not a preference. Measured
// (2026-09-15, the first version of this, which ranked every unread passage by how
// much of its vocabulary was new): all eight excerpts it delivered were passages
// the question had nothing to do with — a chapter heading, 曹操's youth, 张角
// receiving the book — because "says the most you have not seen" and "is about
// this question" are anti-correlated: the passages that carry the subject share
// their vocabulary with what the session already read, so they score LOW. The
// Mentioning one of those words is a FILTER, not a preference. Ranking unread passages by
// how much of their vocabulary is new delivers passages the question has nothing to do
// with, because "says the most you have not seen" and "is about this question" are
// anti-correlated: the passages that carry the subject share their vocabulary with what the
// session already read, so they score LOW. The
// words the session itself used are what tells the two apart, and they are the
// model's words, not a lexicon.
func (s *SessionState) unreadPoolExcerpt() string {
// ONE per session, and that is deliberate. Across the first two runs of this
// mechanism (2026-09-15) it delivered twelve excerpts — a chapter heading, 曹操's
// youth, 张角 receiving the book, a 文丑 passage — and none of them carried a
// member the record was missing. The premise still holds (the passage naming
// 管亥 was in the pool for the whole of those runs), but the selection is not
// good enough to spend context on every flat turn, so it stays available as the
// session's last resort rather than a routine.
// ONE per session, and that is deliberate. The premise holds — such a passage really is
// in the pool and really is unread — but the selection is not reliable enough to spend
// context on every flat turn, so it stays available as the session's last resort rather
// than a routine.
if s.KB == nil || s.PoolRead {
return ""
}
@@ -294,9 +274,9 @@ func (s *SessionState) subjectWords() []string {
}
}
}
// The session's OWN queries first: they are the most specific thing it has
// said, and they are where the corpus's aliases enter (a session that asked
// about 关公 gets passages that say 关公, while the direction may only say 关羽).
// The session's OWN queries first: they are the most specific thing it has said, and
// they are where the corpus's aliases enter — asking with one alias retrieves passages
// that use another, while the direction may use only one.
for _, q := range s.SearchQueries {
add(q)
}
@@ -1,4 +1,4 @@
package harness
package runtime
import (
"strconv"
@@ -7,18 +7,18 @@ import (
"github.com/cloudwego/eino/schema"
"ragflow/internal/rag/advanced_rag/slots"
"ragflow/internal/rag/agentic-rag/slots"
)
// typedMembersSlot / typedCountSlot build the slots the record reads: a slot holds
// members or a number because the model DECLARED it (see package slots), so these are
// what a patch now produces.
func typedMembersSlot(id int, typ string, names ...string) Variable {
items := make([]slots.Member, 0, len(names))
items := make([]slots.Item, 0, len(names))
for _, n := range names {
items = append(items, slots.Member{Name: n})
items = append(items, slots.Item{Value: n})
}
v := slots.Members(items...)
v := slots.Items(items...)
rendered := slots.Render(v)
return Variable{ID: id, Type: typ, Candidate: &rendered, Value: &v}
}
@@ -373,7 +373,7 @@ func TestUnreadPoolExcerptShowsTextTheSessionHasNotSeen(t *testing.T) {
}
}
// TestSetShapedFollowsThePlannersDeclaration pins the half of the gate that reads the
// TestCoverageFollowsThePlannersDeclaration pins the half of the gate that reads the
// table: what the planner TYPED, never what a candidate looks like. A list-shaped
// candidate under a scalar type is prose that happens to contain separators —
// measured (2026-09-15, FRAMES) a slot typed "dataset" carried
@@ -381,99 +381,103 @@ func TestUnreadPoolExcerptShowsTextTheSessionHasNotSeen(t *testing.T) {
// reading of it is how a "how much shorter" record came to say "enumerated
// members: 16" and how a `[count]` slot on a "how many times larger" question once
// bought 24 continuation offers.
func TestSetShapedFollowsThePlannersDeclaration(t *testing.T) {
func TestCoverageFollowsThePlannersDeclaration(t *testing.T) {
value := State{State: []Variable{{ID: 0, Type: "entity", Candidate: strPtr("白马坡")}}}
if SetShaped(value) {
if CoverageOf(value).Set {
t.Error("a single-value table asks for no set")
}
counted := State{State: []Variable{{ID: 0, Type: "count", Candidate: strPtr("10")}}}
if !SetShaped(counted) {
if !CoverageOf(counted).Set {
t.Error("a count slot asks for a set")
}
quantity := State{State: []Variable{{ID: 0, Type: "number", Candidate: strPtr("2452 feet")}}}
if SetShaped(quantity) {
if CoverageOf(quantity).Set {
t.Error("a number slot is a value, not a set")
}
prose := State{State: []Variable{{ID: 0, Type: "dataset", Candidate: strPtr("Grace's、High、Falls")}}}
if SetShaped(prose) {
if CoverageOf(prose).Set {
t.Error("the table must not be read through a candidate's separators")
}
// The actor's declared forms are what the corpus is read with, and the recall list is
// one entry per operand — never one per (actor, act) pair.
shaped := State{State: []Variable{
{ID: 0, Type: "count", Terms: []string{"斩", "杀", "斩"}, Subject: "关羽|云长"},
{ID: 1, Type: "dataset"},
}}
cov := CoverageOf(shaped)
if !cov.Ok() {
t.Fatalf("coverage = %+v, want an enumeration", cov)
}
if actors := cov.Actors(); len(actors) != 2 || actors[0] != "关羽" || actors[1] != "云长" {
t.Errorf("actors = %v, want the two declared forms", actors)
}
if ops := cov.Operands(); len(ops) != 4 {
t.Errorf("operands = %v, want 2 actor forms + 2 deduped act words", ops)
}
}
// TestSetDirectionIsSeededWithTheMethod pins WHERE the enumeration method is delivered — in the
// TestEnumerationIsSeededWithTheMethod pins WHERE the enumeration method is delivered — in the
// seed, before the first turn — and, just as important, WHO gets it: only a table that declared the
// whole ENUMERATION shape (see EnumerationShaped).
// whole enumeration (a count/set/list slot, a NAME-carrying slot and the act words, see
// Coverage.Ok).
//
// Its first instruction — propose more candidates than you expect — is a decision taken before the
// first query: measured (2026-09-16, 三国/关羽) the same question answered eighteen members with the
// method in the seed of its `[count]` table and fourteen when it arrived a turn later. But the same
// instruction on a question whose answer is ONE value sends the session looking for members it does
// not need: measured (2026-09-16, FRAMES — 4 questions in flight, 300s deadline) the SetShaped-only
// not need: measured (2026-09-16, FRAMES — 4 questions in flight, 300s deadline) the shape-only
// gate seeded the value questions that merely contain a count and the run finished 0.833 with two
// timeouts against 0.875 with none.
func TestSetDirectionIsSeededWithTheMethod(t *testing.T) {
//
// What the seed carries alongside the method is the windows the enumeration FOUND, never the
// queries to make (see CoverageSet.Render): a query list is advice the model did not follow, a
// window is evidence with the chunk id a member is cited by.
func TestEnumerationIsSeededWithTheMethod(t *testing.T) {
loader := StringPromptLoader{"action_set": "SET / COUNT directions — the member list IS the work"}
method := "SET / COUNT directions — the member list IS the work"
// A table that declared count/set/list AND a NAME-carrying slot AND its act words: the
// enumeration strategy, seeded with the method and the corpus queries those words render into
// (see ScanPatterns) — the seed is where the session is told to ask the corpus for the act,
// which is the one thing its memory cannot do.
declared := State{State: []Variable{
{ID: 0, Type: "count", Candidate: strPtr("18"), Terms: []string{"斩", "杀"}, Subject: "关羽|云长"},
{ID: 1, Type: "dataset"},
}}
got := setProtocolFor(declared, loader)
if !strings.Contains(got, "关羽.*斩|云长.*斩") {
t.Errorf("a direction with declared act words must be seeded with their queries, got %q", got)
seed := "## The enumeration already ran for this direction\n\n- chunk_id=c1 \"云长手起刀落,斩孔秀于马下\"\n"
got := enumerationSeed(declared, loader, seed)
if !strings.Contains(got, method) || !strings.Contains(got, "斩孔秀于马下") {
t.Errorf("seed = %q, want the method AND the windows the enumeration found", got)
}
if !strings.Contains(got, "SET / COUNT directions") {
t.Errorf("the method must travel with them, got %q", got)
if strings.Contains(got, "one call per line") {
t.Errorf("seed = %q still lists queries to make", got)
}
// No enumeration (no clock left, no executor): the method travels alone. The seed never
// carries queries to make.
if got := enumerationSeed(declared, loader, ""); got != method {
t.Errorf("seed without an enumeration = %q, want the method alone", got)
}
// And now the three ways a table FAILS to be an enumeration, each of which must leave a value
// question alone. A count with no names: a count of EVENTS, which the planner declares act
// words for as well ("how many times had Brazil won the World Cup").
countsOnly := State{State: []Variable{
{ID: 0, Type: "count", Candidate: strPtr("5"), Terms: []string{"won", "trophy"}, Subject: "Brazil"},
}}
if got := setProtocolFor(countsOnly, loader); got != "" {
t.Errorf("a count of events must not be seeded with the member method, got %q", got)
// The ways a table FAILS to be an enumeration, each of which must leave a value question
// alone: a count of EVENTS (the planner declares act words for those too), one named thing
// with no count/set/list, a count with nothing declared to enumerate, a measured quantity, a
// list-shaped candidate under a scalar type, and a single value.
for _, tc := range []struct {
name string
table State
}{
{"a count of events", State{State: []Variable{{ID: 0, Type: "count", Candidate: strPtr("5"), Terms: []string{"won", "trophy"}, Subject: "Brazil"}}}},
{"one named thing", State{State: []Variable{{ID: 0, Type: "person", Terms: []string{"wrote"}, Subject: "the writer"}, {ID: 1, Type: "date"}}}},
{"a count with no act words", State{State: []Variable{{ID: 0, Type: "count", Candidate: strPtr("18")}}}},
{"a measured quantity", State{State: []Variable{{ID: 0, Type: "number", Candidate: strPtr("14")}}}},
{"a list-shaped candidate", State{State: []Variable{{ID: 1, Type: "entity", Candidate: strPtr("孔秀、孟坦")}}}},
{"a single value", State{State: []Variable{{ID: 0, Type: "date", Candidate: strPtr("1858")}}}},
} {
if got := enumerationSeed(tc.table, loader, seed); got != "" {
t.Errorf("%s must not be seeded with the member method, got %q", tc.name, got)
}
}
// Names with no count/set/list: a question about ONE named thing that also declared act words
// (the shape a multi-hop value question takes).
namesOnly := State{State: []Variable{
{ID: 0, Type: "person", Terms: []string{"wrote", "published"}, Subject: "the writer"},
{ID: 1, Type: "date"},
}}
if got := setProtocolFor(namesOnly, loader); got != "" {
t.Errorf("a value question holding one name must not be seeded with the member method, got %q", got)
}
// A count with no act words: nothing was declared to enumerate.
counted := State{State: []Variable{{ID: 0, Type: "count", Candidate: strPtr("18")}}}
if got := setProtocolFor(counted, loader); got != "" {
t.Errorf("a count with no declared act words must not be seeded, got %q", got)
}
// `number` is the planner's label for a measured QUANTITY, and it typed the same
// question both ways on two runs of 2026-09-16: not a seed trigger, and exactly
// what the batch path exists for.
quantity := State{State: []Variable{{ID: 0, Type: "number", Candidate: strPtr("14")}}}
if got := setProtocolFor(quantity, loader); got != "" {
t.Errorf("a number-typed table must not be seeded, got %q", got)
}
// Nor is a candidate's punctuation a declaration: a list-shaped candidate under a
// scalar type is how the permissive gate seeded 44 of 67 FRAMES sessions.
prose := State{State: []Variable{{ID: 1, Type: "entity", Candidate: strPtr("孔秀、孟坦")}}}
if got := setProtocolFor(prose, loader); got != "" {
t.Errorf("a list-shaped candidate must not seed the method, got %q", got)
}
value := State{State: []Variable{{ID: 0, Type: "date", Candidate: strPtr("1858")}}}
if got := setProtocolFor(value, loader); got != "" {
t.Errorf("a single-value direction must not be seeded, got %q", got)
}
// A loader that predates the template yields nothing rather than panicking (see
// loadOptionalPrompt): its sessions run exactly as they did before it existed.
if got := setProtocolFor(counted, StringPromptLoader{}); got != "" {
t.Errorf("a loader without action_set must yield nothing, got %q", got)
// A loader that predates the template still travels with the evidence rather than
// panicking (see loadOptionalPrompt): the windows are the part the model cannot get back.
if got := enumerationSeed(declared, StringPromptLoader{}, seed); got != strings.TrimSpace(seed) {
t.Errorf("a loader without action_set must yield the evidence alone, got %q", got)
}
}
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -29,30 +29,25 @@ import (
"ragflow/internal/agent/chat"
)
// LLM-call instrumentation for the harness.
// LLM-call instrumentation for the retrieval.
//
// Mirrors Python harness/stats.py. Every phase of the pipeline (route /
// planner / orchestrator / direct / sufficiency / finalize, ...) drives the LLM,
// and each call is attributed to the phase that was executing. Phase wall-clock
// is measured here rather than summing LLM latency, which is meaningless when
// calls run in parallel.
// Every phase of the pipeline (route / planner / orchestrator / direct / sufficiency /
// finalize, ...) drives the LLM, and each call is attributed to the phase that was
// executing. Phase wall-clock is measured here rather than summing LLM latency, which is
// meaningless when calls run in parallel.
//
// Python stores the current phase and the active stats in ContextVars so
// parallel asyncio tasks get independent accounting. Go has no ContextVars; the
// same isolation is achieved by carrying both on the context.Context, which is
// already per-request (and per-goroutine when a phase fans out).
// The current phase and the active stats are carried on the context.Context, which is
// already per-request (and per-goroutine when a phase fans out) — that is what gives
// parallel work independent accounting.
// Canonical pipeline order for the per-phase usage table. Phases are listed in
// execution order so the log reads top-to-bottom like the actual flow,
// regardless of which phase first touched the counters. Phases not in this list
// are appended afterwards, alphabetically.
// This list matches Python _PHASE_ORDER (harness/stats.py) EXACTLY, so a Go run
// and a Python run emit comparable usage tables. Python's _PHASE_ORDER omits
// dynamic/draft/sca/rewrite (they are real @in_phase phases in Python too, but
// not in its canonical list, so Python appends them alphabetically at the end);
// compute is a Go-only phase (arithmetic.Compute). The snapshot/Log logic below
// re-appends any phase not present here alphabetically, mirroring Python, so all
// of them still appear in both runs in the same relative position.
// This list is the canonical order: it omits dynamic/draft/sca/rewrite (real phases, but
// not part of the canonical list) and compute is a this-file-only phase
// (arithmetic.Compute). The snapshot/Log logic below re-appends any phase not present here
// alphabetically, so all of them still appear in the same relative position.
var phaseOrder = []string{
"formalize",
"route",
@@ -66,7 +61,7 @@ var phaseOrder = []string{
"finalize",
}
// Phase names used by the harness (Python @in_phase labels).
// Phase names used by the retrieval.
const (
PhaseFormalize = "formalize"
PhaseRoute = "route"
@@ -92,7 +87,7 @@ type phaseRoundKey struct {
}
// LLMUsageStats holds per-phase LLM call, wall-clock, and token counters for
// one harness run.
// one runtime run.
type LLMUsageStats struct {
mu sync.Mutex
@@ -139,12 +134,11 @@ func NewLLMUsageStats() *LLMUsageStats {
}
}
// elapsedMs returns a monotonic-aware wall duration in milliseconds. Python's
// stats use time.perf_counter() (a monotonic clock); Go's time.Time carries a
// monotonic reading too as long as we keep the struct (time.Now().UnixNano()
// would strip it and expose wall-clock step-backs), so we subtract the stored
// start time directly. This matches perf_counter's guarantee that phase/round
// durations never go negative when the system clock is stepped backwards.
// elapsedMs returns a monotonic-aware wall duration in milliseconds. Go's time.Time carries
// a monotonic reading as long as the struct is kept (time.Now().UnixNano() would strip it
// and expose wall-clock step-backs), so the stored start time is subtracted directly. That
// guarantees phase/round durations never go negative when the system clock is stepped
// backwards.
func elapsedMs(now, start time.Time) float64 {
return now.Sub(start).Seconds() * 1000.0
}
@@ -208,9 +202,8 @@ func (s *LLMUsageStats) RecordRoundClaims(phaseName string, count int) {
s.roundClaimCounts[phaseName] = counts
}
// notePhaseEnter / notePhaseExit implement the re-entrancy rule from Python:
// the same phase may be wrapped several times along one call path, and only the
// outermost interval is timed.
// notePhaseEnter / notePhaseExit implement the re-entrancy rule: the same phase may be
// wrapped several times along one call path, and only the outermost interval is timed.
func (s *LLMUsageStats) notePhaseEnter(phaseName string, entryRound int) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -282,7 +275,7 @@ func (s *LLMUsageStats) notePhaseExit(phaseName string, entryRound int) {
}
}
// Snapshot mirrors Python LLMUsageStats.snapshot: rows keyed by phase in
// Snapshot: rows keyed by phase in
// canonical pipeline order.
func (s *LLMUsageStats) Snapshot() map[string]map[string]any {
s.mu.Lock()
@@ -338,9 +331,7 @@ func (s *LLMUsageStats) Snapshot() map[string]map[string]any {
return rows
}
// ---------------------------------------------------------------------------
// Context plumbing
// ---------------------------------------------------------------------------
type statsCtxKey struct{}
type phaseCtxKey struct{}
@@ -366,8 +357,8 @@ func CurrentPhase(ctx context.Context) string {
return "unknown"
}
// Phase mirrors Python's phase() context manager: it marks the enclosed block
// as executing `name` and accrues its wall-clock into the bound stats.
// Phase marks the enclosed block as executing `name` and accrues its wall-clock into the
// bound stats.
//
// The returned func MUST be called when the block ends (defer it). Nesting the
// same phase name is supported: only the outermost interval is timed, so the
@@ -387,14 +378,14 @@ func Phase(ctx context.Context, name string) (context.Context, func()) {
}
}
// InPhase mirrors Python's in_phase decorator: it runs fn inside Phase(name).
// InPhase runs fn inside Phase(name).
func InPhase(ctx context.Context, name string, fn func(context.Context) error) error {
ctx, done := Phase(ctx, name)
defer done()
return fn(ctx)
}
// RecordExternalResponse mirrors Python record_external_response: records a raw
// RecordExternalResponse: records a raw
// completion response that bypasses CountingInvoker, including token usage.
// action_session calls this for the two raw model calls it makes directly
// (action_session.py:_acompletion/1086).
@@ -422,26 +413,25 @@ func recordResponseUsage(stats *LLMUsageStats, phase string, usage *chat.Usage,
}
}
// RecordRound mirrors Python record_round.
// RecordRound
func RecordRound(ctx context.Context, name string) {
if s := CurrentStats(ctx); s != nil {
s.RecordRound(name)
}
}
// RecordRoundClaims mirrors Python record_round_claims.
// RecordRoundClaims
func RecordRoundClaims(ctx context.Context, name string, count int) {
if s := CurrentStats(ctx); s != nil {
s.RecordRoundClaims(name, count)
}
}
// Log emits the per-phase usage table. Mirrors Python LLMUsageStats.log.
// Log emits the per-phase usage table.
//
// With orchestrator-round data it expands hierarchically: each round repeats
// its "orchestrator" row with the nested sub-phases (claim_research /
// sufficiency / grounded) indented underneath, matching the Python rendering.
// Phases outside the loop (route / planner / finalize) are listed flat.
// With orchestrator-round data it expands hierarchically: each round repeats its
// "orchestrator" row with the nested sub-phases (claim_research / sufficiency / grounded)
// indented underneath. Phases outside the loop (route / planner / finalize) are listed flat.
func (s *LLMUsageStats) Log(logger *log.Logger) {
rows := s.Snapshot()
if len(rows) == 0 {
@@ -476,8 +466,7 @@ func (s *LLMUsageStats) Log(logger *log.Logger) {
sort.Strings(rest)
phases = append(phases, rest...)
// Per-round structure, read from the locked snapshot (Python reads
// self.round_phase_times_ms / self.round_times directly).
// Per-round structure, read from the locked snapshot.
orchRT := []float64{}
if orchRow, ok := rows[PhaseOrchestrator]; ok {
if v, ok := orchRow["round_times"].([]float64); ok {
@@ -502,8 +491,8 @@ func (s *LLMUsageStats) Log(logger *log.Logger) {
fmt.Sprintf(" %-16s %7s %10s %12s %10s %10s", "phase", "llm_calls", "prompt_tok", "output_tok", "total_tok", "time(s)"),
}
// phaseLabel mirrors Python's phase_label: claim_research gets a
// "(N)" suffix with the round's claim count when known.
// phaseLabel: claim_research gets a "(N)" suffix with the round's claim count when
// known.
phaseLabel := func(p string, r map[string]any, roundIdx int) string {
label := p
if p == PhaseClaimResearch {
@@ -513,9 +502,8 @@ func (s *LLMUsageStats) Log(logger *log.Logger) {
}
return label
}
// row mirrors Python's custom_row / row: the label is printed verbatim
// (orchestrator round headers pass a custom label), token columns come
// from rows[p]).
// row: the label is printed verbatim (orchestrator round headers pass a custom label),
// token columns come from rows[p].
row := func(indent, label, p string, tMs float64) string {
r := rows[p]
return fmt.Sprintf("%s%-16s %7d %10d %12d %10d %10.1f",
@@ -572,12 +560,11 @@ func joinLines(lines []string) string {
return out
}
// CountingInvoker mirrors Python CountingChatModel: it wraps a chat.Invoker and
// CountingInvoker: it wraps a chat.Invoker and
// records calls / failures / token usage against the phase carried on ctx.
//
// Unlike the Python proxy (which falls back to a bundle-wide stats object), the
// Go version records only when stats are bound to the context — matching how
// Python's _CURRENT_STATS resolution behaves for the innermost active stats.
// Unlike a bundle-wide fallback, this records only when stats are bound to the context,
// which is what gives the innermost active stats their accounting.
type CountingInvoker struct {
Inner chat.Invoker
Stats *LLMUsageStats
@@ -610,13 +597,10 @@ func (c *CountingInvoker) Invoke(ctx context.Context, db *gorm.DB, req chat.Requ
// AND is counted. Without this, StreamComplete's type assertion
// (m.Invoker.(chat.StreamingInvoker)) fails on the wrapper and the caller falls
// back to a one-shot (non-streaming) Invoke — losing both the stream and the
// accounting. Mirrors Python CountingChatModel.async_chat_streamly /
// async_chat_streamly_delta, which record usage in a finally block so the call
// is counted even when the inner model raised (last_usage is None then, so
// record_usage is a no-op — the same outcome as the success-only branch below).
// accounting. Usage is recorded even when the inner model raised (no usage is reported
// then, so the record step is a no-op — the same outcome as the success-only branch below).
//
// record_usage is taken from the returned *Response, mirroring Python's
// _last_usage(self._chat_mdl). If the inner invoker is not itself a
// Usage is taken from the returned *Response. If the inner invoker is not itself a
// StreamingInvoker, we decline rather than silently downgrade to a blocking
// Invoke, so callers keep their existing "fall back to the one-shot call"
// behaviour.
@@ -631,7 +615,7 @@ func (c *CountingInvoker) Stream(ctx context.Context, db *gorm.DB, req chat.Requ
}
inner, ok := c.Inner.(chat.StreamingInvoker)
if !ok {
return nil, fmt.Errorf("harness: chat invoker %T does not support streaming", c.Inner)
return nil, fmt.Errorf("runtime: chat invoker %T does not support streaming", c.Inner)
}
resp, err := inner.Stream(ctx, db, req, onDelta)
if err != nil {
@@ -1,4 +1,4 @@
package harness
package runtime
import (
"bytes"
@@ -10,14 +10,14 @@ import (
"ragflow/internal/agent/chat"
)
// TestLogHierarchicalRounds verifies that Log mirrors Python LLMUsageStats.log:
// the orchestrator phase expands into one "orchestrator round N" row per round,
// with the in-loop sub-phases (claim_research / sufficiency) nested underneath,
// while the out-of-loop phases (route / planner / finalize) stay flat.
// TestLogHierarchicalRounds verifies the log shape: the orchestrator phase expands into one
// "orchestrator round N" row per round, with the in-loop sub-phases (claim_research /
// sufficiency) nested underneath, while the out-of-loop phases (route / planner / finalize)
// stay flat.
//
// NOTE: this test cannot run while the unrelated pre-existing break in
// internal/agent/tool/retrieval_nlp.go (RankFeature) blocks compilation of the
// harness test binary. It will run once that is resolved.
// runtime test binary. It will run once that is resolved.
func TestLogHierarchicalRounds(t *testing.T) {
s := NewLLMUsageStats()
ctx := WithStats(context.Background(), s)
@@ -121,15 +121,10 @@ func TestCountingInvokerCountsStreaming(t *testing.T) {
}
}
// TestCountingInvokerStreamDeclinesWithoutStreamingInner mirrors Python's
// invariant that the wrapper only streams when the inner model can: a non-
// streaming inner returns an explicit error rather than silently downgrading to
// a blocking Invoke (which would look like a one-shot call in the stats).
// TestCountingInvokerStreamDeclinesWithoutStreamingInner mirrors Python's
// invariant that the wrapper only streams when the inner model can: a non-
// streaming inner returns an explicit error rather than silently downgrading to
// a blocking Invoke (which would look like a one-shot call in the stats).
// TestCountingInvokerStreamDeclinesWithoutStreamingInner pins the invariant that the wrapper
// only streams when the inner model can: a non-streaming inner returns an explicit error rather
// than silently downgrading to a blocking Invoke (which would look like a one-shot call in the
// stats).
func TestCountingInvokerStreamDeclinesWithoutStreamingInner(t *testing.T) {
wrapped := &CountingInvoker{Inner: &plainInvoker{}}
if _, err := wrapped.Stream(context.Background(), nil, chat.Request{}, nil); err == nil {
@@ -137,6 +132,6 @@ func TestCountingInvokerStreamDeclinesWithoutStreamingInner(t *testing.T) {
}
}
// capturingInvoker records the last Request and returns a native tool call so we
// can verify the seam forwards the declared tools (Python native tools) and reads
// the model's calls back out of the native tool_calls field.
// capturingInvoker records the last Request and returns a native tool call so we can verify
// the seam forwards the declared tools and reads the model's calls back out of the native
// tool_calls field.
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -29,22 +29,20 @@ import (
// Ask the chat model to answer a question from a compiled-structure outline.
//
// Mirrors Python harness/structure_qa.py. Both Go navigation paths that read
// compiled rows render entities + relations into the same compact outline, ask
// the model whether that outline alone answers the question, and use the
// returned relevant_entities to pull the underlying source chunks even when the
// outline is NOT sufficient (so evidence still flows back to the caller).
// Both navigation paths that read compiled rows render entities + relations into the same
// compact outline, ask the model whether that outline alone answers the question, and use the
// returned relevant_entities to pull the underlying source chunks even when the outline is NOT
// sufficient (so evidence still flows back to the caller).
//
// Lives apart from navigation.go because it is pure prompt/render work with no
// store access, and because both callers would otherwise import it from a file
// that in turn imports them.
// structureQATemperature pins Python structure_qa.py:79's
// {"temperature": 0.2} for the outline-verdict call.
// structureQATemperature pins the temperature for the outline-verdict call.
const structureQATemperature = 0.2
// RenderStructure renders a compiled structure (entities + relations) as a
// compact outline for the prompt. Mirrors Python _render_structure:
// compact outline for the prompt:
//
// Entities:
// - name (type): description
@@ -98,15 +96,13 @@ func RenderStructure(entities, relations []map[string]any) string {
// AskStructure asks the chat model to answer `topic` from the rendered outline.
//
// Mirrors Python _ask_structure. Returns (answer, relevant_entity_names):
// answer is empty unless the model judged the outline sufficient; the names are
// always returned so the caller can pull the underlying source chunks. `noun`
// Returns (answer, relevant_entity_names): answer is empty unless the model judged the
// outline sufficient; the names are always returned so the caller can pull the underlying
// source chunks. `noun`
// is the display noun ("catalog" / "mindmap" / "knowledge graph"); `label` is
// the log tag. `model` is the request-scoped chat model (Python passes
// tools.chat_mdl); a nil model means no model is available and the call skips,
// mirroring Python's no-op when tools.chat_mdl is absent. Never raises: on any
// chat/parse failure both results are empty, matching Python's except-return of
// an empty verdict.
// the log tag. `model` is the request-scoped chat model; a nil model means no model is
// available and the call skips. Never raises: on any chat/parse failure both results are
// empty.
func AskStructure(ctx context.Context, model SessionModel, topic, noun, label string, entities, relations []map[string]any) (string, []string) {
if model == nil {
_LOG.Printf("[%s] structure QA skipped (no chat model)", label)
@@ -116,11 +112,8 @@ func AskStructure(ctx context.Context, model SessionModel, topic, noun, label st
rendered := RenderStructure(entities, relations)
user := fmt.Sprintf("Question:\n%s\n\n%s:\n%s\n\nOutput JSON:", topic, capitalizeWord(noun), rendered)
// Mirror Python _ask_structure:
// message_fit_in(form_message(system, user), tools.chat_mdl.max_length)
// tools.chat_mdl.max_length is exposed via ContextLengthModel; when absent,
// the 8192 default (chat EffectiveContextLength) applies, matching Python's
// LLM.max_length defaulting when the model config omits max_tokens.
// The context length is exposed via ContextLengthModel; when absent, the 8192 default
// (chat.EffectiveContextLength) applies, i.e. when the model config omits it.
budget := 0
if cl, ok := model.(ContextLengthModel); ok {
budget = cl.ContextLength()
@@ -136,7 +129,7 @@ func AskStructure(ctx context.Context, model SessionModel, topic, noun, label st
return "", nil
}
// FitMessages may prepend/trim a system message; re-extract it so the model
// call is exactly [system, user...] as Python sends msg[0] then msg[1:].
// call is exactly [system, user...].
if len(fitted) > 0 && fitted[0].Role == schema.System {
system = fitted[0].Content
fitted = fitted[1:]
@@ -144,8 +137,8 @@ func AskStructure(ctx context.Context, model SessionModel, topic, noun, label st
msgs := make([]schema.Message, 0, 1+len(fitted))
msgs = append(msgs, *schema.SystemMessage(system))
msgs = append(msgs, fitted...)
// Python hardcodes {"temperature": 0.2} for this node (structure_qa.py:79)
// — a stable verdict on a mechanical render, so it must not sample hot. The
// The temperature is pinned to 0.2 for this node: a stable verdict on a mechanical
// render, so it must not sample hot. The
// value is a pinned constant: every production carrier implements
// TemperatureModel (compile-time assertion on InvokerSessionModel), so the
// temperature is always sent; a carrier without per-call temperature
@@ -155,7 +148,7 @@ func AskStructure(ctx context.Context, model SessionModel, topic, noun, label st
if tm, ok := model.(TemperatureModel); ok {
resp, err = tm.CompleteWithTemperature(ctx, msgs, nil, structureQATemperature)
} else {
_LOG.Printf("[%s] model %T cannot carry per-call temperature; using its default (Python would send %v)", label, model, structureQATemperature)
_LOG.Printf("[%s] model %T cannot carry per-call temperature; using its default (wanted %v)", label, model, structureQATemperature)
resp, err = model.Complete(ctx, msgs, nil)
}
if err != nil {
@@ -201,10 +194,8 @@ func strAny(v any) string {
}
}
// capitalizeWord mirrors Python str.capitalize(): uppercase the first rune and
// lowercase every remaining rune. Python lowercases the rest, so e.g.
// "hELLo".capitalize() -> "Hello"; the previous Go version left the tail
// untouched ("HELLO"), which diverged.
// capitalizeWord uppercases the first rune and lowercases every remaining rune, so e.g.
// "hELLo" -> "Hello"; leaving the tail untouched produced "HELLO", which diverged.
func capitalizeWord(s string) string {
if s == "" {
return s
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -22,7 +22,7 @@ import (
"testing"
)
// entity map helper (mirrors Python structure_qa.py rendering inputs).
// entity map helper (the rendering inputs).
func saEntity(name, typ, desc string) map[string]any {
return map[string]any{"name": name, "type": typ, "description": desc}
}
@@ -31,10 +31,9 @@ func saRelation(from, to, typ string) map[string]any {
return map[string]any{"from": from, "to": to, "type": typ}
}
// RenderStructure feeds the LLM prompt in graph_explore (via AskStructure); it
// must render exactly "Name (Type): Description" for entities (Type defaulting
// to "other") and "From -[Type]-> To" for relations (Type defaulting to
// "related"), mirroring Python _render_structure.
// RenderStructure feeds the LLM prompt in graph_explore (via AskStructure); it must render
// exactly "Name (Type): Description" for entities (Type defaulting to "other") and
// "From -[Type]-> To" for relations (Type defaulting to "related").
func TestRenderStructure(t *testing.T) {
entities := []map[string]any{
saEntity("OmiyaSoft", "company", "game dev"),
@@ -92,16 +91,15 @@ func TestRenderStructureCapsAtLimits(t *testing.T) {
}
}
// Python _render_structure joins an empty list, so both-empty input renders "".
// An empty list is joined to "", so both-empty input renders "".
func TestRenderStructureEmpty(t *testing.T) {
if out := RenderStructure(nil, nil); out != "" {
t.Errorf("empty structure must render empty string, got %q", out)
}
}
// capitalizeWord must mirror Python str.capitalize(): uppercase the first rune
// AND lowercase every remaining rune. The previous Go version left the tail
// untouched, so "hELLo" produced "HELLO" instead of Python's "Hello".
// capitalizeWord: uppercase the first rune AND lowercase every remaining rune. Leaving the
// tail untouched produced "HELLO" instead of "Hello".
func TestCapitalizeWord(t *testing.T) {
cases := []struct {
in, want string
@@ -121,13 +119,10 @@ func TestCapitalizeWord(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// AskStructure: request-scoped model + temperature 0.2 (Python _ask_structure).
// ---------------------------------------------------------------------------
// AskStructure: request-scoped model + temperature 0.2.
// TestAskStructureNilModelSkips verifies a nil model short-circuits to an empty
// verdict instead of reaching the global invoker (mirrors Python's no-op when
// tools.chat_mdl is absent).
// TestAskStructureNilModelSkips verifies a nil model short-circuits to an empty verdict
// instead of reaching the global invoker (no model is available).
func TestAskStructureNilModelSkips(t *testing.T) {
answer, relevant := AskStructure(context.Background(), nil, "Q", "knowledge graph", "Graph exploration", nil, nil)
if answer != "" || len(relevant) != 0 {
@@ -136,7 +131,7 @@ func TestAskStructureNilModelSkips(t *testing.T) {
}
// TestAskStructureUsesTemperatureTwoTenths verifies the structure verdict is a
// mechanical rewrite drawn at 0.2, matching Python {"temperature": 0.2}.
// mechanical rewrite drawn at 0.2.
func TestAskStructureUsesTemperatureTwoTenths(t *testing.T) {
mdl := &tempRecordingModel{replies: []*ModelReply{{
Content: `{"is_sufficient": true, "answer": "founded in 1984", "relevant_entities": ["OmiyaSoft"]}`,
@@ -22,7 +22,7 @@
// (StepReporter.Emit). The developer log and the user-visible trace are separate
// channels fed from that one call, so they cannot drift, and nothing becomes
// visible (or invisible) because a log line was reworded.
package harness
package runtime
import (
"context"
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"bytes"
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -25,8 +25,7 @@ import (
"ragflow/internal/service/nav"
)
// The claim_agg navigation-tree router: the Go mirror of Python
// dataset_api_service.py `_search_layers_navigation_tree(router="claim_agg")`.
// The claim_agg navigation-tree router.
//
// The claim leg runs first and decides the ranking outright when it hits — a
// claim is an atomic proposition carrying its own vector and verbatim
@@ -36,9 +35,9 @@ import (
//
// Like chunk_agg (internal/service/nav), the algorithm consumes the agentic
// claim-recall capability through the NavTreeRouter interface and keeps the
// summarizer injected, so the harness stays free of the service layer.
// summarizer injected, so the runtime stays free of the service layer.
// claimAggPool mirrors Python _NAV_CLAIM_POOL: the per-leg store limit for the
// claimAggPool: the per-leg store limit for the
// claim routing leg, wide enough for a document to collect several claim hits
// before the per-document roll-up (the aggregation rewards multi-hit docs).
const claimAggPool = 256
@@ -60,13 +59,11 @@ type ClaimAggRouter struct {
Deps SearchDeps
// Fallback is the chunk_agg router used when the claim leg is empty.
Fallback NavTreeRouter
// Summarize loads the nav_doc summary per routed document (Python
// _nav_doc_summaries). Nil leaves the summaries empty.
// Summarize loads the nav_doc summary per routed document. Nil leaves the summaries empty.
Summarize nav.DocSummarizer
}
// claimAggBucket is the per-document roll-up of claim hits (Python
// _nav_bucket_compiled_rows): total, best and hit count per doc.
// claimAggBucket is the per-document roll-up of claim hits: total, best and hit count per doc.
type claimAggBucket struct {
docID string
total float64
@@ -90,21 +87,20 @@ func (r *ClaimAggRouter) Route(ctx context.Context, tenantID, kbID, query string
deps := r.Deps
deps.TenantID = tenantID
deps.KbIDs = []string{kbID}
// Python queries the two compilers SEPARATELY, never as one mixed
// condition (:4230-4239): tree first — it is the compiled benchmark path —
// then page_index only when the tree pass came back empty. Each pass pins
// compile_kwd and row_types=("claim",).
// The two compilers are queried SEPARATELY, never as one mixed condition: tree first — it is
// the compiled benchmark path — then page_index only when the tree pass came back empty. Each
// pass pins compile_kwd and row_types=("claim",).
claims := recallDatasetClaimsFiltered(ctx, deps, query, claimAggPool, []string{"tree"}, []string{"claim"})
if len(claims) == 0 {
claims = recallDatasetClaimsFiltered(ctx, deps, query, claimAggPool, []string{"page_index", "pageindex"}, []string{"claim"})
}
if len(claims) == 0 {
// No claim rows (or the recall failed): a legitimate empty leg — the
// chunk leg decides (Python _search_layers_navigation_tree:4053-4057).
// chunk leg decides.
return fallback()
}
// Roll the claim hits up per document (Python _nav_bucket_compiled_rows):
// Roll the claim hits up per document:
// the doc score lives on each claim's similarity.
agg := map[string]*claimAggBucket{}
for _, c := range claims {
@@ -124,9 +120,8 @@ func (r *ClaimAggRouter) Route(ctx context.Context, tenantID, kbID, query string
}
}
// doc_scope: Python filters the store condition before the recall
// (:4186-4187); post-filtering the rolled-up buckets is equivalent here
// because the recall is threshold-free.
// doc_scope: the store condition is filtered before the recall; post-filtering the rolled-up
// buckets is equivalent here because the recall is threshold-free.
scope := make(map[string]bool, len(docScope))
for _, d := range docScope {
if d = strings.TrimSpace(d); d != "" {
@@ -145,8 +140,7 @@ func (r *ClaimAggRouter) Route(ctx context.Context, tenantID, kbID, query string
// chunk leg (which applies the same scope) may reach further.
return fallback()
}
// Order by the damped DocScore (Python _nav_rank_compiled_buckets), best
// similarity as the tie-break.
// Order by the damped DocScore, best similarity as the tie-break.
sort.SliceStable(ranked, func(i, j int) bool {
si := claimDocScore(ranked[i].total, ranked[i].hits)
sj := claimDocScore(ranked[j].total, ranked[j].hits)
@@ -158,7 +152,7 @@ func (r *ClaimAggRouter) Route(ctx context.Context, tenantID, kbID, query string
if topK > 0 && len(ranked) > topK {
ranked = ranked[:topK]
}
// Same focus cap as the sibling routers (Python _NAV_DOC_FOCUS_LIMIT = 3):
// Same focus cap as the sibling routers (3):
// routing to many documents makes the agent carry that many times the
// evidence in every later round.
const navDocFocusLimit = 3
@@ -1,7 +1,5 @@
// Package-level claim recall for the agentic harness: a KB-wide FLAT search
// over claim/evidence rows (entity_type_kwd="claim"), mirroring Python
// harness/tools/navigation.py::recall_dataset_claims and its prefetch wiring
// (action_session.py::_claim_prefetch + agentic_rag_graph.py channel 0).
// Package-level claim recall for the agentic runtime: a KB-wide FLAT search over
// claim/evidence rows (entity_type_kwd="claim"), plus its prefetch wiring.
//
// Two legs per search — BM25 over content_ltks/content_sm_ltks and KNN over
// the claim rows' q_<dim>_vec — fused by reciprocal rank. No similarity
@@ -9,7 +7,7 @@
// keeps recall alive without an embedder, so claim recall can only ever ADD
// to a search result.
package harness
package runtime
import (
"context"
@@ -27,46 +25,39 @@ import (
)
const (
// ClaimPrefetchTopN mirrors Python _CLAIM_PREFETCH_TOP_N.
// ClaimPrefetchTopN
ClaimPrefetchTopN = 6
// ClaimEvidenceChars caps the rendered verbatim quote (Python
// _STRUCT_CLAIM_EVIDENCE_CHARS). 1200 over 600: the quote must be
// ClaimEvidenceChars caps the rendered verbatim quote. 1200 over 600: the quote must be
// self-sufficient in ONE shot — a thin quote makes the SCA judge the
// context insufficient, and one extra re-search turn costs a full
// 10k+-token prompt.
ClaimEvidenceChars = 1200
// claimRecallLegFloor is the per-leg store limit floor (Python
// `limit = max(top_n, 32)`).
// claimRecallLegFloor is the per-leg store limit floor (max(top_n, 32)).
claimRecallLegFloor = 32
// claimPrefetchCacheTTL / claimPrefetchCacheCap mirror Python
// _CLAIM_PREFETCH_CACHE_TTL/_CAP: the agent re-issues near-identical
// claimPrefetchCacheTTL / claimPrefetchCacheCap: the agent re-issues near-identical
// queries across turns and each miss costs one KNN round-trip per KB.
claimPrefetchCacheTTL = 300.0
claimPrefetchCacheCap = 64
// EvidenceQuoteChars caps the verbatim quote in the GRAPH fan-out's
// channel-0 pseudo chunks (Python agentic_rag_graph.py:723 `[:400]`). The
// action-session prefetch uses the longer ClaimEvidenceChars (1200,
// _STRUCT_CLAIM_EVIDENCE_CHARS) — the two conventions are deliberately
// different on the Python side and must not be conflated.
// EvidenceQuoteChars caps the verbatim quote in the GRAPH fan-out's channel-0 pseudo
// chunks. The action-session prefetch uses the longer ClaimEvidenceChars (1200) — the two
// conventions are deliberately different and must not be conflated.
EvidenceQuoteChars = 400
)
// claimRowTypes lists the entity_type_kwd values the agentic claim search
// matches: Python _evidence_row_types resolves to ("claim",) for every
// compile kind (page_index's pre-rename fact/conclusion spellings are not
// searched — a recompile retypes them).
var _ = []string{"claim", "fact", "conclusion"} // legacy spellings, API-side only (Python CLAIM_ROW_TYPES)
// claimRowTypes lists the entity_type_kwd values the agentic claim search matches:
// ("claim",) for every compile kind (page_index's pre-rename fact/conclusion spellings are
// not searched — a recompile retypes them).
var _ = []string{"claim", "fact", "conclusion"} // legacy spellings, API-side only
// claimRowTypes lists the entity_type_kwd values that carry a claim (Python
// _EVIDENCE_ROW_TYPES_BY_COMPILE flattened: every compiler writes "claim"
// now; the legacy spellings keep pre-rename rows visible).
// claimRowTypes lists the entity_type_kwd values that carry a claim: every compiler writes
// "claim" now; the legacy spellings keep pre-rename rows visible.
var claimRowTypes = []string{"claim"}
// compilationKwds mirrors Python _COMPILATION_KWDS — the compile_kwd values
// compilationKwds: the compile_kwd values
// the knowledge-compilation paths write, used by the has-compilation probe.
var compilationKwds = []string{"tree", "page_index", "pageindex", "timeline", "dataset_nav"}
// ClaimHit is one recalled claim, in Python's recall_dataset_claims shape.
// ClaimHit is one recalled claim.
type ClaimHit struct {
Name string
Description string
@@ -111,10 +102,9 @@ func claimIndexName(deps SearchDeps) string {
return fmt.Sprintf("ragflow_%s", deps.TenantID)
}
// DatasetHasCompilation reports whether any in-scope KB carries compiled
// rows (Python dataset_has_compilation + dataset_compilation_kinds): one
// cheap probe per KB set, TTL-cached. Probe failures FAIL OPEN (true) so a
// glitch never silently disables claim recall.
// DatasetHasCompilation reports whether any in-scope KB carries compiled rows: one cheap
// probe per KB set, TTL-cached. Probe failures FAIL OPEN (true) so a glitch never silently
// disables claim recall.
func DatasetHasCompilation(ctx context.Context, deps SearchDeps) bool {
de := claimEngine(deps)
if de == nil || len(deps.KbIDs) == 0 {
@@ -140,7 +130,7 @@ func DatasetHasCompilation(ctx context.Context, deps SearchDeps) bool {
Filter: map[string]interface{}{"compile_kwd": compilationKwds},
})
if err != nil {
has = true // fail open, mirroring Python
has = true // fail open
} else {
has = res != nil && len(res.Chunks) > 0
}
@@ -159,9 +149,9 @@ func RecallDatasetClaims(ctx context.Context, deps SearchDeps, query string, top
}
// recallDatasetClaimsFiltered is the recall core with optional compile_kwd /
// entity_type_kwd pinning (Python claim_agg queries ("tree","claim") and
// ("page_index","claim") as two separate passes; the session-level recall
// passes nil/nil and matches row_types=("claim",) unconditionally).
// entity_type_kwd pinning: the pinning queries ("tree","claim") and ("page_index","claim")
// run as two separate passes, while the session-level recall passes nil/nil and matches
// row_types=("claim",) unconditionally.
func recallDatasetClaimsFiltered(ctx context.Context, deps SearchDeps, query string, topN int, compileKwds, rowTypes []string) []*ClaimHit {
de := claimEngine(deps)
if de == nil || deps.TenantID == "" || len(deps.KbIDs) == 0 {
@@ -306,9 +296,8 @@ func parseClaimHit(row map[string]interface{}) *ClaimHit {
return hit
}
// rrfFuseClaims fuses the retrieval legs by reciprocal rank (Python
// _rrf_fuse, k=60): each fused hit keeps its best leg score and gains a
// 1-based rank.
// rrfFuseClaims fuses the retrieval legs by reciprocal rank (k=60): each fused hit keeps
// its best leg score and gains a 1-based rank.
func rrfFuseClaims(legs ...[]*ClaimHit) []*ClaimHit {
const k = 60
type key struct {
@@ -343,18 +332,15 @@ func rrfFuseClaims(legs ...[]*ClaimHit) []*ClaimHit {
return out
}
// ClaimPseudoChunks renders recalled claims as the pool-shaped pseudo chunks
// the GRAPH fan-out's channel 0 admits (Python agentic_rag_graph.py
// _collect_evidence :712-733): the row leads with the literal "[evidence]"
// prefix, the quote is a LITERAL quoted span capped at EvidenceQuoteChars
// (400), and the whole content is capped at 1200. source_chunk_ids ride along
// so the directional top-up and _prefill_slots_from_evidence can find the
// underlying chunks.
// ClaimPseudoChunks renders recalled claims as the pool-shaped pseudo chunks the GRAPH
// fan-out's channel 0 admits: the row leads with the literal "[evidence]" prefix, the quote
// is a LITERAL quoted span capped at EvidenceQuoteChars (400), and the whole content is
// capped at 1200. source_chunk_ids ride along so the directional top-up and the slot prefill
// can find the underlying chunks.
//
// This is NOT the action-session prefetch's format — Python's _claim_prefetch
// renders "[claim #rank]" with a 1200-char quote (action_session.py:741-747),
// which ClaimPrefetch builds inline. The two formats are Python-exact and
// deliberately distinct.
// This is NOT the action-session prefetch's format — that one renders "[claim #rank]" with a
// 1200-char quote and is built inline by ClaimPrefetch. The two formats are deliberately
// distinct.
func ClaimPseudoChunks(claims []*ClaimHit) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(claims))
for _, c := range claims {
@@ -364,11 +350,11 @@ func ClaimPseudoChunks(claims []*ClaimHit) []map[string]interface{} {
content += " — " + c.Description
}
if c.Quote != "" {
// Python [:_STRUCT_CLAIM_EVIDENCE_CHARS] counts CODE POINTS.
// The cap counts CODE POINTS.
quote := truncateRunes(c.Quote, EvidenceQuoteChars)
content += "\nEvidence (verbatim): \"" + quote + "\""
}
// Python content[:1200] (:728) counts CODE POINTS, not bytes.
// The content cap counts CODE POINTS, not bytes.
content = truncateRunes(content, 1200)
out = append(out, map[string]interface{}{
"chunk_id": cid,
@@ -380,14 +366,13 @@ func ClaimPseudoChunks(claims []*ClaimHit) []map[string]interface{} {
return out
}
// claimHitID mirrors Python's "claim_" + md5(f"{doc_id}:{name}")[:12] pool id.
// claimHitID is the pool id: "claim_" + md5(doc_id+":"+name)[:12].
func claimHitID(c *ClaimHit) string {
return "claim_" + fmt.Sprintf("%x", md5.Sum([]byte(c.DocID+":"+c.Name)))[:12]
}
// ClaimPrefetch runs the gated prefetch for one corpus search (Python
// action_session._claim_prefetch): the has-compilation gate, the recall, and
// the exclusive passage build. ok is false when the caller must fall through
// ClaimPrefetch runs the gated prefetch for one corpus search: the has-compilation gate,
// the recall, and the exclusive passage build. ok is false when the caller must fall through
// to the plain chunk search.
func ClaimPrefetch(ctx context.Context, deps SearchDeps, query string, seen map[string]bool) ([]map[string]any, []string, []map[string]interface{}, bool) {
if strings.TrimSpace(query) == "" {
@@ -413,11 +398,11 @@ func ClaimPrefetch(ctx context.Context, deps SearchDeps, query string, seen map[
content += " — " + c.Description
}
if c.Quote != "" {
// Python [:_STRUCT_CLAIM_EVIDENCE_CHARS] counts CODE POINTS.
// The cap counts CODE POINTS.
quote := truncateRunes(c.Quote, ClaimEvidenceChars)
content += fmt.Sprintf("\nEvidence (verbatim): %q", quote)
}
// Python content[:1200] (:747) counts CODE POINTS, not bytes.
// The content cap counts CODE POINTS, not bytes.
content = truncateRunes(content, 1200)
docID := c.DocID
payload = append(payload, map[string]any{"id": cid, "content": content, "doc_id": docID})
@@ -487,14 +472,11 @@ func claimStr(v interface{}) string {
}
}
// ---------------------------------------------------------------------------
// Document-level claim recall (Python navigation.py::_recall_claim_hits)
// ---------------------------------------------------------------------------
// Document-level claim recall.
// DocClaimHit is one claim recalled for a SINGLE document, in the shape
// Python _recall_claim_hits returns to _render_toc_drilldown: the source
// chunk pointer, the fused rank/score, the statement and its verbatim
// evidence ([{"quote": ...}] when the claim carries one, else empty).
// DocClaimHit is one claim recalled for a SINGLE document: the source chunk pointer, the
// fused rank/score, the statement and its verbatim evidence ([{"quote": ...}] when the claim
// carries one, else empty).
type DocClaimHit struct {
ChunkID string
Score float64
@@ -504,11 +486,11 @@ type DocClaimHit struct {
Evidence []map[string]any
}
// evidenceRowTypes mirrors Python _evidence_row_types: the entity_type_kwd
// evidenceRowTypes: the entity_type_kwd
// values carrying evidence for the given compile kinds. Every compiler writes
// its evidence rows as "claim", so any known kind yields ["claim"]; with no
// kinds the drill searches ["claim"] outright. Unknown kinds contribute
// nothing (Python: the same map lookup misses).
// nothing (an unknown kind matches no row type).
func evidenceRowTypes(kinds []string) []string {
if len(kinds) == 0 {
return []string{"claim"}
@@ -536,7 +518,7 @@ var (
)
// RecallDocClaimHits runs the two-leg hybrid claim recall for ONE document and
// returns up to topN fused hits (Python navigation.py:_recall_claim_hits):
// returns up to topN fused hits:
// a KNN leg over the claim rows' q_<dim>_vec (when qvec is available) and a
// BM25 leg over content_ltks/content_sm_ltks, fused by reciprocal rank. No
// similarity threshold: the store ranks, the top-N ARE the hit set. Empty —
@@ -628,7 +610,7 @@ func RecallDocClaimHits(ctx context.Context, deps SearchDeps, query, docID strin
if len(h.ChunkIDs) > 0 {
hit.ChunkID = h.ChunkIDs[0]
}
// Python: description = h["description"] or h["name"].
// Fall back to the name when the description is empty.
if hit.Description == "" {
hit.Description = h.Name
}
@@ -645,7 +627,7 @@ func RecallDocClaimHits(ctx context.Context, deps SearchDeps, query, docID strin
}
// docClaimQuote returns the first non-empty verbatim quote of a hit's evidence
// list (Python: the `for ev in hit["evidence"]` loop).
// list.
func docClaimQuote(h DocClaimHit) string {
for _, ev := range h.Evidence {
if q, ok := ev["quote"].(string); ok {
@@ -657,9 +639,9 @@ func docClaimQuote(h DocClaimHit) string {
return ""
}
// publishClaimHits mirrors the rendered claims of ONE document into the shared
// evidence pool (Python navigation.py::_publish_claim_hits): the SCA, the slot
// prefill and the final compose all read ONLY the pool, so a claim that
// publishClaimHits mirrors the rendered claims of ONE document into the shared evidence
// pool: the SCA, the slot prefill and the final compose all read ONLY the pool, so a claim
// that
// already states the fact must land there. Pure addition: no retrieval is
// suppressed, entries dedup by the SAME "claim_"+md5(doc_id:name) id the
// session prefetch writes, so a claim found by either path is one entry.
@@ -685,11 +667,11 @@ func publishClaimHits(deps SearchDeps, hits []DocClaimHit, docID string) int {
content += " — " + desc
}
if quote := docClaimQuote(h); quote != "" {
// Python [:_STRUCT_CLAIM_EVIDENCE_CHARS] counts CODE POINTS.
// The cap counts CODE POINTS.
quote := truncateRunes(docClaimQuote(h), ClaimEvidenceChars)
content += "\nEvidence (verbatim): \"" + quote + "\""
}
// Python content[:1200] (navigation.py:1585-1600) counts CODE POINTS.
// The content cap counts CODE POINTS.
content = truncateRunes(content, 1200)
src := []string{}
if h.ChunkID != "" {
@@ -711,9 +693,8 @@ func publishClaimHits(deps SearchDeps, hits []DocClaimHit, docID string) int {
return added
}
// LoadChunksForIDs fetches source chunks by id, any owning document (Python
// navigation.py::_load_chunks_for_ids). Zero LLM, zero recall — a directed
// fetch used by the fan-out evidence top-up to pull the passages an admitted
// LoadChunksForIDs fetches source chunks by id, any owning document. Zero LLM, zero recall
// — a directed fetch used by the fan-out evidence top-up to pull the passages an admitted
// claim row cites. Empty on any failure: best effort by contract.
func LoadChunksForIDs(ctx context.Context, deps SearchDeps, ids []string) []map[string]any {
ids = claimTrimIDs(ids)
@@ -748,8 +729,8 @@ func LoadChunksForIDs(ctx context.Context, deps SearchDeps, ids []string) []map[
}
// mustEncodeQueries embeds one search query: query-side encoding when the
// embedder is asymmetric (Python tools.embed_mdl.encode_queries; NavQueryEmbedder
// for Cohere/Voyage/Jina/NVIDIA), plain Encode otherwise. Returns nil on
// embedder is asymmetric (NavQueryEmbedder for Cohere/Voyage/Jina/NVIDIA), plain Encode
// otherwise. Returns nil on
// failure — the BM25 leg alone keeps claim recall alive.
func mustEncodeQueries(deps SearchDeps, ctx context.Context, query string) [][]float32 {
if deps.Embedder == nil {
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -74,10 +74,9 @@ func resetClaimCaches() {
compilationMu.Unlock()
}
// TestRecallDocClaimHitsFiltersAndMaps pins the store contract and the output
// mapping of the per-document claim leg (Python navigation.py:_recall_claim_hits):
// doc-scope + claim-row filters, sorted compile_kwd when kinds are given, and
// hits shaped as {chunk_id, score, rank, name, description-or-name, evidence}.
// TestRecallDocClaimHitsFiltersAndMaps pins the store contract and the output mapping of the
// per-document claim leg: doc-scope + claim-row filters, sorted compile_kwd when kinds are
// given, and hits shaped as {chunk_id, score, rank, name, description-or-name, evidence}.
func TestRecallDocClaimHitsFiltersAndMaps(t *testing.T) {
resetClaimCaches()
de := &recordingClaimEngine{rows: []map[string]interface{}{
@@ -99,7 +98,7 @@ func TestRecallDocClaimHitsFiltersAndMaps(t *testing.T) {
if h.Name != "The tower opened in 1889" {
t.Errorf("Name = %q", h.Name)
}
// Python: description = h["description"] or h["name"].
// The description falls back to the name.
if h.Description != h.Name {
t.Errorf("Description = %q, want the name fallback", h.Description)
}
@@ -175,8 +174,8 @@ func TestRecallDocClaimHitsDenseLeg(t *testing.T) {
// TestRecallDocClaimHitsMemo pins the (doc_id, query) memo: the drill re-issues
// navigate_structure, so the same pair must not cost two more store
// round-trips, while a different query or doc must miss. Empty results are NOT
// cached (Python returns before the cache write).
// round-trips, while a different query or doc must miss. Empty results are NOT cached (the
// return happens before the cache write).
func TestRecallDocClaimHitsMemo(t *testing.T) {
resetClaimCaches()
de := &recordingClaimEngine{rows: []map[string]interface{}{
@@ -252,10 +251,9 @@ func TestClaimAggRouterClaimsDecide(t *testing.T) {
}
}
// TestClaimAggRouterDocScope pins the doc_scope filter: out-of-scope documents
// never surface, and a scope that empties the claim leg falls back to the
// chunk leg (Python filters the store condition, so a scoped miss is an empty
// leg, not a routing verdict).
// TestClaimAggRouterDocScope pins the doc_scope filter: out-of-scope documents never surface,
// and a scope that empties the claim leg falls back to the chunk leg (the store condition is
// filtered, so a scoped miss is an empty leg, not a routing verdict).
func TestClaimAggRouterDocScope(t *testing.T) {
resetClaimCaches()
de := &recordingClaimEngine{rows: []map[string]interface{}{
@@ -323,11 +321,10 @@ func (r *fallbackRouter) Route(_ context.Context, _, _, _ string, _ []string, _
return r.next, nil
}
// TestPublishClaimHits pins the evidence-pool publish (Python
// _publish_claim_hits): one pseudo chunk per claim under the SAME
// "claim_"+md5(doc:name) id the session prefetch writes, verbatim evidence
// capped at ClaimEvidenceChars, the claim's own chunk pointer as
// source_chunk_ids, deduped against the live pool.
// TestPublishClaimHits pins the evidence-pool publish: one pseudo chunk per claim under the
// SAME "claim_"+md5(doc:name) id the session prefetch writes, verbatim evidence capped at
// ClaimEvidenceChars, the claim's own chunk pointer as source_chunk_ids, deduped against the
// live pool.
func TestPublishClaimHits(t *testing.T) {
kb := &Kbinfos{}
deps := SearchDeps{KB: kb}
@@ -372,9 +369,8 @@ func TestPublishClaimHits(t *testing.T) {
}
}
// TestLoadChunksForIDs pins the directed source-chunk fetch (Python
// _load_chunks_for_ids): an id-filtered store read that maps rows back to
// pool-shaped chunks.
// TestLoadChunksForIDs pins the directed source-chunk fetch: an id-filtered store read that
// maps rows back to pool-shaped chunks.
func TestLoadChunksForIDs(t *testing.T) {
de := &recordingClaimEngine{rows: []map[string]interface{}{
{"id": "c1", "content_with_weight": "full text one", "doc_id": "doc-1", "docnm_kwd": "a.pdf"},
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -29,22 +29,20 @@ import (
"ragflow/internal/tokenizer"
)
// Dense-match parameters for compiled-row search, mirroring Python
// compiled_expansion.py _VECTOR_NUM_CANDIDATES / _VECTOR_SIMILARITY. When a
// SearchCompiled implementation answers matchText with a dense (ANN) leg it MUST
// pass num_candidates = max(topN, vectorNumCandidates) and similarity =
// vectorSimilarity as named options — Python's PR #19172 fixed a bug where 0.1
// was passed positionally as num_candidates (ef_search), collapsing the HNSW
// candidate list and silently destroying recall. Go expresses dense options as a
// named map (engine MatchDenseExpr.ExtraOptions), so the pitfall is avoided as
// long as the implementation uses these constants.
// Dense-match parameters for compiled-row search. When a SearchCompiled implementation
// answers matchText with a dense (ANN) leg it MUST pass num_candidates =
// max(topN, vectorNumCandidates) and similarity = vectorSimilarity as named options: a
// positional mix-up here (0.1 read as num_candidates/ef_search) collapses the HNSW
// candidate list and silently destroys recall. Dense options are expressed as a named map
// (engine MatchDenseExpr.ExtraOptions), so the pitfall is avoided as long as the
// implementation uses these constants.
const (
vectorNumCandidates = 256
vectorSimilarity = 0.1
)
// CompiledStore is the seam over the doc-store compiled-structure rows that the
// harness must not own directly (it would pull in the retrieval backend and
// runtime must not own directly (it would pull in the retrieval backend and
// break unit-tier `go test ./...`). It mirrors the three primitives
// compiled_expansion.py reaches for:
//
@@ -57,13 +55,13 @@ const (
type CompiledStore interface {
// SearchCompiled returns compiled rows (entity / relation / synthesis page)
// matching the given filters and free-text match, scoped to (kbID, tenantID,
// docIDs). docIDs is applied to the doc store as the scope (Python passes it
// as a "doc_id" filter for entity/relation rows and "source_doc_ids" for
// synthesis pages; the exact key is the implementation's concern). A row
// docIDs). docIDs is applied to the doc store as the scope (as a "doc_id" filter for
// entity/relation rows and "source_doc_ids" for synthesis pages; the exact key is the
// implementation's concern). A row
// carries at least content_with_weight / source_chunk_ids / doc_id / docnm_kwd
// / from_entity_kwd / to_entity_kwd / name_kwd.
//
// filters keys mirror Python's condition dict: "knowledge_graph_kwd"
// filters keys mirror the condition dict: "knowledge_graph_kwd"
// (kind: entity/relation), "compile_kwd" (tree/wiki_page/artifact_page/essence),
// "compilation_template_kind_kwd", plus exact-list lookups ("name_kwd",
// "from_entity_kwd", "to_entity_kwd") and "available_int". Each value is an
@@ -81,17 +79,15 @@ type CompiledStore interface {
Vectorize(ctx context.Context, text string) ([]float64, error)
}
// compiledScope is one (kb_id, tenant_id, doc_ids) triple. Mirrors Python
// tools._kg_scopes, which returns exactly that list for a (possibly empty)
// doc_scope.
// compiledScope is one (kb_id, tenant_id, doc_ids) triple — exactly the list produced for
// a (possibly empty) doc_scope.
type compiledScope struct {
kbID string
tenantID string
docIDs []string
}
// Template-kind labels for the entity 1-hop strategies, in the exact order
// Python iterates them in _expand_with_compiled.
// Template-kind labels for the entity 1-hop strategies, in the order they are applied.
var compiledEntityKinds = []struct {
label string
kind string
@@ -102,7 +98,7 @@ var compiledEntityKinds = []struct {
{"page_index", "page_index"},
}
// Synthesis compile keywords, in the exact order Python iterates them.
// Synthesis compile keywords, in the order they are applied.
var compiledSynthesisKinds = []struct {
label string
kind string
@@ -112,23 +108,21 @@ var compiledSynthesisKinds = []struct {
{"essence", "essence"},
}
// CompiledScopeConfig carries the scope-resolution inputs of one search
// session, mirroring the slice of Python's RAGTools that _kg_scopes
// (exploration.py:_kg_scopes) reads: the bound datasets (each with its OWN owner
// tenant), the request tenant used as a fallback, and the optional
// scoped_doc_ids / doc→(kb,tenant) hooks.
// CompiledScopeConfig carries the scope-resolution inputs of one search session: the bound
// datasets (each with its OWN owner tenant), the request tenant used as a fallback, and the
// optional scoped-doc-ids / doc→(kb,tenant) hooks.
type CompiledScopeConfig struct {
// DatasetIDs are the bound dataset ids (Python RAGTools.kb_ids).
// DatasetIDs are the bound dataset ids.
DatasetIDs []string
// TenantID is the request tenant, used only when a bound dataset carries no
// tenant id of its own.
TenantID string
// KBs are the resolved dataset objects; their (ID, TenantID) pairs give each
// bound dataset its own compiled-row index scope (Python kb.tenant_id).
// bound dataset its own compiled-row index scope.
KBs []*entity.Knowledgebase
// DocTenantResolver resolves a document id to its owning (kb, tenant) so a
// doc scope is grouped by real owner (Python tools._resolve_doc_tenant). Nil
// keeps the fallback: each bound dataset searched with the whole doc scope.
// doc scope is grouped by real owner. Nil keeps the fallback: each bound dataset
// searched with the whole doc scope.
DocTenantResolver DocTenantResolver
}
@@ -150,18 +144,16 @@ func NewCompiledExpander(store CompiledStore, cfg CompiledScopeConfig) CompiledE
return &compiledExpander{store: store, cfg: cfg}
}
// Expand mirrors Python _expand_with_compiled: a zero-LLM enrichment that layers
// the dataset's compiled products on top of the retrieved chunks. For each bound
// KB it runs the per-template 1-hop entity expansion (knowledge_graph / mind_map
// / timeline / page_index), the tree structure graph (compile_kwd="tree"), and
// the three synthesis-page expansions (wiki_page / artifact_page / essence) —
// each capped at max_chunks=5 — then re-sorts the whole kbinfos["chunks"] by
// similarity so compiled chunks blend by relevance with the regular ones.
// Expand: a zero-LLM enrichment that layers the dataset's compiled products on top of the
// retrieved chunks. For each bound KB it runs the per-template 1-hop entity expansion
// (knowledge_graph / mind_map / timeline / page_index), the tree structure graph
// (compile_kwd="tree"), and the three synthesis-page expansions (wiki_page /
// artifact_page / essence) — each capped at max_chunks=5 — then re-sorts the whole chunk
// pool by similarity so compiled chunks blend by relevance with the regular ones.
//
// A doc-tenant resolution failure aborts the expansion and is returned rather
// than swallowed: Python's per-doc lookup raises out of _expand_with_compiled
// (compiled_expansion.py:224) and the caller reports it (tool_search.go,
// "compiled expansion failed") while the already-retrieved chunks stay in place.
// A doc-tenant resolution failure aborts the expansion and is returned rather than
// swallowed: the caller reports it (tool_search.go, "compiled expansion failed") while the
// already-retrieved chunks stay in place.
func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywords string, docScope []string) error {
if e == nil || e.store == nil || kb == nil {
return nil
@@ -183,7 +175,7 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
// The hybrid hits themselves — the claim-neighbour leg keys on them (the
// passages hybrid_search deemed relevant), so capture them BEFORE any
// expansion appends its own rows (Python :60-68).
// expansion appends its own rows.
hitChunks := make([]hitChunk, 0, len(kb.Chunks))
for _, c := range kb.Chunks {
hitChunks = append(hitChunks, hitChunk{
@@ -193,11 +185,10 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
})
}
// Admit one expansion batch. UNCAPPED, like Python's expansion (it appends
// straight to kbinfos, which is what pushes the pool past _EVIDENCE_POOL_CAP),
// but under the pool lock and deduped against the LIVE pool — the per-call
// `seen` snapshot it is also gated on cannot see a concurrent session's
// appends. Returns how many chunks were actually new.
// Admit one expansion batch. UNCAPPED (expansion appends straight to the pool, which is
// what pushes it past the cap), but under the pool lock and deduped against the LIVE
// pool — the per-call `seen` snapshot it is also gated on cannot see a concurrent
// session's appends. Returns how many chunks were actually new.
admitExpansion := func(chunks []map[string]any) int {
added := 0
kb.Admit(func(p *PoolAdmitter) {
@@ -213,7 +204,7 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
scopes := e.kgScopes(docScope)
for _, sc := range scopes {
// 1-hop entity-graph expansion, per template kind (Python L71-89):
// 1-hop entity-graph expansion, per template kind:
// compile_kwd empty, template_kind selects the template.
for _, tk := range compiledEntityKinds {
chunks, err := e.expandCompiledStrategy(ctx, sc, match, seen, "", tk.kind, 5)
@@ -224,12 +215,12 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
_LOG.Printf("[Compiled expand] %s: +%d chunks", tk.label, n)
}
}
// Tree structure graph, selected by compile_kwd (Python L91-104):
// Tree structure graph, selected by compile_kwd:
// template_kind empty, compile_kwd="tree". When the per-row strategy
// comes back empty, fall back to the LEGACY graph-blob strategy —
// documents compiled before the per-row migration persist ONE
// knowledge_graph_kwd="graph" blob per document and no raw rows
// (Python L110-125 _expand_tree_blob_strategy); it disappears once
// knowledge_graph_kwd="graph" blob per document and no raw rows; it disappears
// once
// those docs are recompiled.
chunks, err := e.expandCompiledStrategy(ctx, sc, match, seen, "tree", "", 5)
if err != nil {
@@ -242,7 +233,7 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
_LOG.Printf("[Compiled expand] tree: +%d chunks", n)
}
// Synthesis pages — standalone rendered articles, searched directly
// (Python L106-125).
// (synthesis pages are searched directly).
for _, ck := range compiledSynthesisKinds {
chunks, err := e.expandWikiPageStrategy(ctx, sc, match, seen, ck.kind, 5)
if err != nil {
@@ -252,8 +243,7 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
_LOG.Printf("[Compiled expand] %s: +%d chunks", ck.label, n)
}
}
// Claims adjacent to the passages hybrid_search just hit (Python
// L154-169 _expand_claim_neighbor_strategy). Non-redundant with the
// Claims adjacent to the passages the hybrid leg just hit. Non-redundant with the
// query-keyed claim prefetch: when the prefetch hits, the exclusive
// takeover means this expansion never runs; when it misses for the
// QUERY, chunk hits are a different key and their sibling claims may
@@ -264,7 +254,7 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
}
// Re-sort so compiled-expansion chunks blend by similarity with regular ones
// (Python L127-130). Under the pool lock: this PERMUTES the shared slice, so
// Under the pool lock: this PERMUTES the shared slice, so
// it must not run while another session reads or appends it.
kb.Admit(func(*PoolAdmitter) {
if len(kb.Chunks) > 0 {
@@ -278,11 +268,11 @@ func (e *compiledExpander) Expand(ctx context.Context, kb *Kbinfos, query, keywo
return nil
}
// kgScopes mirrors Python _kg_scopes by delegating to the
// kgScopes: by delegating to the
// same resolver graph_explore uses: with a doc scope the documents are grouped
// by their real owning (kb, tenant) — which may lie OUTSIDE the bound datasets —
// and without one each bound dataset is scanned under its OWN tenant. The doc
// scope arrives already ceilinged by resolveDocScope (Python scoped_doc_ids).
// scope arrives already ceilinged by resolveDocScope.
func (e *compiledExpander) kgScopes(docScope []string) []compiledScope {
scopes := resolveKGScope(SearchDeps{
KbIDs: e.cfg.DatasetIDs,
@@ -297,23 +287,22 @@ func (e *compiledExpander) kgScopes(docScope []string) []compiledScope {
return out
}
// searchCompiledRows mirrors Python _search_compiled_rows for a single scope:
// searchCompiledRows: for a single scope:
// a compiled-row search scoped to one (kb_id, tenant_id, doc_ids) with the given
// OR-list filters (including exact name_kwd / from_entity_kwd / to_entity_kwd
// lookups) and an optional free-text match.
func (e *compiledExpander) searchCompiledRows(ctx context.Context, sc compiledScope, matchText string, topN int, filters map[string][]string) []map[string]any {
rows, err := e.store.SearchCompiled(ctx, sc.kbID, sc.tenantID, sc.docIDs, filters, matchText, topN)
if err != nil {
// Python logs the failed search and carries on with no rows
// (compiled_expansion.py:211): the expansion is an enrichment, so a
// missing compiled leg must not fail the regular retrieval.
// The failed search is logged and carried on with no rows: the expansion is an
// enrichment, so a missing compiled leg must not fail the regular retrieval.
_LOG.Printf("[Compiled expand] compiled-row search failed (kb=%s tenant=%s); treating as no rows: %v", sc.kbID, sc.tenantID, err)
return nil
}
return rows
}
// expandCompiledStrategy mirrors Python _expand_compiled_strategy: within one
// expandCompiledStrategy: within one
// scope, embedding-match seed entities of a given template_kind/compile_kwd,
// hop 1-hop through adjacent relations (forward + backward), collect neighbour
// entity names, look up their source_chunk_ids by exact name_kwd, then load the
@@ -376,7 +365,7 @@ func (e *compiledExpander) expandCompiledStrategy(ctx context.Context, sc compil
neighList = neighList[:100] // reasonable cap for name_kwd search
}
neighFilter := seedFilters("entity", compileKwd, templateKwd)
neighFilter["name_kwd"] = neighList // exact name_kwd lookup (Python L361)
neighFilter["name_kwd"] = neighList // exact name_kwd lookup
neighRows := e.searchCompiledRows(ctx, sc, "", len(neighList), neighFilter)
// -- 5. Group chunk ids by doc, load, dedupe, cap --
@@ -397,7 +386,7 @@ func (e *compiledExpander) expandCompiledStrategy(ctx context.Context, sc compil
return e.loadByDoc(ctx, sc, order, byDoc, seen, maxChunks)
}
// expandWikiPageStrategy mirrors Python _expand_wiki_page_strategy: search the
// expandWikiPageStrategy: search the
// synthesis-compiled pages of one compile_kwd directly, collect their
// source_chunk_ids, then load the referenced source chunks. Pages rank high, so
// each loaded chunk is assigned similarity=0.9 unless it already carries one.
@@ -440,7 +429,7 @@ func (e *compiledExpander) expandWikiPageStrategy(ctx context.Context, sc compil
return loaded, nil
}
// searchSynthesisPages mirrors Python _search_synthesis_pages: find synthesis
// searchSynthesisPages: find synthesis
// page rows whose title/topic/content matches the query, filtered by compile_kwd
// and available_int=1. Synthesis pages are standalone articles that do NOT carry
// the knowledge_graph_kwd field, so no entity/relation filter is applied.
@@ -453,18 +442,17 @@ func (e *compiledExpander) searchSynthesisPages(ctx context.Context, sc compiled
// loadByDoc loads source chunks grouped per doc within one scope, honoring a
// global maxChunks cap and the seen-id dedup set. Returns newly added chunks in
// doc order. Mirrors Python's per-doc _load_chunks_for_doc loop and its
// max_chunks break/limit accounting.
// doc order, with the same max_chunks break/limit accounting as the per-doc loop.
//
// A doc whose owner cannot be resolved is dropped (the documented per-row rule),
// but a doc-tenant resolution FAILURE is returned: it is a transport/database
// error, not "no row resolved", and Python surfaces it as an exception.
// A doc whose owner cannot be resolved is dropped (the documented per-row rule), but a
// doc-tenant resolution FAILURE is returned: it is a transport/database error, not "no row
// resolved".
func (e *compiledExpander) loadByDoc(ctx context.Context, sc compiledScope, order []string, byDoc map[string][]string, seen map[string]bool, maxChunks int) ([]map[string]any, error) {
// Python _load_chunks_for_doc resolves EACH row's doc_id to its owning
// (kb, tenant) — not the searched scope — and loads nothing when that
// resolution fails (merged dataset rows carry a pseudo/empty doc_id). Loading
// from the scope instead would surface chunks Python deliberately drops, and
// would query the wrong dataset for a row whose doc belongs elsewhere.
// EACH row's doc_id is resolved to its owning (kb, tenant) — not the searched scope —
// and nothing is loaded when that resolution fails (merged dataset rows carry a
// pseudo/empty doc_id). Loading from the scope instead would surface chunks that are
// deliberately dropped, and would query the wrong dataset for a row whose doc belongs
// elsewhere.
owners := map[string]DocTenant{}
if e.cfg.DocTenantResolver != nil {
ids := make([]string, 0, len(order))
@@ -475,11 +463,9 @@ func (e *compiledExpander) loadByDoc(ctx context.Context, sc compiledScope, orde
}
resolved, err := e.cfg.DocTenantResolver.ResolveDocTenants(ctx, ids)
if err != nil {
// Python resolves one doc at a time and lets a lookup failure raise out
// of _load_chunks_for_doc (compiled_expansion.py:224): nothing is
// loaded, and the failure is loud. Return it so the caller reports it
// instead of letting a transport error masquerade as the documented
// per-row drop below.
// A lookup failure aborts the load: nothing is loaded, and the failure is loud.
// Return it so the caller reports it instead of letting a transport error
// masquerade as the documented per-row drop below.
return nil, fmt.Errorf("doc-tenant resolution failed for %d doc(s): %w", len(ids), err)
}
owners = resolved
@@ -504,8 +490,8 @@ func (e *compiledExpander) loadByDoc(ctx context.Context, sc compiledScope, orde
}
rows, err := e.store.LoadChunks(ctx, kbID, tenantID, cids)
if err != nil {
// Python logs the failed load per doc (compiled_expansion.py:248) and
// drops only that doc; the remaining docs still load.
// The failed load is logged per doc and only that doc is dropped; the remaining
// docs still load.
_LOG.Printf("[Compiled expand] failed to load chunks for doc_id=%s: %v", docID, err)
continue
}
@@ -521,7 +507,7 @@ func (e *compiledExpander) loadByDoc(ctx context.Context, sc compiledScope, orde
return out, nil
}
// seedNameFromRow mirrors Python L293-300: parse content_with_weight as JSON and
// seedNameFromRow: 300: parse content_with_weight as JSON and
// read payload.name or payload.title (stripped).
func seedNameFromRow(row map[string]any) string {
cww, _ := row["content_with_weight"].(string)
@@ -539,8 +525,7 @@ func seedNameFromRow(row map[string]any) string {
return name
}
// lowerUnionSorted returns the sorted union of the names and their lowercased
// forms, mirroring Python `sorted({n.lower() for n in X} | X)`.
// lowerUnionSorted returns the sorted union of the names and their lowercased forms.
func lowerUnionSorted(names map[string]bool) []string {
seen := make(map[string]bool, len(names)*2)
for n := range names {
@@ -589,8 +574,7 @@ func compiledSourceChunkIDs(row map[string]any) []string {
return nil
}
// similarityOf returns a chunk's similarity score as a float64 (Python
// c.get("similarity", 0.0)); nil/absent counts as 0.
// similarityOf returns a chunk's similarity score as a float64; nil/absent counts as 0.
func similarityOf(c map[string]any) float64 {
switch v := c["similarity"].(type) {
case float64:
@@ -612,9 +596,8 @@ func asString(v any) string {
return ""
}
// seedFilters builds the entity/relation condition, mirroring Python
// _search_compiled_rows: {"knowledge_graph_kwd": [kind]} plus an optional
// compile_kwd ("tree") or compilation_template_kind_kwd selector.
// seedFilters builds the entity/relation condition: {"knowledge_graph_kwd": [kind]} plus an
// optional compile_kwd ("tree") or compilation_template_kind_kwd selector.
func seedFilters(kind, compileKwd, templateKwd string) map[string][]string {
f := map[string][]string{"knowledge_graph_kwd": {kind}}
if compileKwd != "" {
@@ -645,21 +628,17 @@ func cosine(a, b []float64) float64 {
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// ---------------------------------------------------------------------------
// Legacy tree-blob expansion (Python _search_tree_blobs / _score_tree_entities
// / _expand_tree_blob_strategy, compiled_expansion.py:579-718) and the
// claim-neighbour expansion (Python _expand_claim_neighbor_strategy, :724-829).
// ---------------------------------------------------------------------------
// Legacy tree-blob expansion and the claim-neighbour expansion.
// hitChunk is one hybrid_search hit the claim-neighbour leg keys on
// (Python :60-68's (chunk_id, similarity, doc_id) triples).
// (chunk_id, similarity, doc_id) triples.
type hitChunk struct {
id string
sim float64
docID string
}
// expandTreeBlobStrategy mirrors Python _expand_tree_blob_strategy: expand a
// expandTreeBlobStrategy: expand a
// tree-compiled document from its pre-migration graph blob, in-process. Same
// contract as expandCompiledStrategy — seed entities, 1-hop neighbours, and
// the neighbours' source_chunk_ids loaded back as real chunks — except seeds,
@@ -667,8 +646,8 @@ type hitChunk struct {
// zero extra store round-trips. Entity payloads are compiled summaries: only
// the loaded chunks reach the model.
func (e *compiledExpander) expandTreeBlobStrategy(ctx context.Context, sc compiledScope, match string, seen map[string]bool, maxChunks int) []map[string]any {
// Python _search_tree_blobs: compile_kwd=["tree"] + knowledge_graph_kwd=
// ["graph"], payload-only, capped at 16 rows.
// Filters: compile_kwd=["tree"] + knowledge_graph_kwd=["graph"], payload-only, capped at
// 16 rows.
filters := map[string][]string{"compile_kwd": {"tree"}, "knowledge_graph_kwd": {"graph"}}
rows := e.searchCompiledRows(ctx, sc, "", 16, filters)
if len(rows) == 0 {
@@ -747,16 +726,15 @@ func (e *compiledExpander) expandTreeBlobStrategy(ctx context.Context, sc compil
}
loaded, err := e.loadByDoc(ctx, sc, order, byDoc, seen, maxChunks)
if err != nil {
// Python's blob strategy lets the store error escape into
// _expand_with_compiled's caller; Expand already reports it the same
// way for the other strategies.
// The store error is reported the same way Expand reports it for the other
// strategies.
_LOG.Printf("[Compiled expand] tree blob chunk load failed: %v", err)
return nil
}
return loaded
}
// scoreTreeEntities mirrors Python _score_tree_entities: rank blob entities by
// scoreTreeEntities: rank blob entities by
// query-term overlap over name + description. Lexical on purpose — the raw-row
// strategy seeds the same way (BM25 over content_ltks), and the blob's vectors
// are one shared row vector with nothing per-entity to cosine against.
@@ -785,8 +763,7 @@ func scoreTreeEntities(graph map[string]any, query string) []map[string]any {
out = append(out, scored{ent: ent, score: score})
}
}
// Stable sort: ties keep the blob's own entity order (Python's list.sort
// is stable, :632).
// Stable sort: ties keep the blob's own entity order.
sort.SliceStable(out, func(i, j int) bool { return out[i].score > out[j].score })
ents := make([]map[string]any, 0, len(out))
for _, s := range out {
@@ -795,9 +772,8 @@ func scoreTreeEntities(graph map[string]any, query string) []map[string]any {
return ents
}
// treeEntityTerms mirrors Python `_tokenize(query).split()` — the coarse
// RAGFlow tokenizer (knowlege_compile/dataset_nav.py:518) — lowercased for the
// containment match.
// treeEntityTerms uses the coarse RAGFlow tokenizer, lowercased for the containment
// match.
func treeEntityTerms(query string) []string {
toks, err := tokenizer.Tokenize(query)
if err != nil {
@@ -814,7 +790,7 @@ func treeEntityTerms(query string) []string {
return out
}
// expandClaimNeighborStrategy mirrors Python _expand_claim_neighbor_strategy:
// expandClaimNeighborStrategy
// inject claims adjacent to the passages hybrid_search just hit. A claim whose
// source_chunk_ids intersect a hit chunk is a verified atomic statement about a
// passage the retriever already deemed relevant — even when the QUERY never
@@ -822,7 +798,7 @@ func treeEntityTerms(query string) []string {
// prefix, ASCII " -- " description separator, no per-quote cap beyond the
// overall 1200) with the hit chunk's similarity, so they blend into the
// ranking. Deliberately NOT 1-hop expanded and deliberately keyed on the HIT
// chunks, not the query (Python :742-746).
// chunks, not the query.
func (e *compiledExpander) expandClaimNeighborStrategy(ctx context.Context, sc compiledScope, hits []hitChunk, seen map[string]bool, maxChunks int) []map[string]any {
live := make([]hitChunk, 0, len(hits))
for _, h := range hits {
@@ -845,7 +821,7 @@ func (e *compiledExpander) expandClaimNeighborStrategy(ctx context.Context, sc c
}
sort.Strings(hitDocs)
// Python :763-780: claim rows of the hit documents, payload-only, 256 rows.
// claim rows of the hit documents, payload-only, 256 rows.
filters := map[string][]string{"entity_type_kwd": {"claim"}, "scope_kwd": {"doc"}}
if len(hitDocs) > 0 {
filters["doc_id"] = hitDocs
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"bytes"
@@ -120,10 +120,9 @@ func (s *ownerRecordingStore) LoadChunks(ctx context.Context, kbID, tenantID str
return s.stubCompiledStore.LoadChunks(ctx, kbID, tenantID, chunkIDs)
}
// TestCompiledExpanderLoadsFromEachDocsOwnerAndSkipsUnresolvable mirrors Python
// _load_chunks_for_doc: a neighbour row's source chunks are fetched from THAT
// row's doc owner (not the searched scope), and a row whose doc id cannot be
// resolved (empty / merged pseudo doc) contributes nothing.
// TestCompiledExpanderLoadsFromEachDocsOwnerAndSkipsUnresolvable: a neighbour row's source
// chunks are fetched from THAT row's doc owner (not the searched scope), and a row whose doc id
// cannot be resolved (empty / merged pseudo doc) contributes nothing.
func TestCompiledExpanderLoadsFromEachDocsOwnerAndSkipsUnresolvable(t *testing.T) {
store := &ownerRecordingStore{stubCompiledStore: &stubCompiledStore{
rows: []map[string]any{
@@ -134,8 +133,7 @@ func TestCompiledExpanderLoadsFromEachDocsOwnerAndSkipsUnresolvable(t *testing.T
{"knowledge_graph_kwd": "entity", "compilation_template_kind_kwd": "knowledge_graph",
"name_kwd": "OmiyaSoft", "content_with_weight": `{"name":"OmiyaSoft"}`,
"source_chunk_ids": []string{"s1"}, "doc_id": "docA"},
// No doc id -> Python's _resolve_doc_tenant fails, so its source
// chunks are dropped.
// No doc id -> the owner cannot be resolved, so its source chunks are dropped.
{"knowledge_graph_kwd": "entity", "compilation_template_kind_kwd": "knowledge_graph",
"name_kwd": "OmiyaSoft", "content_with_weight": `{"name":"OmiyaSoft"}`,
"source_chunk_ids": []string{"s2"}, "doc_id": ""},
@@ -184,9 +182,8 @@ func TestNewCompiledExpanderGuard(t *testing.T) {
}
}
// TestCompiledExpanderScansEachDatasetUnderItsOwnTenant mirrors Python
// `[(kb.id, kb.tenant_id, None) for kb in tools.kbs]`: with no doc scope each
// bound dataset is scanned under ITS OWN owner tenant, not the request tenant.
// TestCompiledExpanderScansEachDatasetUnderItsOwnTenant: with no doc scope each bound dataset
// is scanned under ITS OWN owner tenant, not the request tenant.
func TestCompiledExpanderScansEachDatasetUnderItsOwnTenant(t *testing.T) {
store := &scopeRecordingStore{}
exp := NewCompiledExpander(store, CompiledScopeConfig{
@@ -218,9 +215,9 @@ func TestCompiledExpanderScansEachDatasetUnderItsOwnTenant(t *testing.T) {
}
}
// TestCompiledExpanderGroupsDocScopeByRealOwner mirrors the doc-scope branch of
// Python _kg_scopes: documents are grouped by their real owning (kb, tenant) —
// which may lie OUTSIDE the bound datasets — and the bound list is not scanned.
// TestCompiledExpanderGroupsDocScopeByRealOwner covers the doc-scope branch: documents are
// grouped by their real owning (kb, tenant) — which may lie OUTSIDE the bound datasets — and
// the bound list is not scanned.
func TestCompiledExpanderGroupsDocScopeByRealOwner(t *testing.T) {
store := &scopeRecordingStore{}
exp := NewCompiledExpander(store, CompiledScopeConfig{
@@ -273,7 +270,7 @@ func compiledIDs(chunks []map[string]any) []string {
return out
}
// TestSeedNameFromRowParsesContentWithWeight mirrors Python L293-300: the seed
// TestSeedNameFromRowParsesContentWithWeight: 300: the seed
// name comes from content_with_weight JSON (name, else title), never from name_kwd.
func TestSeedNameFromRowParsesContentWithWeight(t *testing.T) {
if got := seedNameFromRow(map[string]any{"content_with_weight": `{"name":"Culdcept","title":"Game"}`}); got != "Culdcept" {
@@ -285,7 +282,7 @@ func TestSeedNameFromRowParsesContentWithWeight(t *testing.T) {
if got := seedNameFromRow(map[string]any{"content_with_weight": `not json`}); got != "" {
t.Errorf("bad json: got %q", got)
}
// name_kwd is not consulted — Python reads only the JSON payload.
// name_kwd is not consulted — only the JSON payload is read.
if got := seedNameFromRow(map[string]any{"content_with_weight": `{}`, "name_kwd": "X"}); got != "" {
t.Errorf("name_kwd must be ignored, got %q", got)
}
@@ -332,8 +329,8 @@ func TestExpandEntityStrategyOneHop(t *testing.T) {
}
}
// TestExpandBackwardRelation confirms the strategy also walks incoming
// (to_entity_kwd) relations, mirroring Python's fwd+bwd merge.
// TestExpandBackwardRelation confirms the strategy also walks incoming (to_entity_kwd)
// relations — the forward + backward merge.
func TestExpandBackwardRelation(t *testing.T) {
store := &stubCompiledStore{
rows: []map[string]any{
@@ -441,8 +438,8 @@ func TestExpandCapsMaxChunksAndSkipsSeen(t *testing.T) {
}
}
// mapTenantResolver resolves each doc id to its own owner and can fail the whole
// batch, standing in for the batched DB lookup (a per-doc lookup in Python).
// mapTenantResolver resolves each doc id to its own owner and can fail the whole batch,
// standing in for the batched DB lookup.
type mapTenantResolver struct {
owners map[string]DocTenant
err error
@@ -461,11 +458,10 @@ func (m mapTenantResolver) ResolveDocTenants(_ context.Context, docIDs []string)
return out, nil
}
// TestCompiledExpanderReportsDocTenantResolutionFailure pins the two rules apart:
// a row whose owner cannot be resolved is dropped silently (see the test above),
// but a FAILED batched lookup is a transport error — Python lets it raise out of
// _load_chunks_for_doc, so nothing is loaded and the failure is reported instead
// of masquerading as "no row resolved".
// TestCompiledExpanderReportsDocTenantResolutionFailure pins the two rules apart: a row whose
// owner cannot be resolved is dropped silently (see the test above), but a FAILED batched
// lookup is a transport error — nothing is loaded and the failure is reported instead of
// masquerading as "no row resolved".
func TestCompiledExpanderReportsDocTenantResolutionFailure(t *testing.T) {
store := &ownerRecordingStore{stubCompiledStore: &stubCompiledStore{
rows: []map[string]any{
@@ -502,9 +498,8 @@ func TestCompiledExpanderReportsDocTenantResolutionFailure(t *testing.T) {
}
}
// TestCompiledExpanderLogsAndKeepsOtherDocsWhenOneLoadFails mirrors Python's
// per-doc load: the failing doc is logged (compiled_expansion.py:248) and
// dropped, while the remaining docs still contribute.
// TestCompiledExpanderLogsAndKeepsOtherDocsWhenOneLoadFails covers the per-doc load: the
// failing doc is logged and dropped, while the remaining docs still contribute.
func TestCompiledExpanderLogsAndKeepsOtherDocsWhenOneLoadFails(t *testing.T) {
var buf bytes.Buffer
prevLog := _LOG
@@ -14,22 +14,17 @@
// limitations under the License.
//
// Package harness holds the low-level agentic-RAG capability library: the
// Package runtime holds the low-level agentic-RAG capability library: the
// retrieval backend, the action-session runtime, the tool executor, and the
// compiled-structure / knowledge-graph navigation. These are the leaf building
// blocks the RAGTools methods in the parent `agent` package (Run, ComposeAnswer,
// ComposeNaiveAnswer, Formalize, ...) orchestrate. The split mirrors Python's
// layout, where agentic_rag.py's RAGTools imports the helpers under
// harness/ rather than inlining them.
// ComposeNaiveAnswer, Formalize, ...) orchestrate.
//
// tool_executor.go is the Go-side tool-executor adapter: its
// searchExecutor type and the Execute dispatch correspond to the _exec_* tool
// methods in Python's harness/action_session.py (e.g. _exec_retrieve,
// _exec_navigate_tree, _exec_navigate_structure, _exec_list_chunks,
// _exec_calculate, _exec_web_search, _exec_wiki_query, _exec_graph_explore).
// In Python those live inline inside action_session.py; in Go they are pulled
// out into this file so the harness package need not import the advanced_rag package.
package harness
// tool_executor.go is the tool-executor adapter: its searchExecutor type and the Execute
// dispatch are the search / navigation / calculation / web tools the action session calls.
// They are pulled out into this file so the runtime package need not import the
// agentic_rag package.
package runtime
import (
"context"
@@ -43,15 +38,15 @@ import (
"ragflow/internal/service/nav"
)
// RunRequest is one harness run. It lives in the harness package (not the agent
// RunRequest is one runtime run. It lives in the runtime package (not the agent
// package) because searchExecutor needs it to carry the per-run query, and the
// advanced_rag package must not be imported from here (import-cycle rule). RAGTools.Run
// in the advanced_rag package uses it via the harness package.
// agentic_rag package must not be imported from here (import-cycle rule). RAGTools.Run
// in the agentic_rag package uses it via the runtime package.
type RunRequest struct {
// Question is the user's question.
Question string
// ThinkingMode selects the ModeSpec ("low"/"medium"/"high"/"ultra").
// An unrecognised value degrades to NAIVE, as in Python.
// An unrecognised value degrades to NAIVE.
ThinkingMode string
// Keywords narrow retrieved chunks to the sentences mentioning them.
Keywords string
@@ -65,23 +60,21 @@ type RunRequest struct {
TopN int
// DeadlineLeft is the wall-clock budget in seconds. <=0 selects the default.
DeadlineLeft float64
// MaxLength is the chat model's context window (Python
// tools.chat_mdl.max_length). It bounds evidence and document-level reads.
// MaxLength is the chat model's context window. It bounds evidence and document-level
// reads.
// <=0 selects the file-level defaults.
MaxLength int
// SessionID identifies the conversation this call belongs to. It is carried
// for plumbing (e.g. future session-scoped state) but is not read by the
// near-duplicate answer cache: that cache is per-request, mirroring Python's
// per-turn RAGTools._rag_cache.
// near-duplicate answer cache: that cache is per-request, i.e. per turn.
SessionID string
// Images are vision-gated base64 data URIs (Python image_attachments). The
// Images are vision-gated base64 data URIs. The
// outer react loop turns them into multimodal content blocks on the last
// user message so a vision model sees them (advanced_rag.Rag assembles the
// user message so a vision model sees them (agentic_rag.Rag assembles the
// message from these).
Images []string
// TextAttachments is the joined text-file content (Python
// text_attachments_content); appended to the question so the model reads
// attached documents in the reasoning path.
// TextAttachments is the joined text-file content, appended to the question so the
// model reads attached documents in the reasoning path.
TextAttachments string
}
@@ -95,39 +88,33 @@ type searchExecutor struct {
// NewSearchExecutor builds the retrieval/navigation ToolExecutor used by both
// the single-session path and the agentic loop's programmatic fan-out fetches.
//
// Exported so the advanced_rag package (which cannot be imported from here) can reuse
// Exported so the agentic_rag package (which cannot be imported from here) can reuse
// the same evidence handling instead of duplicating it.
func NewSearchExecutor(deps SearchDeps, req RunRequest) ToolExecutor {
return &searchExecutor{deps: deps, req: req}
}
// RunPattern executes ONE completeness pattern and returns the windows it matched, already
// narrowed (see ScanPatterns / RunCompletenessPass).
// CoverageRunner is the tool layer's enumeration seam: the runtime asks the EXECUTOR to
// run the direction's own enumeration over the corpus, so the step belongs to the graph
// rather than to a prompt.
//
// It goes through the same grep path a retrieve call uses, so a pattern is treated as a
// pattern: its operands are recalled on their own (patternRecallTopN — a pattern's recall
// must not stop at a ranking's head, see that constant's measurement), the pattern decides
// which candidates are windows (matchGrepPattern), and the windows come back narrowed to
// the match (GrepOutCharsPerChunk / GrepOutTotalChars). The runtime runs this itself
// because the same queries handed to the model as a list went unrun (see
// RunCompletenessPass).
func (e *searchExecutor) RunPattern(ctx context.Context, pattern string) []map[string]any {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return nil
// It is a runner rather than a caller-side helper for one reason: the runtime has to be
// able to ask the corpus itself. Queries handed to the model as a list are advice and may
// simply not be run, so the enumeration is a step of the graph, and the only thing it needs
// from the tool layer is a search.
type CoverageRunner interface {
EnumerateCoverage(ctx context.Context, cov Coverage, kb *Kbinfos) CoverageSet
}
// EnumerateCoverage runs the direction's OWN enumeration over the corpus: one recall per
// operand (the actor's declared forms and the act words), then the windows where the deed
// is stated (see EnumerateCoverage in coverage_enumerate.go).
func (e *searchExecutor) EnumerateCoverage(ctx context.Context, cov Coverage, kb *Kbinfos) CoverageSet {
deps := e.deps
if len(deps.KbIDs) == 0 && len(e.req.DatasetIDs) > 0 {
deps.KbIDs = e.req.DatasetIDs
}
chunks, _ := GrepSearch(ctx, e.deps, SearchParams{
Question: pattern,
// A pattern is an expression for the MATCHER, not for the engine (see
// GrepSearch): recall gets the operands, the pattern stays here and decides.
Keywords: strings.Join(GrepPatternOperands(pattern), " "),
TopN: patternRecallTopN,
KbIDs: e.req.DatasetIDs,
// The pass asks about the ACTOR and the ACT WORDS, never about candidate names
// (see SearchParams.SkipReachLedger).
SkipReachLedger: true,
})
return chunks
return EnumerateCoverage(ctx, deps, cov, kb)
}
// Execute implements ToolExecutor for the wired tools. Tools whose port has not
@@ -144,10 +131,10 @@ func (e *searchExecutor) RunPattern(ctx context.Context, pattern string) []map[s
func (e *searchExecutor) Execute(ctx context.Context, name string, args map[string]any) (ToolOutcome, error) {
logger := e.deps.Logger
renderedArgs := RenderToolArgs(args)
// Mirror Python rag/llm/tool_decorator.py:tool "[Function tool] Running the
// {name} tool with: {args}". The log line and the step below are the same
// call site on purpose: a tool step is a step because this method says so,
// not because a tagged line matched a pattern.
// "[Function tool] Running the {name} tool with: {args}" — one of the namespaces the
// thinking block shows. The log line and the step below are the same call site on
// purpose: a tool step is a step because this method says so, not because a tagged
// line matched a pattern.
//
// The two projections then part: the log and the event's Args keep the argument
// object Python logs and a developer greps, while the step reports the query
@@ -257,15 +244,15 @@ func (e *searchExecutor) dispatch(ctx context.Context, name string, args map[str
}
// RenderToolArgs renders a tool's arguments for the "[Function tool]" think-log
// line and the event's Args field. Python interpolated the raw args dict; Go
// marshals to JSON so nested values (scopes, tag maps) stay readable. Empty args
// render as "{}" to match Python's look rather than an empty string.
// line and the event's Args field. The args dict is marshalled to JSON so nested
// values (scopes, tag maps) stay readable. Empty args render as "{}" rather than an
// empty string.
//
// String values are capped (capForThink): this field is rendered for display, and
// one uncapped slot-evidence query turns a single call into a paragraph repeated
// on every line of its round.
//
// Exported because the outer react loop (advanced_rag.outerReactSession) logs the
// Exported because the outer react loop (agentic_rag.outerReactSession) logs the
// same line for the tools it dispatches itself (rag / summarize_document).
func RenderToolArgs(args map[string]any) string {
if len(args) == 0 {
@@ -482,7 +469,7 @@ func causeSuffix(diagnostic string) string {
}
// CountOf renders a count with a singular/plural noun ("1 result", "3 results").
// Shared with the outer react loop's narration (advanced_rag.outerReactSession)
// Shared with the outer react loop's narration (agentic_rag.outerReactSession)
// so both layers word their "[Function tool]" result lines identically.
func CountOf(n int, noun string) string {
if n == 1 {
@@ -650,8 +637,8 @@ func (e *searchExecutor) fetchFullDocument(ctx context.Context, args map[string]
}, nil
}
// summarizeDocument mirrors Python's summarize_document tool: load the document
// and hand the model the freshly rendered evidence blocks.
// summarizeDocument loads the document and hands the model the freshly rendered evidence
// blocks.
func (e *searchExecutor) summarizeDocument(ctx context.Context, args map[string]any) (ToolOutcome, error) {
docID := argString(args, "doc_id")
if docID == "" {
@@ -698,18 +685,16 @@ func (e *searchExecutor) navigateTree(ctx context.Context, args map[string]any)
if query == "" {
return ToolOutcome{Payload: []any{}, Status: StatusError, Reason: ReasonBadArgs}, nil
}
router := e.navRouter()
// The tool argument is NOT threaded (Python _exec_navigate_tree,
// action_session.py:_exec_navigate_tree, calls _navigate_tree_impl(query, keywords=...) with
// no doc_scope), but the SESSION scope still applies: the impl routes through
// _nav_search_titled, which ceilings its scope with tools.scoped_doc_ids(None)
// — the session doc_scope (navigation.py:_nav_search_titled). The router must therefore
// receive the session ceiling, never the tool argument.
router := e.navRouter(ctx)
// The tool argument is NOT threaded (navigate_tree takes query + keywords only), but
// the SESSION scope still applies: the router ceilings its scope with the session
// doc_scope. The router must therefore receive the session ceiling, never the tool
// argument.
res := NavigateTree(ctx, router, NavTreeInput{
Query: query,
// Python :985 threads ONLY the tool argument's keywords (usually absent,
// so "") — the run-level request keywords are a Go-only invention that
// re-biased every tree routing toward the original question keywords.
// threads ONLY the tool argument's keywords (usually absent, so "") — passing the
// run-level request keywords re-biased every tree routing toward the original
// question keywords.
Keywords: argString(args, "keywords"),
DocScope: e.deps.DocScope,
TenantID: e.deps.TenantID,
@@ -720,8 +705,7 @@ func (e *searchExecutor) navigateTree(ctx context.Context, args map[string]any)
case ReasonNoStructure:
return ToolOutcome{
Payload: []any{map[string]any{
// Python action_session.py:_exec_navigate_tree (dataset_has_compilation
// gate) — note text verbatim; the payload carries kind+note only.
// The dataset_has_compilation gate: the payload carries kind+note only.
"kind": "navigate_tree",
"note": "This dataset has no compiled document-navigation structure; use search_chunks / retrieve instead.",
}},
@@ -730,7 +714,7 @@ func (e *searchExecutor) navigateTree(ctx context.Context, args map[string]any)
}, nil
case ReasonNoDoc:
// Structure exists, this query reached nothing — a MISS, not an EMPTY.
// Python :990 note verbatim; the payload carries kind+note only.
// note verbatim; the payload carries kind+note only.
return ToolOutcome{
Payload: []any{map[string]any{
"kind": "navigate_tree",
@@ -750,8 +734,8 @@ func (e *searchExecutor) navigateTree(ctx context.Context, args map[string]any)
}, nil
}
// Routed: expose the summary-bearing payload the ladder consumes. Python
// :995 caps the content at 8000 chars.
// Routed: expose the summary-bearing payload the ladder consumes, capped at 8000
// chars.
content := res.Text
if len(content) > 8000 {
content = content[:8000]
@@ -773,13 +757,12 @@ func (e *searchExecutor) navigateTree(ctx context.Context, args map[string]any)
}, nil
}
// chunkAggRetrieveFrom adapts a harness Retriever into the chunk_agg retrieve
// chunkAggRetrieveFrom adapts a runtime Retriever into the chunk_agg retrieve
// leg. It scopes the search to the dataset (kbID) and the caller's document
// scope and pulls the wide pool chunk_agg needs; the backend is expected to
// exclude compiled rows (the production retrieval filters available_int=1),
// mirroring Python settings.retriever.retrieval under _search_layers_nav_chunk_agg.
// exclude compiled rows (the production retrieval filters available_int=1).
//
// It stays in the harness package because it depends on the agentic Retriever /
// It stays in the runtime package because it depends on the agentic Retriever /
// RetrieveRequest; the routing algorithm itself lives in internal/service/nav.
func chunkAggRetrieveFrom(r Retriever) nav.ChunkRetriever {
return func(ctx context.Context, tenantID, kbID, query string, docScope []string, topN int, _ float64) ([]map[string]any, error) {
@@ -823,10 +806,10 @@ func chunkAggRetrieveFrom(r Retriever) nav.ChunkRetriever {
// returns the underlying source chunks.
//
// NOTE on shapes: the Go reader (navigation.loadStructureEntities) currently
// reads only the compact graph-blob rows, whereas Python merges BOTH row shapes
// (graph blob AND per-entity/relation rows). ParseCompiledStructure in
// navtools.go already implements the merged parsing; wiring it into the reader
// is the remaining step for full parity.
// reads only the compact graph-blob rows, whereas BOTH row shapes (graph blob AND
// per-entity/relation rows) should be merged. ParseCompiledStructure in navtools.go
// already implements the merged parsing; wiring it into the reader is the remaining
// step.
func (e *searchExecutor) navigateStructure(ctx context.Context, args map[string]any) (ToolOutcome, error) {
query := argString(args, "query")
docID := argString(args, "doc_id")
@@ -836,27 +819,24 @@ func (e *searchExecutor) navigateStructure(ctx context.Context, args map[string]
if query == "" {
return ToolOutcome{Payload: []any{}, Status: StatusError, Reason: ReasonBadArgs}, nil
}
// No scope-as-doc-pin: Python _exec_navigate_structure
// reads only args["doc_id"] — args["doc_scope"] is never consulted.
// No scope-as-doc-pin: only args["doc_id"] is read — args["doc_scope"] is never
// consulted.
kind := argString(args, "kind")
if kind == "" {
kind = "catalog"
}
// Resolve the document set. When the caller omits doc_id, mirror Python
// _navigate_structure_impl: route to the documents by descending the compiled
// navigation tree (the exact seam navigate_tree uses), not by refusing the
// call. Python's _nav_search_titled caps the routed set at _NAV_TREE_MAX_DOCS.
// Resolve the document set. When the caller omits doc_id, route to the documents by
// descending the compiled navigation tree (the exact seam navigate_tree uses), not by
// refusing the call. The routed set is capped at navTreeMaxDocs.
var docIDs []string
if docID != "" {
docIDs = []string{docID}
} else {
// DocScope is the session ceiling, not the tool argument (see the note in
// navigateTree): Python's _exec_navigate_structure never threads
// args["doc_scope"], but the routing it does when doc_id is absent goes
// through _nav_search_titled, which applies tools.scoped_doc_ids(None)
// (navigation.py:_nav_search_titled / :1012).
res := NavigateTree(ctx, e.navRouter(), NavTreeInput{
// navigateTree): args["doc_scope"] is never threaded, but the routing done when
// doc_id is absent applies the session document scope.
res := NavigateTree(ctx, e.navRouter(ctx), NavTreeInput{
Query: query,
Keywords: e.req.Keywords,
DocScope: e.deps.DocScope,
@@ -871,8 +851,8 @@ func (e *searchExecutor) navigateStructure(ctx context.Context, args map[string]
docIDs = docIDs[:navTreeMaxDocs]
}
if len(docIDs) == 0 {
// Routing reached no document (Python: empty_reason="no_doc"). Python
// :1037 emits the SAME note for every empty_reason — text verbatim.
// Routing reached no document (empty_reason="no_doc"). The SAME note is emitted
// for every empty_reason.
return ToolOutcome{
Payload: []any{map[string]any{
"kind": "navigate_structure",
@@ -885,11 +865,10 @@ func (e *searchExecutor) navigateStructure(ctx context.Context, args map[string]
}
}
// Zero-LLM vector-beam drill-down (mirrors Python _read_structures +
// _navigate_structure_impl): read each document's compiled structure of the
// Zero-LLM vector-beam drill-down: read each document's compiled structure of the
// requested kind, drill toward the query, and hand the model the merged
// <structure_navigation> outline with chunk-pointer anchors.
res, drill := navigateStructures(ctx, e.deps.TenantID, query, docIDs, kind, e.navRouter(), e.deps)
res, drill := navigateStructures(ctx, e.deps.TenantID, query, docIDs, kind, e.navRouter(ctx), e.deps)
metrics := map[string]any{
"entities": drill.nodes,
"chunk_ptrs": drill.chunkPtrs,
@@ -898,8 +877,7 @@ func (e *searchExecutor) navigateStructure(ctx context.Context, args map[string]
"claim_hits": drill.claimHits,
}
if res.EmptyReason != "" {
// Python :1037 — one note for every empty_reason; kind renders with
// Python repr() quoting (kind!r).
// one note for every empty_reason; kind renders quoted.
return ToolOutcome{
Payload: []any{map[string]any{
"kind": "navigate_structure",
@@ -937,18 +915,42 @@ func (e *searchExecutor) navigateStructure(ctx context.Context, args map[string]
}
// navRouter returns the navigation-tree router navigateTree and navigateStructure
// share. It mirrors Python's agentic router choice: the navigation-tree route
// asks for router="claim_agg" (dataset_api_service.search_dataset_layers) — the
// claim leg runs first and decides the ranking when it hits, and raw-chunk
// aggregation (chunk_agg) is the fallback. A nil retrieval backend degrades to
// the nav-row router, which needs no backend.
func (e *searchExecutor) navRouter() NavTreeRouter {
// share, decided in this order:
//
// 1. The dataset has a COMPILATION. Navigation routes over compiled structure, so a
// dataset carrying no compiled rows has nothing to route on and gets
// noCompilationRouter — the tool then reports no_structure and the session is told to
// use search_chunks / retrieve, i.e. plain search. Without this gate the route stood
// behind an uncompiled dataset too, because the chunk leg is a plain similarity
// ranking that needs no compilation: raw retrieval came back dressed as a structure
// descent, and the rounds went to "navigation" results that name no structure.
// 2. It follows the agentic router choice: the navigation-tree route asks for
// router="claim_agg" — the claim leg runs first and decides the ranking when it hits,
// and raw-chunk aggregation (chunk_agg) is the fallback. A nil retrieval backend
// degrades to the nav-row router, which needs no backend.
//
// A caller-installed NavRouter is an explicit override and skips both.
func (e *searchExecutor) navRouter(ctx context.Context) NavTreeRouter {
if e.deps.NavRouter != nil {
return e.deps.NavRouter
}
if !DatasetHasCompilation(ctx, e.deps) {
return noCompilationRouter{}
}
return defaultNavRouter(e.deps)
}
// noCompilationRouter is what an UNCOMPILED dataset gets: Route answers (nil, nil), the
// NavTreeRouter contract's "this dataset has no compiled tree", so NavigateTree reports
// no_structure (dataset-level, and its tool note says to use search_chunks / retrieve)
// instead of routing the query through raw-chunk aggregation.
type noCompilationRouter struct{}
// Route implements NavTreeRouter: no compiled structure, nothing to route, not an error.
func (noCompilationRouter) Route(context.Context, string, string, string, []string, int) ([][2]string, error) {
return nil, nil
}
// defaultNavRouter builds the claim_agg router over the chunk_agg fallback.
// See ClaimAggRouter and nav.NewChunkAggRouter.
func defaultNavRouter(deps SearchDeps) NavTreeRouter {
@@ -984,30 +986,23 @@ func argString(args map[string]any, key string) string {
return s
}
// evidencePoolCap is the hard cap on the shared evidence pool
// (kbinfos["chunks"]). Mirrors Python _EVIDENCE_POOL_CAP (=120,
// action_session.py:62): deliberately LARGER than _SCA_VIEW_CAP (=60) so
// storage and review stay DECOUPLED — the pool accumulates while the SCA reads
// a ranked top-60 view. Coupling them at 60 starved the raw-evidence channel in
// 42% of rounds (every admit rejected -> status REDUNDANT -> the model
// evidencePoolCap is the hard cap on the shared evidence pool. It is deliberately LARGER
// than the SCA view cap (60) so storage and review stay DECOUPLED — the pool accumulates
// while the SCA reads a ranked top-60 view. Coupling them at 60 starved the raw-evidence
// channel in 42% of rounds (every admit rejected -> status REDUNDANT -> the model
// re-searched for nothing).
//
// Claim pseudo-chunks BYPASS this cap: Python's _claim_prefetch appends them
// directly to kbinfos["chunks"] (:755), and the cap check (:657) only guards
// _admit_evidence's regular chunks.
// Claim pseudo-chunks BYPASS this cap: they are appended directly to the pool, and the cap
// only guards regular chunk admission.
//
// 200, not Python's 120. The 120 was sized for a consumer that no longer exists —
// The cap is 200 (the old 120 was sized for a consumer that no longer exists) —
// the round-level sweep that rendered the WHOLE pool into one prompt, where the cap
// and that prompt's budget were the same number. Nothing renders the pool whole any
// more (the SCA reads a ranked 60-chunk view, the session seed injects a bounded
// digest, the draft is bounded), so the cap is a storage-discipline number again —
// and on an enumeration it is the NEXT ceiling rather than a prompt limit. Measured
// (2026-09-14, fixrecall): a round of batch name probing ended at 117 chunks, three
// below the cap, with the question's members still arriving; at 200 the same shape
// reached seventeen. Measured here (2026-09-16, 三国/关羽): the run's own line
// `pool at the cap 120 but the batch asked about "管亥"; admitting the passage that
// reaches it (pool 121, novelty slack 1/40)` — the tail members' passages are what
// the cap refuses.
// and on an enumeration it is the NEXT ceiling rather than a prompt limit: what it
// refuses is the passage that would have reached the last members, which are named
// late in the pool's order.
const evidencePoolCap = 200
// The cap check and its "pool FULL" line now live on PoolAdmitter.Full, where
@@ -1038,9 +1033,7 @@ func probeTerms(q string) []string {
// space-separated list inside one string, or a list of query strings — because
// GrepTermsFromQuery already reads all three. That is the point: the seat
// mechanism is attached to the FACT that the call named terms, not to a syntax
// the model may never write. Measured (2026-09-15): fifteen calls, every one of
// them naming people, zero of them using `|` — a `|`-triggered mechanism fires
// never.
// the model may never write — a `|`-triggered mechanism would fire never.
//
// The cap (GrepTermsMax) bounds the seat pass, which runs one cheap keyword
// search per unreached term; the caller logs how many named terms were dropped
@@ -1048,20 +1041,17 @@ func probeTerms(q string) []string {
//
// The terms are the caller's OWN WORDS (GrepWordsFromQuery), not the CJK windows
// the locate step derives from them: a window is our guess at where a name can be
// found, and probing one spends a retrieval on a fragment nobody asked about
// (measured 2026-09-15: 羽斩 / 杀的 / 的有 / 领名, fourteen of twenty probes
// "absent").
// found, and probing one spends a retrieval on a fragment nobody asked about.
func namedTermsOf(queries []string) []string {
var out []string
seen := make(map[string]bool, len(queries)*2)
for _, q := range queries {
for _, t := range GrepWordsFromQuery(q) {
// A PIECE OF A PATTERN is a phrase, not a name: "关公.*斩" asks how a
// deed is written, and probing 关公 or 斩 as a name spends a retrieval
// on a word nobody proposed as a member (measured: a pass built from
// such fragments probed 羽斩 / 杀的 / 的有 and reported fourteen
// "absent"). An alternation of PLAIN words ("华雄|颜良|蔡阳") is exactly
// what the seat exists for, so the test is pattern OPERATORS, not "|".
// A PIECE OF A PATTERN is a phrase, not a name: an alternation carrying
// operators asks how a deed is written, and probing its pieces as names
// spends a retrieval on a word nobody proposed as a member. An alternation
// of PLAIN words is exactly what the seat exists for, so the test is pattern
// OPERATORS, not "|".
if strings.ContainsAny(t, ".*+?()[]{}^$\\") {
continue
}
@@ -1081,15 +1071,13 @@ func namedTermsOf(queries []string) []string {
// search runs one retrieval call for a tool invocation.
//
// Python's retrieve/search_chunks both funnel into tools/search.py, differing
// only in whether compiled expansion runs (search_chunks expands, retrieve does
// not) and in the accepted query count.
// The two retrieval tools share this body, differing only in whether compiled expansion
// runs (search_chunks expands, retrieve does not) and in the accepted query count.
func (e *searchExecutor) search(ctx context.Context, name string, args map[string]any) (ToolOutcome, error) {
queries := toolQueries(args)
if len(queries) == 0 {
// Python _arg_query_list yields [] for a missing/blank query and
// _run_search simply admits nothing: _search_outcome([], ...) is
// MISS/no_doc (action_session.py:_search_outcome), NOT a bad-args error.
// A missing/blank query yields no queries and simply admits nothing: the outcome is
// MISS/no_doc, NOT a bad-args error.
return ToolOutcome{
Payload: []any{},
EvidenceIDs: nil,
@@ -1098,18 +1086,15 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
Metrics: map[string]any{"hits": 0, "new_evidence": 0},
}, nil
}
// Python's retrieval tools run against tools.kbinfos, and _seed_evidence
// (action_session.py:_admit_evidence) CREATES it when absent — so every search has a
// (possibly empty) pool to admit into. Mirror that instead of bailing out:
// the search runs and its outcome is decided by what it actually admitted.
// The pool is CREATED when absent, so every search has a (possibly empty) pool to admit
// into: the search runs and its outcome is decided by what it actually admitted.
if e.deps.KB == nil {
e.deps.KB = &Kbinfos{}
}
logger := searchLogger(e.deps)
// Max queries per tool call mirrors Python action_session.execute_tool
// (_arg_query_list): retrieve=3, search_chunks=2. grep_search/grep_chunks
// are Go-internal tools (Python exposes no such session tool), so they are
// Max queries per tool call: retrieve=3, search_chunks=2. grep_search/grep_chunks are
// Go-internal tools, so they are
// left uncapped.
//
// A direction assembling a SET/COUNT raises the ceiling. There the call's
@@ -1117,9 +1102,8 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
// a dropped query is not a spared repeat — it is a member nobody searched.
// The flag is set by the same gate that hands the model the set method (see
// Kbinfos.MarkSetDirection), so a VALUE direction keeps the small cap it had.
// Measured (2026-09-16, medium mode): every call asked 3-5 queries against
// these caps, nine calls were cut, and the names the run then failed to
// record had been named only in the dropped ones.
// Calls ask several queries apiece, and a name the answer later turns out to be
// missing was often named only in a dropped one.
maxQ := len(queries)
switch name {
case "retrieve":
@@ -1138,9 +1122,8 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
// The full list is kept: maxQ bounds how many EXPENSIVE queries run, but the
// terms of the dropped ones still get their own cheap seat below. Dropping a
// list item silently (as this cut used to) drops the individuals it named
// before any retrieval happens — measured (2026-09-15): a run named 29
// queries, 8 never executed, and those 8 included the only mentions of the
// names it was missing.
// before any retrieval happens, and those individuals can be the only mentions of
// the very names the answer is missing.
allQueries := queries
dropped := 0
if len(queries) > maxQ {
@@ -1152,11 +1135,10 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
var payload []any
var evidenceIDs []string
newChunks := 0
// Per-call admittance state, mirroring Python _run_search/_admit_evidence
// (action_session.py:_run_search): `seen` dedups chunks ACROSS the queries of
// this one call. The pool-side dedup is Kbinfos.Admit's job, against the LIVE
// pool — a per-call snapshot of it (Python's `kb_seen`) is exact only while
// nothing can interleave, and another session appending makes it stale.
// Per-call admittance state: `seen` dedups chunks ACROSS the queries of this one call.
// The pool-side dedup is Kbinfos.Admit's job, against the LIVE pool — a per-call
// snapshot of it is exact only while nothing can interleave, and another session
// appending makes it stale.
seen := map[string]bool{}
// reached accumulates every candidate the call's own searches returned. It is
// what the seat pass below asks "which of the named terms did this retrieval
@@ -1167,24 +1149,19 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
// names is exactly where this matters — the passages alone cannot say whether
// a member was missing or simply never asked about.
var reachNotes []string
// Claim-first, MUTUALLY EXCLUSIVE (Python _run_search: _claim_prefetch +
// _CLAIM_PREFETCH_EXCLUSIVE): when claim rows hit, their verbatim evidence
// IS the answer material — chunk snippets on top would echo the same
// passages and burn tokens. Claims carry chunk pointers, so deep-reading
// stays one list_chunks away. No hits → the chunk search runs exactly as
// before. Applies to the whole retrieve family + search_chunks, matching
// Python's _exec_retrieve/_exec_search_chunks (both route through
// _run_search). Best effort: any failure falls through without claims.
// Claim-first, MUTUALLY EXCLUSIVE: when claim rows hit, their verbatim evidence IS the
// answer material — chunk snippets on top would echo the same passages and burn tokens.
// Claims carry chunk pointers, so deep-reading stays one list_chunks away. No hits →
// the chunk search runs exactly as before. Applies to the whole retrieve family +
// search_chunks. Best effort: any failure falls through without claims.
if name == "retrieve" || name == "search_chunks" || strings.HasPrefix(name, "grep") {
if len(queries) > 0 {
if claimPayload, _, claimPseudo, ok := ClaimPrefetch(ctx, e.deps, queries[0], seen); ok {
newEvidence := 0
e.deps.KB.Admit(func(p *PoolAdmitter) {
for _, pc := range claimPseudo {
// Claim pseudo-chunks BYPASS the pool cap: Python
// _claim_prefetch appends them directly to
// kbinfos["chunks"] (:755) and the cap check (:657)
// only guards regular chunk admits — the verbatim
// Claim pseudo-chunks BYPASS the pool cap: they are appended directly to the
// pool and the cap only guards regular chunk admits — the verbatim
// evidence must land in the pool even when it is FULL.
cid := ChunkIDOf(pc)
if seen[cid] {
@@ -1213,25 +1190,21 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
}
}
for _, q := range queries {
// Per-tool top_n (mirrors Python action_session: retrieve=10,
// search_chunks=20). They were previously collapsed onto e.req.TopN,
// so search_chunks returned far fewer candidates than Python.
// Per-tool top_n: retrieve=10, search_chunks=20. Collapsing both onto e.req.TopN
// made search_chunks return far fewer candidates.
topN := 10
if name == "search_chunks" {
topN = 20
}
// Python dispatches these tools to genuinely different search functions
// (action_session.py:_exec_retrieve :751): retrieve/grep_* → grep_search
// (keyword-only), search_chunks → hybrid_search (vector leg when an
// embedder is configured). Each is now its own Go function so a single
// global switch can no longer disable the semantic leg.
// These tools dispatch to genuinely different search functions: retrieve/grep_* →
// grep_search (keyword-only), search_chunks → hybrid_search (vector leg when an
// embedder is configured). Each is its own function so a single global switch can no
// longer disable the semantic leg.
//
// Python's search_chunks tool ALWAYS enables compiled-structure expansion
// in ALL modes, not just high (action_session.py:execute_tool passes
// use_compiled=True); retrieve/grep_search never do. So Go mirrors that:
// compiled is on for search_chunks and off for every other retrieve-family
// tool, independent of e.req.UseCompiled (which gates the L1 direct
// retrieve, not the action_session tool loop).
// search_chunks ALWAYS enables compiled-structure expansion, in ALL modes, not just
// high; retrieve/grep_search never do. So compiled is on for search_chunks and off for
// every other retrieve-family tool, independent of e.req.UseCompiled (which gates the
// L1 direct retrieve, not the action-session tool loop).
var searchFn func(context.Context, SearchDeps, SearchParams) ([]map[string]any, []map[string]any)
useCompiled := name == "search_chunks"
if useCompiled {
@@ -1239,31 +1212,27 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
} else {
searchFn = GrepSearch
}
// Keywords mirror the two Python executors exactly:
// - retrieve → grep_search(keywords=nav_hint or None). In Python the
// nav hint is an explicit PARAMETER of _exec_retrieve and is passed
// ONLY by the navigation ladder (action_session.py:_run_drill_merge); the model's
// own retrieve dispatch (:1022) passes none, in which case grep_search
// falls back to the query's own extracted terms (search.py:grep_search) —
// that fallback lives in GrepSearch, which turns them into the BM25
// hint. The session run keywords (req.Keywords) are never forwarded.
// - search_chunks → _exec_search_chunks (:751) takes no keywords at all,
// so hybrid_search's _narrow_or_keep is a no-op.
// Keywords differ per tool:
// - retrieve → grep_search(keywords=nav_hint or None). The nav hint is an explicit
// PARAMETER, passed ONLY by the navigation ladder; the model's own retrieve
// dispatch passes none, in which case GrepSearch falls back to the query's own
// extracted terms and turns them into the BM25 hint. The session run keywords
// (req.Keywords) are never forwarded.
// - search_chunks takes no keywords at all, so hybrid_search's narrowing is a
// no-op.
var kws string
if !useCompiled {
kws = argString(args, "nav_hint")
}
// doc_scope is honoured by the retrieve family only: Python's
// _exec_retrieve reads args["doc_scope"], while
// _exec_search_chunks (:751) takes no doc_scope at all and its
// doc_scope is honoured by the retrieve family only: retrieve reads
// args["doc_scope"], while search_chunks takes no doc_scope at all and its
// hybrid_search call is unscoped. Gating on useCompiled keeps that split.
var docScope []string
if !useCompiled {
docScope = toolDocScope(args)
}
// Python _run_search reads only res["chunks"], but the aggregations it
// drops are what the answer's document/reference list is built from —
// this tool is their only writer.
// The aggregations the search body would otherwise drop are what the answer's
// document/reference list is built from — this tool is their only writer.
chunks, aggs := searchFn(ctx, e.deps, SearchParams{
Question: q,
Keywords: kws,
@@ -1282,7 +1251,7 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
reachNotes = append(reachNotes, line)
}
// Only the first snippetsPerQueryFor(mode) hits of each query are considered
// (Python cands[:_SNIPPETS_PER_QUERY]) — EXCEPT for a query that NAMES
// — EXCEPT for a query that NAMES
// terms, which keeps one candidate per named term first. The locate step
// hands back one window per term, and a flat cut is what turns a six-name
// call into "the names that matched most": the rarest lose their seat to the
@@ -1296,18 +1265,16 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
}
reached = append(reached, chunks...)
// Admittance mirrors _admit_evidence exactly: per-call dedup by chunk
// id, the chunk ID as the evidence reference (Python's `ids` holds ids,
// not pool positions), and only chunks NEW to the shared pool appended
// to it — so REDUNDANT means "nothing new", not "nothing returned".
// id, the chunk ID as the evidence reference (ids, not pool positions), and only
// chunks NEW to the shared pool appended to it — so REDUNDANT means "nothing new",
// not "nothing returned".
//
// ONE query's batch is one critical section: Python's per-query loop has
// no await (the awaits sit in the outer query loop, :691-700), so asyncio
// cannot interleave two sessions' batches. Locking per chunk would let
// them interleave into pool orders Python can never produce.
// ONE query's batch is one critical section: the per-query loop has no await, so
// two sessions' batches cannot interleave. Locking per chunk would let them
// interleave into pool orders that should not happen.
e.deps.KB.Admit(func(p *PoolAdmitter) {
// Python _admit_evidence computes _claim_covered_ids(kbinfos) from the
// LIVE pool once per call; compute it once per batch under the same
// critical section.
// The claim-covered set is computed from the LIVE pool once per batch, under the
// same critical section.
covered := p.ClaimCoveredIDs()
// The cap exemption for this batch, derived from the probe's own terms
// (see probeTerms / PoolAdmitter.Novelty): a full pool still takes the
@@ -1316,9 +1283,9 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
// passage.
novel := p.Novelty(probeTerms(q))
for _, c := range chunks {
// Python _admit_evidence early-stops at the top once the shared pool
// reaches the cap, BEFORE the per-call dedup — except for the probe
// window above, which IS the answer the caller asked for.
// Admission early-stops once the shared pool reaches the cap, BEFORE the
// per-call dedup — except for the probe window above, which IS the answer the
// caller asked for.
if p.Full() && !novel.Admits(c) {
continue
}
@@ -1326,7 +1293,7 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
if seen[cid] {
continue
}
// Python :672-676 — already quoted verbatim by a pooled claim →
// already quoted verbatim by a pooled claim →
// skip the full passage (table chunks exempt: their answer rows
// survive only in full text). Not pooled, not passed to the model.
if p.CoveredByClaim(cid, covered, IsTableChunk(c)) {
@@ -1335,9 +1302,8 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
seen[cid] = true
evidenceIDs = append(evidenceIDs, cid)
payload = append(payload, passageFromChunk(c))
// Pool identity uses chunkKey (Go's stable key): Python's _chunk_key
// falls back to id(ck) — the dict's address — so an equivalent
// re-retrieved chunk never matches and is appended again.
// Pool identity uses chunkKey (a stable key): an address-based key would never
// match an equivalent re-retrieved chunk, and it would be appended again.
if p.Add(c) {
newChunks++
}
@@ -1453,12 +1419,11 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
}, nil
}
// Zero new evidence (every hit was already in the pool) is REDUNDANT, not
// OK. Python _search_outcome still returns the
// FULL payload here — the model sees the passages AND a redundant status, so
// it knows the ground is already covered. The session tool node appends the
// "ALREADY in your evidence" note on StatusRedundant,
// which is what stops the re-issue; dropping the payload (as an earlier port
// did) hid the evidence the model needs and is the divergence from Python.
// OK. The FULL payload is still returned here — the model sees the passages AND a
// redundant status, so it knows the ground is already covered. The session tool node
// appends the "ALREADY in your evidence" note on StatusRedundant, which is what stops
// the re-issue; dropping the payload (as an earlier version did) hid the evidence the
// model needs.
if newChunks == 0 {
return ToolOutcome{
Payload: payload,
@@ -1479,18 +1444,18 @@ func (e *searchExecutor) search(ctx context.Context, name string, args map[strin
}, nil
}
// calculate mirrors Python's calculate tool (action_session.py:_exec_calculate): derive a
// number the evidence does not state outright, by having the model write ONE
// calculate derives a number the evidence does not state outright, by having the model
// write ONE
// expression and evaluating it against the AST whitelist (see arithmetic.go).
//
// The computed value is returned as evidence, so a later answer step can cite it
// without re-deriving. Nothing derivable is POOR/no_doc, not an error — the model
// then answers from the facts it already has.
func (e *searchExecutor) calculate(ctx context.Context, args map[string]any) (ToolOutcome, error) {
// Python validates NOTHING here: an absent question or fact list flows into
// compute_from_facts, whose own `if not question or not facts` guard returns
// None — a POOR/no_doc "nothing derivable", never a bad-args error. There is
// no fallback to the run question either.
// NOTHING is validated here: an absent question or fact list flows into
// compute_from_facts, whose own `if not question or not facts` guard returns None — a
// POOR/no_doc "nothing derivable", never a bad-args error. There is no fallback to the
// run question either.
question := argString(args, "question")
facts := make([]string, 0, 8)
for _, f := range toolStringList(args, "facts") {
@@ -1499,8 +1464,7 @@ func (e *searchExecutor) calculate(ctx context.Context, args map[string]any) (To
}
}
if e.deps.Model == nil {
// Python :946-948 — no chat model is an INFRA failure, checked BEFORE any
// derivation, with the payload Python emits verbatim.
// no chat model is an INFRA failure, checked BEFORE any derivation.
return ToolOutcome{
Payload: []any{map[string]any{
"kind": "calculate",
@@ -1513,7 +1477,7 @@ func (e *searchExecutor) calculate(ctx context.Context, args map[string]any) (To
}
cf := ComputeFromFacts(ctx, e.deps.Model, question, facts, 0)
if cf == nil {
// Nothing derivable is POOR/no_doc (Python :954-960), with Python's note.
// Nothing derivable is POOR/no_doc.
return ToolOutcome{
Payload: []any{map[string]any{
"kind": "calculate",
@@ -1525,7 +1489,7 @@ func (e *searchExecutor) calculate(ctx context.Context, args map[string]any) (To
Metrics: map[string]any{},
}, nil
}
// Python :961 — the success payload is exactly {"kind","expression","result"};
// the success payload is exactly {"kind","expression","result"};
// the label/uses the model returned are deliberately NOT echoed back.
return ToolOutcome{
Payload: []any{map[string]any{
@@ -1599,19 +1563,16 @@ func toolQueries(args map[string]any) []string {
return nil
}
// toolDocScope reads the optional doc_scope restriction — the tool argument
// Python parses in exactly two dispatchers: retrieve (action_session.py:execute_tool)
// and graph_explore (:977). Both do
// toolDocScope reads the optional doc_scope restriction — parsed in exactly two
// dispatchers: retrieve and graph_explore, as
//
// [str(d) for d in (args.get("doc_scope") or []) if str(d).strip()]
//
// so ONLY the "doc_scope" key is honoured: there is deliberately NO "doc_ids"
// alias and NO bare-string coercion (Python would iterate a string char-wise;
// no tool schema advertises either key, so both cases are unreachable). The
// other tools that used to call this — search_chunks, navigate_tree,
// navigate_structure — must NOT read a scope: Python's _exec_search_chunks,
// _exec_navigate_tree and _exec_navigate_structure never thread args["doc_scope"]
// into their impls.
// so ONLY the "doc_scope" key is honoured: there is deliberately NO "doc_ids" alias and
// NO bare-string coercion (a string would be iterated char-wise; no tool schema advertises
// either key, so both cases are unreachable). The other tools that used to call this —
// search_chunks, navigate_tree, navigate_structure — must NOT read a scope: none of them
// threads args["doc_scope"] into its impl.
func toolDocScope(args map[string]any) []string {
raw, ok := args["doc_scope"]
if !ok {
@@ -1638,9 +1599,9 @@ func toolDocScope(args map[string]any) []string {
return out
}
// passageFromChunk renders one chunk as the passage dict the model sees: the keys
// of Python _admit_evidence (:667-673) — {"id", "content", "doc_id"}, no title and
// no query. The id must stay under "id": the drill merge reads it as entry["id"],
// passageFromChunk renders one chunk as the passage dict the model sees: {"id",
// "content", "doc_id"} — no title and no query. The id must stay under "id": the drill
// merge reads it as entry["id"],
// so a "chunk_id" key silently disables the drill's structure_path attachment.
func passageFromChunk(c map[string]any) map[string]any {
// Table chunks pass through un-truncated: the 1200-char cap would hide rows
@@ -1649,8 +1610,7 @@ func passageFromChunk(c map[string]any) map[string]any {
if IsTableChunk(c) {
content = ChunkTextOf(c)
} else {
// Python _admit_evidence: content = _ct[:1200], a plain slice (no trim,
// no ellipsis).
// Content is a plain slice at 1200 chars (no trim, no ellipsis).
content = truncateRunes(ChunkTextOf(c), 1200)
}
return map[string]any{
@@ -1664,10 +1624,10 @@ func passageFromChunk(c map[string]any) map[string]any {
// agent's post-stream citation grounding can read it.
//
// Chunk and aggregation shapes match runtime's referenceChunksFromRetrieval /
// referenceDocAggsFromRetrieval (which dual-write both Python and Go field
// names), so no translation is needed by the consumer. Exported because the
// caller sits in the parent advanced_rag package (agentic_rag.go), not
// inside harness.
// referenceDocAggsFromRetrieval (which dual-write both field-name styles), so no
// translation is needed by the consumer. Exported because the
// caller sits in the parent agentic_rag package (agentic_rag.go), not
// inside retrieval.
func PublishReferences(ctx context.Context, kb *Kbinfos) {
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
if err != nil || state == nil {
@@ -1696,20 +1656,19 @@ func PublishReferences(ctx context.Context, kb *Kbinfos) {
state.SetRetrievalReferences(chunks, kb.DocAggs)
}
// RuntimeRetriever adapts runtime.GetRetrievalService() to the harness Retriever
// RuntimeRetriever adapts runtime.GetRetrievalService() to the runtime Retriever
// interface. The service is read on every call (not captured at construction)
// because the server installs it during boot, which may happen after a harness
// because the server installs it during boot, which may happen after a runtime
// component was built.
//
// This is the only place in the harness that knows about internal/agent/runtime;
// it lives in the harness root package (not a sub-package) so the runtime
// dependency does not leak into the harness's testable core.
// RuntimeRetriever is the production harness retriever. It mirrors Python's
// settings.retriever: it runs the backend search and returns the normalised
// chunks. Child-fragment promotion (retrieval_by_children) is NOT done here —
// it is entry-point specific (only hybrid_search / RAGTools.retrieve do it in
// Python, search.py:_normalize / agentic_rag.py:RAGTools.retrieve), so it lives in runSearch, gated
// by searchOpts.promoteChildren, and this Backend stays caller-agnostic.
// This is the only place in the runtime that knows about internal/agent/runtime;
// it lives in the runtime root package (not a sub-package) so the runtime
// dependency does not leak into the runtime's testable core.
// RuntimeRetriever is the production runtime retriever: it runs the backend search and
// returns the normalised chunks. Child-fragment promotion (retrieval_by_children) is NOT
// done here — it is entry-point specific (only the hybrid and retrieve legs do it), so it
// lives in runSearch, gated by searchOpts.promoteChildren, and this Backend stays
// caller-agnostic.
type RuntimeRetriever struct{}
func (r *RuntimeRetriever) Retrieve(ctx context.Context, req RetrieveRequest) ([]map[string]any, error) {
@@ -1730,9 +1689,8 @@ func (r *RuntimeRetriever) Retrieve(ctx context.Context, req RetrieveRequest) ([
DisableVectorLeg: req.DisableVectorLeg,
TenantID: req.TenantID,
RankFeature: req.RankFeature,
// ExcludeCompiled maps Python hybrid_search's
// must_not={"exists":"compile_kwd"} onto the runtime request's
// OnlyOriginalText (the "no compile_kwd" exclusion).
// ExcludeCompiled maps onto the runtime request's OnlyOriginalText (the
// "no compile_kwd" exclusion).
OnlyOriginalText: req.ExcludeCompiled,
})
if err != nil {
@@ -1741,8 +1699,8 @@ func (r *RuntimeRetriever) Retrieve(ctx context.Context, req RetrieveRequest) ([
return chunksToMaps(chunks), nil
}
// chunksToMaps normalises runtime.RetrievalChunk values into the harness chunk
// shape (the keys the rest of the harness expects: chunk_id / content / doc_id
// chunksToMaps normalises runtime.RetrievalChunk values into the runtime chunk
// shape (the keys the rest of the runtime expects: chunk_id / content / doc_id
// / docnm_kwd / ...).
func chunksToMaps(chunks []runtime.RetrievalChunk) []map[string]any {
out := make([]map[string]any, 0, len(chunks))
@@ -1780,8 +1738,8 @@ func chunksToMaps(chunks []runtime.RetrievalChunk) []map[string]any {
// TenantIDFromContext returns the tenant id bound on the canvas context, used as
// a fallback for SearchDeps.TenantID when the caller's SearchRequest leaves it
// empty. The harness stays independent of the tool registry, so it reads only
// what CanvasState.GetVar exposes. Exported so the advanced_rag package's Run can call
// empty. The runtime stays independent of the tool registry, so it reads only
// what CanvasState.GetVar exposes. Exported so the agentic_rag package's Run can call
// it.
func TenantIDFromContext(ctx context.Context) string {
state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx)
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package harness
package runtime
import (
"bytes"
@@ -30,9 +30,9 @@ import (
"ragflow/internal/engine"
)
// stubRetrievalService returns a fixed set of child chunks so the harness
// stubRetrievalService returns a fixed set of child chunks so the runtime
// retireval path can be exercised without a live search backend. lastReq, when
// set, receives the request the harness handed down.
// set, receives the request the runtime handed down.
type stubRetrievalService struct {
chunks []runtime.RetrievalChunk
lastReq *runtime.RetrievalRequest
@@ -134,8 +134,7 @@ func TestChunkAggRetrievePropagatesError(t *testing.T) {
// TestRuntimeRetrieverPromotesChildrenToParent verifies that
// RuntimeRetriever.Retrieve threads child chunks through retrieval_by_children:
// two child fragments sharing a mom_id collapse into the single parent chunk,
// mirroring Python settings.retriever.retrieval_by_children.
// two child fragments sharing a mom_id collapse into the single parent chunk.
func TestRuntimeRetrieverPromotesChildrenToParent(t *testing.T) {
prev := runtime.GetRetrievalService()
runtime.SetRetrievalService(stubRetrievalService{chunks: []runtime.RetrievalChunk{
@@ -217,10 +216,9 @@ func TestHybridSearchSkipsWhenNoChildren(t *testing.T) {
}
}
// TestBM25SearchDoesPromoteChildren verifies that bm25_search (like every other
// entry point) runs retrieval_by_children: a child fragment is lifted to its
// parent chunk. This mirrors Python search.py:bm25_search, which routes
// through _normalize (which calls retrieval_by_children).
// TestBM25SearchDoesPromoteChildren verifies that bm25_search (like every other entry
// point) runs retrieval_by_children: a child fragment is lifted to its parent chunk, because
// the normaliser calls retrieval_by_children.
func TestBM25SearchDoesPromoteChildren(t *testing.T) {
prev := runtime.GetRetrievalService()
runtime.SetRetrievalService(stubRetrievalService{chunks: []runtime.RetrievalChunk{
@@ -238,9 +236,8 @@ func TestBM25SearchDoesPromoteChildren(t *testing.T) {
}
}
// TestExecuteLogsFunctionToolLine covers the Python tool_decorator.py:tool_call_async line
// "[Function tool] Running the {name} tool with: {args}", which Python's
// _SCOPED_PREFIXES forwarded into the think block.
// TestExecuteLogsFunctionToolLine covers the "[Function tool] Running the {name} tool
// with: {args}" line, one of the namespaces forwarded into the think block.
func TestExecuteLogsFunctionToolLine(t *testing.T) {
var buf bytes.Buffer
deps := SearchDeps{Logger: log.New(&buf, "", 0)}
@@ -267,10 +264,9 @@ func TestExecuteNoLoggerIsSafe(t *testing.T) {
}
}
// TestSearchChunksAlwaysUsesCompiled pins fix #2: Python's search_chunks tool
// ALWAYS enables compiled-structure expansion (action_session.py:execute_tool passes
// use_compiled=True); retrieve never does. The Go executor must mirror that and
// NOT gate it on RunRequest.UseCompiled (which controls the L1 direct retrieve).
// TestSearchChunksAlwaysUsesCompiled pins fix #2: the search_chunks tool ALWAYS enables
// compiled-structure expansion; retrieve never does. The executor must NOT gate it on
// RunRequest.UseCompiled (which controls the L1 direct retrieve).
func TestSearchChunksAlwaysUsesCompiled(t *testing.T) {
// search_chunks must expand (independent of req.UseCompiled).
deps, _ := newTestSearchDeps(&stubRetriever{chunks: []map[string]any{{"content": "hit", "chunk_id": "c1"}}})
@@ -779,11 +775,10 @@ func TestQuoteQueries(t *testing.T) {
}
}
// TestSearchNilKBIsSeeded pins the Python _seed_evidence semantics
// (action_session.py:_admit_evidence): a nil deps.KB is not a caller mistake — the
// executor creates the (empty) pool on first use, so the search RUNS and its
// outcome is decided by what it admitted (OK here), never a forced MISS. The
// executor must also not panic on the nil pool.
// TestSearchNilKBIsSeeded pins the seed-on-demand semantics: a nil deps.KB is not a caller
// mistake — the executor creates the (empty) pool on first use, so the search RUNS and its
// outcome is decided by what it admitted (OK here), never a forced MISS. The executor must
// also not panic on the nil pool.
func TestSearchNilKBIsSeeded(t *testing.T) {
deps, _ := newTestSearchDeps(&stubRetriever{chunks: []map[string]any{{"content": "hit", "chunk_id": "c1"}}})
deps.KB = nil
@@ -800,10 +795,9 @@ func TestSearchNilKBIsSeeded(t *testing.T) {
}
}
// TestListChunksNilKBIsMiss pins the Python semantics: a nil deps.KB is seeded
// by _seed_evidence, and with no doc-store reader wired the read yields nothing
// — _search_outcome([]) is MISS/no_doc (there is no "graceful OK empty" branch in
// Python). It must never error or panic.
// TestListChunksNilKBIsMiss pins the semantics: a nil deps.KB is seeded on first use, and
// with no doc-store reader wired the read yields nothing, which is MISS/no_doc (there is no
// "graceful OK empty" branch). It must never error or panic.
func TestListChunksNilKBIsMiss(t *testing.T) {
deps, _ := newTestSearchDeps(&stubRetriever{})
deps.KB = nil
@@ -820,10 +814,10 @@ func TestListChunksNilKBIsMiss(t *testing.T) {
}
}
// TestListChunksDeepReadsDocStore pins the Python-parity deep read: list_chunks
// reads the document off the chunk store (deps.DocChunks, the same reader
// fetch_full_document uses) even when NONE of its chunks are in the evidence
// pool yet, and admits them into the shared pool the model can cite.
// TestListChunksDeepReadsDocStore pins the deep read: list_chunks reads the document off the
// chunk store (deps.DocChunks, the same reader fetch_full_document uses) even when NONE of
// its chunks are in the evidence pool yet, and admits them into the shared pool the model can
// cite.
func TestListChunksDeepReadsDocStore(t *testing.T) {
deps, kb := newTestSearchDeps(&stubRetriever{})
deps.DocChunks = docChunksFor("doc-a", 2) // only doc-a is readable
@@ -858,10 +852,9 @@ func TestListChunksDeepReadsDocStore(t *testing.T) {
}
}
// TestListChunksCapsDeepReadAndOutput pins the Python parity: the deep read may
// fetch up to listChunksMaxDeep (80) chunks, but _exec_list_chunks admits only
// the first listChunksMaxOut (30) into the shared pool and shows the model the
// same 30 — so the pool holds 30, not 80.
// TestListChunksCapsDeepReadAndOutput pins the two caps: the deep read may fetch up to
// listChunksMaxDeep (80) chunks, but list_chunks admits only the first listChunksMaxOut (30)
// into the shared pool and shows the model the same 30 — so the pool holds 30, not 80.
func TestListChunksCapsDeepReadAndOutput(t *testing.T) {
deps, kb := newTestSearchDeps(&stubRetriever{})
deps.DocChunks = docChunksFor("doc-a", 100)
@@ -872,7 +865,7 @@ func TestListChunksCapsDeepReadAndOutput(t *testing.T) {
t.Fatalf("list_chunks: %v", err)
}
if len(kb.Chunks) != listChunksMaxOut {
t.Errorf("kb.Chunks = %d, want admitted cap %d (Python _exec_list_chunks [:30])", len(kb.Chunks), listChunksMaxOut)
t.Errorf("kb.Chunks = %d, want admitted cap %d", len(kb.Chunks), listChunksMaxOut)
}
if len(oc.Payload) != listChunksMaxOut {
t.Errorf("payload = %d, want output cap %d", len(oc.Payload), listChunksMaxOut)
@@ -959,15 +952,13 @@ func TestEvidencePoolCapExemptsTheProbeWindow(t *testing.T) {
}
}
// TestWebSearchAdmitsToPool pins the web_search parity fix: web results merge
// into the SAME shared evidence pool as corpus hits (Python _exec_web_search →
// _admit_evidence), so downstream formalize/compose can cite them, and the
// outcome is REDUNDANT/OK/MISS like the corpus tools.
// TestWebSearchAdmitsToPool pins the web_search parity fix: web results merge into the SAME
// shared evidence pool as corpus hits, so downstream formalize/compose can cite them, and
// the outcome is REDUNDANT/OK/MISS like the corpus tools.
func TestWebSearchAdmitsToPool(t *testing.T) {
deps, kb := newTestSearchDeps(&stubRetriever{})
deps.WebSearch = stubWebSearch{results: []string{"web answer one", "web answer two", "web answer three"}}
// Python action_session.execute_tool reads the query list from
// args["query"] (_arg_query_list, :938-944, :1159).
// The query list is read from args["query"].
oc, err := WebSearchTool(context.Background(), deps, map[string]any{"query": []any{"q1", "q2"}})
if err != nil {
t.Fatalf("web_search: %v", err)
@@ -992,13 +983,12 @@ func TestWebSearchAdmitsToPool(t *testing.T) {
}
}
// TestWebSearchDedupsAcrossQueries pins the Python parity: a passage that both
// queries return is admitted/shown only once (Python dedups by chunk_id across
// the two queries via a shared `seen` set).
// TestWebSearchDedupsAcrossQueries pins the dedup: a passage that both queries return is
// admitted/shown only once (dedup by chunk_id across the queries via a shared `seen` set).
func TestWebSearchDedupsAcrossQueries(t *testing.T) {
deps, kb := newTestSearchDeps(&stubRetriever{})
deps.WebSearch = stubWebSearch{results: []string{"dup passage", "unique one", "dup passage"}}
// Same args["query"] contract as above (Python _arg_query_list).
// Same args["query"] contract as above.
oc, err := WebSearchTool(context.Background(), deps, map[string]any{"query": []any{"q1", "q2"}})
if err != nil {
t.Fatalf("web_search: %v", err)
@@ -1011,9 +1001,8 @@ func TestWebSearchDedupsAcrossQueries(t *testing.T) {
}
}
// TestListChunksSkipsEmptyChunkID pins Python parity (L3): a deep-read chunk
// with no chunk_id is skipped — neither admitted to the pool nor shown to the
// model (Python _exec_list_chunks: `if not cid: continue`).
// TestListChunksSkipsEmptyChunkID pins the empty-id skip: a deep-read chunk with no
// chunk_id is skipped — neither admitted to the pool nor shown to the model.
func TestListChunksSkipsEmptyChunkID(t *testing.T) {
deps, kb := newTestSearchDeps(&stubRetriever{})
deps.DocChunks = docChunksWithIDs("doc-a", []string{"", "c1"}) // first has empty chunk_id
@@ -1092,10 +1081,9 @@ func (s stubWebSearch) Search(_ context.Context, _ []string) ([]string, error) {
return s.results, nil
}
// TestSearchRedundantReturnsFullPayload pins fix #5: when every hit is already
// in the evidence pool the result is StatusRedundant — but Python _search_outcome
// still returns the FULL passages so the model sees what it already has. The Go
// port must not drop the payload to empty (which hid the evidence).
// TestSearchRedundantReturnsFullPayload pins fix #5: when every hit is already in the
// evidence pool the result is StatusRedundant — but the FULL passages are still returned so
// the model sees what it already has. Dropping the payload to empty hid the evidence.
func TestSearchRedundantReturnsFullPayload(t *testing.T) {
chunk := map[string]any{"content": "already known passage", "chunk_id": "c1"}
deps, _ := newTestSearchDeps(&stubRetriever{chunks: []map[string]any{chunk}})
@@ -1145,7 +1133,7 @@ func TestNavigateToolsRouteWithinSessionDocScope(t *testing.T) {
// TestDocInDatasetsRejectsForeignDocumentViaSubsetVerifier reproduces the
// production DocIDLookup.KnownDocIDs shape: a foreign doc is reported via an
// EMPTY known map (not a false entry). docInDatasets must still reject it
// (mirrors Python _resolve_doc_tenant returning None), not fail open.
// (an unresolvable owner), not fail open.
// End-to-end wiring tests: one Run call, with a stubbed retriever and a
// scripted model, must move evidence from the backend into Kbinfos and (when a
@@ -1188,9 +1176,8 @@ func TestSearchExecutorReportsMissAndRedundancy(t *testing.T) {
t.Errorf("repeat call status = %s, want redundant", oc.Status)
}
// Missing query -> MISS/no_doc, NOT a bad-args error: Python
// _arg_query_list yields [] and _search_outcome([]) is a miss
// (action_session.py:_search_outcome).
// Missing query -> MISS/no_doc, NOT a bad-args error: an empty query list is simply a
// miss.
oc, _ = ex.Execute(context.Background(), "retrieve", map[string]any{})
if oc.Status != StatusMiss || oc.Reason != ReasonNoDoc {
t.Errorf("missing query: got (%s,%s), want (miss,no_doc)", oc.Status, oc.Reason)
@@ -1241,8 +1228,8 @@ func (f *fakeWikiRetriever) SearchWiki(_ context.Context, question string, keywo
}
func TestWikiQueryWiredReturnsPages(t *testing.T) {
// When a WikiRetriever is wired, wiki_query returns the parsed page payload
// in the same shape Python's action layer consumes.
// When a WikiRetriever is wired, wiki_query returns the parsed page payload in the shape
// the action layer consumes.
ex := &searchExecutor{deps: SearchDeps{WikiRetriever: &fakeWikiRetriever{}}, req: RunRequest{}}
oc, err := ex.Execute(context.Background(), "wiki_query", map[string]any{"query": "Culdcept overview"})
@@ -1270,12 +1257,10 @@ func TestWikiQueryWiredReturnsPages(t *testing.T) {
}
func TestWikiQuerySchemaIsUnplugged(t *testing.T) {
// wiki_query is ported as an UNPLUGGED extension seam: the handler exists
// (TestWikiQueryWiredReturnsPages), but it is NOT part of any mode's active
// tool set (removed from allTools) and no SearchWiki backend is wired in
// production, so it must never appear in the advertised surface — mirroring
// Python, which keeps tools/exploration.py::wiki_query but never registers it
// in the action session.
// wiki_query is an UNPLUGGED extension seam: the handler exists
// (TestWikiQueryWiredReturnsPages), but it is NOT part of any mode's active tool set
// (removed from allTools) and no SearchWiki backend is wired in production, so it must
// never appear in the advertised surface.
for _, mode := range []string{"medium", "high", "ultra"} {
ts := &Toolset{ThinkingMode: mode}
if spec, ok := findSpec(ts.ActiveToolSpecs(), "wiki_query"); ok {
@@ -1295,10 +1280,9 @@ func findSpec(specs []ToolSpec, name string) (ToolSpec, bool) {
}
func TestListChunksReadsFromEvidencePool(t *testing.T) {
// list_chunks deep-reads a document. With no doc-store reader wired it scans
// the accumulated evidence pool; an unknown/missing doc_id is a query-level
// MISS (mirroring Python list_chunks("") → _search_outcome), never a dataset
// EMPTY and never a hard error.
// list_chunks deep-reads a document. With no doc-store reader wired it scans the
// accumulated evidence pool; an unknown/missing doc_id is a query-level MISS, never a
// dataset EMPTY and never a hard error.
kb := &Kbinfos{Chunks: []map[string]any{
{"chunk_id": "c1", "doc_id": "doc-a", "content": "first"},
{"chunk_id": "c2", "doc_id": "doc-b", "content": "other"},
@@ -1308,15 +1292,14 @@ func TestListChunksReadsFromEvidencePool(t *testing.T) {
oc, _ := ex.Execute(context.Background(), "list_chunks", map[string]any{"doc_id": "doc-a"})
// Already in evidence → REDUNDANT (nothing new admitted), but the passages
// are still returned (mirrors Python _search_outcome).
// are still returned.
if oc.Status != StatusRedundant {
t.Fatalf("status = %s, want redundant", oc.Status)
}
if len(oc.Payload) != 2 {
t.Errorf("payload = %d, want 2 chunks of doc-a", len(oc.Payload))
}
// Evidence ids are the CHUNK ids — Python _admit_evidence does
// `ids.append(cid)`, not a pool position.
// Evidence ids are the CHUNK ids, not pool positions.
if len(oc.EvidenceIDs) != 2 || oc.EvidenceIDs[0] != "c1" || oc.EvidenceIDs[1] != "c3" {
t.Errorf("evidence ids = %v, want [c1 c3]", oc.EvidenceIDs)
}
@@ -1325,7 +1308,7 @@ func TestListChunksReadsFromEvidencePool(t *testing.T) {
if oc.Status != StatusMiss {
t.Errorf("unknown doc status = %s, want miss", oc.Status)
}
// Missing doc_id → MISS (Python list_chunks("") → empty → _search_outcome).
// Missing doc_id → MISS.
oc, _ = ex.Execute(context.Background(), "list_chunks", map[string]any{})
if oc.Status != StatusMiss {
t.Errorf("no doc_id: got %s, want miss", oc.Status)
@@ -1365,15 +1348,15 @@ func TestPassageFromChunkTruncatesContent(t *testing.T) {
"chunk_id": "c1", "doc_id": "d1", "docnm_kwd": "Title", "content": long,
})
content, _ := p["content"].(string)
// Non-table chunk is cut to 1200 code points (Python _admit_evidence's
// `_ct[:1200]`), a plain slice — so NO trailing ellipsis is added.
// Non-table chunk is cut to 1200 code points as a plain slice — so NO trailing ellipsis
// is added.
if n := len([]rune(content)); n != 1200 {
t.Errorf("content length = %d runes, want 1200", n)
}
if strings.HasSuffix(content, "...") {
t.Errorf("Python _ct[:1200] adds no ellipsis marker: %q", content)
t.Errorf("the 1200-code-point cut adds no ellipsis marker: %q", content)
}
// Keys mirror Python _admit_evidence exactly: {"id","content","doc_id"} — the
// Keys: exactly: {"id","content","doc_id"} — the
// id must live under "id", which is what the drill merge reads back.
if p["doc_id"] != "d1" || p["id"] != "c1" {
t.Errorf("passage = %v", p)
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -30,62 +30,55 @@ import (
"ragflow/internal/service/nlp"
)
// Exploration providers (Go equivalent of Python harness/tools/exploration.py):
// knowledge-graph walks (graph_explore) and wiki page drill-downs (wiki_query),
// plus the open-web search provider seam.
// Exploration providers: knowledge-graph walks (graph_explore) and wiki page drill-downs
// (wiki_query), plus the open-web search provider seam.
//
// graph_explore walks the compiled knowledge graph (see ExploreGraph): it seeds
// entities by dense similarity, hops out over relations, then asks the model
// whether the subgraph answers the question. wiki_query searches the compiled
// wiki_page_draft rows. Together these mirror the exploration.py module, which
// hosts both graph_explore and wiki_query.
// graph_explore walks the compiled knowledge graph (see ExploreGraph): it seeds entities by
// dense similarity, hops out over relations, then asks the model whether the subgraph
// answers the question. wiki_query searches the compiled wiki_page_draft rows.
//
// Search seams stay independent of the search backend so the harness core is
// Search seams stay independent of the search backend so the runtime core is
// testable without one; the bridge wires concrete implementations (wiki_page_draft
// rows for SearchWiki, an HTTP client for WebSearcher).
// WebSearcher runs an open-web search and returns result strings. It is the Go
// equivalent of Python's web_search provider path wired into the action session.
// WebSearcher runs an open-web search and returns result strings.
type WebSearcher interface {
Search(ctx context.Context, queries []string) ([]string, error)
}
// WikiPage is one compiled wiki page returned by WikiRetriever, mirroring the
// payload Python wiki_query emits per hit: the parsed page markdown plus the
// originating row identity so the harness can cite/load it.
// WikiPage is one compiled wiki page returned by WikiRetriever: the parsed page markdown
// plus the
// originating row identity so the runtime can cite/load it.
type WikiPage struct {
ChunkID string
DocID string
DocName string
Title string
// Content is the synthesised page markdown (Python's page_content).
// Content is the synthesised page markdown.
Content string
// Score is the retrieval relevance (higher = better).
Score float64
}
// WikiRetriever searches the compiled wiki_page_draft rows for a question and
// returns the most relevant pages. It is the Go equivalent of Python's
// tools/exploration.py::wiki_query, kept as a seam so the harness core stays
// independent of the search backend and remains testable without one.
// WikiRetriever searches the compiled wiki_page_draft rows for a question and returns the
// most relevant pages. It is a seam, so the runtime core stays independent of the search
// backend and remains testable without one.
type WikiRetriever interface {
// SearchWiki returns up to topN compiled wiki pages for the question,
// biased toward the optional keywords.
SearchWiki(ctx context.Context, question string, keywords []string, topN int) ([]WikiPage, error)
}
// wikiQuery implements the `wiki_query` tool, mirroring Python
// tools/exploration.py::wiki_query + action_session._exec_wiki_query.
// wikiQuery implements the `wiki_query` tool.
//
// It searches the compiled "wiki_page_draft" rows for the question, parses each
// hit's synthesised page markdown out of the row, and returns them in the same
// payload shape Python's action layer consumes: per hit
// payload shape the action layer consumes: per hit
//
// {chunk_id, doc_id, docnm_kwd, title, content, query}
//
// plus n_hits in Metrics. When no wiki retriever is wired (or the dataset has no
// wiki compilation), it returns a clean MISS so the model falls back to corpus
// tools — mirroring Python's wiki gate.
// plus n_hits in Metrics. When no wiki retriever is wired (or the dataset has no wiki
// compilation), it returns a clean MISS so the model falls back to corpus tools.
func (e *searchExecutor) wikiQuery(ctx context.Context, args map[string]any) (ToolOutcome, error) {
if e.deps.WikiRetriever == nil {
return ToolOutcome{
@@ -115,7 +108,7 @@ func (e *searchExecutor) wikiQuery(ctx context.Context, args map[string]any) (To
topN, _ := toInt(args["top_n"])
if topN <= 0 {
topN = 12 // Python _WIKI_QUERY_TOP_N = 12
topN = 12
}
if topN > 30 {
topN = 30
@@ -154,7 +147,7 @@ func (e *searchExecutor) wikiQuery(ctx context.Context, args map[string]any) (To
}
// splitComma splits a comma-separated keyword string into a clean slice,
// tolerating spaces. Mirrors Python's kw.strip() loop after split(",").
// tolerating spaces.
func splitComma(s string) []string {
if s == "" {
return nil
@@ -169,28 +162,24 @@ func splitComma(s string) []string {
return out
}
// graphExplore caps, mirroring Python _exec_graph_explore
// (action_session.py:execute_tool): chunks[:6] and str(content)[:1500].
// graphExplore caps: at most 6 passages of 1500 chars.
const (
graphExploreMaxChunks = 6
graphExplorePassageChars = 1500
)
// graphExplore explores the compiled knowledge graph for a relational / multi-hop
// answer (ultra only), delegating to ExploreGraph: seed entities for the query, hop
// along their relations, and return either a direct answer or the source passages
// behind the relevant entities. Mirrors Python exploration.py::graph_explore (paired
// with wikiQuery).
// graphExplore explores the compiled knowledge graph for a relational / multi-hop answer
// (ultra only), delegating to ExploreGraph: seed entities for the query, hop along their
// relations, and return either a direct answer or the source passages behind the relevant
// entities.
//
// The outcome shape mirrors _exec_graph_explore: a
// single-element payload tagged kind="graph_explore" whose body is exactly one of
// answer / note / chunks, at most 6 passages of 1500 chars, and NO pool merge (Python
// never calls _admit_evidence on this path).
// The outcome shape: a single-element payload tagged kind="graph_explore" whose body is
// exactly one of answer / note / chunks, at most 6 passages of 1500 chars, and NO pool
// merge.
//
// Deliberate deviation: Python reads c["id"] / c["content"] while the producer
// _load_chunks_by_ids emits chunk_id / content_with_weight,
// so Python's snippet is always {"id": None, "content": ""} with zero evidence ids — a
// key-mismatch bug. Go reads the keys the loader produces.
// Deliberate deviation from the original design: reading c["id"] / c["content"] while the
// loader emits chunk_id / content_with_weight yields an always-empty snippet with zero
// evidence ids — a key-mismatch bug. This reads the keys the loader produces.
func (e *searchExecutor) graphExplore(ctx context.Context, args map[string]any) (ToolOutcome, error) {
query := argString(args, "query")
if query == "" {
@@ -199,19 +188,18 @@ func (e *searchExecutor) graphExplore(ctx context.Context, args map[string]any)
if query == "" {
return ToolOutcome{Payload: []any{}, Status: StatusError, Reason: ReasonBadArgs}, nil
}
// No keywords: Python calls graph_explore(tools, query, doc_scope=...) with
// keywords defaulting to "" (action_session.py:_exec_graph_explore), so the graph's own
// narrowing is a no-op there. e.req.Keywords must not be injected.
// No keywords: graph_explore is called with keywords empty, so the graph's own
// narrowing is a no-op. e.req.Keywords must not be injected.
docScope := toolDocScope(args)
if docScope != nil && len(docScope) == 0 {
// An explicitly empty list is Python-falsy: graph_explore reads it as "no
// scope" and scans the bound datasets. (The non-nil empty "match nothing"
// signal only comes from resolveDocScope, downstream of here.)
// An explicitly empty list is falsy here: it reads as "no scope" and scans the
// bound datasets. (The non-nil empty "match nothing" signal only comes from
// resolveDocScope, downstream of here.)
docScope = nil
}
res, err := ExploreGraph(ctx, e.deps, e.deps.TenantID, e.deps.KbIDs, query, "", docScope)
if err != nil {
// Python :978-982 swallows the exception, sets res = {}, and falls into
// The failure is swallowed (res = {}) and falls into
// the empty branch below — an infra failure here is reported as a
// dataset-level EMPTY/no_structure, never an ERROR.
_LOG.Printf("[graph_explore] failed: %v", err)
@@ -219,7 +207,7 @@ func (e *searchExecutor) graphExplore(ctx context.Context, args map[string]any)
}
answer := strings.TrimSpace(res.Answer)
if answer != "" {
// Python :985-986 — a direct answer short-circuits: no passages.
// a direct answer short-circuits: no passages.
return ToolOutcome{
Payload: []any{map[string]any{"kind": "graph_explore", "answer": answer}},
Status: StatusOK,
@@ -228,7 +216,7 @@ func (e *searchExecutor) graphExplore(ctx context.Context, args map[string]any)
}, nil
}
if len(res.Chunks) == 0 {
// Python :987-997 — no compiled KG in scope is a DATASET-level dead end
// no compiled KG in scope is a DATASET-level dead end
// (EMPTY/no_structure), the one class of failure that may disable the tool.
return ToolOutcome{
Payload: []any{map[string]any{
@@ -251,8 +239,7 @@ func (e *searchExecutor) graphExplore(ctx context.Context, args map[string]any)
"content": truncateRunes(ChunkTextOf(c), graphExplorePassageChars),
})
}
// No EvidenceIDs and no pool merge: Python returns ids=[] here (its c["id"]
// lookup misses every loaded chunk) and never admits the passages to kbinfos.
// No EvidenceIDs and no pool merge: the loaded passages are not admitted to kbinfos.
return ToolOutcome{
Payload: []any{map[string]any{"kind": "graph_explore", "chunks": snippet}},
Status: StatusOK,
@@ -261,35 +248,31 @@ func (e *searchExecutor) graphExplore(ctx context.Context, args map[string]any)
}, nil
}
// ---------------------------------------------------------------------------
// graph_explore (knowledge-graph walk) — mirrors exploration.py::graph_explore
// ---------------------------------------------------------------------------
// ExploreGraph walks the compiled knowledge graph: seed entities for the query
// by dense similarity, hop kgHops out over their relations, then ask the model
// whether the resulting subgraph answers the question directly. When it does the
// answer is returned; when it doesn't, the source passages behind the relevant
// nodes are returned so the caller can keep researching. Mirrors Python
// exploration.py::graph_explore (which lives beside wiki_query in exploration.py).
// nodes are returned so the caller can keep researching.
//
// 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).
// similarity >= kgSeedSim, re-ranked by mention_count_int desc); when the embedding model
// is unavailable it degrades to keyword match.
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)
kgSeedSim = 0.8 // dense seed similarity floor
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
)
// kgScope is one (kb, tenant, docs) search group, mirroring one entry of
// Python _kg_scopes' return list. When Docs is non-empty the group searches
// kgScope is one (kb, tenant, docs) search group. When Docs is non-empty the group
// searches
// within those documents (kgScopeDoc); when nil it searches the whole dataset
// (kgScopeDataset).
type kgScope struct {
@@ -298,9 +281,8 @@ type kgScope struct {
Docs []string
}
// resolveKGScope builds the (kb, tenant, docs) search groups, mirroring
// Python _kg_scopes. The session doc_scope ceilings the
// caller's scope first (Python scoped_doc_ids; exploration.py:_kg_scopes). With no scope
// resolveKGScope builds the (kb, tenant, docs) search groups. The session doc_scope
// ceilings the caller's scope first. With no scope
// it returns one group per bound dataset (docs=nil => whole-dataset search).
// With a scope and a DocTenantResolver it groups documents by their real owning
// (kb, tenant), which may surface knowledge bases outside datasetIDs; documents
@@ -308,8 +290,8 @@ type kgScope struct {
// resolver it keeps the pre-existing behaviour: each bound dataset is searched
// with the whole scope.
func resolveKGScope(deps SearchDeps, docScope, datasetIDs []string) []kgScope {
// A non-nil empty scope is resolveDocScope's "match nothing" signal (the
// session ceiling removed every requested id). Python's falsy-empty check
// A non-nil empty scope is resolveDocScope's "match nothing" signal (the session
// ceiling removed every requested id). A falsy-empty check
// would fall through to whole-dataset scopes; Go keeps the ceiling absolute
// and expands nothing.
if docScope != nil && len(docScope) == 0 {
@@ -317,8 +299,7 @@ func resolveKGScope(deps SearchDeps, docScope, datasetIDs []string) []kgScope {
}
docScope = scopedDocIDs(deps.DocScope, docScope)
// Each bound dataset is scanned under its OWN owner tenant (Python
// `[(kb.id, kb.tenant_id, None) for kb in tools.kbs]`); the request tenant is
// Each bound dataset is scanned under its OWN owner tenant; the request tenant is
// only a fallback for a dataset that carries no tenant id.
tenantByKB := make(map[string]string, len(deps.KBs))
for _, kb := range deps.KBs {
@@ -358,9 +339,8 @@ func resolveKGScope(deps SearchDeps, docScope, datasetIDs []string) []kgScope {
byOwner[key] = append(byOwner[key], docID)
}
} else {
// Python's per-doc lookup raises here (exploration.py:68); Go degrades
// to the bound datasets below, so the lost owner grouping must not be
// silent.
// The per-doc lookup failed; the bound datasets are used instead, so the lost
// owner grouping must not be silent.
_LOG.Printf("[Graph explore] doc-tenant resolution failed for %d doc(s); falling back to the bound datasets: %v", len(docScope), err)
}
}
@@ -408,9 +388,8 @@ type kgRelation struct {
DocID string `json:"doc_id"`
}
// ExploreResult is the graph_explore output: exactly one of Answer / Chunks is
// populated, and DocAggs always reflects the returned set. Mirrors Python's
// {"answer", "chunks", "doc_aggs"} return dict.
// ExploreResult is the graph_explore output: exactly one of Answer / Chunks is populated,
// and DocAggs always reflects the returned set.
type ExploreResult struct {
Answer string
Chunks []map[string]interface{}
@@ -429,8 +408,8 @@ func ExploreGraph(ctx context.Context, deps SearchDeps, tenantID string, dataset
return empty, fmt.Errorf("graph_explore: engine not configured")
}
// Build the (kb, tenant, docs) search groups, mirroring Python
// _kg_scopes: group documents by their real owner (which may include KBs
// Build the (kb, tenant, docs) search groups: group documents by their real owner
// (which may include KBs
// outside datasetIDs), or fall back to whole-dataset search.
scopes := resolveKGScope(deps, docScope, datasetIDs)
@@ -459,8 +438,7 @@ func ExploreGraph(ctx context.Context, deps SearchDeps, tenantID string, dataset
for _, sc := range scopes {
// A scope with Docs searches within those documents; one without
// searches the whole dataset (mirrors Python _kg_scopes returning
// (kb, tenant, docs) where docs is None for the whole-dataset case).
// searches the whole dataset (docs is nil for the whole-dataset case).
scopeKwd := kgScopeDataset
if len(sc.Docs) > 0 {
scopeKwd = kgScopeDoc
@@ -487,11 +465,10 @@ func ExploreGraph(ctx context.Context, deps SearchDeps, tenantID string, dataset
map[string]interface{}{"from_entity_kwd": terms}, "", 0, deps.IndexName)
relRows = append(relRows, kgSearch(ctx, de, sc.TenantID, sc.KBID, sc.Docs, "relation", "", kgRelLimit, scopeKwd,
map[string]interface{}{"to_entity_kwd": terms}, "", 0, deps.IndexName)...)
// Dedup keys: Python keeps a relation unless its source row id is
// already seen (rel_rows is a dict keyed by row id), so the same
// endpoint pair from *different* rows survives. Mirror that: prefer
// the row id; only fall back to the from|to|type triple when the
// engine returned no id (so we at least collapse exact duplicates).
// Dedup keys: a relation is kept unless its source row id is already seen, so the
// same endpoint pair from *different* rows survives. Prefer the row id; only fall
// back to the from|to|type triple when the engine returned no id (so exact
// duplicates still collapse).
seenRel := map[string]bool{}
var hopRelations []kgRelation
for _, r := range relRows {
@@ -555,23 +532,21 @@ func ExploreGraph(ctx context.Context, deps SearchDeps, tenantID string, dataset
return empty, nil
}
// (3) Does the subgraph answer the question? The request-scoped model
// (deps.Model, Python's tools.chat_mdl) is threaded through so the verdict is
// drawn by the same tenant model as the rest of the harness.
// (3) Does the subgraph answer the question? The request-scoped model (deps.Model) is
// threaded through so the verdict is drawn by the same tenant model as the rest of the
// retrieval.
answer, relevant := askStructureAnswer(ctx, deps.Model, query, entities, relations)
// (4a) Sufficient — return the answer, no chunks.
if answer != "" {
return ExploreResult{Answer: answer, DocAggs: []map[string]interface{}{}}, nil
}
// (4b) Insufficient — return source passages behind the relevant nodes,
// narrowed to the sentences that carry the keywords (mirror Python
// exploration.py L359-361). Python's _narrow_by_keywords keeps the whole set
// when keywords is empty (text_processing.py `if not kwds: return chunks`),
// so we gate the strict narrow on a non-empty keyword string to avoid
// emptying the evidence. doc_aggs is computed from the resulting set (L363).
// Iterate the evidence groups in FIRST-SEEN order (Python walks its dict's
// .items()), so the executor's chunks[:6] picks the same passages each run.
// (4b) Insufficient — return source passages behind the relevant nodes, narrowed to the
// sentences that carry the keywords. NarrowByKeywords keeps the whole set when keywords
// is empty, so the strict narrow is gated on a non-empty keyword string to avoid
// emptying the evidence. doc_aggs is computed from the resulting set. The evidence
// groups are iterated in FIRST-SEEN order (the order each doc was first reached), so
// the executor's chunks[:6] picks the same passages each run.
evidence := collectEvidenceIDs(entities, relations, relevant)
var chunks []map[string]interface{}
for _, ev := range evidence {
@@ -595,13 +570,12 @@ var seedEncoder = defaultSeedEncoder
// encodeSeedVector encodes the seed text once for the whole ExploreGraph call.
// Returns nil when the tenant embedding model is unavailable (or encoding fails).
//
// An embedder passed in via SearchDeps.Embedder (the external, Python-style
// "tools.embed_mdl" handle) is used when present; otherwise the call falls back
// to the package's internal resolver (defaultSeedEncoder), which needs a
// database and therefore stays self-contained for callers that cannot or do not
// want to supply one.
// An embedder passed in via SearchDeps.Embedder (the external embed handle) is used when
// present; otherwise the call falls back to the package's internal resolver
// (defaultSeedEncoder), which needs a database and therefore stays self-contained for
// callers that cannot or do not want to supply one.
func encodeSeedVector(ctx context.Context, deps SearchDeps, tenantID, text string) []float64 {
// Mirror Python's "if getattr(tools, 'embed_mdl', None)" guard: without a
// Without a
// wired database there is no tenant default embedding model, so skip encoding
// and let the caller fall back to keyword matching rather than panicking.
if dao.GetDB() == nil {
@@ -609,8 +583,7 @@ func encodeSeedVector(ctx context.Context, deps SearchDeps, tenantID, text strin
}
if deps.Embedder != nil {
// The external handle may itself panic on a misconfigured model service
// (e.g. nil-DB); degrade to keyword matching like Python's
// `except Exception` around get_vector, never crash the request.
// (e.g. nil-DB); degrade to keyword matching, never crash the request.
vecs, err := safeEncodeEmbedder(deps.Embedder, ctx, tenantID, text)
if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 {
return nil
@@ -626,16 +599,15 @@ func encodeSeedVector(ctx context.Context, deps SearchDeps, tenantID, text strin
// safeEncodeEmbedder calls the embedder and recovers from any panic so a
// misconfigured embedding service degrades to keyword matching instead of
// crashing the request. It mirrors Python's get_vector exception handling.
// crashing the request.
func safeEncodeEmbedder(emb nlp.NavEmbedder, ctx context.Context, tenantID, text string) ([][]float32, error) {
defer func() {
if r := recover(); r != nil {
_LOG.Printf("[graph_explore] seed encode panicked; falling back to keyword: %v", r)
}
}()
// Seed encoding is a QUERY, not a document: Python's graph_explore and
// navigation seed through settings.retriever.get_vector → encode_queries.
// Prefer the asymmetric query encoding when the embedder exposes it.
// Seed encoding is a QUERY, not a document: graph_explore and navigation seed through
// query encoding. Prefer the asymmetric query encoding when the embedder exposes it.
if q, ok := emb.(nlp.NavQueryEmbedder); ok {
return q.EncodeQueries(ctx, tenantID, []string{text})
}
@@ -655,16 +627,14 @@ func SetSeedEncoder(fn func(ctx context.Context, tenantID, text string) []float6
}
func defaultSeedEncoder(ctx context.Context, tenantID, text string) []float64 {
// Mirror Python's "if getattr(tools, 'embed_mdl', None)" guard: when no
// database (and thus no tenant default embedding model) is wired up, skip
// encoding entirely and fall back to keyword matching rather than panicking
// on a nil DB inside the model-provider layer.
// With no database (and thus no tenant default embedding model) wired up, skip encoding
// entirely and fall back to keyword matching rather than panicking on a nil DB inside
// the model-provider layer.
if dao.GetDB() == nil {
return nil
}
embedder := service.NewNavEmbedder(service.NewModelProviderService(), "")
// Query-side encoding: the seed is the user's query (Python get_vector →
// encode_queries), not an indexed document.
// Query-side encoding: the seed is the user's query, not an indexed document.
vecs, err := embedder.EncodeQueries(ctx, tenantID, []string{text})
if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 {
return nil
@@ -676,10 +646,9 @@ func defaultSeedEncoder(ctx context.Context, tenantID, text string) []float64 {
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). indexName is the caller's
// kgSeedSearch searches the compiled KG entity rows for seeds: 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). indexName is the caller's
// configured index override (deps.IndexName); empty keeps ragflow_<tenantID>.
func kgSeedSearch(ctx context.Context, de engine.DocEngine, tenantID, kbID string, docIDs []string, text, scopeKwd string, seedVec []float64, indexName string) []map[string]interface{} {
if seedVec != nil {
@@ -694,7 +663,7 @@ func kgSeedSearch(ctx context.Context, de engine.DocEngine, tenantID, kbID strin
rows := kgSearchRaw(ctx, de, tenantID, kbID, docIDs, "entity", scopeKwd, nil, []interface{}{dense}, "mention_count_int", kgSeedPool, indexName)
return topMentionCount(rows, kgSeeds)
}
// Text fallback (mirrors Python _kg_search `embed_mdl is None` path).
// Text fallback (no embedding model available).
return kgSearch(ctx, de, tenantID, kbID, docIDs, "entity", text, kgSeeds, scopeKwd, nil, "mention_count_int", kgSeedPool, indexName)
}
@@ -731,7 +700,7 @@ func mentionCount(row map[string]interface{}) int {
// its evidence with indexNameFor(deps.TenantID, deps.IndexName)), so a tenant
// that overrides the name must have it honoured here too — otherwise the walk
// queries one index and loads passages from another. Empty falls back to
// ragflow_<tenantID>, which is what Python's search.index_name always returns.
// ragflow_<tenantID>.
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, indexName string) []map[string]interface{} {
idx := indexNameFor(tenantID, indexName)
condition := map[string]interface{}{"knowledge_graph_kwd": kind}
@@ -764,7 +733,7 @@ func kgSearchRaw(ctx context.Context, de engine.DocEngine, tenantID, kbID string
return res.Chunks
}
// kgSearch searches the compiled KG rows of one KB (mirrors Python _kg_search),
// kgSearch searches the compiled KG rows of one KB,
// 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, indexName string) []map[string]interface{} {
@@ -842,7 +811,7 @@ func kgParseRelation(row map[string]interface{}) (kgRelation, bool) {
}, true
}
// endpointTerms mirrors Python _endpoint_terms: original + lowercased forms, so
// endpointTerms: original + lowercased forms, so
// hop queries match both merged (lowercased) and per-doc (original-case)
// endpoint fields.
func endpointTerms(names []string) []string {
@@ -864,18 +833,16 @@ func endpointTerms(names []string) []string {
}
// kgEvidence is one document's relevant source chunk ids, in FIRST-SEEN order.
// Python's _collect_evidence_ids returns a dict and graph_explore iterates it with
// `evidence.items()` — insertion-ordered, hence deterministic. A Go map would
// randomise the order, and the executor caps the loaded passages at chunks[:6],
// so the SAME query could surface different passages on every run.
// Insertion-ordered, hence deterministic: a plain map would randomise the order, and the
// executor caps the loaded passages at chunks[:6], so the SAME query could surface
// different passages on every run.
type kgEvidence struct {
DocID string
IDs []string
}
// collectEvidenceIDs mirrors Python _collect_evidence_ids: group source chunk ids
// of relevant entities AND relations by doc, preserving the doc order in which
// each doc is first reached (Python's dict insertion order).
// collectEvidenceIDs groups the source chunk ids of relevant entities AND relations by
// doc, preserving the doc order in which each doc is first reached.
func collectEvidenceIDs(entities []kgEntity, relations []kgRelation, relevantNames []string) []kgEvidence {
wanted := map[string]bool{}
for _, n := range relevantNames {
@@ -931,12 +898,10 @@ func intersects(a, b map[string]bool) bool {
return false
}
// askStructureAnswer asks the chat model whether the subgraph answers the query
// (mirrors Python exploration.py::graph_explore calling _ask_structure),
// returning (answer, relevant_names). Delegates to AskStructure
// (structure_qa.go), the shared mirror of Python _ask_structure. `model` is the
// request-scoped chat model (Python's tools.chat_mdl); a nil model simply skips
// the verdict and yields no answer, matching Python's empty-verdict fallback.
// askStructureAnswer asks the chat model whether the subgraph answers the query,
// returning (answer, relevant_names). Delegates to AskStructure (structure_qa.go).
// `model` is the request-scoped chat model; a nil model simply skips the verdict and
// yields no answer.
func askStructureAnswer(ctx context.Context, model SessionModel, query string, entities []kgEntity, relations []kgRelation) (string, []string) {
ems := make([]map[string]any, 0, len(entities))
for _, e := range entities {
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -40,7 +40,7 @@ func (e *kgIndexCapturingEngine) Search(_ context.Context, req *types.SearchRequ
// with indexNameFor(deps.TenantID, deps.IndexName), so a tenant that overrides
// the index name must have it honoured by the KG rows too. Hardcoding
// ragflow_<tenantID> made the walk query one index and return passages loaded
// from another. Empty keeps the default, matching Python's search.index_name.
// from another. Empty keeps the default index name.
func TestKGSearchUsesConfiguredIndexName(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -30,23 +30,18 @@ import (
// Compiled-structure navigation: ontology_navigate / mindmap_navigate /
// navigate_tree / wiki_query.
//
// Mirrors Python harness/tools/navigation.py (the structure side of the module):
// structure navigation, the nav-tree router, the compiled-structure reader and
// dataset-tree LLM selection. The knowledge-graph walk (graph_explore) moved to
// exploration.go to match Python's exploration.py, which hosts both graph_explore
// and wiki_query; the shared seams (verdict struct, prompt, load helpers) stay
// here and are reached by those files through the harness package. In Go these
// were spread across several files (navigation / navtools / navservice /
// datasetnav / kg_explore); they are consolidated here.
// Structure navigation, the nav-tree router, the compiled-structure reader and
// dataset-tree LLM selection. The knowledge-graph walk (graph_explore) lives in
// exploration.go; the shared seams (verdict struct, prompt, load helpers) stay here and
// are reached by those files through the runtime package. These were previously spread
// across several files (navigation / navtools / navservice / datasetnav / kg_explore);
// they are consolidated here.
// ---------------------------------------------------------------------------
// Structure navigation (ontology_navigate / mindmap_navigate)
// ---------------------------------------------------------------------------
// Python _CATALOG_KINDS (navigation.py:49): {"tree", "timeline", "page_index",
// "pageindex"} — raptor is NOT a catalog kind (its stale docstring comment
// notwithstanding, the set itself excludes it; upper RAPTOR clusters carry no
// per-node vectors and drill into them is the claim leg's job).
// catalogKinds: {"tree", "timeline", "page_index", "pageindex"}. raptor is NOT a catalog
// kind — upper RAPTOR clusters carry no per-node vectors and drilling into them is the
// claim leg's job.
var catalogKinds = map[string]bool{"tree": true, "timeline": true, "page_index": true, "pageindex": true}
var mindmapKinds = map[string]bool{"mindmap": true, "mind_map": true}
@@ -83,19 +78,16 @@ type structureNavVerdict struct {
RelevantEntities []string `json:"relevant_entities"`
}
// shapeKwds are the knowledge_graph_kwd ROW SHAPES a compiled structure can be
// written as. Reading BOTH the compact "graph" blob and the per-entity /
// per-relation rows is what mirrors Python _load_compiled_structure (which
// issues one query for the graph blob and a second for the per-entity rows and
// merges them). Otherwise navigation silently returns EMPTY for datasets that
// were compiled into per-entity/relation rows rather than a graph blob.
// shapeKwds are the knowledge_graph_kwd ROW SHAPES a compiled structure can be written as.
// Reading BOTH the compact "graph" blob and the per-entity / per-relation rows is
// required: otherwise navigation silently returns EMPTY for datasets compiled into
// per-entity/relation rows rather than a graph blob.
var shapeKwds = []string{"graph", "entity", "relation"}
// loadStructureGraph reads a document's compiled structure rows and splits them
// into both the entity list and the parent->child relations. Python merges the
// graph-blob and the per-entity/per-relation row shapes and feeds both entities
// and relations into _render_toc_drilldown / _build_toc_tree; mirroring that,
// navigation must carry relations through or the tree hierarchy (children,
// loadStructureGraph reads a document's compiled structure rows and splits them into both
// the entity list and the parent->child relations: the graph-blob and the
// per-entity/per-relation row shapes are merged, and both entities and relations feed the
// TOC builders. Navigation must carry relations through or the tree hierarchy (children,
// parents, roots, ancestors) collapses.
func loadStructureGraph(ctx context.Context, indexName, docID string, kinds map[string]bool, vecField string) ([]structureEntity, []structureRel) {
de := engine.Get()
@@ -147,13 +139,10 @@ func loadStructureGraph(ctx context.Context, indexName, docID string, kinds map[
// A field the compiled payload omits must read as ABSENT, not as its Go
// rendering: fmt.Sprint(nil) is the non-empty string "<nil>", which survives
// navTypeOr's empty check and reaches the model as "- Name (<nil>): <nil>" with
// a phantom edge to "<nil>". Python renders "- Name (other)" and no description,
// because `(e.get("type") or "other")` / `(e.get("description") or "")` treat a
// missing key as falsy (navigation.py:1714-1715), and it DROPS a relation with a
// missing endpoint instead of keeping it (navigation.py:1783-1786). Leaving the
// field empty is also what lets the renderers apply Python's defaults —
// "other" for a type (navigation.py:1714), "related_to" for a relation type
// (navigation.py:1788).
// a phantom edge to "<nil>". The intended rendering is "- Name (other)" and no
// description, because a missing key is falsy — and a relation with a missing endpoint is
// DROPPED instead of kept. Leaving the field empty is also what lets the renderers apply
// their defaults: "other" for a type, "related_to" for a relation type.
func structureGraphFromRaw(rawEntities, rawRels []map[string]any) ([]structureEntity, []structureRel) {
var out []structureEntity
for _, e := range rawEntities {
@@ -186,7 +175,7 @@ func structureGraphFromRaw(rawEntities, rawRels []map[string]any) ([]structureEn
cRaw, _ := r["to"].(string)
p := strings.TrimSpace(pRaw)
c := strings.TrimSpace(cRaw)
// Python _build_toc_tree skips empty or self-loop relations.
// Empty or self-loop relations are skipped.
if p == "" || c == "" || p == c {
continue
}
@@ -284,10 +273,9 @@ func orStr(v, def string) string {
return v
}
// indexNameFor resolves the document/structure search index. Python reads it
// from the dataset's search.index_name (navigation.py:_navigate_structure_impl); the RAGFlow default
// is "ragflow_<tenant_id>", so an empty configured name falls back to that —
// keeping Go behaviour identical unless a tenant overrides index_name.
// indexNameFor resolves the document/structure search index: the dataset's configured
// index name, or the RAGFlow default "ragflow_<tenant_id>" — behaviour is identical
// unless a tenant overrides index_name.
func indexNameFor(tenantID, configured string) string {
if configured != "" {
return configured
@@ -295,23 +283,17 @@ func indexNameFor(tenantID, configured string) string {
return "ragflow_" + tenantID
}
// ---------------------------------------------------------------------------
// The routing seam + NavResult payload
// ---------------------------------------------------------------------------
// Compiled-navigation tools: locate (dataset tree routing) and drill
// (in-document structure pinpointing).
//
// Mirrors Python harness/tools/navigation.py:
// - _navigate_tree_impl (line 830)
// - _nav_search_titled (line 659)
// - NavResult (line 62)
// - _load_compiled_structure (line 111)
// - _normalize_kind (line 101)
// Covers the tree walk, the titled search, the NavResult payload, the compiled-structure
// reader and the kind normalization.
//
// These are the payload builders; the tool dispatch lives in tool_executor.go.
// Navigation constants (Python navigation.py:_NAV_SEARCH_MAX_DOCS, 656, 773).
// Navigation constants.
const (
// navSearchMaxDocs is how many documents the hybrid nav search routes to.
navSearchMaxDocs = 12
@@ -321,7 +303,7 @@ const (
navTreeMaxDocs = 8
)
// NavResult mirrors Python NavResult: the structured outcome of ONE
// NavResult: the structured outcome of ONE
// compiled-navigation call.
//
// Text is what the MODEL sees (XML, unchanged). The remaining fields are the
@@ -361,13 +343,13 @@ type NavResult struct {
// A query-level miss is not a structure absence.
func (n NavResult) HasStructure() bool { return n.EmptyReason != ReasonNoStructure }
// navEmpty returns an empty NavResult carrying Python's <tree_navigation> text:
// navEmpty returns an empty NavResult carrying the <tree_navigation> text:
// count="0" plus an error="<label>" attribute, omitted when label is empty —
// exactly the shapes navigation.py builds (:877 "no retriever", :880 "query is
// required", :899 no attribute at all for a query-level miss). That text is not
// what the model reads for an empty result — both sides substitute their own
// note for every empty_reason (action_session.py:869-876, tool_executor.go:402)
// — but the NavResult must still carry it. The status is derived from the reason
// note for every empty_reason (, tool_executor.go:402)
// but the NavResult must still carry it. The status is derived from the reason
// by ReasonStatus.
func navEmpty(reason, label string) NavResult {
attr := ""
@@ -380,20 +362,17 @@ func navEmpty(reason, label string) NavResult {
}
}
// ---------------------------------------------------------------------------
// The routing seam
// ---------------------------------------------------------------------------
// NavTreeRouter descends a dataset's compiled navigation tree and returns the
// routed documents.
//
// Mirrors Python's `search_dataset_layers(kb.id, tenant_id, query,
// "navigation_tree", top_k, doc_scope)` — a hybrid BFS beam descent (vector +
// BM25) from the root clusters down to the nav_doc leaves.
// A hybrid BFS beam descent (vector + BM25) from the root clusters down to the nav_doc
// leaves.
//
// The Go side exposes this through internal/service/nav's NavService.Search,
// which is the same KNN-over-nav-rows capability. It is an interface here (not a
// direct call) for two reasons: the harness must not import the service layer,
// direct call) for two reasons: the runtime must not import the service layer,
// and a dataset without a compiled tree must be distinguishable from a query
// that routed to nothing.
type NavTreeRouter interface {
@@ -413,7 +392,7 @@ type NavTreeInput struct {
KbIDs []string
}
// NavigateTree mirrors Python _navigate_tree_impl: locate the document(s) most
// NavigateTree: locate the document(s) most
// likely to hold the answer by descending the compiled navigation tree.
//
// This tool ROUTES, it does not retrieve. It deliberately does NOT fetch
@@ -433,8 +412,11 @@ func NavigateTree(ctx context.Context, router NavTreeRouter, in NavTreeInput) Na
return navEmpty(ReasonBadArgs, "query is required")
}
if router == nil {
// Python's counterpart is a missing retriever, and the error label is its
// string verbatim (navigation.py:877): the router IS the retrieval backend.
// A missing retriever is the infra case: the router IS the retrieval backend, and
// the error label is verbatim.
//
// The label is also carried as the Diagnostic: "infra" alone tells a client
// nothing about what actually broke.
res := navEmpty(ReasonInfra, "no retriever")
res.Diagnostic = "no retriever"
return res
@@ -480,19 +462,19 @@ func NavigateTree(ctx context.Context, router NavTreeRouter, in NavTreeInput) Na
// no read succeeded, so "this dataset has no tree" would be a verdict
// drawn from zero evidence — and Route() documents that contract
// itself ("(nil, nil) when the dataset has no compiled tree at all",
// so a non-nil error is a failure, not an absence). Python never
// reaches no_structure from a failed descent either: its nav-tree
// route yields only infra / bad_args / no_doc (navigation.py:877-899),
// while no_structure belongs to the STRUCTURE path, where a successful
// read found zero entities (:1046).
// so a non-nil error is a failure, not an absence). A failed descent never
// produces no_structure either: the nav-tree route yields only infra / bad_args /
// no_doc, while no_structure belongs to the STRUCTURE path, where a successful
// read found zero entities.
//
// The error travels as the Diagnostic: "infra" alone does not say what broke.
res := navEmpty(ReasonInfra, "nav tree descent failed")
res.Diagnostic = anyError.Error()
return res
}
// No compiled tree is a DATASET-level fact: Python's nav-tree route
// deliberately has NO fallback here (navigation.py:902-912 — the generic
// retriever re-inventing dataset_nav's work was the most expensive thing
// that path could do). When routing misses, the CALLER falls back to
// No compiled tree is a DATASET-level fact: there is deliberately NO fallback here
// (re-inventing dataset_nav's work through the generic retriever was the most
// expensive thing that path could do). When routing misses, the CALLER falls back to
// retrieve/search_chunks — the same work, done once and owned by the
// orchestrator instead of hidden inside a "route" call. The tool's empty
// verdict lets the session disable the tool.
@@ -503,11 +485,10 @@ func NavigateTree(ctx context.Context, router NavTreeRouter, in NavTreeInput) Na
if len(ordered) == 0 {
// Structure exists but THIS query reached nothing: a query-level miss.
// The dataset may still have a tree a better-formed query would hit.
// Python deliberately does NOT back this miss with a retrieval fallback
// (navigation.py:902-912): it returns count="0" / no_doc and leaves the
// fallback to the caller's retrieve/search_chunks.
// No error attribute on the empty verdict: Python's query-level miss
// carries none (navigation.py:912), unlike infra/bad_args.
// This miss is deliberately NOT backed by a retrieval fallback: it returns
// count="0" / no_doc and leaves the fallback to the caller's retrieve/search_chunks.
// No error attribute on the empty verdict: a query-level miss carries none, unlike
// infra/bad_args.
return navEmpty(ReasonNoDoc, "")
}
@@ -541,12 +522,10 @@ func NavigateTree(ctx context.Context, router NavTreeRouter, in NavTreeInput) Na
}
}
// ---------------------------------------------------------------------------
// Compiled-structure reading (in-document)
// ---------------------------------------------------------------------------
// StructureRow is one compiled-structure row, mirroring the doc-store fields
// Python reads (navigation.py:_load_compiled_structure).
// StructureRow is one compiled-structure row: the doc-store fields the structure reader
// reads.
type StructureRow struct {
// CompileKwd distinguishes the COMPILE TYPE (tree / page_index / timeline /
// ...). NOT knowledge_graph_kwd.
@@ -570,13 +549,12 @@ type StructureRow struct {
// StructureReader reads a document's compiled structure rows.
//
// Mirrors Python _load_compiled_structure, which issues one doc-store query
// over the per-entity/relation rows and merges the matching buckets (the
// graph blob and the raptor_graph projection are gone from the storage
// It issues one doc-store query over the per-entity/relation rows and merges the matching
// buckets (the graph blob and the raptor_graph projection are gone from the storage
// model).
//
// Go has no doc-store structured-query interface (see runtime.RetrievalService,
// which only exposes Search), so this is a seam: the harness ships the full
// which only exposes Search), so this is a seam: the runtime ships the full
// parsing/merging logic below, and the reader is injected. Until a Go-side
// implementation is wired, navigate_structure reports EMPTY/no_structure and the
// navigation ladder falls through to `global` — the same behaviour as a dataset
@@ -587,9 +565,8 @@ type StructureReader interface {
ReadStructure(ctx context.Context, tenantID, kbID, docID string) ([]StructureRow, error)
}
// normalizeKind is defined above; rowKind projects a StructureRow into that
// shape. It mirrors Python _normalize_kind: the API's kind normalization
// (page_index / knowledge_graph → timeline).
// normalizeKind is defined above; rowKind projects a StructureRow into that shape, applying
// the API's kind normalization (page_index / knowledge_graph → timeline).
func rowKind(row StructureRow) string {
return normalizeKind(map[string]any{
"compile_kwd": row.CompileKwd,
@@ -597,7 +574,7 @@ func rowKind(row StructureRow) string {
})
}
// ParseCompiledStructure mirrors Python _load_compiled_structure's merge step
// ParseCompiledStructure: merge step
// (navigation.py:_query): keep rows whose compile TYPE is in kinds, then split
// by row shape into entities / relations.
//
@@ -671,19 +648,14 @@ func objectList(v any) []map[string]any {
return out
}
// ---------------------------------------------------------------------------
// In-document structure drill-down (mirrors navigation.py, distributed helpers)
// ---------------------------------------------------------------------------
//
// These are the Go counterparts of the functions navigation.py DEFINES in its
// own body. Like Python, they reuse primitives imported from elsewhere: cosine /
// appendUnique (compiled_expansion.go, action_session.go), XMLEscape / Snippet /
// ChunkTextOf (chunk_utils.go). They stay here so navigation.go owns the
// navigate-tree / navigate-structure algorithm, mirroring how navigation.py owns
// its orchestration while importing its primitives.
// These functions reuse primitives imported from elsewhere: cosine / appendUnique
// (compiled_expansion.go, action_session.go), XMLEscape / Snippet / ChunkTextOf
// (chunk_utils.go). They stay here so navigation.go owns the navigate-tree /
// navigate-structure algorithm while importing its primitives.
// Structure drill-down bounds (mirror navigation.py's module-level limits,
// navigation.py:1084-1097).
// Structure drill-down bounds.
const (
structMaxDepth = 3 // _STRUCT_MAX_DEPTH: max TOC levels drilled
structMaxNodes = 10 // _STRUCT_MAX_NODES: cap on nodes rendered in the outline
@@ -694,11 +666,9 @@ const (
structMaxChunks = 4 // _STRUCT_MAX_CHUNKS: cap on chunk snippets returned in the outline
structCatalogKinds = "tree_node|page_index|section" // kinds eligible for catalog outline
// structTocMaxDepth bounds the ancestor walk in renderTocDrilldown's
// chunk-retrieval and llm_toc branches (Python _STRUCT_TOC_MAX_DEPTH). The
// remaining _STRUCT_TOC_* limits gate the whole-TOC LLM pass, which Python
// leaves off by default; they are not mirrored (they would be unreachable in
// Go).
// structTocMaxDepth bounds the ancestor walk in renderTocDrilldown's chunk-retrieval and
// llm_toc branches. The remaining _STRUCT_TOC_* limits gate the whole-TOC LLM pass,
// which is off by default and not mirrored here (it would be unreachable).
structTocMaxDepth = 6
// Chunk-index selection bounds for structures that cannot score themselves
@@ -706,7 +676,7 @@ const (
structRecallTopN = 24 // _STRUCT_RECALL_TOP_N: hybrid chunk hits recalled per document
structMaxChunkHits = 8 // _STRUCT_MAX_CHUNK_HITS: retrieved chunks shown per drilled doc
// Claim/evidence leg for the drill-down (Python navigation.py:1129-1146).
// Claim/evidence leg for the drill-down.
// Claim rows are scored FLAT against the query (hybrid BM25+KNN, RRF), so a
// bad early pick cannot prune the subtree holding the answer. Claim-first:
// when claims hit they REPLACE the drill-down — a claim is an atomic
@@ -716,20 +686,18 @@ const (
structClaimTopN = 8 // _STRUCT_CLAIM_TOP_N: claims rendered per document
structClaimFirst = true // _STRUCT_CLAIM_FIRST
structClaimFirstMinHits = 1 // _STRUCT_CLAIM_FIRST_MIN_HITS
// structClaimSufficientHits is _STRUCT_CLAIM_SUFFICIENT_HITS (Python
// navigation.py:1153): enough distinct claims matching the query means the
// model may cite them without deep-reading chunks. The drillout emits
// <claims_sufficient/> when a document's claim hits reach it.
// structClaimSufficientHits: enough distinct claims matching the query means the model
// may cite them without deep-reading chunks. The drillout emits <claims_sufficient/>
// when a document's claim hits reach it.
structClaimSufficientHits = 3
// Per-node-vector flat selection (Python navigation.py:1307-1315 +
// :1912-1936): page_index spreads facts across TOC levels, so descending
// level by level can prune the branch holding the answer before it is ever
// scored. Scoring EVERY node against the query at once costs nothing extra
// (their embeddings are already loaded) and cannot prune — this is the
// DEFAULT primary path for page_index structures.
structTocLLMSelect = false // _STRUCT_TOC_LLM_SELECT (navigation.py:1111): the one-shot LLM TOC selection is OFF in Python — not ported
structFlatNodeSelect = true // _STRUCT_FLAT_NODE_SELECT (navigation.py:1157)
// Per-node-vector flat selection: page_index spreads facts across TOC levels, so
// descending level by level can prune the branch holding the answer before it is ever
// scored. Scoring EVERY node against the query at once costs nothing extra (their
// embeddings are already loaded) and cannot prune — this is the DEFAULT primary path
// for page_index structures.
structTocLLMSelect = false // the one-shot LLM TOC selection is OFF — not ported
structFlatNodeSelect = true // flat per-node selection is the primary path
structFlatTopN = 10 // _STRUCT_FLAT_TOP_N (navigation.py:1158)
)
@@ -1152,8 +1120,7 @@ func outlineStats(nodes []structureNode) (nodeCount, ptrCount int) {
return count, ptrs
}
// navTypeOr returns a node type, defaulting to "other" as Python's
// “(e.get("type") or "other")“ does.
// navTypeOr returns a node type, defaulting to "other".
func navTypeOr(t string) string {
if t == "" {
return "other"
@@ -1200,7 +1167,7 @@ func renderOutline(nodes []structureNode, rels []structureRel) string {
// rankChunksByTerms mirrors navigation.py _rank_chunks_by_terms: rank candidate
// chunks by how many query terms overlap with their text. Zero-LLM keyword
// relevance for the precise chunk_ids path. The accessor abstracts reading a
// chunk's text so the harness stays store-agnostic and testable.
// chunk's text so the runtime stays store-agnostic and testable.
func rankChunksByTerms(candidates []chunkWithText, queries []string) []chunkWithText {
terms := make([]string, 0, 8)
for _, q := range queries {
@@ -1244,10 +1211,8 @@ type chunkWithText struct {
text string
}
// structureDrillout is the per-document result of the TOC drill-down, mirroring
// what Python's _render_toc_drilldown returns and the per-doc stats
// _navigate_structure_impl aggregates. selector records which strategy chose the
// nodes: llm_toc / chunk_retrieval / beam (stats["selector"] in Python).
// structureDrillout is the per-document result of the TOC drill-down. selector records
// which strategy chose the nodes: llm_toc / chunk_retrieval / beam.
type structureDrillout struct {
outline string
nodes int
@@ -1255,13 +1220,13 @@ type structureDrillout struct {
topScore float64
chunkPaths map[string]string
selector string
// claimHits is the count of claim rows that matched the query for this
// drill (Python stats["claim_hits"], set by the caller when non-empty).
// claimHits is the count of claim rows that matched the query for this drill, set by
// the caller when non-empty.
claimHits int
}
// chunkHit is a retrieved chunk id with its retrieval score, the input shape of
// the chunk_retrieval strategy (Python chunk_hits: list[(chunk_id, score)]).
// chunkHit is a retrieved chunk id with its retrieval score — the input shape of the
// chunk_retrieval strategy.
type chunkHit struct {
id string
score float64
@@ -1273,12 +1238,11 @@ type chunkHit struct {
// order-sensitive outputs are materialised from it: the rendered outline
// (kept[:structMaxNodes]), chunkPaths (first writer wins for a chunk covered by
// several nodes) and collectChunkIDs(kept, 32) — the chunk list whose snippets
// reach the model. Python iterates the kept_names SET (navigation.py:1660, 1673 and
// _drill_kept_nodes:1594), so its order is arbitrary and, because str hashing is
// randomised per process, not even reproducible across runs: there is no
// canonical order to mirror. Go randomises map iteration per range statement, so
// leaving it to the map would vary those three outputs even between two calls in
// one process; sorting gives a stable, machine-independent order instead.
// reach the model. Iterating the kept set gives an arbitrary order that is not even
// reproducible across runs (string hashing is randomised per process), so there is no
// canonical order to inherit. Go randomises map iteration per range statement, so leaving
// it to the map would vary those three outputs even between two calls in one process;
// sorting gives a stable, machine-independent order instead.
func sortedKeptNames(names map[string]bool) []string {
out := make([]string, 0, len(names))
for n := range names {
@@ -1294,7 +1258,7 @@ func drillWithAncestors(names map[string]bool, parents map[string]string) map[st
// Ranging a map while adding keys visits the new ones in an unspecified order,
// but the result is order-independent: every start walks up to the root (or the
// depth guard), so the SET is the same whatever order the names are visited in
// — the same closure Python builds from its `list(names)` snapshot (:1648).
// the same closure built from a `list(names)` snapshot.
for name := range names {
cur := parents[name]
guard := 0
@@ -1315,7 +1279,8 @@ func drillWithAncestors(names map[string]bool, parents map[string]string) map[st
// matched the query directly. They are rendered as statement + verbatim
// evidence and take over the selection outright — nothing is drilled, no
// chunk snippets are loaded, and the tree only labels where each claim sits
// — selector "claim".
//
// selector "claim".
// - selected (non-empty): node names picked by the whole-TOC LLM pass, kept
// whole with their ancestors — selector "llm_toc".
// - chunkHits (non-empty): [(id, score)] from chunk retrieval (RAPTOR blob path);
@@ -1354,7 +1319,7 @@ func renderTocDrilldown(query string, qvec []float64, nodes []structureNode, rel
// Claims already carry the answer material (statement + verbatim
// evidence), so nothing is drilled: the tree's only remaining job is to
// show where each claim sits. Descending would re-derive the same facts
// and risk pruning past them (Python navigation.py:2183-2193).
// and risk pruning past them.
var claimCIDs []string
for _, h := range claimHits {
if cid := strings.TrimSpace(h.ChunkID); cid != "" && !sliceContains(claimCIDs, cid) {
@@ -1428,7 +1393,7 @@ func renderTocDrilldown(query string, qvec []float64, nodes []structureNode, rel
}
if len(kept) == 0 && len(selectedIDs) == 0 && !(structClaimFirst && len(claimHits) > 0) {
// A strategy was chosen but kept nothing; still report which one ran.
// Claim-first is the exception (Python navigation.py:2215-2219): upper
// Claim-first is the exception: upper
// RAPTOR clusters carry no source_chunk_ids, so no node may cover a hit
// claim — bailing out here would throw the claims away.
out := flat()
@@ -1489,9 +1454,9 @@ func renderTocDrilldown(query string, qvec []float64, nodes []structureNode, rel
indent := strings.Repeat(" ", depthOf[name])
lines = append(lines, navOutlineLine(indent, name, e.nodeType, e.desc, e.sourceChunkIDs))
}
// Claim/evidence lines come before the chunk snippets (Python
// navigation.py:2264-2283): a gated claim is a verified atomic fact carrying
// its verbatim quote, so it reads better than a truncated chunk and needs no
// Claim/evidence lines come before the chunk snippets: a gated claim is a verified
// atomic fact carrying its verbatim quote, so it reads better than a truncated chunk
// and needs no
// chunk load at all. Its chunk is dropped from the snippet list below so the
// same content is not sent twice.
claimed := map[string]bool{}
@@ -1511,7 +1476,7 @@ func renderTocDrilldown(query string, qvec []float64, nodes []structureNode, rel
// A claim's chunk still deserves a structural label — annotate it from the
// node that covers it, falling back to nothing when no node does (upper
// RAPTOR clusters carry no source_chunk_ids, so this stays honest rather
// than inventing a path) (Python navigation.py:2285-2294).
// than inventing a path).
for cid := range claimed {
if _, ok := chunkPaths[cid]; ok {
continue
@@ -1525,7 +1490,7 @@ func renderTocDrilldown(query string, qvec []float64, nodes []structureNode, rel
// covers); otherwise take the chunks behind the drilled nodes. Under
// claim-first a matched claim already renders its verbatim evidence, so
// nothing is loaded — loading the chunk behind it would echo the same
// passage a second time (Python navigation.py:2300-2303).
// passage a second time.
var wanted []string
if claimFirst {
wanted = nil
@@ -1585,9 +1550,7 @@ func renderTocDrilldown(query string, qvec []float64, nodes []structureNode, rel
}
}
// ---------------------------------------------------------------------------
// navigate_structure (zero-LLM vector-beam drill-down; mirrors _navigate_structure_impl)
// ---------------------------------------------------------------------------
// structureKindsFor maps a navigate_structure kind string to the compiled-kinds
// set to read, mirroring navigation.py _structure_kinds_for. Defaults to catalog.
@@ -1668,8 +1631,7 @@ func readStructureDocCore(ctx context.Context, tenantID, query, docID, kind stri
case docID == "":
return none, nil, nil, ReasonNoDoc
}
// Python _load_compiled_structure → _resolve_doc_tenant: a document that is
// not in the bound datasets yields an empty structure rather than an
// A document that is not in the bound datasets yields an empty structure rather than an
// unscoped read, so a stale doc_id cannot pull in another dataset's outline.
if belongs, verified := docInDatasets(ctx, deps, docID); verified && !belongs {
return none, nil, nil, ReasonNoStructure
@@ -1692,7 +1654,7 @@ func readStructureDocCore(ctx context.Context, tenantID, query, docID, kind stri
}
loader := func(ids []string) []chunkWithText { return structureNodeLoader(ctx, indexName, ids) }
// Claim leg (Python _read_structures:1294-1306): flat hybrid claim recall
// Claim leg: flat hybrid claim recall
// for this document. Selection priority: claims when they hit, then
// whatever the tree's own shape supports.
var claimHits []DocClaimHit
@@ -1705,17 +1667,17 @@ func readStructureDocCore(ctx context.Context, tenantID, query, docID, kind stri
claimHits = RecallDocClaimHits(ctx, deps, query, docID, kindList, qvec, structClaimTopN)
}
// Claim-first: the claims decide, so nothing is drilled and no chunks are
// recalled to choose (Python _read_structures:1303-1306).
// recalled to choose.
claimFirst := structClaimFirst && len(claimHits) >= structClaimFirstMinHits
// RAPTOR / shared-vector blobs cannot score their nodes against the query, so
// they defer to chunk retrieval for the drill (mirrors Python routing them to
// _recall_chunk_ids_in_doc). Per-node-vector structures take the FLAT node
// selection instead of the beam drill (Python navigation.py:1307-1315).
// they defer to chunk retrieval for the drill. Per-node-vector structures take the FLAT
// node
// selection instead of the beam drill.
var selected []string
var chunkHits []chunkHit
if !claimFirst && hasDistinctNodeVectors(nodes) {
// Python :1312-1315 — _STRUCT_TOC_LLM_SELECT is False (navigation.py:1111),
// _STRUCT_TOC_LLM_SELECT is False,
// so the one-shot LLM TOC selection never runs and the flat vector scoring
// is the default primary path. It renders under selector="llm_toc"
// (renderTocDrilldown's selected branch); an empty result (no query vector)
@@ -1728,19 +1690,17 @@ func readStructureDocCore(ctx context.Context, tenantID, query, docID, kind stri
}
drill := renderTocDrilldown(query, qvec, nodes, rels, loader, chunkHits, selected, claimHits)
if len(claimHits) > 0 {
// Mirror the rendered claims into the shared evidence pool (Python
// _publish_claim_hits): the SCA, the slot prefill and the final compose
// all read ONLY the pool, so a claim that already states the fact must
// land there.
// Mirror the rendered claims into the shared evidence pool: the SCA, the slot prefill
// and the final compose all read ONLY the pool, so a claim that already states the
// fact must land there.
drill.claimHits = len(claimHits)
publishClaimHits(deps, claimHits, docID)
}
return drill, nodes, rels, ""
}
// structureDocSegment renders one <doc> element (doc_id / doc_title="" /
// entities / relations plus the <structure> outline), mirroring Python
// navigation._navigate_structure_impl where doc_title is always empty.
// structureDocSegment renders one <doc> element (doc_id / doc_title="" / entities /
// relations plus the <structure> outline); doc_title is always empty.
func structureDocSegment(docID, query, kind string, rank int, nodes []structureNode, rels []structureRel, drill structureDrillout) string {
esc := func(s string) string { return XMLEscape(s) }
var b strings.Builder
@@ -1748,8 +1708,8 @@ func structureDocSegment(docID, query, kind string, rank int, nodes []structureN
if drill.outline != "" {
b.WriteString("\n <structure>" + esc(drill.outline) + "</structure>")
}
// Python navigation.py:1052-1053 — enough matched claims mark the document's
// evidence as citable without a list_chunks deep-read.
// Enough matched claims mark the document's evidence as citable without a list_chunks
// deep-read.
if drill.claimHits >= structClaimSufficientHits {
b.WriteString("\n <claims_sufficient/>")
}
@@ -1757,8 +1717,8 @@ func structureDocSegment(docID, query, kind string, rank int, nodes []structureN
return b.String()
}
// emptyReasonLabel maps an empty-reason constant to the XML error label Python
// writes into <structure_navigation error="...">.
// emptyReasonLabel maps an empty-reason constant to the XML error label written into
// <structure_navigation error="...">.
func emptyReasonLabel(reason string) string {
switch reason {
case ReasonBadArgs:
@@ -1770,31 +1730,27 @@ func emptyReasonLabel(reason string) string {
}
}
// navigateStructures renders the compiled structures of MULTIPLE documents into a
// single <structure_navigation> with one <doc> per readable structure, mirroring
// Python _read_structures + _navigate_structure_impl: each document is read and
// drilled independently, documents without a compiled structure of the requested
// kind are skipped, and the resulting outline(s) are merged with per-doc ranks.
// navigateStructures renders the compiled structures of MULTIPLE documents into a single
// <structure_navigation> with one <doc> per readable structure: each document is read and
// drilled independently, documents without a compiled structure of the requested kind are
// skipped, and the resulting outline(s) are merged with per-doc ranks.
// The aggregate drillout carries summed node/pointer counts, the max top score,
// and the union of chunk paths across the documents.
// navigateStructures drills the requested compiled-structure kind across the
// given documents. When docIDs is empty it mirrors Python _navigate_structure_impl
// (:1012): with no doc_id it vector-routes the documents FIRST (search_dataset_layers /
// the nav tree) and then drills those — so a direct call need not rely on its
// caller pre-routing. The Go navigate_structure TOOL already routes before calling
// this, so in the tool path docIDs is non-empty and routing here is a no-op.
// given documents. When docIDs is empty it vector-routes the documents FIRST (the nav
// tree) and then drills those — so a direct call need not rely on its caller pre-routing.
// The navigate_structure TOOL already routes before calling this, so in the tool path
// docIDs is non-empty and routing here is a no-op.
func navigateStructures(ctx context.Context, tenantID, query string, docIDs []string, kind string, router NavTreeRouter, deps SearchDeps) (NavResult, structureDrillout) {
agg := structureDrillout{chunkPaths: map[string]string{}}
var segs []string
var docIDsSeen []string
var entities []map[string]any
if len(docIDs) == 0 {
// Python _navigate_structure_impl with an empty doc scope vector-routes
// the documents first; only if that also reaches nothing is it a MISS.
// With an empty doc scope the documents are vector-routed first; only if that also
// reaches nothing is it a MISS.
if router != nil {
// DocScope: the session ceiling, mirroring _nav_search_titled
// (navigation.py:_navigate_structure_impl), which _navigate_structure_impl routes
// through when doc_ids is empty (:1012).
// DocScope: the session ceiling applied when doc_ids is empty.
routed := NavigateTree(ctx, router, NavTreeInput{
Query: query,
KbIDs: deps.KbIDs,
@@ -1813,7 +1769,7 @@ func navigateStructures(ctx context.Context, tenantID, query string, docIDs []st
for _, did := range docIDs {
drill, nodes, rels, empty := readStructureDocCore(ctx, tenantID, query, did, kind, deps)
if empty != "" {
// No compiled structure of this kind — Python's per-doc read skips it.
// No compiled structure of this kind — the per-doc read skips it.
continue
}
segs = append(segs, structureDocSegment(did, query, kind, len(segs)+1, nodes, rels, drill))
@@ -1832,10 +1788,9 @@ func navigateStructures(ctx context.Context, tenantID, query string, docIDs []st
}
}
if len(segs) == 0 {
// doc_ids were given but none carried a compiled structure of this kind —
// Python _navigate_structure_impl sets empty_reason="no_structure" when
// total_entities == 0 (its count="0" <structure_navigation> carries no
// <doc> elements).
// doc_ids were given but none carried a compiled structure of this kind:
// empty_reason is "no_structure" when total_entities == 0 (the count="0"
// <structure_navigation> carries no <doc> elements).
return NavResult{
Text: `<structure_navigation count="0" error="no structure">` + "\n</structure_navigation>",
EmptyReason: ReasonNoStructure,
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -24,9 +24,7 @@ import (
"testing"
)
// ---------------------------------------------------------------------------
// Compiled-structure rendering / normalization
// ---------------------------------------------------------------------------
func TestNormalizeKind(t *testing.T) {
cases := []struct {
@@ -54,9 +52,7 @@ func containsAll(s string, subs ...string) bool {
return true
}
// ---------------------------------------------------------------------------
// navigate_tree routing
// ---------------------------------------------------------------------------
type stubRouter struct {
table [][2]string
@@ -196,9 +192,9 @@ func TestNavigateTreePartialFailureIsInfra(t *testing.T) {
if got.EmptyReason != ReasonInfra {
t.Errorf("partial failure: reason = %q, want infra", got.EmptyReason)
}
// A failed descent has no Python counterpart to copy — Python lets the
// exception escape and navigation.py has no empty_reason for it — so this
// label is the port's own wording; only the XML shape must be Python's.
// A failed descent has no upstream counterpart to copy — the exception escapes upstream
// and there is no empty_reason for it — so this label is this file's own wording; only
// the XML shape is fixed.
if want := "<tree_navigation count=\"0\" error=\"nav tree descent failed\">\n</tree_navigation>"; got.Text != want {
t.Errorf("partial failure: text = %q, want %q", got.Text, want)
}
@@ -214,13 +210,11 @@ func TestNavigateTreePartialFailureIsInfra(t *testing.T) {
}
}
// TestNavigateTreeEmptyXMLMatchesPython pins the text each empty NavResult carries.
// Python builds it in place: `count="0"` plus an `error="..."` attribute that is
// OMITTED for a query-level miss (navigation.py:877 "no retriever", :880 "query is
// required", :899 no attribute). The model does not read it — both sides replace
// every empty_reason with their own note (action_session.py:869-876,
// tool_executor.go:402) — but the NavResult value must match.
func TestNavigateTreeEmptyXMLMatchesPython(t *testing.T) {
// TestNavigateTreeEmptyXML pins the text each empty NavResult carries: `count="0"` plus an
// `error="..."` attribute that is OMITTED for a query-level miss (present for "no retriever"
// and "query is required"). The model does not read it — every empty_reason is replaced with
// the tool's own note (tool_executor.go) — but the NavResult value must match.
func TestNavigateTreeEmptyXML(t *testing.T) {
cases := []struct {
name string
got NavResult
@@ -244,8 +238,8 @@ func TestNavigateTreeEmptyXMLMatchesPython(t *testing.T) {
"<tree_navigation count=\"0\">\n</tree_navigation>",
},
{
// Go-only state: Python's nav-tree route has no no_structure verdict,
// so this label is the port's own wording.
// A state with no upstream equivalent: the nav-tree route has no
// no_structure verdict, so this label is this file's own wording.
"no_structure",
NavigateTree(context.Background(), &stubRouter{nilResult: true}, NavTreeInput{
Query: "q", KbIDs: []string{"kb1"},
@@ -303,9 +297,7 @@ func TestNavigateTreeEscapesXML(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Compiled-structure parsing
// ---------------------------------------------------------------------------
func TestParseCompiledStructureGraphBlob(t *testing.T) {
rows := []StructureRow{{
@@ -373,9 +365,7 @@ func TestParseCompiledStructureEmptyKindsMatchesAll(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Knowledge-graph exploration
// ---------------------------------------------------------------------------
// TestExploreGraphShortCircuitsOnEmptyInput verifies the documented contract:
// no query text or no bound datasets yields an empty ExploreResult (no engine
@@ -414,7 +404,7 @@ func (s stubTenantResolver) ResolveDocTenants(ctx context.Context, docIDs []stri
return out, nil
}
// TestResolveKGScopeGroupsByOwner mirrors Python _kg_scopes: documents are
// TestResolveKGScopeGroupsByOwner: documents are
// grouped by their real owning (kb, tenant) — which may be a KB outside the
// caller's datasetIDs — rather than every bound dataset being searched with
// the whole DocScope.
@@ -458,9 +448,8 @@ func TestResolveKGScopeGroupsByOwner(t *testing.T) {
}
}
// TestResolveKGScopeAppliesSessionDocScopeCeiling verifies Python's
// scoped_doc_ids ceiling (exploration.py:_kg_scopes): the session doc_scope restricts
// whatever scope the caller passed before ownership resolution.
// TestResolveKGScopeAppliesSessionDocScopeCeiling verifies the session ceiling: the session
// doc_scope restricts whatever scope the caller passed before ownership resolution.
func TestResolveKGScopeAppliesSessionDocScopeCeiling(t *testing.T) {
got := resolveKGScope(SearchDeps{
TenantID: "t1",
@@ -484,9 +473,7 @@ func TestResolveKGScopeNonNilEmptyExpandsNothing(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Knowledge-graph row parsing
// ---------------------------------------------------------------------------
func TestKgParseEntity_NameFallbacks(t *testing.T) {
// name wins.
@@ -564,9 +551,7 @@ func TestKgParseRelationEndpointsRequired(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// endpoint terms (original + lowercased, deduped)
// ---------------------------------------------------------------------------
func TestEndpointTerms(t *testing.T) {
got := endpointTerms([]string{"OmiyaSoft", "omiya", "", "Culdcept"})
@@ -583,9 +568,7 @@ func TestEndpointTerms(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Evidence collection: relevant entities + relations grouped by doc
// ---------------------------------------------------------------------------
func TestCollectEvidenceIDsByDocAndAlias(t *testing.T) {
entities := []kgEntity{
@@ -597,7 +580,7 @@ func TestCollectEvidenceIDsByDocAndAlias(t *testing.T) {
}
// Relevant set includes an alias ("Omiya") and the relation target ("Culdcept").
byDoc := collectEvidenceIDs(entities, relations, []string{"Omiya", "Culdcept"})
// Doc order is FIRST-SEEN — Python's dict insertion order (d1 before d2).
// Doc order is FIRST-SEEN (the order the docs were reached), d1 before d2.
if len(byDoc) != 2 || byDoc[0].DocID != "d1" || byDoc[1].DocID != "d2" {
t.Fatalf("doc order = %v, want [d1 d2]", byDoc)
}
@@ -634,9 +617,7 @@ func TestCollectEvidenceIDsDeduplicatesChunks(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// mention_count re-ranking (the dense-seed re-sort)
// ---------------------------------------------------------------------------
func TestMentionCountTypeCoercion(t *testing.T) {
cases := []struct {
@@ -669,9 +650,7 @@ func TestTopMentionCountSortsAndCaps(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// String-field helpers
// ---------------------------------------------------------------------------
func TestStrSliceField(t *testing.T) {
if got := strSliceField([]string{"a", "b"}); len(got) != 2 {
@@ -698,10 +677,9 @@ func TestStrOr(t *testing.T) {
}
// TestBuildTocTreeHierarchyFromRelations guards the navigate_structure relations
// wiring: when relations are loaded (Python passes them into
// _render_toc_drilldown / _build_toc_tree) the tree must expose the parent→child
// hierarchy and a single root, instead of collapsing to isolated roots the way
// an empty/nil relation list does. Mirrors Python _build_toc_tree.
// wiring: when relations are loaded the tree must expose the parent→child hierarchy and a
// single root, instead of collapsing to isolated roots the way an empty/nil relation list
// does.
func TestBuildTocTreeHierarchyFromRelations(t *testing.T) {
nodes := []structureNode{
{name: "root"},
@@ -746,11 +724,10 @@ func TestBuildTocTreeNoRelationsYieldsIsolatedRoots(t *testing.T) {
// TestStructureGraphFromRawMissingFieldsStayEmpty pins the compiled-payload
// defaults: a field a compiled row simply does not carry must read as absent, so
// the renderers apply Python's defaults ("other" for a type, "related_to" for a
// the renderers apply their defaults ("other" for a type, "related_to" for a
// relation type) and a relation with a missing endpoint is DROPPED. fmt.Sprint(nil)
// returns the non-empty string "<nil>", which defeated all three: the model read
// "- Name (<nil>): <nil>" and the tree grew a phantom edge to "<nil>"
// (navigation.py:1714-1715, 1783-1788).
// "- Name (<nil>): <nil>" and the tree grew a phantom edge to "<nil>".
func TestStructureGraphFromRawMissingFieldsStayEmpty(t *testing.T) {
rawEntities := []map[string]any{
{"name": "Bare"}, // no type, no description
@@ -782,8 +759,8 @@ func TestStructureGraphFromRawMissingFieldsStayEmpty(t *testing.T) {
t.Errorf("relation type = %q, want empty so the renderer defaults it to \"related_to\"", rels[0].relType)
}
// End to end: the outline the model reads must show the Python defaults, not
// "<nil>" and not the dropped relations.
// End to end: the outline the model reads must show the defaults, not "<nil>" and not
// the dropped relations.
outline := renderOutline(structureNodesFromEntities(nodes), rels)
if strings.Contains(outline, "<nil>") {
t.Fatalf("outline leaks <nil>:\n%s", outline)
@@ -799,10 +776,9 @@ func TestStructureGraphFromRawMissingFieldsStayEmpty(t *testing.T) {
}
}
// TestStructureDocSegmentFormat locks the per-doc XML shape navigateStructures
// renders for each routed/requested document, mirroring Python's
// <doc rank doc_id doc_title="" entities relations> element (navigation.py
// _navigate_structure_impl, where doc_title is always empty).
// TestStructureDocSegmentFormat locks the per-doc XML shape navigateStructures renders for
// each routed/requested document: the <doc rank doc_id doc_title="" entities relations>
// element, where doc_title is always empty.
func TestStructureDocSegmentFormat(t *testing.T) {
nodes := []structureNode{{name: "N1"}, {name: "N2"}}
rels := []structureRel{{from: "N0", to: "N1"}}
@@ -872,7 +848,7 @@ func TestHasDistinctNodeVectors(t *testing.T) {
}) {
t.Error("distinct per-node vectors -> distinct")
}
// One vector-bearing among several is treated as not-distinct (Python: <2 distinct).
// One vector-bearing among several is treated as not-distinct (fewer than two distinct).
if hasDistinctNodeVectors([]structureNode{
{name: "a", vec: []float64{1, 2}},
{name: "b"},
@@ -994,9 +970,9 @@ func assertKeptNodeOrder(t *testing.T, outline string) {
// three selection strategies. kept drives the rendered outline, chunkPaths (first
// writer wins for a chunk covered by several nodes) and the capped chunk list
// whose snippets reach the model, so an arbitrary order hands back a different
// outline AND a different chunk subset on every run. Python iterates a kept_names
// SET (navigation.py:1660, 1673, 1594) — arbitrary, and hash-randomised per
// process — so there is no canonical order to mirror; the port sorts the names.
// outline AND a different chunk subset on every run. Iterating the kept set gives an
// arbitrary order that is not even reproducible across runs (hash-randomised per process),
// so there is no canonical order to inherit; the names are sorted instead.
func TestTocDrilldownKeptOrderIsDeterministic(t *testing.T) {
nodes := []structureNode{
{name: "Root", nodeType: "tree_node", desc: "root node", sourceChunkIDs: []string{"r1", "shared"}},
@@ -1028,7 +1004,7 @@ func TestTocDrilldownKeptOrderIsDeterministic(t *testing.T) {
if !reflect.DeepEqual(got.chunkPaths, want) {
t.Errorf("chunkPaths = %v, want %v", got.chunkPaths, want)
}
// Snippets are capped at structMaxChunks (=4, Python _STRUCT_MAX_CHUNKS)
// Snippets are capped at structMaxChunks (=4)
// in kept order, so "g1" (Gamma) and "r1" (Root sorts last) sit outside
// the stable, repeatable window.
if !strings.Contains(got.outline, "[chunk a1]") || strings.Contains(got.outline, "[chunk g1]") || strings.Contains(got.outline, "[chunk r1]") {
@@ -1100,8 +1076,8 @@ func TestNavigationToolsReportEmptyWhenUncompiled(t *testing.T) {
}
func TestNavigateStructureDocRejectsForeignDocument(t *testing.T) {
// Python _load_compiled_structure: a doc_id outside the bound datasets
// yields no structure, so nothing from the other dataset is ever read.
// A doc_id outside the bound datasets yields no structure, so nothing from the other
// dataset is ever read.
res, _ := navigateStructures(context.Background(), "t", "q", []string{"foreign-doc"}, "catalog", nil, SearchDeps{
KbIDs: []string{"kb"},
DocIDVerifier: stubVerifier{known: map[string]bool{"mine": true}},
@@ -1115,15 +1091,9 @@ func TestNavigateStructureDocRejectsForeignDocument(t *testing.T) {
}
}
// TestIndexNameForFallsBackToRagflowPrefix pins the index-name seam fix: Python
// reads search.index_name (navigation.py:_navigate_structure_impl); the RAGFlow default is
// "ragflow_<tenant_id>", so an empty configured name falls back to that, while a
// tenant override is honoured verbatim.
// TestIndexNameForFallsBackToRagflowPrefix pins the index-name seam fix: Python
// reads search.index_name (navigation.py:_navigate_structure_impl); the RAGFlow default is
// "ragflow_<tenant_id>", so an empty configured name falls back to that, while a
// tenant override is honoured verbatim.
// TestIndexNameForFallsBackToRagflowPrefix pins the index-name seam: the configured search
// index name is used when set; otherwise the default is "ragflow_<tenant_id>", so an empty
// configured name falls back to that, while a tenant override is honoured verbatim.
func TestIndexNameForFallsBackToRagflowPrefix(t *testing.T) {
if got := indexNameFor("tenantA", ""); got != "ragflow_tenantA" {
t.Errorf("empty configured = %q, want ragflow_tenantA", got)
@@ -1133,22 +1103,15 @@ func TestIndexNameForFallsBackToRagflowPrefix(t *testing.T) {
}
}
// TestNavigateStructuresEmptyDocIDsRoutesViaRouter pins the empty-doc_id vector
// routing fix: mirroring Python _navigate_structure_impl(:1012), a direct call
// with no doc_ids routes through the NavTreeRouter first (search_dataset_layers)
// instead of immediately returning MISS. The tool path still passes non-empty
// doc_ids and must NOT consult the router.
// TestNavigateStructuresEmptyDocIDsRoutesViaRouter pins the empty-doc_id vector
// routing fix: mirroring Python _navigate_structure_impl(:1012), a direct call
// with no doc_ids routes through the NavTreeRouter first (search_dataset_layers)
// instead of immediately returning MISS. The tool path still passes non-empty
// doc_ids and must NOT consult the router.
// TestNavigateStructuresEmptyDocIDsRoutesViaRouter pins the empty-doc_id vector routing: a
// direct call with no doc_ids routes through the NavTreeRouter first instead of immediately
// returning MISS. The tool path still passes non-empty doc_ids and must NOT consult the
// router.
func TestNavigateStructuresEmptyDocIDsRoutesViaRouter(t *testing.T) {
router := &stubNavRouter{docs: [][2]string{{"d1", "summary"}}}
// Empty doc_ids + router => router consulted (it then returns ReasonNoDoc
// because no engine-backed structure surfaces in the test env, identical to
// the Python post-route empty result).
// the post-route empty result).
if _, _ = navigateStructures(context.Background(), "t", "q", []string{}, "catalog", router, SearchDeps{KbIDs: []string{"kb"}}); !router.called {
t.Fatal("empty docIDs should route via NavTreeRouter, but router was not called")
}
@@ -1165,10 +1128,9 @@ func TestNavigateStructuresEmptyDocIDsRoutesViaRouter(t *testing.T) {
}
// TestNavigateTreeRoutesWithinSessionDocScope pins that the nav-tree route runs
// inside the session document scope. Python's _exec_navigate_tree does not thread
// args["doc_scope"], but _navigate_tree_impl routes through _nav_search_titled,
// which ceilings with tools.scoped_doc_ids(None) — i.e. the session doc_scope
// (navigation.py:_nav_search_titled). Dropping it routed over the whole dataset.
// inside the session document scope. args["doc_scope"] is not threaded into the tree route,
// but the routing ceilings with the session doc_scope — dropping it routed over the whole
// dataset.
func TestNavigateTreeRoutesWithinSessionDocScope(t *testing.T) {
router := &stubNavRouter{docs: [][2]string{{"d1", "summary"}}}
NavigateTree(context.Background(), router, NavTreeInput{
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"bytes"
@@ -59,7 +59,7 @@ func newTestSearchDeps(r Retriever) (SearchDeps, *Kbinfos) {
}
func TestHybridSearchBailsWithoutDatasetsOrBackend(t *testing.T) {
// No bound datasets -> no search at all (Python returns empty kbinfos).
// No bound datasets -> no search at all (empty kbinfos).
got, aggs := HybridSearch(context.Background(), SearchDeps{
Backend: &stubRetriever{chunks: []map[string]any{{"content": "x"}}},
}, SearchParams{Question: "q"})
@@ -109,12 +109,10 @@ func TestHybridSearchBuildsEffectiveQuery(t *testing.T) {
}
}
// TestHybridSearchEffectiveQueryCapsCodePoints pins the expanded-query cap to
// code points, not bytes. Python slices a str with `[:400]` (search.py:129/216/254),
// so the cap counts code points: a byte slice both splits a multi-byte rune —
// handing the retriever invalid UTF-8 — and caps a CJK query at ~133 characters,
// dropping expansion terms the fan-out leg weighs on. The ASCII case above
// cannot tell the two apart (bytes == runes there).
// TestHybridSearchEffectiveQueryCapsCodePoints pins the expanded-query cap to code points,
// not bytes: a byte slice both splits a multi-byte rune — handing the retriever invalid
// UTF-8 — and caps a CJK query at ~133 characters, dropping expansion terms the fan-out
// leg weighs on. The ASCII case above cannot tell the two apart (bytes == runes there).
func TestHybridSearchEffectiveQueryCapsCodePoints(t *testing.T) {
// "who made it" + " " is 12 bytes, so byte 400 lands one byte inside a CJK
// rune (400-12 = 388 = 3*129 + 1): the byte slice is not even valid UTF-8.
@@ -147,11 +145,9 @@ func TestHybridSearchEffectiveQueryCapsCodePoints(t *testing.T) {
}
func TestRankFeatureOnlyOnRetrieveLeg(t *testing.T) {
// Python passes rank_feature ONLY from RAGTools.retrieve
// (agentic_rag.py:668 rank_feature=label_question(question, self.kbs)).
// search.py's three legs call retriever.retrieval WITHOUT rank_feature
// (:158-173 hybrid, :223-238 vector, :260-275 bm25; grep_search delegates to
// bm25_search, :428), so those requests must stay nil even with a Tagger.
// rank_feature is passed ONLY by the retrieve channel; the three search legs call the
// retriever WITHOUT it (the grep leg delegates to bm25), so those requests must stay
// nil even with a Tagger.
r := &stubRetriever{chunks: []map[string]any{{"content": "hit"}}}
deps, _ := newTestSearchDeps(r)
deps.KBs = []*entity.Knowledgebase{{}}
@@ -183,8 +179,7 @@ func TestRankFeatureOnlyOnRetrieveLeg(t *testing.T) {
t.Errorf("RetrieveSearch RankFeature = %v, want %v", got, want)
}
// Nil Tagger -> empty rank feature even on the retrieve leg (Python
// label_question returning None).
// Nil Tagger -> empty rank feature even on the retrieve leg.
r3 := &stubRetriever{chunks: []map[string]any{{"content": "hit"}}}
deps3, _ := newTestSearchDeps(r3)
deps3.UsingEmbedding = true
@@ -267,9 +262,7 @@ func TestHybridSearchReturnsEmptyOnBackendError(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Narrowing
// ---------------------------------------------------------------------------
func TestNarrowOrKeepIsAllOrNothing(t *testing.T) {
var lines []string
@@ -363,8 +356,7 @@ func TestSplitKeywordsFallsBackToBigrams(t *testing.T) {
func TestHighlightKeywordsPrefersLongestTerm(t *testing.T) {
// "new york" must win over "york" so the shorter term cannot split it.
got := HighlightKeywords("welcome to New York city", []string{"york", "new york"})
// Python's star marker, and ONE span for the multi-word entity
// (text_processing.py:391-394) — never "*New* *York*".
// The star marker, and ONE span for the multi-word entity — never "*New* *York*".
if !strings.Contains(got, "*New York*") {
t.Errorf("highlight = %q, want the longest term applied", got)
}
@@ -374,9 +366,9 @@ func TestHighlightKeywordsPrefersLongestTerm(t *testing.T) {
}
// TestHighlightKeywordsFoldsWithoutByteOffsets pins the matching to rune space.
// `strings.ToLower` is not byte-length-preserving: "İ" (U+0130) is 2 bytes and
// folds to the 1-byte "i" (Go applies the simple 1:1 case mapping, the opposite
// direction from Python's full fold, which expands it to 3 bytes). So a byte
// `strings.ToLower` is not byte-length-preserving: "İ" (U+0130) is 2 bytes and folds to
// the 1-byte "i" (Go applies the simple 1:1 case mapping, the opposite direction from a
// full Unicode fold, which expands it to 3 bytes). So a byte
// offset taken from the original indexes the folded string at a different
// position: the loop then runs past its end (panic: slice bounds out of range
// [10:9]) or cuts a rune in half (invalid UTF-8). Corpus text reaches this via
@@ -400,9 +392,9 @@ func TestHighlightKeywordsFoldsWithoutByteOffsets(t *testing.T) {
}
// TestHighlightKeywordsFoldsUppercaseKeywords pins that a keyword's OWN casing is
// folded the way the haystack is: terms are matched against the lowercased text
// (`lows`), and Python builds its phrase list with `(kw or "").strip().lower()`
// (text_processing.py:396) plus re.IGNORECASE (:411). A caller-supplied "Rocket"
// folded the way the haystack is: terms are matched against the lowercased text (`lows`),
// and the phrase list is built with a strip+lower pass plus case-insensitive matching. A
// caller-supplied "Rocket"
// used to be compared verbatim, so a capitalised keyword never matched and the
// span was silently left unstarred.
func TestHighlightKeywordsFoldsUppercaseKeywords(t *testing.T) {
@@ -425,9 +417,9 @@ func TestHighlightKeywordsFoldsUppercaseKeywords(t *testing.T) {
}
}
// TestHighlightKeywordsKeepsPhrasePartsWhole pins Python's phrase guard
// (text_processing.py:405): a stem-matched word that already occurs inside a
// keyword phrase is NOT added as a term of its own, so a standalone "Braves" is
// TestHighlightKeywordsKeepsPhrasePartsWhole pins the phrase guard: a stem-matched word
// that already occurs inside a keyword phrase is NOT added as a term of its own, so a
// standalone "Braves" is
// left alone and only the "Atlanta Braves" span is starred.
func TestHighlightKeywordsKeepsPhrasePartsWhole(t *testing.T) {
got := HighlightKeywords("Braves lost. Atlanta Braves won.", []string{"Atlanta Braves"})
@@ -436,9 +428,9 @@ func TestHighlightKeywordsKeepsPhrasePartsWhole(t *testing.T) {
}
}
// TestHighlightKeywordsStemMatchesCapitalisedWords pins the word scan: Python
// stems every `[A-Za-z]+` word (:403), so a capitalised inflected word still
// contributes its stem term. The shared lowercase pattern matched only the
// TestHighlightKeywordsStemMatchesCapitalisedWords pins the word scan: every `[A-Za-z]+`
// word is stemmed, so a capitalised inflected word still contributes its stem term. The
// shared lowercase pattern matched only the
// fragment after the capital ("Nominated" -> "ominated"), so nothing was starred.
func TestHighlightKeywordsStemMatchesCapitalisedWords(t *testing.T) {
got := HighlightKeywords("Nominated twice.", []string{"nominations"})
@@ -447,9 +439,7 @@ func TestHighlightKeywordsStemMatchesCapitalisedWords(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Doc aggregations
// ---------------------------------------------------------------------------
func TestDocAggsGroupsByDocument(t *testing.T) {
chunks := []map[string]any{
@@ -501,9 +491,9 @@ func TestHybridSearchAggregatesAndRespectsScope(t *testing.T) {
}
func TestHybridSearchAggsCoverFullRetrievedBeforeNarrowing(t *testing.T) {
// doc_aggs must reflect the FULL pre-narrow retrieved set, mirroring Python
// where _normalize takes doc_aggs from the retriever and _narrow_or_keep only
// replaces kbinfos["chunks"]. Here the keyword narrows the chunks to doc d1,
// doc_aggs must reflect the FULL pre-narrow retrieved set: the aggregations come
// straight from the retriever and narrowing only replaces the chunk list. Here the
// keyword narrows the chunks to doc d1,
// but the aggregation still counts both retrieved documents.
r := &stubRetriever{chunks: []map[string]any{
{"doc_id": "d1", "docnm_kwd": "one", "content": "needle buried here", "id": "c1"},
@@ -566,8 +556,7 @@ func ptrFloat(t *testing.T, p *float64) float64 {
return *p
}
// TestVectorSearchBailsWithoutEmbedder mirrors Python vector_search: with no
// embedder configured it returns nothing (Python bails when embd_mdl is unset),
// TestVectorSearchBailsWithoutEmbedder: with no embedder configured it returns nothing,
// and must NOT even hit the backend.
func TestVectorSearchBailsWithoutEmbedder(t *testing.T) {
r := &stubRetriever{chunks: []map[string]any{{"content": "x"}}}
@@ -580,7 +569,7 @@ func TestVectorSearchBailsWithoutEmbedder(t *testing.T) {
}
}
// TestVectorSearchWeightIsOne mirrors Python vector_search: the pure-vector leg
// TestVectorSearchWeightIsOne: the pure-vector leg
// carries weight 1.0 and excludes compiled rows.
func TestVectorSearchWeightIsOne(t *testing.T) {
r := &stubRetriever{chunks: []map[string]any{{"content": "x"}}}
@@ -596,7 +585,7 @@ func TestVectorSearchWeightIsOne(t *testing.T) {
}
}
// TestBM25SearchUsesZeroWeight mirrors Python bm25_search: keyword-only, vector
// TestBM25SearchUsesZeroWeight: keyword-only, vector
// weight unconditionally 0, threshold 0.0, excludes compiled rows.
func TestBM25SearchUsesZeroWeight(t *testing.T) {
r := &stubRetriever{chunks: []map[string]any{{"content": "x"}}}
@@ -606,9 +595,9 @@ func TestBM25SearchUsesZeroWeight(t *testing.T) {
if got := ptrFloat(t, req.VectorSimilarityWeight); got != 0 {
t.Errorf("bm25 search weight = %v, want 0", got)
}
// Python bm25_search passes embd_mdl=None: no dense leg at all.
// No dense leg at all.
if !req.DisableVectorLeg {
t.Error("bm25 search must disable the vector leg (embd_mdl=None)")
t.Error("bm25 search must disable the vector leg")
}
if got := ptrFloat(t, req.SimilarityThreshold); got != 0 {
t.Errorf("bm25 search threshold = %v, want 0", got)
@@ -618,7 +607,7 @@ func TestBM25SearchUsesZeroWeight(t *testing.T) {
}
}
// TestGrepSearchDelegatesToBM25 mirrors Python grep_search: it is bm25_search
// TestGrepSearchDelegatesToBM25: it is bm25_search
// with a keyword-only (weight 0) leg and compiled-row exclusion.
func TestGrepSearchDelegatesToBM25(t *testing.T) {
r := &stubRetriever{chunks: []map[string]any{{"content": "x"}}}
@@ -849,7 +838,7 @@ func TestGrepTermsFromQuery(t *testing.T) {
}
}
// TestGrepSearchNarrowsProseViaTermWindow mirrors Python grep_search's two
// TestGrepSearchNarrowsProseViaTermWindow: two
// narrowing stages: bm25_search narrows by the keywords hint first
// (search.py:grep_search via _narrow_or_keep), then the prose candidates alone are
// narrowed to the term-grep window (search.py:grep_search) while table chunks pass
@@ -874,21 +863,20 @@ func TestGrepSearchNarrowsProseViaTermWindow(t *testing.T) {
// so the prose only survives the FIRST stage if it carries those terms:
// "Culdcept was made" yields the bigrams "culdcept was"/"was made", which
// the prose sentence does. (With "who made Culdcept?" the hint matches the
// table alone and Python drops the prose outright — pinned by
// table alone and the prose is dropped outright — pinned by
// TestGrepSearchDerivesKeywordsHint.)
chunks, _ := GrepSearch(context.Background(), deps, SearchParams{Question: "Culdcept was made"})
// Order: table chunks first, then narrowed prose (Python keeps table+kept).
// Order: table chunks first, then narrowed prose (table + kept prose).
if len(chunks) != 2 {
t.Fatalf("chunks = %d, want 2 (table + narrowed prose)", len(chunks))
}
if chunks[0]["chunk_id"] != "t1" {
t.Errorf("first chunk = %v, want table chunk t1", chunks[0]["chunk_id"])
}
// The table chunk must be returned WHOLE, every row intact: Python's
// pipe-table branch returns "..." + _highlight_keywords(content) + "..."
// (text_processing.py _narrow_content), so the body is unchanged and only
// the wrapper differs from the raw chunk.
// The table chunk must be returned WHOLE, every row intact: the pipe-table branch wraps
// the highlighted content, so the body is unchanged and only the wrapper differs from the
// raw chunk.
if got := ChunkTextOf(chunks[0]); !strings.Contains(got, "team | pts | rank") || !strings.Contains(got, "D | 2 | 4") {
t.Errorf("table chunk was truncated; got %q", got)
}
@@ -905,7 +893,7 @@ func TestGrepSearchNarrowsProseViaTermWindow(t *testing.T) {
}
}
// TestGrepSearchKeepsRawCandidatesWhenNoMatch mirrors Python grep_search: when
// TestGrepSearchKeepsRawCandidatesWhenNoMatch: when
// the term-grep matches nothing, the raw BM25 candidates are returned unchanged
// so evidence is never dropped.
func TestGrepSearchKeepsRawCandidatesWhenNoMatch(t *testing.T) {
@@ -924,7 +912,7 @@ func TestGrepSearchKeepsRawCandidatesWhenNoMatch(t *testing.T) {
}
}
// TestGrepSearchDerivesKeywordsHint mirrors Python grep_search:421-424: with no
// TestGrepSearchDerivesKeywordsHint: with no
// explicit hint the BM25 pool is built from "query + the query's own extracted
// terms", so a long question's proper nouns stop hiding under stopwords and the
// keyword-narrowing stage runs even without a nav hint. An explicit hint (the
@@ -942,7 +930,7 @@ func TestGrepSearchDerivesKeywordsHint(t *testing.T) {
}
// The derived hint also drives the narrowing stage: a prose candidate whose
// sentences miss those terms is dropped, while a >=3-row pipe table is kept
// whole (Python _narrow_by_keywords + _narrow_content's table branch).
// whole (the narrowing's table branch).
prose := map[string]any{"chunk_id": "p1", "content": "Unrelated sentence about weather."}
table := map[string]any{"chunk_id": "t1", "content": "a | b | c\nd | e | f\ng | h | i"}
r2 := &stubRetriever{chunks: []map[string]any{prose, table}}
@@ -961,10 +949,9 @@ func TestGrepSearchDerivesKeywordsHint(t *testing.T) {
}
}
// TestSearchCacheIsHybridOnly mirrors Python: tools.search_cache is read and
// written by hybrid_search alone (/ :207). A keyword-only leg
// must neither serve nor be served by it — the key carries no weight,
// threshold or compiled policy, so a shared cache would hand a hybrid result
// TestSearchCacheIsHybridOnly: the search cache is read and written by the hybrid leg
// alone. A keyword-only leg must neither serve nor be served by it — the key carries no
// weight, threshold or compiled policy, so a shared cache would hand a hybrid result
// (vector weight 0.3) to a BM25/grep call and vice versa.
func TestSearchCacheIsHybridOnly(t *testing.T) {
r := &stubRetriever{chunks: []map[string]any{{"content": "hit"}}}
@@ -983,9 +970,8 @@ func TestSearchCacheIsHybridOnly(t *testing.T) {
}
}
// TestHybridSearchExcludesCompiledAndWeightsThreeTenths mirrors Python
// hybrid_search: vector weight 0.3 when an embedder is configured, compiled
// rows excluded.
// TestHybridSearchExcludesCompiledAndWeightsThreeTenths: vector weight 0.3 when an
// embedder is configured, compiled rows excluded.
func TestHybridSearchExcludesCompiledAndWeightsThreeTenths(t *testing.T) {
r := &stubRetriever{chunks: []map[string]any{{"content": "x"}}}
deps, _ := newTestSearchDeps(r)
@@ -995,8 +981,8 @@ func TestHybridSearchExcludesCompiledAndWeightsThreeTenths(t *testing.T) {
if got := ptrFloat(t, req.VectorSimilarityWeight); got != HybridSearchDefaultVectorWeight {
t.Errorf("hybrid search weight = %v, want %v", got, HybridSearchDefaultVectorWeight)
}
// With an embedder configured Python hybrid_search passes the real
// embd_mdl: the dense leg RUNS at weight 0.3.
// With an embedder configured the real handle is passed: the dense leg RUNS at
// weight 0.3.
if req.DisableVectorLeg {
t.Error("hybrid search with an embedder must keep the vector leg")
}
@@ -1005,7 +991,7 @@ func TestHybridSearchExcludesCompiledAndWeightsThreeTenths(t *testing.T) {
}
}
// TestRetrieveSearchDoesNotExcludeCompiled mirrors Python RAGTools.retrieve:
// TestRetrieveSearchDoesNotExcludeCompiled
// unlike hybrid_search it does NOT exclude compiled rows, and honours
// UsingEmbedding (weight 0.7 when on).
func TestRetrieveSearchDoesNotExcludeCompiled(t *testing.T) {
@@ -1022,9 +1008,8 @@ func TestRetrieveSearchDoesNotExcludeCompiled(t *testing.T) {
}
}
// TestHybridSearchMergesSQLKBs verifies that hybrid_search folds the session's
// structured (SQL) datasets into the target id list, mirroring Python
// search.py:hybrid_search (`tools.kb_ids + [kb.id for kb in tools.sql_kbs]`).
// TestHybridSearchMergesSQLKBs verifies that the hybrid leg folds the session's structured
// (SQL) datasets into the target id list.
func TestHybridSearchMergesSQLKBs(t *testing.T) {
r := &stubRetriever{chunks: []map[string]any{{"content": "x"}}}
deps, _ := newTestSearchDeps(r)
@@ -1089,12 +1074,11 @@ func TestNormalizeWebResults(t *testing.T) {
}
}
// TestQueryToTerms pins Python _query_to_terms on the shapes the
// fan-out prefetch feeds it. The CJK case is the
// regression guard: Go's RE2 \w is ASCII-only, so tokenizing a Chinese fan-out
// with \w+ produced NO terms, leaving the BM25 leg of the fan-out without the
// keyed terms that give the discriminating entity its own score mass
// (agentic_rag_graph.py:_search_one) — the log line then read "keywords: ".
// TestQueryToTerms pins the tokenizer on the shapes the fan-out prefetch feeds it. The CJK
// case is the regression guard: Go's RE2 \w is ASCII-only, so tokenizing a Chinese
// fan-out with \w+ produced NO terms, leaving the BM25 leg without the keyed terms that
// give the discriminating entity its own score mass — the log line then read
// "keywords: ".
func TestQueryToTerms(t *testing.T) {
for _, c := range []struct {
name string
@@ -1102,9 +1086,9 @@ func TestQueryToTerms(t *testing.T) {
want []string
}{
{"cjk sentence", "关羽 斩杀 有姓名 人物 名单", []string{"关羽", "斩杀", "有姓名", "人物", "名单"}},
// Case is preserved, as in Python (downstream matching is (?i)).
// Case is preserved (downstream matching is case-insensitive).
{"ascii sentence", "Culdcept Saga release date", []string{"Culdcept", "Saga", "release", "date"}},
// Python dedups case-sensitively (tok not in terms), so both survive.
// Dedup is case-sensitive, so both survive.
{"case-sensitive dedup", "Saga saga", []string{"Saga", "saga"}},
{"regex alternation", `(?i)\b关羽|张飞\b`, []string{"关羽", "张飞"}},
{"tokens below two runes dropped", "a b cd", []string{"cd"}},
@@ -1129,10 +1113,10 @@ func TestQueryToTerms(t *testing.T) {
}
// The exact fan-out queries of a real high-mode run ("关羽杀了多少有姓名的人?"),
// with the keywords Python's implementation produces for them (verified by
// with the keywords the implementation produces for them (verified by
// running rag.advanced_rag.harness.tools.search._query_to_terms plus the keyed
// filter of agentic_rag_graph.py:_search_one). Note the surviving term is a generic
// long word, not the entity: Python's len>=3 rule drops every two-character
// long word, not the entity: the len>=3 rule drops every two-character
// Chinese name (关羽/华雄/颜良/文丑/蔡阳), so this is parity, not a Go bug.
for _, c := range []struct{ in, want string }{
{"关羽 斩杀 有姓名 人物 名单", "有姓名"},
@@ -1140,17 +1124,17 @@ func TestQueryToTerms(t *testing.T) {
{"关羽 杀 将领 三国志", "三国志"},
} {
if got := strings.Join(FanoutKeyedTerms(QueryToTerms(c.in)), " "); got != c.want {
t.Errorf("fan-out keywords for %q = %q, want %q (Python parity)", c.in, got, c.want)
t.Errorf("fan-out keywords for %q = %q, want %q", c.in, got, c.want)
}
}
}
func TestAgenticVectorWeightDefaultsToZero(t *testing.T) {
// Python's agentic retrieve runs keyword-only: using_embedding defaults to
// The agentic retrieve runs keyword-only: UsingEmbedding defaults to
// False and no caller passes True (agentic_rag.py:retrieve, 643-646).
got := floatPtrOrDef(nil, DefaultAgenticVectorWeight)
if got != 0 {
t.Fatalf("default vector weight = %v, want 0 (keyword-only, as in Python)", got)
t.Fatalf("default vector weight = %v, want 0 (keyword-only)", got)
}
if DefaultAgenticVectorWeight != 0 {
t.Fatalf("DefaultAgenticVectorWeight = %v, want 0", DefaultAgenticVectorWeight)
@@ -1167,7 +1151,7 @@ func TestVectorWeightHonoursExplicitZero(t *testing.T) {
// Fallback must apply only when unset.
if DefaultAgenticVectorWeight != 0 {
t.Fatal("the fallback itself must stay 0 to match Python")
t.Fatal("the fallback itself must stay 0")
}
}
@@ -1189,8 +1173,8 @@ func TestRetrievalDefaultsUseIntOrDef(t *testing.T) {
}
func TestResolveVectorWeightRetrieveMirrorsUsingEmbedding(t *testing.T) {
// Python RAGTools.retrieve(using_embedding: bool = False).
// Off → keyword-only (weight 0); on → 0.7 default or the configured override.
// UsingEmbedding off → keyword-only (weight 0); on → 0.7 default or the configured
// override.
if got := resolveVectorWeight(SearchDeps{UsingEmbedding: false}, ChannelRetrieve); got != 0 {
t.Fatalf("using_embedding=false → %v, want 0 (keyword-only)", got)
}
@@ -1204,21 +1188,19 @@ func TestResolveVectorWeightRetrieveMirrorsUsingEmbedding(t *testing.T) {
}
func TestResolveVectorWeightHybridDefaultsToThreeTenths(t *testing.T) {
// Python hybrid_search defaults the vector weight to 0.3
// (_DEFAULT_HYBRID_VECTOR_WEIGHT,), unlike RAGTools.retrieve's 0.7.
// The hybrid leg defaults the vector weight to 0.3, unlike the retrieve channel's 0.7.
if got := resolveVectorWeight(SearchDeps{HasEmbedder: true}, ChannelHybrid); got != HybridSearchDefaultVectorWeight {
t.Fatalf("hybrid → %v, want %v", got, HybridSearchDefaultVectorWeight)
}
if got := resolveVectorWeight(SearchDeps{HasEmbedder: false}, ChannelHybrid); got != 0 {
t.Fatalf("hybrid with no embedder → %v, want 0 (Python: `if embd_mdl`)", got)
t.Fatalf("hybrid with no embedder → %v, want 0", got)
}
}
// TestResolveVectorWeightHybridIgnoresUsingEmbedding is the regression guard for
// the channel-granularity bug: Python's hybrid_search has NO using_embedding
// parameter (search.py:hybrid_search, :143-145), so gating it on that flag disabled the
// semantic leg for search_chunks — losing recall of passages sharing no surface
// words. The gate for this channel is the embedder, nothing else.
// TestResolveVectorWeightHybridIgnoresUsingEmbedding is the regression guard for the
// channel-granularity bug: the hybrid leg has NO using_embedding parameter, so gating it
// on that flag disabled the semantic leg for search_chunks — losing recall of passages
// sharing no surface words. The gate for this channel is the embedder, nothing else.
func TestResolveVectorWeightHybridIgnoresUsingEmbedding(t *testing.T) {
if got := resolveVectorWeight(SearchDeps{UsingEmbedding: false, HasEmbedder: true}, ChannelHybrid); got != HybridSearchDefaultVectorWeight {
t.Fatalf("hybrid with using_embedding=false → %v, want %v: the vector leg "+
@@ -1226,9 +1208,9 @@ func TestResolveVectorWeightHybridIgnoresUsingEmbedding(t *testing.T) {
}
}
// TestResolveVectorWeightGrepIsAlwaysZero pins Python grep_search: the retrieve
// and grep_* session tools are keyword-only and have no vector leg at all, so no
// flag can turn one on.
// TestResolveVectorWeightGrepIsAlwaysZero: the grep channel and the retrieve / grep_*
// session tools are keyword-only and have no vector leg at all, so no flag can turn one
// on.
func TestResolveVectorWeightGrepIsAlwaysZero(t *testing.T) {
override := 0.9
if got := resolveVectorWeight(SearchDeps{UsingEmbedding: true, HasEmbedder: true}, ChannelGrep); got != 0 {
@@ -1279,8 +1261,8 @@ func TestResolveDocScopeKeepsKnownIDs(t *testing.T) {
}
func TestResolveDocScopeDropsScopeWhenAllUnknown(t *testing.T) {
// Python retrieve:633-635 — an entirely bogus scope falls back to
// unfiltered retrieval instead of returning nothing.
// An entirely bogus scope falls back to unfiltered retrieval instead of returning
// nothing.
got := resolveDocScope(context.Background(), SearchDeps{
DocIDVerifier: stubVerifier{known: map[string]bool{"z": true}},
}, []string{"a", "b"}, []string{"kb"}, _LOG)
@@ -1376,8 +1358,8 @@ func TestDocInDatasetsRejectsForeignDocumentViaSubsetVerifier(t *testing.T) {
// TestResolveDocScopeDropsScopeWhenAllUnknownViaSubsetVerifier exercises the
// production empty-map path: when every candidate is foreign the verifier
// returns an empty map (no error). The scope must be dropped (unfiltered
// retrieval, mirroring Python retrieve's fall-back), not passed through.
// returns an empty map (no error). The scope must be dropped (unfiltered retrieval), not
// passed through.
func TestResolveDocScopeDropsScopeWhenAllUnknownViaSubsetVerifier(t *testing.T) {
got := resolveDocScope(context.Background(), SearchDeps{
DocIDVerifier: subsetVerifier{knownDocs: map[string]bool{"z": true}},
@@ -1391,9 +1373,8 @@ func TestResolveDocScopeDropsScopeWhenAllUnknownViaSubsetVerifier(t *testing.T)
// TestResolveDocScopeEmptyWhenCeilingRemovesEveryID pins the ceiling's hard
// edge: when the session doc_scope removes every requested id the result is
// EMPTY (match nothing), never unfiltered. Python's falsy-empty quirk would fall
// through to unfiltered retrieval here; Go deliberately keeps the ceiling
// absolute.
// EMPTY (match nothing), never unfiltered. A falsy-empty check would fall through to
// unfiltered retrieval here; the ceiling is deliberately kept absolute.
func TestResolveDocScopeEmptyWhenCeilingRemovesEveryID(t *testing.T) {
got := resolveDocScope(context.Background(), SearchDeps{
DocIDVerifier: subsetVerifier{knownDocs: map[string]bool{"sess1": true}},
@@ -1406,8 +1387,8 @@ func TestResolveDocScopeEmptyWhenCeilingRemovesEveryID(t *testing.T) {
}
}
// TestResolveDocScopeEmptyWhenSessionScopeSetAndAllUnknown pins Python
// retrieve:631-632 — when the session has a fixed base doc_scope and the
// TestResolveDocScopeEmptyWhenSessionScopeSetAndAllUnknown: when the session has a fixed
// base doc_scope and the
// requested ids survive the ceiling but do not resolve, the result is EMPTY,
// never unfiltered. Encoded as a non-nil empty slice.
func TestResolveDocScopeEmptyWhenSessionScopeSetAndAllUnknown(t *testing.T) {
@@ -1418,12 +1399,12 @@ func TestResolveDocScopeEmptyWhenSessionScopeSetAndAllUnknown(t *testing.T) {
}, []string{"sess1", "sess2"}, []string{"kb"}, _LOG)
if got == nil || len(got) != 0 {
t.Fatalf("scope = %v, want non-nil empty (Python returns empty result, not unfiltered)", got)
t.Fatalf("scope = %v, want non-nil empty (match nothing, not unfiltered)", got)
}
}
// TestResolveDocScopeFallsBackToSessionScopeWhenRequestNone pins Python's
// scoped_doc_ids fallback (retrieve:620): a None request scope falls back to the
// TestResolveDocScopeFallsBackToSessionScopeWhenRequestNone: a nil request scope falls
// back to the
// session's fixed doc_scope before ownership verification.
func TestResolveDocScopeFallsBackToSessionScopeWhenRequestNone(t *testing.T) {
got := resolveDocScope(context.Background(), SearchDeps{
@@ -1452,11 +1433,9 @@ func TestResolveDocScopeUnfilteredWhenNoScopeAtAll(t *testing.T) {
}
}
// TestScopedDocIDsMirrorsPythonCeiling pins RAGTools.scoped_doc_ids
// (agentic_rag.py:scoped_doc_ids): no session scope → the caller's scope as-is; a
// session scope with no caller scope → the session scope; both → the
// intersection.
func TestScopedDocIDsMirrorsPythonCeiling(t *testing.T) {
// TestScopedDocIDsCeiling: no session scope → the caller's scope as-is; a session scope
// with no caller scope → the session scope; both → the intersection.
func TestScopedDocIDsCeiling(t *testing.T) {
cases := []struct {
name string
session []string
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"context"
@@ -29,13 +29,12 @@ import (
// Text processing: keyword narrowing of retrieved chunks.
//
// Mirrors Python harness/tools/text_processing.py (_narrow_or_keep /
// _narrow_by_keywords / _narrow_content / _narrow_by_terms) and the
// compiled-structure grep_sed narrowing (_narrow_content) that sits alongside
// it in the Python module. In Go this lives next to search.go so the only two
// consumers (HybridSearch and the structure-nav grepper) both reach it.
// This covers the narrowing family (keep-or-narrow, keyword narrowing, content
// narrowing, term narrowing) and the compiled-structure grep_sed narrowing. It lives next
// to search.go so the only two consumers (HybridSearch and the structure-nav grepper)
// both reach it.
// NarrowOrKeep mirrors Python text_processing._narrow_or_keep: narrow chunks to
// NarrowOrKeep: narrow chunks to
// keyword-bearing sentences, but keep the originals when narrowing would drop
// everything.
//
@@ -76,15 +75,12 @@ func NarrowOrKeep(ctx context.Context, chunks []map[string]any, keywords, label
// NarrowByKeywords narrows each chunk to the sentences mentioning any keyword
// (+/-1 neighbour) and drops keyword-less chunks.
//
// Mirrors Python _narrow_by_keywords. Unlike NarrowOrKeep this is the strict
// form: it may return an empty slice, and callers that must not lose evidence
// should use NarrowOrKeep instead.
// Unlike NarrowOrKeep this is the strict form: it may return an empty slice, and callers
// that must not lose evidence should use NarrowOrKeep instead.
func NarrowByKeywords(chunks []map[string]any, keywords string) []map[string]any {
kwds := SplitKeywords(keywords)
// Python _narrow_by_keywords returns the input
// unchanged when there is nothing to narrow on: `if not kwds or not chunks:
// return chunks`. A nil return would wipe the whole evidence pool, so return
// the original chunks verbatim instead.
// The input is returned unchanged when there is nothing to narrow on. A nil return
// would wipe the whole evidence pool, so return the original chunks verbatim instead.
if len(kwds) == 0 || len(chunks) == 0 {
return chunks
}
@@ -98,8 +94,7 @@ func NarrowByKeywords(chunks []map[string]any, keywords string) []map[string]any
if !ok {
continue
}
// Dedup identical narrowed passages (mirrors Python _narrow_by_keywords,
// which drops chunks whose narrowed text hashes the same).
// Dedup identical narrowed passages (chunks whose narrowed text hashes the same).
h := md5.Sum([]byte(narrowed))
key := fmt.Sprintf("%x", h)
if seen[key] {
@@ -110,7 +105,7 @@ func NarrowByKeywords(chunks []map[string]any, keywords string) []map[string]any
for k, v := range c {
cp[k] = v
}
// Mirror Python _narrow_by_keywords exactly: content_with_weight is
// content_with_weight is
// always overwritten with the narrowed text, "content" is mirrored ONLY
// when the original chunk already carried a "content" key, and the
// pre-narrow "highlight" spans are dropped because they no longer apply.
@@ -129,7 +124,7 @@ func NarrowByKeywords(chunks []map[string]any, keywords string) []map[string]any
// SplitKeywords normalizes a keyword string into search terms. When fewer than
// 3 comma terms exist, falls back to space-split bigrams — a bare keyword blob
// ("finale run time") is more discriminative as bigrams than as single words.
// Mirrors Python _narrow_by_keywords' term construction.
// This is the term construction used by NarrowByKeywords.
func SplitKeywords(keywords string) []string {
if strings.TrimSpace(keywords) == "" {
return nil
@@ -156,24 +151,17 @@ func SplitKeywords(keywords string) []string {
return kwds
}
// ---------------------------------------------------------------------------
// Stem-aware keyword matching (Python _keyword_forms / _sentence_stems /
// _sentence_matches / _stem). When nltk is unavailable Python falls back to a
// suffix-stripping stemmer; Go always uses that fallback (no nltk dependency),
// so behaviour matches Python's no-nltk path.
// ---------------------------------------------------------------------------
// Stem-aware keyword matching: the stemmed forms of a keyword and of the text's words are
// matched, so e.g. "nominated" is highlighted for the keyword "nominations".
var wordRe = regexp.MustCompile("[a-z0-9]+")
// wordLetterRe is the scan Python's _highlight_keywords uses to find the words
// to stem-match: `re.findall(r"[A-Za-z]+", text)` (text_processing.py:403). It is
// deliberately NOT the shared lowercase wordRe, which on capitalized text matches
// only fragments ("New" -> "ew", "Nominated" -> "ominated") and therefore never
// yields the stem term Python adds for those words.
// wordLetterRe finds the words to stem-match. It is deliberately NOT the shared lowercase
// wordRe, which on capitalized text matches only fragments ("New" -> "ew", "Nominated" ->
// "ominated") and therefore never yields the stem term those words should contribute.
var wordLetterRe = regexp.MustCompile("[A-Za-z]+")
// containedInPhrase reports whether low occurs inside any keyword phrase,
// mirroring Python's `any(low in p for p in phrases)` (text_processing.py:405).
// containedInPhrase reports whether low occurs inside any keyword phrase.
func containedInPhrase(low string, phrases map[string]struct{}) bool {
for p := range phrases {
if strings.Contains(p, low) {
@@ -192,18 +180,16 @@ func isAlphaOnly(s string) bool {
return true
}
// stemmable mirrors Python _stemmable: len>=4 and purely ASCII letters.
// stemmable: len>=4 and purely ASCII letters.
func stemmable(token string) bool { return len(token) >= 4 && isAlphaOnly(token) }
// stem is Python _stem: nltk's PorterStemmer (text_processing.py:218-222
// uses it whenever nltk is importable, which production guarantees via
// rag/nlp/synonym.py). porterStem is a faithful port of that exact
// implementation (NLTK_EXTENSIONS mode), so Go matches Python's stems
// word-for-word. The old suffix-stripping fallback is gone: it diverged from
// nltk on exactly the words that matter for keyword narrowing.
// stem is the stemmer used throughout: porterStem is a faithful port of nltk's
// PorterStemmer (NLTK_EXTENSIONS mode), so the stems match word-for-word. The old
// suffix-stripping fallback is gone: it diverged on exactly the words that matter for
// keyword narrowing.
func stem(word string) string { return porterStem(word) }
// keywordForms mirrors Python _keyword_forms: verbatim keeps forms containing
// keywordForms: verbatim keeps forms containing
// any non-stemmable token (matched by substring); stemmed holds all-ASCII-letter
// keyword forms as stem tuples (matched by a contiguous stem sequence).
func keywordForms(kwds []string) (verbatim []string, stemmed [][]string) {
@@ -235,7 +221,7 @@ func allStemmable(tokens []string) bool {
return true
}
// sentenceStems mirrors Python _sentence_stems.
// sentenceStems
func sentenceStems(sentence string) []string {
tokens := wordRe.FindAllString(strings.ToLower(sentence), -1)
out := make([]string, len(tokens))
@@ -249,7 +235,7 @@ func sentenceStems(sentence string) []string {
return out
}
// sentenceMatches mirrors Python _sentence_matches: any verbatim substring OR a
// sentenceMatches: any verbatim substring OR a
// contiguous stemmed sequence.
func sentenceMatches(low string, stems, verbatim []string, stemmed [][]string) bool {
for _, v := range verbatim {
@@ -273,12 +259,11 @@ func sentenceMatches(low string, stems, verbatim []string, stemmed [][]string) b
// NarrowContent returns the keyword-bearing sentences (+/-2 neighbours) with
// the keywords highlighted, or ("", false) when no keyword occurs.
// Mirrors Python _narrow_content: matching keeps keyword sentences within a
// +/-2 window, AND fact-dense sentences (numbers / years / percentages / proper
// nouns) are kept within a +/-1 window even without a keyword hit, so numeric or
// named-entity answers survive narrowing. Block-level tables and markdown
// pipe-tables (>=3 rows) are returned whole — keyword-window narrowing would
// otherwise truncate them.
// Keyword sentences are kept within a +/-2 window, AND fact-dense sentences (numbers /
// years / percentages / proper nouns) within a +/-1 window even without a keyword hit, so
// numeric or named-entity answers survive narrowing. Block-level tables and markdown
// pipe-tables (>=3 rows) are returned whole — keyword-window narrowing would otherwise
// truncate them.
func NarrowContent(content string, kwds []string) (string, bool) {
if strings.TrimSpace(content) == "" || len(kwds) == 0 {
return "", false
@@ -331,10 +316,9 @@ func NarrowContent(content string, kwds []string) (string, bool) {
return "..." + HighlightKeywords(b.String(), kwds) + "...", true
}
// HighlightKeywords stars keyword occurrences, longest term first so a longer
// keyword is not partially consumed by a shorter one. The marker is a STAR, not
// an XML tag: Python returns `*term*` (text_processing.py:412) and its docstring
// relies on it — a multi-word entity must stay ONE contiguous span
// HighlightKeywords stars keyword occurrences, longest term first so a longer keyword is
// not partially consumed by a shorter one. The marker is a STAR, not an XML tag — a
// multi-word entity must stay ONE contiguous span
// ("*Atlanta Braves*", never "*Atlanta* *Braves*") for the downstream
// entity cross-check. The <em> tags elsewhere in this port are the ENGINE's
// highlight markup (rag/utils/*_conn.py, agentic_search.go), a different layer.
@@ -343,17 +327,16 @@ func HighlightKeywords(text string, kwds []string) string {
return text
}
terms := append([]string(nil), kwds...)
// Python's phrase set (text_processing.py:396): keywords trimmed, lowercased
// and deduplicated. It guards the stem terms added just below.
// The phrase set: keywords trimmed, lowercased and deduplicated. It guards the stem
// terms added just below.
phrases := make(map[string]struct{}, len(kwds))
for _, kw := range kwds {
if p := strings.ToLower(strings.TrimSpace(kw)); p != "" {
phrases[p] = struct{}{}
}
}
// Stem-based highlight terms: a stemmed form that matches in the text is
// wrapped too, mirroring Python _highlight_keywords (which adds stemmed-word
// occurrences so e.g. "nominated" is highlighted for keyword "nominations").
// Stem-based highlight terms: a stemmed form that matches in the text is wrapped too,
// so e.g. "nominated" is highlighted for keyword "nominations".
_, stemmed := keywordForms(kwds)
if len(stemmed) > 0 {
stemSet := make(map[string]bool, 8)
@@ -364,10 +347,9 @@ func HighlightKeywords(text string, kwds []string) string {
}
for _, word := range wordLetterRe.FindAllString(text, -1) {
low := strings.ToLower(word)
// Python adds a stem-matched word only when it is NOT already inside a
// keyword phrase (text_processing.py:405): "nominated" is starred for
// "nominations", while the "Braves" of "Atlanta Braves" is left to the
// phrase's own span instead of being starred on its own elsewhere.
// A stem-matched word is added only when it is NOT already inside a keyword phrase:
// "nominated" is starred for "nominations", while the "Braves" of "Atlanta Braves"
// is left to the phrase's own span instead of being starred on its own elsewhere.
if stemmable(low) && stemSet[stem(low)] && !containedInPhrase(low, phrases) {
terms = append(terms, low)
}
@@ -378,9 +360,9 @@ func HighlightKeywords(text string, kwds []string) string {
// original indexes the folded string at a different position. The loop then
// slices past the end of the folded string (panic: slice bounds out of
// range) or cuts a rune in half and emits invalid UTF-8. Go's case mapping is
// 1:1 per RUNE, so a rune index is valid in both strings. (Python is immune
// for a different reason: re.sub re-emits m.group(0) from the ORIGINAL text
// instead of re-slicing it — text_processing.py:411-412.)
// 1:1 per RUNE, so a rune index is valid in both strings. (A regex-based rewrite is
// immune for a different reason: it re-emits the matched group from the ORIGINAL text
// instead of re-slicing it.)
rs := []rune(text)
lows := []rune(strings.ToLower(text))
if len(lows) != len(rs) {
@@ -389,17 +371,15 @@ func HighlightKeywords(text string, kwds []string) string {
// plain text instead of misaligned spans.
return text
}
// Longest term first, compared by rune count: Python sorts on str length,
// i.e. code points (text_processing.py:396/411).
// Longest term first, compared by rune count — i.e. by code points.
termRunes := make([][]rune, 0, len(terms))
for _, t := range terms {
// Fold the TERM the same way the haystack was folded: the match below
// compares against `lows`, and only the stem-derived terms appended above
// were already lowercase — a caller-supplied "Rocket" or "New York" kept
// its casing and therefore never matched, silently dropping the highlight.
// Python builds its phrase list with `(kw or "").strip().lower()`
// (text_processing.py:396) and additionally compiles with re.IGNORECASE
// (:411), so the trim and the case fold both belong here.
// The phrase list is built with a strip+lower pass and matched case-insensitively, so
// the trim and the case fold both belong here.
if t = strings.ToLower(strings.TrimSpace(t)); t != "" {
termRunes = append(termRunes, []rune(t))
}
@@ -419,7 +399,7 @@ func HighlightKeywords(text string, kwds []string) string {
continue
}
// Emit the ORIGINAL runes, so the highlight keeps the source casing
// (Python re-emits m.group(0)), wrapped in Python's star marker.
// wrapped in the star marker.
b.WriteString("*")
b.WriteString(string(rs[i : i+bestLen]))
b.WriteString("*")
@@ -442,20 +422,18 @@ func runesHavePrefix(hay, needle []rune) bool {
}
// IsFactDenseSentence reports whether a sentence carries a fact-bearing signal: a
// number / year / percentage / magnitude word (_FACT_RE) or a proper noun that is
// not part of an abbreviation run. Mirrors Python _is_fact_dense_sentence
// (text_processing.py:_is_fact_dense_sentence): it keeps only informative sentences when narrowing /
// grepping, so a numeric or entity answer is never dropped just because it lacks
// the query keywords. Unlike the previous port it has NO quoted-span or ≥6-token
// rule and does not treat any bare digit as a fact signal — those widened the
// gate well beyond Python.
// number / year / percentage / magnitude word or a proper noun that is not part of an
// abbreviation run. It keeps only informative sentences when narrowing / grepping, so a
// numeric or entity answer is never dropped just because it lacks the query keywords.
// Deliberately NO quoted-span or ≥6-token rule, and no bare digit counts as a fact
// signal — those widened the gate far beyond the strict definition.
func IsFactDenseSentence(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
// Python: `_FACT_RE.search(sent) or _FACT_RE.search(sent.lower())`. The regex
// is re.IGNORECASE so a single (?i) search is equivalent.
// The regex is case-insensitive, so a single (?i) search covers both the original and
// the lowercased form.
if factPattern.MatchString(s) {
return true
}
@@ -466,22 +444,22 @@ func IsFactDenseSentence(s string) bool {
}
var (
// factPattern mirrors Python _FACT_RE: a number that
// factPattern: a number that
// may carry an ordinal suffix (st/nd/rd/th) or a percent sign and may use
// comma/dot group separators, a 1900-2099 four-digit year, or a magnitude
// word (percent/million/billion/thousand/km/km2/sq km/m above/m). re.IGNORECASE
// makes the words case-insensitive.
factPattern = regexp.MustCompile(`(?i)(?:\d[\d,\.]*(?:st|nd|rd|th)?%?)|(?:19|20)\d{2}|\b(?:percent|percentage|million|billion|thousand|km|km2|sq\s*km|m\s*above|m)\b`)
// properNounPattern mirrors Python _PROPER_NOUN_RE's word pattern: a
// capitalized word of at least three letters. Python wraps it in the negative
// lookbehind (?<![.!?]\.); RE2 has no lookbehind, so that abbreviation guard
// is applied by hasProperNoun instead of in the regex.
// properNounPattern: word pattern: a
// capitalized word of at least three letters, normally wrapped in the negative lookbehind
// (?<![.!?]\.); RE2 has no lookbehind, so that abbreviation guard is applied by
// hasProperNoun instead of in the regex.
properNounPattern = regexp.MustCompile(`\b[A-Z][a-z]{2,}\b`)
)
// hasProperNoun reports whether s contains a capitalized proper noun that is not
// preceded by an abbreviation point. Python's _PROPER_NOUN_RE is
// preceded by an abbreviation point. The pattern is
// (?<![.!?]\.)\b[A-Z][a-z]{2,}\b: the guard rejects a match whose start sits right
// after the two characters "X." where X ∈ {., !, ?} (e.g. a "U.S."-style run).
func hasProperNoun(s string) bool {
@@ -497,27 +475,25 @@ func hasProperNoun(s string) bool {
return false
}
// ---------------------------------------------------------------------------
// Sentence segmentation (Python tools/text_processing.py::_split_sentences).
// Sentence segmentation.
//
// The foundation for every narrowing / highlighting / fact-density step in the
// harness. Two properties matter:
// retrieval. Two properties matter:
//
// 1. terminators (。!?;!?; and a digit-guarded English period) are KEPT on
// their sentence, so "3.14" and "v1.2" do not split;
// 2. block-level HTML elements (table/div/p/ul/li/... — see htmlBlockTags) and
// markdown tables are ATOMIC and never split internally, so a whole table /
// list / block counts as ONE "sentence" for keyword matching (a keyword
// inside one keeps the whole block). This mirrors Python's nesting-aware
// _html_block_spans, not a bare <table> regex.
// inside one keeps the whole block). The scanning is nesting-aware, not a bare
// <table> regex.
//
// Go's RE2 lacks lookbehind, so the digit guard is a manual scan rather than a
// regex assertion.
// ---------------------------------------------------------------------------
// htmlBlockTags are the block-level HTML containers kept atomic during sentence
// splitting. Mirrors Python _HTML_BLOCK_TAGS (inline tags like <b>/<i>/<em> are
// deliberately excluded so ordinary prose still splits).
// splitting. Inline tags like <b>/<i>/<em> are deliberately excluded, so ordinary prose
// still splits.
var htmlBlockTags = map[string]bool{
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true,
"td": true, "th": true, "caption": true, "colgroup": true, "ul": true,
@@ -531,12 +507,12 @@ var htmlBlockTags = map[string]bool{
var htmlTagRe = regexp.MustCompile(`(?i)<(/?)([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>`)
// mdTableRe mirrors Python _MD_TABLE: header row with a pipe, a separator row
// mdTableRe: header row with a pipe, a separator row
// of dashes/colons/pipes, then zero+ body rows with a pipe.
var mdTableRe = regexp.MustCompile("(?m)^[ \t]*\\|?[^\n]*\\|[\n][ \t]*\\|?[ \t]*:?-{1,}:?[ \t]*(?:\\|[ \t]*:?-{1,}:?[ \t]*)+\\|?[ \t]*\r?\n(?:[ \t]*\\|?[^\n]*\\|[^\n]*\r?\n?)*")
// htmlBlockSpans returns outermost balanced block-level HTML element spans
// (nesting-aware) via a tag stack, mirroring Python _html_block_spans.
// (nesting-aware) via a tag stack.
func htmlBlockSpans(text string) [][2]int {
type stackItem struct {
name string
@@ -572,9 +548,8 @@ func htmlBlockSpans(text string) [][2]int {
return spans
}
// protectedSpans returns non-overlapping atomic (start, end) spans in order,
// covering block-level HTML elements and markdown tables, mirroring Python
// _protected_spans (overlaps are unioned).
// protectedSpans returns non-overlapping atomic (start, end) spans in order, covering
// block-level HTML elements and markdown tables (overlaps are unioned).
func protectedSpans(text string) [][2]int {
spans := htmlBlockSpans(text)
for _, m := range mdTableRe.FindAllStringIndex(text, -1) {
@@ -598,7 +573,7 @@ func protectedSpans(text string) [][2]int {
}
// SplitSentences splits text into sentences, treating each block-level HTML
// element and markdown table as one atomic unit. Mirrors Python _split_sentences.
// element and markdown table as one atomic unit.
func SplitSentences(text string) []string {
if text == "" {
return nil
@@ -630,12 +605,11 @@ func SplitSentences(text string) []string {
//
// The split is LOSSLESS: inter-sentence whitespace is kept as the prefix of the
// FOLLOWING sentence (only whitespace-only segments are dropped), so
// `"".join(sents)` reproduces the input. Python's _split_plain has the same
// property and _narrow_content depends on it — it rejoins the kept sentences
// with "" (text_processing.py), and trimming each sentence there would collapse
// a multi-line chunk into one line, which the grep term-window (line-based)
// then matches wholesale instead of line by line. Observed as the grep leg
// keeping an entire trailing paragraph Python drops.
// `"".join(sents)` reproduces the input, and the content narrowing depends on that
// property — it rejoins the kept sentences with "", and trimming each sentence would
// collapse a multi-line chunk into one line, which the grep term-window (line-based) then
// matches wholesale instead of line by line. Observed as the grep leg keeping an entire
// trailing paragraph.
func splitPlainSentences(text string) []string {
rs := []rune(text)
var sents []string
@@ -679,10 +653,9 @@ func isSentTerminator(r rune) bool {
func isASCIIDigit(r rune) bool { return r >= '0' && r <= '9' }
// ---------------------------------------------------------------------------
// Keyword compaction (Python tools/text_processing.py::_compact_keywords).
// Keyword compaction.
//
// Mirrors RAGTools.extract_keywords' post-processing: dedupe (preserving order)
// Post-processing for keyword extraction: dedupe (preserving order)
// and cap the compacted keyword string at compactMaxKeywords terms. The
// extraction prompt asks for 3-10 terms PLUS 2-3 synonyms each, which models
// answer with a 40-60 word redundant synonym run; appending that whole run onto
@@ -690,17 +663,13 @@ func isASCIIDigit(r rune) bool { return r >= '0' && r <= '9' }
// keeps the recall terms but drops the redundancy, so keywords stay a compact
// hint instead of a pollution source.
//
// In Python _compact_keywords lives in harness/tools/text_processing.py; the Go
// port keeps it here for the same reason — it is a text-processing primitive of
// the harness, not an agentic-pipe stage.
// ---------------------------------------------------------------------------
// It lives here because it is a text-processing primitive of the runtime, not an
// agentic-pipe stage.
// compactMaxKeywords caps the compacted keyword string. Mirrors Python
// _compact_keywords(max_terms=15).
// compactMaxKeywords caps the compacted keyword string.
const compactMaxKeywords = 15
// CompactKeywords mirrors Python harness/tools/text_processing.py::_compact_keywords:
// dedupe (preserving order) and cap at compactMaxKeywords.
// CompactKeywords dedupes (preserving order) and caps at compactMaxKeywords.
//
// Accepts both space- and comma-separated input (single-turn extract_keywords
// emits spaces; multi-turn formalize emits commas).
@@ -14,7 +14,7 @@
// limitations under the License.
//
package harness
package runtime
import (
"regexp"
@@ -150,13 +150,12 @@ func TestIsFactDenseSentence(t *testing.T) {
{"A 3.14 version was shipped.", true}, // decimal number
{"The project spans over two million users.", true}, // magnitude word (case-insensitive)
{"Culdcept was released by OmiyaSoft.", true}, // proper noun
// A quoted span or a ≥6-token clause is NOT a fact signal on its own
// (Python _is_fact_dense_sentence has no such rules).
// A quoted span or a ≥6-token clause is NOT a fact signal on its own.
{"He said \"hello\" to the group about the meeting.", false}, // quoted only
{"the cat sat on the mat and looked at the bird outside.", false}, // >=6 tokens only
{"the cat sat", false}, // short, no signal
// Abbreviation guard: a proper noun right after ".", "!" or "?" followed by
// a dot is not counted (Python's (?<![.!?]\.) lookbehind). The lead "It" is
// a dot is not counted (the abbreviation guard). The lead "It" is
// only one lowercase letter so it is not a proper noun either; Atlas is the
// sole candidate and it is excluded.
{"It was wonderful!!..Atlas shrugged", false},
@@ -175,10 +174,8 @@ func TestNarrowByTermsNilInput(t *testing.T) {
}
}
// TestNarrowByKeywordsEmptyReturnsChunks pins the divergence from Python
// _narrow_by_keywords: `if not kwds or not chunks: return chunks`. An empty
// keyword string must return the input chunks unchanged, NOT an empty/nil
// slice that would wipe the whole evidence pool.
// TestNarrowByKeywordsEmptyReturnsChunks: with nothing to narrow on, the input chunks are
// returned unchanged — NOT an empty/nil slice that would wipe the whole evidence pool.
func TestNarrowByKeywordsEmptyReturnsChunks(t *testing.T) {
chunks := []map[string]any{
{"content": "some passage"},
@@ -192,10 +189,10 @@ func TestNarrowByKeywordsEmptyReturnsChunks(t *testing.T) {
}
}
// TestNarrowByKeywordsMirrorsContentOnlyWhenPresent pins the two text-writing
// rules of Python _narrow_by_keywords: content_with_weight is always overwritten
// with the narrowed text, "content" is mirrored ONLY when the chunk already had
// a "content" key, and a pre-narrow "highlight" key is dropped (no longer valid).
// TestNarrowByKeywordsMirrorsContentOnlyWhenPresent pins the two text-writing rules:
// content_with_weight is always overwritten with the narrowed text, "content" is mirrored ONLY
// when the chunk already had a "content" key, and a pre-narrow "highlight" key is dropped (no
// longer valid).
func TestNarrowByKeywordsMirrorsContentOnlyWhenPresent(t *testing.T) {
// Chunk with only content_with_weight (no "content"): narrowing keeps
// content_with_weight, and must NOT invent a "content" key.
@@ -234,8 +231,7 @@ func TestNarrowByKeywordsMirrorsContentOnlyWhenPresent(t *testing.T) {
func TestNarrowByTermsNoMatchKeepsOriginalUntouched(t *testing.T) {
// A long chunk that the grep terms never hit must be returned verbatim
// (full text, no head-truncation), exactly like Python _apply_narrow's
// else branch. This guards against the old bug where unmatched evidence
// (full text, no head-truncation). This guards against the old bug where unmatched evidence
// was clobbered with a head-truncated copy.
long := "The weather was calm and the birds were singing while the river flowed gently past the old stone bridge under a pale morning sky that promised a quiet day for the villagers who had risen early to tend their small gardens and fields. " +
"The old mill stood silent at the edge of the wood where the children used to play among the ferns and the brook that chattered over smooth grey stones all through the long golden afternoons of a summer that nobody wanted to end."
@@ -1,13 +1,12 @@
// Package slots is the VALUE ALGEBRA of a slot table: what a candidate value IS,
// how two values MERGE, and how a table's count is DERIVED.
//
// It exists because the alternative was measured to fail. When a candidate is free
// text, the structure is lost at the boundary — the model writes "约 17-19 人" — and
// every consumer downstream has to GUESS it back: is this a count? a list? are these
// pieces names? Each guess is a rule, the rules interact, and measured (2026-09-16,
// 三国/关羽) the result was a count slot holding "约、人" (the digits rejected as "not
// members", the qualifiers accepted as "members") while fifteen real names sat in
// another slot and the answer reported ten.
// It exists because the alternative fails. When a candidate is free text, the structure is
// lost at the boundary — a count arrives as a sentence — and every consumer downstream has
// to GUESS it back: is this a count? a list? are these pieces names? Each guess is a rule,
// the rules interact, and the result is a count slot holding the words around the number
// (digits rejected as "not members", qualifiers accepted as "members") while the real names
// sit in another slot.
//
// So the structure travels WITH the value: a members list carries its items (each
// with the chunk that proves it), a count carries a number, a range carries two, and
@@ -42,14 +41,14 @@ const (
// and merged by strength, and NEVER parsed, split, counted, or compared
// arithmetically.
KindText
// KindMembers is a list of named members, each optionally carrying the chunk that
// proves it.
KindMembers
// KindItems is a list of ITEMS the corpus states, each optionally carrying the chunk that
// proves it. The element kind is the answer's (names for one question, dates for the next),
// so the type says item and nothing here claims to know which kind this list holds.
KindItems
// KindCount is a claimed number ("13").
KindCount
// KindRange is a claimed interval ("约 17-19"), kept as two numbers rather than as
// a string that someone later tries to parse (measured: "17-19" parsed
// digit-by-digit is 1719).
// KindRange is a claimed interval ("about 17-19"), kept as two numbers rather than as a
// string that someone later tries to parse: parsed digit-by-digit it is 1719.
KindRange
)
@@ -57,7 +56,7 @@ func (k Kind) String() string {
switch k {
case KindText:
return "text"
case KindMembers:
case KindItems:
return "members"
case KindCount:
return "count"
@@ -67,11 +66,11 @@ func (k Kind) String() string {
return "empty"
}
// Member is one named member and the passage that proves it. A member without
// evidence is a member nobody can point at, so the empty ChunkID is allowed but
// visible (renderers show it).
type Member struct {
Name string
// Item is one element of an enumerated answer and the passage that states it: a name for one
// question, a date or a number for the next. The element kind belongs to the answer, not to the
// type, and an empty ChunkID is allowed and visible — an item without one is not counted.
type Item struct {
Value string
ChunkID string
Quote string
}
@@ -79,18 +78,18 @@ type Member struct {
// Value is what one slot holds. Exactly one of the fields matching Kind is set.
type Value struct {
Kind Kind
Text string // KindText (the raw candidate, verbatim)
Items []Member // KindMembers
Count int // KindCount
Lo int // KindRange (inclusive)
Hi int // KindRange (inclusive)
Text string // KindText (the raw candidate, verbatim)
Items []Item // KindItems
Count int // KindCount
Lo int // KindRange (inclusive)
Hi int // KindRange (inclusive)
}
// Text wraps an opaque string.
func Text(s string) Value { return Value{Kind: KindText, Text: s} }
// Members wraps a member list (evidence may be empty per item).
func Members(items ...Member) Value { return Value{Kind: KindMembers, Items: items} }
func Items(items ...Item) Value { return Value{Kind: KindItems, Items: items} }
// Number wraps a claimed count.
func Number(n int) Value { return Value{Kind: KindCount, Count: n} }
@@ -108,21 +107,21 @@ func (v Value) IsZero() bool {
switch v.Kind {
case KindText:
return strings.TrimSpace(v.Text) == ""
case KindMembers:
case KindItems:
return len(v.Items) == 0
}
return v.Kind == KindEmpty
}
// Names is the member names in order, deduped case-insensitively.
func (v Value) Names() []string {
if v.Kind != KindMembers {
// ItemValues is the items' values in order, deduped case-insensitively.
func (v Value) ItemValues() []string {
if v.Kind != KindItems {
return nil
}
seen := make(map[string]bool, len(v.Items))
out := make([]string, 0, len(v.Items))
for _, m := range v.Items {
name := strings.TrimSpace(m.Name)
name := strings.TrimSpace(m.Value)
if name == "" {
continue
}
@@ -136,6 +135,34 @@ func (v Value) Names() []string {
return out
}
// Anchored is the items that carry the chunk stating them: what a count may be derived from and
// what an answer may cite. The test reads ChunkID, not Quote, because a quotation with no passage
// to look it up in cannot be checked.
func (v Value) Anchored() []Item {
return v.itemsWhere(func(it Item) bool { return strings.TrimSpace(it.ChunkID) != "" })
}
// Unanchored is the complement of Anchored: the items asserted with no passage in hand. They are
// not deleted — the record lists them beside the number — but they are not counted.
func (v Value) Unanchored() []Item {
return v.itemsWhere(func(it Item) bool { return strings.TrimSpace(it.ChunkID) == "" })
}
// itemsWhere filters the items by a property of the item. A value that is not a list has no
// items, so the filters answer nothing rather than inventing a list out of text.
func (v Value) itemsWhere(keep func(Item) bool) []Item {
if v.Kind != KindItems {
return nil
}
out := make([]Item, 0, len(v.Items))
for _, it := range v.Items {
if keep(it) {
out = append(out, it)
}
}
return out
}
// Number is the number a value claims: a count's number, a range's UPPER bound (the
// claim it makes about how many there could be). Text and members claim no number —
// that is the point of typing them.
@@ -156,8 +183,8 @@ func Render(v Value) string {
switch v.Kind {
case KindText:
return v.Text
case KindMembers:
names := v.Names()
case KindItems:
names := v.ItemValues()
return strings.Join(names, "、")
case KindCount:
return strconv.Itoa(v.Count)
@@ -182,16 +209,15 @@ func Union(a, b Value) (union, dropped Value, ok bool) {
return Value{}, Value{}, false
}
switch {
case a.Kind == KindMembers && b.Kind == KindMembers:
merged, added := mergeMembers(a.Items, b.Items)
if !added {
// A subset union is still a resolution: the union IS the base.
return Value{Kind: KindMembers, Items: merged}, Value{}, true
}
return Value{Kind: KindMembers, Items: merged}, Value{}, true
case a.Kind == KindMembers && isNumeric(b.Kind):
case a.Kind == KindItems && b.Kind == KindItems:
// Two member lists never produce a LOSER: the union is the resolution, and a
// subset union (this side added nothing) is a resolution too — the base is
// already the answer, so nothing is reported as dropped.
merged, _ := mergeItems(a.Items, b.Items)
return Value{Kind: KindItems, Items: merged}, Value{}, true
case a.Kind == KindItems && isNumeric(b.Kind):
return a, b, true
case b.Kind == KindMembers && isNumeric(a.Kind):
case b.Kind == KindItems && isNumeric(a.Kind):
return b, a, true
case isNumeric(a.Kind) && isNumeric(b.Kind):
an, _ := a.Number()
@@ -199,19 +225,25 @@ func Union(a, b Value) (union, dropped Value, ok bool) {
if bn > an {
return b, a, true
}
return a, b, true
if bn < an {
return a, b, true
}
// Equal claims: there is no loser to keep. Recording one put "alternate
// (claimed by another session, not adopted): 13" beside a slot saying 13 —
// a dropped claim identical to the one that won.
return a, Value{}, true
}
return Value{}, Value{}, false
}
func isNumeric(k Kind) bool { return k == KindCount || k == KindRange }
// mergeMembers unions two member lists by name, first evidence wins.
func mergeMembers(base, branch []Member) ([]Member, bool) {
out := make([]Member, 0, len(base)+len(branch))
// mergeItems unions two member lists by name, first evidence wins.
func mergeItems(base, branch []Item) ([]Item, bool) {
out := make([]Item, 0, len(base)+len(branch))
seen := make(map[string]int, len(base)+len(branch))
for _, m := range base {
name := strings.TrimSpace(m.Name)
name := strings.TrimSpace(m.Value)
if name == "" {
continue
}
@@ -220,11 +252,11 @@ func mergeMembers(base, branch []Member) ([]Member, bool) {
continue
}
seen[key] = len(out)
out = append(out, Member{Name: name, ChunkID: m.ChunkID, Quote: m.Quote})
out = append(out, Item{Value: name, ChunkID: m.ChunkID, Quote: m.Quote})
}
added := false
for _, m := range branch {
name := strings.TrimSpace(m.Name)
name := strings.TrimSpace(m.Value)
if name == "" {
continue
}
@@ -238,7 +270,7 @@ func mergeMembers(base, branch []Member) ([]Member, bool) {
continue
}
seen[key] = len(out)
out = append(out, Member{Name: name, ChunkID: m.ChunkID, Quote: m.Quote})
out = append(out, Item{Value: name, ChunkID: m.ChunkID, Quote: m.Quote})
added = true
}
return out, added
@@ -264,7 +296,7 @@ func Parse(patch map[string]any) Value {
// Declared a list and gave none: the text (if any) is opaque, not a list.
return Text(candidateText(patch))
}
return Value{Kind: KindMembers, Items: items}
return Value{Kind: KindItems, Items: items}
case "count":
if n, ok := asInt(patch["count"]); ok {
return Number(n)
@@ -288,7 +320,7 @@ func Parse(patch map[string]any) Value {
}
// candidateText reads the legacy free-text field. A null candidate is an empty value
// (Python's "set candidate null" — an eliminated claim).
// (a null candidate — an eliminated claim).
func candidateText(patch map[string]any) string {
raw, ok := patch["candidate"]
if !ok || raw == nil {
@@ -300,27 +332,27 @@ func candidateText(patch map[string]any) string {
return strings.TrimSpace(asString(raw))
}
func parseItems(raw any) []Member {
func parseItems(raw any) []Item {
list, _ := raw.([]any)
out := make([]Member, 0, len(list))
out := make([]Item, 0, len(list))
for _, item := range list {
switch it := item.(type) {
case string:
if name := strings.TrimSpace(it); name != "" {
out = append(out, Member{Name: name})
out = append(out, Item{Value: name})
}
case map[string]any:
name := strings.TrimSpace(asString(it["name"]))
if name == "" {
// Tolerate {"candidate": …} / {"value": …} spellings, but a missing
// name is not a member: skip rather than invent one.
// value is not an item: skip rather than invent one.
name = strings.TrimSpace(asString(it["candidate"]))
}
if name == "" {
continue
}
out = append(out, Member{
Name: name,
out = append(out, Item{
Value: name,
ChunkID: strings.TrimSpace(asString(it["chunk_id"])),
Quote: strings.TrimSpace(asString(it["quote"])),
})
@@ -11,17 +11,17 @@ import "testing"
// fifteen real names sat in the list slot and the answer reported ten.
func TestUnionKeepsTheInvariants(t *testing.T) {
// I2 — members union by name, first evidence wins.
a := Members(Member{Name: "华雄", ChunkID: "c1"}, Member{Name: "颜良"})
b := Members(Member{Name: "颜良", ChunkID: "c2"}, Member{Name: "管亥", ChunkID: "c3"})
a := Items(Item{Value: "华雄", ChunkID: "c1"}, Item{Value: "颜良"})
b := Items(Item{Value: "颜良", ChunkID: "c2"}, Item{Value: "管亥", ChunkID: "c3"})
got, dropped, ok := Union(a, b)
if !ok || len(got.Names()) != 3 {
if !ok || len(got.ItemValues()) != 3 {
t.Fatalf("members ∪ members = %v (ok=%v), want three names", got, ok)
}
if !dropped.IsZero() {
t.Errorf("dropped = %v, want nothing: a member list does not lose members to another list", dropped)
}
for _, m := range got.Items {
switch m.Name {
switch m.Value {
case "华雄":
if m.ChunkID != "c1" {
t.Errorf("华雄 = %v, want its own evidence kept", m)
@@ -36,14 +36,14 @@ func TestUnionKeepsTheInvariants(t *testing.T) {
}
}
// A member that arrived without evidence takes the one a later list supplies.
withEvidence, _, _ := Union(Members(Member{Name: "庞德"}), Members(Member{Name: "庞德", ChunkID: "c9"}))
withEvidence, _, _ := Union(Items(Item{Value: "庞德"}), Items(Item{Value: "庞德", ChunkID: "c9"}))
if withEvidence.Items[0].ChunkID != "c9" {
t.Errorf("庞德 = %v, want the evidence a later list supplied", withEvidence.Items[0])
}
// I3 — members beat a number, and the number is kept as the dropped claim.
union, dropped, ok := Union(a, Number(2))
if !ok || union.Kind != KindMembers {
if !ok || union.Kind != KindItems {
t.Fatalf("members ∪ count = %v (ok=%v), want the members", union, ok)
}
if dropped.Kind != KindCount || dropped.Count != 2 {
@@ -51,7 +51,7 @@ func TestUnionKeepsTheInvariants(t *testing.T) {
}
// …in either order.
union, dropped, ok = Union(Number(2), a)
if !ok || union.Kind != KindMembers || dropped.Count != 2 {
if !ok || union.Kind != KindItems || dropped.Count != 2 {
t.Errorf("count ∪ members = %v / %v (ok=%v), want the members with the count dropped", union, dropped, ok)
}
@@ -78,7 +78,7 @@ func TestUnionKeepsTheInvariants(t *testing.T) {
if _, _, ok := Union(Text("13"), Number(11)); ok {
t.Error("text ∪ count must not resolve: text is opaque and claims nothing")
}
if _, _, ok := Union(Members(Member{Name: "华雄"}), Text("华雄")); ok {
if _, _, ok := Union(Items(Item{Value: "华雄"}), Text("华雄")); ok {
t.Error("a member list and a text value must not resolve by union")
}
}
@@ -95,7 +95,7 @@ func TestParseIsFailClosed(t *testing.T) {
map[string]any{"quote": "no name here"}, // skipped, not invented
},
})
if members.Kind != KindMembers || len(members.Names()) != 2 {
if members.Kind != KindItems || len(members.ItemValues()) != 2 {
t.Fatalf("members patch = %v, want two declared members", members)
}
if members.Items[0].ChunkID != "c1" {
@@ -111,7 +111,7 @@ func TestParseIsFailClosed(t *testing.T) {
if _, ok := opaque.Number(); ok {
t.Error("text must claim no number: that is what stops 17-19 from being read as 1719")
}
if names := opaque.Names(); names != nil {
if names := opaque.ItemValues(); names != nil {
t.Errorf("text names = %v, want none", names)
}
@@ -142,7 +142,7 @@ func TestRenderIsDerivedFromTheType(t *testing.T) {
in Value
want string
}{
{Members(Member{Name: "华雄"}, Member{Name: "颜良"}), "华雄、颜良"},
{Items(Item{Value: "华雄"}, Item{Value: "颜良"}), "华雄、颜良"},
{Number(13), "13"},
{Interval(17, 19), "17-19"},
{Interval(5, 5), "5"},
+4
View File
@@ -21,6 +21,10 @@ Based on the provided document or chat history, add citations to the input text
6. **Direct attributions**: What someone said, did, or believes
7. **Predictions/forecasts**: Future projections, trend analyses
8. **Controversial claims**: Disputed facts, minority opinions
9. **List/enumeration answers**: When the answer lists several things (people, places, dates,
events), cite EACH element with the passage that states it — one citation per element, never
one citation for the whole list. If you cannot cite an element, say which one you cannot;
never write that "some of the list" is supported.
## What Should NOT Be Cited:
- Common knowledge (e.g., "The sun rises in the east")
+3
View File
@@ -150,6 +150,9 @@ func kbpBlock(c map[string]any, id int) (string, bool) {
return "", false
}
block := fmt.Sprintf("\nID: %d", id)
if cid, _ := c["chunk_id"].(string); strings.TrimSpace(cid) != "" {
block += "\n├── chunk_id: " + strings.TrimSpace(cid)
}
if title := chunkTitle(c); title != "" {
block += "\n├── Title: " + flattenNewlines(title)
}
+1 -1
View File
@@ -302,7 +302,7 @@ upload/parse docs from test/benchmark/test_docs, run the benchmark, and clean up
The both script runs retrieval then chat on the same dataset, then deletes it.
- Make sure to run ```uv sync --python 3.13 --group test ``` before running the commands.
- It is also necessary to run these commands prior to initializing your containers if you plan on using the built-in embedded model: ```echo -e "TEI_MODEL=BAAI/bge-small-en-v1.5" >> docker/.env```
- It is also necessary to run these commands prior to initializing your containers if you plan on using the built-in embedded model: ```echo -e "TEI_MODEL=BAAI/bge-small-en-v1.5" >> docker/.env```
and ```echo -e "COMPOSE_PROFILES=\${COMPOSE_PROFILES},tei-cpu" >> docker/.env```
Chat only: