feat(quality): implement 3-tier quality gate, manifest auditing, and quote backfill
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
4474d575da
commit
434ce844f8
@@ -0,0 +1,289 @@
|
||||
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",
|
||||
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` 补全并展开所有章节,输出完整的修复初稿到 `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
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/quote"
|
||||
)
|
||||
|
||||
// FormatMoney 格式化金额用于 Markdown 表格展示
|
||||
func FormatMoney(v float64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
if v == float64(int64(v)) {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||
}
|
||||
|
||||
// RenderCDOQuoteTable 将 quote.Summary 转换为标准 Markdown 表格
|
||||
func RenderCDOQuoteTable(summary *quote.Summary) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("| 报价代码 | 服务内容 | 人数 | 人年 | 成本 | 加Buffer20% | 加毛利40% | 加税7% | 设备设施等费用 | 税后价格 |\n")
|
||||
b.WriteString("|---|---|---:|---:|---:|---:|---:|---:|---:|---:|\n")
|
||||
|
||||
var totalPersonYear, totalCost, totalBuffer, totalMargin, totalTax float64
|
||||
for _, line := range summary.Lines {
|
||||
fmt.Fprintf(&b, "| %s | %s | — | %s | %s | %s | %s | %s | — | %s |\n",
|
||||
line.Code, line.Name,
|
||||
FormatMoney(line.PersonYear),
|
||||
FormatMoney(line.Cost),
|
||||
FormatMoney(line.Buffer),
|
||||
FormatMoney(line.Margin),
|
||||
FormatMoney(line.Tax),
|
||||
FormatMoney(line.Price),
|
||||
)
|
||||
totalPersonYear += line.PersonYear
|
||||
totalCost += line.Cost
|
||||
totalBuffer += line.Buffer
|
||||
totalMargin += line.Margin
|
||||
totalTax += line.Tax
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "| | **总计** | — | **%s** | **%s** | **%s** | **%s** | **%s** | — | **%s** |\n",
|
||||
FormatMoney(totalPersonYear),
|
||||
FormatMoney(totalCost),
|
||||
FormatMoney(totalBuffer),
|
||||
FormatMoney(totalMargin),
|
||||
FormatMoney(totalTax),
|
||||
FormatMoney(summary.TotalPrice),
|
||||
)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BackfillCDOQuoteMarkdown 将计算结果回填到 cdo_quote 模板中
|
||||
func BackfillCDOQuoteMarkdown(body string, summary *quote.Summary) string {
|
||||
conclusion := fmt.Sprintf(
|
||||
"按公司 CDO 定价核算模板计算,税后合计 **%s 元**(含 Buffer 20%%、毛利 40%%、税 7%%)。数字与配套 Excel「报价模型」一致。",
|
||||
FormatMoney(summary.TotalPrice),
|
||||
)
|
||||
body = replaceSection(body, "## 结论", conclusion)
|
||||
|
||||
tableIntro := "表中「人年」列与 Excel 模板同一列,对应模型投入人月合计(工时/160)。\n\n"
|
||||
body = replaceSection(body, "## 报价模型", tableIntro+RenderCDOQuoteTable(summary))
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func replaceSection(body, heading, content string) string {
|
||||
start := strings.Index(body, heading)
|
||||
if start == -1 {
|
||||
return body
|
||||
}
|
||||
rest := body[start+len(heading):]
|
||||
nextHeading := strings.Index(rest, "\n## ")
|
||||
if nextHeading == -1 {
|
||||
return body[:start] + heading + "\n\n" + content + "\n"
|
||||
}
|
||||
end := start + len(heading) + nextHeading
|
||||
return body[:start] + heading + "\n\n" + content + "\n" + body[end:]
|
||||
}
|
||||
Reference in New Issue
Block a user