mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-06 19:38:36 +08:00
### Summary Merge HTTP response functions into common/response.go --------- Signed-off-by: Jin Hai <haijin.chn@gmail.com>
839 lines
25 KiB
Go
839 lines
25 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"ragflow/internal/entity"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"ragflow/internal/common"
|
|
"ragflow/internal/tokenizer"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type OpenAIRequest struct {
|
|
ChatID string
|
|
Model string
|
|
// Chat is the loaded chat entity, mutated in place by MergeGenerationConfig.
|
|
Chat *entity.Chat
|
|
// Messages are pre-normalized: system messages removed, leading assistant
|
|
// removed, content coerced to string (vision parts dropped).
|
|
Messages []map[string]interface{}
|
|
Stream bool
|
|
NeedReference bool
|
|
IncludeRefMetadata bool
|
|
MetadataFields []string
|
|
MetadataCondition map[string]interface{}
|
|
// Internet not plumbed — matches Python's openai_api.py behavior.
|
|
GenerationConfig map[string]interface{}
|
|
}
|
|
|
|
// FormattedChunk is a normalized chunk matching Python's chunks_format output.
|
|
type FormattedChunk struct {
|
|
ID string `json:"id"`
|
|
Content string `json:"content"`
|
|
DocumentID string `json:"document_id"`
|
|
DocumentName string `json:"document_name"`
|
|
DatasetID string `json:"dataset_id"`
|
|
ImageID string `json:"image_id"`
|
|
Positions interface{} `json:"positions"`
|
|
URL interface{} `json:"url"`
|
|
Similarity interface{} `json:"similarity"`
|
|
VectorSimilarity interface{} `json:"vector_similarity"`
|
|
TermSimilarity interface{} `json:"term_similarity"`
|
|
RowID interface{} `json:"row_id"`
|
|
DocType interface{} `json:"doc_type"`
|
|
DocumentMetadata interface{} `json:"document_metadata"`
|
|
}
|
|
|
|
// OpenAICompletionResponse is the non-streaming response payload.
|
|
// The reasoning_tokens quirk (openai_api.py:348-352) lives in the c.JSON call.
|
|
type OpenAICompletionResponse struct {
|
|
Model string
|
|
Content string
|
|
Reference []FormattedChunk
|
|
PromptTokens int
|
|
CompletionTokens int
|
|
TotalTokens int
|
|
Created int64
|
|
}
|
|
|
|
// OpenAIStreamEventKind discriminates stream events.
|
|
type OpenAIStreamEventKind int
|
|
|
|
const (
|
|
OpenAIEventContent OpenAIStreamEventKind = iota // delta.content
|
|
OpenAIEventReasoning // delta.reasoning_content
|
|
OpenAIEventFinal // trailing chunk
|
|
OpenAIEventError // in-band error
|
|
)
|
|
|
|
// OpenAIStreamEvent is yielded by the event-translator inside OpenAIChatCompletions.
|
|
type OpenAIStreamEvent struct {
|
|
Kind OpenAIStreamEventKind
|
|
Delta string // for Content / Reasoning
|
|
FinalAnswer string // for Final
|
|
FinalReference []FormattedChunk
|
|
Error string // for Error
|
|
PromptTokens int
|
|
CompletionTokens int
|
|
TotalTokens int
|
|
}
|
|
|
|
// OpenAIChatService implements the /api/v1/openai/<chat_id>/chat/completions route.
|
|
// It composes ChatPipelineService for the shared RAG pipeline (AsyncChat) while
|
|
// keeping handler-level concerns (message filtering, generation config merge,
|
|
// reference metadata enrichment) on the service itself.
|
|
type OpenAIChatService struct {
|
|
chatSvc *ChatService
|
|
tenantLLMSvc *TenantLLMService
|
|
pipeline *ChatPipelineService
|
|
}
|
|
|
|
func NewOpenAIChatService() *OpenAIChatService {
|
|
return &OpenAIChatService{
|
|
chatSvc: NewChatService(),
|
|
tenantLLMSvc: NewTenantLLMService(),
|
|
pipeline: NewChatPipelineService(),
|
|
}
|
|
}
|
|
|
|
// OpenAIChatRequest mirrors the OpenAI Chat Completions request body.
|
|
// `stop` and `user` are omitted intentionally — JSON unmarshal silently drops them.
|
|
type OpenAIChatRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []map[string]interface{} `json:"messages"`
|
|
Stream *bool `json:"stream,omitempty"`
|
|
ExtraBody interface{} `json:"extra_body,omitempty"`
|
|
|
|
Temperature *float64 `json:"temperature,omitempty"`
|
|
TopP *float64 `json:"top_p,omitempty"`
|
|
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
|
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
|
}
|
|
|
|
func (s *OpenAIChatService) OpenAIChatCompletions(c *gin.Context, userID, chatID string, bodyBytes []byte) {
|
|
var req OpenAIChatRequest
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
s.writeArgError(c, err.Error())
|
|
return
|
|
}
|
|
common.Info("OpenAIChatCompletions started", zap.String("chat_id", chatID))
|
|
|
|
normalizedMessages, err := normalizeOpenAIMessages(req.Messages)
|
|
if err != nil {
|
|
s.writeDataError(c, err.Error())
|
|
return
|
|
}
|
|
if len(normalizedMessages) == 0 {
|
|
s.writeDataError(c, "You have to provide messages.")
|
|
return
|
|
}
|
|
|
|
lastRole, _ := normalizedMessages[len(normalizedMessages)-1]["role"].(string)
|
|
if lastRole != "user" {
|
|
s.writeDataError(c, "The last content of this conversation is not from user.")
|
|
return
|
|
}
|
|
|
|
if req.ExtraBody != nil {
|
|
if _, ok := req.ExtraBody.(map[string]interface{}); !ok {
|
|
s.writeDataError(c, "extra_body must be an object.")
|
|
return
|
|
}
|
|
}
|
|
|
|
var needReference = false
|
|
var includeRefMetadata = false
|
|
var metadataFields []string
|
|
var metadataCondition map[string]interface{}
|
|
if eb, ok := req.ExtraBody.(map[string]interface{}); ok {
|
|
if v, hasRef := eb["reference"].(bool); hasRef {
|
|
needReference = v
|
|
}
|
|
rawRM, hasRM := eb["reference_metadata"]
|
|
if hasRM && rawRM != nil {
|
|
rm, ok := rawRM.(map[string]interface{})
|
|
if !ok {
|
|
s.writeDataError(c, "reference_metadata must be an object.")
|
|
return
|
|
}
|
|
if inc, hasInc := rm["include"].(bool); hasInc {
|
|
includeRefMetadata = inc
|
|
}
|
|
if rawFields, hasFields := rm["fields"]; hasFields && rawFields != nil {
|
|
rawArr, rawOK := rawFields.([]interface{})
|
|
if !rawOK {
|
|
s.writeDataError(c, "reference_metadata.fields must be an array.")
|
|
return
|
|
}
|
|
if len(rawArr) == 0 {
|
|
metadataFields = []string{}
|
|
} else {
|
|
for _, f := range rawArr {
|
|
str, ok := f.(string)
|
|
if !ok {
|
|
s.writeDataError(c, "reference_metadata.fields must be an array.")
|
|
return
|
|
}
|
|
metadataFields = append(metadataFields, str)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if mc, hasMC := eb["metadata_condition"]; hasMC && mc != nil {
|
|
mcMap, isObj := mc.(map[string]interface{})
|
|
if !isObj {
|
|
s.writeDataError(c, "metadata_condition must be an object.")
|
|
return
|
|
}
|
|
if len(mcMap) > 0 {
|
|
metadataCondition = mcMap
|
|
}
|
|
}
|
|
}
|
|
|
|
dialogResp, err := s.chatSvc.GetChat(userID, chatID)
|
|
if err != nil {
|
|
s.writeDataError(c, err.Error())
|
|
return
|
|
}
|
|
dialog := dialogResp.Chat
|
|
resolvedModel := req.Model
|
|
if req.Model == "model" {
|
|
resolvedModel = dialog.LLMID
|
|
if resolvedModel == "" {
|
|
resolvedModel = "model"
|
|
}
|
|
}
|
|
if req.Model != "model" {
|
|
if _, _, _, _, mErr := s.pipeline.ModelProviderSvc.GetChatModelConfig(dialog.TenantID, resolvedModel); mErr != nil {
|
|
s.writeArgError(c, fmt.Sprintf("`llm_id` %s doesn't exist", req.Model))
|
|
return
|
|
}
|
|
apiKey, apiErr := s.tenantLLMSvc.GetAPIKeyFromInstance(dialog.TenantID, req.Model)
|
|
if apiErr != nil || apiKey == "" {
|
|
s.writeDataError(c, fmt.Sprintf("Cannot use specified model %s.", req.Model))
|
|
return
|
|
}
|
|
dialog.LLMID = resolvedModel
|
|
}
|
|
|
|
genCfg := extractGenerationConfig(&req)
|
|
|
|
s.MergeGenerationConfig(dialog, genCfg)
|
|
|
|
stream := req.Stream != nil && *req.Stream
|
|
openaiReq := &OpenAIRequest{
|
|
ChatID: chatID,
|
|
Model: resolvedModel,
|
|
Chat: dialog,
|
|
Messages: normalizedMessages,
|
|
Stream: stream,
|
|
NeedReference: needReference,
|
|
IncludeRefMetadata: includeRefMetadata,
|
|
MetadataFields: metadataFields,
|
|
MetadataCondition: metadataCondition,
|
|
GenerationConfig: genCfg,
|
|
}
|
|
|
|
completionID := fmt.Sprintf("chatcmpl-%s", openaiReq.ChatID)
|
|
|
|
ctx := c.Request.Context()
|
|
lfClient := LangfuseClientFromTenant(ctx, dialog.TenantID, userID, openaiReq.ChatID, openaiReq.Model)
|
|
if lfClient != nil {
|
|
ctx = context.WithValue(ctx, langfuseCtxKey, lfClient)
|
|
defer func() {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = lfClient.Shutdown(shutdownCtx)
|
|
}()
|
|
}
|
|
|
|
filteredMessages := s.filterMessages(openaiReq.Messages)
|
|
|
|
var docIDsStr string
|
|
if openaiReq.MetadataCondition != nil {
|
|
common.Debug("metadata_condition filter started",
|
|
zap.Any("condition", openaiReq.MetadataCondition))
|
|
kbIDs := make([]string, 0, len(dialog.KBIDs))
|
|
for _, raw := range dialog.KBIDs {
|
|
if id, ok := raw.(string); ok && id != "" {
|
|
kbIDs = append(kbIDs, id)
|
|
}
|
|
}
|
|
metas, mdErr := s.pipeline.MetadataSvc.GetFlattedMetaByKBs(kbIDs)
|
|
if mdErr != nil {
|
|
s.writeDataError(c, fmt.Errorf("metadata_condition: load metadata: %w", mdErr).Error())
|
|
return
|
|
}
|
|
docIDsStr = MetadataConditionToDocIDs(metas, openaiReq.MetadataCondition)
|
|
common.Debug("metadata_condition filter ended", zap.String("doc_ids", docIDsStr))
|
|
}
|
|
|
|
common.Debug("OpenAI chat config resolved",
|
|
zap.String("tenant_id", dialog.TenantID),
|
|
zap.String("dialog_id", dialog.ID),
|
|
zap.String("llm_id", dialog.LLMID),
|
|
zap.Any("llm_setting", dialog.LLMSetting),
|
|
zap.Any("request_generation_config", openaiReq.GenerationConfig),
|
|
zap.String("doc_ids", docIDsStr))
|
|
|
|
promptTokens := 0
|
|
if lastMsg := filteredMessages[len(filteredMessages)-1]; lastMsg != nil {
|
|
if content, ok := lastMsg["content"].(string); ok {
|
|
promptTokens = tokenizer.NumTokensFromString(content)
|
|
}
|
|
}
|
|
|
|
chatKwargs := map[string]interface{}{
|
|
"toolcall_session": nil, // no tool calls on OpenAI-compat path
|
|
"tools": nil,
|
|
"quote": needReference,
|
|
}
|
|
if docIDsStr != "" {
|
|
chatKwargs["doc_ids"] = docIDsStr
|
|
}
|
|
|
|
asyncResults, asyncErr := s.pipeline.AsyncChat(ctx, userID, dialog, filteredMessages, openaiReq.Stream, chatKwargs)
|
|
if asyncErr != nil {
|
|
s.writeDataError(c, asyncErr.Error())
|
|
return
|
|
}
|
|
|
|
if stream {
|
|
events := make(chan OpenAIStreamEvent, 16)
|
|
go func() {
|
|
defer close(events)
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
common.Warn("OpenAI streaming goroutine panic", zap.Any("recover", r))
|
|
events <- OpenAIStreamEvent{Kind: OpenAIEventError, Error: fmt.Sprintf("internal error: %v", r)}
|
|
}
|
|
}()
|
|
|
|
var (
|
|
fullContent string
|
|
completionTok int
|
|
deltaCount int
|
|
finalReference []FormattedChunk
|
|
lastResult AsyncChatResult
|
|
)
|
|
|
|
for result := range asyncResults {
|
|
lastResult = result
|
|
|
|
if result.StartToThink || result.EndToThink {
|
|
// Think markers only toggle routing state; no SSE event
|
|
// emitted. Matches Python's _stream_chat_completion_sse
|
|
// which ignores start_to_think/end_to_think flags and
|
|
// never emits "<think>" or "</think>" as content.
|
|
continue
|
|
}
|
|
|
|
if result.Final {
|
|
finalContent := strings.TrimSpace(result.Answer)
|
|
fullContent = finalContent
|
|
if ref, ok := result.Reference["chunks"]; ok {
|
|
if chunks, ok := ref.([]map[string]interface{}); ok {
|
|
finalReference = formatChunks(chunks)
|
|
}
|
|
}
|
|
s.enrichChunksWithDocumentMetadata(finalReference, dialog.TenantID, openaiReq.IncludeRefMetadata, openaiReq.MetadataFields)
|
|
completionTok = tokenizer.NumTokensFromString(result.Answer)
|
|
events <- OpenAIStreamEvent{
|
|
Kind: OpenAIEventFinal,
|
|
FinalAnswer: finalContent,
|
|
FinalReference: finalReference,
|
|
PromptTokens: promptTokens,
|
|
CompletionTokens: completionTok,
|
|
TotalTokens: promptTokens + completionTok,
|
|
}
|
|
return
|
|
}
|
|
|
|
if result.Reasoning != "" {
|
|
completionTok += tokenizer.NumTokensFromString(result.Reasoning)
|
|
events <- OpenAIStreamEvent{Kind: OpenAIEventReasoning, Delta: result.Reasoning}
|
|
}
|
|
|
|
if result.Answer != "" {
|
|
delta := result.Answer
|
|
fullContent += delta
|
|
completionTok += tokenizer.NumTokensFromString(delta)
|
|
events <- OpenAIStreamEvent{Kind: OpenAIEventContent, Delta: delta}
|
|
if deltaCount < 3 {
|
|
common.Debug("OpenAI first content delta",
|
|
zap.Int("delta_index", deltaCount),
|
|
zap.String("delta", result.Answer),
|
|
zap.Int("delta_len", len(result.Answer)))
|
|
deltaCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if finalReference == nil && openaiReq.NeedReference {
|
|
if ref, ok := lastResult.Reference["chunks"]; ok {
|
|
if chunks, ok := ref.([]map[string]interface{}); ok {
|
|
finalReference = formatChunks(chunks)
|
|
}
|
|
}
|
|
}
|
|
s.enrichChunksWithDocumentMetadata(finalReference, dialog.TenantID, openaiReq.IncludeRefMetadata, openaiReq.MetadataFields)
|
|
events <- OpenAIStreamEvent{
|
|
Kind: OpenAIEventFinal,
|
|
FinalAnswer: strings.TrimSpace(fullContent),
|
|
FinalReference: finalReference,
|
|
PromptTokens: promptTokens,
|
|
CompletionTokens: completionTok,
|
|
TotalTokens: promptTokens + completionTok,
|
|
}
|
|
}()
|
|
if err := streamChatCompletionSSE(c, events, completionID, resolvedModel, openaiReq.NeedReference); err != nil {
|
|
s.writeDataError(c, err.Error())
|
|
}
|
|
} else {
|
|
var finalResult AsyncChatResult
|
|
found := false
|
|
for result := range asyncResults {
|
|
if result.Final {
|
|
finalResult = result
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
s.writeDataError(c, "AsyncChat returned no final result")
|
|
return
|
|
}
|
|
|
|
content := strings.TrimSpace(finalResult.Answer)
|
|
completionTokens := tokenizer.NumTokensFromString(content)
|
|
resp := &OpenAICompletionResponse{
|
|
Created: time.Now().Unix(),
|
|
Model: openaiReq.Model,
|
|
Content: content,
|
|
PromptTokens: promptTokens,
|
|
CompletionTokens: completionTokens,
|
|
TotalTokens: promptTokens + completionTokens,
|
|
}
|
|
if openaiReq.NeedReference {
|
|
if ref, ok := finalResult.Reference["chunks"]; ok {
|
|
if chunks, ok := ref.([]map[string]interface{}); ok {
|
|
resp.Reference = formatChunks(chunks)
|
|
}
|
|
}
|
|
s.enrichChunksWithDocumentMetadata(resp.Reference, dialog.TenantID, openaiReq.IncludeRefMetadata, openaiReq.MetadataFields)
|
|
}
|
|
|
|
contextUsed := 0
|
|
for _, m := range openaiReq.Messages {
|
|
if c, ok := m["content"].(string); ok {
|
|
contextUsed += tokenizer.NumTokensFromString(c)
|
|
}
|
|
}
|
|
|
|
choices := []gin.H{{
|
|
"index": 0,
|
|
"finish_reason": "stop",
|
|
"logprobs": nil,
|
|
"message": gin.H{
|
|
"role": "assistant",
|
|
"content": resp.Content,
|
|
},
|
|
}}
|
|
if openaiReq.NeedReference {
|
|
choices[0]["message"].(gin.H)["reference"] = resp.Reference
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": completionID,
|
|
"object": "chat.completion",
|
|
"created": resp.Created,
|
|
"model": resp.Model,
|
|
"usage": gin.H{
|
|
"prompt_tokens": resp.PromptTokens,
|
|
"completion_tokens": resp.CompletionTokens,
|
|
"total_tokens": resp.PromptTokens + resp.CompletionTokens,
|
|
"completion_tokens_details": gin.H{
|
|
"reasoning_tokens": contextUsed,
|
|
"accepted_prediction_tokens": resp.CompletionTokens,
|
|
"rejected_prediction_tokens": 0,
|
|
},
|
|
},
|
|
"choices": choices,
|
|
})
|
|
}
|
|
common.Info("OpenAIChatCompletions completed", zap.String("chat_id", chatID))
|
|
}
|
|
|
|
// MergeGenerationConfig merges request config into dialog.LLMSetting (mutating).
|
|
func (s *OpenAIChatService) MergeGenerationConfig(dialog *entity.Chat, config map[string]interface{}) {
|
|
if config == nil {
|
|
return
|
|
}
|
|
if dialog.LLMSetting == nil {
|
|
dialog.LLMSetting = map[string]interface{}{}
|
|
}
|
|
for k, v := range config {
|
|
dialog.LLMSetting[k] = v
|
|
}
|
|
}
|
|
|
|
// filterMessages drops system messages and leading assistant messages.
|
|
func (s *OpenAIChatService) filterMessages(messages []map[string]interface{}) []map[string]interface{} {
|
|
var out []map[string]interface{}
|
|
for _, m := range messages {
|
|
role, _ := m["role"].(string)
|
|
if role == "system" {
|
|
continue
|
|
}
|
|
if role == "assistant" && len(out) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// cleanCitationMarkers strips "##N$$" markers from the answer.
|
|
func cleanCitationMarkers(s string) string {
|
|
var citationMarkerRegex = regexp.MustCompile(`##\d+\$\$`)
|
|
return citationMarkerRegex.ReplaceAllString(s, "")
|
|
}
|
|
|
|
// isContentDelta filters out "[DONE]" leaked by some drivers.
|
|
func isContentDelta(answer *string) bool {
|
|
if answer == nil {
|
|
return false
|
|
}
|
|
if *answer == "" {
|
|
return false
|
|
}
|
|
if *answer == "[DONE]" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// extractGenerationConfig mirrors Python's extract_generation_config.
|
|
func extractGenerationConfig(req *OpenAIChatRequest) map[string]interface{} {
|
|
cfg := make(map[string]interface{})
|
|
if req.Temperature != nil {
|
|
cfg["temperature"] = *req.Temperature
|
|
}
|
|
if req.TopP != nil {
|
|
cfg["top_p"] = *req.TopP
|
|
}
|
|
if req.MaxTokens != nil {
|
|
cfg["max_tokens"] = float64(*req.MaxTokens)
|
|
}
|
|
if req.FrequencyPenalty != nil {
|
|
cfg["frequency_penalty"] = *req.FrequencyPenalty
|
|
}
|
|
if req.PresencePenalty != nil {
|
|
cfg["presence_penalty"] = *req.PresencePenalty
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// normalizeMessageContent coerces content to string (drops non-text parts).
|
|
func normalizeMessageContent(content interface{}) (string, error) {
|
|
if content == nil {
|
|
return "", nil
|
|
}
|
|
if s, ok := content.(string); ok {
|
|
return s, nil
|
|
}
|
|
if arr, ok := content.([]interface{}); ok {
|
|
parts := make([]string, 0, len(arr))
|
|
for _, p := range arr {
|
|
pm, ok := p.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if pm["type"] != "text" {
|
|
continue
|
|
}
|
|
t, _ := pm["text"].(string)
|
|
parts = append(parts, t)
|
|
}
|
|
return joinNonEmpty(parts, "\n"), nil
|
|
}
|
|
return "", fmt.Errorf("messages[].content must be a string or an array of content parts.")
|
|
}
|
|
|
|
// normalizeOpenAIMessages normalizes message content for all messages.
|
|
func normalizeOpenAIMessages(messages []map[string]interface{}) ([]map[string]interface{}, error) {
|
|
out := make([]map[string]interface{}, 0, len(messages))
|
|
for _, m := range messages {
|
|
normalized := make(map[string]interface{}, len(m))
|
|
for k, v := range m {
|
|
normalized[k] = v
|
|
}
|
|
c, err := normalizeMessageContent(m["content"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
normalized["content"] = c
|
|
out = append(out, normalized)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// joinNonEmpty joins strings with sep, skipping empties.
|
|
func joinNonEmpty(parts []string, sep string) string {
|
|
nonEmpty := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
nonEmpty = append(nonEmpty, p)
|
|
}
|
|
}
|
|
out := ""
|
|
for i, p := range nonEmpty {
|
|
if i > 0 {
|
|
out += sep
|
|
}
|
|
out += p
|
|
}
|
|
return out
|
|
}
|
|
|
|
// getValue reads chunk[m1] falling back to chunk[m2].
|
|
func getValue(chunk map[string]interface{}, k1, k2 string) interface{} {
|
|
if v, ok := chunk[k1]; ok {
|
|
return v
|
|
}
|
|
return chunk[k2]
|
|
}
|
|
|
|
func strVal(v interface{}) string {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// formatChunks normalizes chunk fields to a canonical schema, matching Python's chunks_format.
|
|
func formatChunks(chunks []map[string]interface{}) []FormattedChunk {
|
|
out := make([]FormattedChunk, 0, len(chunks))
|
|
for _, chunk := range chunks {
|
|
out = append(out, FormattedChunk{
|
|
ID: strVal(getValue(chunk, "chunk_id", "id")),
|
|
Content: strVal(getValue(chunk, "content_with_weight", "content")),
|
|
DocumentID: strVal(getValue(chunk, "doc_id", "document_id")),
|
|
DocumentName: strVal(getValue(chunk, "docnm_kwd", "document_name")),
|
|
DatasetID: strVal(getValue(chunk, "kb_id", "dataset_id")),
|
|
ImageID: strVal(getValue(chunk, "image_id", "img_id")),
|
|
Positions: getValue(chunk, "positions", "position_int"),
|
|
URL: chunk["url"],
|
|
Similarity: sanitizeJSONFloats(chunk["similarity"]),
|
|
VectorSimilarity: sanitizeJSONFloats(chunk["vector_similarity"]),
|
|
TermSimilarity: sanitizeJSONFloats(chunk["term_similarity"]),
|
|
RowID: chunk["row_id"],
|
|
DocType: getValue(chunk, "doc_type_kwd", "doc_type"),
|
|
DocumentMetadata: chunk["document_metadata"],
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// enrichChunksWithDocumentMetadata enriches chunks with document metadata.
|
|
// Mirrors Python's enrich_chunks_with_document_metadata() in
|
|
// api/utils/reference_metadata_utils.py.
|
|
// When fields is a non-nil empty slice (explicitly provided as []), enrichment
|
|
// is skipped — matching Python's behavior for {"fields": []}.
|
|
func (s *OpenAIChatService) enrichChunksWithDocumentMetadata(chunks []FormattedChunk, tenantID string, include bool, fields []string) {
|
|
if !include || len(chunks) == 0 || s == nil || s.pipeline.MetadataSvc == nil {
|
|
return
|
|
}
|
|
if fields != nil && len(fields) == 0 {
|
|
return
|
|
}
|
|
maps := make([]map[string]interface{}, len(chunks))
|
|
for i, ch := range chunks {
|
|
maps[i] = map[string]interface{}{
|
|
"kb_id": ch.DatasetID,
|
|
"doc_id": ch.DocumentID,
|
|
"document_metadata": ch.DocumentMetadata,
|
|
}
|
|
}
|
|
s.pipeline.MetadataSvc.EnrichChunksWithDocMetadata(maps, tenantID, fields)
|
|
for i, m := range maps {
|
|
if md, ok := m["document_metadata"]; ok {
|
|
chunks[i].DocumentMetadata = md
|
|
}
|
|
}
|
|
}
|
|
|
|
// streamChatCompletionSSE drains events and writes SSE chunks.
|
|
func streamChatCompletionSSE(
|
|
c *gin.Context,
|
|
events <-chan OpenAIStreamEvent,
|
|
completionID string,
|
|
requestedModel string,
|
|
needReference bool,
|
|
) error {
|
|
c.Header("Cache-control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("X-Accel-Buffering", "no")
|
|
c.Header("Content-Type", "text/event-stream; charset=utf-8")
|
|
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
return fmt.Errorf("streaming unsupported")
|
|
}
|
|
|
|
writeSSE := func(payload gin.H) {
|
|
body, _ := json.Marshal(payload)
|
|
_, _ = c.Writer.Write([]byte("data:"))
|
|
_, _ = c.Writer.Write(body)
|
|
_, _ = c.Writer.Write([]byte("\n\n"))
|
|
flusher.Flush()
|
|
}
|
|
|
|
for ev := range events {
|
|
switch ev.Kind {
|
|
case OpenAIEventContent:
|
|
chunk := gin.H{
|
|
"id": completionID,
|
|
"object": "chat.completion.chunk",
|
|
"created": time.Now().Unix(),
|
|
"model": requestedModel,
|
|
"system_fingerprint": "",
|
|
"usage": nil,
|
|
"choices": []gin.H{{
|
|
"index": 0,
|
|
"delta": gin.H{
|
|
"role": "assistant",
|
|
"content": ev.Delta,
|
|
"reasoning_content": nil,
|
|
"function_call": nil,
|
|
"tool_calls": nil,
|
|
},
|
|
"finish_reason": nil,
|
|
"logprobs": nil,
|
|
}},
|
|
}
|
|
writeSSE(chunk)
|
|
|
|
case OpenAIEventReasoning:
|
|
chunk := gin.H{
|
|
"id": completionID,
|
|
"object": "chat.completion.chunk",
|
|
"created": time.Now().Unix(),
|
|
"model": requestedModel,
|
|
"system_fingerprint": "",
|
|
"usage": nil,
|
|
"choices": []gin.H{{
|
|
"index": 0,
|
|
"delta": gin.H{
|
|
"role": "assistant",
|
|
"content": nil,
|
|
"reasoning_content": ev.Delta,
|
|
"function_call": nil,
|
|
"tool_calls": nil,
|
|
},
|
|
"finish_reason": nil,
|
|
"logprobs": nil,
|
|
}},
|
|
}
|
|
writeSSE(chunk)
|
|
|
|
case OpenAIEventError:
|
|
chunk := gin.H{
|
|
"id": completionID,
|
|
"object": "chat.completion.chunk",
|
|
"created": time.Now().Unix(),
|
|
"model": requestedModel,
|
|
"system_fingerprint": "",
|
|
"usage": nil,
|
|
"choices": []gin.H{{
|
|
"index": 0,
|
|
"delta": gin.H{
|
|
"role": "assistant",
|
|
"content": "**ERROR**: " + ev.Error,
|
|
"reasoning_content": nil,
|
|
"function_call": nil,
|
|
"tool_calls": nil,
|
|
},
|
|
"finish_reason": nil,
|
|
"logprobs": nil,
|
|
}},
|
|
}
|
|
writeSSE(chunk)
|
|
|
|
case OpenAIEventFinal:
|
|
delta := gin.H{
|
|
"role": "assistant",
|
|
"content": nil,
|
|
"reasoning_content": nil,
|
|
"function_call": nil,
|
|
"tool_calls": nil,
|
|
}
|
|
if needReference {
|
|
delta["reference"] = ev.FinalReference
|
|
delta["final_content"] = ev.FinalAnswer
|
|
}
|
|
chunk := gin.H{
|
|
"id": completionID,
|
|
"object": "chat.completion.chunk",
|
|
"created": time.Now().Unix(),
|
|
"model": requestedModel,
|
|
"system_fingerprint": "",
|
|
"usage": gin.H{
|
|
"prompt_tokens": ev.PromptTokens,
|
|
"completion_tokens": ev.CompletionTokens,
|
|
"total_tokens": ev.TotalTokens,
|
|
},
|
|
"choices": []gin.H{{
|
|
"index": 0,
|
|
"delta": delta,
|
|
"finish_reason": "stop",
|
|
"logprobs": nil,
|
|
}},
|
|
}
|
|
writeSSE(chunk)
|
|
}
|
|
}
|
|
|
|
// Always terminate with data: [DONE]\n\n.
|
|
_, _ = c.Writer.Write([]byte("data: [DONE]\n\n"))
|
|
flusher.Flush()
|
|
return nil
|
|
}
|
|
|
|
// writeArgError writes a 101 JSON error envelope (malformed request).
|
|
func (s *OpenAIChatService) writeArgError(c *gin.Context, msg string) {
|
|
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, msg)
|
|
}
|
|
|
|
// writeDataError writes a 102 JSON error envelope (service failure).
|
|
func (s *OpenAIChatService) writeDataError(c *gin.Context, msg string) {
|
|
common.ResponseWithCodeData(c, common.CodeDataError, nil, msg)
|
|
}
|