mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-24 17:10:12 +08:00
Implement two-level research loop, sufficiency ladder, AutoRater, grounded review, pipeline, inspector tools, and graph exploration for high/ultra agentic search modes.
583 lines
21 KiB
Go
583 lines
21 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package harness
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
|
|
einotool "github.com/cloudwego/eino/components/tool"
|
|
|
|
"gorm.io/gorm"
|
|
"ragflow/internal/agent/tool"
|
|
"ragflow/internal/common"
|
|
"ragflow/internal/dao"
|
|
"ragflow/internal/entity"
|
|
modelModule "ragflow/internal/entity/models"
|
|
"ragflow/internal/service"
|
|
"ragflow/internal/service/nav"
|
|
"ragflow/internal/service/wikisearch"
|
|
)
|
|
|
|
// ProductionRunner wires the real agentic-search tools (hybrid_search,
|
|
// dataset_navigation_by_tree, wiki_query) into the RunAgenticRAG flow, so the
|
|
// tools are actually invoked rather than merely registered. This is the
|
|
// production counterpart to the unit-testable SearchFn seam.
|
|
type ProductionRunner struct {
|
|
db *gorm.DB
|
|
tenantID string
|
|
datasetIDs []string
|
|
searchTool einotool.InvokableTool
|
|
navSvc nav.NavService // defaults to nav.GetNavService() when nil
|
|
wikiSvc wikisearch.Service
|
|
// webTool is an optional, already-configured web search tool. When nil the
|
|
// runner never exposes web fallback (P8: no web provider configured => the
|
|
// agent does not attempt web search and no failing tool call is made).
|
|
webTool einotool.InvokableTool
|
|
// sqlKBs are the tabular (structured) KBs — those whose parser_config carries
|
|
// a field_map. structured_query only runs over these (mirrors RAGTools.sql_kbs).
|
|
sqlKBs []*entity.Knowledgebase
|
|
// fieldMap is the merged field_map across the tabular KBs (mirrors
|
|
// RAGTools.field_map).
|
|
fieldMap map[string]interface{}
|
|
// chatPipeline drives structured_query (useSQL). Lazily constructed, guarded
|
|
// by chatPipelineOnce against concurrent runSQLTool calls.
|
|
chatPipeline *service.ChatPipelineService
|
|
chatPipelineOnce sync.Once
|
|
}
|
|
|
|
// NewProductionRunner builds a ProductionRunner backed by the real tools. The
|
|
// dataset-nav router (harness.NavigateDatasetByTree) resolves its NavService
|
|
// lazily via nav.GetNavService(). When a web provider is configured (a Tavily
|
|
// API key is present), the runner also wires the web fallback tool so
|
|
// high/ultra modes can fill an empty KB result from the web; otherwise no web
|
|
// tool is attached and no web call is ever attempted (P8/R2).
|
|
func NewProductionRunner(ctx context.Context, db *gorm.DB, tenantID string, datasetIDs []string) (*ProductionRunner, error) {
|
|
searchBase, err := tool.BuildByName("hybrid_search", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
search, ok := searchBase.(einotool.InvokableTool)
|
|
if !ok {
|
|
return nil, fmt.Errorf("hybrid_search is not invokable")
|
|
}
|
|
r := &ProductionRunner{db: db, tenantID: tenantID, datasetIDs: datasetIDs, searchTool: search}
|
|
if common.GetEnv(common.EnvTavilyAPIKey) != "" {
|
|
r.webTool = tool.NewTavilyTool()
|
|
}
|
|
// Partition the bound KBs into tabular (field_map-bearing) vs general,
|
|
// mirroring Python RAGTools._exclude_sql_kb. Runs under the caller's context
|
|
// so request deadlines/cancellation propagate to the lookup.
|
|
r.loadSQLKBs(ctx)
|
|
return r, nil
|
|
}
|
|
|
|
// loadSQLKBs partitions the bound KBs into tabular (structured_query) vs general
|
|
// and merges their field_map, mirroring Python RAGTools._exclude_sql_kb.
|
|
func (r *ProductionRunner) loadSQLKBs(ctx context.Context) {
|
|
if r == nil || r.db == nil {
|
|
return
|
|
}
|
|
kbs, err := dao.NewKnowledgebaseDAO().GetByIDs(ctx, r.db, r.datasetIDs)
|
|
if err != nil {
|
|
// Distinguish a database failure from "no tabular KBs": structured_query
|
|
// silently reporting no tabular KBs would otherwise mask this.
|
|
log.Printf("agentic_rag: loadSQLKBs lookup failed for %v: %v", r.datasetIDs, err)
|
|
return
|
|
}
|
|
r.fieldMap = map[string]interface{}{}
|
|
for _, kb := range kbs {
|
|
if kb == nil {
|
|
continue
|
|
}
|
|
if fm, ok := kb.ParserConfig["field_map"].(map[string]interface{}); ok && len(fm) > 0 {
|
|
for k, v := range fm {
|
|
r.fieldMap[k] = v
|
|
}
|
|
r.sqlKBs = append(r.sqlKBs, kb)
|
|
}
|
|
}
|
|
}
|
|
|
|
// runSQLTool runs structured_query: translate the query to SQL over the tabular
|
|
// KBs and return the answer + referenced chunks. Returns empty when there are no
|
|
// tabular KBs or no chat model is configured (mirrors Python structured_query's
|
|
// sql_kbs guard).
|
|
func (r *ProductionRunner) runSQLTool(ctx context.Context, args map[string]interface{}) ToolResult {
|
|
if r == nil || len(r.sqlKBs) == 0 || len(r.fieldMap) == 0 {
|
|
return ToolResult{}
|
|
}
|
|
query := stringValue(args["query"])
|
|
if strings.TrimSpace(query) == "" {
|
|
return ToolResult{}
|
|
}
|
|
// Resolve the tenant's default chat model (empty llmID → default).
|
|
driver, modelName, apiConfig, _, err := service.NewModelProviderService().GetChatModelConfig(ctx, r.tenantID, "")
|
|
if err != nil {
|
|
return ToolResult{}
|
|
}
|
|
chatModel := modelModule.NewChatModel(driver, &modelName, apiConfig)
|
|
r.chatPipelineOnce.Do(func() {
|
|
r.chatPipeline = service.NewChatPipelineService()
|
|
})
|
|
ans, err := r.chatPipeline.StructuredQuery(ctx, &entity.Chat{TenantID: r.tenantID}, r.sqlKBs, query, chatModel, r.fieldMap)
|
|
if err != nil || ans == nil {
|
|
return ToolResult{}
|
|
}
|
|
answer := stringValue(ans["answer"])
|
|
ref, _ := ans["reference"].(map[string]interface{})
|
|
var chunks []map[string]interface{}
|
|
if ref != nil {
|
|
if c, ok := ref["chunks"].([]map[string]interface{}); ok {
|
|
chunks = c
|
|
}
|
|
}
|
|
return ToolResult{Answer: answer, Chunks: chunks}
|
|
}
|
|
|
|
// newProductionRunnerWithTools builds a ProductionRunner with an injected
|
|
// search tool and nav service, for unit/E2E tests that want to fake the
|
|
// invocation surface without real services.
|
|
func newProductionRunnerWithTools(db *gorm.DB, tenantID string, datasetIDs []string, searchTool einotool.InvokableTool, navSvc nav.NavService) *ProductionRunner {
|
|
return &ProductionRunner{db: db, tenantID: tenantID, datasetIDs: datasetIDs, searchTool: searchTool, navSvc: navSvc}
|
|
}
|
|
|
|
// Run executes the agentic-search graph with the real tools. It computes the
|
|
// route once and uses it to pick a search strategy: when the route suggests a
|
|
// wiki compilation and the bound KBs actually carry wiki artifacts, the runner
|
|
// tries wiki_query first and falls back to general hybrid search on an empty
|
|
// result; otherwise it uses hybrid search. Web fallback is only reachable when a
|
|
// web provider is configured (P8). Returns the final answer.
|
|
func (r *ProductionRunner) Run(ctx context.Context, question, keywords, modeLabel string) AnswerResult {
|
|
if r.searchTool == nil {
|
|
log.Printf("agentic_rag: production runner not fully wired (search tool missing)")
|
|
return AnswerResult{FinalAnswer: emptyResultMessage, Empty: true}
|
|
}
|
|
route := RouteNode(ctx, r.db, question, modeLabel)
|
|
|
|
// Base hybrid search, optionally scoped by the nav router for decomposition
|
|
// modes.
|
|
searchFn := r.hybridSearchFn(ctx, question, keywords, modeLabel)
|
|
|
|
// P8/R4: web fallback is phase-gated — only wired for modes whose
|
|
// AvailableTools actually include web_search (high/ultra), AND only when a
|
|
// web provider is configured. Low/medium never trigger external web requests
|
|
// from an empty KB result. Unconfigured => no web tool call is ever attempted.
|
|
if modeAllowsWeb(modeLabel) {
|
|
searchFn = r.webFallbackFn(searchFn)
|
|
}
|
|
|
|
// P5: prefer wiki when the route suggests it AND the bound KBs carry the
|
|
// artifact; fall back to hybrid on empty/absent wiki results.
|
|
if route.SuggestsCompilation == "wiki" && r.wikiAvailable(ctx) {
|
|
searchFn = r.wikiPreferredSearchFn(searchFn)
|
|
}
|
|
|
|
// high/ultra (agentic_research / deep_research) drive the two-level research
|
|
// loop through the Pipeline (real tools + compilation gating + doc routing),
|
|
// not the SearchFn closure used by low/medium. This is the P5 strategy
|
|
// dispatch: high/ultra is a strict superset of medium.
|
|
if route.ExecutionStrategy == "agentic_research" || route.ExecutionStrategy == "deep_research" {
|
|
return r.runAgentic(ctx, question, keywords, modeLabel, route)
|
|
}
|
|
return RunAgenticRAGWithRoute(ctx, r.db, question, keywords, modeLabel, route, searchFn)
|
|
}
|
|
|
|
// runAgentic drives the high/ultra two-level loop over a Pipeline. It shares the
|
|
// same route/planner as RunAgenticRAGWithRoute but researches claims via the
|
|
// research agent (inner tool loop) rather than a single hybrid search.
|
|
func (r *ProductionRunner) runAgentic(ctx context.Context, question, keywords, modeLabel string, route RouteDecision) AnswerResult {
|
|
mode, _ := GetMode(modeLabel)
|
|
if mode.Label == "" {
|
|
mode = THINKING_MODES["high"]
|
|
}
|
|
|
|
kbinfos := &Kbinfos{}
|
|
// pre_search grounds the planner (same as the medium path).
|
|
chunks, aggs := r.hybridSearchFn(ctx, question, keywords, modeLabel)(ctx, question, keywords)
|
|
seed := extractChunkTexts(chunks)
|
|
kbinfos.Merge(chunks, aggs)
|
|
|
|
plan := PlannerNode(ctx, r.db, route, seed)
|
|
claims := make([]*ClaimTarget, len(plan.Claims))
|
|
for i := range plan.Claims {
|
|
claims[i] = &plan.Claims[i]
|
|
}
|
|
|
|
compilation := buildCompilationMap(ctx, r.db, r.tenantID, r.datasetIDs)
|
|
pipeline := NewPipeline(r.db, r.tenantID, r.datasetIDs, r, kbinfos, compilation)
|
|
|
|
orch := AgenticResearch(ctx, r.db, pipeline, question, claims, mode)
|
|
|
|
return FormalizeAnswer(ctx, r.db, question, orch.Kbinfos, orch.PartialAnswer, orch.Abstain, orch.EmptyResult, orch.Caveat, orch.ForceLLM)
|
|
}
|
|
|
|
// buildCompilationMap reports which compiled artifacts each bound KB carries,
|
|
// so the Pipeline can gate compilation-requiring tools (ontology/mindmap/graph/
|
|
// wiki). It mirrors Python _get_compilation_map (parser_config toggles + dataset
|
|
// nav rows). A nil/empty map disables gating (all tools pass through).
|
|
|
|
// modeAllowsWeb reports whether the mode's AvailableTools include web_search, so
|
|
// web fallback is only reachable in the modes that are supposed to have it
|
|
// (high/ultra). Unknown modes are treated as not allowing web.
|
|
func modeAllowsWeb(modeLabel string) bool {
|
|
mode, ok := GetMode(modeLabel)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, name := range mode.AvailableTools {
|
|
if name == "web_search" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hybridSearchFn builds the base hybrid search closure (optionally doc-scoped
|
|
// for decomposition modes).
|
|
func (r *ProductionRunner) hybridSearchFn(ctx context.Context, question, keywords, modeLabel string) SearchFn {
|
|
searchFn := func(ctx context.Context, query, kws string) ([]map[string]interface{}, []map[string]interface{}) {
|
|
return r.search(ctx, query, kws, nil)
|
|
}
|
|
if mode, _ := GetMode(modeLabel); mode.RequiresDecomposition {
|
|
docs := r.routeDocs(ctx, question, keywords)
|
|
if len(docs) > 0 {
|
|
searchFn = func(ctx context.Context, query, kws string) ([]map[string]interface{}, []map[string]interface{}) {
|
|
return r.search(ctx, query, kws, docs)
|
|
}
|
|
}
|
|
}
|
|
return searchFn
|
|
}
|
|
|
|
// wikiPreferredSearchFn wraps the hybrid searchFn so that each search first asks
|
|
// the compiled wiki for the query and only falls back to hybrid when the wiki
|
|
// returns nothing (or the wiki backend is unavailable). This is the P5 route
|
|
// consumption: a wiki suggestion selects the wiki path without discarding the
|
|
// hybrid fallback.
|
|
func (r *ProductionRunner) wikiPreferredSearchFn(hybrid SearchFn) SearchFn {
|
|
return func(ctx context.Context, query, kws string) ([]map[string]interface{}, []map[string]interface{}) {
|
|
chunks, aggs := r.wikiSearch(ctx, query, kws)
|
|
if len(chunks) > 0 {
|
|
return chunks, aggs
|
|
}
|
|
return hybrid(ctx, query, kws)
|
|
}
|
|
}
|
|
|
|
// wikiAvailable reports whether the bound datasets carry searchable wiki
|
|
// artifacts, so the runner only selects the wiki path when it can actually serve.
|
|
func (r *ProductionRunner) wikiAvailable(ctx context.Context) bool {
|
|
ws := r.wikiSvc
|
|
if ws == nil {
|
|
ws = wikisearch.GetService()
|
|
}
|
|
if ws == nil {
|
|
return false
|
|
}
|
|
return ws.AvailableFor(ctx, r.tenantID, r.datasetIDs)
|
|
}
|
|
|
|
// wikiSearch invokes the wiki_query tool against the compiled wiki. It returns
|
|
// empty chunks (never a hard error) when the service is unavailable or yields
|
|
// nothing, so the caller falls back to hybrid search.
|
|
func (r *ProductionRunner) wikiSearch(ctx context.Context, query, keywords string) ([]map[string]interface{}, []map[string]interface{}) {
|
|
ws := r.wikiSvc
|
|
if ws == nil {
|
|
ws = wikisearch.GetService()
|
|
}
|
|
if ws == nil || !ws.AvailableFor(ctx, r.tenantID, r.datasetIDs) {
|
|
return nil, nil
|
|
}
|
|
res, err := ws.QueryPages(ctx, r.tenantID, r.datasetIDs, query, keywords, 12)
|
|
if err != nil || len(res.Chunks) == 0 {
|
|
return nil, nil
|
|
}
|
|
// P7: backfill the original source chunks referenced by the compiled page
|
|
// hits, deduped and bounded, so the answer can cite raw evidence.
|
|
return r.expandCompiledEvidence(ctx, res.Chunks, res.DocAggs)
|
|
}
|
|
|
|
// search invokes the hybrid_search tool and normalizes its chunk output.
|
|
func (r *ProductionRunner) search(ctx context.Context, query, keywords string, docScope []string) ([]map[string]interface{}, []map[string]interface{}) {
|
|
args := map[string]interface{}{"query": query, "keywords": keywords, "kb_ids": r.datasetIDs, "top_n": 12}
|
|
if len(docScope) > 0 {
|
|
args["doc_scope"] = docScope
|
|
}
|
|
raw, err := r.searchTool.InvokableRun(ctx, mustJSON(args))
|
|
if err != nil {
|
|
log.Printf("agentic_rag: hybrid_search failed: %v", err)
|
|
return nil, nil
|
|
}
|
|
var res struct {
|
|
Chunks []map[string]interface{} `json:"chunks"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &res); err != nil {
|
|
return nil, nil
|
|
}
|
|
return res.Chunks, nil
|
|
}
|
|
|
|
// expandCompiledEvidence backfills the ORIGINAL source chunks a compiled-page
|
|
// hit was built from (P7/R3). It collects the page hits' source_chunk_ids
|
|
// (bounded per page and in total), then asks the concrete wiki service to fetch
|
|
// them BY ID — scoped to the tenant + datasets — so the answer can cite raw
|
|
// evidence. When the page hits carry no source ids, the service is unavailable,
|
|
// or none of the ids resolve, the page results are kept as-is (safe degradation;
|
|
// nothing is fabricated).
|
|
func (r *ProductionRunner) expandCompiledEvidence(ctx context.Context, chunks, aggs []map[string]interface{}) ([]map[string]interface{}, []map[string]interface{}) {
|
|
if len(chunks) == 0 {
|
|
return chunks, aggs
|
|
}
|
|
ws := r.wikiSvc
|
|
if ws == nil {
|
|
ws = wikisearch.GetService()
|
|
}
|
|
if ws == nil {
|
|
return chunks, aggs
|
|
}
|
|
const maxEvidencePerPage = 4
|
|
const maxEvidenceTotal = 12
|
|
|
|
// Collect bounded source-chunk ids from the page hits (deduped, in page
|
|
// order), grouped by dataset so the backfill stays within each KB's scope.
|
|
var sourceIDs []string
|
|
seen := map[string]bool{}
|
|
datasets := map[string]bool{}
|
|
for _, c := range chunks {
|
|
if len(sourceIDs) >= maxEvidenceTotal {
|
|
break
|
|
}
|
|
ids := stringSlice(c["source_chunk_ids"])
|
|
count := 0
|
|
for _, id := range ids {
|
|
if count >= maxEvidencePerPage {
|
|
break
|
|
}
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
count++
|
|
sourceIDs = append(sourceIDs, id)
|
|
if ds := stringValue(c["dataset_id"]); ds != "" {
|
|
datasets[ds] = true
|
|
}
|
|
if len(sourceIDs) >= maxEvidenceTotal {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(sourceIDs) == 0 {
|
|
return chunks, aggs
|
|
}
|
|
// Scope the backfill to the page hits' datasets (fall back to all bound
|
|
// datasets when the page hits carry none). Build a fresh slice: never mutate
|
|
// r.datasetIDs.
|
|
scope := make([]string, 0, len(r.datasetIDs))
|
|
if len(datasets) == 0 {
|
|
scope = append(scope, r.datasetIDs...)
|
|
} else {
|
|
for ds := range datasets {
|
|
scope = append(scope, ds)
|
|
}
|
|
}
|
|
evidence, err := ws.BackfillChunks(ctx, r.tenantID, scope, sourceIDs)
|
|
if err != nil || len(evidence) == 0 {
|
|
return chunks, aggs
|
|
}
|
|
|
|
// Stable merge: page results first (in retrieval order), then the backfilled
|
|
// evidence, deduped by chunk key.
|
|
merged := append([]map[string]interface{}(nil), chunks...)
|
|
keys := map[string]bool{}
|
|
for _, c := range chunks {
|
|
if k := chunkKey(c); k != "" {
|
|
keys[k] = true
|
|
}
|
|
}
|
|
for _, e := range evidence {
|
|
k := chunkKey(e)
|
|
if k != "" && !keys[k] {
|
|
keys[k] = true
|
|
merged = append(merged, e)
|
|
}
|
|
}
|
|
// Doc aggs: union the page doc aggs with the evidence docs.
|
|
dseen := map[string]bool{}
|
|
for _, d := range aggs {
|
|
if id, _ := d["doc_id"].(string); id != "" {
|
|
dseen[id] = true
|
|
}
|
|
}
|
|
for _, e := range evidence {
|
|
id := stringValue(e["doc_id"])
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if !dseen[id] {
|
|
dseen[id] = true
|
|
aggs = append(aggs, map[string]interface{}{"doc_id": id, "doc_name": stringValue(e["docnm_kwd"])})
|
|
}
|
|
}
|
|
return merged, aggs
|
|
}
|
|
|
|
// webFallbackFn wraps a SearchFn so that, when the KB search returns nothing, a
|
|
// configured web provider is invoked to fill the gap (P8). It is only used when
|
|
// webTool is non-nil; otherwise it returns the hybrid path unchanged and no web
|
|
// tool call is ever attempted (no failing call when unconfigured).
|
|
func (r *ProductionRunner) webFallbackFn(hybrid SearchFn) SearchFn {
|
|
if r.webTool == nil {
|
|
return hybrid
|
|
}
|
|
return func(ctx context.Context, query, kws string) ([]map[string]interface{}, []map[string]interface{}) {
|
|
chunks, aggs := hybrid(ctx, query, kws)
|
|
if len(chunks) > 0 {
|
|
return chunks, aggs
|
|
}
|
|
raw, err := r.webTool.InvokableRun(ctx, mustJSON(map[string]interface{}{"query": query, "keywords": kws}))
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
out := normalizeWebResults([]byte(raw))
|
|
if len(out) == 0 {
|
|
return nil, nil
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
// normalizeWebResults parses the web provider's raw JSON and normalizes it into
|
|
// the same agentic evidence shape as KB chunks. Accepts both the agent "chunks"
|
|
// envelope and the Tavily "results" envelope (tavily.go returns
|
|
// {"results":[...]}); each result contributes content + a doc_id reference so
|
|
// the answer can retain the source URL.
|
|
func normalizeWebResults(raw []byte) []map[string]interface{} {
|
|
var res struct {
|
|
Chunks []map[string]interface{} `json:"chunks"`
|
|
Results []map[string]interface{} `json:"results"`
|
|
}
|
|
if err := json.Unmarshal(raw, &res); err != nil {
|
|
return nil
|
|
}
|
|
src := res.Chunks
|
|
if len(src) == 0 {
|
|
src = res.Results
|
|
}
|
|
out := make([]map[string]interface{}, 0, len(src))
|
|
for i, c := range src {
|
|
url := firstNonEmpty(stringValue(c["url"]), stringValue(c["link"]), stringValue(c["source"]))
|
|
if url == "" {
|
|
continue
|
|
}
|
|
content := firstNonEmpty(stringValue(c["content"]), stringValue(c["raw_content"]), stringValue(c["text"]))
|
|
if content == "" {
|
|
continue
|
|
}
|
|
docID := stringValue(c["doc_id"])
|
|
if docID == "" {
|
|
docID = url
|
|
}
|
|
// doc_id stays the source URL; chunk_id must be UNIQUE per snippet so
|
|
// Kbinfos.Merge (dedup by chunkKey) does not collapse several snippets
|
|
// from the same URL, or the same URL across two retrieval rounds.
|
|
chunkID := fmt.Sprintf("%s#%d", docID, i)
|
|
out = append(out, map[string]interface{}{
|
|
"chunk_id": chunkID, "content_with_weight": content,
|
|
"doc_id": docID, "docnm_kwd": firstNonEmpty(stringValue(c["title"]), stringValue(c["source"])),
|
|
"dataset_id": stringValue(c["dataset_id"]), "url": url, "source": "web",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func stringValue(v interface{}) string {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstNonEmpty(ss ...string) string {
|
|
for _, s := range ss {
|
|
if strings.TrimSpace(s) != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func stringSlice(v interface{}) []string {
|
|
if raw, ok := v.([]string); ok {
|
|
return raw
|
|
}
|
|
arr, ok := v.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(arr))
|
|
for _, item := range arr {
|
|
if s, ok := item.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// routeDocs derives the doc scope via the canonical dataset-nav router
|
|
// (harness.NavigateDatasetByTree — the full LLM two-round selection). It routes
|
|
// across ALL bound datasets and merges the doc ids, so every KB contributes its
|
|
// own relevant docs to the shared scope (a multi-KB session must not collapse to
|
|
// the first KB only).
|
|
func (r *ProductionRunner) routeDocs(ctx context.Context, topic, keywords string) []string {
|
|
ns := r.navSvc
|
|
if ns == nil {
|
|
ns = nav.GetNavService()
|
|
}
|
|
if ns == nil {
|
|
log.Printf("agentic_rag: dataset nav service not initialized; skipping doc routing")
|
|
return nil
|
|
}
|
|
// Combine topic + keywords into the routing query so the nav router actually
|
|
// uses the full user signal (keywords must not be dropped).
|
|
query := strings.TrimSpace(topic + " " + keywords)
|
|
seen := map[string]bool{}
|
|
var docs []string
|
|
for _, kbID := range r.datasetIDs {
|
|
for _, id := range NavigateDatasetByTree(ctx, r.db, ns, r.tenantID, kbID, query) {
|
|
if id != "" && !seen[id] {
|
|
seen[id] = true
|
|
docs = append(docs, id)
|
|
}
|
|
}
|
|
}
|
|
return docs
|
|
}
|
|
|
|
func mustJSON(v interface{}) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|