//
//  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, err := s.getModels(ctx, chat)
		if err != nil {
			out <- AsyncChatResult{
				Answer: fmt.Sprintf("**ERROR**: %s", err.Error()),
				Final:  true,
			}
			return
		}

		// 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.ResolveModelType(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.ResolveModelConfig(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
	error,
) {
	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 {
		if err := validateDatasetEmbeddingModels(kbs); err != nil {
			return nil, nil, nil, nil, nil, err
		}
		if kbs[0].EmbdID != "" {
			embdTenantID := kbs[0].TenantID
			driver, modelName, apiConfig, maxTokens, err := s.ModelProviderSvc.ResolveModelConfig(
				embdTenantID, entity.ModelTypeEmbedding, kbs[0].EmbdID,
			)
			if err != nil {
				common.Warn("Failed to get embedding model for chat retrieval",
					zap.String("embdID", kbs[0].EmbdID),
					zap.String("tenantID", embdTenantID),
					zap.Error(err))
				return nil, nil, nil, nil, nil, fmt.Errorf("failed to get embedding model: %w", err)
			}
			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.ResolveModelConfig(
			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, nil
}

// 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]
}
