feat: update auto reply and packaging

This commit is contained in:
ly1213
2026-06-29 17:44:22 +08:00
parent 1ca66dc0af
commit 2d5ee7f08d
19 changed files with 1147 additions and 227 deletions

View File

@@ -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)