Files
ragflow/internal/service/chat_pipeline.go
Hz_ 358152f758 fix(go-document): add document and file access checks (#16592)
## Summary
Adds ownership/access checks before updating or deleting documents,
setting document metadata, and reading file contents from storage. Also
adds tests for authorized and unauthorized access paths.
2026-07-06 10:13:46 +08:00

4290 lines
147 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"ragflow/internal/common"
"ragflow/internal/engine"
"ragflow/internal/entity"
modelModule "ragflow/internal/entity/models"
"ragflow/internal/service/graph"
"ragflow/internal/service/nlp"
"regexp"
"sort"
"strings"
"time"
"ragflow/internal/dao"
"go.uber.org/zap"
)
// ChatPipelineService is the shared RAG chat pipeline engine used by both
// the OpenAI-compatible endpoint (/api/v1/openai/<chat_id>/chat/completions)
// and the regular chat completion endpoint (/api/v1/chat/completions).
//
// It owns the core retrieval → generation pipeline (AsyncChat, AsyncChatSolo)
// and all their supporting helpers. Callers (OpenAIChatService, ChatSessionService)
// compose it to avoid code duplication.
type ChatPipelineService struct {
ModelProviderSvc *ModelProviderService
MetadataSvc *MetadataService
datasetService *DatasetService
}
// NewChatPipelineService creates a new ChatPipelineService with all required dependencies.
func NewChatPipelineService() *ChatPipelineService {
return &ChatPipelineService{
ModelProviderSvc: NewModelProviderService(),
MetadataSvc: NewMetadataService(),
datasetService: NewDatasetService(),
}
}
// ---------------------------------------------------------------------------
// AsyncChatResult mirrors the dicts yielded by Python's async_chat /
// async_chat_solo. The handler translates these into OpenAIStreamEvent or
// builds a non-streaming OpenAICompletionResponse.
// ---------------------------------------------------------------------------
// AsyncChatResult is a single yield from the chat pipeline.
//
// Reasoning carries chain-of-thought text routed by the driver to a
// separate `reason` channel (e.g. OpenAI's `delta.reasoning_content`
// from Qwen / SiliconFlow). It is kept distinct from Answer so the
// SSE handler can map it to `delta.reasoning_content` rather than
// `delta.content`. Mirrors Python's _async_chat_streamly, which wraps
// reasoning_content in <think>…</think> markers (rag/llm/chat_model.py:226-232)
// so _stream_with_think_delta can route it via in_think state. In Go
// the driver already separates the two streams, so we surface them as
// separate fields directly instead of merging-then-re-splitting.
type AsyncChatResult struct {
Answer string `json:"answer"`
Reasoning string `json:"reasoning,omitempty"`
Reference map[string]interface{} `json:"reference"`
AudioBinary interface{} `json:"audio_binary"`
Prompt string `json:"prompt"`
CreatedAt float64 `json:"created_at"`
Final bool `json:"final"`
StartToThink bool `json:"start_to_think,omitempty"`
EndToThink bool `json:"end_to_think,omitempty"`
// Internal-only: accumulated answer for building the decorated final result.
accumulatedAnswer string
}
// AsyncChat is the Go equivalent of Python's async_chat() in
// api/db/services/dialog_service.py:541.
//
// Full pipeline:
//
// ┌───────────────────────────────────────────────────────┐
// │ 1. Entry validation │
// │ messages non-empty, last role = "user" │
// ├───────────────────────────────────────────────────────┤
// │ No KBs & no web search → AsyncChatSolo (LLM-only) │
// ├───────────────────────────────────────────────────────┤
// │ 2. Resolve LLM model config + max_tokens │
// │ 3. Langfuse trace setup │
// ├───────────────────────────────────────────────────────┤
// │ 4. Bind Models: getModels() → embd, rerank, chat, tts │
// │ + BindTools (toolcall session) │
// ├───────────────────────────────────────────────────────┤
// │ 5. Extract questions, attachments, image_files │
// ├───────────────────────────────────────────────────────┤
// │ 6. SQL Retrieval (field_map + chat_model) │
// │ HIT → return structured SQL result directly │
// │ MISS → fall through to vector retrieval │
// ├───────────────────────────────────────────────────────┤
// │ 7. Prompt parameters: resolve param_keys, auto-fix │
// │ {knowledge} placeholder, validate kwargs │
// │ 8. Query refinement(LLM): │
// │ refine_multiturn → cross_languages → │
// │ meta_data_filter → keyword extraction │
// ├───────────────────────────────────────────────────────┤
// │ 9. Retrieval (if hasKnowledgeParam): │
// │ │
// │ reasoning=true? │
// │ YES → DeepResearcher (recursive, maxDepth=3) │
// │ each layer: KB → Web(Tavily) → KG(use_kg) │
// │ → sufficiencyCheck → multiQueriesGen → recurse│
// │ NO → Standard vector retrieval │
// │ vector/hybrid search → rerank → │
// │ TOC enhance → child chunk retrieval → │
// │ Tavily web search → KG retrieval (prepend) │
// │ │
// │ enrichChunksWithMetadata (doc metadata) │
// │ kbPrompt (build knowledge blocks) │
// ├───────────────────────────────────────────────────────┤
// │ 10. Build LLM request: │
// │ empty_response check → formatPrompt → │
// │ citationPrompt(quote) → messageFitIn(95% budget) │
// │ → multimodal conversion → adjust max_tokens │
// ├───────────────────────────────────────────────────────┤
// │ 11. Drive LLM (stream / non-stream) │
// │ + answer decoration (citations, references, │
// │ timing stats, Langfuse trace, TTS synthesis) │
// └───────────────────────────────────────────────────────┘
//
// Parameters:
// - chat: the chat/chat entity with KBs, prompt_config, etc.
// - messages: pre-filtered user/assistant messages (system already stripped).
// - stream: if true, yields content deltas as they arrive.
// - kwargs: extra parameters (doc_ids, knowledge, quote, etc.).
func (s *ChatPipelineService) AsyncChat(
ctx context.Context,
userID string,
chat *entity.Chat,
messages []map[string]interface{},
stream bool,
kwargs map[string]interface{},
) (<-chan AsyncChatResult, error) {
common.Info("AsyncChat started", zap.String("chat_id", chat.ID))
// === Phase 1: Entry Validation ===
// Guard: messages must be non-empty and the last role must be "user".
common.Info("phase 1: Entry Validation")
if len(messages) == 0 {
return nil, fmt.Errorf("AsyncChat: messages is empty")
}
lastMsg := messages[len(messages)-1]
if role, _ := lastMsg["role"].(string); role != "user" {
return nil, fmt.Errorf("The last content of this conversation is not from user.")
}
// No KBs & no web search → fast-path to LLM-only chat.
hasKBs := false
for _, raw := range chat.KBIDs {
if id, ok := raw.(string); ok && id != "" {
hasKBs = true
break
}
}
useWebSearch := s.shouldUseWebSearch(chat, kwargs["internet"])
if useWebSearch {
common.Debug("web_search",
zap.Bool("kb", hasKBs),
zap.Bool("tavily", chat.PromptConfig != nil && chat.PromptConfig["tavily_api_key"] != "" && chat.PromptConfig["tavily_api_key"] != nil),
zap.Any("internet", kwargs["internet"]),
zap.Bool("enabled", useWebSearch))
}
if !hasKBs && !useWebSearch {
return s.AsyncChatSolo(ctx, userID, chat, messages, stream)
}
// Spawn goroutine for the async pipeline. All remaining phases run inside.
out := make(chan AsyncChatResult, 16)
go func() {
defer close(out)
timer := common.NewTimer()
timer.Start()
// === Phase 2: Resolve LLM Model Config + max_tokens ===
common.Info("Phase 2: Resolve LLM Model Config + max_tokens")
timer.Enter(common.PhaseCheckLLM)
llmModelConfig, _, _, _, err := s.getLLMModelConfig(chat)
if err != nil {
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: %s", err.Error()),
Final: true,
}
return
}
modelMaxTokens := 8192
if llmModelConfig != nil {
// Treat max_tokens=0 as unset (default 8192) — mirrors
// PR #16413 Python fix: model_extra.get("max_tokens") or 8192
if mt, ok := llmModelConfig["max_tokens"].(int); ok && mt > 0 {
modelMaxTokens = mt
}
}
timer.Exit(common.PhaseCheckLLM)
// === Phase 3: Langfuse Trace Setup ===
common.Info("Phase 3: Setup Langfuse Trace")
timer.Enter(common.PhaseCheckLangfuse)
var langfuseTraceID string
if lfClient, ok := ctx.Value(langfuseCtxKey).(*LangfuseClient); ok && lfClient != nil {
langfuseTraceID = fmt.Sprintf("trace-%d", time.Now().UnixNano())
_ = lfClient.PostTrace(ctx, LangfuseTrace{
ID: langfuseTraceID,
Name: "openai_chat",
UserID: chat.TenantID,
SessionID: chat.ID,
Metadata: map[string]interface{}{
"stream": stream,
"kb_count": len(chat.KBIDs),
},
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
})
}
timer.Exit(common.PhaseCheckLangfuse)
// === Phase 4: Bind Models (embedding, rerank, chat, TTS) + ToolCall ===
common.Info("Phase 4: Bind Models (embedding, rerank, chat, TTS)")
timer.Enter(common.PhaseBindModels)
kbs, embModel, rerankModel, chatModel, ttsModel := s.getModels(ctx, chat)
// Toolcall binding
if toolcallSession, hasSession := kwargs["toolcall_session"]; hasSession && toolcallSession != nil {
if tools, hasTools := kwargs["tools"]; hasTools && tools != nil {
if chatModel != nil {
if ts, ok := toolcallSession.(modelModule.ToolCallSession); ok {
common.Info("Bind ToolCall")
chatModel.BindTools(ts, tools)
}
}
}
}
timer.Exit(common.PhaseBindModels)
// === Phase 5: Extract Questions, doc_ids, Attachments ===
common.Info("Phase 5: Extract questions, doc_ids, attachments")
// Retrieve the last 3 user questions.
var questions []string
for _, m := range messages {
if role, _ := m["role"].(string); role == "user" {
if content, ok := m["content"].(string); ok {
questions = append(questions, content)
}
}
}
if len(questions) > 3 {
questions = questions[len(questions)-3:]
}
common.Debug("Extracted questions", zap.Strings("questions", questions))
// Resolve doc_ids from kwargs or the last message.
// Kwargs["doc_ids"] is a comma-separated string.
// messages[-1]["doc_ids"] ALWAYS overrides the kwargs value.
var docIDs []string
if docIDsStr, ok := kwargs["doc_ids"].(string); ok {
for _, p := range strings.Split(docIDsStr, ",") {
p = strings.TrimSpace(p)
if p != "" {
docIDs = append(docIDs, p)
}
}
}
if docIDsRaw, ok := lastMsg["doc_ids"]; ok {
docIDs = nil
if v, ok := docIDsRaw.([]string); ok {
for _, id := range v {
if id != "" {
docIDs = append(docIDs, id)
}
}
} else {
common.Warn("doc_ids in message is not []string, ignoring",
zap.Any("type", fmt.Sprintf("%T", docIDsRaw)))
}
}
if docIDs != nil {
common.Debug("Resolved doc_ids", zap.Strings("doc_ids", docIDs))
}
// Parse file attachments from the last message.
// Split text-file URLs (joined with "\n\n") and image URLs.
// Chat model: images → imageAttachments (multimodal conversion).
// Image2text model: images → imageFiles (raw URLs).
var textAttachmentsList []string
var imageAttachments []string
var imageFiles []string
// Joined text attachments (appended to system prompt).
var attachments string
// When files are file dicts, splitFileAttachments fetches blobs
// from storage. When plain strings, falls back to string splitting.
if files, hasFiles := lastMsg["files"]; hasFiles {
modelType := "chat"
if llmModelConfig != nil {
if mt, ok := llmModelConfig["model_type"].(string); ok {
modelType = mt
}
}
if modelType == "chat" {
textAttachmentsList, imageAttachments = splitFileAttachments(userID, files, false)
} else {
textAttachmentsList, imageFiles = splitFileAttachments(userID, files, true)
}
attachments = strings.Join(textAttachmentsList, "\n\n")
common.Debug("Resolved attachments",
zap.Strings("text_attachments_list", textAttachmentsList),
zap.Strings("image_attachments", imageAttachments),
zap.Strings("image_files", imageFiles),
zap.String("attachments", attachments))
}
// === Phase 6: SQL Retrieval ===
// Retrieve field_map for SQL retrieval (preferred over vector search)
promptConfig := chat.PromptConfig
fieldMap, fmErr := s.datasetService.GetFieldMap(kbIDStrings(kbs))
if fmErr != nil {
common.Warn("get_field_map failed; proceeding without field_map", zap.Error(fmErr))
fieldMap = nil
}
// Try structured SQL retrieval before vector search.
// Only runs on the last question
// HIT → return structured result directly.
// MISS → fall through to vector search.
if len(fieldMap) > 0 && chatModel != nil && len(kbs) > 0 {
common.Info("Phase 6: Use SQL to retrieval")
common.Debug("field_map retrieved", zap.Any("field_map", fieldMap))
quote := true
if v, ok := promptConfig["quote"].(bool); ok {
quote = v
}
ans, sqlErr := s.useSQL(
ctx, chat, kbs, questions[len(questions)-1], chatModel, fieldMap, quote,
)
if sqlErr != nil {
common.Warn("SQL retrieval error; falling through", zap.Error(sqlErr))
}
// For aggregate queries (COUNT, SUM, etc.), chunks may be empty
// but answer is still valid.
chunks := []map[string]interface{}{}
ansStr := ""
if ans != nil {
if refs, ok := ans["reference"].(map[string]interface{}); ok {
if c, ok := refs["chunks"].([]map[string]interface{}); ok {
chunks = c
}
}
ansStr, _ = ans["answer"].(string)
}
if ans != nil && (ansStr != "" || len(chunks) > 0) {
common.Info("SQL retrieval succeeded, skipping vector retrieval")
// Enrich chunks with document metadata
if includeRefMeta, metadataFields := s.resolveReferenceMetadata(promptConfig, kwargs); includeRefMeta && len(chunks) > 0 {
if len(kbs) != 1 {
hasMissingKBID := false
for _, cm := range chunks {
if _, hasKBID := cm["kb_id"]; !hasKBID {
hasMissingKBID = true
break
}
}
if hasMissingKBID {
common.Warn("Skipping some _enrich_chunks_with_document_metadata results because chat.kb_ids has multiple entries and use_sql returned chunks without kb_id",
zap.Int("kb_count", len(kbs)))
}
}
kbinfos := map[string]interface{}{"chunks": chunks}
s.enrichChunksWithMetadata(kbinfos, chat.TenantID, metadataFields)
}
out <- AsyncChatResult{
Answer: ansStr,
Reference: ans["reference"].(map[string]interface{}),
Final: true,
}
return
}
common.Info("SQL retrieval: no valid result, falling back to vector search")
}
// === Phase 7: Prompt Parameters ===
common.Info("Phase 7: Building Prompt Parameters")
// Build param_keys from prompt_config["parameters"].
// prompt_config["parameters"] is a JSON array of
// {key: string, optional: bool}
// objects declaring which placeholder variables the system prompt
// template expects to be substituted.
//
// hasKnowledgeParam gates the entire RAG retrieval phase below.
// When true: vector / DeepResearcher / TOC / Tavily / KG retrieval
// populates kbinfos (from which knowledges is derived afterward).
// When false: skip retrieval and rely on caller-supplied
// kwargs["knowledge"] or LLM-only.
var parameters []interface{}
if paramsRaw, ok := promptConfig["parameters"]; ok {
if p, ok := paramsRaw.([]interface{}); ok {
parameters = p
}
}
var paramKeys []string
hasKnowledgeParam := false
for _, p := range parameters {
if pMap, ok := p.(map[string]interface{}); ok {
if key, _ := pMap["key"].(string); key != "" {
paramKeys = append(paramKeys, key)
if key == "knowledge" {
hasKnowledgeParam = true
}
}
}
}
// Auto-fix: ensure "knowledge" is in param_keys when the chat has
// KBs and the system prompt references {knowledge}.
if len(kbs) > 0 && !hasKnowledgeParam {
systemPrompt, _ := promptConfig["system"].(string)
if strings.Contains(systemPrompt, "{knowledge}") {
common.Warn("prompt_config['parameters'] is missing 'knowledge' entry despite kb_ids being set; auto-fixing.")
parameters = append(parameters, map[string]interface{}{
"key": "knowledge",
"optional": false,
})
promptConfig["parameters"] = parameters
paramKeys = append(paramKeys, "knowledge")
hasKnowledgeParam = true
}
}
// Validate prompt template parameters against caller-supplied kwargs.
// - "knowledge" is always skipped (system-injected, not caller-supplied).
// - Missing non-optional param => return error immediately.
// - Missing optional param => replace "{key}" placeholder with space.
systemPrompt, _ := promptConfig["system"].(string)
for _, p := range parameters {
pMap, ok := p.(map[string]interface{})
if !ok {
continue
}
key, _ := pMap["key"].(string)
if key == "knowledge" {
continue // system-injected, skip caller validation
}
if _, inKwargs := kwargs[key]; !inKwargs {
optional, _ := pMap["optional"].(bool)
if !optional {
// Required parameter missing => fail fast
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: Miss parameter: %s", key),
Final: true,
}
return
}
// Optional parameter missing => erase placeholder from system prompt
systemPrompt = strings.ReplaceAll(systemPrompt, "{"+key+"}", " ")
}
}
promptConfig["system"] = systemPrompt
common.Debug("Prompt parameters",
zap.Strings("doc_ids", docIDs),
zap.Strings("param_keys", paramKeys),
zap.Bool("has_embd_mdl", embModel != nil),
zap.Any("prompt_config.parameters", promptConfig["parameters"]),
zap.String("prompt_config.system", systemPrompt))
// === Phase 8: Query refinement(LLM) ===
// Sub-steps: refine_multiturn → cross_languages → meta_data_filter → keyword.
common.Info("Phase 8: Query refinement(LLM)")
timer.Enter(common.PhaseQueryRefinement)
// refine_multiturn — condense multi-turn conversation into a single
// refined question via LLM. When disabled, simply keep the last question.
if refine, _ := chat.PromptConfig["refine_multiturn"].(bool); refine && len(questions) > 1 && chatModel != nil {
if refined, err := FullQuestion(ctx, chatModel, messages, ""); err == nil && refined != "" {
questions = []string{refined} // replace with refined question
common.Debug("refine_multiturn applied",
zap.String("refined", truncateForLog(refined, 60)))
} else if err != nil {
common.Warn("refine_multiturn failed; using original question", zap.Error(err))
}
} else {
// Keep only the last question.
questions = questions[len(questions)-1:]
}
// cross_languages — translate the question into configured target
// languages via LLM, replacing the original. Useful for cross-lingual retrieval.
if crossLangs, ok := chat.PromptConfig["cross_languages"].([]interface{}); ok && len(crossLangs) > 0 && chatModel != nil && len(questions) > 0 {
langs := make([]string, 0, len(crossLangs))
for _, x := range crossLangs {
if s, ok := x.(string); ok && s != "" {
langs = append(langs, s)
}
}
if len(langs) > 0 {
if translated, err := CrossLanguages(ctx, chat.TenantID, chat.LLMID, questions[0], langs); err == nil && translated != "" {
original := questions[0]
questions = []string{translated} // replace with translated question
common.Debug("cross_languages applied",
zap.String("original_question", original),
zap.String("translated_question", translated))
} else if err != nil {
common.Warn("cross_languages failed", zap.Error(err))
}
}
}
// meta_data_filter — use LLM to map the question to metadata
// criteria, then filter docIDs to matching
// documents only.
if chat.MetaDataFilter != nil && len(*chat.MetaDataFilter) > 0 && len(kbs) > 0 {
kbIDs := kbIDStrings(kbs)
if metaQ := questions[len(questions)-1]; metaQ != "" {
var flattedMeta common.MetaData
var mErr error
if s.MetadataSvc != nil {
flattedMeta, mErr = s.MetadataSvc.GetFlattedMetaByKBs(kbIDs)
}
if mErr == nil {
if filtered, ok := ApplyMetaDataFilter(
ctx,
*chat.MetaDataFilter,
flattedMeta,
metaQ,
chatModel,
docIDs,
kbIDs,
); ok {
common.Debug("meta_data_filter applied",
zap.Int("filtered_count", len(filtered)),
zap.Int("pre_filter_count", len(docIDs)))
docIDs = filtered
}
} else {
common.Warn("loadMetaData failed; skipping meta_data_filter", zap.Error(mErr))
}
}
}
// keyword — extract top-N keywords from the question via LLM and
// append them to the question text to boost lexical retrieval recall.
if useKW, _ := chat.PromptConfig["keyword"].(bool); useKW && chatModel != nil && len(questions) > 0 {
if kw, err := KeywordExtraction(ctx, chatModel, questions[len(questions)-1], 3); err == nil && kw != "" {
original := questions[len(questions)-1]
questions[len(questions)-1] = questions[len(questions)-1] + "," + kw
common.Debug("keyword extraction applied",
zap.String("original_question", original),
zap.String("augmented_question", questions[len(questions)-1]))
} else if err != nil {
common.Warn("keyword extraction failed", zap.Error(err))
}
}
timer.Exit(common.PhaseQueryRefinement)
// === Phase 9: Retrieval ===
promptReasoning, _ := chat.PromptConfig["reasoning"].(bool)
kwargReasoning, _ := kwargs["reasoning"].(bool)
useReasoning := promptReasoning || kwargReasoning
common.Info("Phase 9: Retrieval",
zap.Bool("has_knowledge_param", hasKnowledgeParam),
zap.Bool("reasoning", useReasoning))
timer.Enter(common.PhaseRetrieval)
var kbinfos map[string]interface{}
kbinfos = map[string]interface{}{
"total": 0,
"chunks": []map[string]interface{}{},
"doc_aggs": []interface{}{},
}
var knowledges []string
// When hasKnowledgeParam is true, runs (mutually exclusive):
// a) If reasoning is enabled: DeepResearcher replaces vector retrieval.
// b) Otherwise: standard retrieval, then:
// - TOC enhancement (if toc_enhance is enabled).
// - Child chunk retrieval.
// - Tavily web search (if internet is enabled).
// - Knowledge graph retrieval (if use_kg is enabled).
// Populates kbinfos (chunks + doc_aggs) and knowledges.
// When false, the entire block is skipped.
if hasKnowledgeParam {
if useReasoning && chatModel != nil && len(kbs) > 0 {
// DeepResearcher — replaces vector retrieval.
// Yields <retrieving> / </retrieving> markers + intermediate messages.
docEngine := engine.Get()
if docEngine != nil {
retSvc := nlp.NewRetrievalService(docEngine, dao.NewDocumentDAO())
tenantIDs := kbTenantIDStrings(kbs)
kbIDs := kbIDStrings(kbs)
// KB retrieval callback for the deep researcher
kbRetrieve := func(ctx context.Context, q string) (*nlp.RetrievalResult, error) {
return retSvc.Retrieval(ctx, &nlp.RetrievalRequest{
Question: q,
TenantIDs: tenantIDs,
KbIDs: kbIDs,
DocIDs: docIDs,
Page: 1,
PageSize: int(chat.TopN),
EmbeddingModel: embModel,
})
}
dr := NewDeepResearcher(
chatModel,
map[string]interface{}(chat.PromptConfig),
kbRetrieve,
useWebSearch,
docEngine,
kbIDs,
tenantIDs,
embModel,
)
question := strings.Join(questions, " ")
drErr := dr.Research(ctx, kbinfos, question, question, func(msg string) {
switch {
case strings.HasPrefix(msg, "<START_DEEP_RESEARCH>"):
out <- AsyncChatResult{
Answer: "<retrieving>",
Reference: map[string]interface{}{},
AudioBinary: nil,
Final: false,
}
case strings.HasPrefix(msg, "<END_DEEP_RESEARCH>"):
out <- AsyncChatResult{
Answer: "</retrieving>",
Reference: map[string]interface{}{},
AudioBinary: nil,
Final: false,
}
default:
out <- AsyncChatResult{
Answer: msg,
Reference: map[string]interface{}{},
AudioBinary: nil,
Final: false,
}
}
})
if drErr != nil {
common.Warn("DeepResearcher failed", zap.Error(drErr))
} else {
// kbinfos now contains real chunks with proper
// chunk_ids from the recursive tree search.
common.Debug("DeepResearcher completed",
zap.Int("chunks", len(kbinfos["chunks"].([]map[string]interface{}))))
}
}
} else {
searchQuestion := strings.Join(questions, " ")
if embModel != nil {
// Retrieval
rankFeature := s.MetadataSvc.LabelQuestion(searchQuestion, kbs)
{
tenantIDs := make([]string, 0)
kbIDs := make([]string, 0)
for _, kb := range kbs {
tenantIDs = append(tenantIDs, kb.TenantID)
kbIDs = append(kbIDs, kb.ID)
}
docEngine := engine.Get()
documentDAO := dao.NewDocumentDAO()
retrievalSvc := nlp.NewRetrievalService(docEngine, documentDAO)
top := int(chat.TopK)
threshold := chat.SimilarityThreshold
vsw := chat.VectorSimilarityWeight
topN := int(chat.TopN)
req := &nlp.RetrievalRequest{
Question: searchQuestion,
TenantIDs: tenantIDs,
KbIDs: kbIDs,
DocIDs: docIDs,
Page: 1,
PageSize: topN,
Top: &top,
SimilarityThreshold: &threshold,
VectorSimilarityWeight: &vsw,
RankFeature: &rankFeature,
RerankModel: rerankModel,
EmbeddingModel: embModel,
Aggs: func() *bool { v := true; return &v }(),
}
result, retErr := retrievalSvc.Retrieval(ctx, req)
if retErr != nil {
kbinfos = map[string]interface{}{
"total": 0,
"chunks": []map[string]interface{}{},
"doc_aggs": []interface{}{},
}
err = retErr
} else {
docAggs := make([]interface{}, len(result.DocAggs))
for i, da := range result.DocAggs {
docAggs[i] = da
}
kbinfos = map[string]interface{}{
"total": len(result.Chunks),
"chunks": result.Chunks,
"doc_aggs": docAggs,
}
}
}
if err != nil {
common.Warn("Retrieval failed", zap.Error(err))
// Continue with empty kbinfos.
}
// TOC enhancement
if useTOC, _ := chat.PromptConfig["toc_enhance"].(bool); useTOC && chatModel != nil && len(kbs) > 0 {
enhancer := NewTOCEnhancer(
engine.Get(),
chatModel,
kbTenantIDStrings(kbs),
kbIDStrings(kbs),
searchQuestion,
int(chat.TopN),
)
if added, err := enhancer.Enhance(ctx, kbinfos); err != nil {
common.Warn("TOC enhance failed", zap.Error(err))
} else if added > 0 {
common.Debug("TOC enhance added chunks", zap.Int("added", added))
}
}
}
// Child chunk retrieval
if existingChunks, ok := kbinfos["chunks"].([]map[string]interface{}); ok && len(existingChunks) > 0 {
kbinfos["chunks"] = nlp.RetrievalByChildren(existingChunks, kbTenantIDStrings(kbs), engine.Get(), ctx)
}
// Web search via Tavily
if s.shouldUseWebSearch(chat, kwargs["internet"]) {
tavilyKey, _ := chat.PromptConfig["tavily_api_key"].(string)
tavResult, tavErr := s.tavilyRetrieve(ctx, tavilyKey, searchQuestion)
if tavErr != nil {
common.Warn("Tavily web search failed", zap.Error(tavErr))
} else {
// Extend chunks and doc_aggs with web search results.
if existingChunks, ok := kbinfos["chunks"].([]map[string]interface{}); ok {
if newChunks, ok := tavResult["chunks"].([]map[string]interface{}); ok {
kbinfos["chunks"] = append(existingChunks, newChunks...)
}
}
if existingAggs, ok := kbinfos["doc_aggs"].([]interface{}); ok {
if newAggs, ok := tavResult["doc_aggs"].([]interface{}); ok {
kbinfos["doc_aggs"] = append(existingAggs, newAggs...)
}
}
}
}
// Knowledge Graph retrieval
if useKG, _ := chat.PromptConfig["use_kg"].(bool); useKG && chatModel != nil && len(kbs) > 0 {
kgIDs := kbIDStrings(kbs)
if len(kgIDs) > 0 {
kgPipeline := graph.NewPipeline(engine.Get(), kgIDs, kbTenantIDStrings(kbs), searchQuestion)
kgPipeline.SetChatModel(chatModel)
if embModel != nil {
kgPipeline.SetEmbModel(embModel)
}
kgChunk, kgErr := kgPipeline.Retrieval(ctx)
if kgErr != nil {
common.Warn("KG retrieval failed; falling through to vector-only",
zap.Error(kgErr))
} else if kgChunk != nil {
if _, hasContent := kgChunk["content_with_weight"]; hasContent {
if existingChunks, ok := kbinfos["chunks"].([]map[string]interface{}); ok {
newChunks := make([]map[string]interface{}, 0, len(existingChunks)+1)
newChunks = append(newChunks, kgChunk)
newChunks = append(newChunks, existingChunks...)
kbinfos["chunks"] = newChunks
common.Debug("KG chunk prepended",
zap.Int("total_chunks", len(newChunks)))
}
}
}
}
}
}
}
// Enrich chunks with document metadata AFTER all retrieval adds.
// Request values (kwargs) take precedence over config values.
if includeRefMeta, metadataFields := s.resolveReferenceMetadata(promptConfig, kwargs); includeRefMeta {
s.enrichChunksWithMetadata(kbinfos, chat.TenantID, metadataFields)
}
timer.Exit(common.PhaseRetrieval)
// === Phase 10: Build LLM Request ===
// Sub-steps: empty_response check → formatPrompt → citationPrompt →
// messageFitIn (95% token budget) → multimodal conversion → adjust max_tokens.
// If no knowledges and empty_response is configured, yield it and return.
knowledges = s.kbPrompt(kbinfos, modelMaxTokens)
common.Info("Phase 10: Build LLM Request")
common.Debug("Knowledge prompt",
zap.String("question", strings.Join(questions, " ")),
zap.Strings("knowledges", knowledges))
// empty_response check
// When no knowledge chunks were retrieved, skip the LLM entirely and
// return the user-configured fallback message (if set).
// If empty_response is not configured, fall through to the LLM call
// with an empty knowledge context.
if len(knowledges) == 0 {
if emptyResp, ok := promptConfig["empty_response"].(string); ok && emptyResp != "" {
out <- AsyncChatResult{
Answer: emptyResp,
Reference: kbinfos,
AudioBinary: s.synthesizeTTS(ttsModel, emptyResp),
Prompt: fmt.Sprintf("\n\n### Query:\n%s", strings.Join(questions, " ")),
Final: true,
}
return
}
}
// Format the system prompt with knowledge.
// Only overwrite kwargs["knowledge"] when retrieval produced something;
// otherwise preserve any caller-supplied value.
knowledge := strings.Join(knowledges, "\n\n------\n\n")
if knowledge != "" {
kwargs["knowledge"] = "\n------\n" + knowledge
}
systemPrompt = ""
if sp, ok := promptConfig["system"].(string); ok {
systemPrompt = s.formatPrompt(sp, kwargs) + attachments
// If knowledge was retrieved but the template has no {knowledge}
// placeholder, auto-append it so the LLM still sees the context.
if len(knowledges) > 0 && !strings.Contains(sp, "{knowledge}") {
if kw, ok := kwargs["knowledge"].(string); ok {
systemPrompt += kw
}
}
}
if systemPrompt != "" {
common.Info("System prompt built",
zap.Int("length", len(systemPrompt)))
}
// Build citation prompt if quoting is enabled.
prompt4citation := ""
quote := true
if v, ok := kwargs["quote"].(bool); ok {
quote = v
}
if promptConfigQuote, ok := promptConfig["quote"].(bool); ok {
quote = quote && promptConfigQuote
}
if len(knowledges) > 0 && quote {
prompt4citation = citationPrompt()
}
if prompt4citation != "" {
common.Info("Citation prompt built",
zap.Bool("quote", quote),
zap.Int("length", len(prompt4citation)))
}
// Build the message list: system + cleaned user/assistant messages.
var llmMessages []map[string]interface{}
llmMessages = append(llmMessages, map[string]interface{}{
"role": "system",
"content": systemPrompt,
})
factoryName := ""
if llmModelConfig != nil {
if f, ok := llmModelConfig["llm_factory"].(string); ok && f != "" {
factoryName = strings.ToLower(f)
}
}
if factoryName == "" {
factoryName = factoryFromLLMID(chat.LLMID)
}
for _, m := range messages {
role, _ := m["role"].(string)
if role == "system" {
continue
}
content := m["content"]
if contentStr, ok := content.(string); ok {
content = cleanCitationMarkers(contentStr)
}
llmMessages = append(llmMessages, map[string]interface{}{
"role": role,
"content": content,
})
}
// Fit messages within token budget.
usedTokenCount, llmMessages := s.messageFitIn(llmMessages, int(float64(modelMaxTokens)*0.95))
common.Debug("Messages fitted in token budget",
zap.Int("model max_tokens", modelMaxTokens),
zap.Int("used_token_count", usedTokenCount),
zap.Int("msg_count", len(llmMessages)))
// Multimodal conversion
allImages := make([]string, 0, len(imageAttachments)+len(imageFiles))
allImages = append(allImages, imageAttachments...)
allImages = append(allImages, imageFiles...)
if len(llmMessages) >= 2 && len(allImages) > 0 {
lastIdx := len(llmMessages) - 1
if role, _ := llmMessages[lastIdx]["role"].(string); role == "user" {
if converted, err := common.ConvertLastUserMsgToMultimodal(
llmMessages[lastIdx],
allImages,
factoryName,
); err == nil {
llmMessages[lastIdx] = converted
}
}
}
prompt := systemPrompt
if len(llmMessages) > 0 {
if c, ok := llmMessages[0]["content"].(string); ok {
prompt = c
}
}
if len(llmMessages) < 2 {
out <- AsyncChatResult{
Answer: "**ERROR**: message_fit_in has bug",
Final: true,
}
return
}
// Adjust max_tokens so the LLM has room within the total budget.
if chat.LLMSetting != nil {
if mt, ok := chat.LLMSetting["max_tokens"].(float64); ok {
original := int(mt)
adjusted := original
if adjusted > modelMaxTokens-usedTokenCount {
adjusted = modelMaxTokens - usedTokenCount
}
chat.LLMSetting["max_tokens"] = float64(adjusted)
common.Debug("Adjusted max_tokens", zap.Int("max_tokens in chat", adjusted))
}
}
// === Phase 11: Drive LLM + Decorate Answer ===
// Stream path: accumulate deltas → per-delta TTS → decorate final.
// Non-stream path: one-shot chat → decorate (includes TTS).
// Answer decoration: citation markers, references, timing stats, Langfuse.
common.Info("Phase 11: Drive LLM + Decorate Answer",
zap.Bool("stream", stream),
zap.Int("llm_messages_count", len(llmMessages)))
timer.Enter(common.PhaseGenerateAnswer)
chatDriver := s.buildChatDriver(chat, chatModel)
if chatDriver == nil {
out <- AsyncChatResult{
Answer: "**ERROR**: No chat model available for this chat.",
Final: true,
}
return
}
chatMessages := s.buildChatMessages(prompt+prompt4citation, llmMessages[1:])
// Langfuse generation start observation.
var langfuseGenerationID string
if langfuseTraceID != "" {
if lfClient, ok := ctx.Value(langfuseCtxKey).(*LangfuseClient); ok && lfClient != nil {
langfuseGenerationID = fmt.Sprintf("gen-%s", langfuseTraceID)
modelName := ""
if llmModelConfig != nil {
if mn, ok := llmModelConfig["llm_name"].(string); ok {
modelName = mn
}
}
// PostGeneration creates a start-observation span.
// Error is non-fatal; end-observation fires regardless.
genInput := map[string]interface{}{
"prompt": prompt,
"prompt4citation": prompt4citation,
"messages": chatMessages,
}
if err := lfClient.PostGeneration(ctx, LangfuseGeneration{
ID: langfuseGenerationID,
TraceID: langfuseTraceID,
Name: "chat",
Model: modelName,
StartTime: time.Now().UTC().Format(time.RFC3339Nano),
Input: genInput,
}); err != nil {
common.Warn("Langfuse start observation (PostGeneration) failed; continuing without start-side tracing",
zap.String("langfuse_trace_id", langfuseTraceID),
zap.Error(err))
// Keep langfuseGenerationID set so the end
// Keep langfuseGenerationID set so end-observation fires.
}
}
}
// Stream path: per-delta callbacks, accumulate answer.
// Non-stream path: one-shot synchronous answer.
if stream {
// Streaming path: accumulate answer, emit deltas.
var fullAnswer string
thinkState := &ThinkStreamState{}
chatCfg := BuildChatConfig(chat, nil)
// Tool routing: use tool-loop method when tools are bound.
var driverErr error
if chatDriver.ToolConfig != nil {
// Tool streaming path:
// Wraps reasoning in <think></think> markers.
// inThink tracks local state to route reasoning vs answer.
var inThink bool
_, driverErr = chatDriver.ChatStreamlyWithTools(ctx, prompt+prompt4citation, chatMessages, chatCfg,
func(answerDelta *string, reason *string) error {
if answerDelta == nil || *answerDelta == "" {
return nil
}
text := *answerDelta
fullAnswer += text
if text == "<think>" {
inThink = true
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
StartToThink: true,
}
return nil
}
if text == "</think>" {
inThink = false
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
EndToThink: true,
}
return nil
}
if inThink {
// Reasoning text — route to Reasoning field so
// the SSE handler maps it to
// `delta.reasoning_content`.
out <- AsyncChatResult{
Reasoning: text,
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
} else {
// Regular answer content
out <- AsyncChatResult{
Answer: text,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, text),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
return nil
})
} else {
driverErr = chatDriver.ModelDriver.ChatStreamlyWithSender(
*chatDriver.ModelName, chatMessages, chatDriver.APIConfig, chatCfg,
func(answer *string, reason *string) error {
if reason != nil && *reason != "" {
if thinkState.EnterReasoning() {
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
StartToThink: true,
}
}
deltas := NextThinkDelta(thinkState, *reason, 16)
for _, d := range deltas {
if d.Kind == ThinkDeltaText && d.Value != "" {
fullAnswer += d.Value
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
}
if isContentDelta(answer) {
if thinkState.ExitReasoning() {
for _, d := range FlushRemaining(thinkState) {
if d.Kind == ThinkDeltaText && d.Value != "" {
fullAnswer += d.Value
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
EndToThink: true,
}
}
fullAnswer += *answer
deltas := BufferAnswerDelta(thinkState, *answer, 16)
for _, d := range deltas {
if d.Kind == ThinkDeltaText && d.Value != "" {
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
}
return nil
},
)
}
if driverErr != nil {
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: %s", driverErr.Error()),
Final: true,
}
return
}
// Flush remaining state matching Python's final flush order
// (dialog_service.py:1601-1612): think_buffer → marker → answer_buffer → pending_after_close
// Python has no Reasoning field — all text is Answer.
hadThinkClose := false
for _, d := range FlushRemaining(thinkState) {
if d.Kind == ThinkDeltaMarker && d.Value == "</think>" {
hadThinkClose = true
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
EndToThink: true,
}
} else if d.Kind == ThinkDeltaText && d.Value != "" {
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
// Close reasoning if the stream ended while still in reasoning mode
// (e.g. model returned only reasoning chunks with no content delta).
// Skip when FlushRemaining already emitted a </think> marker.
if !hadThinkClose && thinkState.ExitReasoning() {
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
EndToThink: true,
}
}
// Decorate and yield the final answer.
// Python uses state.full_text (raw text with <think> tags) as input
// to _extract_visible_answer → decorate_answer (dialog_service.py:914-920).
visibleAnswer := s.extractVisibleAnswer(thinkState.fullText)
// Pass nil for ttsModel — audio was already produced per-delta.
final := s.decorateAnswer(ctx, visibleAnswer, kbinfos, prompt, questions, usedTokenCount, timer, embModel, chat.VectorSimilarityWeight, quote, nil, langfuseTraceID, llmModelConfig, chat.TenantID, kbTenantIDStrings(kbs), len(knowledges) > 0)
final.Final = true
final.AudioBinary = nil
timer.Exit(common.PhaseGenerateAnswer)
out <- final
} else {
// Non-streaming: get the answer synchronously.
var answer string
var err error
chatCfg := BuildChatConfig(chat, nil)
// Tool routing: use tool-loop when tools are bound.
if chatDriver.ToolConfig != nil {
answer, _, err = chatDriver.ChatWithTools(ctx, prompt+prompt4citation, chatMessages, chatCfg)
} else {
resp, respErr := chatDriver.ModelDriver.ChatWithMessages(
*chatDriver.ModelName, chatMessages, chatDriver.APIConfig, chatCfg,
)
if respErr != nil {
err = respErr
} else if resp != nil && resp.Answer != nil {
answer = *resp.Answer
}
}
if err != nil {
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: %s", err.Error()),
Final: true,
}
return
}
// Last user message's content for the debug log.
userContent := "[content not available]"
if len(llmMessages) > 1 {
if c, ok := llmMessages[len(llmMessages)-1]["content"].(string); ok {
userContent = c
}
}
common.Debug("User: " + userContent + "|Assistant: " + answer)
// Synthesize TTS for the full answer (non-stream, one-shot).
final := s.decorateAnswer(ctx, answer, kbinfos, prompt, questions, usedTokenCount, timer, embModel, chat.VectorSimilarityWeight, quote, ttsModel, langfuseTraceID, llmModelConfig, chat.TenantID, kbTenantIDStrings(kbs), len(knowledges) > 0)
final.Final = true
timer.Exit(common.PhaseGenerateAnswer)
out <- final
}
common.Info("AsyncChat completed", zap.String("chat_id", chat.ID))
}()
return out, nil
}
// AsyncChatSolo is the LLM-only chat path (no KBs, no web search).
// Equivalent to Python's async_chat_solo() in dialog_service.py:289-337.
func (s *ChatPipelineService) AsyncChatSolo(
ctx context.Context,
userID string,
chat *entity.Chat,
messages []map[string]interface{},
stream bool,
) (<-chan AsyncChatResult, error) {
out := make(chan AsyncChatResult, 16)
go func() {
defer close(out)
// Timer brackets the LLM call; other phases are N/A in solo mode.
timer := common.NewTimer()
timer.Start()
// 1. Resolve system prompt.
promptConfig := chat.PromptConfig
systemPrompt := ""
if sp, ok := promptConfig["system"].(string); ok {
systemPrompt = sp
}
// 1b. Resolve LLM model config (needed early for model_type dispatch).
llmModelConfig, _, _, _, err := s.getLLMModelConfig(chat)
factoryName := ""
if err == nil && llmModelConfig != nil {
factoryName, _ = llmModelConfig["llm_factory"].(string)
}
if factoryName == "" {
factoryName = factoryFromLLMID(chat.LLMID)
}
// 2. Process file attachments (chat → data URIs, image2text → raw URLs).
attachmentsStr := ""
var imageFiles []string
modelType := "chat"
if llmModelConfig != nil {
if mt, ok := llmModelConfig["model_type"].(string); ok && mt != "" {
modelType = mt
}
}
isImage2Text := modelType == "image2text"
if len(messages) > 0 {
if files, hasFiles := messages[len(messages)-1]["files"]; hasFiles {
attachmentsStr = s.processFileAttachments(userID, files)
if isImage2Text {
imageFiles = s.extractRawImageURLs(files)
} else {
imageFiles = s.extractImageFiles(userID, files)
}
}
}
// 3. Strip citation markers and drop system messages from history.
var msg []map[string]interface{}
for _, m := range messages {
role, _ := m["role"].(string)
if role == "system" {
continue
}
content := m["content"]
if contentStr, ok := content.(string); ok {
content = cleanCitationMarkers(contentStr)
}
msg = append(msg, map[string]interface{}{
"role": role,
"content": content,
})
}
// Append text attachments to the last user message (no separator).
if attachmentsStr != "" && len(msg) > 0 {
if lastContent, ok := msg[len(msg)-1]["content"].(string); ok {
msg[len(msg)-1]["content"] = lastContent + attachmentsStr
}
}
// 4. Build the chat model wrapper.
driver, modelName, apiConfig, _, err := s.ModelProviderSvc.GetChatModelConfig(chat.TenantID, chat.LLMID)
if err != nil {
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: %s", err.Error()),
Final: true,
}
return
}
chatModel := modelModule.NewChatModel(driver, &modelName, apiConfig)
// 5. Resolve TTS model. Best-effort: warn and proceed without TTS on lookup failure.
var ttsModel *modelModule.ChatModel
if promptConfig != nil {
if useTTS, _ := promptConfig["tts"].(bool); useTTS {
ttsDriver, ttsName, ttsConfig, _, ttsErr := s.ModelProviderSvc.GetTenantDefaultModelByType(
chat.TenantID, entity.ModelTypeTTS,
)
if ttsErr != nil {
common.Warn("AsyncChatSolo: TTS lookup failed; proceeding without TTS",
zap.String("tenant_id", chat.TenantID),
zap.Error(ttsErr))
} else {
ttsModel = modelModule.NewChatModel(ttsDriver, &ttsName, ttsConfig)
}
}
}
// 6. Build messages for driver. Convert last user msg to multimodal if images present.
var chatMessages []modelModule.Message
if systemPrompt != "" {
chatMessages = append(chatMessages, modelModule.Message{
Role: "system",
Content: systemPrompt,
})
}
for i, m := range msg {
role, _ := m["role"].(string)
content := m["content"]
// Multimodal conversion for the last user message.
if i == len(msg)-1 && role == "user" && len(imageFiles) > 0 {
if converted, err := common.ConvertLastUserMsgToMultimodal(
map[string]interface{}{"role": role, "content": content},
imageFiles,
strings.ToLower(factoryName),
); err == nil {
content = converted["content"]
}
}
chatMessages = append(chatMessages, modelModule.Message{
Role: role,
Content: content,
})
}
// 7. Drive the LLM: stream (per-delta with think markers) or non-stream (one-shot).
if stream {
var fullAnswer string
thinkState := &ThinkStreamState{}
chatCfg := BuildChatConfig(chat, nil)
timer.Enter(common.PhaseGenerateAnswer)
driverErr := chatModel.ModelDriver.ChatStreamlyWithSender(
*chatModel.ModelName, chatMessages, chatModel.APIConfig, chatCfg,
func(answer *string, reason *string) error {
if reason != nil && *reason != "" {
if thinkState.EnterReasoning() {
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
StartToThink: true,
}
}
deltas := NextThinkDelta(thinkState, *reason, 16)
for _, d := range deltas {
if d.Kind == ThinkDeltaText && d.Value != "" {
fullAnswer += d.Value
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
}
if isContentDelta(answer) {
if thinkState.ExitReasoning() {
for _, d := range FlushRemaining(thinkState) {
if d.Kind == ThinkDeltaText && d.Value != "" {
fullAnswer += d.Value
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
EndToThink: true,
}
}
fullAnswer += *answer
deltas := BufferAnswerDelta(thinkState, *answer, 16)
for _, d := range deltas {
if d.Kind == ThinkDeltaText && d.Value != "" {
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
}
return nil
},
)
if driverErr != nil {
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: %s", driverErr.Error()),
Final: true,
}
return
}
timer.Exit(common.PhaseGenerateAnswer)
hadThinkClose := false
for _, d := range FlushRemaining(thinkState) {
if d.Kind == ThinkDeltaMarker && d.Value == "</think>" {
hadThinkClose = true
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
EndToThink: true,
}
} else if d.Kind == ThinkDeltaText && d.Value != "" {
out <- AsyncChatResult{
Answer: d.Value,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, d.Value),
CreatedAt: float64(time.Now().Unix()),
Final: false,
}
}
}
// Close reasoning if the stream ended while still in reasoning mode
// (e.g. model returned only reasoning chunks with no content delta).
// Skip when FlushRemaining already emitted a </think> marker.
if !hadThinkClose && thinkState.ExitReasoning() {
out <- AsyncChatResult{
Answer: "",
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: false,
EndToThink: true,
}
}
finalAnswer := ExtractVisibleAnswer(thinkState.fullText)
if finalAnswer == "" {
finalAnswer = fullAnswer
}
out <- AsyncChatResult{
Answer: finalAnswer,
Reference: map[string]interface{}{},
AudioBinary: nil,
CreatedAt: float64(time.Now().Unix()),
Final: true,
}
} else {
// Non-streaming: one-shot call.
chatCfg := BuildChatConfig(chat, nil)
timer.Enter(common.PhaseGenerateAnswer)
resp, err := chatModel.ModelDriver.ChatWithMessages(
*chatModel.ModelName, chatMessages, chatModel.APIConfig, chatCfg,
)
timer.Exit(common.PhaseGenerateAnswer)
if err != nil {
out <- AsyncChatResult{
Answer: fmt.Sprintf("**ERROR**: %s", err.Error()),
Final: true,
}
return
}
answer := ""
if resp.Answer != nil {
answer = *resp.Answer
}
// Debug log matching Python's dialog_service.py:335-336.
userContent := "[content not available]"
if len(msg) > 0 {
if c, ok := msg[len(msg)-1]["content"].(string); ok {
userContent = c
}
}
common.Debug("User: " + userContent + "|Assistant: " + answer)
// Raw answer with full TTS, no decorate_answer. Caller handles decoration.
out <- AsyncChatResult{
Answer: answer,
Reference: map[string]interface{}{},
AudioBinary: s.synthesizeTTS(ttsModel, answer),
CreatedAt: float64(time.Now().Unix()),
Final: true,
}
}
}()
return out, nil
}
// extractImageFiles extracts data-URI image attachments from the files list.
// Mirrors Python split_file_attachments raw mode.
func (s *ChatPipelineService) extractImageFiles(userID string, files interface{}) []string {
// ── File-dict mode ──
if fileDicts, ok := parseFileDicts(files); ok {
fileSvc := NewFileService()
// Use raw=false to get base64 data URIs for images.
_, images, err := fileSvc.GetFileContents(userID, fileDicts, false)
if err != nil {
common.Warn("GetFileContents failed in extractImageFiles",
zap.Error(err))
return nil
}
return images
}
// ── String fallback ──
var images []string
switch v := files.(type) {
case []string:
for _, f := range v {
if strings.HasPrefix(f, "data:") {
images = append(images, f)
}
}
case []interface{}:
for _, f := range v {
if s, ok := f.(string); ok && strings.HasPrefix(s, "data:") {
images = append(images, s)
}
}
}
return images
}
// extractRawImageURLs extracts image references as raw URLs/data-URIs from
// the string-mode files list, WITHOUT fetching blobs and WITHOUT filtering
// to data: prefixes. Used for image2text models that expect URLs in the
// multimodal content (matches Python's `image_files` from
// `split_file_attachments(files, raw=True)` at
// dialog_service.py:371-392).
//
// The downstream ConvertLastUserMsgToMultimodal calls parseDataURIOrB64
// (multimodal.go:63-92) which correctly handles all three forms:
// - data: URI → base64 source
// - http:// or https:// URL → URL source
// - raw base64 → base64 source (default media type)
//
// File-dict mode is a known limitation: returns empty for now. A future
// FileService.GetFileURLsForChat (mirror of GetFileContents with
// raw=true) would be needed to fully cover the file-dict + image2text
// combination. The Python equivalent has the same limitation
// (split_file_attachments calls FileService.get_files which doesn't
// fetch blobs in raw mode).
func (s *ChatPipelineService) extractRawImageURLs(files interface{}) []string {
if fileDicts, ok := parseFileDicts(files); ok {
_ = fileDicts // see file-dict limitation comment above
common.Debug("AsyncChatSolo: file-dict + image2text not yet supported; image refs dropped",
zap.Int("file_dict_count", len(fileDicts)))
return nil
}
// String-mode: return all entries as-is. The downstream
// ConvertLastUserMsgToMultimodal + parseDataURIOrB64 will
// dispatch on prefix (data: → base64, http(s): → url, else →
// raw base64).
var urls []string
switch v := files.(type) {
case []string:
for _, f := range v {
if f != "" {
urls = append(urls, f)
}
}
case []interface{}:
for _, f := range v {
if s, ok := f.(string); ok && s != "" {
urls = append(urls, s)
}
}
}
return urls
}
// ---------------------------------------------------------------------------
// Helper methods
// ---------------------------------------------------------------------------
// internetTruthyStrings / internetFalsyStrings mirror the case-insensitive,
// whitespace-trimmed alias sets at dialog_service.py:115-117 of
// _normalize_internet_flag. Kept in one place so a future addition (e.g.
// Python accepting "y"/"n") is a one-line change here.
var internetTruthyStrings = map[string]bool{"true": true, "1": true, "yes": true, "on": true}
var internetFalsyStrings = map[string]bool{"false": true, "0": true, "no": true, "off": true, "": true}
// normalizeInternetFlag is the Go port of Python's
// _normalize_internet_flag (dialog_service.py:108-119). Three-state
// return matches Python: *true → explicit truthy, *false → explicit
// falsy, nil → couldn't interpret (Python's `return None`). The caller
// decides what to do with nil — _should_use_web_search treats it as
// "not enabled," so shouldUseWebSearch below only returns true when
// the normalized result is explicitly true.
//
// Accepted inputs (mirroring Python):
// - bool: returned as-is
// - int / int64 / float64 with value 0 or 1: coerced to bool
// - string (case-insensitive, trimmed): "true"/"1"/"yes"/"on" → true;
// "false"/"0"/"no"/"off"/"" → false
// - everything else (nil, slices, maps, other numeric values,
// unrecognized strings, complex, etc.) → nil
func normalizeInternetFlag(v interface{}) *bool {
switch x := v.(type) {
case bool:
return &x
case string:
s := strings.ToLower(strings.TrimSpace(x))
if internetTruthyStrings[s] {
t := true
return &t
}
if internetFalsyStrings[s] {
f := false
return &f
}
case int:
if x == 0 || x == 1 {
b := x == 1
return &b
}
case int64:
if x == 0 || x == 1 {
b := x == 1
return &b
}
case float64:
if x == 0 || x == 1 {
b := x == 1
return &b
}
}
return nil
}
// shouldUseWebSearch returns true if web search should be enabled.
// Mirrors Python's _should_use_web_search (dialog_service.py:122-126):
// Tavily key must be present on chat.PromptConfig AND the internet
// flag must normalize to explicit true.
//
// The second parameter takes the raw internet value (typically
// kwargs["internet"] at the call site) — same shape as Python's
// `_should_use_web_search(chat.prompt_config, kwargs.get("internet"))`.
func (s *ChatPipelineService) shouldUseWebSearch(chat *entity.Chat, internet interface{}) bool {
if chat.PromptConfig == nil {
return false
}
tavilyKey, _ := chat.PromptConfig["tavily_api_key"].(string)
if tavilyKey == "" {
return false
}
normalized := normalizeInternetFlag(internet)
return normalized != nil && *normalized
}
// tavilyRetrieve calls the Tavily API and returns results in the same chunk
// format used by performRetrieval. Mirrors Python's Tavily.retrieve_chunks()
// in rag/utils/tavily_conn.py.
func (s *ChatPipelineService) tavilyRetrieve(ctx context.Context, apiKey, question string) (map[string]interface{}, error) {
const tavilyURL = "https://api.tavily.com/search"
body := map[string]interface{}{
"query": question,
"search_depth": "advanced",
"max_results": 6,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("tavily: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tavilyURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("tavily: new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("tavily: do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tavily: status %d", resp.StatusCode)
}
var tavilyResp struct {
Results []struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&tavilyResp); err != nil {
return nil, fmt.Errorf("tavily: decode response: %w", err)
}
chunks := make([]map[string]interface{}, 0, len(tavilyResp.Results))
docAggs := make([]interface{}, 0, len(tavilyResp.Results))
for _, r := range tavilyResp.Results {
id := fmt.Sprintf("tavily-%s", r.URL)
chunk := map[string]interface{}{
"chunk_id": id,
"content_ltks": tokenizeText(r.Content), // tokenized content
"content_with_weight": r.Content,
"doc_id": id,
"docnm_kwd": r.Title,
"kb_id": []interface{}{},
"important_kwd": []interface{}{},
"image_id": "",
"similarity": r.Score,
"vector_similarity": 1.0,
"term_similarity": 0.0,
"vector": []float64{}, // empty; no embedding for web results
"positions": []interface{}{},
"url": r.URL,
}
chunks = append(chunks, chunk)
docAggs = append(docAggs, map[string]interface{}{
"doc_name": r.Title,
"doc_id": id,
"count": 1,
"url": r.URL,
})
}
common.Info("[Tavily] question: "+question, zap.Int("results", len(chunks)))
return map[string]interface{}{
"chunks": chunks,
"doc_aggs": docAggs,
}, nil
}
// tokenizeText is a lightweight tokenizer for Tavily content.
// It lowercases and splits on whitespace, similar to rag_tokenizer.tokenize.
func tokenizeText(text string) string {
// Collapse multiple whitespaces and split.
ws := regexp.MustCompile(`\s+`)
text = ws.ReplaceAllString(text, " ")
// Convert to lowercase for tokenization.
return strings.ToLower(text)
}
// getLLMModelConfig resolves the LLM model configuration for the chat.
// Mirrors Python's three-branch resolver at dialog_service.py:552-561:
//
// if chat.llm_id:
// if "image2text" in get_model_type_by_name(...): → IMAGE2TEXT
// else: → CHAT
// else: → tenant default CHAT
//
// The returned `cfg` map's "model_type" field carries the chosen type
// so downstream code (e.g. the multimodal-conversion guard in AsyncChat
// at async_chat.go:632) can skip chat-only logic for image2text dialogs.
func (s *ChatPipelineService) getLLMModelConfig(chat *entity.Chat) (map[string]interface{}, string, string, string, error) {
if chat.LLMID == "" {
// Branch 3: no explicit LLM → tenant default chat model.
return s.buildLLMModelConfig(
s.ModelProviderSvc.GetTenantDefaultModelByType(chat.TenantID, entity.ModelTypeChat),
)
}
// Branches 1/2: explicit LLM. Probe model types and pick IMAGE2TEXT
// when the LLM is registered as such, otherwise CHAT.
modelType := entity.ModelTypeChat
modelTypeStr := "chat"
if modelTypes, mtErr := s.ModelProviderSvc.GetModelTypeByName(chat.TenantID, chat.LLMID); mtErr == nil {
for _, mt := range modelTypes {
if mt == entity.ModelTypeImage2Text {
modelType = entity.ModelTypeImage2Text
modelTypeStr = "image2text"
break
}
}
}
cfg, modelName, factoryName, baseURL, err := s.buildLLMModelConfig(
s.ModelProviderSvc.GetModelConfigFromProviderInstance(chat.TenantID, modelType, chat.LLMID),
)
if err != nil {
return nil, "", "", "", err
}
cfg["model_type"] = modelTypeStr
return cfg, modelName, factoryName, baseURL, nil
}
// buildLLMModelConfig collapses the (driver, modelName, apiConfig,
// _, err) tuple from a model-provider lookup into the dict-shaped
// config the rest of async_chat.go consumes. Default "model_type" is
// "chat"; callers that resolved a different type overwrite the key
// before returning.
func (s *ChatPipelineService) buildLLMModelConfig(
driver modelModule.ModelDriver,
modelName string,
apiConfig *modelModule.APIConfig,
maxTokens int,
err error,
) (map[string]interface{}, string, string, string, error) {
if err != nil {
return nil, "", "", "", err
}
// Match Python: llm.max_tokens if llm.max_tokens else 8192.
if maxTokens == 0 {
maxTokens = 8192
}
cfg := map[string]interface{}{
"model_type": "chat",
"llm_name": modelName,
"max_tokens": maxTokens,
"llm_factory": driver.Name(),
}
baseURL := ""
if apiConfig != nil && apiConfig.BaseURL != nil {
baseURL = *apiConfig.BaseURL
}
return cfg, modelName, driver.Name(), baseURL, nil
}
// getModels resolves all models needed for the RAG pipeline.
// Mirrors Python's get_models() in dialog_service.py:340.
func (s *ChatPipelineService) getModels(ctx context.Context, chat *entity.Chat) (
[]*entity.Knowledgebase,
*modelModule.EmbeddingModel,
*modelModule.RerankModel,
*modelModule.ChatModel,
*modelModule.ChatModel, // TTS model
) {
kbDAO := dao.NewKnowledgebaseDAO()
// Extract KB ID strings.
kbIDs := make([]string, 0, len(chat.KBIDs))
for _, raw := range chat.KBIDs {
if id, ok := raw.(string); ok && id != "" {
kbIDs = append(kbIDs, id)
}
}
var kbs []*entity.Knowledgebase
if len(kbIDs) > 0 {
var err error
kbs, err = kbDAO.GetByIDs(kbIDs)
if err != nil {
common.Warn("Failed to get KBs by IDs; retrieval may be incomplete",
zap.Strings("kbIDs", kbIDs), zap.Error(err))
}
}
// Embedding model.
var embModel *modelModule.EmbeddingModel
if len(kbs) > 0 {
// All KBs must share the same embedding model.
embdIDs := make(map[string]bool)
for _, kb := range kbs {
if kb.EmbdID != "" {
embdIDs[kb.EmbdID] = true
}
}
if len(embdIDs) > 1 {
// Multiple embedding models across KBs — error.
common.Warn("Knowledge bases use different embedding models")
}
if len(embdIDs) == 1 {
for embdID := range embdIDs {
embdTenantID := kbs[0].TenantID
driver, modelName, apiConfig, maxTokens, err := s.ModelProviderSvc.GetModelConfigFromProviderInstance(
embdTenantID, entity.ModelTypeEmbedding, embdID,
)
if err == nil {
embModel = modelModule.NewEmbeddingModel(driver, &modelName, apiConfig, maxTokens)
}
}
}
}
// Chat model.
driver, modelName, apiConfig, _, err := s.ModelProviderSvc.GetChatModelConfig(chat.TenantID, chat.LLMID)
var chatModel *modelModule.ChatModel
if err == nil {
chatModel = modelModule.NewChatModel(driver, &modelName, apiConfig)
}
// Rerank model.
var rerankModel *modelModule.RerankModel
if chat.RerankID != "" {
rerankDriver, rerankName, rerankConfig, _, err := s.ModelProviderSvc.GetModelConfigFromProviderInstance(
chat.TenantID, entity.ModelTypeRerank, chat.RerankID,
)
if err == nil {
rerankModel = modelModule.NewRerankModel(rerankDriver, &rerankName, rerankConfig)
}
}
// TTS model.
var ttsModel *modelModule.ChatModel
if chat.PromptConfig != nil {
if useTTS, _ := chat.PromptConfig["tts"].(bool); useTTS {
ttsDriver, ttsName, ttsConfig, _, err := s.ModelProviderSvc.GetTenantDefaultModelByType(
chat.TenantID, entity.ModelTypeTTS,
)
if err == nil {
ttsModel = modelModule.NewChatModel(ttsDriver, &ttsName, ttsConfig)
}
}
}
return kbs, embModel, rerankModel, chatModel, ttsModel
}
// lastUserQuestion returns the content of the most recent user message in
// `messages`, or "" if there is no user message. Used by the P2
// meta_data_filter wiring (Python's `questions[-1]` in
// dialog_service.py:655).
// factoryFromLLMID extracts the provider name from a composite LLM ID
// like "Qwen3-8B@ling@SILICONFLOW" → "SILICONFLOW". When the LLM ID has
// no "@provider" segment, returns "openai" as a default. The lowercase
// return value is what ConvertLastUserMsgToMultimodal /
// RenderContentPartsForFactory dispatch on.
func factoryFromLLMID(llmID string) string {
if llmID == "" {
return "openai"
}
parts := strings.Split(llmID, "@")
if len(parts) < 3 {
return "openai"
}
provider := strings.ToLower(parts[len(parts)-1])
if provider == "" {
return "openai"
}
return provider
}
// The handler in openai_chat.go has already rejected requests
// whose last message is not from the user, so this should always succeed.
func lastUserQuestion(messages []map[string]interface{}) string {
for i := len(messages) - 1; i >= 0; i-- {
role, _ := messages[i]["role"].(string)
if role == "user" {
if c, ok := messages[i]["content"].(string); ok {
return c
}
return ""
}
}
return ""
}
// processFileAttachments extracts text content from file attachments.
// Mirrors Python's split_file_attachments (dialog_service.py:371-392)
// in raw=false mode: returns text attachments joined by "\n\n",
// filtering out data-URI image attachments.
//
// When files are file dicts (Python-compatible format), calls
// FileService.GetFileContents to fetch actual blobs from storage.
func (s *ChatPipelineService) processFileAttachments(userID string, files interface{}) string {
// ── File-dict mode ──
if fileDicts, ok := parseFileDicts(files); ok {
fileSvc := NewFileService()
texts, _, err := fileSvc.GetFileContents(userID, fileDicts, false)
if err != nil {
common.Warn("GetFileContents failed in processFileAttachments",
zap.Error(err))
return ""
}
if len(texts) == 0 {
return ""
}
return strings.Join(texts, "\n\n")
}
// ── String fallback ──
var texts []string
switch v := files.(type) {
case []string:
for _, f := range v {
if s := strings.TrimSpace(f); s != "" && !strings.HasPrefix(s, "data:") {
texts = append(texts, s)
}
}
case []interface{}:
for _, f := range v {
if s, ok := f.(string); ok && strings.TrimSpace(s) != "" && !strings.HasPrefix(s, "data:") {
texts = append(texts, s)
}
}
}
if len(texts) == 0 {
return ""
}
return strings.Join(texts, "\n\n")
}
// splitFileAttachments mirrors Python's `split_file_attachments` at
// dialog_service.py:371-392. It separates `messages[-1]["files"]`
// into text-file content and image attachments.
//
// Two modes of operation:
//
// 1. File-dict mode: When `files` is `[]map[string]interface{}` (each dict
// with keys "id", "created_by", "mime_type", "name"), the method calls
// FileService.GetFileContents to fetch actual file blobs from
// storage, mirroring Python's FileService.get_files().
//
// 2. String-fallback mode: When `files` is `[]string` or `[]interface{}` of
// strings (pre-resolved content), the method does simple string splitting:
// - raw=false: split by "data:" prefix. Text → textAttachments; data:
// URIs → image files.
// - raw=true: all items go to textAttachments (Python's FileService.get_files
// with raw=True pre-separates images, so non-image content arrives here).
func splitFileAttachments(userID string, files interface{}, raw bool) (textAttachments []string, imageAttachments []string) {
// ── Mode 1: file dicts (Python-compatible) ──
if fileDicts, ok := parseFileDicts(files); ok {
fileSvc := NewFileService()
texts, images, err := fileSvc.GetFileContents(userID, fileDicts, raw)
if err != nil {
common.Warn("GetFileContents failed, falling back to string splitting",
zap.Error(err))
} else {
return texts, images
}
}
// ── Mode 2: string content fallback (backward compat) ──
var texts []string
var images []string
if raw {
// Mirrors Python raw=True: FileService.get_files already
// separated images; only non-image content arrives here.
switch v := files.(type) {
case []string:
for _, f := range v {
f = strings.TrimSpace(f)
if f != "" {
texts = append(texts, f)
}
}
case []interface{}:
for _, f := range v {
if s, ok := f.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
texts = append(texts, s)
}
}
}
}
return texts, images
}
// raw=false: split by "data:" prefix.
process := func(f string) {
f = strings.TrimSpace(f)
if f == "" {
return
}
if strings.HasPrefix(f, "data:") {
images = append(images, f)
} else {
texts = append(texts, f)
}
}
switch v := files.(type) {
case []string:
for _, f := range v {
process(f)
}
case []interface{}:
for _, f := range v {
if s, ok := f.(string); ok {
process(s)
}
}
}
return texts, images
}
// parseFileDicts attempts to parse files as a list of file-dict maps
// (the Python-compatible format from messages[-1]["files"]).
// Returns the parsed slice and true on success.
func parseFileDicts(files interface{}) ([]map[string]interface{}, bool) {
switch v := files.(type) {
case []map[string]interface{}:
if len(v) == 0 {
return nil, false
}
// Verify the first element has a recognizable file-dict key.
if _, ok := v[0]["id"]; ok {
return v, true
}
return nil, false
case []interface{}:
if len(v) == 0 {
return nil, false
}
// Check if the first element is a map with file-dict keys.
if m, ok := v[0].(map[string]interface{}); ok {
if _, hasID := m["id"]; hasID {
result := make([]map[string]interface{}, len(v))
for i, item := range v {
if m2, mok := item.(map[string]interface{}); mok {
result[i] = m2
} else {
return nil, false
}
}
return result, true
}
}
}
return nil, false
}
// cleanTTSText sanitizes text for TTS synthesis.
// Mirrors dialog_service.py:1404-1423.
func cleanTTSText(text string) string {
if text == "" {
return ""
}
// Strip control chars.
controlRe := regexp.MustCompile(`[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]`)
text = controlRe.ReplaceAllString(text, "")
// Strip emojis.
emojiRe := regexp.MustCompile("[\U0001f600-\U0001f64f\U0001f300-\U0001f5ff\U0001f680-\U0001f6ff\U0001f1e0-\U0001f1ff\U00002700-\U000027bf\U0001f900-\U0001f9ff\U0001fa70-\U0001faff\U0001fad0-\U0001faff]+")
text = emojiRe.ReplaceAllString(text, "")
// Collapse whitespace.
wsRe := regexp.MustCompile(`\s+`)
text = wsRe.ReplaceAllString(text, " ")
text = strings.TrimSpace(text)
if len(text) > 500 {
text = text[:500]
}
return text
}
// synthesizeTTS calls the TTS model to convert text to audio.
// Mirrors dialog_service.py:1426-1432.
func (s *ChatPipelineService) synthesizeTTS(ttsModel *modelModule.ChatModel, text string) interface{} {
if ttsModel == nil || text == "" {
return nil
}
text = cleanTTSText(text)
if text == "" {
return nil
}
ttsResp, err := ttsModel.ModelDriver.AudioSpeech(
ttsModel.ModelName, &text, ttsModel.APIConfig, &modelModule.TTSConfig{Format: "mp3"},
)
if err != nil {
common.Warn("TTS synthesis failed", zap.Error(err))
return nil
}
if ttsResp == nil || len(ttsResp.Audio) == 0 {
return nil
}
return ttsResp.Audio
}
// truncateForLog returns at most n characters of s, appending an
// ellipsis when truncated. Used to keep zap log lines bounded.
func truncateForLog(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
return s[:n] + "..."
}
// resolveReferenceMetadata mirrors Python's
// `resolve_reference_metadata_preferences` in
// api/utils/reference_metadata_utils.py:22-62. Returns (include,
// fields). The Python algorithm:
//
// resolved = {**config["reference_metadata"], **request["reference_metadata"]}
// if "include_metadata" in request: resolved["include"] = ...
// if "metadata_fields" in request: resolved["fields"] = ...
// include = bool(resolved.get("include", False))
// fields = resolved.get("fields") → list of strings
//
// Config is `promptConfig["reference_metadata"]`, request is `kwargs`.
// `kwargs` takes precedence for both `include_metadata` (legacy) and
// `metadata_fields` (legacy), and for the entire `reference_metadata`
// sub-dict (preferred).
func (s *ChatPipelineService) resolveReferenceMetadata(promptConfig map[string]interface{}, kwargs map[string]interface{}) (bool, []string) {
resolved := map[string]interface{}{}
// Layer 1: prompt_config["reference_metadata"] (config).
if promptConfig != nil {
if cfgRef, ok := promptConfig["reference_metadata"].(map[string]interface{}); ok {
for k, v := range cfgRef {
resolved[k] = v
}
}
}
// Layer 2: kwargs["reference_metadata"] (request, takes precedence).
if kwargs != nil {
if reqRef, ok := kwargs["reference_metadata"].(map[string]interface{}); ok {
for k, v := range reqRef {
resolved[k] = v
}
}
// Layer 3: legacy request keys (kwargs).
if v, ok := kwargs["include_metadata"]; ok {
if b, ok := v.(bool); ok {
resolved["include"] = b
}
}
if v, ok := kwargs["metadata_fields"]; ok {
resolved["fields"] = v
}
}
include, _ := resolved["include"].(bool)
if !include {
return false, nil
}
rawFields, ok := resolved["fields"]
if !ok || rawFields == nil {
return true, nil
}
switch v := rawFields.(type) {
case []string:
return true, v
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return true, out
}
return true, nil
}
// enrichChunksWithMetadata enriches chunk records in kbinfos with document-level
// metadata. Mirrors Python's enrich_chunks_with_document_metadata() in
// api/utils/reference_metadata_utils.py.
func (s *ChatPipelineService) enrichChunksWithMetadata(kbinfos map[string]interface{}, tenantID string, fields []string) {
chunksRaw, ok := kbinfos["chunks"].([]map[string]interface{})
if !ok || len(chunksRaw) == 0 {
return
}
chunks := make([]map[string]interface{}, 0, len(chunksRaw))
chunks = append(chunks, chunksRaw...)
if len(chunks) == 0 {
return
}
s.MetadataSvc.EnrichChunksWithDocMetadata(chunks, tenantID, fields)
}
// kbPrompt builds knowledge prompt blocks from retrieved chunks.
// Mirrors Python's kb_prompt() in rag/prompts/generator.py.
func (s *ChatPipelineService) kbPrompt(kbinfos map[string]interface{}, maxTokens int) []string {
chunksRaw, ok := kbinfos["chunks"].([]map[string]interface{})
if !ok || len(chunksRaw) == 0 {
return nil
}
// Pass 1: count content tokens to determine how many chunks fit.
type chunkContent struct {
content string
}
contents := make([]chunkContent, 0, len(chunksRaw))
for _, ck := range chunksRaw {
c := getMapString(ck, "content_with_weight", "content")
if c == "" {
continue
}
contents = append(contents, chunkContent{content: c})
}
usedTokenCount := 0
chunksNum := 0
for _, cc := range contents {
usedTokenCount += graph.NumTokensFromString(cc.content)
chunksNum++
if float64(maxTokens)*0.97 < float64(usedTokenCount) {
common.Warn("Not all the retrieval into prompt",
zap.Int("kept", chunksNum),
zap.Int("total", len(contents)))
break
}
}
// Pass 2: format chunks with tree structure, capped at chunksNum.
if chunksNum > len(chunksRaw) {
chunksNum = len(chunksRaw)
}
var result []string
for i := 0; i < chunksNum; i++ {
ck := chunksRaw[i]
c := getMapString(ck, "content_with_weight", "content")
if c == "" {
continue
}
cnt := fmt.Sprintf("\nID: %d", i)
cnt += drawNode("Title", getMapString(ck, "docnm_kwd", "document_name"))
cnt += drawNode("URL", getMapString(ck, "url"))
if meta, ok := ck["document_metadata"].(map[string]interface{}); ok {
for k, v := range meta {
cnt += drawNode(k, v)
}
}
cnt += "\n└── Content:\n"
cnt += c
result = append(result, cnt)
}
return result
}
// formatPrompt substitutes {key} placeholders in a prompt string.
func (s *ChatPipelineService) formatPrompt(template string, kwargs map[string]interface{}) string {
result := template
for key, value := range kwargs {
placeholder := "{" + key + "}"
if strings.Contains(result, placeholder) {
strVal := fmt.Sprintf("%v", value)
result = strings.ReplaceAll(result, placeholder, strVal)
}
}
// Replace any remaining {unknown} placeholders with empty string.
for _, key := range []string{"knowledge", "quote"} {
placeholder := "{" + key + "}"
if strings.Contains(result, placeholder) {
result = strings.ReplaceAll(result, placeholder, " ")
}
}
return result
}
// messageFitIn trims messages to fit within a token budget.
// Mirrors Python's message_fit_in() in rag/prompts/generator.py.
//
// Strategy:
// 1. If everything fits → return as-is.
// 2. Keep all system messages + the last user/assistant message.
// 3. If still too large, trim content proportionally:
// - System dominates (>80%) → preserve last message first.
// - Otherwise → preserve system first.
func (s *ChatPipelineService) messageFitIn(messages []map[string]interface{}, maxTokens int) (int, []map[string]interface{}) {
if maxTokens <= 0 {
maxTokens = 8192
}
// Step 1: everything fits.
totalTokens := s.countAllTokens(messages)
if totalTokens < maxTokens {
return totalTokens, messages
}
// Step 2: keep all system messages + the last message.
result := make([]map[string]interface{}, 0)
for _, m := range messages {
if role, _ := m["role"].(string); role == "system" {
result = append(result, m)
}
}
if len(messages) > 1 {
result = append(result, messages[len(messages)-1])
}
totalTokens = s.countAllTokens(result)
if totalTokens < maxTokens {
return totalTokens, result
}
// Step 3: trim content to fit.
ll := graph.NumTokensFromString(s.stringContent(result[0]))
ll2 := graph.NumTokensFromString(s.stringContent(result[len(result)-1]))
total := ll + ll2
if total <= 0 {
return 0, result
}
if len(result) == 1 {
result[0]["content"] = graph.TrimContentToTokenLimit(s.stringContent(result[0]), maxTokens)
return s.countAllTokens(result), result
}
if float64(ll)/float64(total) > 0.8 {
preservedLast := min(ll2, maxTokens)
result[len(result)-1]["content"] = graph.TrimContentToTokenLimit(s.stringContent(result[len(result)-1]), preservedLast)
remaining := max(0, maxTokens-preservedLast)
result[0]["content"] = graph.TrimContentToTokenLimit(s.stringContent(result[0]), remaining)
} else {
preservedSystem := min(ll, maxTokens)
result[0]["content"] = graph.TrimContentToTokenLimit(s.stringContent(result[0]), preservedSystem)
remaining := max(0, maxTokens-preservedSystem)
result[len(result)-1]["content"] = graph.TrimContentToTokenLimit(s.stringContent(result[len(result)-1]), remaining)
}
return s.countAllTokens(result), result
}
// countAllTokens returns the total token count across all messages.
func (s *ChatPipelineService) countAllTokens(messages []map[string]interface{}) int {
total := 0
for _, m := range messages {
total += graph.NumTokensFromString(s.stringContent(m))
}
return total
}
// stringContent extracts the "content" string from a message map, or "".
func (s *ChatPipelineService) stringContent(m map[string]interface{}) string {
c, _ := m["content"].(string)
return c
}
// buildChatMessages converts the internal message representation to
// modelModule.Message for the driver.
func (s *ChatPipelineService) buildChatMessages(systemContent string, messages []map[string]interface{}) []modelModule.Message {
var result []modelModule.Message
if systemContent != "" {
result = append(result, modelModule.Message{Role: "system", Content: systemContent})
}
for _, m := range messages {
role, _ := m["role"].(string)
content := m["content"]
if role == "" || content == nil {
continue
}
result = append(result, modelModule.Message{Role: role, Content: content})
}
return result
}
// buildChatDriver creates a ChatModel wrapper from the chat.
func (s *ChatPipelineService) buildChatDriver(chat *entity.Chat, chatModel *modelModule.ChatModel) *modelModule.ChatModel {
if chatModel != nil {
return chatModel
}
driver, modelName, apiConfig, _, err := s.ModelProviderSvc.GetChatModelConfig(chat.TenantID, chat.LLMID)
if err != nil {
return nil
}
return modelModule.NewChatModel(driver, &modelName, apiConfig)
}
// HydrateChunkVectors fills the `vector` field on each chunk in `kbinfos`
// that lacks one, by issuing a single batched fetch via
// RetrievalService.FetchChunkVectors. Mirrors Python's
// `async_chat._hydrate_chunk_vectors` at
// api/db/services/dialog_service.py:62-106.
//
// The vector dimension is auto-detected from chunks that already carry a
// vector. If no chunk has a vector yet, no fetch is attempted (returns 0).
//
// Returns the number of chunks that gained a vector.
//
// Skips:
// - chunks that already have a non-empty `vector`
// - chunks without a `chunk_id`
//
// Errors are non-fatal: caller logs and proceeds with whatever vectors
// are available. InsertCitations tolerates missing vectors by falling
// back to token-only similarity (when the chat's
// vector_similarity_weight allows).
//
// Parameters:
// - tenantIDs: tenant ID(s) to derive index/table names (ragflow_<tid>).
// If empty, no fetch is attempted.
func HydrateChunkVectors(ctx context.Context, kbinfos map[string]interface{}, tenantIDs []string, kbIDs []string, docEngine engine.DocEngine) (int, error) {
if kbinfos == nil {
return 0, nil
}
chunksRaw, ok := kbinfos["chunks"].([]map[string]interface{})
if !ok || len(chunksRaw) == 0 {
return 0, nil
}
if docEngine == nil {
docEngine = engine.Get()
}
if docEngine == nil {
return 0, nil
}
// Auto-detect vector dimension from chunks that already carry a
// vector. If none do, there is nothing to hydrate against.
var dim int
var missing []string
for _, cm := range chunksRaw {
if cv, ok := cm["vector"].([]float64); ok && len(cv) > 0 {
if dim == 0 {
dim = len(cv)
}
continue
}
if cid, ok := cm["chunk_id"].(string); ok && cid != "" {
missing = append(missing, cid)
}
}
if len(missing) == 0 || dim == 0 || len(tenantIDs) == 0 {
return 0, nil
}
// Use RetrievalService which mirrors Python's Dealer.fetch_chunk_vectors.
retrievalSvc := nlp.NewRetrievalService(docEngine, dao.NewDocumentDAO())
vectors, err := retrievalSvc.FetchChunkVectors(ctx, missing, tenantIDs, kbIDs, dim)
if err != nil {
common.Warn("HydrateChunkVectors: FetchChunkVectors failed", zap.Error(err))
return 0, err
}
// Stitch the vectors back onto the chunks.
hits := 0
for _, cm := range chunksRaw {
if cv, ok := cm["vector"].([]float64); ok && len(cv) > 0 {
continue
}
cid, _ := cm["chunk_id"].(string)
if cid == "" {
continue
}
vec, ok := vectors[cid]
if !ok || len(vec) == 0 {
continue
}
cm["vector"] = vec
hits++
}
common.Debug("HydrateChunkVectors complete",
zap.Int("hits", hits), zap.Int("requested", len(missing)))
return hits, nil
}
// embeddingModelEmbedder adapts an EmbeddingModel to the Embedder interface.
type embeddingModelEmbedder struct {
embModel *modelModule.EmbeddingModel
}
func (e *embeddingModelEmbedder) Encode(texts []string) ([][]float64, error) {
config := &modelModule.EmbeddingConfig{Dimension: 0}
embeds, err := e.embModel.ModelDriver.Embed(e.embModel.ModelName, texts, e.embModel.APIConfig, config)
if err != nil {
return nil, err
}
vecs := make([][]float64, len(embeds))
for i, v := range embeds {
vecs[i] = v.Embedding
}
return vecs, nil
}
// decorateAnswer applies citation insertion, reference construction,
// timing stats, token accounting, TTS, and Langfuse generation end to
// the final answer.
//
// P1: the `timer` parameter carries the per-phase durations emitted in the
// `## Time elapsed:` block of the prompt. Caller must have called
// timer.Exit() for PhaseGenerateAnswer before invoking this function.
func (s *ChatPipelineService) decorateAnswer(
ctx context.Context,
answer string,
kbinfos map[string]interface{},
prompt string,
questions []string,
usedTokenCount int,
timer *common.Timer,
embModel *modelModule.EmbeddingModel,
vectorSimilarityWeight float64,
quote bool,
ttsModel *modelModule.ChatModel,
langfuseTraceID string,
llmModelConfig map[string]interface{},
tenantID string,
tenantIDs []string,
hasKnowledges bool,
) AsyncChatResult {
// Handle think markers: split on </think>.
think := ""
ans := answer
if strings.Contains(answer, "</think>") {
parts := strings.Split(answer, "</think>")
if len(parts) == 2 {
think = parts[0] + "</think>"
ans = strings.TrimSpace(parts[1])
}
}
var citationIdx map[int]struct{}
var refs map[string]interface{}
// Citation insertion: encode answer sentences, score against chunks,
// and insert [ID:N] markers. Mirrors Python's insert_citations().
//
// P0.11 (CITATION_MARKER_PATTERN pre-check): if the LLM already emitted
// citation markers in canonical or near-canonical form, skip
// insertCitations to avoid double-tagging. Mirrors
// dialog_service.py:790-802.
if hasKnowledges && quote {
chunksRaw, ok := kbinfos["chunks"].([]map[string]interface{})
if ok && len(chunksRaw) > 0 {
// P7 — _hydrate_chunk_vectors. Mirrors
// dialog_service.py:794. If any chunk lacks a `vector`
// field (true for the ES path; Infinity ships vectors
// inline), fetch them in one batched engine call. We only
// need this when we'll actually call insertCitations
// (i.e., the LLM didn't already emit markers).
if embModel != nil && !HasCitationMarkers(ans) {
if _, err := HydrateChunkVectors(ctx, kbinfos, tenantIDs, nil, engine.Get()); err != nil {
common.Warn("hydrate chunk vectors failed", zap.Error(err))
}
}
if embModel != nil && !HasCitationMarkers(ans) {
// Build chunkVectors aligned with chunksRaw.
chunkVectors := make([][]float64, len(chunksRaw))
allVec := len(chunksRaw) > 0
for i, cm := range chunksRaw {
cv, _ := cm["vector"].([]float64)
chunkVectors[i] = cv
if len(cv) == 0 {
allVec = false
}
}
if allVec {
embedder := &embeddingModelEmbedder{embModel: embModel}
if decorated, cited := InsertCitations(ans, NewSourcedChunks(chunksRaw), embedder, chunkVectors); len(cited) > 0 {
ans = decorated
citationIdx = make(map[int]struct{})
for _, ci := range cited {
citationIdx[ci] = struct{}{}
}
}
}
} else {
// P0.11 pre-check matched: collect indices from existing
// markers instead of calling insertCitations.
for _, ci := range ExtractCitationMarkers(ans, len(chunksRaw)) {
if citationIdx == nil {
citationIdx = make(map[int]struct{})
}
citationIdx[ci] = struct{}{}
}
}
}
// repair_bad_citation_formats — runs even when chunks are empty.
// Mirrors dialog_service.py:818.
if ok {
ans = RepairBadCitationFormats(ans)
for _, ci := range ExtractCitationMarkers(ans, len(chunksRaw)) {
if citationIdx == nil {
citationIdx = make(map[int]struct{})
}
citationIdx[ci] = struct{}{}
}
}
// Map cited chunk indices to doc_ids and filter doc_aggs.
// Mirrors dialog_service.py:820-824.
if len(citationIdx) > 0 {
citedDocIDs := make(map[string]struct{})
if chunksRaw, ok := kbinfos["chunks"].([]map[string]interface{}); ok {
for ci := range citationIdx {
if ci >= 0 && ci < len(chunksRaw) {
cm := chunksRaw[ci]
if docID, ok := cm["doc_id"].(string); ok && docID != "" {
citedDocIDs[docID] = struct{}{}
}
}
}
}
if len(citedDocIDs) > 0 {
if docAggsRaw, ok := kbinfos["doc_aggs"].([]interface{}); ok && len(docAggsRaw) > 0 {
var filtered []interface{}
for _, da := range docAggsRaw {
if dam, ok := da.(map[string]interface{}); ok {
if docID, ok := dam["doc_id"].(string); ok {
if _, cited := citedDocIDs[docID]; cited {
filtered = append(filtered, da)
}
}
}
}
if len(filtered) > 0 {
kbinfos["doc_aggs"] = filtered
}
}
}
}
}
// Build refs: deepcopy kbinfos and strip vectors — done whenever
// hasKnowledges is true, regardless of quote flag.
// Mirrors dialog_service.py:826-829.
if hasKnowledges {
refs = make(map[string]interface{})
for k, v := range kbinfos {
refs[k] = v
}
if chunksRaw, ok := refs["chunks"].([]map[string]interface{}); ok {
newChunks := make([]map[string]interface{}, 0, len(chunksRaw))
for _, cm := range chunksRaw {
newChunk := make(map[string]interface{})
for ck, cv := range cm {
if ck == "vector" {
continue
}
newChunk[ck] = cv
}
newChunks = append(newChunks, newChunk)
}
refs["chunks"] = chunksFormat(newChunks)
}
}
// Check for invalid API key errors (outside knowledges guard).
// Mirrors dialog_service.py:831-832.
if strings.Contains(strings.ToLower(ans), "invalid key") ||
strings.Contains(strings.ToLower(ans), "invalid api") {
ans += " Please set LLM API-Key in 'User Setting -> Model providers -> API-Key'"
}
finishChatTs := time.Now()
// Build timing stats.
// P1: emit Timer.Markdown() (6 phase lines + Total) and then the
// token-count / token-speed lines that the existing OpenAI endpoint
// already exposes. Total wall-clock is rounded to ms.
totalMs := timer.Total().Seconds() * 1000
tkNum := graph.NumTokensFromString(think + ans)
prompt += fmt.Sprintf("\n\n### Query:\n%s", strings.Join(questions, " "))
timeStats := prompt + timer.Markdown() + "\n"
timeStats += fmt.Sprintf(" - Generated tokens(approximately): %d\n", tkNum)
if totalMs > 0 {
timeStats += fmt.Sprintf(" - Token speed: %d/s", int(float64(tkNum)/(totalMs/1000.0)))
}
// TTS synthesis for the final answer.
audioBinary := s.synthesizeTTS(ttsModel, think+ans)
// Langfuse generation end observation.
if langfuseTraceID != "" {
if lfClient, ok := ctx.Value(langfuseCtxKey).(*LangfuseClient); ok && lfClient != nil {
// Mirrors dialog_service.py:853-854. Python extracts
// everything from `### Query:` onwards (the time-elapsed
// + token-usage block) and replaces \n with " \n" for
// markdown line breaks.
langfuseOutput := langfuseExtractTimeElapsed(timeStats)
usage := &LangfuseUsage{
PromptTokens: usedTokenCount,
CompletionTokens: tkNum,
TotalTokens: usedTokenCount + tkNum,
}
modelName := ""
if llmModelConfig != nil {
if mn, ok := llmModelConfig["llm_name"].(string); ok {
modelName = mn
}
}
_ = lfClient.PostGeneration(ctx, LangfuseGeneration{
ID: fmt.Sprintf("gen-%s", langfuseTraceID),
TraceID: langfuseTraceID,
Name: "chat",
Model: modelName,
StartTime: time.Now().UTC().Format(time.RFC3339Nano),
EndTime: time.Now().UTC().Format(time.RFC3339Nano),
Output: langfuseOutput,
Usage: usage,
})
}
}
return AsyncChatResult{
Answer: think + ans,
Reference: refs,
AudioBinary: audioBinary,
// Fix 7: Apply the markdown line-break substitution
// re.sub(r"\n", " \n", prompt) at the very end, matching
// dialog_service.py:865. This converts single \n to " \n"
// so multi-line prompt text renders as a single markdown
// paragraph instead of being broken into separate lines.
Prompt: strings.ReplaceAll(timeStats, "\n", " \n"),
CreatedAt: float64(finishChatTs.Unix()),
Final: false, // caller sets Final = true
}
}
// langfuseExtractTimeElapsed extracts the time-elapsed + token-usage
// block from the prompt and applies the \n → " \n" substitution.
// Mirrors dialog_service.py:853-854:
//
// langfuse_output = "\n" + re.sub(r"^.*?(### Query:.*)", r"\1", prompt, flags=re.DOTALL)
// langfuse_output = {"time_elapsed:": re.sub(r"\n", " \n", langfuse_output), ...}
func langfuseExtractTimeElapsed(prompt string) string {
const marker = "### Query:"
idx := strings.Index(prompt, marker)
if idx < 0 {
// Fallback: return the whole prompt with \n substitution.
return strings.ReplaceAll(prompt, "\n", " \n")
}
return strings.ReplaceAll(prompt[idx:], "\n", " \n")
}
// extractVisibleAnswer mirrors Python's _extract_visible_answer.
func (s *ChatPipelineService) extractVisibleAnswer(text string) string {
return ExtractVisibleAnswer(text)
}
// citationPrompt returns the citation instruction prompt.
// Mirrors Python's citation_prompt() in rag/prompts/generator.py.
func citationPrompt() string {
return "\n\n### Citation\nWhen answering, please cite sources using the format [ID:N] " +
"(where N is the chunk number) after each sentence where the information from that chunk is used."
}
// -----------------------------------------------------------------------
// Moved from sql_fallback.go (2026-06-12). SQL retrieval system, repair
// helpers, and Python parity helpers. Kept in async_chat.go because the
// orchestrator entry point is s.useSQL at async_chat.go:319.
// -----------------------------------------------------------------------
// SQL retrieval system + user prompts, dispatched by engine type.
// Mirrors dialog_service.py:1031-1105. The Go port previously used a
// single engine-agnostic prompt, which made Infinity/OceanBase queries
// fail because the LLM didn't know to use json_extract_string. These
// constants restore parity with Python's three-way engine dispatch.
// infinitySQLSysPrompt is for Infinity's JSON 'chunk_data' column.
// References docnm (no _kwd suffix) per the Python prompt at
// dialog_service.py:1035-1052.
const infinitySQLSysPrompt = `You are a Database Administrator. Write SQL for a table with JSON 'chunk_data' column.
JSON Extraction: json_extract_string(chunk_data, '$.FieldName')
Numeric Cast: CAST(json_extract_string(chunk_data, '$.FieldName') AS INTEGER/FLOAT)
NULL Check: json_extract_isnull(chunk_data, '$.FieldName') == false
RULES:
1. Use EXACT field names (case-sensitive) from the list below
2. For SELECT: include doc_id, docnm, and json_extract_string() for requested fields
3. For COUNT: use COUNT(*) or COUNT(DISTINCT json_extract_string(...))
4. Add AS alias for extracted field names
5. DO NOT select 'content' field
6. Only add NULL check (json_extract_isnull() == false) in WHERE clause when:
- Question asks to "show me" or "display" specific columns
- Question mentions "not null" or "excluding null"
- Add NULL check for count specific column
- DO NOT add NULL check for COUNT(*) queries (COUNT(*) counts all rows including nulls)
7. json_extract_string() returns JSON-quoted strings ("value"), so WHERE comparisons MUST wrap values in double-quotes inside single-quotes (no spaces between quotes): '"value"' (e.g. WHERE json_extract_string(chunk_data, '$.name') = '"Alice"')
8. For partial text search, use LIKE with wildcards: '"%value%"' (e.g. WHERE json_extract_string(chunk_data, '$.name') LIKE '"%Alice%"')
9. Output ONLY the SQL, no explanations`
// infinitySQLUserPromptTemplate has 4 %s placeholders:
// table_name, comma-joined field names, bullet list of field names,
// question. Mirrors dialog_service.py:1053-1059.
const infinitySQLUserPromptTemplate = `Table: %s
Fields (EXACT case): %s
%s
Question: %s
Write SQL using json_extract_string() with exact field names. Include doc_id, docnm for data queries. Only SQL.`
// oceanbaseSQLSysPrompt is identical to Infinity's but uses docnm_kwd
// (the _kwd suffix is the OceanBase convention). Mirrors
// dialog_service.py:1064-1081.
const oceanbaseSQLSysPrompt = `You are a Database Administrator. Write SQL for a table with JSON 'chunk_data' column.
JSON Extraction: json_extract_string(chunk_data, '$.FieldName')
Numeric Cast: CAST(json_extract_string(chunk_data, '$.FieldName') AS INTEGER/FLOAT)
NULL Check: json_extract_isnull(chunk_data, '$.FieldName') == false
RULES:
1. Use EXACT field names (case-sensitive) from the list below
2. For SELECT: include doc_id, docnm_kwd, and json_extract_string() for requested fields
3. For COUNT: use COUNT(*) or COUNT(DISTINCT json_extract_string(...))
4. Add AS alias for extracted field names
5. DO NOT select 'content' field
6. Only add NULL check (json_extract_isnull() == false) in WHERE clause when:
- Question asks to "show me" or "display" specific columns
- Question mentions "not null" or "excluding null"
- Add NULL check for count specific column
- DO NOT add NULL check for COUNT(*) queries (COUNT(*) counts all rows including nulls)
7. Output ONLY the SQL, no explanations`
// oceanbaseSQLUserPromptTemplate — same shape as Infinity, docnm_kwd in
// the trailing sentence. Mirrors dialog_service.py:1082-1088.
const oceanbaseSQLUserPromptTemplate = `Table: %s
Fields (EXACT case): %s
%s
Question: %s
Write SQL using json_extract_string() with exact field names. Include doc_id, docnm_kwd for data queries. Only SQL.`
// esSQLSysPrompt is for Elasticsearch / OpenSearch / default engines
// where fields are direct columns (no JSON extraction). Mirrors
// dialog_service.py:1092-1100.
const esSQLSysPrompt = `You are a Database Administrator. Write SQL queries.
RULES:
1. Use EXACT field names from the schema below (e.g., product_tks, not product)
2. Quote field names starting with digit: "123_field"
3. Add IS NOT NULL in WHERE clause when:
- Question asks to "show me" or "display" specific columns
4. Include doc_id/docnm in non-aggregate statement
5. Output ONLY the SQL, no explanations`
// esSQLUserPromptTemplate — 3 %s placeholders: table_name, bullet
// list with types, question. Mirrors dialog_service.py:1101-1105.
const esSQLUserPromptTemplate = `Table: %s
Available fields:
%s
Question: %s
Write SQL using exact field names above. Include doc_id, docnm_kwd for data queries. Only SQL.`
// SQL retrieval repair prompts, split into TWO flows × TWO engine
// families, mirroring dialog_service.py:repair_table_for_missing_source_columns
// (lines 1129-1156) and the execution-error retry at lines 1164-1205.
//
// The previous single-template repair was generic and could not tell
// the LLM to keep using json_extract_string on Infinity, which led
// to fragile repairs. Per-engine prompts make the syntax intent
// explicit on the repair path too.
//
// Flow A (missing-source-columns): the SQL executed successfully but
// the result set is missing doc_id / docnm* columns. We call the LLM
// to rewrite the SQL with those columns added.
// Flow B (execution-error): the SQL failed to execute at all
// (syntax error, unknown column, etc.). We call the LLM with the
// error message and ask for a corrected SQL.
//
// Engine family A (Infinity / OceanBase): data lives in a JSON
// 'chunk_data' column, so JSON-extraction syntax must be preserved.
// Engine family B (Elasticsearch / OpenSearch / default): fields
// are direct columns.
// infinityMissingColumnsRepairPromptTemplate — 5 %s args:
// table_name, JSON field bullets, question, previous_sql,
// expected_doc_name_column. Mirrors dialog_service.py:1132-1143.
// OceanBase shares this template (line 1130 dispatch) with
// expected_doc_name_column="docnm_kwd" instead of "docnm".
const infinityMissingColumnsRepairPromptTemplate = `Table name: %s;
JSON fields available in 'chunk_data' column (use exact names):
%s
Question: %s
Previous SQL:
%s
The previous SQL result is missing required source columns for citations.
Rewrite SQL to keep the same query intent and include doc_id and %s in the SELECT list.
For extracted JSON fields, use json_extract_string(chunk_data, '$.field_name').
Return ONLY SQL.`
// esMissingColumnsRepairPromptTemplate — 4 %s args: table_name,
// ES field bullets (with types), question, previous_sql. Mirrors
// dialog_service.py:1145-1155.
const esMissingColumnsRepairPromptTemplate = `Table name: %s
Available fields:
%s
Question: %s
Previous SQL:
%s
The previous SQL result is missing required source columns for citations.
Rewrite SQL to keep the same query intent and include doc_id and docnm_kwd in the SELECT list.
Return ONLY SQL.`
// infinityExecutionErrorRepairPromptTemplate — 4 %s args:
// table_name, JSON field bullets, question, error. Mirrors
// dialog_service.py:1168-1181. Used for both Infinity and OceanBase
// (line 1165 dispatch).
const infinityExecutionErrorRepairPromptTemplate = `
Table name: %s;
JSON fields available in 'chunk_data' column (use these exact names in json_extract_string):
%s
Question: %s
Please write the SQL using json_extract_string(chunk_data, '$.field_name') with the field names from the list above. Only SQL, no explanations.
The SQL error you provided last time is as follows:
%s
Please correct the error and write SQL again using json_extract_string(chunk_data, '$.field_name') syntax with the correct field names. Only SQL, no explanations.`
// esExecutionErrorRepairPromptTemplate — 4 %s args: table_name,
// ES field bullets (with types), question, error. Mirrors
// dialog_service.py:1184-1198.
const esExecutionErrorRepairPromptTemplate = `
Table name: %s;
Table of database fields are as follows (use the field names directly in SQL):
%s
Question are as follows:
%s
Please write the SQL using the exact field names above, only SQL, without any other explanations or text.
The SQL error you provided last time is as follows:
%s
Please correct the error and write SQL again using the exact field names above, only SQL, without any other explanations or text.`
// useSQL is the Go port of dialog_service.use_sql
// (api/db/services/dialog_service.py:914-1226). It branches on the
// active document engine, asks the chat model to produce SQL,
// optionally repairs it once, and executes the query.
//
// The caller is responsible for resolving fieldMap (typically via
// s.KbService.GetFieldMap in AsyncChat before invoking this) so the
// structured-schema lookup happens once per request and is observable
// in logs at the AsyncChat call site. Pass nil/empty to short-circuit.
//
// Returns:
//
// - ans: a map mirroring the Python use_sql return shape:
//
// {"answer": <string>, "reference": {"chunks": [], "doc_aggs": [], "total": <int>}}
//
// or nil when SQL retrieval doesn't apply / produced no usable
// result. The caller checks `ans != nil && (ans["answer"] != ""
// or non-empty chunks)` to decide whether to short-circuit.
//
// - err: non-nil when something went wrong; caller should log and fall
// through.
func (s *ChatPipelineService) useSQL(
ctx context.Context,
chat *entity.Chat,
kbs []*entity.Knowledgebase,
question string,
chatModel *modelModule.ChatModel,
fieldMap map[string]interface{},
quote bool,
) (ans map[string]interface{}, err error) {
if chat == nil || chatModel == nil || len(kbs) == 0 {
return nil, nil
}
if fieldMap == nil || len(fieldMap) == 0 {
// No structured schema → SQL retrieval doesn't apply.
return nil, nil
}
docEngine := engine.Get()
if docEngine == nil {
return nil, nil
}
// Entry log. Mirrors `logging.debug(f"use_sql: Question: {question}")`
// at dialog_service.py:934.
common.Debug("SQL retrieval: question", zap.String("question", question))
// Build the table name. Infinity: ragflow_{tenant}_{kb_id} (one per
// KB). ES: ragflow_{tenant} (kb_id in WHERE).
tableName := ragflowTableName(chat.TenantID, kbs, docEngine)
// Build engine-specific prompts. Mirrors the three-way dispatch
// at dialog_service.py:1031-1105.
engineName := docEngine.GetType()
sysPrompt, userPrompt, overrideSQL := buildSQLPrompts(engineName, tableName, question, fieldMap)
// Step 1: generate SQL. If the question is a "how many rows in the
// dataset" row-count question, buildSQLPrompts returns a hard-coded
// override and we skip the LLM call entirely (matches Python
// row_count_override at dialog_service.py:1034/1063).
var sqlText string
if overrideSQL != "" {
sqlText = normalizeSQL(overrideSQL)
common.Debug("SQL retrieval: using row-count override",
zap.String("sql", sqlText))
} else {
var sqlErr error
sqlText, sqlErr = generateSQL(ctx, chatModel, sysPrompt, userPrompt)
if sqlErr != nil {
common.Warn("SQL retrieval: LLM generation failed", zap.Error(sqlErr))
return nil, nil
}
}
// Step 1.5: inject the kb_id WHERE filter for ES / OS / OceanBase.
// No-op for Infinity (the table name already encodes the KB scope).
// Mirrors add_kb_filter at dialog_service.py:992-1021, called from
// get_table right after normalize_sql.
if filtered, ok := addKBFilter(sqlText, engineName, kbs); ok {
sqlText = filtered
} else {
common.Warn("SQL retrieval: invalid kb_id UUID; SQL will run unfiltered")
}
// Step 2: try to execute. On failure, repair once with the
// engine-specific execution-error prompt so the LLM regenerates
// correctly (Flow B at dialog_service.py:1164-1205).
rows, execErr := docEngine.RunSQL(ctx, tableName, sqlText, kbIDStrings(kbs), "json")
if execErr != nil {
common.Debug("SQL retrieval: initial execution failed, attempting repair",
zap.String("sql", sqlText), zap.Error(execErr))
repaired, repairErr := repairSQLForExecutionError(
ctx, chatModel, sysPrompt, tableName, question, execErr.Error(), engineName, fieldMap,
)
if repairErr != nil {
common.Warn("SQL retrieval: repair failed", zap.Error(repairErr))
return nil, nil
}
// Re-apply the kb filter after the LLM-driven repair.
if filtered, ok := addKBFilter(repaired, engineName, kbs); ok {
repaired = filtered
}
rows, execErr = docEngine.RunSQL(ctx, tableName, repaired, kbIDStrings(kbs), "json")
if execErr != nil {
common.Warn("SQL retrieval: repaired SQL also failed", zap.Error(execErr))
return nil, nil
}
}
if len(rows) == 0 {
common.Debug("SQL retrieval: execution succeeded but returned 0 rows")
// Empty result set; let vector retrieval try.
return nil, nil
}
// Step 3 (Python parity): for non-aggregate SQL, check that the
// result has source-citation columns (Flow A at
// dialog_service.py:1211-1221). If missing, call the LLM to
// rewrite the SQL with the right columns and retry once. If the
// repair doesn't yield source columns, fall through to the
// best-effort answer (matches Python's `returning best-effort
// answer` log at line 1221).
if !isAggregateSQL(sqlText) && !hasSourceColumns(rows) {
common.Debug("SQL retrieval: result missing source columns; attempting repair",
zap.String("sql", sqlText))
expectedCol := expectedDocNameColumn(engineName)
repaired, repairErr := repairSQLForMissingColumns(
ctx, chatModel, sysPrompt, tableName, question, sqlText, expectedCol, engineName, fieldMap,
)
if repairErr == nil && repaired != "" {
// Re-apply the kb filter after the LLM-driven repair.
if filtered, ok := addKBFilter(repaired, engineName, kbs); ok {
repaired = filtered
}
repairedRows, repairedErr := docEngine.RunSQL(ctx, tableName, repaired, kbIDStrings(kbs), "json")
if repairedErr == nil && len(repairedRows) > 0 && hasSourceColumns(repairedRows) {
common.Debug("SQL retrieval: missing-columns repair succeeded",
zap.String("sql", repaired))
rows = repairedRows
sqlText = repaired
} else {
common.Warn("SQL retrieval: missing-columns repair did not yield source columns; using best-effort answer",
zap.String("sql", repaired))
}
} else if repairErr != nil {
common.Warn("SQL retrieval: missing-columns repair failed; using best-effort answer",
zap.Error(repairErr))
}
}
// 4. Build the answer and reference from the rows. Mirrors Python's
// `return {"answer": ..., "reference": {"chunks": ...,
// "doc_aggs": ...}, "prompt": sys_prompt}` at dialog_service.py:1361
// and 1377-1401. buildSQLReference handles all three branches:
// primary (rows have source columns), aggregate secondary fetch,
// and best-effort empty refs.
answerStr, ref := s.buildSQLReference(
ctx, docEngine, tableName, sqlText, rows,
sysPrompt, engineName, kbs, fieldMap,
)
return map[string]interface{}{
"answer": answerStr,
"reference": ref,
"prompt": sysPrompt,
}, nil
}
// ragflowTableName returns the engine-specific SQL target name.
// Mirrors dialog_service.py:954-963. For Infinity with a single KB,
// validates the kb_id is a canonical UUID before interpolating
// (SQL injection guard matching _assert_valid_uuid at
// dialog_service.py:944-949).
func ragflowTableName(tenantID string, kbs []*entity.Knowledgebase, docEngine engine.DocEngine) string {
if docEngine == nil {
return "ragflow_" + tenantID
}
engineName := docEngine.GetType()
if engineName == "infinity" && len(kbs) == 1 {
if !isValidUUID(kbs[0].ID) {
common.Warn("ragflowTableName: invalid kb_id; falling back to base index",
zap.String("kb_id", kbs[0].ID))
return "ragflow_" + tenantID
}
return fmt.Sprintf("ragflow_%s_%s", tenantID, kbs[0].ID)
}
// Elasticsearch / OpenSearch / default: single index, kb_id in WHERE.
return "ragflow_" + tenantID
}
// isValidUUID returns true if s is a canonical UUID string (8-4-4-4-12
// hex format). Used to validate kb_id before SQL interpolation, matching
// Python's _assert_valid_uuid (dialog_service.py:944-949).
var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}(-?[0-9a-fA-F]{4}){3}-?[0-9a-fA-F]{12}$`)
func isValidUUID(s string) bool {
if s == "" {
return false
}
return uuidRe.MatchString(s)
}
// addKBFilter injects a validated kb_id WHERE filter into sqlText for
// ES / OS / OceanBase engines. Infinity is a no-op because the table
// name already encodes the KB scope. Mirrors dialog_service.py:992-1021.
//
// Returns the (possibly modified) SQL and a boolean indicating whether
// all kb_ids passed UUID validation. When validation fails, the SQL is
// returned unchanged — the engine will likely reject the un-filtered
// query, triggering the repair path (Python's `_assert_valid_uuid` raises
// ValueError, which `get_table`'s try/except catches and routes to the
// repair flow).
//
// If the SQL already has a WHERE clause with `kb_id =`, the filter is
// not duplicated. Otherwise a fresh WHERE is appended, or `kb_id = '...'
// AND` is prepended to an existing WHERE.
func addKBFilter(sqlText, engineName string, kbs []*entity.Knowledgebase) (string, bool) {
if engineName == "infinity" || len(kbs) == 0 {
return sqlText, true
}
// Validate all kb_ids as UUIDs.
for _, kb := range kbs {
if kb == nil || !isValidUUID(kb.ID) {
return sqlText, false
}
}
kbIDs := kbIDStrings(kbs)
var kbFilter string
if len(kbIDs) == 1 {
kbFilter = fmt.Sprintf("kb_id = '%s'", kbIDs[0])
} else {
parts := make([]string, len(kbIDs))
for i, kid := range kbIDs {
parts[i] = fmt.Sprintf("kb_id = '%s'", kid)
}
kbFilter = "(" + strings.Join(parts, " OR ") + ")"
}
lower := strings.ToLower(sqlText)
if !strings.Contains(lower, "where ") {
// No WHERE clause: append one. Honor ORDER BY if present.
if oIdx := strings.Index(lower, "order by"); oIdx >= 0 {
sqlText = sqlText[:oIdx] + " WHERE " + kbFilter + " order by " + sqlText[oIdx+len("order by"):]
} else {
sqlText += " WHERE " + kbFilter
}
} else if !strings.Contains(lower, "kb_id =") && !strings.Contains(lower, "kb_id=") {
// Has WHERE but no kb_id: insert "kb_id = '...' AND" after WHERE.
whereRe := regexp.MustCompile(`(?i)\bwhere\b `)
sqlText = whereRe.ReplaceAllString(sqlText, "where "+kbFilter+" and ")
}
return sqlText, true
}
// generateSQL calls the chat model to produce a SQL SELECT.
// sysPrompt and userPrompt are pre-built by buildSQLPrompts and already
// carry engine-specific instructions (json_extract_string for Infinity/
// OceanBase, direct column access for ES/OS). Thin wrapper over
// chatForSQL.
func generateSQL(
ctx context.Context,
chatModel *modelModule.ChatModel,
sysPrompt, userPrompt string,
) (string, error) {
return chatForSQL(ctx, chatModel, sysPrompt, userPrompt, "sql generation")
}
// buildSQLPrompts returns the (system, user) prompt pair for the
// active document engine, plus an optional row-count override SQL.
// The override is non-empty only for "how many rows in the dataset/
// table/spreadsheet/excel" questions, matching Python's
// row_count_override at dialog_service.py:1034 and 1063.
//
// engineName comes from docEngine.GetType() and is one of:
// "infinity", "oceanbase", "elasticsearch", "opensearch", or any
// other value (treated as the ES/OS default).
//
// Field names are sorted alphabetically for stable test output and
// to match the order-independent iteration of Python's dict.
func buildSQLPrompts(engineName, tableName, question string, fieldMap map[string]interface{}) (sysPrompt, userPrompt, overrideSQL string) {
names := make([]string, 0, len(fieldMap))
for k := range fieldMap {
names = append(names, k)
}
sort.Strings(names)
switch engineName {
case "infinity":
sysPrompt = infinitySQLSysPrompt
bullets := strings.Builder{}
for _, n := range names {
bullets.WriteString(" - " + n + "\n")
}
userPrompt = fmt.Sprintf(
infinitySQLUserPromptTemplate,
tableName,
strings.Join(names, ", "),
strings.TrimRight(bullets.String(), "\n"),
question,
)
if isRowCountQuestion(question) {
overrideSQL = fmt.Sprintf("SELECT COUNT(*) AS rows FROM %s", tableName)
}
case "oceanbase":
sysPrompt = oceanbaseSQLSysPrompt
bullets := strings.Builder{}
for _, n := range names {
bullets.WriteString(" - " + n + "\n")
}
userPrompt = fmt.Sprintf(
oceanbaseSQLUserPromptTemplate,
tableName,
strings.Join(names, ", "),
strings.TrimRight(bullets.String(), "\n"),
question,
)
if isRowCountQuestion(question) {
overrideSQL = fmt.Sprintf("SELECT COUNT(*) AS rows FROM %s", tableName)
}
default:
// Elasticsearch / OpenSearch / unknown — direct column access.
sysPrompt = esSQLSysPrompt
bullets := strings.Builder{}
for _, n := range names {
bullets.WriteString(fmt.Sprintf(" - %s (%v)\n", n, fieldMap[n]))
}
userPrompt = fmt.Sprintf(
esSQLUserPromptTemplate,
tableName,
strings.TrimRight(bullets.String(), "\n"),
question,
)
}
return
}
// isRowCountQuestion returns true when the question is asking for a
// total row count of a dataset/table. Mirrors Python's
// is_row_count_question at dialog_service.py:1023-1028. Uses word-
// boundary regex (not Contains) to match the Python implementation.
var rowCountPhraseRe = regexp.MustCompile(`(?i)\b(how many rows|number of rows|row count)\b`)
var rowCountSubjectRe = regexp.MustCompile(`(?i)\b(dataset|table|spreadsheet|excel)\b`)
func isRowCountQuestion(q string) bool {
q = strings.TrimSpace(q)
if q == "" {
return false
}
return rowCountPhraseRe.MatchString(q) && rowCountSubjectRe.MatchString(q)
}
// -----------------------------------------------------------------------
// Repair helpers (Python parity: dialog_service.py:1129-1205)
// -----------------------------------------------------------------------
// expectedDocNameColumn returns the column name the engine uses for
// the document name in source-citation joins. "docnm" for Infinity
// (no _kwd suffix), "docnm_kwd" for OceanBase / ES / OS / default.
// Mirrors dialog_service.py:965.
func expectedDocNameColumn(engineName string) string {
if engineName == "infinity" {
return "docnm"
}
return "docnm_kwd"
}
// hasSourceColumns reports whether the SQL result has the columns
// needed to build source citations: doc_id and (docnm OR docnm_kwd).
// Mirrors dialog_service.py:967-970. Returns false for empty rows
// (no schema to inspect).
func hasSourceColumns(rows []map[string]interface{}) bool {
if len(rows) == 0 {
return false
}
names := map[string]bool{}
for k := range rows[0] {
names[strings.ToLower(k)] = true
}
if !names["doc_id"] {
return false
}
return names["docnm_kwd"] || names["docnm"]
}
// isAggregateSQL reports whether the SQL contains an aggregate
// function call (count, sum, avg, max, min, distinct). Mirrors
// dialog_service.py:972-974.
var aggregateFnRe = regexp.MustCompile(`(?i)\b(count|sum|avg|max|min|distinct)\s*\(`)
func isAggregateSQL(sqlText string) bool {
return aggregateFnRe.MatchString(sqlText)
}
// sortedFieldNames returns the field_map keys in alphabetical order.
// Used to format prompt bullets deterministically (matches Python's
// dict-iteration order on small maps, and gives stable test output).
func sortedFieldNames(fieldMap map[string]interface{}) []string {
names := make([]string, 0, len(fieldMap))
for k := range fieldMap {
names = append(names, k)
}
sort.Strings(names)
return names
}
// buildMissingColumnsRepairPrompt returns the engine-specific user
// prompt for the missing-source-columns repair flow. The Infinity
// and OceanBase branches share the JSON-column template; ES and
// OpenSearch share the direct-column template. expectedCol is
// "docnm" for Infinity or "docnm_kwd" for everything else.
func buildMissingColumnsRepairPrompt(engineName, tableName, question, prevSQL, expectedCol string, fieldMap map[string]interface{}) string {
isJSONEngine := engineName == "infinity" || engineName == "oceanbase"
names := sortedFieldNames(fieldMap)
bullets := strings.Builder{}
if isJSONEngine {
for _, n := range names {
bullets.WriteString(" - " + n + "\n")
}
return fmt.Sprintf(
infinityMissingColumnsRepairPromptTemplate,
tableName,
strings.TrimRight(bullets.String(), "\n"),
question, prevSQL, expectedCol,
)
}
// ES / OS: include types in bullets
for _, n := range names {
bullets.WriteString(fmt.Sprintf(" - %s (%v)\n", n, fieldMap[n]))
}
return fmt.Sprintf(
esMissingColumnsRepairPromptTemplate,
tableName,
strings.TrimRight(bullets.String(), "\n"),
question, prevSQL,
)
}
// buildExecutionErrorRepairPrompt returns the engine-specific user
// prompt for the execution-error repair flow.
func buildExecutionErrorRepairPrompt(engineName, tableName, question, errMsg string, fieldMap map[string]interface{}) string {
isJSONEngine := engineName == "infinity" || engineName == "oceanbase"
names := sortedFieldNames(fieldMap)
bullets := strings.Builder{}
if isJSONEngine {
for _, n := range names {
bullets.WriteString(" - " + n + "\n")
}
return fmt.Sprintf(
infinityExecutionErrorRepairPromptTemplate,
tableName,
strings.TrimRight(bullets.String(), "\n"),
question, errMsg,
)
}
for _, n := range names {
bullets.WriteString(fmt.Sprintf(" - %s (%v)\n", n, fieldMap[n]))
}
return fmt.Sprintf(
esExecutionErrorRepairPromptTemplate,
tableName,
strings.TrimRight(bullets.String(), "\n"),
question, errMsg,
)
}
// chatForSQL is the shared chat-model invocation for SQL generation
// and both repair flows. Returns the cleaned (normalized) SQL or an
// error. errPrefix is included in error messages to disambiguate
// which flow failed ("sql generation", "sql repair", etc.).
func chatForSQL(
ctx context.Context,
chatModel *modelModule.ChatModel,
sysPrompt, userPrompt, errPrefix string,
) (string, error) {
if chatModel == nil || chatModel.ModelDriver == nil {
return "", fmt.Errorf("nil chat model")
}
// Python uses 0.06 (dialog_service.py:1115) for all SQL LLM calls.
// Match it for parity — 0.0 made the LLM deterministic but produced
// SQL that diverged from the Python reference on some prompts.
tempLow := 0.06
cfg := &modelModule.ChatConfig{
Temperature: &tempLow,
}
modelName := ""
if chatModel.ModelName != nil {
modelName = *chatModel.ModelName
}
msgs := []modelModule.Message{
modelModule.Message{Role: "system", Content: sysPrompt},
modelModule.Message{Role: "user", Content: userPrompt},
}
resp, err := chatModel.ModelDriver.ChatWithMessages(
modelName, msgs, chatModel.APIConfig, cfg,
)
if err != nil {
return "", err
}
if resp == nil || resp.Answer == nil {
return "", fmt.Errorf("%s: empty response", errPrefix)
}
cleaned := normalizeSQL(*resp.Answer)
if cleaned == "" {
return "", fmt.Errorf("%s: empty after normalize", errPrefix)
}
return cleaned, nil
}
// repairSQLForExecutionError calls the LLM to fix SQL that the engine
// refused to execute (syntax error, unknown column, etc.). Engine-
// specific user prompt keeps the right syntax (json_extract_string
// on Infinity, direct field access on ES).
func repairSQLForExecutionError(
ctx context.Context,
chatModel *modelModule.ChatModel,
sysPrompt, tableName, question, errMsg, engineName string,
fieldMap map[string]interface{},
) (string, error) {
userPrompt := buildExecutionErrorRepairPrompt(engineName, tableName, question, errMsg, fieldMap)
return chatForSQL(ctx, chatModel, sysPrompt, userPrompt, "sql repair")
}
// repairSQLForMissingColumns calls the LLM to fix SQL whose result
// set is missing the source-citation columns (doc_id, expectedCol).
// expectedCol is "docnm" for Infinity or "docnm_kwd" for everything
// else — see expectedDocNameColumn.
func repairSQLForMissingColumns(
ctx context.Context,
chatModel *modelModule.ChatModel,
sysPrompt, tableName, question, prevSQL, expectedCol, engineName string,
fieldMap map[string]interface{},
) (string, error) {
userPrompt := buildMissingColumnsRepairPrompt(engineName, tableName, question, prevSQL, expectedCol, fieldMap)
return chatForSQL(ctx, chatModel, sysPrompt, userPrompt, "sql missing-columns repair")
}
// normalizeSQL strips LLM artifacts from a SQL response. Mirrors the
// helper at dialog_service.py:976-990.
func normalizeSQL(s string) string {
if s == "" {
return ""
}
// Remove <think>...</think> blocks.
thinkRe := regexp.MustCompile(`(?s)<think>.*?</think>`)
s = thinkRe.ReplaceAllString(s, "")
// Also strip Chinese reasoning markers (思考...) — some models
// (notably Qwen) emit these instead of <think>. Mirrors
// dialog_service.py:985: `re.sub(r"思考\n.*?\n", "", ...)`.
chineseThinkRe := regexp.MustCompile(`(?s)思考\n.*?\n`)
s = chineseThinkRe.ReplaceAllString(s, "")
// Strip Markdown code fences.
fenceRe := regexp.MustCompile("(?i)```(?:sql)?\\s*")
s = fenceRe.ReplaceAllString(s, "")
fenceEnd := regexp.MustCompile("```\\s*$")
s = fenceEnd.ReplaceAllString(s, "")
// Trim trailing semicolons (ES SQL parser doesn't like them) and
// outer whitespace.
s = strings.TrimSpace(s)
s = strings.TrimRight(s, ";")
return strings.TrimSpace(s)
}
// -----------------------------------------------------------------------
// Python parity helpers (dialog_service.py:56-59, 1238-1309, 1321-1365)
// -----------------------------------------------------------------------
// Redundant-space cleanup regexes. Mirrors
// common.string_utils.remove_redundant_spaces (string_utils.py:20-46).
// Pass 1: drop spaces after a "left boundary" character (parens, <, >).
// Pass 2: drop spaces before a "right boundary" character (parens, !).
var (
redundantSpacePass1Re = regexp.MustCompile(`([^a-z0-9.,)>\x{ff08}]) +([^ ])`) // left boundary + space + non-space
redundantSpacePass2Re = regexp.MustCompile(`([^ ]) +([^a-z0-9.,(<])`) // non-space + space + right boundary
)
// removeRedundantSpaces ports common.string_utils.remove_redundant_spaces.
// Two-pass regex cleanup; both passes use case-insensitive matching.
func removeRedundantSpaces(s string) string {
s = redundantSpacePass1Re.ReplaceAllString(s, "$1$2")
s = redundantSpacePass2Re.ReplaceAllString(s, "$1$2")
return s
}
// ISO timestamp stripping regex. Mirrors the cleanup at
// dialog_service.py:1309. Matches `T13:24:55|` or `T13:24:55.123Z|`.
var isoTimestampCellRe = regexp.MustCompile(`T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+Z)?\|`)
// stripISOTimestamps removes ISO-8601 timestamps that end a markdown
// table cell. Operates on the full joined rows string (not per-cell).
func stripISOTimestamps(rows string) string {
return isoTimestampCellRe.ReplaceAllString(rows, "|")
}
// asAliasRe extracts the `AS alias` portion of a SQL column expression.
var asAliasRe = regexp.MustCompile(`(?i)\s+AS\s+([^\s,)]+)`)
// parenSuffixRe strips `/...` and Chinese-parenthesized suffixes from
// display names (matches the regex in dialog_service.py:1251, 1255, 1263,
// 1269, 1279). The CJK variant `...` is intentionally non-greedy
// and stops at the first nested `` or ``.
var parenSuffixRe = regexp.MustCompile(`(/.*|[^]+)`)
// cleanDisplay applies the Python suffix-cleanup regex to a display name.
func cleanDisplay(s string) string {
return parenSuffixRe.ReplaceAllString(s, "")
}
// mapColumnName translates a raw SQL column name to a human-readable
// display name using the field_map. Mirrors
// dialog_service.py:1238-1280 exactly. Algorithm:
// 1. Special case: literal "count(star)" → "COUNT(*)".
// 2. Try to extract `AS alias`; if alias is in fieldMap, return its
// cleaned display value (exact, then case-insensitive, then alias
// unchanged).
// 3. No AS: try fieldMap[colName] exact, then case-insensitive.
// 4. Still no match: bulk-replace each fieldMap key with its display
// value in the raw column name (handles bare json_extract_string
// expressions without AS).
func mapColumnName(colName string, fieldMap map[string]interface{}) string {
if strings.EqualFold(colName, "count(star)") {
return "COUNT(*)"
}
if m := asAliasRe.FindStringSubmatch(colName); len(m) >= 2 {
alias := strings.Trim(m[1], `"'`)
if disp, ok := fieldMap[alias]; ok {
return cleanDisplay(fmt.Sprintf("%v", disp))
}
for k, v := range fieldMap {
if strings.EqualFold(k, alias) {
return cleanDisplay(fmt.Sprintf("%v", v))
}
}
return alias
}
if disp, ok := fieldMap[colName]; ok {
return cleanDisplay(fmt.Sprintf("%v", disp))
}
colLower := strings.ToLower(colName)
for k, v := range fieldMap {
if strings.ToLower(k) == colLower {
return cleanDisplay(fmt.Sprintf("%v", v))
}
}
result := colName
for k, v := range fieldMap {
result = strings.ReplaceAll(result, k, fmt.Sprintf("%v", v))
}
return cleanDisplay(result)
}
// chunkKBIDForDoc resolves the kb_id for a citation chunk. Mirrors
// dialog_service.py:56-59. Single-kb queries use the chat's known
// kb_id; multi-kb queries read it from the row.
func chunkKBIDForDoc(rowDict map[string]interface{}, kbIDs []string, docID interface{}) string {
if len(kbIDs) == 1 {
return kbIDs[0]
}
if v, ok := rowDict["kb_id"]; ok && v != nil && v != "" {
return fmt.Sprintf("%v", v)
}
if v, ok := rowDict["kb_id_kwd"]; ok && v != nil && v != "" {
return fmt.Sprintf("%v", v)
}
return ""
}
// cleanCellValue renders one cell value: replaces "None" with a space
// then runs the redundant-space cleanup. Mirrors dialog_service.py:1298.
func cleanCellValue(v interface{}) string {
s := fmt.Sprintf("%v", v)
s = strings.ReplaceAll(s, "None", " ")
return removeRedundantSpaces(s)
}
// extractSourceColumnIndexes returns, for a set of SQL result rows,
// parallel slices of column indices that match `doc_id`,
// `docnm_kwd`/`docnm`, and `kb_id`/`kb_id_kwd` (case-insensitive). Also
// returns the full sorted column-name list. The Go RunSQL result is
// already keyed by column name; this helper derives a positional view
// (sorted alphabetically for stable iteration) that mirrors Python's
// `enumerate(tbl["columns"])`.
func extractSourceColumnIndexes(rows []map[string]interface{}) (docIDIdx, docNameIdx, kbIDIdx []int, columns []string) {
if len(rows) == 0 {
return
}
for k := range rows[0] {
columns = append(columns, k)
}
sort.Strings(columns)
for i, c := range columns {
switch strings.ToLower(c) {
case "doc_id":
docIDIdx = append(docIDIdx, i)
case "docnm_kwd", "docnm":
docNameIdx = append(docNameIdx, i)
case "kb_id", "kb_id_kwd":
kbIDIdx = append(kbIDIdx, i)
}
}
return
}
// WHERE-clause extraction for the aggregate secondary fetch.
// Mirrors dialog_service.py:1321.
var whereClauseRe = regexp.MustCompile(`(?i)\bwhere\b(.+?)(?:\bgroup by\b|\border by\b|\blimit\b|$)`)
// limitClauseRe detects whether a SQL already has a LIMIT clause.
var limitClauseRe = regexp.MustCompile(`(?i)\blimit\b`)
// buildChunkFetchSQL extracts the WHERE clause from the original SQL
// and constructs a secondary SQL to fetch source chunks. Mirrors
// dialog_service.py:1327-1331. Returns ("", false) when no WHERE is
// present. The `multiKB` flag controls whether `kb_id` is included
// in the SELECT list (single-kb queries don't need it because the
// caller already knows the kb_id).
func buildChunkFetchSQL(originalSQL, tableName, expectedCol string, multiKB bool) (string, bool) {
m := whereClauseRe.FindStringSubmatch(originalSQL)
if len(m) < 2 {
return "", false
}
where := strings.TrimSpace(m[1])
kbCol := ""
if multiKB {
kbCol = ", kb_id"
}
sql := fmt.Sprintf("select doc_id, %s%s from %s where %s",
expectedCol, kbCol, tableName, where)
if !limitClauseRe.MatchString(sql) {
sql += " limit 20"
}
return sql, true
}
// toIfaceSlice converts a []map[string]interface{} to []interface{} for
// the call-site contract at async_chat.go:334, which type-asserts
// `reference["chunks"].([]map[string]interface{})`.
func toIfaceSlice(maps []map[string]interface{}) []interface{} {
out := make([]interface{}, len(maps))
for i, m := range maps {
out[i] = m
}
return out
}
// -----------------------------------------------------------------------
// Aggregate secondary fetch (dialog_service.py:1311-1367)
// -----------------------------------------------------------------------
// fetchAggregateChunks runs the secondary "select doc_id, docnm[, kb_id]
// from <table> where <extracted_where> [limit 20]" query and uses the
// result to build chunks and doc_aggs. Mirrors the aggregate path in
// dialog_service.py:1311-1365.
//
// Returns (nil, nil) when the secondary fetch should be skipped or
// fails. Skips on Infinity multi-KB (RunSQL rejects), on missing WHERE
// clause, and on engine errors — all matching Python's try/except
// semantics at dialog_service.py:1333-1364.
func (s *ChatPipelineService) fetchAggregateChunks(
ctx context.Context,
docEngine engine.DocEngine,
tableName, originalSQL, expectedCol string,
kbIDs []string,
) (chunks []map[string]interface{}, docAggs []map[string]interface{}) {
multiKB := len(kbIDs) > 1
// Infinity's RunSQL rejects multi-KB (see infinity/sql.go:63-65).
// Python's add_kb_filter is a no-op for Infinity, so this branch is
// never exercised in Python either. Skip explicitly to avoid a
// hard error.
if multiKB && docEngine != nil && docEngine.GetType() == "infinity" {
common.Debug("SQL retrieval: skipping aggregate secondary fetch on Infinity multi-KB",
zap.Strings("kb_ids", kbIDs))
return nil, nil
}
chunksSQL, ok := buildChunkFetchSQL(originalSQL, tableName, expectedCol, multiKB)
if !ok {
common.Debug("SQL retrieval: aggregate secondary fetch skipped (no WHERE clause)",
zap.String("sql", originalSQL))
return nil, nil
}
rows, err := docEngine.RunSQL(ctx, tableName, chunksSQL, kbIDs, "json")
if err != nil {
common.Warn("SQL retrieval: aggregate secondary fetch failed",
zap.String("sql", chunksSQL), zap.Error(err))
return nil, nil
}
if len(rows) == 0 {
return nil, nil
}
docIDIdx, docNameIdx, kbIDIdx, columns := extractSourceColumnIndexes(rows)
if len(docIDIdx) == 0 || len(docNameIdx) == 0 {
common.Warn("SQL retrieval: aggregate secondary fetch missing source columns",
zap.Any("columns", columns))
return nil, nil
}
chunks = make([]map[string]interface{}, 0, len(rows))
docAggMap := map[string]map[string]interface{}{}
for _, r := range rows {
docID := r[columns[docIDIdx[0]]]
docName := r[columns[docNameIdx[0]]]
chunk := map[string]interface{}{"doc_id": docID, "docnm_kwd": docName}
kid := chunkKBIDForDoc(r, kbIDs, docID)
if kid == "" && len(kbIDIdx) > 0 {
if v := r[columns[kbIDIdx[0]]]; v != nil && v != "" {
kid = fmt.Sprintf("%v", v)
}
}
if kid != "" {
chunk["kb_id"] = kid
}
chunks = append(chunks, chunk)
// doc_aggs aggregation: group by doc_id, count occurrences,
// first-seen doc_name wins.
if entry, ok := docAggMap[fmt.Sprintf("%v", docID)]; ok {
entry["count"] = entry["count"].(int) + 1
} else {
docAggMap[fmt.Sprintf("%v", docID)] = map[string]interface{}{
"doc_name": docName,
"count": 1,
}
}
}
docAggs = make([]map[string]interface{}, 0, len(docAggMap))
for did, d := range docAggMap {
docAggs = append(docAggs, map[string]interface{}{
"doc_id": did,
"doc_name": d["doc_name"],
"count": d["count"],
})
}
common.Debug("SQL retrieval: aggregate secondary fetch produced chunks",
zap.Int("chunks", len(chunks)),
zap.Int("doc_aggs", len(docAggs)))
return chunks, docAggs
}
// -----------------------------------------------------------------------
// Answer + reference assembly (replaces renderSQLAnswer)
// -----------------------------------------------------------------------
// buildSQLReference renders the Markdown table answer and assembles
// the reference (chunks + doc_aggs) for a SQL retrieval result. Mirrors
// dialog_service.py:1282-1401.
//
// Three branches match Python:
// 1. hasSrc: rows themselves carry doc_id + docnm*. Build chunks/doc_aggs
// from the rows directly. (Python L1369-1401.)
// 2. isAggregateSQL: source columns missing. Run a secondary fetch to
// build chunks/doc_aggs; preserve the rendered table as the answer.
// (Python L1311-1367.)
// 3. Non-aggregate missing source: best-effort answer with empty refs.
// (Python L1367.)
//
// Scalar shortcut: when the result is a single-cell (1 row, 1 column),
// return the value directly without a table — matches the previous
// renderSQLAnswer behavior and the Python non-aggregate path's
// one-cell edge case.
func (s *ChatPipelineService) buildSQLReference(
ctx context.Context,
docEngine engine.DocEngine,
tableName, originalSQL string,
rows []map[string]interface{},
sysPrompt, engineName string,
kbs []*entity.Knowledgebase,
fieldMap map[string]interface{},
) (string, map[string]interface{}) {
if len(rows) == 0 {
return "No results.", map[string]interface{}{
"chunks": []map[string]interface{}{},
"doc_aggs": []interface{}{},
"total": 0,
}
}
// Scalar shortcut — matches the previous renderSQLAnswer behavior.
if len(rows) == 1 && len(rows[0]) == 1 {
for _, v := range rows[0] {
return cleanCellValue(v), map[string]interface{}{
"chunks": []map[string]interface{}{},
"doc_aggs": []interface{}{},
"total": 1,
}
}
}
kbIDs := kbIDStrings(kbs)
docIDIdx, docNameIdx, kbIDIdx, columns := extractSourceColumnIndexes(rows)
expectedCol := expectedDocNameColumn(engineName)
hasSrc := len(docIDIdx) > 0 && len(docNameIdx) > 0
// Build the set of "display column" indices (everything except
// doc_id, docnm*, kb_id*). Python uses set subtraction at
// dialog_service.py:1232.
exclude := map[int]bool{}
for _, i := range docIDIdx {
exclude[i] = true
}
for _, i := range docNameIdx {
exclude[i] = true
}
for _, i := range kbIDIdx {
exclude[i] = true
}
displayCols := make([]int, 0, len(columns))
for i := range columns {
if !exclude[i] {
displayCols = append(displayCols, i)
}
}
// --- Header ---
var header strings.Builder
header.WriteString("|")
for _, i := range displayCols {
header.WriteString(mapColumnName(columns[i], fieldMap))
header.WriteString("|")
}
if hasSrc {
header.WriteString("Source|")
}
// --- Separator (Python L1285) ---
sep := strings.Repeat("|------", len(displayCols)) + "|"
if hasSrc {
sep += "------|"
}
// --- Body rows + ##N$$ citation markers ---
bodyRows := make([]string, 0, len(rows))
for rowIdx, r := range rows {
var cells strings.Builder
cells.WriteString("|")
for _, i := range displayCols {
cells.WriteString(cleanCellValue(r[columns[i]]))
cells.WriteString("|")
}
if hasSrc {
cells.WriteString(fmt.Sprintf(" ##%d$$|", rowIdx))
}
// Skip rows that are entirely empty/whitespace (Python's
// `if re.sub(r"[ |]+", "", row_str)` filter at L1303).
rowStr := cells.String()
if strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(rowStr, "|", ""), " ", "")) != "" {
bodyRows = append(bodyRows, rowStr)
}
}
rowsJoined := stripISOTimestamps(strings.Join(bodyRows, "\n"))
answer := strings.Join([]string{header.String(), sep, rowsJoined}, "\n")
// --- Reference: chunks + doc_aggs ---
ref := map[string]interface{}{
"chunks": []map[string]interface{}{},
"doc_aggs": []interface{}{},
"total": len(rows),
}
if hasSrc {
// Primary path — build chunks and doc_aggs from rows.
chunks := make([]map[string]interface{}, 0, len(rows))
docAggMap := map[string]map[string]interface{}{}
for _, r := range rows {
did := r[columns[docIDIdx[0]]]
dn := r[columns[docNameIdx[0]]]
entry := map[string]interface{}{"doc_id": did, "docnm_kwd": dn}
if kid := chunkKBIDForDoc(r, kbIDs, did); kid != "" {
entry["kb_id"] = kid
} else if len(kbIDIdx) > 0 {
if v := r[columns[kbIDIdx[0]]]; v != nil && v != "" {
entry["kb_id"] = fmt.Sprintf("%v", v)
}
}
chunks = append(chunks, entry)
docIDKey := fmt.Sprintf("%v", did)
if e, ok := docAggMap[docIDKey]; ok {
e["count"] = e["count"].(int) + 1
} else {
docAggMap[docIDKey] = map[string]interface{}{
"doc_name": dn,
"count": 1,
}
}
}
docAggs := make([]map[string]interface{}, 0, len(docAggMap))
for did, d := range docAggMap {
docAggs = append(docAggs, map[string]interface{}{
"doc_id": did,
"doc_name": d["doc_name"],
"count": d["count"],
})
}
ref["chunks"] = chunksFormat(chunks)
ref["doc_aggs"] = docAggs
return answer, ref
}
// Source columns missing — try the aggregate secondary fetch.
if isAggregateSQL(originalSQL) {
chunks, docAggs := s.fetchAggregateChunks(ctx, docEngine, tableName, originalSQL, expectedCol, kbIDs)
if len(chunks) > 0 {
ref["chunks"] = chunksFormat(chunks)
ref["doc_aggs"] = docAggs
}
return answer, ref
}
// Non-aggregate, no source columns: best-effort empty refs.
common.Debug("SQL retrieval: non-aggregate SQL missing source columns; returning best-effort answer",
zap.String("sql", originalSQL))
return answer, ref
}
// jsonMarshal is a small wrapper around encoding/json to keep this
// file's imports tidy.
func jsonMarshal(v interface{}) ([]byte, error) {
return json.Marshal(v)
}
// kbIDStrings extracts the string KB IDs from a slice of Knowledgebase.
// Returns nil if no KB has a non-empty ID. Mirrors Python's `kb_ids`
// iteration in dialog_service.py:651-660.
func kbTenantIDStrings(kbs []*entity.Knowledgebase) []string {
if len(kbs) == 0 {
return nil
}
seen := make(map[string]struct{})
out := make([]string, 0, len(kbs))
for _, kb := range kbs {
if kb == nil {
continue
}
if kb.TenantID != "" {
if _, ok := seen[kb.TenantID]; !ok {
seen[kb.TenantID] = struct{}{}
out = append(out, kb.TenantID)
}
}
}
if len(out) == 0 {
return nil
}
return out
}
// BuildChatConfig converts the dialog's LLM setting (with optional
// per-request overrides) into a typed ChatConfig for the LLM driver.
// Dialog values are read first; request config values win when present.
func BuildChatConfig(dialog *entity.Chat, config map[string]interface{}) *modelModule.ChatConfig {
cfg := &modelModule.ChatConfig{}
if dialog.LLMSetting != nil {
if v, ok := dialog.LLMSetting["stream"].(bool); ok {
cfg.Stream = &v
}
if v, ok := dialog.LLMSetting["thinking"].(bool); ok {
cfg.Thinking = &v
}
if v, ok := dialog.LLMSetting["max_tokens"].(float64); ok {
i := int(v)
cfg.MaxTokens = &i
}
if v, ok := dialog.LLMSetting["temperature"].(float64); ok {
cfg.Temperature = &v
}
if v, ok := dialog.LLMSetting["top_p"].(float64); ok {
cfg.TopP = &v
}
if v, ok := dialog.LLMSetting["do_sample"].(bool); ok {
cfg.DoSample = &v
}
if v, ok := dialog.LLMSetting["stop"].([]interface{}); ok {
stops := make([]string, 0, len(v))
for _, s := range v {
if str, ok := s.(string); ok {
stops = append(stops, str)
}
}
cfg.Stop = &stops
}
if v, ok := dialog.LLMSetting["model_class"].(string); ok {
cfg.ModelClass = &v
}
if v, ok := dialog.LLMSetting["effort"].(string); ok {
cfg.Effort = &v
}
if v, ok := dialog.LLMSetting["verbosity"].(string); ok {
cfg.Verbosity = &v
}
}
if config != nil {
if v, ok := config["stream"].(bool); ok {
cfg.Stream = &v
}
if v, ok := config["thinking"].(bool); ok {
cfg.Thinking = &v
}
if v, ok := config["max_tokens"].(float64); ok {
i := int(v)
cfg.MaxTokens = &i
}
if v, ok := config["temperature"].(float64); ok {
cfg.Temperature = &v
}
if v, ok := config["top_p"].(float64); ok {
cfg.TopP = &v
}
if v, ok := config["do_sample"].(bool); ok {
cfg.DoSample = &v
}
if v, ok := config["stop"].([]interface{}); ok {
stops := make([]string, 0, len(v))
for _, s := range v {
if str, ok := s.(string); ok {
stops = append(stops, str)
}
}
cfg.Stop = &stops
}
if v, ok := config["model_class"].(string); ok {
cfg.ModelClass = &v
}
if v, ok := config["effort"].(string); ok {
cfg.Effort = &v
}
if v, ok := config["verbosity"].(string); ok {
cfg.Verbosity = &v
}
}
return cfg
}
func kbIDStrings(kbs []*entity.Knowledgebase) []string {
if len(kbs) == 0 {
return nil
}
out := make([]string, 0, len(kbs))
for _, kb := range kbs {
if kb == nil {
continue
}
if kb.ID != "" {
out = append(out, kb.ID)
}
}
if len(out) == 0 {
return nil
}
return out
}
// chunksFormat normalizes raw chunk maps to the frontend-expected field names.
// Mirrors Python's chunks_format in rag/prompts/generator.py:41-65.
func chunksFormat(chunksRaw []map[string]interface{}) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(chunksRaw))
for _, chunk := range chunksRaw {
formatted := map[string]interface{}{
"id": getChunkValue(chunk, "chunk_id", "id"),
"content": getChunkValue(chunk, "content", "content_with_weight"),
"document_id": getChunkValue(chunk, "doc_id", "document_id"),
"document_name": getChunkValue(chunk, "docnm_kwd", "document_name"),
"dataset_id": getChunkValue(chunk, "kb_id", "dataset_id"),
"image_id": getChunkValue(chunk, "image_id", "img_id"),
"positions": getChunkValue(chunk, "positions", "position_int"),
"url": chunk["url"],
"similarity": chunk["similarity"],
"vector_similarity": chunk["vector_similarity"],
"term_similarity": chunk["term_similarity"],
"row_id": chunk["row_id"],
"doc_type": getChunkValue(chunk, "doc_type_kwd", "doc_type"),
"document_metadata": chunk["document_metadata"],
}
result = append(result, formatted)
}
return result
}
// getChunkValue returns the first non-nil value from a chunk map, trying k1 first then k2.
// Mirrors Python's get_value helper in rag/prompts/generator.py:37-38.
func getChunkValue(chunk map[string]interface{}, k1, k2 string) interface{} {
if v, ok := chunk[k1]; ok && v != nil {
return v
}
return chunk[k2]
}