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

330 lines
9.6 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"
"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))
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
}
}
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, "## ") {
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
}