package chat_test import ( "context" "fmt" "strings" "testing" "time" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/chat" "chat-agent-system/internal/config" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/squad" ) func TestChatSession_EndToEndDialog(t *testing.T) { rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) userCtx := &coordinator.UserProjectContext{ SnapshotID: "sess_ctx_1", User: coordinator.UserInfo{ ID: "u1", Name: "张工", Role: "engineer", ProjectRole: "composer", }, Project: coordinator.ProjectInfo{ Name: "掌上无线超声系统", CurrentStage: bizdev.StageV01_Triage, }, LatestAdvisorBrief: &squad.AdvisorBriefing{ MaturityScore: 75, MaturityLevel: "V0.1", MaturityHeadline: "初步框架已就绪", PreemptiveRisks: []string{"探头功耗与散热设计"}, NextBestActions: []string{"补充客户供电参数", "推进到V0.5可行性论证"}, CommunicationKit: "建议与客户确认供电方式", }, } session := chat.NewSession("sess_001", userCtx, rec, engine) // Turn 1: 问候 (分支 2: 新指令/对话) resp1, err := session.SendMessage(context.Background(), "你好,请问你是谁?") if err != nil { t.Fatalf("turn 1 failed: %v", err) } if resp1.IntentResult.TopIntent.Intent != intent.IntentCasualChat { t.Errorf("turn 1 expected CASUAL_CHAT, got %s", resp1.IntentResult.TopIntent.Intent) } // Turn 2: 查进展与风险 (分支 2: 抢占查询) resp2, err := session.SendMessage(context.Background(), "现在这个项目进展怎么样?有啥风险没?") if err != nil { t.Fatalf("turn 2 failed: %v", err) } if resp2.IntentResult.TopIntent.Intent != intent.IntentQueryProgress { t.Errorf("turn 2 expected QUERY_PROGRESS, got %s", resp2.IntentResult.TopIntent.Intent) } if !strings.Contains(resp2.ReplyText, "掌上无线超声系统") { t.Errorf("turn 2 reply should mention project name: %s", resp2.ReplyText) } // Turn 3: 补充信息 (分支 2: 补充事实) resp3, err := session.SendMessage(context.Background(), "客户补充说目标市场是国内及欧洲CE") if err != nil { t.Fatalf("turn 3 failed: %v", err) } if resp3.IntentResult.TopIntent.Intent != intent.IntentSupplementInfo { t.Errorf("turn 3 expected SUPPLEMENT_INFO, got %s", resp3.IntentResult.TopIntent.Intent) } if session.UserCtx.BlackboardFacts["target_market"] != "国内及欧洲CE" { t.Errorf("target_market not updated: %v", session.UserCtx.BlackboardFacts) } // Turn 4: 单字快速肯定兑现上轮推荐 (分支 1: 极速兑现) resp4, err := session.SendMessage(context.Background(), "好") if err != nil { t.Fatalf("turn 4 failed: %v", err) } if resp4.Branch != intent.BranchExpectationHit { t.Errorf("turn 4 expected branch EXPECTATION_HIT, got %s", resp4.Branch) } if resp4.IntentResult.TopIntent.Intent != intent.IntentAdvanceStage { t.Errorf("turn 4 expected ADVANCE_STAGE, got %s", resp4.IntentResult.TopIntent.Intent) } if session.UserCtx.Project.CurrentStage != bizdev.StageV05_Feasibility { t.Errorf("expected project advanced to V0.5, got %s", session.UserCtx.Project.CurrentStage) } if len(session.Messages) != 8 { t.Errorf("expected 8 messages (4 user + 4 asst), got %d", len(session.Messages)) } } // 用例 1: 分支 1 极速兑现 ("好" / "1") func TestChatSession_Branch1_FastAffirmative(t *testing.T) { rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) userCtx := &coordinator.UserProjectContext{ Project: coordinator.ProjectInfo{ Name: "心电贴片系统", CurrentStage: bizdev.StageV01_Triage, }, } session := chat.NewSession("sess_fast", userCtx, rec, engine) resp, err := session.SendMessage(context.Background(), "好") if err != nil { t.Fatalf("send message failed: %v", err) } if resp.Branch != intent.BranchExpectationHit { t.Errorf("expected EXPECTATION_HIT, got %s", resp.Branch) } if resp.IntentResult.TopIntent.Intent != intent.IntentAdvanceStage { t.Errorf("expected ADVANCE_STAGE, got %s", resp.IntentResult.TopIntent.Intent) } if session.UserCtx.Project.CurrentStage != bizdev.StageV05_Feasibility { t.Errorf("expected stage V0.5, got %s", session.UserCtx.Project.CurrentStage) } } // 用例 2: 分支 1 复合打补丁 ("好,但目标市场改成欧洲CE") func TestChatSession_Branch1_AffirmativeWithPatch(t *testing.T) { rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) userCtx := &coordinator.UserProjectContext{ Project: coordinator.ProjectInfo{ Name: "便携超声设备", CurrentStage: bizdev.StageV01_Triage, }, BlackboardFacts: make(map[string]string), } session := chat.NewSession("sess_patch", userCtx, rec, engine) resp, err := session.SendMessage(context.Background(), "好,但目标市场改成欧洲CE") if err != nil { t.Fatalf("send message failed: %v", err) } if resp.Branch != intent.BranchExpectationHit { t.Errorf("expected EXPECTATION_HIT, got %s", resp.Branch) } if resp.IntentResult.TopIntent.Intent != intent.IntentAdvanceStage { t.Errorf("expected ADVANCE_STAGE, got %s", resp.IntentResult.TopIntent.Intent) } if session.UserCtx.BlackboardFacts["target_market"] != "欧洲CE" { t.Errorf("expected blackboard target_market '欧洲CE', got '%s'", session.UserCtx.BlackboardFacts["target_market"]) } if session.UserCtx.Project.TargetMarket != "欧洲CE" { t.Errorf("expected project TargetMarket '欧洲CE', got '%s'", session.UserCtx.Project.TargetMarket) } } // 用例 3: 分支 1 否定撤销 ("先别推,等等") func TestChatSession_Branch1_Decline(t *testing.T) { rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) userCtx := &coordinator.UserProjectContext{ Project: coordinator.ProjectInfo{ Name: "骨科手术机器人", CurrentStage: bizdev.StageV01_Triage, }, } session := chat.NewSession("sess_decline", userCtx, rec, engine) resp, err := session.SendMessage(context.Background(), "先别推,等等") if err != nil { t.Fatalf("send message failed: %v", err) } if resp.Branch != intent.BranchExpectationHit { t.Errorf("expected EXPECTATION_HIT, got %s", resp.Branch) } if resp.IntentResult.TopIntent.Intent != intent.IntentDeclineExpectation { t.Errorf("expected DECLINE_EXPECTATION, got %s", resp.IntentResult.TopIntent.Intent) } if session.UserCtx.PendingExpectation != nil { t.Errorf("expected PendingExpectation to be cleared after decline") } if !strings.Contains(resp.ReplyText, "听您的") { t.Errorf("expected polite advisor reply: %s", resp.ReplyText) } } // 用例 4: 分支 2 突发话题抢占 ("查一下探头功耗要怎么设计") func TestChatSession_Branch2_TopicPreemption(t *testing.T) { rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) userCtx := &coordinator.UserProjectContext{ Project: coordinator.ProjectInfo{ Name: "无线超声系统", CurrentStage: bizdev.StageV01_Triage, }, } session := chat.NewSession("sess_preempt", userCtx, rec, engine) resp, err := session.SendMessage(context.Background(), "查一下探头功耗要怎么设计") if err != nil { t.Fatalf("send message failed: %v", err) } if resp.Branch != intent.BranchNewIntentPreempt { t.Errorf("expected NEW_INTENT_PREEMPT, got %s", resp.Branch) } if resp.IntentResult.TopIntent.Intent != intent.IntentQueryProgress { t.Errorf("expected QUERY_PROGRESS, got %s", resp.IntentResult.TopIntent.Intent) } if !strings.Contains(resp.ReplyText, "功耗") && !strings.Contains(resp.ReplyText, "架构") { t.Errorf("expected technical answer in reply: %s", resp.ReplyText) } } // 用例 5: 分支 3 模糊分忧 ("这个先放放,你觉得呢") func TestChatSession_Branch3_AmbiguousConsult(t *testing.T) { rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) userCtx := &coordinator.UserProjectContext{ Project: coordinator.ProjectInfo{ Name: "智能输注泵项目", CurrentStage: bizdev.StageV01_Triage, }, } session := chat.NewSession("sess_consult", userCtx, rec, engine) resp, err := session.SendMessage(context.Background(), "这个先放放,你觉得呢") if err != nil { t.Fatalf("send message failed: %v", err) } if resp.Branch != intent.BranchAmbiguousFallback { t.Errorf("expected AMBIGUOUS_FALLBACK, got %s", resp.Branch) } if !strings.Contains(resp.ReplyText, "完全理解") && !strings.Contains(resp.ReplyText, "建议") { t.Errorf("expected thoughtful advisor consult in reply: %s", resp.ReplyText) } if session.UserCtx.PendingExpectation == nil { t.Errorf("expected NextExpectation to be populated for user choice") } } // 用例 6: 贴身参谋决策锦囊中自推荐往期标杆案例 func TestChatSession_AdvisorCaseRecommendation(t *testing.T) { cfg := &config.Config{DataDir: t.TempDir()} svc := bizdev.MustNewService(t, cfg) ctx := context.Background() // 1. 预置一个已终审批准的往期标杆案例 doc := &docgov.DocumentRecord{ DocID: "doc_history_ultrasonic_01", ProjectID: 201, TaskKey: "feasibility_reg", Title: "超声软组织切割止血手术设备注册评估", Stage: "V0.5_feasibility", CurrentState: docgov.StateApproved, IsLocked: true, AuthorName: "李工", } body := `# 超声软组织切割止血手术设备注册评估 ## 概述与器械分类 主要用于外科微创手术中软组织切割与止血闭合,按三类医疗器械申报。 ## 关键性能与型检预估 重点满足 GB 9706.1-2020 及 YY 0644 专用安全规范,型检周期约 5 个月。` _, err := bizdev.Snapshot(svc).Ctx.IndexApprovedDocument(ctx, doc, body) if err != nil { t.Fatalf("IndexApprovedDocument failed: %v", err) } // 2. 发起新会话咨询 rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(svc) engine.SetContextServices(bizdev.Snapshot(svc).Ctx, bizdev.Snapshot(svc).KB) userCtx := &coordinator.UserProjectContext{ Project: coordinator.ProjectInfo{ Name: "超声软组织刀研发咨询", DeviceType: "超声手术设备", IntendedUse: "软组织切割闭合", CurrentStage: bizdev.StageV01_Triage, }, LatestAdvisorBrief: &squad.AdvisorBriefing{ MaturityScore: 60, MaturityLevel: "V0.1", MaturityHeadline: "需求初步梳理中", }, } session := chat.NewSession("sess_case_recommend", userCtx, rec, engine) session.CtxMgr = bizdev.Snapshot(svc).Ctx resp, err := session.SendMessage(ctx, "我们之前做过类似的超声手术设备吗?注册和型检要注意什么?") if err != nil { t.Fatalf("SendMessage failed: %v", err) } if resp.AdvisorBrief == nil { t.Fatalf("expected AdvisorBrief in response") } if len(resp.AdvisorBrief.RecommendedCases) == 0 { t.Fatalf("expected RecommendedCases to be populated, got 0") } recCase := resp.AdvisorBrief.RecommendedCases[0] t.Logf("✓ 贴身参谋锦囊成功自推荐标杆案例: 文档=%s, 标题=%s, 章节=%s, 相似度=%.2f", recCase.DocID, recCase.ArtifactTitle, recCase.SectionTitle, recCase.SimilarityScore) if recCase.DocID != "doc_history_ultrasonic_01" { t.Errorf("unexpected doc ID: %s", recCase.DocID) } } func TestChatSession_RecentDialogueAndGuidanceSync(t *testing.T) { ctx := context.Background() rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) ctxMgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) userCtx := &coordinator.UserProjectContext{ SnapshotID: "sess_ctx_sync", User: coordinator.UserInfo{ ID: "u_sync_1", Name: "李工", Role: "engineer", ProjectRole: "composer", }, Project: coordinator.ProjectInfo{ Name: "数字化微创内窥镜", CurrentStage: bizdev.StageV01_Triage, }, BlackboardFacts: make(map[string]string), } session := chat.NewSession("sess_sync_001", userCtx, rec, engine) session.UserID = 42 session.ProjectID = 100 session.CtxMgr = ctxMgr // Turn 1: 问候 _, err := session.SendMessage(ctx, "你好,我是内窥镜项目负责人李工") if err != nil { t.Fatalf("turn 1 failed: %v", err) } if len(session.UserCtx.RecentDialogue) != 2 { t.Fatalf("expected RecentDialogue to have 2 turns after turn 1, got %d", len(session.UserCtx.RecentDialogue)) } if session.UserCtx.RecentDialogue[0].Role != "user" || !strings.Contains(session.UserCtx.RecentDialogue[0].Content, "李工") { t.Errorf("unexpected user dialogue turn: %+v", session.UserCtx.RecentDialogue[0]) } if session.UserCtx.RecentDialogue[1].Role != "assistant" { t.Errorf("unexpected assistant dialogue turn: %+v", session.UserCtx.RecentDialogue[1]) } // Turn 2: 用户表达偏好指令 ("以后请...") _, err = session.SendMessage(ctx, "以后请直接说重点并优先关注法规标准合规") if err != nil { t.Fatalf("turn 2 failed: %v", err) } if len(session.UserCtx.RecentDialogue) != 4 { t.Fatalf("expected RecentDialogue to have 4 turns after turn 2, got %d", len(session.UserCtx.RecentDialogue)) } if session.UserCtx.PersonalizedGuidance == "" { t.Errorf("expected PersonalizedGuidance to be populated after custom instruction") } if !strings.Contains(session.UserCtx.PersonalizedGuidance, "Memory Injection") { t.Errorf("expected Memory Injection header in PersonalizedGuidance, got: %s", session.UserCtx.PersonalizedGuidance) } // Turn 3: 连续对话验证 _, err = session.SendMessage(ctx, "现在项目进展到哪一步了?") if err != nil { t.Fatalf("turn 3 failed: %v", err) } if len(session.UserCtx.RecentDialogue) != 6 { t.Fatalf("expected RecentDialogue to have 6 turns after turn 3, got %d", len(session.UserCtx.RecentDialogue)) } if session.UserCtx.PersonalizedGuidance == "" { t.Errorf("PersonalizedGuidance lost on subsequent turn") } } func TestChatSession_SlidingWindowAndSummary(t *testing.T) { ctx := context.Background() rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) ctxMgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) userCtx := &coordinator.UserProjectContext{ SnapshotID: "sess_ctx_window", User: coordinator.UserInfo{ ID: "u_win_1", Name: "赵工", Role: "engineer", ProjectRole: "composer", }, Project: coordinator.ProjectInfo{ Name: "数字化高频超声吸引系统", CurrentStage: bizdev.StageV01_Triage, }, BlackboardFacts: make(map[string]string), } session := chat.NewSession("sess_win_001", userCtx, rec, engine) session.UserID = 99 session.ProjectID = 200 session.CtxMgr = ctxMgr // 模拟连续进行 10 轮交互 (生成 20 条消息,超出默认窗口上限 16 条) // 第 1 轮带有核心事实 _, err := session.SendMessage(ctx, "你好,我们准备立项高频超声手术刀系统,预算200万") if err != nil { t.Fatalf("turn 1 failed: %v", err) } // 模拟追加 9 轮简短问答 for i := 2; i <= 10; i++ { _, err := session.SendMessage(ctx, fmt.Sprintf("这是第 %d 轮日常沟通咨询", i)) if err != nil { t.Fatalf("turn %d failed: %v", i, err) } } if len(session.Messages) != 20 { t.Fatalf("expected 20 total messages, got %d", len(session.Messages)) } // 验证双预算滑动窗口(条数上限 16 且受 Token 预算约束裁剪) if len(session.UserCtx.RecentDialogue) > 16 || len(session.UserCtx.RecentDialogue) < 10 { t.Errorf("expected between 10 and 16 messages in RecentDialogue window under dual budget, got %d", len(session.UserCtx.RecentDialogue)) } // 验证早期被截断的消息是否被压缩为滚动摘要 if session.UserCtx.DialogueSummary == "" { t.Errorf("expected DialogueSummary to be populated after window truncation") } if !strings.Contains(session.UserCtx.DialogueSummary, "【早期对话摘要】") { t.Errorf("expected summary header, got: %s", session.UserCtx.DialogueSummary) } if !strings.Contains(session.UserCtx.DialogueSummary, "超声手术设备") || !strings.Contains(session.UserCtx.DialogueSummary, "200万元") { t.Errorf("expected preserved facts in summary snippet, got: %s", session.UserCtx.DialogueSummary) } // 验证会话级任务状态跟踪 (DST) 正常运转 if session.UserCtx.TaskState == nil { t.Fatalf("expected TaskState to be populated on UserCtx") } if session.UserCtx.TaskState.TopicAnchor == "" { t.Errorf("expected TopicAnchor in TaskState") } } func TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord(t *testing.T) { if testing.Short() { t.Skip("skipping async memory indexing test in short mode") } ctx := context.Background() rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) ctxMgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) // 1. 预置一条同一用户的既往历史会话切片 _, err := ctxMgr.RecordTurnMemory( ctx, "sess_past_recall", 3, 99, 200, "YY 0505 电磁兼容检测需要提前准备几台样机?", "通常需要准备至少 3 台工程样机,并配齐专用试验附件。", "TECHNICAL_CONSULT", "DIRECT_HIT", true, ) if err != nil { t.Fatalf("seed past memory failed: %v", err) } userCtx := &coordinator.UserProjectContext{ SnapshotID: "sess_current_recall", User: coordinator.UserInfo{ ID: "99", Name: "赵工", Role: "engineer", ProjectRole: "composer", }, Project: coordinator.ProjectInfo{ ID: "proj_200", Name: "数字化高频超声吸引系统", CurrentStage: bizdev.StageV01_Triage, }, BlackboardFacts: make(map[string]string), } session := chat.NewSession("sess_current_recall", userCtx, rec, engine) session.UserID = 99 session.ProjectID = 200 session.CtxMgr = ctxMgr // 2. 发送带有历史回溯触发词的问答 resp, err := session.SendMessage(ctx, "我们之前讨论过 YY 0505 电磁兼容测试要几台样机吗?") if err != nil { t.Fatalf("SendMessage failed: %v", err) } if resp == nil { t.Fatalf("expected non-nil response") } // 3. 验证是否成功命中按需召回 if len(session.UserCtx.RecalledMemories) == 0 { t.Fatalf("expected RecalledMemories to be populated on UserCtx") } if !strings.Contains(session.UserCtx.RecalledMemorySnippet, "【相关历史片段】") { t.Errorf("expected header in RecalledMemorySnippet, got: %s", session.UserCtx.RecalledMemorySnippet) } if !strings.Contains(session.UserCtx.RecalledMemorySnippet, "YY 0505") { t.Errorf("expected snippet to contain YY 0505, got: %s", session.UserCtx.RecalledMemorySnippet) } // 4. 等待异步写入 goroutine 落盘(轮询至 deadline,避免固定 sleep 竞态) deadline := time.Now().Add(2 * time.Second) var results []ctxmgr.RecalledMemoryItem foundCurrentTurn := false for time.Now().Before(deadline) { var searchErr error results, searchErr = ctxMgr.SearchChatMemory(ctx, ctxmgr.ChatMemorySearchOptions{ Query: "电磁兼容样机数量", UserID: 99, ProjectID: 200, TopK: 5, MinScore: 0.1, }) if searchErr != nil { t.Fatalf("SearchChatMemory failed: %v", searchErr) } for _, r := range results { if r.SessionID == "sess_current_recall" { foundCurrentTurn = true break } } if foundCurrentTurn { break } time.Sleep(20 * time.Millisecond) } // 5. 验证本轮当场生成的问答对是否已被向量记忆库索引 if !foundCurrentTurn { t.Errorf("expected current turn to be asynchronously indexed into chat memory") } } func TestChatSession_QueryRewrite_EllipsisAndPronoun(t *testing.T) { ctx := context.Background() rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) ctxMgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) userCtx := &coordinator.UserProjectContext{ SnapshotID: "sess_rewrite_test", User: coordinator.UserInfo{ ID: "101", Name: "钱工", Role: "engineer", ProjectRole: "composer", }, Project: coordinator.ProjectInfo{ ID: "proj_rewrite_1", Name: "数字化高频超声吸引系统", DeviceType: "高频超声手术刀", CurrentStage: bizdev.StageV01_Triage, }, BlackboardFacts: make(map[string]string), } session := chat.NewSession("sess_rewrite_test", userCtx, rec, engine) session.UserID = 101 session.ProjectID = 301 session.CtxMgr = ctxMgr // Turn 1: 提问完整独立句(建立 TopicAnchor) _, err := session.SendMessage(ctx, "GB 9706.1 对我们这个器械适用吗?") if err != nil { t.Fatalf("turn 1 failed: %v", err) } if session.UserCtx.TaskState == nil || session.UserCtx.TaskState.TopicAnchor == "" { t.Fatalf("expected TopicAnchor to be set on Turn 1") } // Turn 2: 省略句提问 "YY 0505 呢?" _, err = session.SendMessage(ctx, "YY 0505 呢?") if err != nil { t.Fatalf("turn 2 failed: %v", err) } // 验证改写结果与规则命中 if session.UserCtx.RewrittenQuery == "" { t.Errorf("expected RewrittenQuery to be set on Turn 2") } if !strings.Contains(session.UserCtx.RewrittenQuery, "YY 0505") || !strings.Contains(session.UserCtx.RewrittenQuery, "适用吗") { t.Errorf("expected completed query with YY 0505 and predicate, got: %s", session.UserCtx.RewrittenQuery) } // 验证 Prompt 双呈现 composer := coordinator.NewMemoryComposer() promptTurn2 := composer.ComposeDialoguePrompt("YY 0505 呢?", session.UserCtx) if !strings.Contains(promptTurn2, "用户原话: YY 0505 呢?") { t.Errorf("expected raw user input in prompt, got: %s", promptTurn2) } if !strings.Contains(promptTurn2, "结合上下文理解完整诉求:") { t.Errorf("expected contextual explanation in prompt, got: %s", promptTurn2) } // Turn 3: 代词消解提问 "它的预算是多少?" _, err = session.SendMessage(ctx, "它的预算是多少?") if err != nil { t.Fatalf("turn 3 failed: %v", err) } if !strings.Contains(session.UserCtx.RewrittenQuery, "数字化高频超声吸引系统") { t.Errorf("expected pronoun resolution to project name, got: %s", session.UserCtx.RewrittenQuery) } }