Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

View File

@@ -0,0 +1,335 @@
package main
import (
"fmt"
"strings"
"time"
)
type humanAssistPending struct {
Key string
ConversationKey string
Msg autoReplyMessage
RawData map[string]interface{}
ReceivedAt time.Time
HumanReplies []string
}
type humanAssistAssessment struct {
Decision string
Reason string
}
func (e *AutoReplyEngine) maybeDelayForHumanAssist(job AutoReplyJob, msg autoReplyMessage) bool {
cfg := e.getConfig()
if job.SkipHumanAssist || !cfg.HumanAssist.Enabled || cfg.HumanAssist.WaitSeconds <= 0 {
return false
}
if strings.TrimSpace(msg.ConversationID) == "" || msg.isSelfMessage() {
return false
}
key := humanAssistPendingKey(msg)
conversationKey := e.humanAssistConversationKey(msg)
pending := &humanAssistPending{
Key: key,
ConversationKey: conversationKey,
Msg: msg,
RawData: job.RawData,
ReceivedAt: job.ReceivedAt,
}
e.mu.Lock()
if e.humanPending == nil {
e.humanPending = make(map[string]*humanAssistPending)
}
e.humanPending[key] = pending
e.mu.Unlock()
e.noteReason("human_assist_waiting")
go e.finishHumanAssistAfterDelay(key)
return true
}
func (e *AutoReplyEngine) finishHumanAssistAfterDelay(key string) {
cfg := e.getConfig()
wait := time.Duration(cfg.HumanAssist.WaitSeconds) * time.Second
if wait <= 0 {
wait = 15 * time.Second
}
time.Sleep(wait)
after := time.Duration(cfg.HumanAssist.AfterHumanReplyDelaySeconds) * time.Second
if after > 0 && e.pendingHasHumanReply(key) {
time.Sleep(after)
}
pending := e.takeHumanAssistPending(key)
if pending == nil {
return
}
if len(pending.HumanReplies) > 0 {
assessment := e.assessHumanReply(pending.Msg, pending.HumanReplies)
if assessment.Decision == "sufficient" {
e.incStatus("ignored")
e.noteReason("human_reply_sufficient")
e.addRecord(AutoReplyRecord{
RobotID: pending.Msg.RobotID,
ClientID: pending.Msg.ClientID,
UserID: pending.Msg.RobotID,
ConversationID: pending.Msg.ConversationID,
Source: pending.Msg.sourceLabel(),
FromWxID: pending.Msg.FromWxID,
FromNickName: pending.Msg.FromNickName,
Question: pending.Msg.Content,
Action: "ignored",
Reason: "human_reply_sufficient: " + assessment.Reason,
Answer: strings.Join(pending.HumanReplies, "\n"),
SenderIdentity: pending.Msg.SenderIdentity,
IdentitySource: pending.Msg.IdentitySource,
})
return
}
e.noteReason("human_reply_need_supplement")
}
retryJob := AutoReplyJob{
ClientID: pending.Msg.ClientID,
RawData: pending.RawData,
ReceivedAt: pending.ReceivedAt,
SkipHumanAssist: true,
SupplementReason: "human_assist_supplement",
}
select {
case e.queue <- retryJob:
default:
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "human assist retry queue is full")
e.addRecord(AutoReplyRecord{
RobotID: pending.Msg.RobotID,
ClientID: pending.Msg.ClientID,
UserID: pending.Msg.RobotID,
ConversationID: pending.Msg.ConversationID,
Source: pending.Msg.sourceLabel(),
FromWxID: pending.Msg.FromWxID,
FromNickName: pending.Msg.FromNickName,
Question: pending.Msg.Content,
Action: "failed",
Reason: "human_assist_retry_queue_full",
SenderIdentity: pending.Msg.SenderIdentity,
IdentitySource: pending.Msg.IdentitySource,
})
}
}
func (e *AutoReplyEngine) pendingHasHumanReply(key string) bool {
e.mu.Lock()
defer e.mu.Unlock()
pending := e.humanPending[key]
return pending != nil && len(pending.HumanReplies) > 0
}
func (e *AutoReplyEngine) takeHumanAssistPending(key string) *humanAssistPending {
e.mu.Lock()
defer e.mu.Unlock()
pending := e.humanPending[key]
delete(e.humanPending, key)
return pending
}
func (e *AutoReplyEngine) observeHumanReply(msg autoReplyMessage) bool {
content := strings.TrimSpace(msg.Content)
if content == "" || strings.TrimSpace(msg.ConversationID) == "" {
return false
}
if e.consumeAutoSentMessage(msg) {
return true
}
cfg := e.getConfig()
if !cfg.HumanAssist.Enabled {
return false
}
if len([]rune(content)) < cfg.HumanAssist.MinimumHumanReplyLengthRunes {
return false
}
conversationKey := e.humanAssistConversationKey(msg)
e.mu.Lock()
defer e.mu.Unlock()
count := 0
for _, pending := range e.humanPending {
if pending.ConversationKey != conversationKey {
continue
}
pending.HumanReplies = append(pending.HumanReplies, content)
count++
}
if count > 0 {
e.status.HumanAssistObservedCount += count
}
return count > 0
}
func (e *AutoReplyEngine) assessHumanReply(msg autoReplyMessage, replies []string) humanAssistAssessment {
joined := strings.TrimSpace(strings.Join(replies, "\n"))
if joined == "" {
return humanAssistAssessment{Decision: "need_supplement", Reason: "empty human reply"}
}
if isLikelyHoldingReply(joined) {
return humanAssistAssessment{Decision: "need_supplement", Reason: "holding reply"}
}
searchText := e.contextualSearchText(msg.Content, msg)
result := e.searchKnowledgeDetailed(searchText)
hits := result.Hits
if len(hits) == 0 {
if len([]rune(joined)) >= 8 {
return humanAssistAssessment{Decision: "sufficient", Reason: "no knowledge hit and human replied"}
}
return humanAssistAssessment{Decision: "need_supplement", Reason: "short human reply"}
}
if e.humanReplyCoversKnowledge(joined, hits) {
return humanAssistAssessment{Decision: "sufficient", Reason: "keyword coverage"}
}
if decision, reason, err := e.askHumanReplyAssessment(msg, joined, hits); err == nil {
return humanAssistAssessment{Decision: decision, Reason: reason}
}
return humanAssistAssessment{Decision: "need_supplement", Reason: "knowledge not covered"}
}
func (e *AutoReplyEngine) askHumanReplyAssessment(msg autoReplyMessage, humanReply string, hits []KnowledgeChunk) (string, string, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return "", "", fmt.Errorf("AI not configured")
}
systemPrompt := prependAISystemPrompt(cfg, "你负责判断人工客服回复是否已经覆盖知识库要点。只输出一行SUFFICIENT、NEED_SUPPLEMENT 或 CONFLICT后面可以用冒号补充一个简短原因。")
var b strings.Builder
b.WriteString("客户问题:\n")
b.WriteString(msg.Content)
b.WriteString("\n\n人工回复\n")
b.WriteString(humanReply)
b.WriteString("\n\n知识库片段\n")
for i, hit := range compactKnowledgeHitsForAI(hits) {
if i >= 4 {
break
}
b.WriteString(fmt.Sprintf("[%d] %s\n%s\n", i+1, hit.Title, truncateTextForPrompt(hit.Content, 700)))
}
var result *AIResult
var err error
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
result, err = callOllamaChat(cfg.AI, systemPrompt, b.String())
default:
result, err = callOpenAICompatibleChat(cfg.AI, systemPrompt, b.String())
}
if err != nil {
return "", "", err
}
answer := strings.TrimSpace(strings.ToUpper(result.Answer))
reason := strings.TrimSpace(result.Answer)
switch {
case strings.HasPrefix(answer, "SUFFICIENT"):
return "sufficient", reason, nil
case strings.HasPrefix(answer, "CONFLICT"):
return "need_supplement", reason, nil
case strings.HasPrefix(answer, "NEED_SUPPLEMENT"):
return "need_supplement", reason, nil
default:
return "need_supplement", reason, nil
}
}
func (e *AutoReplyEngine) humanReplyCoversKnowledge(reply string, hits []KnowledgeChunk) bool {
replyNorm := normalizeGreetingText(reply)
if len([]rune(replyNorm)) < 10 {
return false
}
keywords := topKnowledgeKeywords(hits, 8)
if len(keywords) == 0 {
return len([]rune(replyNorm)) >= 20
}
matches := 0
for _, keyword := range keywords {
if strings.Contains(replyNorm, normalizeGreetingText(keyword)) {
matches++
}
}
return matches >= 2 || (matches >= 1 && len([]rune(replyNorm)) >= 30)
}
func topKnowledgeKeywords(hits []KnowledgeChunk, limit int) []string {
seen := make(map[string]bool)
result := make([]string, 0, limit)
for _, hit := range hits {
for _, token := range strings.FieldsFunc(hit.Title+" "+hit.Content, func(r rune) bool {
return r == ' ' || r == '\n' || r == '\t' || r == '' || r == '。' || r == ',' || r == '.' || r == ':' || r == '' || r == '' || r == ';'
}) {
token = strings.TrimSpace(token)
if len([]rune(token)) < 3 || seen[token] {
continue
}
seen[token] = true
result = append(result, token)
if len(result) >= limit {
return result
}
}
}
return result
}
func isLikelyHoldingReply(text string) bool {
n := normalizeGreetingText(text)
for _, token := range []string{"稍等", "等下", "看下", "我看看", "确认一下", "稍后", "一会"} {
if strings.Contains(n, normalizeGreetingText(token)) {
return true
}
}
return false
}
func humanAssistPendingKey(msg autoReplyMessage) string {
key := msg.dedupeKey()
if key == "" {
key = fmt.Sprintf("%d|%s|%s|%d", msg.ClientID, msg.ConversationID, normalizeGreetingText(msg.Content), time.Now().UnixNano())
}
return key
}
func (e *AutoReplyEngine) humanAssistConversationKey(msg autoReplyMessage) string {
return e.contextKeyForMessage(msg)
}
func (e *AutoReplyEngine) rememberAutoSentMessage(clientID uint32, conversationID string, content string) {
key := autoSentFingerprint(clientID, conversationID, content)
if key == "" {
return
}
e.mu.Lock()
if e.autoSent == nil {
e.autoSent = make(map[string]time.Time)
}
now := time.Now()
for item, ts := range e.autoSent {
if now.Sub(ts) > 10*time.Minute {
delete(e.autoSent, item)
}
}
e.autoSent[key] = now
e.mu.Unlock()
}
func (e *AutoReplyEngine) consumeAutoSentMessage(msg autoReplyMessage) bool {
key := autoSentFingerprint(uint32(msg.ClientID), msg.ConversationID, msg.Content)
if key == "" {
return false
}
e.mu.Lock()
defer e.mu.Unlock()
if ts, ok := e.autoSent[key]; ok && time.Since(ts) < 10*time.Minute {
delete(e.autoSent, key)
return true
}
return false
}
func autoSentFingerprint(clientID uint32, conversationID string, content string) string {
conversationID = strings.TrimSpace(conversationID)
content = normalizeGreetingText(content)
if clientID == 0 || conversationID == "" || content == "" {
return ""
}
return fmt.Sprintf("%d|%s|%s", clientID, conversationID, content)
}