Files
Agent-System/internal/squad/quality_gate.go
T
weichenandCursor 900af79e14 fix(docgov): quality gate self-pollution, server repair budget, quality_failed UI
受控小队任务服务端启用修复预算;否定性自检引用不再误报;quality_failed 保持失败且不激活未过门禁稿。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 15:00:50 +00:00

479 lines
16 KiB
Go
Raw 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
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/agent"
"chat-agent-system/internal/stream"
)
// ProhibitedPhrases 医疗器械文档小队严禁出现的敷衍套话与省略词
var ProhibitedPhrases = []string{
"不做展开",
"不展开",
"篇幅有限",
"从略",
"不赘述",
"略过细节",
"仅作概要",
"待续",
"…(略)",
"(略)",
}
// QualityGate 负责小队交付物的三层质量门禁与合规审计
type QualityGate struct{}
func NewQualityGate() *QualityGate {
return &QualityGate{}
}
// Validate 对交付正文执行静态 AST 与质量合规校验
func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*QualityReport, error) {
now := time.Now()
report := &QualityReport{
Passed: true,
Score: 100.0,
TitleCheck: true,
MissingSections: make([]string, 0),
ProhibitedPhrases: make([]string, 0),
Violations: make([]string, 0),
QuoteMatches: true,
CheckedAt: now,
}
// 1. 检查标题完整性 (对比 template.md 的 ## 章节)
tmplPath := filepath.Join(wsDir, "template.md")
tmplData, err := os.ReadFile(tmplPath)
if err == nil {
expectedHeadings := extractHeadings(string(tmplData))
actualHeadings := extractHeadings(content)
for _, h := range expectedHeadings {
found := false
for _, act := range actualHeadings {
if strings.Contains(act, h) || strings.Contains(h, act) {
found = true
break
}
}
if !found && !strings.Contains(content, h) {
report.MissingSections = append(report.MissingSections, h)
report.Violations = append(report.Violations, fmt.Sprintf("缺少必填章节: %s", h))
report.Score -= 15.0
}
}
if len(report.MissingSections) > 0 {
report.TitleCheck = false
report.Passed = false
}
}
// 2. 检查敷衍违规套话(否定性自检/禁令复述不计入,避免门禁自污染)
for _, phrase := range ProhibitedPhrases {
if containsProhibitedUsage(content, phrase) {
report.ProhibitedPhrases = append(report.ProhibitedPhrases, phrase)
// 违规说明勿回写禁用原词,避免修复指令/自检再次自污染
report.Violations = append(report.Violations, "检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)")
report.Score -= 20.0
report.Passed = false
}
}
// 3. 检查未渲染的双大括号模板占位符
if strings.Contains(content, "{{") && strings.Contains(content, "}}") {
report.Violations = append(report.Violations, "检测到未替换的双大括号占位符 {{...}}")
report.Score -= 10.0
report.Passed = false
}
// 4. CDO 报价任务检查
if taskKey == "cdo_quote" {
xlsxPath := filepath.Join(wsDir, "CDO报价核算-V01.xlsx")
if _, err := os.Stat(xlsxPath); err != nil {
report.QuoteMatches = false
report.Violations = append(report.Violations, "CDO 报价任务缺少配套核算文件 CDO报价核算-V01.xlsx")
report.Score -= 25.0
report.Passed = false
}
}
// 5. 智能产品经理工作流:系统需求规格书检查
if taskKey == "system_reqs" {
if !strings.Contains(content, "REQ-") && !strings.Contains(content, "SYS-REQ") {
report.Violations = append(report.Violations, "系统需求规格书缺少规范的需求编号(如 REQ-01 / SYS-REQ-01)")
report.Score -= 20.0
report.Passed = false
}
hasVerification := strings.Contains(content, "测试") || strings.Contains(content, "分析") ||
strings.Contains(content, "审查") || strings.Contains(content, "演示") ||
strings.Contains(content, "检验") || strings.Contains(content, "检") || strings.Contains(content, "测")
if !hasVerification {
report.Violations = append(report.Violations, "系统需求清单缺少验证方式字段(测试/分析/审查/演示)")
report.Score -= 15.0
report.Passed = false
}
}
// 6. 智能测试经理工作流:系统验证方案及计划检查
if taskKey == "test_manager" {
if !strings.Contains(content, "TC-") && !strings.Contains(content, "TEST-") {
report.Violations = append(report.Violations, "系统验证方案缺少规范的测试用例编号(如 TC-01 / TEST-01)")
report.Score -= 20.0
report.Passed = false
}
if !strings.Contains(content, "REQ-") {
report.Violations = append(report.Violations, "系统验证方案缺少对上游系统需求编号(REQ-)的追溯覆盖")
report.Score -= 15.0
report.Passed = false
}
tcPath := filepath.Join(wsDir, "artifacts", "test_cases.json")
if tcData, err := os.ReadFile(tcPath); err == nil && len(tcData) > 0 {
var dummy any
if err := json.Unmarshal(tcData, &dummy); err != nil {
report.Violations = append(report.Violations, "artifacts/test_cases.json 不是合法的 JSON 格式")
report.Score -= 10.0
report.Passed = false
}
}
}
if report.Score < 0 {
report.Score = 0
}
if report.Score < 80.0 {
report.Passed = false
}
return report, nil
}
// sanitizeViolationForAgent 修复指令中不得原样回写禁用套话,避免二次自污染。
func sanitizeViolationForAgent(v string) string {
out := v
for _, phrase := range ProhibitedPhrases {
if phrase != "" && strings.Contains(out, phrase) {
out = strings.ReplaceAll(out, phrase, "「省略/敷衍写法」")
}
}
return out
}
// containsProhibitedUsage 仅当禁词被用作正文省略/敷衍时命中。
// 否定性自检、写作禁令复述(如:无"…"等敷衍套话)不得误报。
func containsProhibitedUsage(content, phrase string) bool {
if phrase == "" || content == "" {
return false
}
searchFrom := 0
for {
rel := strings.Index(content[searchFrom:], phrase)
if rel < 0 {
return false
}
abs := searchFrom + rel
if !isNegatedOrMetaProhibitedCitation(content, abs, phrase) {
return true
}
searchFrom = abs + len(phrase)
}
}
func isNegatedOrMetaProhibitedCitation(content string, idx int, phrase string) bool {
lineStart := strings.LastIndex(content[:idx], "\n") + 1
lineEnd := strings.Index(content[idx:], "\n")
if lineEnd < 0 {
lineEnd = len(content)
} else {
lineEnd = idx + lineEnd
}
line := content[lineStart:lineEnd]
metaMarkers := []string{
"写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写",
"负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准",
}
for _, m := range metaMarkers {
if strings.Contains(line, m) {
return true
}
}
prefixStart := idx - 48
if prefixStart < 0 {
prefixStart = 0
}
prefix := content[prefixStart:idx]
negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"}
for _, n := range negMarkers {
if strings.Contains(prefix, n) {
return true
}
}
// 现场形态:无"不做展开""从略""篇幅有限"等敷衍套话
if strings.Contains(prefix, "无\"") || strings.Contains(prefix, "无「") || strings.Contains(prefix, "无'") ||
strings.Contains(prefix, "无“") {
return true
}
_ = phrase
return false
}
// GenerateManifest 扫描工作区核心文件生成 SHA-256 指纹与防篡改审计清单
func (q *QualityGate) GenerateManifest(wsDir string, docKey string, report QualityReport) (*Manifest, error) {
now := time.Now()
files := make(map[string]string)
trackedPaths := []string{
"workspace.json",
"squad.json",
"workflow.json",
"template.md",
"blackboard.json",
"output.md",
filepath.Join("context", "index.json"),
filepath.Join("context", "user.md"),
filepath.Join("context", "project.md"),
filepath.Join("context", "agent.md"),
}
for _, rel := range trackedPaths {
p := filepath.Join(wsDir, rel)
if hash, err := computeSHA256(p); err == nil {
files[filepath.ToSlash(rel)] = hash
}
}
// 扫描 artifacts/
artDir := filepath.Join(wsDir, "artifacts")
if entries, err := os.ReadDir(artDir); err == nil {
for _, e := range entries {
if !e.IsDir() {
rel := filepath.Join("artifacts", e.Name())
p := filepath.Join(wsDir, rel)
if hash, err := computeSHA256(p); err == nil {
files[filepath.ToSlash(rel)] = hash
}
}
}
}
// 扫描 tasks/*/output.md
tasksDir := filepath.Join(wsDir, "tasks")
if entries, err := os.ReadDir(tasksDir); err == nil {
for _, e := range entries {
if e.IsDir() {
outPath := filepath.Join(tasksDir, e.Name(), "output.md")
if hash, err := computeSHA256(outPath); err == nil {
rel := filepath.Join("tasks", e.Name(), "output.md")
files[filepath.ToSlash(rel)] = hash
}
}
}
}
manifest := &Manifest{
ManifestVersion: "1.0.0",
WorkspaceID: filepath.Base(wsDir),
DocKey: docKey,
GeneratedAt: now,
Deliverable: filepath.Join(wsDir, "output.md"),
Files: files,
QualityReport: report,
}
manifestPath := filepath.Join(wsDir, "manifest.json")
if err := writeJSON(manifestPath, manifest); err != nil {
return nil, fmt.Errorf("write manifest.json: %w", err)
}
return manifest, nil
}
// EnsureQualityOrRepair 质量门禁自动闭环:若质检未通过,动态向工作流追加修复步骤直到达标或达到重试上限
func (q *QualityGate) EnsureQualityOrRepair(
ctx context.Context,
wsDir string,
taskKey string,
driver *WorkflowDriver,
backend agent.Backend,
maxRetries int,
opts ExecuteOptions,
) (*QualityReport, error) {
outPath := filepath.Join(wsDir, "output.md")
contentBytes, err := os.ReadFile(outPath)
if err != nil {
return nil, fmt.Errorf("read output.md: %w", err)
}
report, err := q.Validate(wsDir, taskKey, string(contentBytes))
if err != nil {
return nil, err
}
if report.Passed || maxRetries <= 0 {
_, _ = q.GenerateManifest(wsDir, taskKey, *report)
return report, nil
}
// 治理:加载预算账本(修复轮预算预扣 + 跨轮停滞检测)
ledger := LoadOrCreateLedger(wsDir, DefaultBudgetPolicy())
prevRepairContent := string(contentBytes)
stallHinted := false
stallBreak := false
for attempt := 1; attempt <= maxRetries; attempt++ {
// 治理:修复轮预算预扣(超限硬停,杜绝机械空转烧成本)
if lErr := ledger.ReserveRepair(); lErr != nil {
_ = ledger.Save(wsDir)
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("governance", "修复预算硬停: "+lErr.Error(), "taskgov", 0, 0, 0)
}
break
}
repairStepID := fmt.Sprintf("step_repair_%d", attempt)
subDir := fmt.Sprintf("tasks/repair_%d", attempt)
var acceptCriteria []string
if len(report.MissingSections) > 0 {
acceptCriteria = append(acceptCriteria, "补齐缺失章节: "+strings.Join(report.MissingSections, ", "))
}
if len(report.ProhibitedPhrases) > 0 {
acceptCriteria = append(acceptCriteria, "清除正文中实际使用的省略/敷衍写法;自检表勿复述禁用原词")
}
for _, v := range report.Violations {
acceptCriteria = append(acceptCriteria, "解决违规点: "+v)
}
repairStep := StepSpec{
ID: repairStepID,
Role: "writer",
Title: fmt.Sprintf("根据质量门禁反馈修复交付初稿 (第%d次修正)", attempt),
SubWorkspace: subDir,
Mode: "execute",
Needs: []string{"output.md"},
Accept: acceptCriteria,
}
if err := driver.AddStep(wsDir, repairStep); err != nil {
return report, fmt.Errorf("add repair step: %w", err)
}
// 定制该修复子任务的 TASK.md 增加门禁报错说明
repairTaskPath := filepath.Join(wsDir, filepath.FromSlash(subDir), "TASK.md")
var b strings.Builder
b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt))
b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n")
for _, v := range report.Violations {
b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v)))
}
// 治理:停滞升级——上轮修复产物与再上轮几乎相同,注入反重复指令换策略
if stallHinted {
b.WriteString("\n⚠️ 治理警告:最近两轮修复产物相似度过高,被判定为无进度空转。本轮必须针对上述违规点逐条重写对应章节,产出与之前实质不同的内容,否则任务将被强制终止。\n")
}
b.WriteString("\n请参考 `../../template.md`,并基于根目录 `../../output.md`(当前未通过门禁的初稿)与黑板事实,补全并展开所有章节,输出完整的修复初稿到 `output.md`。\n")
_ = os.WriteFile(repairTaskPath, []byte(b.String()), 0o644)
// 执行修复步骤(治理入账:轨迹 + 预算)
runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.Timeout, opts.StepNotify, nil, &[]string{}, ledger)
if runErr != nil {
break
}
// 读出修复后的产物并覆盖根目录 output.md
repairedOutPath := filepath.Join(wsDir, filepath.FromSlash(subDir), "output.md")
if repairedBytes, err := os.ReadFile(repairedOutPath); err == nil && len(repairedBytes) > 0 {
// 治理:跨修复轮停滞检测(相似度 ≥ 0.98 判定无进度;二次停滞直接止损)
sim := OutputSimilarity(prevRepairContent, string(repairedBytes))
if sim >= 0.98 {
if stallHinted {
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0)
}
appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
stallBreak = true
} else {
stallHinted = true
appendGovAudit(wsDir, "repair_stall_hinted", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
}
}
prevRepairContent = string(repairedBytes)
_ = os.WriteFile(outPath, repairedBytes, 0o644)
report, _ = q.Validate(wsDir, taskKey, string(repairedBytes))
if report.Passed || stallBreak {
break
}
}
}
_ = ledger.Save(wsDir)
_, _ = q.GenerateManifest(wsDir, taskKey, *report)
return report, nil
}
func extractHeadings(md string) []string {
var headings []string
lines := strings.Split(md, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## ") {
raw := strings.TrimPrefix(trimmed, "## ")
clean := cleanHeadingTitle(raw)
if clean != "" {
headings = append(headings, clean)
}
}
}
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
}
}
// 去掉数字序号如 "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)
}
// 去除可能括号后缀
if idx := strings.Index(t, "("); idx > 0 {
t = strings.TrimSpace(t[:idx])
}
if idx := strings.Index(t, "("); idx > 0 {
t = strings.TrimSpace(t[:idx])
}
return strings.TrimSpace(t)
}
func computeSHA256(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}