From 5e8e17b96a43b9f09f62199bbe996ee0e09bc7df Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 02:26:23 +0000 Subject: [PATCH] fix(squad): strip multi-digit Chinese ordinals in quality headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nightly TestManager failed because cleanHeadingTitle only stripped 一…十, leaving 十一、/十二、 unmatched against template 签署/说明. --- internal/squad/quality_gate.go | 57 +++++++++++++++++----- internal/squad/quality_gate_export_test.go | 4 ++ internal/squad/quality_gate_test.go | 52 ++++++++++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 internal/squad/quality_gate_export_test.go diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index 0a42c081..a3fee681 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -702,23 +702,56 @@ func extractHeadings(md string) []string { return headings } -func cleanHeadingTitle(title string) string { - // 去除前缀序号如 "一、", "二、", "1.", "1.1", "(一)" 等 - t := strings.TrimSpace(title) - runes := []rune(t) - // 去掉中文序号如 "一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、", "十、" - chineseNums := []string{"一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、", "十、"} - for _, cn := range chineseNums { - if strings.HasPrefix(t, cn) { - t = strings.TrimPrefix(t, cn) - break - } +func isChineseNumeralRune(r rune) bool { + switch r { + case '零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '百', '千', '两': + return true + default: + return false } - // 去掉数字序号如 "1. ", "1、" +} + +// stripChineseOrdinalPrefix 去掉「一、」「十一、」「二十三、」等中文序号前缀。 +// 旧实现只枚举到「十、」,长文档 LLM 常用「十一、签署」会导致与模板「签署」对不上。 +func stripChineseOrdinalPrefix(t string) string { + runes := []rune(t) + if len(runes) == 0 || !isChineseNumeralRune(runes[0]) { + return t + } + i := 0 + for i < len(runes) && isChineseNumeralRune(runes[i]) { + i++ + } + if i >= len(runes) { + return t + } + switch runes[i] { + case '、', '.', '.': + i++ + default: + return t + } + for i < len(runes) && (runes[i] == ' ' || runes[i] == '\t') { + i++ + } + return string(runes[i:]) +} + +func stripArabicOrdinalPrefix(t string) string { + runes := []rune(t) + // 去掉数字序号如 "1. ", "1、", "1.1 " for len(runes) > 0 && ((runes[0] >= '0' && runes[0] <= '9') || runes[0] == '.' || runes[0] == '、' || runes[0] == ' ') { t = strings.TrimSpace(string(runes[1:])) runes = []rune(t) } + return t +} + +func cleanHeadingTitle(title string) string { + // 去除前缀序号如 "一、", "十一、", "1.", "1.1" 等 + t := strings.TrimSpace(title) + t = stripChineseOrdinalPrefix(t) + t = stripArabicOrdinalPrefix(t) // 去除可能括号后缀 if idx := strings.Index(t, "("); idx > 0 { t = strings.TrimSpace(t[:idx]) diff --git a/internal/squad/quality_gate_export_test.go b/internal/squad/quality_gate_export_test.go new file mode 100644 index 00000000..de600dcd --- /dev/null +++ b/internal/squad/quality_gate_export_test.go @@ -0,0 +1,4 @@ +package squad + +// CleanHeadingTitleForTest 暴露 cleanHeadingTitle,供同模块白盒用例验证序号清洗。 +var CleanHeadingTitleForTest = cleanHeadingTitle diff --git a/internal/squad/quality_gate_test.go b/internal/squad/quality_gate_test.go index c5dfef12..862ea5d2 100644 --- a/internal/squad/quality_gate_test.go +++ b/internal/squad/quality_gate_test.go @@ -480,3 +480,55 @@ func TestQualityGate_ActionPlanStatus_DoesNotTriggerRepair(t *testing.T) { } } } + +// 现场 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) + } +}