WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// PluginIntoChatMessage handles the transformation of search results into chat messages
|
||||
type PluginIntoChatMessage struct {
|
||||
messageService interfaces.MessageService
|
||||
}
|
||||
|
||||
// NewPluginIntoChatMessage creates and registers a new PluginIntoChatMessage instance
|
||||
func NewPluginIntoChatMessage(eventManager *EventManager, messageService interfaces.MessageService) *PluginIntoChatMessage {
|
||||
res := &PluginIntoChatMessage{messageService: messageService}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginIntoChatMessage) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.INTO_CHAT_MESSAGE}
|
||||
}
|
||||
|
||||
// OnEvent processes the INTO_CHAT_MESSAGE event to format chat message content
|
||||
func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
pipelineInfo(ctx, "IntoChatMessage", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"merge_result_cnt": len(chatManage.MergeResult),
|
||||
"template_len": len(chatManage.SummaryConfig.ContextTemplate),
|
||||
})
|
||||
|
||||
// Separate FAQ and document results when FAQ priority is enabled
|
||||
var faqResults, docResults []*types.SearchResult
|
||||
var hasHighConfidenceFAQ bool
|
||||
|
||||
if chatManage.FAQPriorityEnabled {
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if result.ChunkType == string(types.ChunkTypeFAQ) {
|
||||
faqResults = append(faqResults, result)
|
||||
// Check if this FAQ has high confidence (above direct answer threshold)
|
||||
if result.Score >= chatManage.FAQDirectAnswerThreshold && !hasHighConfidenceFAQ {
|
||||
hasHighConfidenceFAQ = true
|
||||
pipelineInfo(ctx, "IntoChatMessage", "high_confidence_faq", map[string]interface{}{
|
||||
"chunk_id": result.ID,
|
||||
"score": fmt.Sprintf("%.4f", result.Score),
|
||||
"threshold": chatManage.FAQDirectAnswerThreshold,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
docResults = append(docResults, result)
|
||||
}
|
||||
}
|
||||
pipelineInfo(ctx, "IntoChatMessage", "faq_separation", map[string]interface{}{
|
||||
"faq_count": len(faqResults),
|
||||
"doc_count": len(docResults),
|
||||
"has_high_confidence": hasHighConfidenceFAQ,
|
||||
})
|
||||
}
|
||||
|
||||
// 验证用户查询的安全性
|
||||
safeQuery, isValid := utils.ValidateInput(chatManage.Query)
|
||||
if !isValid {
|
||||
pipelineWarn(ctx, "IntoChatMessage", "invalid_query", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return ErrTemplateExecute.WithError(fmt.Errorf("user query contains invalid content"))
|
||||
}
|
||||
|
||||
// Intent-based no-search path: no retrieval results, but still render
|
||||
// through the context template so runtime metadata (current_time, etc.) is injected.
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
userContent := safeQuery
|
||||
if rewrite := strings.TrimSpace(chatManage.RewriteQuery); rewrite != "" {
|
||||
if safeRewrite, ok := utils.ValidateInput(rewrite); ok {
|
||||
userContent = safeRewrite
|
||||
} else {
|
||||
pipelineWarn(ctx, "IntoChatMessage", "invalid_rewrite_query_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
if chatManage.ImageDescription != "" && !chatManage.ChatModelSupportsVision {
|
||||
userContent += "\n\n[用户上传图片内容]\n" + chatManage.ImageDescription
|
||||
}
|
||||
if chatManage.QuotedContext != "" {
|
||||
userContent += "\n\n" + chatManage.QuotedContext
|
||||
}
|
||||
// Inject attachment content (documents, audio transcripts, etc.)
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
userContent += chatManage.Attachments.BuildPrompt()
|
||||
}
|
||||
|
||||
if tpl := chatManage.SummaryConfig.ContextTemplate; tpl != "" {
|
||||
chatManage.UserContent = types.RenderPromptPlaceholders(tpl, types.PlaceholderValues{
|
||||
"query": userContent,
|
||||
"contexts": "",
|
||||
"language": chatManage.Language,
|
||||
})
|
||||
} else {
|
||||
chatManage.UserContent = userContent
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "IntoChatMessage", "no_search_with_template", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_content_len": len(chatManage.UserContent),
|
||||
"has_template": chatManage.SummaryConfig.ContextTemplate != "",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
var contextsBuilder strings.Builder
|
||||
|
||||
// Collect unique document metadata (title + description), once per knowledge
|
||||
allResults := chatManage.MergeResult
|
||||
if chatManage.FAQPriorityEnabled && len(faqResults) > 0 {
|
||||
allResults = append(faqResults, docResults...)
|
||||
}
|
||||
docHeader := buildDocumentHeader(allResults)
|
||||
if docHeader != "" {
|
||||
contextsBuilder.WriteString(docHeader)
|
||||
contextsBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
// Build contexts string based on FAQ priority strategy
|
||||
if chatManage.FAQPriorityEnabled && len(faqResults) > 0 {
|
||||
contextsBuilder.WriteString("<source type=\"faq\" priority=\"high\">\n")
|
||||
for i, result := range faqResults {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
if hasHighConfidenceFAQ && i == 0 {
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"FAQ-%d\" match=\"exact\">%s</context>\n", i+1, passage))
|
||||
} else {
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"FAQ-%d\">%s</context>\n", i+1, passage))
|
||||
}
|
||||
}
|
||||
contextsBuilder.WriteString("</source>\n")
|
||||
|
||||
if len(docResults) > 0 {
|
||||
contextsBuilder.WriteString("<source type=\"document\" priority=\"supplementary\">\n")
|
||||
for i, result := range docResults {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"DOC-%d\">%s</context>\n", i+1, passage))
|
||||
}
|
||||
contextsBuilder.WriteString("</source>")
|
||||
}
|
||||
} else {
|
||||
for i, result := range chatManage.MergeResult {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
if i > 0 {
|
||||
contextsBuilder.WriteString("\n")
|
||||
}
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"%d\">%s</context>", i+1, passage))
|
||||
}
|
||||
}
|
||||
|
||||
chatManage.RenderedContexts = contextsBuilder.String()
|
||||
|
||||
// Replace placeholders in context template
|
||||
userContent := types.RenderPromptPlaceholders(chatManage.SummaryConfig.ContextTemplate, types.PlaceholderValues{
|
||||
"query": safeQuery,
|
||||
"contexts": chatManage.RenderedContexts,
|
||||
"language": chatManage.Language,
|
||||
})
|
||||
|
||||
// Append image description as text fallback only when the chat model cannot
|
||||
// process images directly. Vision-capable models see images via MultiContent.
|
||||
if chatManage.ImageDescription != "" && !chatManage.ChatModelSupportsVision {
|
||||
userContent += "\n\n[用户上传图片内容]\n" + chatManage.ImageDescription
|
||||
}
|
||||
if chatManage.QuotedContext != "" {
|
||||
userContent += "\n\n" + chatManage.QuotedContext
|
||||
}
|
||||
// Inject attachment content (documents, audio transcripts, etc.)
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
userContent += chatManage.Attachments.BuildPrompt()
|
||||
}
|
||||
|
||||
// Set formatted content back to chat management
|
||||
chatManage.UserContent = userContent
|
||||
pipelineInfo(ctx, "IntoChatMessage", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_content_len": len(chatManage.UserContent),
|
||||
"faq_priority": chatManage.FAQPriorityEnabled,
|
||||
"intent": chatManage.Intent,
|
||||
"image_description": chatManage.ImageDescription,
|
||||
"chat_model_supports_vision": chatManage.ChatModelSupportsVision,
|
||||
})
|
||||
|
||||
p.persistRenderedContent(ctx, chatManage)
|
||||
return next()
|
||||
}
|
||||
|
||||
// persistRenderedContent asynchronously writes the RAG-augmented UserContent back
|
||||
// to the user message so that subsequent conversation turns can see the full
|
||||
// retrieval context in history.
|
||||
func (p *PluginIntoChatMessage) persistRenderedContent(ctx context.Context, chatManage *types.ChatManage) {
|
||||
if chatManage.UserMessageID == "" || chatManage.UserContent == "" {
|
||||
pipelineInfo(ctx, "IntoChatMessage", "persist_rendered_content_skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"has_user_content": chatManage.UserContent != "",
|
||||
"reason": "empty_id_or_content",
|
||||
})
|
||||
return
|
||||
}
|
||||
if chatManage.UserContent == chatManage.Query {
|
||||
return
|
||||
}
|
||||
pipelineInfo(ctx, "IntoChatMessage", "persist_rendered_content", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"rendered_content_len": len(chatManage.UserContent),
|
||||
})
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
if err := p.messageService.UpdateMessageRenderedContent(
|
||||
bgCtx, chatManage.SessionID, chatManage.UserMessageID, chatManage.UserContent,
|
||||
); err != nil {
|
||||
pipelineWarn(bgCtx, "IntoChatMessage", "persist_rendered_content_error", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// buildDocumentHeader generates a document metadata section listing each unique
|
||||
// knowledge document (by KnowledgeID) with its title and description.
|
||||
// Returns an empty string when no meaningful metadata is available.
|
||||
func buildDocumentHeader(results []*types.SearchResult) string {
|
||||
type docMeta struct {
|
||||
title string
|
||||
description string
|
||||
metadata string
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var docs []docMeta
|
||||
|
||||
for _, r := range results {
|
||||
if r.KnowledgeID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r.KnowledgeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.KnowledgeID] = struct{}{}
|
||||
|
||||
title := r.KnowledgeTitle
|
||||
if title == "" {
|
||||
title = r.KnowledgeFilename
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
docs = append(docs, docMeta{
|
||||
title: title,
|
||||
description: r.KnowledgeDescription,
|
||||
metadata: r.KnowledgeCustomMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
if len(docs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("<documents>\n")
|
||||
for _, d := range docs {
|
||||
b.WriteString("<document>\n")
|
||||
b.WriteString(fmt.Sprintf("<title>%s</title>\n", html.EscapeString(d.title)))
|
||||
if d.description != "" {
|
||||
b.WriteString(fmt.Sprintf("<description>%s</description>\n", html.EscapeString(d.description)))
|
||||
}
|
||||
if d.metadata != "" {
|
||||
b.WriteString(fmt.Sprintf("<metadata>%s</metadata>\n", html.EscapeString(d.metadata)))
|
||||
}
|
||||
b.WriteString("</document>\n")
|
||||
}
|
||||
b.WriteString("</documents>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// getEnrichedPassageForChat 合并Content和ImageInfo的文本内容,为聊天消息准备
|
||||
func getEnrichedPassageForChat(ctx context.Context, result *types.SearchResult) string {
|
||||
// 如果没有图片信息,直接返回内容
|
||||
if result.Content == "" && result.ImageInfo == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 如果只有内容,没有图片信息
|
||||
if result.ImageInfo == "" {
|
||||
return result.Content
|
||||
}
|
||||
|
||||
// 处理图片信息并与内容合并
|
||||
return enrichContentWithImageInfo(ctx, result.Content, result.ImageInfo)
|
||||
}
|
||||
|
||||
// enrichContentWithImageInfo delegates to the shared searchutil implementation.
|
||||
func enrichContentWithImageInfo(_ context.Context, content string, imageInfoJSON string) string {
|
||||
return searchutil.EnrichContentWithImageInfoForChat(content, imageInfoJSON)
|
||||
}
|
||||
Reference in New Issue
Block a user