feat: update auto reply and packaging
This commit is contained in:
@@ -125,6 +125,10 @@ func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
|
||||
if name := e.displayNameForMessage(msg); name != "" {
|
||||
msg.FromNickName = name
|
||||
}
|
||||
if cfg.Identity.ReplyExternalOnly && identity.isInternal() {
|
||||
e.ignoreMessage(msg, "internal_ignored_external_only")
|
||||
return
|
||||
}
|
||||
if identity.Source == identitySourceUnknownAsCustomer {
|
||||
e.noteReason(identitySourceUnknownAsCustomer)
|
||||
if !msg.IsGroup {
|
||||
@@ -269,7 +273,11 @@ func (e *AutoReplyEngine) processJob(job AutoReplyJob) {
|
||||
}
|
||||
e.setLastRetrievalScores(searchResult.KeywordScore, searchResult.VectorScore, searchResult.RerankScore)
|
||||
if len(materialMatches) > 0 {
|
||||
if err := e.sendMaterials(msg, materialMatches, "materials_replied", withSearchMetadata(currentTimings(), searchResult)); err != nil {
|
||||
materialMatches = e.filterRecentlySentMaterials(msg, materialMatches)
|
||||
}
|
||||
if len(materialMatches) > 0 {
|
||||
tutorialText := materialTutorialTextFromHits(hits)
|
||||
if err := e.sendMaterials(msg, materialMatches, "materials_replied", withSearchMetadata(currentTimings(), searchResult), tutorialText); err != nil {
|
||||
e.setLastErrorWithScope(autoReplyErrorScopeRecords, "material send failed: "+err.Error())
|
||||
}
|
||||
return
|
||||
|
||||
@@ -54,9 +54,18 @@ func handleAutoReplyRebuildKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
idx, err := getAutoReplyEngine().rebuildKnowledgeIndex()
|
||||
if err != nil {
|
||||
data := map[string]interface{}{
|
||||
"durationMs": time.Since(start).Milliseconds(),
|
||||
}
|
||||
if idx != nil {
|
||||
data["fileCount"] = idx.FileCount
|
||||
data["chunkCount"] = len(idx.Chunks)
|
||||
data["failedFiles"] = idx.FailedFiles
|
||||
}
|
||||
sendJSONResponse(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
"data": data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,9 +162,18 @@ func (e *AutoReplyEngine) rebuildKnowledgeIndex() (*KnowledgeIndex, error) {
|
||||
return nil, err
|
||||
}
|
||||
e.updateKnowledgeStatus(idx)
|
||||
if err := e.rebuildEmbeddingIndex(idx); err != nil {
|
||||
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, err.Error())
|
||||
if requiresEmbeddingIndex(cfg.Retrieval.RetrievalMode) {
|
||||
if err := e.rebuildEmbeddingIndex(idx); err != nil {
|
||||
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, err.Error())
|
||||
return idx, err
|
||||
}
|
||||
if len(idx.Chunks) > 0 && e.embeddingEntryCount() == 0 {
|
||||
err := fmt.Errorf("向量索引为空,请先重建知识库索引")
|
||||
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, err.Error())
|
||||
return idx, err
|
||||
}
|
||||
}
|
||||
e.clearLastErrorScope(autoReplyErrorScopeKnowledge)
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
@@ -206,6 +215,19 @@ func (e *AutoReplyEngine) updateKnowledgeStatus(idx *KnowledgeIndex) {
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *AutoReplyEngine) embeddingEntryCount() int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.embeddingIndex == nil {
|
||||
return 0
|
||||
}
|
||||
return len(e.embeddingIndex.Entries)
|
||||
}
|
||||
|
||||
func requiresEmbeddingIndex(mode string) bool {
|
||||
return strings.TrimSpace(mode) != retrievalModeKeywordOnly
|
||||
}
|
||||
|
||||
func scoreKnowledgeChunk(queryTokens map[string]int, chunk KnowledgeChunk) float64 {
|
||||
textTokens := tokenizeKnowledgeText(chunk.Title + " " + chunk.Content)
|
||||
if len(textTokens) == 0 {
|
||||
|
||||
@@ -79,8 +79,8 @@ func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
|
||||
// 根目录故意不放任何知识文件,全部放进多层子目录。
|
||||
files := map[string]string{
|
||||
filepath.Join("01_产品", "数控机床", "VMC850规格.md"): "VMC850 立式加工中心,主轴转速 8000rpm。",
|
||||
filepath.Join("03_售后", "故障排查", "常见故障.md"): "报警 E01 表示伺服过载,请检查负载。",
|
||||
filepath.Join("readme.txt"): "", // 空文件,应进 FailedFiles 不计入 FileCount
|
||||
filepath.Join("03_售后", "故障排查", "常见故障.md"): "报警 E01 表示伺服过载,请检查负载。",
|
||||
filepath.Join("readme.txt"): "", // 空文件,应进 FailedFiles 不计入 FileCount
|
||||
}
|
||||
for rel, content := range files {
|
||||
full := filepath.Join(dir, rel)
|
||||
@@ -96,6 +96,7 @@ func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
|
||||
cfg.Knowledge.Directory = dir
|
||||
cfg.Knowledge.IndexPath = filepath.Join(dir, "index.json")
|
||||
cfg.Retrieval.EmbeddingIndexPath = filepath.Join(dir, "embedding_index.json")
|
||||
cfg.Retrieval.RetrievalMode = retrievalModeKeywordOnly
|
||||
engine := testAutoReplyEngine(cfg)
|
||||
|
||||
idx, err := engine.rebuildKnowledgeIndex()
|
||||
@@ -118,6 +119,56 @@ func TestRebuildKnowledgeIndexScansSubdirectories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildKnowledgeIndexClearsKnowledgeLastErrorOnSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "faq.md"), []byte("AgentBox supports knowledge search."), 0644); err != nil {
|
||||
t.Fatalf("write knowledge file failed: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.NewDefaultAutoReplyConfig()
|
||||
cfg.Knowledge.Directory = dir
|
||||
cfg.Knowledge.IndexPath = filepath.Join(dir, "index.json")
|
||||
cfg.Retrieval.EmbeddingIndexPath = filepath.Join(dir, "embedding_index.json")
|
||||
cfg.Retrieval.RetrievalMode = retrievalModeKeywordOnly
|
||||
engine := testAutoReplyEngine(cfg)
|
||||
engine.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "向量召回失败,已降级关键词检索: test")
|
||||
|
||||
if _, err := engine.rebuildKnowledgeIndex(); err != nil {
|
||||
t.Fatalf("rebuildKnowledgeIndex failed: %v", err)
|
||||
}
|
||||
if engine.status.LastError != "" || engine.status.LastErrorScope != "" {
|
||||
t.Fatalf("expected knowledge error cleared, got scope=%q error=%q", engine.status.LastErrorScope, engine.status.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildKnowledgeIndexKeepsKnowledgeLastErrorWhenEmbeddingFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "faq.md"), []byte("AgentBox supports knowledge search."), 0644); err != nil {
|
||||
t.Fatalf("write knowledge file failed: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.NewDefaultAutoReplyConfig()
|
||||
cfg.Knowledge.Directory = dir
|
||||
cfg.Knowledge.IndexPath = filepath.Join(dir, "index.json")
|
||||
cfg.Retrieval.EmbeddingIndexPath = filepath.Join(dir, "embedding_index.json")
|
||||
cfg.Retrieval.RetrievalMode = retrievalModeHybridRerank
|
||||
cfg.AI.BaseURL = ""
|
||||
cfg.AI.APIKey = ""
|
||||
engine := testAutoReplyEngine(cfg)
|
||||
engine.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "向量召回失败,已降级关键词检索: test")
|
||||
|
||||
idx, err := engine.rebuildKnowledgeIndex()
|
||||
if err == nil {
|
||||
t.Fatal("expected embedding rebuild failure")
|
||||
}
|
||||
if idx == nil || idx.FileCount != 1 {
|
||||
t.Fatalf("expected scanned index returned with one file, got %#v", idx)
|
||||
}
|
||||
if engine.status.LastErrorScope != autoReplyErrorScopeKnowledge || engine.status.LastError == "" {
|
||||
t.Fatalf("expected knowledge error retained, got scope=%q error=%q", engine.status.LastErrorScope, engine.status.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePDFKnowledgeFileExtractsTextLayer(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "text.pdf")
|
||||
writeMinimalTextPDF(t, path, "AgentBox PDF content 123")
|
||||
|
||||
@@ -131,7 +131,7 @@ func generateMaterialCaptionByChat(aiCfg config.AIConfig, provider string, userP
|
||||
|
||||
func materialCaptionSystemPrompt() string {
|
||||
return "你是企业微信里的真人客服,现在要把一份资料顺手发给客户。请写一句自然口语的开场白," +
|
||||
"要求:①只有一句话,不超过40字;②像微信里随手发东西时说的话,亲切自然,不要书面腔和客服模板腔" +
|
||||
"要求:①只有一句话,不超过40字;②使用“您”,像微信里顺手发东西时说的话,亲切自然,不要书面腔和客服模板腔" +
|
||||
"(不要用“您好”“为您提供”“请查收”这类);③结合资料内容点出这是什么、对客户有什么用;" +
|
||||
"④不要编造资料里没有的信息;⑤只输出这句话本身,不要加引号、解释或多余标点。"
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const autoReplyMaterialRepeatWindow = 30 * time.Minute
|
||||
|
||||
type AutoReplyMaterial struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -56,9 +59,6 @@ func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string,
|
||||
if !cfg.Materials.AutoSendEnabled {
|
||||
return nil
|
||||
}
|
||||
if isBroadAllMaterialRequest(userQuery) {
|
||||
return nil
|
||||
}
|
||||
materials, err := loadAutoReplyMaterials(cfg.Materials.IndexPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
@@ -74,7 +74,23 @@ func (e *AutoReplyEngine) matchMaterials(userQuery string, searchContext string,
|
||||
|
||||
requestedTypes := requestedMaterialTypes(userQuery)
|
||||
hasSendIntent := hasMaterialSendIntent(userQuery)
|
||||
if hasSendIntent && isGenericMaterialRequest(userQuery) && !materialQueryHasSpecificSignal(userQuery, materials) && strings.TrimSpace(searchContext) == strings.TrimSpace(userQuery) {
|
||||
explicitAll := isBroadAllMaterialRequest(userQuery)
|
||||
hasSpecificSignal := materialQueryHasSpecificSignal(userQuery, materials)
|
||||
if !hasSendIntent && !explicitAll {
|
||||
return nil
|
||||
}
|
||||
if explicitAll {
|
||||
if hasSpecificSignal {
|
||||
queryText := buildMaterialSearchText(userQuery, "", nil, false)
|
||||
return e.collectMaterialMatches(materials, cfg.Materials.Directory, requestedTypesForExplicitAll(userQuery, requestedTypes), queryText, true)
|
||||
}
|
||||
filteredTypes := requestedTypesForExplicitAll(userQuery, requestedTypes)
|
||||
if len(filteredTypes) > 0 {
|
||||
return e.collectMaterialMatchesByType(materials, cfg.Materials.Directory, filteredTypes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hasSendIntent && isGenericMaterialRequest(userQuery) && !hasSpecificSignal && strings.TrimSpace(searchContext) == strings.TrimSpace(userQuery) {
|
||||
return nil
|
||||
}
|
||||
queryText := buildMaterialSearchText(userQuery, "", nil, false)
|
||||
@@ -122,6 +138,86 @@ func (e *AutoReplyEngine) collectMaterialMatches(materials []AutoReplyMaterial,
|
||||
return matches
|
||||
}
|
||||
|
||||
func (e *AutoReplyEngine) collectMaterialMatchesByType(materials []AutoReplyMaterial, root string, requestedTypes map[string]bool) []autoReplyMaterialMatch {
|
||||
if len(requestedTypes) == 0 {
|
||||
return nil
|
||||
}
|
||||
matches := make([]autoReplyMaterialMatch, 0, len(materials))
|
||||
for _, material := range materials {
|
||||
if !requestedTypes[material.MaterialType] {
|
||||
continue
|
||||
}
|
||||
path := resolveAutoReplyMaterialPath(root, material.Path)
|
||||
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: 1})
|
||||
}
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
if matches[i].Material.Priority != matches[j].Material.Priority {
|
||||
return matches[i].Material.Priority > matches[j].Material.Priority
|
||||
}
|
||||
return strings.ToLower(matches[i].Material.Path) < strings.ToLower(matches[j].Material.Path)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func (e *AutoReplyEngine) filterRecentlySentMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch) []autoReplyMaterialMatch {
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
e.mu.Lock()
|
||||
if e.materialSent == nil {
|
||||
e.materialSent = make(map[string]time.Time)
|
||||
}
|
||||
for key, ts := range e.materialSent {
|
||||
if now.Sub(ts) > autoReplyMaterialRepeatWindow {
|
||||
delete(e.materialSent, key)
|
||||
}
|
||||
}
|
||||
filtered := make([]autoReplyMaterialMatch, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
key := materialSentFingerprint(msg, match)
|
||||
if key == "" {
|
||||
filtered = append(filtered, match)
|
||||
continue
|
||||
}
|
||||
if ts, ok := e.materialSent[key]; ok && now.Sub(ts) <= autoReplyMaterialRepeatWindow {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, match)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (e *AutoReplyEngine) rememberSentMaterial(msg autoReplyMessage, match autoReplyMaterialMatch) {
|
||||
key := materialSentFingerprint(msg, match)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
if e.materialSent == nil {
|
||||
e.materialSent = make(map[string]time.Time)
|
||||
}
|
||||
e.materialSent[key] = time.Now()
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func materialSentFingerprint(msg autoReplyMessage, match autoReplyMaterialMatch) string {
|
||||
conversationID := strings.TrimSpace(msg.ConversationID)
|
||||
path := strings.ToLower(filepath.Clean(strings.TrimSpace(match.Path)))
|
||||
if path == "." || path == "" {
|
||||
path = strings.ToLower(filepath.Clean(strings.TrimSpace(match.Material.Path)))
|
||||
}
|
||||
if conversationID == "" || path == "" || path == "." {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{msg.stableRobotID(), conversationID, path}, "|")
|
||||
}
|
||||
|
||||
func limitMaterialMatches(matches []autoReplyMaterialMatch, maxPerReply int) []autoReplyMaterialMatch {
|
||||
limit := maxPerReply
|
||||
if limit <= 0 {
|
||||
@@ -192,6 +288,29 @@ func requestedMaterialTypes(query string) map[string]bool {
|
||||
return result
|
||||
}
|
||||
|
||||
func requestedTypesForExplicitAll(query string, requestedTypes map[string]bool) map[string]bool {
|
||||
if len(requestedTypes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(requestedTypes) == 1 && requestedTypes["file"] && !explicitlyRequestsOnlyFiles(query) {
|
||||
return nil
|
||||
}
|
||||
return requestedTypes
|
||||
}
|
||||
|
||||
func explicitlyRequestsOnlyFiles(query string) bool {
|
||||
text := normalizeGreetingText(query)
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range []string{"文档", "表格", "手册", "说明书", "ppt", "pdf", "doc", "docx", "xls", "xlsx"} {
|
||||
if strings.Contains(text, normalizeGreetingText(token)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsAnyMaterialIntent(text string, keywords []string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
@@ -632,30 +751,37 @@ func inferMaterialType(path string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch, reason string, timings autoReplyTimings) error {
|
||||
func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoReplyMaterialMatch, reason string, timings autoReplyTimings, tutorialText string) 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))
|
||||
if text := strings.TrimSpace(tutorialText); text != "" {
|
||||
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, text); err != nil {
|
||||
return err
|
||||
}
|
||||
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, text)
|
||||
}
|
||||
for _, match := range matches {
|
||||
caption := materialCaptionForSend(match.Material)
|
||||
captionAfter := materialCaptionShouldFollowMaterial(match.Material)
|
||||
if !captionAfter {
|
||||
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, caption); err != nil {
|
||||
return err
|
||||
}
|
||||
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, caption)
|
||||
}
|
||||
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)
|
||||
}
|
||||
e.rememberSentMaterial(msg, match)
|
||||
sent = append(sent, fmt.Sprintf("%s:%s", match.Material.MaterialType, match.Path))
|
||||
if captionAfter {
|
||||
if err := sendAutoReplyText(uint32(msg.ClientID), msg.ConversationID, caption); err != nil {
|
||||
return err
|
||||
}
|
||||
e.rememberAutoSentMessage(uint32(msg.ClientID), msg.ConversationID, caption)
|
||||
}
|
||||
}
|
||||
e.markCooldown(msg)
|
||||
e.incStatus("replied")
|
||||
@@ -689,6 +815,15 @@ func (e *AutoReplyEngine) sendMaterials(msg autoReplyMessage, matches []autoRepl
|
||||
return nil
|
||||
}
|
||||
|
||||
func materialCaptionShouldFollowMaterial(material AutoReplyMaterial) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(material.MaterialType)) {
|
||||
case "image", "video", "gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func materialCaptionForSend(material AutoReplyMaterial) string {
|
||||
if caption := customMaterialCaptionForSend(material); caption != "" {
|
||||
return caption
|
||||
@@ -696,6 +831,45 @@ func materialCaptionForSend(material AutoReplyMaterial) string {
|
||||
return defaultMaterialCaption(material.MaterialType)
|
||||
}
|
||||
|
||||
func materialTutorialTextFromHits(hits []KnowledgeChunk) string {
|
||||
items := make([]string, 0, 3)
|
||||
for _, hit := range hits {
|
||||
content := strings.TrimSpace(hit.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
content = compactMaterialTutorialContent(content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, content)
|
||||
if len(items) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "我也把相关排查说明整理给您:\n" + strings.Join(items, "\n")
|
||||
}
|
||||
|
||||
func compactMaterialTutorialContent(content string) string {
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
result := make([]string, 0, 4)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(strings.Trim(line, "#>*- \t"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, line)
|
||||
if len(result) >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
text := strings.Join(result, "\n")
|
||||
return truncateText(text, 280)
|
||||
}
|
||||
|
||||
func customMaterialCaptionForSend(material AutoReplyMaterial) string {
|
||||
caption := strings.TrimSpace(material.Caption)
|
||||
if caption != "" && !isLegacyGenericMaterialCaption(caption) {
|
||||
@@ -718,19 +892,19 @@ func isLegacyGenericMaterialCaption(caption string) bool {
|
||||
func defaultMaterialCaption(materialType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(materialType)) {
|
||||
case "image":
|
||||
return "我把图片发你。"
|
||||
return "我把图片发给您。"
|
||||
case "video":
|
||||
return "我把视频发你。"
|
||||
return "我把视频发给您。"
|
||||
case "gif":
|
||||
return "我把动图发你。"
|
||||
return "我把动图发给您。"
|
||||
default:
|
||||
return "我把文件发你。"
|
||||
return "我把文件发给您。"
|
||||
}
|
||||
}
|
||||
|
||||
func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
|
||||
if len(matches) == 0 {
|
||||
return "我把文件发你。"
|
||||
return "我把文件发给您。"
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
labels := make([]string, 0, 4)
|
||||
@@ -755,7 +929,7 @@ func combinedMaterialCaption(matches []autoReplyMaterialMatch) string {
|
||||
if len(labels) == 1 {
|
||||
return defaultMaterialCaption(matches[0].Material.MaterialType)
|
||||
}
|
||||
return "我把" + strings.Join(labels, "和") + "发你。"
|
||||
return "我把" + strings.Join(labels, "和") + "发给您。"
|
||||
}
|
||||
|
||||
func uniqueMaterialStrings(items []string) []string {
|
||||
@@ -805,6 +979,13 @@ func sendAutoReplyMaterialRequest(clientID uint32, conversationID string, materi
|
||||
"file": path,
|
||||
},
|
||||
}
|
||||
if messageType == 11031 {
|
||||
if fileName := strings.TrimSpace(filepath.Base(path)); fileName != "" && fileName != "." {
|
||||
data := request["data"].(map[string]interface{})
|
||||
data["fileName"] = fileName
|
||||
data["file_name"] = fileName
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -121,6 +121,7 @@ type AutoReplyEngine struct {
|
||||
humanPending map[string]*humanAssistPending
|
||||
collaborations map[string]*collaborationSession
|
||||
autoSent map[string]time.Time
|
||||
materialSent map[string]time.Time
|
||||
records []AutoReplyRecord
|
||||
nextRecordID int64
|
||||
status AutoReplyStatus
|
||||
@@ -177,6 +178,7 @@ func initAutoReplyEngine() {
|
||||
humanPending: make(map[string]*humanAssistPending),
|
||||
collaborations: make(map[string]*collaborationSession),
|
||||
autoSent: make(map[string]time.Time),
|
||||
materialSent: make(map[string]time.Time),
|
||||
status: AutoReplyStatus{
|
||||
Enabled: cfg.Enabled,
|
||||
Running: cfg.Enabled,
|
||||
@@ -318,6 +320,19 @@ func (e *AutoReplyEngine) setLastErrorWithScope(scope string, msg string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AutoReplyEngine) clearLastErrorScope(scope string) {
|
||||
scope = normalizeAutoReplyErrorScope(scope)
|
||||
if scope == "" {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
if e.status.LastErrorScope == scope {
|
||||
e.status.LastError = ""
|
||||
e.status.LastErrorScope = ""
|
||||
}
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func normalizeAutoReplyErrorScope(scope string) string {
|
||||
switch strings.TrimSpace(scope) {
|
||||
case autoReplyErrorScopeListen:
|
||||
|
||||
@@ -31,6 +31,8 @@ func testAutoReplyEngine(cfg config.AutoReplyConfig) *AutoReplyEngine {
|
||||
identityGroups: make(map[int32]map[string]autoReplyGroupOption),
|
||||
contextEntries: make(map[string][]autoReplyContextEntry),
|
||||
collaborations: make(map[string]*collaborationSession),
|
||||
autoSent: make(map[string]time.Time),
|
||||
materialSent: make(map[string]time.Time),
|
||||
status: AutoReplyStatus{
|
||||
ReasonCounts: make(map[string]int),
|
||||
},
|
||||
@@ -313,7 +315,7 @@ func TestSendMaterialsRoutesByMessageClientID(t *testing.T) {
|
||||
Score: 10,
|
||||
}}
|
||||
|
||||
if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}); err != nil {
|
||||
if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}, ""); err != nil {
|
||||
t.Fatalf("sendMaterials failed: %v", err)
|
||||
}
|
||||
if textClient != 42 || textConversation != msg.ConversationID || textContent != "安装视频发你。" {
|
||||
@@ -334,11 +336,11 @@ func TestMaterialDefaultCaptionsByType(t *testing.T) {
|
||||
caption string
|
||||
want string
|
||||
}{
|
||||
{name: "image", materialType: "image", want: "我把图片发你。"},
|
||||
{name: "video", materialType: "video", want: "我把视频发你。"},
|
||||
{name: "gif", materialType: "gif", want: "我把动图发你。"},
|
||||
{name: "file", materialType: "file", want: "我把文件发你。"},
|
||||
{name: "legacy generic", materialType: "image", caption: "我把相关资料直接发你。", want: "我把图片发你。"},
|
||||
{name: "image", materialType: "image", want: "我把图片发给您。"},
|
||||
{name: "video", materialType: "video", want: "我把视频发给您。"},
|
||||
{name: "gif", materialType: "gif", want: "我把动图发给您。"},
|
||||
{name: "file", materialType: "file", want: "我把文件发给您。"},
|
||||
{name: "legacy generic", materialType: "image", caption: "我把相关资料直接发你。", want: "我把图片发给您。"},
|
||||
{name: "custom", materialType: "video", caption: "安装视频发你。", want: "安装视频发你。"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -360,7 +362,7 @@ func TestCombinedMaterialCaptionMergesTypes(t *testing.T) {
|
||||
{Material: AutoReplyMaterial{MaterialType: "image"}},
|
||||
{Material: AutoReplyMaterial{MaterialType: "video"}},
|
||||
}
|
||||
if got := combinedMaterialCaption(matches); got != "我把图片和视频发你。" {
|
||||
if got := combinedMaterialCaption(matches); got != "我把图片和视频发给您。" {
|
||||
t.Fatalf("expected merged type caption, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -393,10 +395,10 @@ func TestSendMaterialsUsesTypedDefaultCaption(t *testing.T) {
|
||||
Score: 10,
|
||||
}}
|
||||
|
||||
if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}); err != nil {
|
||||
if err := engine.sendMaterials(msg, matches, "material_sent", autoReplyTimings{}, ""); err != nil {
|
||||
t.Fatalf("sendMaterials failed: %v", err)
|
||||
}
|
||||
if sentText != "我把图片发你。" {
|
||||
if sentText != "我把图片发给您。" {
|
||||
t.Fatalf("expected typed image caption, got %q", sentText)
|
||||
}
|
||||
}
|
||||
@@ -433,6 +435,45 @@ func TestDiscoverAutoReplyMaterialsScansDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderMaterialRequestLimitsUnlessExplicitAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
folder := filepath.Join(dir, "0505尾部切刀")
|
||||
if err := os.MkdirAll(folder, 0755); err != nil {
|
||||
t.Fatalf("make folder: %v", err)
|
||||
}
|
||||
for _, name := range []string{"气压检查.jpg", "拉膜皮带.mp4", "刀槽清洁.pdf"} {
|
||||
if err := os.WriteFile(filepath.Join(folder, name), []byte("material"), 0644); err != nil {
|
||||
t.Fatalf("write material %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := config.NewDefaultAutoReplyConfig()
|
||||
cfg.Materials.Directory = dir
|
||||
cfg.Materials.IndexPath = filepath.Join(dir, "missing-materials.json")
|
||||
cfg.Materials.MaxPerReply = 1
|
||||
engine := testAutoReplyEngine(cfg)
|
||||
|
||||
matches := engine.matchMaterials("0505尾部切刀切不断怎么排查", "0505尾部切刀切不断怎么排查", nil)
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("expected normal troubleshooting request to diagnose first instead of sending materials, got %#v", matches)
|
||||
}
|
||||
|
||||
matches = engine.matchMaterials("把0505尾部切刀排查视频发我", "把0505尾部切刀排查视频发我", nil)
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("expected explicit troubleshooting material request to respect maxPerReply, got %#v", matches)
|
||||
}
|
||||
|
||||
allMatches := engine.matchMaterials("0505尾部切刀的资料全部发我", "0505尾部切刀的资料全部发我", nil)
|
||||
if len(allMatches) != 3 {
|
||||
t.Fatalf("expected explicit all request for matched folder to send all folder files, got %#v", allMatches)
|
||||
}
|
||||
|
||||
genericAll := engine.matchMaterials("全部资料都发我", "全部资料都发我", nil)
|
||||
if len(genericAll) != 0 {
|
||||
t.Fatalf("generic all-material request should still require clarification, got %#v", genericAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAutoReplyMaterialsAddsRemovesAndKeepsExistingConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "kept.pdf"), []byte("pdf"), 0644); err != nil {
|
||||
@@ -566,6 +607,64 @@ func TestMaterialTypeIntentFiltersMatches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterSalesAlarmMaterialRequiresExplicitSendIntent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "立式机编码器报警3排查.mp4"), []byte("video"), 0644); err != nil {
|
||||
t.Fatalf("write alarm3 material: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "立式机编码器报警5排查.mp4"), []byte("video"), 0644); err != nil {
|
||||
t.Fatalf("write alarm5 material: %v", err)
|
||||
}
|
||||
indexPath := filepath.Join(dir, "materials.json")
|
||||
materials := autoReplyMaterialsFile{Materials: []AutoReplyMaterial{
|
||||
{
|
||||
ID: "alarm3-video",
|
||||
Title: "立式机编码器报警3排查",
|
||||
Keywords: []string{"立式机编码器报警3", "报警3排查"},
|
||||
MaterialType: "video",
|
||||
Path: "立式机编码器报警3排查.mp4",
|
||||
Caption: "报警3排查视频",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "alarm5-video",
|
||||
Title: "立式机编码器报警5排查",
|
||||
Keywords: []string{"立式机编码器报警5", "报警5排查"},
|
||||
MaterialType: "video",
|
||||
Path: "立式机编码器报警5排查.mp4",
|
||||
Caption: "报警5排查视频",
|
||||
Enabled: true,
|
||||
},
|
||||
}}
|
||||
data, err := json.Marshal(materials)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal materials: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(indexPath, data, 0644); err != nil {
|
||||
t.Fatalf("write materials index: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.NewDefaultAutoReplyConfig()
|
||||
cfg.Materials.Directory = dir
|
||||
cfg.Materials.IndexPath = indexPath
|
||||
cfg.Materials.MaxPerReply = 2
|
||||
engine := testAutoReplyEngine(cfg)
|
||||
|
||||
if matches := engine.matchMaterials("立式机编码器报警", "立式机编码器报警\n视频封面识别:控制屏显示报警", nil); len(matches) != 0 {
|
||||
t.Fatalf("expected alarm diagnosis without send intent not to send materials, got %#v", matches)
|
||||
}
|
||||
|
||||
matches := engine.matchMaterials("把立式机编码器报警3排查视频发我", "把立式机编码器报警3排查视频发我", nil)
|
||||
if len(matches) != 1 || matches[0].Material.ID != "alarm3-video" {
|
||||
t.Fatalf("expected only alarm3 video, got %#v", matches)
|
||||
}
|
||||
|
||||
matches = engine.matchMaterials("把立式机编码器报警5排查视频发我", "把立式机编码器报警5排查视频发我", nil)
|
||||
if len(matches) != 1 || matches[0].Material.ID != "alarm5-video" {
|
||||
t.Fatalf("expected only alarm5 video, got %#v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialSendIntentUsesContextWithoutFullFilename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fileName := "企业级AI数字员工解决方案宣传手册(无标价版)(1).pptx"
|
||||
@@ -1818,13 +1917,13 @@ func TestQuestionReferencesContext(t *testing.T) {
|
||||
question string
|
||||
want bool
|
||||
}{
|
||||
{"它多少钱", true}, // 它多少钱
|
||||
{"这个怎么用", true}, // 这个怎么用
|
||||
{"刚才那个再说说", true}, // 刚才那个再说说
|
||||
{"继续", true}, // 继续
|
||||
{"今天星期几", false}, // 今天星期几
|
||||
{"它多少钱", true}, // 它多少钱
|
||||
{"这个怎么用", true}, // 这个怎么用
|
||||
{"刚才那个再说说", true}, // 刚才那个再说说
|
||||
{"继续", true}, // 继续
|
||||
{"今天星期几", false}, // 今天星期几
|
||||
{"你们有什么产品", false}, // 你们有什么产品
|
||||
{"换个话题吧", false}, // 换个话题吧
|
||||
{"换个话题吧", false}, // 换个话题吧
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := questionReferencesContext(c.question); got != c.want {
|
||||
@@ -1838,11 +1937,11 @@ func TestIsPureTopicSwitchMessage(t *testing.T) {
|
||||
content string
|
||||
want bool
|
||||
}{
|
||||
{"换个话题吧", true}, // 换个话题吧
|
||||
{"我们聊点别的", true}, // 我们聊点别的
|
||||
{"不聊这个了", true}, // 不聊这个了
|
||||
{"换个话题吧", true}, // 换个话题吧
|
||||
{"我们聊点别的", true}, // 我们聊点别的
|
||||
{"不聊这个了", true}, // 不聊这个了
|
||||
{"换个话题,你们产品多少钱", false}, // 换个话题,你们产品多少钱(带了新问题)
|
||||
{"今天星期几", false}, // 今天星期几
|
||||
{"今天星期几", false}, // 今天星期几
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isPureTopicSwitchMessage(c.content); got != c.want {
|
||||
@@ -2685,6 +2784,47 @@ func TestFastAutoReplyDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyExternalOnlyIgnoresInternalMessages(t *testing.T) {
|
||||
cfg := config.NewDefaultAutoReplyConfig()
|
||||
cfg.Enabled = true
|
||||
cfg.Identity.ReplyExternalOnly = true
|
||||
cfg.Identity.InternalUserIDs = []string{"internal-user"}
|
||||
engine := testAutoReplyEngine(cfg)
|
||||
|
||||
sentTexts := 0
|
||||
restoreSenders := stubAutoReplySenders(nil, func(clientID uint32, conversationID string, content string) error {
|
||||
sentTexts++
|
||||
return nil
|
||||
})
|
||||
defer restoreSenders()
|
||||
|
||||
engine.processJob(AutoReplyJob{
|
||||
ClientID: 7,
|
||||
RawData: map[string]interface{}{
|
||||
"type": 11041,
|
||||
"data": map[string]interface{}{
|
||||
"conversation_id": "S:robot-user_internal-user",
|
||||
"sender": "internal-user",
|
||||
"receiver": "robot-user",
|
||||
"sender_name": "Internal",
|
||||
"content": "设备怎么调试",
|
||||
"server_id": "server-internal-external-only",
|
||||
},
|
||||
},
|
||||
ReceivedAt: time.Unix(1779434669, 0),
|
||||
})
|
||||
|
||||
if sentTexts != 0 {
|
||||
t.Fatalf("expected internal message to be ignored without reply, sentTexts=%d", sentTexts)
|
||||
}
|
||||
if engine.status.TodayReplied != 0 || engine.status.TodayHandoff != 0 || engine.status.TodayIgnored != 1 {
|
||||
t.Fatalf("expected ignored-only status, replied=%d handoff=%d ignored=%d", engine.status.TodayReplied, engine.status.TodayHandoff, engine.status.TodayIgnored)
|
||||
}
|
||||
if len(engine.records) != 1 || engine.records[0].Reason != "internal_ignored_external_only" {
|
||||
t.Fatalf("expected internal_ignored_external_only record, got %#v", engine.records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDefaultsAddsIdentityLabelsAndCustomerNotice(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
AutoReplyConfig: config.AutoReplyConfig{
|
||||
@@ -2830,7 +2970,7 @@ func TestKnowledgeScopedLowScoreDoesNotUseGeneralReply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialMatchShortCircuitsAIReply(t *testing.T) {
|
||||
func TestExplicitMaterialRequestShortCircuitsAIReply(t *testing.T) {
|
||||
restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-user"})
|
||||
defer restoreClients()
|
||||
|
||||
@@ -2906,15 +3046,18 @@ func TestMaterialMatchShortCircuitsAIReply(t *testing.T) {
|
||||
"sender": "customer-user",
|
||||
"receiver": "robot-user",
|
||||
"sender_name": "Customer",
|
||||
"content": "show cat",
|
||||
"content": "请发 cat 图片",
|
||||
"server_id": "server-material-short-circuit",
|
||||
},
|
||||
},
|
||||
ReceivedAt: time.Now(),
|
||||
})
|
||||
|
||||
if len(sentTexts) != 1 || sentTexts[0] != "cat image sent" {
|
||||
t.Fatalf("expected only material caption text, got %#v", sentTexts)
|
||||
if len(sentTexts) != 2 ||
|
||||
!strings.Contains(sentTexts[0], "我也把相关排查说明整理给您") ||
|
||||
!strings.Contains(sentTexts[0], "cat product knowledge") ||
|
||||
sentTexts[1] != "cat image sent" {
|
||||
t.Fatalf("expected tutorial text followed by material caption, got %#v", sentTexts)
|
||||
}
|
||||
if len(sentMaterials) != 1 || sentMaterials[0] != "image:"+materialPath {
|
||||
t.Fatalf("expected one material send, got %#v", sentMaterials)
|
||||
@@ -2924,6 +3067,112 @@ func TestMaterialMatchShortCircuitsAIReply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialSendDeduplicatesSameConversation(t *testing.T) {
|
||||
restoreClients := setTestIdentifiedClients(t, map[uint32]string{7: "robot-user"})
|
||||
defer restoreClients()
|
||||
|
||||
dir := t.TempDir()
|
||||
materialPath := filepath.Join(dir, "alarm3.mp4")
|
||||
if err := os.WriteFile(materialPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatalf("write material: %v", err)
|
||||
}
|
||||
indexPath := filepath.Join(dir, "materials.json")
|
||||
materials := autoReplyMaterialsFile{Materials: []AutoReplyMaterial{{
|
||||
ID: "alarm3-video",
|
||||
Title: "立式机编码器报警3排查视频",
|
||||
Keywords: []string{"立式机编码器报警3"},
|
||||
MaterialType: "video",
|
||||
Path: "alarm3.mp4",
|
||||
Caption: "报警3排查视频",
|
||||
Enabled: true,
|
||||
}}}
|
||||
data, err := json.Marshal(materials)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal materials: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(indexPath, data, 0644); err != nil {
|
||||
t.Fatalf("write materials index: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.NewDefaultAutoReplyConfig()
|
||||
cfg.Enabled = true
|
||||
cfg.Materials.Directory = dir
|
||||
cfg.Materials.IndexPath = indexPath
|
||||
cfg.Retrieval.RetrievalMode = retrievalModeKeywordOnly
|
||||
cfg.Knowledge.MinScore = 0.1
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("unexpected AI path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"先检查编码器信号线和报警编号。"}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg.AI.BaseURL = server.URL + "/v1"
|
||||
cfg.AI.Model = "qwen-turbo"
|
||||
engine := testAutoReplyEngine(cfg)
|
||||
engine.index = &KnowledgeIndex{Chunks: []KnowledgeChunk{{
|
||||
ID: "knowledge-alarm3",
|
||||
Source: "alarm3.md",
|
||||
Title: "立式机编码器报警3",
|
||||
Content: "请先检查编码器信号线、端子和控制屏报警编号。",
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
Score: 1,
|
||||
}}}
|
||||
|
||||
var sentTexts []string
|
||||
var sentMaterials []string
|
||||
oldTextSender := sendAutoReplyTextSender
|
||||
oldMaterialSender := sendAutoReplyMaterialSender
|
||||
oldLookupRequester := sendIdentityLookupRequester
|
||||
sendAutoReplyTextSender = func(clientID uint32, conversationID string, content string) error {
|
||||
sentTexts = append(sentTexts, content)
|
||||
return nil
|
||||
}
|
||||
sendAutoReplyMaterialSender = func(clientID uint32, conversationID string, typ string, path string) error {
|
||||
sentMaterials = append(sentMaterials, typ+":"+path)
|
||||
return nil
|
||||
}
|
||||
sendIdentityLookupRequester = func(clientID uint32, requestType int, query string) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sendAutoReplyTextSender = oldTextSender
|
||||
sendAutoReplyMaterialSender = oldMaterialSender
|
||||
sendIdentityLookupRequester = oldLookupRequester
|
||||
})
|
||||
|
||||
for i, serverID := range []string{"server-material-1", "server-material-2"} {
|
||||
engine.processJob(AutoReplyJob{
|
||||
ClientID: 7,
|
||||
ForceNoCooldown: i > 0,
|
||||
RawData: map[string]interface{}{
|
||||
"type": 11041,
|
||||
"data": map[string]interface{}{
|
||||
"conversation_id": "S:robot-user_customer-user",
|
||||
"sender": "customer-user",
|
||||
"receiver": "robot-user",
|
||||
"sender_name": "Customer",
|
||||
"content": "把立式机编码器报警3排查视频发我",
|
||||
"server_id": serverID,
|
||||
},
|
||||
},
|
||||
ReceivedAt: time.Now().Add(time.Duration(i) * time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
if len(sentMaterials) != 1 || sentMaterials[0] != "video:"+materialPath {
|
||||
t.Fatalf("expected one material send after duplicate request, got %#v", sentMaterials)
|
||||
}
|
||||
reasons := map[string]int{}
|
||||
for _, record := range engine.records {
|
||||
reasons[record.Reason]++
|
||||
}
|
||||
if len(engine.records) != 2 || reasons["materials_replied"] != 1 || reasons["ok"] != 1 {
|
||||
t.Fatalf("expected second duplicate to fall through to AI reply, got records=%#v texts=%#v", engine.records, sentTexts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingKnowledgeSearchFindsSalesButNotResearch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeTestMeetingWorkbookAt(t, dir)
|
||||
|
||||
Reference in New Issue
Block a user