package server_test import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" "chat-agent-system/internal/kb" "chat-agent-system/internal/server" "chat-agent-system/internal/squad" ) // mergePiAgentE2EKbConfig 从分层 Load(根 .env + configs/.env.{ENV})注入共享知识库配置。 // E2E 默认 fail-closed:须在线 KB;仅 KB_ALLOW_OFFLINE=1 时允许降级并打印 NOTE。 func mergePiAgentE2EKbConfig(cfg *config.Config) { if cfg == nil { return } _ = os.Setenv("KB_REQUIRE_ONLINE", "1") envCfg, err := config.Load("") if err == nil && envCfg != nil { cfg.KbDbHost = envCfg.KbDbHost cfg.KbDbPort = envCfg.KbDbPort cfg.KbDbUser = envCfg.KbDbUser cfg.KbDbPassword = envCfg.KbDbPassword cfg.KbDbName = envCfg.KbDbName cfg.EmbeddingEndpointURL = envCfg.EmbeddingEndpointURL cfg.EmbeddingAPIKey = envCfg.EmbeddingAPIKey cfg.EmbeddingModel = envCfg.EmbeddingModel cfg.RerankerEndpointURL = envCfg.RerankerEndpointURL cfg.RerankerAPIKey = envCfg.RerankerAPIKey cfg.RerankerModel = envCfg.RerankerModel } config.NormalizeKBHostForRuntime(cfg) if cfg.KbDbHost == "" { cfg.KbDbHost = "127.0.0.1" } if cfg.KbDbPort == 0 { cfg.KbDbPort = 5432 } if cfg.KbDbUser == "" { cfg.KbDbUser = "regkb" } if cfg.KbDbName == "" { cfg.KbDbName = "regulations" } } // TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E // 贯穿:新建项目 -> 角色权限配置 -> 分角色材料补充与真实对话 -> 自由非标任务执行 -> 工作流推进 -> 审核驳回/再批 -> 数据库落盘与全量审计追溯 // // 运行方式(需 pi-agent runner 在线,建议 1h 超时): // // go test ./internal/server/ -run TestPiAgent_FullLifecycle -timeout 2h -v func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { if testing.Short() { t.Skip("skipping slow real-world E2E test in short mode") } // 1. 初始化配置并强制指定 pi-agent 后端与真实 Runner runnerURL := os.Getenv("PI_AGENT_RUNNER_URL") if runnerURL == "" { runnerURL = "http://127.0.0.1:18865" } runnerToken := os.Getenv("PI_AGENT_RUNNER_AUTH_TOKEN") if runnerToken == "" { runnerToken = os.Getenv("RUNNER_AUTH_TOKEN") } cfg := &config.Config{ Env: "dev", AgentBackend: "pi-agent", AgentTimeout: 60 * time.Minute, PiAgentRunnerURL: runnerURL, PiAgentRunnerAuthToken: runnerToken, PiAgentModel: "MiniMax-M3", PiAgentProvider: "minimax", DataDir: t.TempDir(), AllowInMemory: true, } mergePiAgentE2EKbConfig(cfg) svc := bizdev.MustNewService(t, cfg) srv := server.NewServer(cfg, svc) ts := httptest.NewServer(srv.Routes()) defer ts.Close() // 单次 pi-agent 远程调用可能超过 30min;HTTP client 与 AgentTimeout 对齐,整体由 go test -timeout 2h 兜底。 client := &http.Client{Timeout: 65 * time.Minute} ctx := context.Background() stepStart := time.Now() logStep := func(label string) { elapsed := time.Since(stepStart).Round(time.Second) t.Logf("⏱ [%s] 累计耗时 %s", label, elapsed) stepStart = time.Now() } // 2. 验证 Health 确认 AgentBackend 为 pi-agent respHealth, err := client.Get(ts.URL + "/api/v1/health") if err != nil { t.Fatalf("health check failed: %v", err) } var healthData server.HealthResponse _ = json.NewDecoder(respHealth.Body).Decode(&healthData) respHealth.Body.Close() t.Logf("✓ [0. 环境校验] Health检查正常: Env=%s, AgentBackend=%s, SharedKBPort=%d kb_ready=%v", healthData.Env, healthData.AgentBackend, healthData.SharedKBPort, healthData.KBReady) if !kb.RequireOnline() && !healthData.KBReady { t.Logf("NOTE: KB offline degradation test (KB_ALLOW_OFFLINE=1)") } kbClient := kb.NewClient(cfg) defer kbClient.Close() if err := kb.EnsureOnline(kbClient); err != nil { t.Fatal(err) } if !kbClient.Online() { t.Logf("NOTE: KB offline degradation test — continuing without shared KB") } else if stats, err := kbClient.Stats(ctx); err != nil { t.Fatalf("KB online but Stats failed: %v", err) } else if reg, ok := stats[kb.DomainRegulatory]; ok { t.Logf("✓ [0. 知识库] 已连接共享 KB: documents=%d chunks=%d", reg.DocumentCount, reg.ChunkCount) } else { t.Logf("✓ [0. 知识库] 已连接共享 KB") } // 3. 项目基本信息与三级角色定义 projectID := int64(time.Now().UnixNano()%900000 + 100000) projectName := "数字化高频超声手术吸引系统 (CUSA-Ultra)" customerName := "北京协和神外微创转化中心" deviceType := "高频超声手术吸引系统" intendedUse := "神经外科与肝胆外科精密手术中利用 24kHz-35kHz 超声微幅振动选择性粉碎并吸引病灶组织,保留邻近大血管与神经" targetMarket := "中国NMPA三类创新医疗器械" // 与 dev 种子账号对齐(internal/auth/store.go) authorID := int64(2) // zhanggong / 张工 authorName := "张工" reviewerID := int64(3) // lizhuguan / 李主管 reviewerName := "李主管" approverID := int64(4) // wangzong / 王总监 approverName := "王总监" authorToken := loginTestUser(t, ts.Config.Handler, "zhanggong", "123456") reviewerToken := loginTestUser(t, ts.Config.Handler, "lizhuguan", "123456") approverToken := loginTestUser(t, ts.Config.Handler, "wangzong", "123456") authedPost := func(path, token string, payload any) (*http.Response, error) { b, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") withBearerAuth(req, token) return client.Do(req) } expectOK := func(label string, resp *http.Response, err error) { t.Helper() if err != nil { t.Fatalf("%s: %v", label, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { var buf bytes.Buffer _, _ = buf.ReadFrom(resp.Body) t.Fatalf("%s: HTTP %d body=%s", label, resp.StatusCode, buf.String()) } } sessionID := fmt.Sprintf("sess_cusa_%d", time.Now().UnixNano()) // 4. 【编制人·张工】登录与立项阶段对话 (穿插非工作流问答 + 行业标准咨询) t.Log("--------------------------------------------------------------------------------") t.Log(">>> 步骤 1: 【编制人·张工】开展立项对接对话与标准咨询 (含非工作流穿插交互)") t.Log("--------------------------------------------------------------------------------") postChat := func(msg string, uID int64, uName, uRole string) string { chatReq := server.ChatRequest{ SessionID: sessionID, Message: msg, ProjectName: projectName, CustomerName: customerName, DeviceType: deviceType, IntendedUse: intendedUse, TargetMarket: targetMarket, UserName: uName, UserRole: uRole, UserID: uID, ProjectID: projectID, } b, _ := json.Marshal(chatReq) res, err := client.Post(ts.URL+"/api/v1/chat", "application/json", bytes.NewReader(b)) if err != nil { t.Fatalf("post chat failed: %v", err) } defer res.Body.Close() var cr struct { ReplyText string `json:"reply_text"` Intent string `json:"intent"` Branch string `json:"branch"` } _ = json.NewDecoder(res.Body).Decode(&cr) return cr.ReplyText } // 4.1 穿插闲聊 / 非工作流技术探讨论述 casualReply := postChat("参谋,你觉得明天的天气怎么样?另外帮我解释一下什么叫压电换能器的逆压电效应?", authorID, authorName, "author") t.Logf("✓ [张工-闲聊与技术咨询] 协同参谋答复:\n%s", casualReply) // 4.2 业务补充与立项需求对话 intakeReply := postChat("我们准备正式立项 CUSA-Ultra 项目,刀头频率设定为 24kHz 与 35kHz 双频,具备实时灌注与负压吸引,满足神经外科浅脑肿瘤切除,请问法规应关注哪些强标?", authorID, authorName, "author") t.Logf("✓ [张工-立项需求对话] 协同参谋答复:\n%s", intakeReply) logStep("步骤 1 对话完成") // 5. 推进成熟度工作流 V0.1: 立项摸底 (intake) t.Log("--------------------------------------------------------------------------------") t.Log(">>> 步骤 2: 推进工作流成熟度阶段 V0.1 (立项需求摸底与盲区探测)") t.Log("--------------------------------------------------------------------------------") stageReqV01 := bizdev.StageRunRequest{ Stage: bizdev.StageV01_Triage, ProjectID: projectID, ProjectName: projectName, CustomerName: customerName, DeviceType: deviceType, IntendedUse: intendedUse, TargetMarket: targetMarket, Backend: "pi-agent", UserID: authorID, AuthorID: authorID, AuthorName: authorName, ReviewerID: reviewerID, ReviewerName: reviewerName, ApproverID: approverID, ApproverName: approverName, Fields: map[string]string{ "operating_frequency": "24kHz / 35kHz", "transducer_type": "PZT-8 压电陶瓷复合换能器", "cooling_flow_rate": "10-50 mL/min 生理盐水冲洗", }, } resV01, err := svc.ExecuteStage(ctx, stageReqV01) if err != nil { t.Fatalf("ExecuteStage V0.1 failed: %v", err) } if !resV01.Success { t.Fatalf("Stage V0.1 failed: %s", resV01.ErrorReason) } t.Logf("✓ [V0.1 阶段完成] 交付物: %s, 成熟度评分: %d, 标题: %s", resV01.PrimaryArtifact.ArtifactTitle, resV01.AdvisorBrief.MaturityScore, resV01.AdvisorBrief.MaturityHeadline) targetDocID := "" if resV01.PrimaryArtifact != nil && resV01.PrimaryArtifact.DocRecord != nil { targetDocID = resV01.PrimaryArtifact.DocRecord.DocID } if targetDocID == "" { targetDocID = fmt.Sprintf("doc_proj%d_intake_v1", projectID) } logStep("步骤 2 V0.1 阶段完成") // 6. 推进自由非标任务 (Free Ad-Hoc Task): 24kHz 高频空化钛合金刀头微裂纹与疲劳剥落风险专项分析报告 t.Log("--------------------------------------------------------------------------------") t.Log(">>> 步骤 3: 触发自由非标任务 (Free Ad-Hoc Task) - 超声空化微射流与钛合金疲劳断裂专项研判") t.Log("--------------------------------------------------------------------------------") freeTaskReq := server.TaskRunRequestDTO{ TaskKey: "free_cusa_cavitation_risk", ProjectName: projectName, CustomerName: customerName, DeviceType: deviceType, IntendedUse: intendedUse, TargetMarket: targetMarket, Backend: "pi-agent", ProjectID: projectID, AuthorID: authorID, AuthorName: authorName, ReviewerID: reviewerID, ReviewerName: reviewerName, ApproverID: approverID, ApproverName: approverName, Fields: map[string]string{ "material_grade": "Ti-6Al-4V ELI (超低间隙五级钛合金)", "tip_amplitude_um": "300 微米纵向振幅峰峰值", "cavitation_cycles": "10^8 次高周疲劳循环", }, } // 为自由非标任务定制快速精简 WBS 步骤,确保单测试验证效率 if os.Getenv("RUN_FULL_ADHOC_WBS") != "1" { freeTaskReq.CustomSteps = []squad.StepSpec{ { ID: "step_01_cavitation_analysis", Role: "writer", Title: "钛合金刀头高周超声疲劳与空化剥落风险专项研判", SubWorkspace: "tasks/01_analysis", Mode: "execute", Accept: []string{ "分析 Ti-6Al-4V ELI 在 24kHz/35kHz 空化微射流下的疲劳断裂机理", "给出刀头表面喷丸强化与声学阻抗匹配方案", "交付正文 output.md", }, }, } } freeBytes, _ := json.Marshal(freeTaskReq) freeResp, err := client.Post(ts.URL+"/api/v1/tasks/run", "application/json", bytes.NewReader(freeBytes)) if err != nil { t.Fatalf("run free task failed: %v", err) } var freeRes struct { Success bool `json:"success"` ArtifactTitle string `json:"artifact_title"` WorkDir string `json:"work_dir"` } if freeResp.StatusCode != http.StatusOK { t.Fatalf("run free task HTTP %d", freeResp.StatusCode) } _ = json.NewDecoder(freeResp.Body).Decode(&freeRes) freeResp.Body.Close() if !freeRes.Success { t.Fatalf("free ad-hoc task failed: artifact=%q work_dir=%q", freeRes.ArtifactTitle, freeRes.WorkDir) } t.Logf("✓ [自由非标任务完成] 任务成功=%v, 交付物=[%s], work_dir=%s", freeRes.Success, freeRes.ArtifactTitle, freeRes.WorkDir) logStep("步骤 3 自由非标任务完成") // 7. 若开启全量阶段环境变量 (RUN_HEAVY_STAGES=1),则继续演练 V0.5 与 V1.0 if os.Getenv("RUN_HEAVY_STAGES") == "1" { t.Log("--------------------------------------------------------------------------------") t.Log(">>> 步骤 4: 推进工作流成熟度阶段 V0.5 (法规合规与注册可行性论证)") t.Log("--------------------------------------------------------------------------------") stageReqV05 := bizdev.StageRunRequest{ Stage: bizdev.StageV05_Feasibility, ProjectID: projectID, ProjectName: projectName, CustomerName: customerName, DeviceType: deviceType, IntendedUse: intendedUse, TargetMarket: targetMarket, Backend: "pi-agent", UserID: authorID, AuthorID: authorID, AuthorName: authorName, ReviewerID: reviewerID, ReviewerName: reviewerName, ApproverID: approverID, ApproverName: approverName, } resV05, err := svc.ExecuteStage(ctx, stageReqV05) if err != nil { t.Fatalf("Stage V0.5 execution failed: %v", err) } if !resV05.Success { t.Fatalf("Stage V0.5 should succeed, got error: %s", resV05.ErrorReason) } if resV05.PrimaryArtifact != nil && resV05.PrimaryArtifact.DocRecord != nil { targetDocID = resV05.PrimaryArtifact.DocRecord.DocID t.Logf("✓ [V0.5 阶段完成] 法规可行性交付物: %s, DocID=%s, 成熟度评分=%d", resV05.PrimaryArtifact.ArtifactTitle, targetDocID, resV05.AdvisorBrief.MaturityScore) } t.Log("--------------------------------------------------------------------------------") t.Log(">>> 步骤 4.2: 推进成熟度阶段 V1.0 (商业锁定与 CDO 报价 Excel 自动测算)") t.Log("--------------------------------------------------------------------------------") stageReqV10 := bizdev.StageRunRequest{ Stage: bizdev.StageV10_Quoting, ProjectID: projectID, ProjectName: projectName, CustomerName: customerName, DeviceType: deviceType, IntendedUse: intendedUse, TargetMarket: targetMarket, Backend: "pi-agent", UserID: authorID, AuthorID: authorID, AuthorName: authorName, ReviewerID: reviewerID, ReviewerName: reviewerName, ApproverID: approverID, ApproverName: approverName, } resV10, err := svc.ExecuteStage(ctx, stageReqV10) if err != nil { t.Fatalf("Stage V1.0 execution failed: %v", err) } if !resV10.Success { t.Fatalf("Stage V1.0 should succeed, got error: %s", resV10.ErrorReason) } t.Logf("✓ [V1.0 阶段完成] 商业报价交付物: %s, 成熟度评分=%d (%s)", resV10.PrimaryArtifact.ArtifactTitle, resV10.AdvisorBrief.MaturityScore, resV10.AdvisorBrief.MaturityLevel) } // 8. 受控文档生命周期演练: 编制人认领 -> 提交审核 -> 审核人批注 -> 审核驳回 -> 编制人修改并再提交 -> 审核人通过 -> 批准人最终批准 t.Log("--------------------------------------------------------------------------------") t.Log(">>> 步骤 5: 受控文档闭环治理演练 (认领 -> 批注 -> 审核驳回 -> 修正再送审 -> 批准)") t.Log("--------------------------------------------------------------------------------") // 8.1 编制人【张工】查阅并显式认领 claimReq := map[string]any{ "doc_id": targetDocID, "user_id": authorID, "user_name": authorName, } respClaim, err := authedPost("/api/v1/docgov/claim", authorToken, claimReq) expectOK("claim", respClaim, err) t.Logf("✓ [张工] 成功认领受控文档: DocID=%s", targetDocID) // 8.2 编制人【张工】送审 (进入 StateReviewing) submitReq := map[string]any{ "doc_id": targetDocID, "author_id": authorID, "note": "初稿编制完成,已包含 24kHz/35kHz 双频超声与灌注吸引合规说明,请主管审核。", } respSub, err := authedPost("/api/v1/docgov/submit-review", authorToken, submitReq) expectOK("submit-review", respSub, err) t.Logf("✓ [张工] 提交审核成功,文档进入锁定审核态") // 8.3 【审核人·李主管】介入并添加批注 commReq := map[string]any{ "doc_id": targetDocID, "user_id": reviewerID, "user_name": reviewerName, "user_role": "reviewer", "target_section": "第三章:注册检验与电气安全合规路径", "comment_type": "risk", "content": "请补充说明超声刀头与高频手术设备(高频电刀)联合使用时,GB 9706.202 及 YY 0644 隔离抗扰度测试要求。", } cmb, _ := json.Marshal(commReq) respComm, err := client.Post(ts.URL+"/api/v1/docgov/comments", "application/json", bytes.NewReader(cmb)) if err != nil || respComm.StatusCode != http.StatusOK { t.Fatalf("add comment failed: %v", err) } respComm.Body.Close() t.Logf("✓ [李主管] 审核批注已录入") // 8.4 【审核人·李主管】第一次执行【驳回 (Reject)】以验证受控纠偏流 rejReq := map[string]any{ "doc_id": targetDocID, "user_id": reviewerID, "user_name": reviewerName, "action": "reject", "reason": "缺少高频电刀共模互扰防范机制论证,打回草稿态由编制人张工补充后重新送审。", } respRej, err := authedPost("/api/v1/docgov/review-decision", reviewerToken, rejReq) expectOK("review reject", respRej, err) t.Logf("✓ [李主管] 驳回成功,受控文档自动解锁并退回 drafting 状态") // 8.5 编制人【张工】再次送审 submitReq2 := map[string]any{ "doc_id": targetDocID, "author_id": authorID, "note": "已补充 GB 9706.202 绝缘隔离与高频消磁屏蔽方案,重新送审。", } respSub2, err := authedPost("/api/v1/docgov/submit-review", authorToken, submitReq2) expectOK("resubmit", respSub2, err) t.Logf("✓ [张工] 补充材料后再次送审成功") // 8.6 【审核人·李主管】第二次审核 -> 审核通过 (Pass) passReq := map[string]any{ "doc_id": targetDocID, "user_id": reviewerID, "user_name": reviewerName, "action": "approve", "comment": "方案已完善,GB 9706.202 及 YY 0644 检验项齐备,同意流转至王总监终审。", } respPass, err := authedPost("/api/v1/docgov/review-decision", reviewerToken, passReq) expectOK("review approve", respPass, err) t.Logf("✓ [李主管] 审核通过,文档流转至待批准态 (StateApproving)") // 8.7 【批准人·王总监】最终批准 (Approve) apprReq := map[string]any{ "doc_id": targetDocID, "user_id": approverID, "user_name": approverName, "action": "approve", "comment": "符合创新微创手术吸引系统三年规划,同意立项并进入工程实施与核价锁定。", } respAppr, err := authedPost("/api/v1/docgov/approver-decision", approverToken, apprReq) expectOK("approver decision", respAppr, err) t.Logf("✓ [王总监] 最终批准成功,文档彻底锁定生效归档 (StateApproved)") // 9. 验证全生命周期数据落盘与审计追溯 t.Log("--------------------------------------------------------------------------------") t.Log(">>> 步骤 6: 审计与数据库落盘全面核查 (会话、消息、文档状态、审计日志)") t.Log("--------------------------------------------------------------------------------") // 9.1 验证审计日志 API (GET /api/v1/docgov/audit-logs) respAudit, err := client.Get(fmt.Sprintf("%s/api/v1/docgov/audit-logs?doc_id=%s", ts.URL, targetDocID)) if err != nil || respAudit.StatusCode != http.StatusOK { t.Fatalf("list audit logs failed: %v", err) } var auditLogs []docgov.AuditLog _ = json.NewDecoder(respAudit.Body).Decode(&auditLogs) respAudit.Body.Close() if len(auditLogs) < 5 { t.Errorf("expected at least 5 audit logs, got %d", len(auditLogs)) } t.Logf("✓ 成功追溯受控文档全生命周期审计轨迹流 (%d 条记录):", len(auditLogs)) for idx, log := range auditLogs { t.Logf(" [%d] %s | 操作人: %s (%s) | 动作: %s -> %s", idx+1, log.CreatedAt.Format("15:04:05"), log.ActorName, log.ActorRole, log.FromState, log.ToState) } // 10.2 验证历史对话消息持久化 (GET /api/v1/chat/messages) respMsgs, err := client.Get(fmt.Sprintf("%s/api/v1/chat/messages?session_id=%s", ts.URL, sessionID)) if err != nil || respMsgs.StatusCode != http.StatusOK { t.Fatalf("get chat messages failed: %v", err) } var msgList []ctxmgr.ChatMessageRecord _ = json.NewDecoder(respMsgs.Body).Decode(&msgList) respMsgs.Body.Close() if len(msgList) < 4 { t.Errorf("expected at least 4 recorded messages, got %d", len(msgList)) } t.Logf("✓ 成功查询并追溯全周期真实多轮对话持久化记录 (%d 条):", len(msgList)) for idx, m := range msgList { roleName := "智能体参谋 (pi-agent/MiniMax)" if m.Role == "user" { roleName = "用户 (张工)" } summaryContent := m.Content if len(summaryContent) > 60 { summaryContent = summaryContent[:60] + "..." } t.Logf(" [%d] [%s] %s: %s (意图: %s, 分支: %s)", idx+1, m.CreatedAt.Format("15:04:05"), roleName, summaryContent, m.Intent, m.Branch) } // 10.3 验证工作区物理产物存在 wsPath := filepath.Join(cfg.DataDir, "workspaces") if fi, err := os.Stat(wsPath); err == nil && fi.IsDir() { t.Logf("✓ 物理工作区目录正常落盘: %s", wsPath) } }