feat(auto-reply): 优化自动回复逻辑和知识库功能

- 将默认回复详细程度从"detailed"调整为"medium",前后端保持一致
- 新增话题切换检测逻辑,当用户主动要求换话题时提供引导回复
- 优化上下文处理机制,仅在指代型追问时注入历史对话,避免模型复读旧内容
- 改进知识库检索逻辑,区分自包含问题和指代型问题的上下文需求
- 完善知识库完整性指令,确保回复详细程度与知识展开程度一致
- 重构知识库重建逻辑,支持递归扫描子目录中的文件,修复索引为空的问题
- 增强素材匹配算法,引入强信号检测机制,避免仅凭模糊匹配误发素材
- 新增素材开场白AI生成功能,支持图片、视频、文档等类型智能描述
- 改进知识库重建通知,显示具体的文件数、分片数及失败统计信息
This commit is contained in:
2026-06-26 14:25:35 +08:00
parent 1517be2a25
commit 849090a627
12 changed files with 809 additions and 40 deletions

View File

@@ -46,7 +46,7 @@ func (e *AutoReplyEngine) getConfig() config.AutoReplyConfig {
cfg.AI.MaxTokens = 700
}
if strings.TrimSpace(cfg.AI.ReplyDetail) == "" {
cfg.AI.ReplyDetail = "detailed"
cfg.AI.ReplyDetail = "medium"
}
if cfg.Knowledge.TopK <= 0 {
cfg.Knowledge.TopK = 3
@@ -69,8 +69,8 @@ func (e *AutoReplyEngine) askAI(question string, hits []KnowledgeChunk, msg auto
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
userPrompt := buildAutoReplyUserPrompt(question, hits, msg, cfg.ReplyPolicy.UnknownAnswerToken)
msg.ContextText = e.contextPromptForQuestion(question, msg)
userPrompt := buildAutoReplyUserPrompt(question, hits, msg, cfg.ReplyPolicy.UnknownAnswerToken, cfg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
@@ -88,7 +88,7 @@ func (e *AutoReplyEngine) askGeneralAI(question string, msg autoReplyMessage) (*
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildGeneralAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
msg.ContextText = e.contextPromptForQuestion(question, msg)
userPrompt := buildGeneralAutoReplyUserPrompt(question, msg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
@@ -137,7 +137,7 @@ func buildAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
if token == "" {
token = "NO_ANSWER"
}
return prependAISystemPrompt(cfg, "你是企业微信客服。请基于提供的知识库片段,用自然亲切的语气回答客户问题。"+replyDetailInstruction(cfg)+"如果知识库里有详细内容,请完整展开说明,不要只列标题。知识库不足以确定答案时,只输出 "+token+"。不要编造政策、价格、承诺、库存或物流时效。客户要求人工、投诉、退款、合同、发票、赔偿或价格特殊审批时,也只输出 "+token+"。")
return prependAISystemPrompt(cfg, "你是企业微信客服。请基于提供的知识库片段,用自然亲切的语气回答客户问题。"+replyDetailInstruction(cfg)+knowledgeCompletenessInstruction(cfg)+"知识库不足以确定答案时,只输出 "+token+"。不要编造政策、价格、承诺、库存或物流时效。客户要求人工、投诉、退款、合同、发票、赔偿或价格特殊审批时,也只输出 "+token+"。")
}
func buildGeneralAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
@@ -184,12 +184,26 @@ func replyDetailInstruction(cfg config.AutoReplyConfig) string {
case "concise":
return "回复简洁直接1-2句话说清楚核心内容即可。"
case "medium":
return "回复适度详细2-4句话说明关键信息和注意事项。"
return "回复适度详细2-4句话说明关键信息和注意事项,不要罗列大段条目。"
default:
return "回复详细充分,把知识库的相关内容完整说清楚,让客户能理解具体情况。语气要自然,像真人对话一样,不要用模板化的官方表达。"
}
}
// knowledgeCompletenessInstruction 控制"知识库片段要展开到多细"。
// 这条指令必须与 replyDetailInstruction 一致,否则会出现"选了中等却仍写长文"的矛盾:
// detailed 才要求完整展开concise/medium 只挑与问题最相关的部分作答,避免又慢又被 max_tokens 截断。
func knowledgeCompletenessInstruction(cfg config.AutoReplyConfig) string {
switch strings.ToLower(strings.TrimSpace(cfg.AI.ReplyDetail)) {
case "concise":
return "只回答客户这一句问的内容,挑知识库里最相关的一点说清楚,不要把整段资料都搬出来。"
case "medium":
return "只针对客户当前的问题作答,从知识库里挑最相关的关键信息,不要把不相关的条目也一并列出。"
default:
return "如果知识库里有详细内容,请完整展开说明,不要只列标题。"
}
}
func effectiveReplyMaxTokens(cfg config.AIConfig) int {
maxTokens := cfg.MaxTokens
switch strings.ToLower(strings.TrimSpace(cfg.ReplyDetail)) {
@@ -220,7 +234,7 @@ func buildGeneralAutoReplyUserPrompt(question string, msg autoReplyMessage) stri
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString("\n\n最近对话上下文(仅供理解称呼和承接,请只回答“客户问题”那一句,不要主动延续之前的话题)\n")
b.WriteString(contextText)
}
b.WriteString("\n请直接给客户一条友好、可发送的回复。")
@@ -253,7 +267,7 @@ func buildNonTextAutoReplyUserPrompt(msg autoReplyMessage) string {
return b.String()
}
func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoReplyMessage, noAnswerToken string) string {
func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoReplyMessage, noAnswerToken string, cfg config.AutoReplyConfig) string {
noAnswerToken = strings.TrimSpace(noAnswerToken)
if noAnswerToken == "" {
noAnswerToken = "NO_ANSWER"
@@ -268,14 +282,16 @@ func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoRe
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString("\n\n最近对话上下文(仅供理解称呼和承接,请只回答“客户问题”那一句,不要主动延续之前的话题)\n")
b.WriteString(contextText)
}
b.WriteString("\n\n知识库片段\n")
for i, hit := range compactKnowledgeHitsForAI(hits) {
b.WriteString(fmt.Sprintf("[%d] 来源:%s 分数:%.3f\n%s\n\n", i+1, hit.Source, hit.Score, hit.Content))
}
b.WriteString("请基于上面的知识库片段回答客户问题。如果片段中有详细说明(比如具体步骤、标准、要求等),请完整地告诉客户,不要只列出标题。用自然的口语化表达,避免生硬的书面语。")
b.WriteString("请基于上面的知识库片段回答客户问题。")
b.WriteString(knowledgeCompletenessInstruction(cfg))
b.WriteString("用自然的口语化表达,避免生硬的书面语。")
if isGenericProductQuery(question) {
b.WriteString("如果客户询问全部产品、产品线或产品总览,请根据片段中能确定的内容整理产品/产品线清单只列能确定的产品不要说“knowledge库”“根据知识库”“知识库内容无法确定具体产品”不要输出空的 Markdown 列表或连续星号。")
}