Files
Agent-System/internal/squad/quality_gate.go
T

407 lines
13 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 strings.Contains(content, phrase) {
report.ProhibitedPhrases = append(report.ProhibitedPhrases, phrase)
report.Violations = append(report.Violations, fmt.Sprintf("检测到敷衍违规词: '%s'", phrase))
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
}
// 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, "清除敷衍违规词: "+strings.Join(report.ProhibitedPhrases, ", "))
}
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", 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.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
}