Files
xinmi/internal/application/service/chat_pipeline/common.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

281 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ![alt](url). 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
}