- 将默认回复详细程度从"detailed"调整为"medium",前后端保持一致 - 新增话题切换检测逻辑,当用户主动要求换话题时提供引导回复 - 优化上下文处理机制,仅在指代型追问时注入历史对话,避免模型复读旧内容 - 改进知识库检索逻辑,区分自包含问题和指代型问题的上下文需求 - 完善知识库完整性指令,确保回复详细程度与知识展开程度一致 - 重构知识库重建逻辑,支持递归扫描子目录中的文件,修复索引为空的问题 - 增强素材匹配算法,引入强信号检测机制,避免仅凭模糊匹配误发素材 - 新增素材开场白AI生成功能,支持图片、视频、文档等类型智能描述 - 改进知识库重建通知,显示具体的文件数、分片数及失败统计信息
222 lines
8.0 KiB
Go
222 lines
8.0 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
|
||
"qiweimanager/config"
|
||
)
|
||
|
||
// materialCaptionGenerator 返回引擎当前可用的描述生成器。
|
||
// 未配置 AI(BaseURL/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 里手填的非通用 caption(source 为空)视为人工,保留不动。
|
||
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
|
||
}
|