281 lines
9.8 KiB
Go
281 lines
9.8 KiB
Go
package chatpipeline
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"regexp"
|
|||
|
|
"slices"
|
|||
|
|
"sort"
|
|||
|
|
"strings"
|
|||
|
|
"sync"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/common"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/models/chat"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/searchutil"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var regThinkTags = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
|||
|
|
|
|||
|
|
const retrievedImageOutputRequirement = `
|
|||
|
|
|
|||
|
|
## Retrieved Image Output Requirement
|
|||
|
|
The retrieved context for this turn contains Markdown images. Images attached to retrieved passages should be treated as relevant by default.
|
|||
|
|
- Unless the user explicitly requests text-only output, or every retrieved image is clearly unrelated to the answer, the final answer MUST include at least one relevant Markdown image copied from the retrieved context.
|
|||
|
|
- Copy the complete Markdown image syntax and its URL verbatim. Never invent, shorten, normalize, or replace the URL.
|
|||
|
|
- Use ASCII half-width parentheses in image Markdown exactly as . Never use full-width ( or ).
|
|||
|
|
- Place each image immediately after the paragraph it supports, rather than collecting images at the end.
|
|||
|
|
- When multiple retrieved images support different sections of a multi-section answer, include them in their corresponding sections instead of stopping after the first image.
|
|||
|
|
- Before finishing, silently verify that the answer contains a Markdown image whenever this requirement applies.`
|
|||
|
|
|
|||
|
|
func appendRetrievedImageOutputRequirement(systemPrompt, renderedContexts string) string {
|
|||
|
|
if !searchutil.MarkdownImageRegex.MatchString(renderedContexts) {
|
|||
|
|
return systemPrompt
|
|||
|
|
}
|
|||
|
|
return strings.TrimRight(systemPrompt, " \t\r\n") + retrievedImageOutputRequirement
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// pipelineInfo logs pipeline info level entries.
|
|||
|
|
func pipelineInfo(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
|||
|
|
common.PipelineInfo(ctx, stage, action, fields)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// pipelineWarn logs pipeline warning level entries.
|
|||
|
|
func pipelineWarn(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
|||
|
|
common.PipelineWarn(ctx, stage, action, fields)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// pipelineError logs pipeline error level entries.
|
|||
|
|
func pipelineError(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
|||
|
|
common.PipelineError(ctx, stage, action, fields)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// prepareChatModel shared logic to prepare chat model and options
|
|||
|
|
// it gets the chat model and sets up the chat options based on the chat manage.
|
|||
|
|
func prepareChatModel(ctx context.Context, modelService interfaces.ModelService,
|
|||
|
|
chatManage *types.ChatManage,
|
|||
|
|
) (chat.Chat, *chat.ChatOptions, error) {
|
|||
|
|
chatModel, err := modelService.GetChatModel(ctx, chatManage.ChatModelID)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Errorf(ctx, "Failed to get chat model: %v", err)
|
|||
|
|
return nil, nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
opt := &chat.ChatOptions{
|
|||
|
|
Temperature: chatManage.SummaryConfig.Temperature,
|
|||
|
|
TopP: chatManage.SummaryConfig.TopP,
|
|||
|
|
Seed: chatManage.SummaryConfig.Seed,
|
|||
|
|
MaxTokens: chatManage.SummaryConfig.MaxTokens,
|
|||
|
|
MaxCompletionTokens: chatManage.SummaryConfig.MaxCompletionTokens,
|
|||
|
|
FrequencyPenalty: chatManage.SummaryConfig.FrequencyPenalty,
|
|||
|
|
PresencePenalty: chatManage.SummaryConfig.PresencePenalty,
|
|||
|
|
Thinking: chatManage.SummaryConfig.Thinking,
|
|||
|
|
PromptCacheKey: chatManage.SessionID,
|
|||
|
|
}
|
|||
|
|
if opt.Thinking != nil {
|
|||
|
|
pipelineInfo(ctx, "Stream", "thinking_option", map[string]interface{}{
|
|||
|
|
"enabled": *opt.Thinking,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return chatModel, opt, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// prepareMessagesWithHistory prepare complete messages including history.
|
|||
|
|
// When SystemPromptOverride is set (e.g. by intent-specific prompt logic),
|
|||
|
|
// it takes precedence over the default SummaryConfig.Prompt.
|
|||
|
|
func prepareMessagesWithHistory(chatManage *types.ChatManage) []chat.Message {
|
|||
|
|
base := chatManage.SummaryConfig.Prompt
|
|||
|
|
if chatManage.SystemPromptOverride != "" {
|
|||
|
|
base = chatManage.SystemPromptOverride
|
|||
|
|
}
|
|||
|
|
systemPrompt := types.RenderPromptPlaceholders(base, types.PlaceholderValues{
|
|||
|
|
"query": chatManage.Query,
|
|||
|
|
"language": chatManage.Language,
|
|||
|
|
"contexts": chatManage.RenderedContexts,
|
|||
|
|
})
|
|||
|
|
// Memory goes at the end of the system prompt, after the retrieved-context
|
|||
|
|
// placeholders have been rendered, so a remembered sentence can never be
|
|||
|
|
// substituted into prompt structure.
|
|||
|
|
systemPrompt += chatManage.MemoryPrompt
|
|||
|
|
|
|||
|
|
chatMessages := []chat.Message{
|
|||
|
|
{Role: "system", Content: systemPrompt},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
chatMessages = AppendHistoryMessages(chatMessages, chatManage.History)
|
|||
|
|
|
|||
|
|
// Image-output rules are turn-specific. Putting them on the current user
|
|||
|
|
// message keeps the system prefix byte-stable so provider prompt caches
|
|||
|
|
// still hit on later turns of the same session.
|
|||
|
|
userContent := appendRetrievedImageOutputRequirement(chatManage.UserContent, chatManage.RenderedContexts)
|
|||
|
|
|
|||
|
|
// Add current user message. Only include images when the chat model supports
|
|||
|
|
// vision; non-vision models rely on the text description in UserContent.
|
|||
|
|
userMsg := chat.Message{Role: "user", Content: userContent}
|
|||
|
|
if chatManage.ChatModelSupportsVision && len(chatManage.Images) > 0 {
|
|||
|
|
userMsg.Images = chatManage.Images
|
|||
|
|
}
|
|||
|
|
chatMessages = append(chatMessages, userMsg)
|
|||
|
|
|
|||
|
|
return chatMessages
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func withPromptCacheMetadata(
|
|||
|
|
ctx context.Context,
|
|||
|
|
chatModel chat.Chat,
|
|||
|
|
messages []chat.Message,
|
|||
|
|
opts *chat.ChatOptions,
|
|||
|
|
purpose string,
|
|||
|
|
) context.Context {
|
|||
|
|
prefixFingerprint := chat.PromptPrefixFingerprint(messages, opts)
|
|||
|
|
_ = chatModel // model identity is already captured by the usage sink
|
|||
|
|
return types.WithLLMCallMetadata(ctx, purpose, prefixFingerprint)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AppendHistoryMessages appends prior Q&A rounds in chronological order.
|
|||
|
|
// History is already filtered and truncated upstream by the load_history plugin.
|
|||
|
|
func AppendHistoryMessages(messages []chat.Message, history []*types.History) []chat.Message {
|
|||
|
|
for _, history := range history {
|
|||
|
|
messages = append(messages, chat.Message{Role: "user", Content: history.Query})
|
|||
|
|
messages = append(messages, chat.Message{Role: "assistant", Content: history.Answer})
|
|||
|
|
}
|
|||
|
|
return messages
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// loadAndProcessHistory fetches recent messages, groups them into Q&A pairs,
|
|||
|
|
// strips <think> tags from assistant answers, sorts by recency, and limits to maxRounds.
|
|||
|
|
// fetchCount controls how many raw messages to fetch (typically maxRounds*2+10).
|
|||
|
|
func loadAndProcessHistory(
|
|||
|
|
ctx context.Context,
|
|||
|
|
messageService interfaces.MessageService,
|
|||
|
|
sessionID string,
|
|||
|
|
maxRounds int,
|
|||
|
|
fetchCount int,
|
|||
|
|
) ([]*types.History, error) {
|
|||
|
|
history, err := messageService.GetRecentMessagesBySession(ctx, sessionID, fetchCount)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
historyMap := make(map[string]*types.History)
|
|||
|
|
for _, message := range history {
|
|||
|
|
h, ok := historyMap[message.RequestID]
|
|||
|
|
if !ok {
|
|||
|
|
h = &types.History{}
|
|||
|
|
}
|
|||
|
|
if message.Role == "user" {
|
|||
|
|
// RenderedContent is a snapshot of the prompt/context format used by
|
|||
|
|
// the original turn. Replaying it would mix legacy <context id="…">
|
|||
|
|
// envelopes and old citation instructions into the current protocol.
|
|||
|
|
// Historical references are carried separately in KnowledgeReferences
|
|||
|
|
// and can be re-merged into this turn's freshly rendered context.
|
|||
|
|
h.Query = message.Content
|
|||
|
|
h.CreateAt = message.CreatedAt
|
|||
|
|
if desc := extractImageCaptions(message.Images); desc != "" {
|
|||
|
|
h.Query += "\n\n[用户上传图片内容]\n" + desc
|
|||
|
|
}
|
|||
|
|
if len(message.Attachments) > 0 {
|
|||
|
|
h.Query += message.Attachments.BuildPrompt()
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
h.Answer = regThinkTags.ReplaceAllString(message.Content, "")
|
|||
|
|
h.KnowledgeReferences = message.KnowledgeReferences
|
|||
|
|
}
|
|||
|
|
historyMap[message.RequestID] = h
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
historyList := make([]*types.History, 0, len(historyMap))
|
|||
|
|
for _, h := range historyMap {
|
|||
|
|
if h.Answer != "" && h.Query != "" {
|
|||
|
|
historyList = append(historyList, h)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sort.Slice(historyList, func(i, j int) bool {
|
|||
|
|
return historyList[i].CreateAt.After(historyList[j].CreateAt)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if len(historyList) > maxRounds {
|
|||
|
|
historyList = historyList[:maxRounds]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
slices.Reverse(historyList)
|
|||
|
|
return historyList, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// extractImageCaptions concatenates non-empty Caption fields from stored
|
|||
|
|
// message images. Used when loading history so that previous turns' image
|
|||
|
|
// descriptions are visible to the model.
|
|||
|
|
func extractImageCaptions(images types.MessageImages) string {
|
|||
|
|
var parts []string
|
|||
|
|
for _, img := range images {
|
|||
|
|
if img.Caption != "" {
|
|||
|
|
parts = append(parts, img.Caption)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return strings.Join(parts, "\n")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Concurrency utilities
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
// ParallelTask represents a named unit of concurrent work.
|
|||
|
|
type ParallelTask struct {
|
|||
|
|
Name string
|
|||
|
|
Run func() *PluginError
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RunParallel executes tasks concurrently.
|
|||
|
|
// Returns a map of task name → error for tasks that returned non-nil errors.
|
|||
|
|
func RunParallel(tasks ...ParallelTask) map[string]*PluginError {
|
|||
|
|
errs := make(map[string]*PluginError)
|
|||
|
|
var mu sync.Mutex
|
|||
|
|
var wg sync.WaitGroup
|
|||
|
|
|
|||
|
|
wg.Add(len(tasks))
|
|||
|
|
for _, task := range tasks {
|
|||
|
|
go func(t ParallelTask) {
|
|||
|
|
defer wg.Done()
|
|||
|
|
if err := t.Run(); err != nil {
|
|||
|
|
mu.Lock()
|
|||
|
|
errs[t.Name] = err
|
|||
|
|
mu.Unlock()
|
|||
|
|
}
|
|||
|
|
}(task)
|
|||
|
|
}
|
|||
|
|
wg.Wait()
|
|||
|
|
return errs
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ParallelMap applies fn to each element of items concurrently (up to
|
|||
|
|
// maxWorkers goroutines) and returns results in the same order as items.
|
|||
|
|
// If maxWorkers <= 0, concurrency is unbounded (one goroutine per item).
|
|||
|
|
func ParallelMap[T, R any](items []T, maxWorkers int, fn func(int, T) R) []R {
|
|||
|
|
n := len(items)
|
|||
|
|
if n == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
results := make([]R, n)
|
|||
|
|
|
|||
|
|
if maxWorkers <= 0 || maxWorkers > n {
|
|||
|
|
maxWorkers = n
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var wg sync.WaitGroup
|
|||
|
|
sem := make(chan struct{}, maxWorkers)
|
|||
|
|
|
|||
|
|
for i, item := range items {
|
|||
|
|
wg.Add(1)
|
|||
|
|
sem <- struct{}{}
|
|||
|
|
go func(idx int, it T) {
|
|||
|
|
defer func() { <-sem; wg.Done() }()
|
|||
|
|
results[idx] = fn(idx, it)
|
|||
|
|
}(i, item)
|
|||
|
|
}
|
|||
|
|
wg.Wait()
|
|||
|
|
return results
|
|||
|
|
}
|