Files
weichen 5e8e17b96a fix(squad): strip multi-digit Chinese ordinals in quality headings
Nightly TestManager failed because cleanHeadingTitle only stripped 一…十,
leaving 十一、/十二、 unmatched against template 签署/说明.
2026-09-24 02:26:23 +00:00

535 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
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()
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()
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()
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()
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())
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()
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)
}
}