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

222 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"path/filepath"
"strings"
"sync"
"qiweimanager/config"
)
// materialCaptionGenerator 返回引擎当前可用的描述生成器。
// 未配置 AIBaseURL/Model 为空)时返回 nil同步时整体跳过生成
// 素材沿用按类型的默认话术,不影响原有行为。
func (e *AutoReplyEngine) materialCaptionGenerator() materialCaptionGenerator {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" || strings.TrimSpace(cfg.AI.Model) == "" {
return nil
}
aiCfg := cfg.AI
provider := strings.ToLower(strings.TrimSpace(cfg.AI.Provider))
return func(material AutoReplyMaterial, absPath string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(material.MaterialType)) {
case "image", "gif":
// 本地图片喂 vision 模型“看图说话”ollama 的多模态格式不同,退回按标题生成。
if provider == "local" || provider == "ollama" {
return generateMaterialCaptionByChat(aiCfg, provider, materialCaptionTitleUserPrompt(material))
}
return generateMaterialCaptionFromImage(aiCfg, material, absPath)
case "video":
// 视频不便直接喂模型,用标题让 chat 模型生成。
return generateMaterialCaptionByChat(aiCfg, provider, materialCaptionTitleUserPrompt(material))
default:
// 文档/表格:抽取开头文字喂 chat 模型概括;抽不出就退回按标题生成。
excerpt := materialDocumentExcerpt(absPath)
return generateMaterialCaptionByChat(aiCfg, provider, materialCaptionDocumentUserPrompt(material, excerpt))
}
}
}
// applyMaterialCaptions 为需要的素材并发生成开场白,原地写回 synced。
// 每个 goroutine 只写各自下标,互不重叠,无需加锁。
func applyMaterialCaptions(materials []AutoReplyMaterial, root string, generate materialCaptionGenerator) {
targets := make([]int, 0, len(materials))
for i := range materials {
if materialNeedsCaptionGeneration(materials[i]) {
targets = append(targets, i)
}
}
if len(targets) == 0 {
return
}
const maxConcurrent = 3
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, idx := range targets {
idx := idx
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
absPath := resolveAutoReplyMaterialPath(root, materials[idx].Path)
if caption, ok := generate(materials[idx], absPath); ok {
materials[idx].Caption = caption
materials[idx].CaptionSource = "ai"
}
}()
}
wg.Wait()
}
// materialNeedsCaptionGeneration 判断某条素材是否需要(重新)生成开场白。
// - manual运营手写绝不覆盖。
// - ai已生成过避免每次同步重复花费 token需要刷新走专门入口。
// - 其它caption 为空或仍是按类型的通用默认话术时才生成;
// 运营在 JSON 里手填的非通用 captionsource 为空)视为人工,保留不动。
func materialNeedsCaptionGeneration(material AutoReplyMaterial) bool {
switch strings.ToLower(strings.TrimSpace(material.CaptionSource)) {
case "manual", "ai":
return false
}
caption := strings.TrimSpace(material.Caption)
return caption == "" || isGenericMaterialCaption(caption)
}
// isGenericMaterialCaption 判断 caption 是否为系统内置的通用默认话术(含历史版本)。
func isGenericMaterialCaption(caption string) bool {
if isLegacyGenericMaterialCaption(caption) {
return true
}
norm := normalizeGreetingText(caption)
if norm == "" {
return true
}
for _, materialType := range []string{"image", "video", "gif", "file"} {
if normalizeGreetingText(defaultMaterialCaption(materialType)) == norm {
return true
}
}
return false
}
func generateMaterialCaptionFromImage(aiCfg config.AIConfig, material AutoReplyMaterial, absPath string) (string, bool) {
dataURL, err := imageDataURLFromFile(absPath)
if err != nil {
return "", false
}
result, err := callOpenAICompatibleVisionChat(aiCfg, materialCaptionSystemPrompt(), materialCaptionImageUserPrompt(material), dataURL)
if err != nil || result == nil {
return "", false
}
return sanitizeMaterialCaption(result.Answer)
}
func generateMaterialCaptionByChat(aiCfg config.AIConfig, provider string, userPrompt string) (string, bool) {
var (
result *AIResult
err error
)
if provider == "local" || provider == "ollama" {
result, err = callOllamaChat(aiCfg, materialCaptionSystemPrompt(), userPrompt)
} else {
result, err = callOpenAICompatibleChat(aiCfg, materialCaptionSystemPrompt(), userPrompt)
}
if err != nil || result == nil {
return "", false
}
return sanitizeMaterialCaption(result.Answer)
}
func materialCaptionSystemPrompt() string {
return "你是企业微信里的真人客服,现在要把一份资料顺手发给客户。请写一句自然口语的开场白," +
"要求①只有一句话不超过40字②像微信里随手发东西时说的话亲切自然不要书面腔和客服模板腔" +
"(不要用“您好”“为您提供”“请查收”这类);③结合资料内容点出这是什么、对客户有什么用;" +
"④不要编造资料里没有的信息;⑤只输出这句话本身,不要加引号、解释或多余标点。"
}
func materialCaptionImageUserPrompt(material AutoReplyMaterial) string {
return fmt.Sprintf("这是一张要发给客户的图片,标题是「%s」。请先看图片实际内容再写一句发图时的自然开场白。", strings.TrimSpace(material.Title))
}
func materialCaptionDocumentUserPrompt(material AutoReplyMaterial, excerpt string) string {
title := strings.TrimSpace(material.Title)
label := materialTypeLabel(material.MaterialType)
excerpt = strings.TrimSpace(excerpt)
if excerpt == "" {
return fmt.Sprintf("这是一份要发给客户的%s标题是「%s」。请根据标题写一句发送时的自然开场白。", label, title)
}
return fmt.Sprintf("这是一份要发给客户的%s标题是「%s」。以下是它开头部分的内容节选\n%s\n请结合内容写一句发送时的自然开场白。", label, title, excerpt)
}
func materialCaptionTitleUserPrompt(material AutoReplyMaterial) string {
return fmt.Sprintf("这是一个要发给客户的%s标题是「%s」。请根据标题写一句发送时的自然开场白。", materialTypeLabel(material.MaterialType), strings.TrimSpace(material.Title))
}
func materialTypeLabel(materialType string) string {
switch strings.ToLower(strings.TrimSpace(materialType)) {
case "image":
return "图片"
case "video":
return "视频"
case "gif":
return "动图"
default:
return "文件"
}
}
// materialDocumentExcerpt 复用知识库解析器抽取文档开头文字,供模型概括。
// 不支持的格式(如 .pptx会解析失败返回空串调用方退回按标题生成。
func materialDocumentExcerpt(absPath string) string {
chunks, err := parseKnowledgeFile(absPath, filepath.Dir(absPath))
if err != nil || len(chunks) == 0 {
return ""
}
var builder strings.Builder
for _, chunk := range chunks {
title := strings.TrimSpace(chunk.Title)
content := strings.TrimSpace(chunk.Content)
if title != "" {
if builder.Len() > 0 {
builder.WriteString("\n")
}
builder.WriteString(title)
}
if content != "" {
if builder.Len() > 0 {
builder.WriteString(" ")
}
builder.WriteString(content)
}
if len([]rune(builder.String())) >= 600 {
break
}
}
return truncateText(strings.TrimSpace(builder.String()), 800)
}
// sanitizeMaterialCaption 清洗模型输出:去包裹引号、压成单行、挡掉异常输出,限制长度。
func sanitizeMaterialCaption(raw string) (string, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return "", false
}
text = strings.Trim(text, "\"'“”‘’ \t\r\n")
text = strings.Join(strings.Fields(text), " ")
if text == "" {
return "", false
}
if strings.Contains(strings.ToUpper(text), "NO_ANSWER") {
return "", false
}
if runes := []rune(text); len(runes) > 60 {
text = strings.TrimSpace(string(runes[:60]))
}
if text == "" {
return "", false
}
return text, true
}