package squad_test import ( "context" "os" "path/filepath" "strings" "testing" "time" "chat-agent-system/internal/agent" "chat-agent-system/internal/squad" ) func TestQualityGate_Validate(t *testing.T) { tmpDir := t.TempDir() engine := squad.NewWorkspaceEngine(nil) ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: tmpDir, TaskKey: "intake", StageTitle: "用户初始需求收集表", ProjectName: "医疗设备", TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n", }) if err != nil { t.Fatalf("PrepareWorkspace failed: %v", err) } gate := squad.NewQualityGate() // 1. 测试违规文本:包含敷衍词「不做展开」与缺少第 2 节 badContent := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处篇幅有限,不做展开。\n\n## 3. 待确认清单\n- 待确认事项\n" report1, err := gate.Validate(ws.WorkDir, "intake", badContent) if err != nil { t.Fatalf("Validate failed: %v", err) } if report1.Passed { t.Errorf("expected report1 to fail quality gate") } if len(report1.ProhibitedPhrases) == 0 { t.Errorf("expected prohibited phrase '不做展开' to be detected") } if len(report1.MissingSections) == 0 { t.Errorf("expected missing section '## 2. 预期用途' to be detected") } // 2. 测试合规文本:包含所有章节且无违规词 goodContent := `# 用户初始需求收集表 ## 1. 客户与项目画像 本项目客户为专业医疗机构〔材料: 需求表 §1〕。 (四段式展开详细论述) ## 2. 预期用途 用于临床辅助诊断与生理信号监测。 ## 3. 待确认清单 - 供电模块认证周期待窗口确认。 ` report2, err := gate.Validate(ws.WorkDir, "intake", goodContent) if err != nil { t.Fatalf("Validate failed: %v", err) } if !report2.Passed { t.Errorf("expected report2 to pass quality gate, violations: %v", report2.Violations) } if report2.Score < 80.0 { t.Errorf("expected high score, got %f", report2.Score) } // 3. 测试 Manifest 生成 manifest, err := gate.GenerateManifest(ws.WorkDir, "intake", *report2) if err != nil { t.Fatalf("GenerateManifest failed: %v", err) } if manifest.DocKey != "intake" { t.Errorf("expected DocKey 'intake', got %s", manifest.DocKey) } if len(manifest.Files) == 0 { t.Errorf("expected tracked files in manifest") } if _, err := os.Stat(filepath.Join(ws.WorkDir, "manifest.json")); err != nil { t.Errorf("manifest.json not written to disk") } } func TestQualityGate_AutoRepair(t *testing.T) { tmpDir := t.TempDir() engine := squad.NewWorkspaceEngine(nil) ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: tmpDir, TaskKey: "intake", StageTitle: "用户初始需求收集表", TemplateContent: "# 用户初始需求收集表\n\n## 1. 结论\n\n## 2. 详细方案\n", }) if err != nil { t.Fatalf("PrepareWorkspace failed: %v", err) } driver := squad.NewWorkflowDriver(engine) gate := squad.NewQualityGate() // 模拟不合格草稿 badDraft := "# 用户初始需求收集表\n\n## 1. 结论\n由于篇幅有限,不做展开。\n" _ = os.WriteFile(filepath.Join(ws.WorkDir, "output.md"), []byte(badDraft), 0o644) // 触发自动修复:门禁检测到不合格,向 workflow 追加修复步骤并执行 stubBackend := &agent.StubBackend{} report, err := gate.EnsureQualityOrRepair(context.Background(), ws.WorkDir, "intake", driver, stubBackend, 1, squad.ExecuteOptions{ Timeout: 5 * time.Second, }) if err != nil { t.Fatalf("EnsureQualityOrRepair failed: %v", err) } // 验证工作流中已追加过修复步骤 wf, _ := driver.LoadWorkflow(ws.WorkDir) hasRepairStep := false for _, s := range wf.Steps { if s.ID == "step_repair_1" { hasRepairStep = true break } } if !hasRepairStep { t.Errorf("expected step_repair_1 to be appended to workflow") } if report == nil { t.Errorf("expected non-nil quality report") } } func TestQualityGate_SystemReqsAndTestManager(t *testing.T) { tmpDir := t.TempDir() engine := squad.NewWorkspaceEngine(nil) gate := squad.NewQualityGate() // 1. 测试 system_reqs ws1, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: filepath.Join(tmpDir, "ws_reqs"), TaskKey: "system_reqs", StageTitle: "系统需求规格书", ProjectName: "医疗监护仪", TemplateContent: "# 系统需求规格书\n\n## 1. 结论\n\n## 2. 系统需求清单\n", }) if err != nil { t.Fatalf("PrepareWorkspace failed: %v", err) } // 缺少需求编号和验证方式 badReqContent := "# 系统需求规格书\n\n## 1. 结论\n结论完整\n\n## 2. 系统需求清单\n系统应支持心率采集。\n" rep1, err := gate.Validate(ws1.WorkDir, "system_reqs", badReqContent) if err != nil { t.Fatalf("Validate failed: %v", err) } if rep1.Passed { t.Errorf("expected system_reqs missing REQ- and verification to fail") } // 合规文本 goodReqContent := "# 系统需求规格书\n\n## 1. 结论\n结论完整\n\n## 2. 系统需求清单\n| REQ-01 | 系统应支持心率监测,测量误差不超过 ±2bpm | 测试 | 高 |\n" rep2, err := gate.Validate(ws1.WorkDir, "system_reqs", goodReqContent) if err != nil { t.Fatalf("Validate failed: %v", err) } if !rep2.Passed { t.Errorf("expected compliant system_reqs to pass, got violations: %v", rep2.Violations) } // 2. 测试 test_manager ws2, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: filepath.Join(tmpDir, "ws_test"), TaskKey: "test_manager", StageTitle: "系统验证方案及计划", ProjectName: "医疗监护仪", TemplateContent: "# 系统验证方案及计划\n\n## 1. 结论\n\n## 2. 用例与验证规程\n", }) if err != nil { t.Fatalf("PrepareWorkspace failed: %v", err) } // 缺少用例和上游追溯 badTestContent := "# 系统验证方案及计划\n\n## 1. 结论\n结论\n\n## 2. 用例与验证规程\n验证功能正常。\n" rep3, err := gate.Validate(ws2.WorkDir, "test_manager", badTestContent) if err != nil { t.Fatalf("Validate failed: %v", err) } if rep3.Passed { t.Errorf("expected test_manager missing TC- and REQ- to fail") } // 合规文本 goodTestContent := "# 系统验证方案及计划\n\n## 1. 结论\n结论\n\n## 2. 用例与验证规程\n| TC-01 | 验证心率测量精度 | 追溯 REQ-01 | 判定准则误差 <= ±2bpm | 通过 |\n" rep4, err := gate.Validate(ws2.WorkDir, "test_manager", goodTestContent) if err != nil { t.Fatalf("Validate failed: %v", err) } if !rep4.Passed { t.Errorf("expected compliant test_manager to pass, got violations: %v", rep4.Violations) } } // 现场证据 output.md:204 — 终稿自检表否定性引用禁词,不得误报为敷衍违规。 func TestQualityGate_NegatedSelfCheckCitation_NoFalsePositive(t *testing.T) { tmpDir := t.TempDir() engine := squad.NewWorkspaceEngine(nil) ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: tmpDir, TaskKey: "intake", StageTitle: "用户初始需求收集表", ProjectName: "医疗设备", TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n", }) if err != nil { t.Fatalf("PrepareWorkspace: %v", err) } gate := squad.NewQualityGate() // 与 data/dev/workspaces/bizdev/intake_1789308825666278807/output.md 第204行同构 realChecklistLine := `| 写作禁令遵守情况 | ✅ 通过 | 无"不做展开""从略""篇幅有限"等敷衍套话 |` content := `# 用户初始需求收集表 ## 1. 客户与项目画像 本项目客户为专业医疗机构〔材料: 需求表 §1〕。四段式展开详细论述客户背景与项目边界。 ## 2. 预期用途 用于临床辅助诊断与生理信号监测,覆盖病房与门诊场景。 ## 3. 待确认清单 - 供电模块认证周期待窗口确认。 ### 附录C:审核记录(Review Record) | 审核项 | 审核结论 | 说明 | |-------|---------|------| | 全文逻辑一致性 | ✅ 通过 | 结论→正文→解读完整 | ` + realChecklistLine + "\n" report, err := gate.Validate(ws.WorkDir, "intake", content) if err != nil { t.Fatalf("Validate: %v", err) } if !report.Passed { t.Fatalf("negated self-check must not fail gate; violations=%v phrases=%v score=%.0f", report.Violations, report.ProhibitedPhrases, report.Score) } if len(report.ProhibitedPhrases) != 0 { t.Fatalf("expected no prohibited hits on meta citation, got %v", report.ProhibitedPhrases) } // 真敷衍仍须命中 hedging := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处篇幅有限,不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n" bad, err := gate.Validate(ws.WorkDir, "intake", hedging) if err != nil { t.Fatalf("Validate hedging: %v", err) } if bad.Passed || len(bad.ProhibitedPhrases) == 0 { t.Fatalf("real hedging must fail; passed=%v phrases=%v", bad.Passed, bad.ProhibitedPhrases) } for _, v := range bad.Violations { for _, p := range squad.ProhibitedPhrases { if strings.Contains(v, p) { t.Fatalf("violation text must not echo banned phrase %q: %s", p, v) } } } // 正文出现「无」但非自检引用:不得因 48 字窗口豁免 falseNeg := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处无更多数据,不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n" fn, err := gate.Validate(ws.WorkDir, "intake", falseNeg) if err != nil { t.Fatalf("Validate false-neg: %v", err) } if fn.Passed || len(fn.ProhibitedPhrases) == 0 { t.Fatalf("body hedging with 无 must still fail; passed=%v phrases=%v", fn.Passed, fn.ProhibitedPhrases) } // 仅有审核栏标题、禁词作正文省略:不得豁免 metaOnly := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n审核结论:本节不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n" mo, err := gate.Validate(ws.WorkDir, "intake", metaOnly) if err != nil { t.Fatalf("Validate meta-only: %v", err) } if mo.Passed || len(mo.ProhibitedPhrases) == 0 { t.Fatalf("meta heading plus real hedging must fail; passed=%v phrases=%v", mo.Passed, mo.ProhibitedPhrases) } } func intakeTemplate(t *testing.T) (workDir string, gate *squad.QualityGate) { t.Helper() engine := squad.NewWorkspaceEngine(nil) ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: t.TempDir(), TaskKey: "intake", StageTitle: "用户初始需求收集表", ProjectName: "医疗设备", TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n", }) if err != nil { t.Fatalf("PrepareWorkspace: %v", err) } return ws.WorkDir, squad.NewQualityGate() } func wrapIntakeBody(body string) string { return `# 用户初始需求收集表 ## 1. 客户与项目画像 本项目客户为专业医疗机构。四段式展开客户背景、项目边界与采集依据。 ## 2. 预期用途 用于临床辅助诊断与生理信号监测,覆盖病房与门诊场景。 ## 3. 待确认清单 - 供电模块认证周期待窗口确认。 ` + body } // 现场 intake_1789314411395330821 output.md:163-164 — 行动计划表独立状态「待续」不得误报。 func TestQualityGate_ActionPlanStatusCell_PendingNotHedging(t *testing.T) { workDir, gate := intakeTemplate(t) stillListed := false for _, p := range squad.ProhibitedPhrases { if p == "待续" { stillListed = true break } } if !stillListed { t.Fatal("待续 must remain in ProhibitedPhrases") } // 与 workspace 第163-164 行同构(下一步行动建议 / 阶段·行动项·负责方·触发条件) actionPlan := `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | **立即** | 用户补充D-01(注册分类)、D-02(预期用途)两项高优先级缺口,提供分类依据或产品技术要求初稿。 | 北京水木东方 | 收到用户反馈后 | | **待续** | 合规专员基于用户补充材料,评估GB 9706.1-2020符合性与软件合规要求,出具初步合规评估意见。 | 合规专员 | D-01至D-07全部确认后 | | **待续** | 组织专员编制里程碑规划与检测排期方案,明确各阶段时间节点、责任部门及交付物要求。 | 组织专员 | 合规评估完成后 | ` report, err := gate.Validate(workDir, "intake", wrapIntakeBody(actionPlan)) if err != nil { t.Fatalf("Validate: %v", err) } if !report.Passed { t.Fatalf("action-plan status cell must pass; violations=%v phrases=%v score=%.0f", report.Violations, report.ProhibitedPhrases, report.Score) } if len(report.ProhibitedPhrases) != 0 { t.Fatalf("expected no prohibited hits on action-plan status, got %v", report.ProhibitedPhrases) } } func TestQualityGate_ActionPlanStatus_ReorderedColumnsPass(t *testing.T) { workDir, gate := intakeTemplate(t) reordered := `### 下一步行动建议 | 行动项 | 负责方 | 触发条件 | 阶段 | |---|---|---|---| | 合规专员基于用户补充材料评估GB 9706.1符合性并出具初步意见。 | 合规专员 | D-01至D-07全部确认后 | **待续** | ` report, err := gate.Validate(workDir, "intake", wrapIntakeBody(reordered)) if err != nil { t.Fatalf("Validate: %v", err) } if !report.Passed || len(report.ProhibitedPhrases) != 0 { t.Fatalf("reordered status column must pass; violations=%v phrases=%v", report.Violations, report.ProhibitedPhrases) } } func TestQualityGate_PendingPhrase_HostileContextsStillFail(t *testing.T) { workDir, gate := intakeTemplate(t) cases := []struct { name string body string }{ {"body_hedging", "后续章节待续,本节仅列框架。\n"}, {"heading", "## 后续章节待续\n正文已展开风险与电气安全条款适用性。\n"}, {"list", "- 后续章节待续\n"}, {"plain_two_col_table", "| 项目 | 内容 |\n|---|---|\n| 风险管理 | 待续 |\n"}, {"four_col_non_action_table", "| 章节 | 摘要 | 作者 | 备注 |\n|---|---|---|---|\n| 风险 | 待续 | 张三 | 无 |\n"}, {"labeled_pending_note", `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | 待续说明 | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | D-01确认后 | `}, {"incomplete_row", `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | **待续** | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | | `}, {"thin_action", `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | **待续** | 再议 | 合规专员 | 完成后 | `}, {"owner_cell_pending", `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | 立即 | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 待续 | D-01确认后 | `}, {"trigger_cell_pending", `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | 立即 | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | 待续 | `}, {"no_header_separator", `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | | **待续** | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | D-01确认后 | `}, {"header_4_data_5", `### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | **待续** | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | D-01确认后 | 额外列 | `}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rep, err := gate.Validate(workDir, "intake", wrapIntakeBody(tc.body)) if err != nil { t.Fatalf("Validate: %v", err) } if rep.Passed || len(rep.ProhibitedPhrases) == 0 { t.Fatalf("must still fail; passed=%v phrases=%v", rep.Passed, rep.ProhibitedPhrases) } hit := false for _, p := range rep.ProhibitedPhrases { if p == "待续" { hit = true } } if !hit { t.Fatalf("must record 待续, got %v", rep.ProhibitedPhrases) } }) } } func TestQualityGate_ActionPlanStatus_DoesNotTriggerRepair(t *testing.T) { workDir, gate := intakeTemplate(t) content := wrapIntakeBody(`### 下一步行动建议 | 阶段 | 行动项 | 负责方 | 触发条件 | |---|---|---|---| | **待续** | 合规专员基于用户补充材料评估电气安全符合性并出具意见。 | 合规专员 | D-01至D-07全部确认后 | `) if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte(content), 0o644); err != nil { t.Fatal(err) } driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine(nil)) report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &agent.StubBackend{}, 1, squad.ExecuteOptions{ Timeout: 5 * time.Second, }) if err != nil { t.Fatalf("EnsureQualityOrRepair: %v", err) } if report == nil || !report.Passed { t.Fatalf("action-plan pending must pass without repair; report=%+v", report) } wf, _ := driver.LoadWorkflow(workDir) for _, s := range wf.Steps { if strings.HasPrefix(s.ID, "step_repair_") { t.Fatalf("must not append repair for status-cell false positive, got %q", s.ID) } } } // 现场 nightly TestManager:LLM 写「## 十一、签署 / ## 十二、说明」,旧清洗只剥到「十、」会误报缺章。 func TestCleanHeadingTitle_MultiDigitChineseOrdinal(t *testing.T) { cases := []struct { in, want string }{ {"一、客户与项目画像", "客户与项目画像"}, {"十、附录", "附录"}, {"十一、签署", "签署"}, {"十二、说明", "说明"}, {"二十三、风险评估", "风险评估"}, {"1. 结论", "结论"}, {"签署", "签署"}, {"十一、签署(终稿)", "签署"}, } for _, c := range cases { got := squad.CleanHeadingTitleForTest(c.in) if got != c.want { t.Errorf("cleanHeadingTitle(%q) = %q, want %q", c.in, got, c.want) } } } // 现场 nightly TestManager:LLM 写「## 十一、签署 / ## 十二、说明」,旧清洗只剥到「十、」会误报缺章。 func TestQualityGate_Validate_ChineseOrdinalElevenTwelve(t *testing.T) { tmpDir := t.TempDir() engine := squad.NewWorkspaceEngine(nil) ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: filepath.Join(tmpDir, "ws_tm"), TaskKey: "test_manager", StageTitle: "系统验证方案及计划", ProjectName: "医疗监护仪", TemplateContent: "# 系统验证方案及计划\n\n" + "## 结论\n\n## 用例与验证规程\n\n## 签署\n\n## 说明\n", }) if err != nil { t.Fatalf("PrepareWorkspace: %v", err) } gate := squad.NewQualityGate() body := "# 系统验证方案及计划\n\n" + "## 一、结论\n本方案覆盖核心验证路径,结论可交付。\n\n" + "## 二、用例与验证规程\n| TC-01 | 验证心率测量精度 | 追溯 REQ-01 | 判定准则误差 <= ±2bpm | 通过 |\n\n" + "## 十一、签署\n产品负责人签字确认。\n\n" + "## 十二、说明\n本说明补充验证边界与假设。\n" rep, err := gate.Validate(ws.WorkDir, "test_manager", body) if err != nil { t.Fatalf("Validate: %v", err) } if !rep.Passed { t.Fatalf("expected eleven/twelve ordinal headings to match template, violations=%v", rep.Violations) } } func TestNormalizeHeadingTitle_FoldsSpaceWidthAndCase(t *testing.T) { cases := []struct { in, want string }{ {"技术路线与 State-of-the-Art", "技术路线与state-of-the-art"}, {"技术路线与State-of-the-Art", "技术路线与state-of-the-art"}, {"技术路线与 State-of-the-Art", "技术路线与state-of-the-art"}, {"十一、签署", "签署"}, } for _, c := range cases { got := squad.NormalizeHeadingTitleForTest(c.in) if got != c.want { t.Errorf("normalizeHeadingTitle(%q)=%q want %q", c.in, got, c.want) } } } func TestQualityGate_Validate_HeadingLevelAndSpaceDrift(t *testing.T) { tmpDir := t.TempDir() engine := squad.NewWorkspaceEngine(nil) ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ WorkDir: filepath.Join(tmpDir, "ws_research"), TaskKey: "research", StageTitle: "调研报告", ProjectName: "吞咽电刺激", TemplateContent: "# 调研报告\n\n" + "## 结论\n\n## 技术路线与 State-of-the-Art\n\n## 阻塞信息\n\n## 正文\n", }) if err != nil { t.Fatalf("PrepareWorkspace: %v", err) } gate := squad.NewQualityGate() drifted := "# 调研报告\n\n" + "## 结论\n结论句。\n\n" + "### 技术路线与State-of-the-Art\n路线对比。\n\n" + "### 阻塞信息\n待确认。\n\n" + "## 2. 正文\n正文展开。\n" rep, err := gate.Validate(ws.WorkDir, "research", drifted) if err != nil { t.Fatalf("Validate: %v", err) } if !rep.Passed { t.Fatalf("space/level drift must not fail the gate, violations=%v missing=%v", rep.Violations, rep.MissingSections) } if rep.Score != 100 { t.Fatalf("level warning must not deduct score, got %v", rep.Score) } joined := strings.Join(rep.Violations, "\n") if !strings.Contains(joined, "章节层级与模板不一致: 技术路线与 State-of-the-Art") { t.Fatalf("missing level warning: %v", rep.Violations) } if !strings.Contains(joined, "章节层级与模板不一致: 阻塞信息") { t.Fatalf("missing blocking-info level warning: %v", rep.Violations) } if len(rep.MissingSections) != 0 { t.Fatalf("missing=%v", rep.MissingSections) } absent := "# 调研报告\n\n## 结论\n有结论。\n\n## 技术路线与 State-of-the-Art\n有路线。\n\n## 阻塞信息\n有阻塞。\n" miss, err := gate.Validate(ws.WorkDir, "research", absent) if err != nil { t.Fatalf("Validate absent: %v", err) } if miss.Passed { t.Fatal("truly absent 正文 must still fail") } found := false for _, s := range miss.MissingSections { if s == "正文" { found = true } } if !found { t.Fatalf("missing sections=%v", miss.MissingSections) } }