- 将默认回复详细程度从"detailed"调整为"medium",前后端保持一致 - 新增话题切换检测逻辑,当用户主动要求换话题时提供引导回复 - 优化上下文处理机制,仅在指代型追问时注入历史对话,避免模型复读旧内容 - 改进知识库检索逻辑,区分自包含问题和指代型问题的上下文需求 - 完善知识库完整性指令,确保回复详细程度与知识展开程度一致 - 重构知识库重建逻辑,支持递归扫描子目录中的文件,修复索引为空的问题 - 增强素材匹配算法,引入强信号检测机制,避免仅凭模糊匹配误发素材 - 新增素材开场白AI生成功能,支持图片、视频、文档等类型智能描述 - 改进知识库重建通知,显示具体的文件数、分片数及失败统计信息
826 lines
26 KiB
Go
826 lines
26 KiB
Go
package main
|
||
|
||
import (
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
type AutoReplyMaterial struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Keywords []string `json:"keywords"`
|
||
QuestionPatterns []string `json:"questionPatterns"`
|
||
MaterialType string `json:"materialType"`
|
||
Path string `json:"path"`
|
||
Caption string `json:"caption"`
|
||
// CaptionSource 标记 caption 的来源:
|
||
// "ai" —— 同步时由模型自动生成;重新同步可被再次刷新。
|
||
// "manual" —— 运营手工编写;重新同步绝不覆盖。
|
||
// "" —— 未知/历史数据;按需生成。
|
||
CaptionSource string `json:"captionSource,omitempty"`
|
||
Priority int `json:"priority"`
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
|
||
type autoReplyMaterialsFile struct {
|
||
Materials []AutoReplyMaterial `json:"materials"`
|
||
}
|
||
|
||
type autoReplyMaterialMatch struct {
|
||
Material AutoReplyMaterial
|
||
Path string
|
||
Score int
|
||
}
|
||
|
||
type autoReplyMaterialSyncResult struct {
|
||
Added int `json:"added"`
|
||
Removed int `json:"removed"`
|
||
Total int `json:"total"`
|
||
Materials []AutoReplyMaterial `json:"materials"`
|
||
IndexPath string `json:"indexPath"`
|
||
Directory string `json:"directory"`
|
||
AddedPaths []string `json:"addedPaths"`
|
||
RemovedPaths []string `json:"removedPaths"`
|
||
}
|
||
|
||
func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string, hits []KnowledgeChunk) []autoReplyMaterialMatch {
|
||
cfg := e.getConfig()
|
||
if !cfg.Materials.AutoSendEnabled {
|
||
return nil
|
||
}
|
||
if isBroadAllMaterialRequest(userQuery) {
|
||
return nil
|
||
}
|
||
materials, err := loadAutoReplyMaterials(cfg.Materials.IndexPath)
|
||
if err != nil {
|
||
if !os.IsNotExist(err) {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "load materials failed: "+err.Error())
|
||
}
|
||
}
|
||
if len(materials) == 0 {
|
||
materials = discoverAutoReplyMaterials(cfg.Materials.Directory)
|
||
}
|
||
if len(materials) == 0 {
|
||
return nil
|
||
}
|
||
|
||
requestedTypes := requestedMaterialTypes(userQuery)
|
||
hasSendIntent := hasMaterialSendIntent(userQuery)
|
||
if hasSendIntent && isGenericMaterialRequest(userQuery) && !materialQueryHasSpecificSignal(userQuery, materials) && strings.TrimSpace(searchContext) == strings.TrimSpace(userQuery) {
|
||
return nil
|
||
}
|
||
queryText := buildMaterialSearchText(userQuery, "", nil, false)
|
||
matches := e.collectMaterialMatches(materials, cfg.Materials.Directory, requestedTypes, queryText, hasSendIntent)
|
||
if len(matches) > 0 {
|
||
return limitMaterialMatches(matches, cfg.Materials.MaxPerReply)
|
||
}
|
||
if !hasSendIntent {
|
||
return nil
|
||
}
|
||
|
||
searchText := buildMaterialSearchText(userQuery, "", hits, true)
|
||
matches = e.collectMaterialMatches(materials, cfg.Materials.Directory, requestedTypes, searchText, hasSendIntent)
|
||
return limitMaterialMatches(matches, cfg.Materials.MaxPerReply)
|
||
}
|
||
|
||
func (e *AutoReplyEngine) collectMaterialMatches(materials []AutoReplyMaterial, root string, requestedTypes map[string]bool, searchText string, hasSendIntent bool) []autoReplyMaterialMatch {
|
||
matches := make([]autoReplyMaterialMatch, 0, 4)
|
||
for _, material := range materials {
|
||
if len(requestedTypes) > 0 && !requestedTypes[material.MaterialType] {
|
||
continue
|
||
}
|
||
path := resolveAutoReplyMaterialPath(root, material.Path)
|
||
score, strong := materialMatchScoreDetailed(searchText, material, hasSendIntent)
|
||
// 必须命中过强信号(整词关键词/问句模板,或整串标题/文件名)才算候选;
|
||
// 仅靠 2-gram 模糊片段凑分的弱命中直接丢弃,避免误发。
|
||
if score <= 0 || !strong {
|
||
continue
|
||
}
|
||
if _, err := os.Stat(path); err != nil {
|
||
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, fmt.Sprintf("material file missing: %s", path))
|
||
continue
|
||
}
|
||
matches = append(matches, autoReplyMaterialMatch{Material: material, Path: path, Score: score})
|
||
}
|
||
sort.Slice(matches, func(i, j int) bool {
|
||
if matches[i].Score != matches[j].Score {
|
||
return matches[i].Score > matches[j].Score
|
||
}
|
||
if matches[i].Material.Priority != matches[j].Material.Priority {
|
||
return matches[i].Material.Priority > matches[j].Material.Priority
|
||
}
|
||
return matches[i].Material.Title < matches[j].Material.Title
|
||
})
|
||
return matches
|
||
}
|
||
|
||
func limitMaterialMatches(matches []autoReplyMaterialMatch, maxPerReply int) []autoReplyMaterialMatch {
|
||
limit := maxPerReply
|
||
if limit <= 0 {
|
||
limit = 2
|
||
}
|
||
if len(matches) > limit {
|
||
matches = matches[:limit]
|
||
}
|
||
return matches
|
||
}
|
||
|
||
func buildMaterialSearchText(userQuery string, searchContext string, hits []KnowledgeChunk, includeContext bool) string {
|
||
parts := []string{userQuery}
|
||
if includeContext {
|
||
parts = append(parts, searchContext)
|
||
for _, hit := range hits {
|
||
parts = append(parts, hit.Source, hit.Title, hit.Content)
|
||
}
|
||
}
|
||
return strings.ToLower(strings.Join(parts, "\n"))
|
||
}
|
||
|
||
func hasMaterialSendIntent(query string) bool {
|
||
text := normalizeGreetingText(query)
|
||
if text == "" {
|
||
return false
|
||
}
|
||
return containsAnyMaterialIntent(text, []string{
|
||
"发我", "发给我", "发一下", "发下", "发来", "发送", "传给我", "给我发",
|
||
"给我", "我要", "我想要", "需要", "有吗", "有没有", "资料", "素材",
|
||
"手册", "文档", "文件", "附件", "说明书", "宣传册", "ppt", "pdf",
|
||
"视频", "图片", "表格", "清单", "案例", "模板",
|
||
})
|
||
}
|
||
|
||
func requestedMaterialTypes(query string) map[string]bool {
|
||
text := strings.ToLower(strings.TrimSpace(query))
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
result := map[string]bool{}
|
||
if containsAnyMaterialIntent(text, []string{
|
||
"\u56fe\u7247", "\u7167\u7247", "\u76f8\u7247", "\u56fe\u50cf", "\u622a\u56fe", "\u914d\u56fe",
|
||
"image", "photo", "jpg", "jpeg", "png", "webp",
|
||
}) {
|
||
result["image"] = true
|
||
}
|
||
if containsAnyMaterialIntent(text, []string{
|
||
"\u89c6\u9891", "\u5f55\u50cf", "\u5f71\u7247", "\u77ed\u89c6\u9891", "video", "movie", "mp4", "mov",
|
||
}) {
|
||
result["video"] = true
|
||
}
|
||
if containsAnyMaterialIntent(text, []string{
|
||
"\u52a8\u56fe", "\u8868\u60c5\u5305", "gif",
|
||
}) {
|
||
result["gif"] = true
|
||
}
|
||
if containsAnyMaterialIntent(text, []string{
|
||
"\u6587\u4ef6", "\u6587\u6863", "\u6587\u7a3f", "\u9644\u4ef6", "\u8868\u683c",
|
||
"\u624b\u518c", "\u8d44\u6599", "\u65b9\u6848", "\u8bf4\u660e\u4e66",
|
||
"file", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
||
}) {
|
||
result["file"] = true
|
||
}
|
||
if len(result) == 0 {
|
||
return nil
|
||
}
|
||
return result
|
||
}
|
||
|
||
func containsAnyMaterialIntent(text string, keywords []string) bool {
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(text, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func loadAutoReplyMaterials(indexPath string) ([]AutoReplyMaterial, error) {
|
||
path := resolveAutoReplyPath(indexPath)
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var wrapped autoReplyMaterialsFile
|
||
if err := json.Unmarshal(data, &wrapped); err == nil {
|
||
return normalizeAutoReplyMaterials(wrapped.Materials), nil
|
||
}
|
||
var list []AutoReplyMaterial
|
||
if err := json.Unmarshal(data, &list); err != nil {
|
||
return nil, err
|
||
}
|
||
return normalizeAutoReplyMaterials(list), nil
|
||
}
|
||
|
||
// materialCaptionGenerator 根据素材本身(含已解析的绝对路径)生成一句开场白。
|
||
// 返回 ok=false 表示本条生成失败/跳过,调用方应保留原 caption 不动。
|
||
type materialCaptionGenerator func(material AutoReplyMaterial, absPath string) (caption string, ok bool)
|
||
|
||
func (e *AutoReplyEngine) syncAutoReplyMaterials() (autoReplyMaterialSyncResult, error) {
|
||
cfg := e.getConfig()
|
||
return syncAutoReplyMaterials(cfg.Materials.Directory, cfg.Materials.IndexPath, e.materialCaptionGenerator())
|
||
}
|
||
|
||
func syncAutoReplyMaterials(root string, indexPath string, generateCaption materialCaptionGenerator) (autoReplyMaterialSyncResult, error) {
|
||
result := autoReplyMaterialSyncResult{
|
||
Directory: resolveAutoReplyPath(root),
|
||
IndexPath: resolveAutoReplyPath(indexPath),
|
||
}
|
||
if err := os.MkdirAll(result.Directory, 0755); err != nil {
|
||
return result, err
|
||
}
|
||
|
||
existing, err := loadAutoReplyMaterials(indexPath)
|
||
if err != nil && !os.IsNotExist(err) {
|
||
return result, err
|
||
}
|
||
discovered := discoverAutoReplyMaterials(root)
|
||
discoveredByPath := make(map[string]AutoReplyMaterial, len(discovered))
|
||
for _, item := range discovered {
|
||
discoveredByPath[materialPathKey(item.Path)] = item
|
||
}
|
||
|
||
synced := make([]AutoReplyMaterial, 0, len(discovered))
|
||
seen := make(map[string]bool, len(discovered))
|
||
for _, item := range existing {
|
||
key := materialPathKey(item.Path)
|
||
if key == "" || seen[key] {
|
||
continue
|
||
}
|
||
if _, ok := discoveredByPath[key]; !ok {
|
||
result.Removed++
|
||
result.RemovedPaths = append(result.RemovedPaths, item.Path)
|
||
continue
|
||
}
|
||
synced = append(synced, item)
|
||
seen[key] = true
|
||
}
|
||
for _, item := range discovered {
|
||
key := materialPathKey(item.Path)
|
||
if key == "" || seen[key] {
|
||
continue
|
||
}
|
||
synced = append(synced, item)
|
||
seen[key] = true
|
||
result.Added++
|
||
result.AddedPaths = append(result.AddedPaths, item.Path)
|
||
}
|
||
|
||
sort.SliceStable(synced, func(i, j int) bool {
|
||
li := strings.ToLower(synced[i].Path)
|
||
lj := strings.ToLower(synced[j].Path)
|
||
if li != lj {
|
||
return li < lj
|
||
}
|
||
return strings.ToLower(synced[i].Title) < strings.ToLower(synced[j].Title)
|
||
})
|
||
|
||
// 在写盘前为需要的素材生成开场白;generateCaption 为 nil(如未配置 AI 或单测)时整体跳过。
|
||
if generateCaption != nil {
|
||
applyMaterialCaptions(synced, result.Directory, generateCaption)
|
||
}
|
||
|
||
if err := os.MkdirAll(filepath.Dir(result.IndexPath), 0755); err != nil {
|
||
return result, err
|
||
}
|
||
data, err := json.MarshalIndent(autoReplyMaterialsFile{Materials: synced}, "", " ")
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if err := os.WriteFile(result.IndexPath, data, 0644); err != nil {
|
||
return result, err
|
||
}
|
||
result.Materials = synced
|
||
result.Total = len(synced)
|
||
return result, nil
|
||
}
|
||
|
||
func discoverAutoReplyMaterials(root string) []AutoReplyMaterial {
|
||
dir := resolveAutoReplyPath(root)
|
||
items := make([]AutoReplyMaterial, 0, 8)
|
||
// 递归遍历子目录(filepath.WalkDir):支持 config/materials 下任意层级嵌套。
|
||
// Path 存相对 root 的子路径并统一为 / 分隔;顶层文件相对路径即文件名,向后兼容旧索引。
|
||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return nil // 单个条目出错跳过,不中断整体扫描
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
name := d.Name()
|
||
if strings.EqualFold(name, "materials.json") {
|
||
return nil
|
||
}
|
||
materialType := inferMaterialType(name)
|
||
if materialType == "" {
|
||
return nil
|
||
}
|
||
rel, relErr := filepath.Rel(dir, path)
|
||
if relErr != nil {
|
||
rel = name
|
||
}
|
||
rel = filepath.ToSlash(rel)
|
||
title := strings.TrimSuffix(name, filepath.Ext(name))
|
||
keywords := defaultMaterialKeywords(title, materialType)
|
||
// 把子目录名也并入关键词,便于"发我<目录名>的图/文件"命中。
|
||
// 只添加目录名本身,不再分词,避免关键词过度膨胀。
|
||
if dirPart := filepath.ToSlash(filepath.Dir(rel)); dirPart != "." && dirPart != "" {
|
||
for _, seg := range strings.Split(dirPart, "/") {
|
||
if seg = strings.TrimSpace(seg); seg != "" {
|
||
keywords = append(keywords, seg)
|
||
}
|
||
}
|
||
keywords = dedupeNonEmptyStrings(keywords)
|
||
}
|
||
items = append(items, AutoReplyMaterial{
|
||
ID: materialIDFromTitle(strings.TrimSuffix(rel, filepath.Ext(rel))),
|
||
Title: title,
|
||
Keywords: keywords,
|
||
QuestionPatterns: defaultMaterialQuestionPatterns(title),
|
||
MaterialType: materialType,
|
||
Path: rel,
|
||
Caption: defaultMaterialCaption(materialType),
|
||
Priority: 1,
|
||
Enabled: true,
|
||
})
|
||
return nil
|
||
})
|
||
return normalizeAutoReplyMaterials(items)
|
||
}
|
||
|
||
func materialIDFromTitle(title string) string {
|
||
base := strings.TrimSpace(strings.ToLower(title))
|
||
var builder strings.Builder
|
||
lastDash := false
|
||
for _, r := range base {
|
||
switch {
|
||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||
builder.WriteRune(r)
|
||
lastDash = false
|
||
case r == '-' || r == '_':
|
||
if builder.Len() > 0 {
|
||
builder.WriteRune(r)
|
||
lastDash = false
|
||
}
|
||
default:
|
||
if builder.Len() > 0 && !lastDash {
|
||
builder.WriteByte('-')
|
||
lastDash = true
|
||
}
|
||
}
|
||
}
|
||
id := strings.Trim(builder.String(), "-_")
|
||
if id == "" {
|
||
sum := sha1.Sum([]byte(base))
|
||
id = "material-" + hex.EncodeToString(sum[:])[:12]
|
||
}
|
||
return id
|
||
}
|
||
|
||
func defaultMaterialQuestionPatterns(title string) []string {
|
||
title = strings.TrimSpace(title)
|
||
if title == "" {
|
||
return nil
|
||
}
|
||
return []string{"我要" + title, "发我" + title, "看" + title, "有没有" + title, "把" + title + "发我", "需要" + title}
|
||
}
|
||
|
||
func defaultMaterialKeywords(title string, materialType string) []string {
|
||
keywords := []string{strings.TrimSpace(title)}
|
||
keywords = append(keywords, materialSearchTokens(title)...)
|
||
switch materialType {
|
||
case "image":
|
||
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
|
||
case "video":
|
||
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
|
||
case "gif":
|
||
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
|
||
default:
|
||
keywords = append(keywords, specificMaterialTokensForType(materialType)...)
|
||
}
|
||
return dedupeNonEmptyStrings(keywords)
|
||
}
|
||
|
||
func specificMaterialTokensForType(materialType string) []string {
|
||
switch materialType {
|
||
case "video":
|
||
return []string{"安装视频", "演示视频", "教程视频"}
|
||
case "image":
|
||
return []string{"示意图", "效果图", "截图"}
|
||
case "gif":
|
||
return []string{"动图"}
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func materialPathKey(path string) string {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
return strings.ToLower(filepath.ToSlash(filepath.Clean(path)))
|
||
}
|
||
|
||
func normalizeAutoReplyMaterials(items []AutoReplyMaterial) []AutoReplyMaterial {
|
||
result := make([]AutoReplyMaterial, 0, len(items))
|
||
for _, item := range items {
|
||
item.ID = strings.TrimSpace(item.ID)
|
||
item.Title = strings.TrimSpace(item.Title)
|
||
item.MaterialType = strings.ToLower(strings.TrimSpace(item.MaterialType))
|
||
item.Path = strings.TrimSpace(item.Path)
|
||
item.Caption = strings.TrimSpace(item.Caption)
|
||
if item.MaterialType == "" {
|
||
item.MaterialType = inferMaterialType(item.Path)
|
||
}
|
||
if item.Path == "" || item.MaterialType == "" {
|
||
continue
|
||
}
|
||
if !item.Enabled && strings.TrimSpace(item.ID+item.Title) == "" {
|
||
continue
|
||
}
|
||
result = append(result, item)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func materialMatchScore(searchText string, material AutoReplyMaterial, hasSendIntent bool) int {
|
||
score, _ := materialMatchScoreDetailed(searchText, material, hasSendIntent)
|
||
return score
|
||
}
|
||
|
||
// materialMatchScoreDetailed 在打分之外额外返回 strong:是否命中过“强信号”。
|
||
// 强信号 = 整词关键词/问句模板命中,或整串标题/文件名命中。
|
||
// 仅靠 2-gram 模糊片段(fuzzyMaterialTokenScore)凑出的分数不算强信号——
|
||
// 这类弱命中只用于在多个强匹配之间排序,不能单独触发发送,避免误发。
|
||
func materialMatchScoreDetailed(searchText string, material AutoReplyMaterial, hasSendIntent bool) (int, bool) {
|
||
score := 0
|
||
strong := false
|
||
for _, keyword := range append(material.Keywords, material.QuestionPatterns...) {
|
||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||
if keyword == "" || isGenericMaterialIntentToken(keyword) {
|
||
continue
|
||
}
|
||
if strings.Contains(searchText, keyword) {
|
||
score += 10
|
||
strong = true
|
||
}
|
||
}
|
||
for _, field := range []string{material.Title, filepath.Base(material.Path), strings.TrimSuffix(filepath.Base(material.Path), filepath.Ext(material.Path))} {
|
||
field = strings.ToLower(strings.TrimSpace(field))
|
||
if field != "" && len([]rune(field)) >= 2 && strings.Contains(searchText, field) {
|
||
score += 4
|
||
strong = true
|
||
}
|
||
score += fuzzyMaterialTokenScore(searchText, field)
|
||
}
|
||
if hasSendIntent && score > 0 {
|
||
score += 3
|
||
}
|
||
return score, strong
|
||
}
|
||
|
||
func isBroadAllMaterialRequest(query string) bool {
|
||
text := normalizeGreetingText(query)
|
||
if text == "" {
|
||
return false
|
||
}
|
||
phrases := []string{
|
||
"全部资料", "所有资料", "全部文件", "所有文件", "全部素材", "所有素材", "全部发", "全都发",
|
||
"都发我", "都发给我", "资料都发", "文件都发", "全套资料", "所有手册", "全部手册",
|
||
}
|
||
for _, phrase := range phrases {
|
||
if strings.Contains(text, normalizeGreetingText(phrase)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isGenericMaterialRequest(query string) bool {
|
||
text := normalizeGreetingText(query)
|
||
if text == "" || !hasMaterialSendIntent(query) {
|
||
return false
|
||
}
|
||
generic := []string{
|
||
"资料", "文件", "文档", "附件", "素材", "手册", "说明书", "宣传册", "方案",
|
||
"模板", "案例", "清单", "表格", "图片", "照片", "截图", "视频", "ppt", "pdf", "doc", "docx", "xls", "xlsx",
|
||
"发我", "发给我", "发一个", "发下", "发来", "发送", "传给我", "给我发", "给我", "我要", "我想要", "需要", "有吗", "有没有",
|
||
}
|
||
remaining := text
|
||
for _, token := range generic {
|
||
remaining = strings.ReplaceAll(remaining, normalizeGreetingText(token), "")
|
||
}
|
||
remaining = strings.Trim(remaining, " \t\r\n,,。.!!??;;::、()()[]【】")
|
||
return len([]rune(remaining)) == 0
|
||
}
|
||
|
||
func materialQueryHasSpecificSignal(query string, materials []AutoReplyMaterial) bool {
|
||
text := strings.ToLower(normalizeGreetingText(query))
|
||
if text == "" {
|
||
return false
|
||
}
|
||
for _, material := range materials {
|
||
fields := []string{
|
||
material.Title,
|
||
filepath.Base(material.Path),
|
||
strings.TrimSuffix(filepath.Base(material.Path), filepath.Ext(material.Path)),
|
||
}
|
||
for _, keyword := range append(material.Keywords, material.QuestionPatterns...) {
|
||
if !isGenericMaterialIntentToken(keyword) {
|
||
fields = append(fields, keyword)
|
||
}
|
||
}
|
||
for _, field := range fields {
|
||
field = strings.ToLower(normalizeGreetingText(field))
|
||
if len([]rune(field)) >= 3 && strings.Contains(text, field) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isGenericMaterialIntentToken(token string) bool {
|
||
token = normalizeGreetingText(token)
|
||
if token == "" {
|
||
return true
|
||
}
|
||
switch token {
|
||
case "资料", "文件", "文档", "附件", "素材", "手册", "说明书", "宣传册",
|
||
"方案", "模板", "案例", "清单", "表格", "图片", "照片", "截图",
|
||
"视频", "录像", "ppt", "pptx", "pdf", "doc", "docx", "xls", "xlsx",
|
||
"发我", "给我", "需要", "有没有", "我要", "发一下":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func fuzzyMaterialTokenScore(searchText string, field string) int {
|
||
tokens := materialSearchTokens(field)
|
||
if len(tokens) == 0 {
|
||
return 0
|
||
}
|
||
score := 0
|
||
for _, token := range tokens {
|
||
if len([]rune(token)) < 2 {
|
||
continue
|
||
}
|
||
if isGenericMaterialIntentToken(token) {
|
||
continue
|
||
}
|
||
if strings.Contains(searchText, token) {
|
||
score += 2
|
||
}
|
||
}
|
||
return score
|
||
}
|
||
|
||
func materialSearchTokens(text string) []string {
|
||
text = strings.ToLower(strings.TrimSpace(text))
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
separators := func(r rune) bool {
|
||
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r))
|
||
}
|
||
parts := strings.FieldsFunc(text, separators)
|
||
result := make([]string, 0, len(parts)*2)
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if len([]rune(part)) < 2 {
|
||
continue
|
||
}
|
||
result = append(result, part)
|
||
runes := []rune(part)
|
||
if len(runes) > 4 {
|
||
for i := 0; i+2 <= len(runes); i++ {
|
||
result = append(result, string(runes[i:i+2]))
|
||
}
|
||
}
|
||
}
|
||
return dedupeNonEmptyStrings(result)
|
||
}
|
||
|
||
func resolveAutoReplyMaterialPath(root string, materialPath string) string {
|
||
materialPath = strings.TrimSpace(materialPath)
|
||
if filepath.IsAbs(materialPath) {
|
||
return filepath.Clean(materialPath)
|
||
}
|
||
return filepath.Join(resolveAutoReplyPath(root), filepath.Clean(materialPath))
|
||
}
|
||
|
||
func inferMaterialType(path string) string {
|
||
switch strings.ToLower(filepath.Ext(path)) {
|
||
case ".jpg", ".jpeg", ".png", ".bmp", ".webp":
|
||
return "image"
|
||
case ".gif":
|
||
return "gif"
|
||
case ".mp4", ".mov", ".avi", ".mkv", ".wmv":
|
||
return "video"
|
||
case ".json":
|
||
return ""
|
||
default:
|
||
return "file"
|
||
}
|
||
}
|
||
|
||
func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch, reason string, timings autoReplyTimings) error {
|
||
if len(matches) == 0 {
|
||
return nil
|
||
}
|
||
captions := make([]string, 0, len(matches))
|
||
for _, match := range matches {
|
||
if caption := customMaterialCaptionForSend(match.Material); caption != "" {
|
||
captions = append(captions, caption)
|
||
}
|
||
}
|
||
if len(captions) == 0 {
|
||
captions = append(captions, combinedMaterialCaption(matches))
|
||
}
|
||
caption := strings.Join(uniqueMaterialStrings(captions), "\n")
|
||
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, caption); err != nil {
|
||
return err
|
||
}
|
||
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, caption)
|
||
sent := make([]string, 0, len(matches))
|
||
for _, match := range matches {
|
||
if err := sendAutoReplyMaterial(uint32(msg.ClientID), msg.ConversationID, match.Material.MaterialType, match.Path); err != nil {
|
||
return fmt.Errorf("send material %s failed: %w", match.Path, err)
|
||
}
|
||
sent = append(sent, fmt.Sprintf("%s:%s", match.Material.MaterialType, match.Path))
|
||
}
|
||
e.markCooldown(msg)
|
||
e.incStatus("replied")
|
||
e.noteReason(reason)
|
||
e.addRecord(AutoReplyRecord{
|
||
RobotID: msg.RobotID,
|
||
ClientID: msg.ClientID,
|
||
UserID: msg.RobotID,
|
||
ConversationID: msg.ConversationID,
|
||
Source: msg.sourceLabel(),
|
||
FromWxID: msg.FromWxID,
|
||
FromNickName: msg.FromNickName,
|
||
Question: msg.Content,
|
||
Action: "replied",
|
||
Reason: reason,
|
||
Answer: strings.Join(sent, "\n"),
|
||
SenderIdentity: msg.SenderIdentity,
|
||
IdentitySource: msg.IdentitySource,
|
||
KeywordScore: timings.KeywordScore,
|
||
VectorScore: timings.VectorScore,
|
||
RerankScore: timings.RerankScore,
|
||
RetrievalMode: timings.RetrievalMode,
|
||
UsedKnowledgeSources: strings.Join(timings.UsedKnowledgeSources, ", "),
|
||
KnowledgeDurationMS: timings.KnowledgeDurationMS,
|
||
KeywordDurationMS: timings.KeywordDurationMS,
|
||
VectorDurationMS: timings.VectorDurationMS,
|
||
RerankDurationMS: timings.RerankDurationMS,
|
||
AIDurationMS: timings.AIDurationMS,
|
||
TotalDurationMS: timings.TotalDurationMS,
|
||
})
|
||
return nil
|
||
}
|
||
|
||
func materialCaptionForSend(material AutoReplyMaterial) string {
|
||
if caption := customMaterialCaptionForSend(material); caption != "" {
|
||
return caption
|
||
}
|
||
return defaultMaterialCaption(material.MaterialType)
|
||
}
|
||
|
||
func customMaterialCaptionForSend(material AutoReplyMaterial) string {
|
||
caption := strings.TrimSpace(material.Caption)
|
||
if caption != "" && !isLegacyGenericMaterialCaption(caption) {
|
||
return caption
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isLegacyGenericMaterialCaption(caption string) bool {
|
||
text := normalizeGreetingText(caption)
|
||
switch text {
|
||
case normalizeGreetingText("我把相关资料直接发你。"),
|
||
normalizeGreetingText("我把相关资料发你。"):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func defaultMaterialCaption(materialType string) string {
|
||
switch strings.ToLower(strings.TrimSpace(materialType)) {
|
||
case "image":
|
||
return "我把图片发你。"
|
||
case "video":
|
||
return "我把视频发你。"
|
||
case "gif":
|
||
return "我把动图发你。"
|
||
default:
|
||
return "我把文件发你。"
|
||
}
|
||
}
|
||
|
||
func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
|
||
if len(matches) == 0 {
|
||
return "我把文件发你。"
|
||
}
|
||
seen := map[string]bool{}
|
||
labels := make([]string, 0, 4)
|
||
add := func(materialType string, label string) {
|
||
if !seen[materialType] {
|
||
seen[materialType] = true
|
||
labels = append(labels, label)
|
||
}
|
||
}
|
||
for _, match := range matches {
|
||
switch strings.ToLower(strings.TrimSpace(match.Material.MaterialType)) {
|
||
case "image":
|
||
add("image", "图片")
|
||
case "video":
|
||
add("video", "视频")
|
||
case "gif":
|
||
add("gif", "动图")
|
||
default:
|
||
add("file", "文件")
|
||
}
|
||
}
|
||
if len(labels) == 1 {
|
||
return defaultMaterialCaption(matches[0].Material.MaterialType)
|
||
}
|
||
return "我把" + strings.Join(labels, "和") + "发你。"
|
||
}
|
||
|
||
func uniqueMaterialStrings(items []string) []string {
|
||
seen := make(map[string]bool, len(items))
|
||
result := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
item = strings.TrimSpace(item)
|
||
if item == "" || seen[item] {
|
||
continue
|
||
}
|
||
seen[item] = true
|
||
result = append(result, item)
|
||
}
|
||
return result
|
||
}
|
||
|
||
var sendAutoReplyMaterialSender = sendAutoReplyMaterialRequest
|
||
|
||
func sendAutoReplyMaterial(clientID uint32, conversationID string, materialType string, path string) error {
|
||
return sendAutoReplyMaterialSender(clientID, conversationID, materialType, path)
|
||
}
|
||
|
||
func sendAutoReplyMaterialRequest(clientID uint32, conversationID string, materialType string, path string) error {
|
||
if strings.TrimSpace(conversationID) == "" {
|
||
return fmt.Errorf("conversationId is empty")
|
||
}
|
||
if strings.TrimSpace(path) == "" {
|
||
return fmt.Errorf("material path is empty")
|
||
}
|
||
messageType := 11031
|
||
switch strings.ToLower(strings.TrimSpace(materialType)) {
|
||
case "image":
|
||
messageType = 11030
|
||
case "video":
|
||
messageType = 11067
|
||
case "gif":
|
||
messageType = 11070
|
||
case "file":
|
||
messageType = 11031
|
||
default:
|
||
messageType = 11031
|
||
}
|
||
request := map[string]interface{}{
|
||
"type": messageType,
|
||
"data": map[string]interface{}{
|
||
"conversation_id": conversationID,
|
||
"file": path,
|
||
},
|
||
}
|
||
data, err := json.Marshal(request)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
result, err := handleSendWxWorkData(map[string]interface{}{
|
||
"data": string(data),
|
||
"clientId": clientID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resultMap, ok := result.(map[string]interface{}); ok {
|
||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||
return fmt.Errorf("%v", resultMap["error"])
|
||
}
|
||
}
|
||
return nil
|
||
}
|