Files
qiweimanager-master/helper/auto_reply_material_caption.go
2026-06-29 17:44:22 +08:00

222 lines
8.0 KiB
Go
Raw 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
}