feat(im): add QuotedContext to shared types and IM service layer
This commit is contained in:
+28
-5
@@ -35,6 +35,8 @@ const (
|
||||
dedupCleanupInterval = 1 * time.Minute
|
||||
// maxContentLength is the maximum allowed message content length.
|
||||
maxContentLength = 4096
|
||||
// maxQuoteContentLength is the max runes to include from a quoted message.
|
||||
maxQuoteContentLength = 500
|
||||
// streamFlushInterval is how often buffered stream content is flushed to the IM platform.
|
||||
// This prevents API rate-limiting while keeping perceived latency low.
|
||||
streamFlushInterval = 300 * time.Millisecond
|
||||
@@ -160,6 +162,24 @@ func makeUserKey(channelID, userID, chatID, threadID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", channelID, userID, chatID)
|
||||
}
|
||||
|
||||
// formatQuotedContext formats a QuotedMessage into a labeled string for LLM context.
|
||||
// Returns empty string if quote is nil or has no content.
|
||||
func formatQuotedContext(quote *QuotedMessage) string {
|
||||
if quote == nil || quote.Content == "" {
|
||||
return ""
|
||||
}
|
||||
content := quote.Content
|
||||
runes := []rune(content)
|
||||
if len(runes) > maxQuoteContentLength {
|
||||
content = string(runes[:maxQuoteContentLength]) + "..."
|
||||
}
|
||||
label := "[被引用的消息]"
|
||||
if quote.IsBotMessage {
|
||||
label = "[引用的机器人回复]"
|
||||
}
|
||||
return label + "\n" + content
|
||||
}
|
||||
|
||||
func buildIMQARequest(
|
||||
session *types.Session,
|
||||
query string,
|
||||
@@ -167,6 +187,7 @@ func buildIMQARequest(
|
||||
userMessageID string,
|
||||
customAgent *types.CustomAgent,
|
||||
kbIDs []string,
|
||||
quote *QuotedMessage,
|
||||
) *types.QARequest {
|
||||
// WebSearchEnabled: the web handler passes this per-request from the
|
||||
// frontend toggle; for IM channels the user has no per-message toggle,
|
||||
@@ -180,6 +201,7 @@ func buildIMQARequest(
|
||||
KnowledgeBaseIDs: kbIDs,
|
||||
UserMessageID: userMessageID,
|
||||
WebSearchEnabled: webSearchEnabled,
|
||||
QuotedContext: formatQuotedContext(quote),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +745,7 @@ func (s *Service) HandleMessage(ctx context.Context, msg *IncomingMessage, chann
|
||||
attribute.String("im.chat_id", msg.ChatID),
|
||||
attribute.String("im.thread_id", msg.ThreadID),
|
||||
attribute.String("im.message_type", string(msg.MessageType)),
|
||||
attribute.Bool("im.has_quote", msg.Quote != nil),
|
||||
)
|
||||
|
||||
// Dedup: skip if this message was already processed (IM platforms may retry)
|
||||
@@ -937,7 +960,7 @@ func (s *Service) executeQARequest(req *qaRequest) {
|
||||
}
|
||||
|
||||
// Non-streaming fallback: collect full answer then send.
|
||||
answer, err := s.runQA(ctx, req.session, req.msg.Content, req.agent, kbIDs, req.userKey)
|
||||
answer, err := s.runQA(ctx, req.session, req.msg.Content, req.agent, kbIDs, req.userKey, req.msg.Quote)
|
||||
if err != nil {
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
logger.Errorf(ctx, "[IM] QA failed: %v, sending fallback reply", err)
|
||||
@@ -1559,7 +1582,7 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage,
|
||||
// Run QA async
|
||||
go func() {
|
||||
var err error
|
||||
req := buildIMQARequest(session, msg.Content, assistantMsg.ID, userMsg.ID, customAgent, kbIDs)
|
||||
req := buildIMQARequest(session, msg.Content, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, msg.Quote)
|
||||
if useAgent {
|
||||
err = s.sessionService.AgentQA(qaCtx, req, eventBus)
|
||||
} else {
|
||||
@@ -1649,7 +1672,7 @@ loop:
|
||||
|
||||
// fallbackNonStream is used when streaming initialization fails.
|
||||
func (s *Service) fallbackNonStream(ctx context.Context, msg *IncomingMessage, session *types.Session, customAgent *types.CustomAgent, kbIDs []string, adapter Adapter, userKey string) error {
|
||||
answer, err := s.runQA(ctx, session, msg.Content, customAgent, kbIDs, userKey)
|
||||
answer, err := s.runQA(ctx, session, msg.Content, customAgent, kbIDs, userKey, msg.Quote)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "[IM] QA fallback failed: %v", err)
|
||||
answer = "抱歉,处理您的问题时出现了异常,请稍后再试。"
|
||||
@@ -1659,7 +1682,7 @@ func (s *Service) fallbackNonStream(ctx context.Context, msg *IncomingMessage, s
|
||||
}
|
||||
|
||||
// runQA executes the WeKnora QA pipeline and returns the full answer text.
|
||||
func (s *Service) runQA(ctx context.Context, session *types.Session, query string, customAgent *types.CustomAgent, kbIDs []string, userKey string) (string, error) {
|
||||
func (s *Service) runQA(ctx context.Context, session *types.Session, query string, customAgent *types.CustomAgent, kbIDs []string, userKey string, quote *QuotedMessage) (string, error) {
|
||||
// Add timeout to prevent indefinite blocking
|
||||
ctx, cancel := context.WithTimeout(ctx, qaTimeout)
|
||||
defer cancel()
|
||||
@@ -1749,7 +1772,7 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin
|
||||
// Run QA async
|
||||
go func() {
|
||||
var err error
|
||||
req := buildIMQARequest(session, query, assistantMsg.ID, userMsg.ID, customAgent, kbIDs)
|
||||
req := buildIMQARequest(session, query, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, quote)
|
||||
if useAgent {
|
||||
err = s.sessionService.AgentQA(ctx, req, eventBus)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package im
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatQuotedContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quote *QuotedMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil quote",
|
||||
quote: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
quote: &QuotedMessage{Content: ""},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "bot message",
|
||||
quote: &QuotedMessage{Content: "bot reply text", IsBotMessage: true},
|
||||
want: "[引用的机器人回复]\nbot reply text",
|
||||
},
|
||||
{
|
||||
name: "user message",
|
||||
quote: &QuotedMessage{Content: "user message text", IsBotMessage: false},
|
||||
want: "[被引用的消息]\nuser message text",
|
||||
},
|
||||
{
|
||||
name: "truncation at 500 runes",
|
||||
quote: &QuotedMessage{
|
||||
Content: string(make([]rune, 600)),
|
||||
IsBotMessage: false,
|
||||
},
|
||||
want: "[被引用的消息]\n" + string(make([]rune, 500)) + "...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatQuotedContext(tt.quote)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatQuotedContext() length = %d, want length %d", len(got), len(tt.want))
|
||||
if len(got) < 200 && len(tt.want) < 200 {
|
||||
t.Errorf("got = %q, want %q", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ type PipelineState struct {
|
||||
RenderedContexts string `json:"-"`
|
||||
ChatResponse *ChatResponse `json:"-"`
|
||||
ImageDescription string `json:"-"`
|
||||
QuotedContext string `json:"-"` // Quoted message text, injected at LLM prompt stage
|
||||
SystemPromptOverride string `json:"-"`
|
||||
}
|
||||
|
||||
@@ -195,6 +196,7 @@ func (c *ChatManage) Clone() *ChatManage {
|
||||
RewriteQuery: c.RewriteQuery,
|
||||
Intent: c.Intent,
|
||||
ImageDescription: c.ImageDescription,
|
||||
QuotedContext: c.QuotedContext,
|
||||
SystemPromptOverride: c.SystemPromptOverride,
|
||||
RenderedContexts: c.RenderedContexts,
|
||||
},
|
||||
|
||||
@@ -16,4 +16,5 @@ type QARequest struct {
|
||||
UserMessageID string // Created user message ID
|
||||
WebSearchEnabled bool // Whether web search is enabled for this request
|
||||
EnableMemory bool // Whether memory feature is enabled
|
||||
QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user