package squad import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "time" "unicode" "unicode/utf8" "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) || isActionPlanStatusCell(content, abs, phrase) { searchFrom = abs + len(phrase) continue } return true } } 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] localIdx := idx - lineStart if localIdx < 0 || localIdx > len(line) { return false } // 仅同一自检/规则行,且须同时具备元标注 + 否定词紧邻或引号引用结构。 // 禁止用「48 字窗口出现 无」豁免:正文「此处无更多数据,不做展开」必须仍命中。 if !lineHasProhibitedMetaMarker(line) { return false } return phraseQuotedOnLine(line, phrase) || negationAdjacentToPhrase(line, localIdx) } func lineHasProhibitedMetaMarker(line string) bool { metaMarkers := []string{ "写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写", "负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准", } for _, m := range metaMarkers { if strings.Contains(line, m) { return true } } return false } func phraseQuotedOnLine(line, phrase string) bool { if phrase == "" { return false } wrappers := [][2]string{ {`"`, `"`}, {"「", "」"}, {`'`, `'`}, {"“", "”"}, {"‘", "’"}, } for _, w := range wrappers { if strings.Contains(line, w[0]+phrase+w[1]) { return true } } return false } var ( actionPlanStatusHeaders = []string{"阶段", "状态", "进度"} actionPlanActionHeaders = []string{"行动项", "行动", "动作", "后续动作", "后续行动", "工作内容"} actionPlanOwnerHeaders = []string{"负责方", "责任人", "负责人"} actionPlanTriggerHeaders = []string{"触发条件", "触发", "完成条件", "前置条件"} ) // isActionPlanStatusCell 仅豁免行动计划/后续动作表中「阶段/状态/进度」列的独立单元格「待续」。 // 命中列必须与表头状态列索引一致;动作/负责/触发按表头取值。不得凭同行任意列或邻近词放宽。 // 只接受同一连续表格:表头后须紧跟合法 separator,且数据行列数与表头相等。 func isActionPlanStatusCell(content string, idx int, phrase string) bool { if phrase != "待续" || idx < 0 || idx >= len(content) { return false } 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] cells := markdownTableCells(line) if len(cells) < 4 || isMarkdownTableSeparator(cells) { return false } header := findPrecedingTableHeader(content, lineStart) if len(header) == 0 || len(cells) != len(header) { return false } cols, ok := parseActionPlanColumns(header) if !ok { return false } cell, hitIdx, ok := tableCellAt(line, idx-lineStart) if !ok || hitIdx != cols.status || stripMarkdownEmphasis(cell) != "待续" { return false } if cols.action >= len(cells) || cols.owner >= len(cells) || cols.trigger >= len(cells) || cols.status >= len(cells) { return false } if strings.TrimSpace(stripMarkdownEmphasis(cells[cols.owner])) == "" || strings.TrimSpace(stripMarkdownEmphasis(cells[cols.trigger])) == "" { return false } return hasSubstantialActionText(stripMarkdownEmphasis(cells[cols.action])) } func markdownTableCells(line string) []string { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "|") { return nil } parts := strings.Split(trimmed, "|") if len(parts) > 0 && parts[0] == "" { parts = parts[1:] } if len(parts) > 0 && strings.TrimSpace(parts[len(parts)-1]) == "" { parts = parts[:len(parts)-1] } if len(parts) == 0 { return nil } return parts } func tableCellAt(line string, localIdx int) (string, int, bool) { if localIdx < 0 || localIdx >= len(line) { return "", -1, false } var pipes []int for i := 0; i < len(line); i++ { if line[i] == '|' { pipes = append(pipes, i) } } if len(pipes) < 2 { return "", -1, false } for i := 0; i < len(pipes)-1; i++ { start, end := pipes[i], pipes[i+1] if localIdx > start && localIdx < end { return line[start+1 : end], i, true } } return "", -1, false } func isMarkdownTableSeparator(cells []string) bool { if len(cells) == 0 { return false } for _, c := range cells { s := strings.Trim(strings.TrimSpace(c), ":") s = strings.TrimSpace(s) if s == "" || strings.Trim(s, "-") != "" { return false } } return true } func stripMarkdownEmphasis(s string) string { s = strings.TrimSpace(s) s = strings.ReplaceAll(s, "**", "") s = strings.ReplaceAll(s, "__", "") s = strings.TrimSpace(s) if len(s) >= 2 && strings.HasPrefix(s, "*") && strings.HasSuffix(s, "*") { s = strings.TrimSpace(s[1 : len(s)-1]) } return strings.TrimSpace(s) } func hasSubstantialActionText(s string) bool { s = strings.TrimSpace(s) if s == "" || strings.Contains(s, "待续") { return false } if utf8.RuneCountInString(s) < 12 { return false } letters := 0 for _, r := range s { if unicode.Is(unicode.Han, r) || unicode.IsLetter(r) { letters++ } } return letters >= 10 } func findPrecedingTableHeader(content string, lineStart int) []string { var above []string prevEnd := lineStart - 1 for prevEnd >= 0 { prevStart := 0 if prevEnd > 0 { if i := strings.LastIndex(content[:prevEnd], "\n"); i >= 0 { prevStart = i + 1 } } line := strings.TrimSpace(content[prevStart:prevEnd]) if line == "" { break } cells := markdownTableCells(line) if cells == nil { break } above = append(above, line) prevEnd = prevStart - 1 } if len(above) < 2 { return nil } header := markdownTableCells(above[len(above)-1]) sep := markdownTableCells(above[len(above)-2]) if len(header) == 0 || isMarkdownTableSeparator(header) { return nil } if !isMarkdownTableSeparator(sep) || len(sep) != len(header) { return nil } return header } type actionPlanColumns struct { status int action int owner int trigger int } func headerColumnIndex(header []string, names []string) int { want := make(map[string]struct{}, len(names)) for _, n := range names { want[n] = struct{}{} } for i, c := range header { if _, ok := want[stripMarkdownEmphasis(c)]; ok { return i } } return -1 } func parseActionPlanColumns(header []string) (actionPlanColumns, bool) { cols := actionPlanColumns{ status: headerColumnIndex(header, actionPlanStatusHeaders), action: headerColumnIndex(header, actionPlanActionHeaders), owner: headerColumnIndex(header, actionPlanOwnerHeaders), trigger: headerColumnIndex(header, actionPlanTriggerHeaders), } if cols.status < 0 || cols.action < 0 || cols.owner < 0 || cols.trigger < 0 { return actionPlanColumns{}, false } seen := map[int]struct{}{cols.status: {}, cols.action: {}, cols.owner: {}, cols.trigger: {}} if len(seen) != 4 { return actionPlanColumns{}, false } return cols, true } func isActionPlanTableHeader(cells []string) bool { _, ok := parseActionPlanColumns(cells) return ok } func negationAdjacentToPhrase(line string, phraseIdx int) bool { if phraseIdx <= 0 || phraseIdx > len(line) { return false } prefix := strings.TrimRight(line[:phraseIdx], " \t\"'「“‘") negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"} for _, n := range negMarkers { if strings.HasSuffix(prefix, n) { return true } } 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 质量门禁自动闭环:若质检未通过,动态向工作流追加修复步骤直到达标或达到重试上限。 // 提升根 output.md 的唯一条件:runErr==nil、无受保护父级越界、本地目标可信。 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"}, InputLayout: InputLayoutSnapshot, Accept: acceptCriteria, } parentSnap := SnapshotWorkspaceInputs(wsDir) if err := driver.AddStep(wsDir, repairStep); err != nil { return report, fmt.Errorf("add repair step: %w", err) } repairAbs := filepath.Join(wsDir, filepath.FromSlash(subDir)) if err := WriteRepairTaskMarkdown(repairAbs, attempt, report, stallHinted); err != nil { return report, fmt.Errorf("write repair TASK.md: %w", err) } // 执行修复步骤(治理入账:轨迹 + 预算) runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.Timeout, opts.StepNotify, nil, &[]string{}, ledger) repairedBytes, localOK := TrustedLocalDeliverable(repairAbs, RepairTargetName) mutated := ParentInputsMutated(wsDir, parentSnap) if len(mutated) > 0 { recordContainmentViolation(wsDir, attempt, mutated, localOK, parentSnap) if rerr := restoreWorkspaceInputsFn(wsDir, parentSnap); rerr != nil { appendGovAudit(wsDir, GovAuditRestoreFailure, map[string]string{ "attempt": fmt.Sprintf("%d", attempt), "error": rerr.Error(), "mutated": strings.Join(mutated, ","), }) if st := stream.FromContext(ctx); st != nil { st.EmitPhase("governance", "修复 containment 恢复失败: "+rerr.Error(), "taskgov", 0, 0, 0) } return report, fmt.Errorf("restore parent inputs after containment: %w", rerr) } if st := stream.FromContext(ctx); st != nil { st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节,本轮作废", "taskgov", 0, 0, 0) } } promote, reason := DecideRepairPromote(runErr, mutated, localOK) if !promote { detail := map[string]string{"attempt": fmt.Sprintf("%d", attempt), "reason": reason} if runErr != nil { detail["error"] = runErr.Error() } appendGovAudit(wsDir, "repair_round_discarded", detail) continue } // 仅 runErr==nil、无 containment、本地目标可信时才覆盖根 output.md 并 Validate。 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) if err := writeRegularFile(outPath, repairedBytes); err != nil { return report, fmt.Errorf("promote repair output: %w", err) } 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 }