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 }