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