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

291 lines
8.3 KiB
Go

package squad
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/agent"
)
// 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))
for _, h := range expectedHeadings {
if !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
}
}
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
}
for attempt := 1; attempt <= maxRetries; attempt++ {
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{"tasks/02_draft/output.md", "tasks/01_organize/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))
}
b.WriteString("\n请参考 `../../template.md`,并基于 `../../tasks/02_draft/output.md` 和 `../../tasks/01_organize/output.md` 的丰富事实与论述,补全并展开所有章节,输出完整的修复初稿到 `output.md`。\n")
_ = os.WriteFile(repairTaskPath, []byte(b.String()), 0o644)
// 执行修复步骤
runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.StepNotify, nil, &[]string{})
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 {
_ = os.WriteFile(outPath, repairedBytes, 0o644)
report, _ = q.Validate(wsDir, taskKey, string(repairedBytes))
if report.Passed {
break
}
}
}
_, _ = 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, "## ") {
headings = append(headings, trimmed)
}
}
return headings
}
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
}