diff --git a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md new file mode 100644 index 00000000..8b4d3982 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md @@ -0,0 +1,46 @@ +--- +title: "质量门禁自污染与 auto_repair 未启用导致 quality_failed" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# 质量门禁自污染与 auto_repair 未启用导致 quality_failed + +## 现象 + +同一整改范围真实 run(与 SSE/幂等现场同源): + +- workspace=`data/dev/workspaces/bizdev/intake_1789308825666278807` +- run 于 **14:17:51** 结束为 **`quality_failed`**(仅 1 个 `task_runs`) +- 质量门禁事件:**score=40**,命中 `不做展开` / `篇幅有限` / `从略` +- `output.md` **第204行**终稿自检表:`无"不做展开""从略""篇幅有限"等敷衍套话` → 门禁 `strings.Contains` **自污染误报** +- 请求体未带 `auto_repair`;虽存在 `EnsureQualityOrRepair`,实际 **maxRetries=0** +- chat_messages 同 `client_turn` 两对(1131/1132 正常;1133/1134 重连误写)——与 SSE 幂等缺陷同源,另案处理 + +## 根因 + +| 项 | 说明 | +|---|---| +| A | 修复预算依赖客户端 `auto_repair`(浏览器可省略,默认 false) | +| B | 生成指令与自检表原样引用禁词;检测无否定/元语境豁免 | +| C | FE 将 `quality_failed` 映射为 `completed`,并在断线 catch 路径同步受控文档 | +| D | 缺真实失败形态单测 | + +## 整改(同分支 `fix/document-revision-sse-idempotency`) + +- A:有 `TaskKey` 的受控小队任务服务端强制 `DefaultMaxRepairRounds`;handlers 产品契约 `AutoRepair: true` +- B:prompt 去原词;`containsProhibitedUsage` + 否定/元语境;违规文案与修复 TASK 不回写禁词 +- C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 +- D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 + +## 证据 + +- workspace path 如上;events seq10;output.md:204 +- assets:[../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../test-reports/assets/2026-09-13-document-revision-runtime-ui/)(勿删) + +## 关联 + +- Short-term 扩写:[../ops/short-term/2026-09-13-document-revision-sse-idempotency.md](../ops/short-term/2026-09-13-document-revision-sse-idempotency.md) +- SSE 幂等审计:[./2026-09-13-document-revision-sse-idempotency.md](./2026-09-13-document-revision-sse-idempotency.md) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index f5b6bcd4..f1ae21ee 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30** 已合入;真实 UI-03 暴露 SSE WriteTimeout 断线后同 turn 误 409 DOC_BUSY;整改在 `fix/document-revision-sse-idempotency`) +**production_ready**: no(DOCREV:schema **30**;SSE 幂等 + 质量门禁自污染/auto_repair 整改在 `fix/document-revision-sse-idempotency`) ## 焦点 -1. **DOCREV-RT**: [SSE 幂等与写超时](short-term/2026-09-13-document-revision-sse-idempotency.md)(`fix/document-revision-sse-idempotency`:幂等先于 busy、SSE heartbeat、FE after_seq 重连;不 push) +1. **DOCREV-RT**: [SSE 幂等 + 质量门禁](short-term/2026-09-13-document-revision-sse-idempotency.md)(`fix/document-revision-sse-idempotency`:幂等/SSE + A–D 质量契约;不 push) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term @@ -51,3 +51,4 @@ | DOCREV ADR | [../adr/2026-09-13-document-version-boundary.md](../adr/2026-09-13-document-version-boundary.md) | | DOCREV 测试 | [../test-reports/2026-09-13-document-revision-management-test-report.md](../test-reports/2026-09-13-document-revision-management-test-report.md) | | SSE/幂等审计 | [../audit/2026-09-13-document-revision-sse-idempotency.md](../audit/2026-09-13-document-revision-sse-idempotency.md) | +| 质量门禁自污染审计 | [../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index cefe97e0..430acdb9 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -1,5 +1,5 @@ --- -title: "任务 SSE 写超时与同 turn 幂等先于 busy 预检" +title: "任务 SSE 幂等 + 质量门禁自污染与 auto_repair 产品契约" status: in_progress created: 2026-09-13 updated: 2026-09-13 @@ -9,18 +9,18 @@ worktree: "-" mutex: docgov --- -# 任务 SSE 写超时与同 turn 幂等先于 busy 预检 +# 任务 SSE 幂等 + 质量门禁自污染与 auto_repair 产品契约 ## 目标 -同 turn 在 SSE 断线重连时返回既有 run 202、不重复聊天、不误 409 DOC_BUSY;长任务 events SSE 不被 120s WriteTimeout 切断;前端 EOF 按 after_seq 重订阅同一 run。 +1. 同 turn 在 SSE 断线重连时返回既有 run 202、不重复聊天、不误 409 DOC_BUSY;长任务 events SSE 不被 120s WriteTimeout 切断;前端 EOF 按 after_seq 重订阅同一 run。 +2. 受控文档小队任务质量门禁:**服务端启用修复预算**;否定性自检引用不误报;`quality_failed` UI 保持失败且不激活未过门禁稿。 ## 范围 -- `internal/server/task_run_handlers.go`:授权后幂等查找先于 scope/preflight -- `internal/server`:SSE 专用 `ResponseController` 写期限延长 + heartbeat -- `web/src/api/taskRuns.ts` + `chatStream` seq:EOF 重连 -- 单测与文档 +- SSE/幂等:`task_run_handlers`、SSE write deadline、FE subscribe 重连 +- 质量 A–D:`repairBudgetForSquadTask`、`quality_gate` 语境检测、prompt 去原词、Materialize/`TerminalStatus`、FE `quality_failed`、单测 +- 证据:`data/dev/workspaces/bizdev/intake_1789308825666278807`(output.md:204;events seq10) ## 非目标 @@ -32,15 +32,21 @@ mutex: docgov 2. [x] 幂等先查 + 属主校验 3. [x] SSE write deadline + heartbeat 4. [x] FE subscribe 重连 -5. [x] 测试与原子提交 +5. [x] A 服务端修复预算产品契约 +6. [x] B 门禁否定/元语境 + prompt 去原词 +7. [x] C quality_failed UI/Materialize 契约 +8. [x] D 真实问题单测 + 门禁回归 ## 验收标准 - [x] 同 turn + 活跃租约 → 同 run 202、仅一对消息;不同 turn → 仍 409 - [x] SSE 心跳/写期限可测;FE EOF/after_seq 单测 +- [x] TaskKey 任务 AutoRepair=false 仍有 DefaultMaxRepairRounds;L204 自检引用不误报;真敷衍仍 FAIL +- [x] quality_failed ≠ completed;Materialize 跳过未过门禁稿 - [x] `go test -short ./...`、`go vet`、`npm test`/`build`、`git diff --check` ## 相关链接 -- Audit: [../../audit/2026-09-13-document-revision-sse-idempotency.md](../../audit/2026-09-13-document-revision-sse-idempotency.md) +- Audit SSE: [../../audit/2026-09-13-document-revision-sse-idempotency.md](../../audit/2026-09-13-document-revision-sse-idempotency.md) +- Audit 质量: [../../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) - Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/) diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index 70a5f984..fb6c47bc 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.md @@ -63,6 +63,7 @@ | DOCREV-RUNTIME-404 | revision-state 404;账本内存丢失;块保存/overlay 无效;agent 假报告 | **FAIL** | [audit](../audit/2026-09-13-document-revision-runtime-404.md);`internal/docrevision/service.go` 仅 map | [remediation](./2026-09-13-document-revision-runtime-remediation.md) | | DOCREV-UI03-CLIENT-SYNC | 小队完成后右栏仍模板;UI-03 选择器误命中 Banner;`pending_author_confirmation` 撞 chk;重试重复建 run | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-controlled-doc-client-sync-ui03.md);分支 `fix/document-revision-runtime-ui03`:post-task sync、templateMatch intake 优先、goose 00030、Claim+idempotency | 合入前:schema30 **真实现卷**迁移独立验证;占槽真实 UI-03 重跑;勿把 stub 当验收 | | DOCREV-SSE-IDEM-BUSY | SSE WriteTimeout 断线后同 turn 再 POST 误 409 DOC_BUSY + 重复聊天 | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-sse-idempotency.md);证据 [assets/2026-09-13-document-revision-runtime-ui](../test-reports/assets/2026-09-13-document-revision-runtime-ui/);`fix/document-revision-sse-idempotency` | 占槽真实 UI-03 重跑;不删证据 | +| DOCREV-QUALITY-SELF-POLLUTE | 自检表引用禁词致 score=40 quality_failed;auto_repair 未启用;UI 映射 completed | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md);workspace `intake_1789308825666278807` output.md:204;同分支 | 占槽真实重跑;不删证据 | --- diff --git a/configs/agents/writer.md b/configs/agents/writer.md index 80d100fd..5fe9f4ab 100644 --- a/configs/agents/writer.md +++ b/configs/agents/writer.md @@ -15,7 +15,7 @@ - 先立框架:读者是谁、要他们做什么决定。 - 按本阶段模板撰写,把专员输入填进去;模板是下限,按共用写作质量把每一节写完整。 - **正文优先**:文档主题章节写在前面且详尽;`阻塞信息`/`输入摘要`/`本阶段材料` 等字段快照放文末,不得用字段表顶替正文。 -- **禁止省略**:不得写「不做展开」「从略」「不赘述」等;缺信息标「待确认」并说明缺什么。 +- **禁止省略**:各节须实质展开,不得以篇幅借口或省略写法跳过应写细节;缺信息标「待确认」并说明缺什么(自检勿复述禁用原词)。 - 数字标注来源;材料没有的数字不许填。涉及法规条款用〔标准号 §条款 p.页〕。 - 缺口显式化,文末汇总「待确认」。没有出处的判断可保留,写「(分析判断,无直接条文依据)」。 - 按 TASK.md 指定文件落盘:普通/执行步写 `output.md`;计划步(plan)只写 `output_plan.md`,**不要**写 `output.md`。 diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 33a9fbd2..dd510754 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -728,7 +728,8 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun // leave needs_confirmation. Activity is recorded only after a committed projection // (or a pending-awaiting note that is explicitly not completion). - if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil { + if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil && + (res.QualityReport == nil || res.QualityReport.Passed) { _ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir) dataRoot := "." if s.cfg != nil && s.cfg.DataDir != "" { diff --git a/internal/bizdev/task_result_status.go b/internal/bizdev/task_result_status.go index 48cad723..f98b7527 100644 --- a/internal/bizdev/task_result_status.go +++ b/internal/bizdev/task_result_status.go @@ -28,16 +28,20 @@ func (r *TaskRunResult) IsProposalIntercept() bool { // TerminalStatus maps TaskRunResult to a conversation/workflow terminal status. // Pending agent confirmation is never "completed" until commit+projection. +// Quality failure stays quality_failed even when Success was cleared to block Materialize. func (r *TaskRunResult) TerminalStatus() string { - if r == nil || !r.Success { + if r == nil { + return TaskStatusFailed + } + if r.QualityReport != nil && !r.QualityReport.Passed { + return TaskStatusQualityFailed + } + if !r.Success { return TaskStatusFailed } if r.AwaitsAgentConfirmation() { return TaskStatusPendingAuthorConfirmation } - if r.QualityReport != nil && !r.QualityReport.Passed { - return TaskStatusQualityFailed - } return TaskStatusCompleted } diff --git a/internal/bizdev/task_result_status_test.go b/internal/bizdev/task_result_status_test.go index db73f750..e9094544 100644 --- a/internal/bizdev/task_result_status_test.go +++ b/internal/bizdev/task_result_status_test.go @@ -6,6 +6,7 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/squad" ) func TestTaskRunResult_PendingConfirmation_NotCompleted(t *testing.T) { @@ -79,3 +80,33 @@ func TestTaskRunResult_CompletedOnlyWhenCommitted(t *testing.T) { t.Fatalf("completed summary: %s", sum) } } + +// 现场:score=40 / quality_failed 不得映射 completed;Success 已清仍保持 quality_failed。 +func TestTaskRunResult_QualityFailed_NotCompleted(t *testing.T) { + res := &bizdev.TaskRunResult{ + Success: false, + ArtifactTitle: "用户初始需求收集表", + TaskKey: "intake", + QualityReport: &squad.QualityReport{ + Passed: false, + Score: 40, + ProhibitedPhrases: []string{"不做展开", "从略", "篇幅有限"}, + Violations: []string{"检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)"}, + }, + DocRecord: &docgov.DocumentRecord{DocID: "doc_proj6098_intake"}, + } + if res.TerminalStatus() != bizdev.TaskStatusQualityFailed { + t.Fatalf("status=%q want quality_failed", res.TerminalStatus()) + } + sum := res.CompletionSummary() + if strings.Contains(sum, "已完成") { + t.Fatalf("quality_failed summary must not claim completed: %s", sum) + } + if !strings.Contains(sum, "质量检查未通过") { + t.Fatalf("summary missing quality fail wording: %s", sum) + } + art := res.ArtifactMetadata() + if art["quality_passed"] != false { + t.Fatalf("quality_passed=%v", art["quality_passed"]) + } +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 3c53b0fa..8afe0ddd 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -578,7 +578,7 @@ func (s *Server) buildBizdevTaskRequest(dto TaskRunRequestDTO, sessionID, client Fields: dto.Fields, Materials: dto.Materials, Backend: dto.Backend, - AutoRepair: dto.AutoRepair, + AutoRepair: true, // 产品契约:受控小队任务服务端启用修复,不依赖浏览器字段 SessionID: sessionID, UserID: dto.UserID, ProjectID: dto.ProjectID, @@ -607,7 +607,7 @@ func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clien Fields: dto.Fields, Materials: dto.Materials, Backend: dto.Backend, - AutoRepair: dto.AutoRepair, + AutoRepair: true, // 产品契约:阶段任务同样启用修复预算 SessionID: sessionID, UserID: dto.UserID, ProjectID: dto.ProjectID, diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index 44dd98e2..9bb2b00c 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -77,11 +77,12 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q } } - // 2. 检查敷衍违规套话 + // 2. 检查敷衍违规套话(否定性自检/禁令复述不计入,避免门禁自污染) for _, phrase := range ProhibitedPhrases { - if strings.Contains(content, phrase) { + if containsProhibitedUsage(content, phrase) { report.ProhibitedPhrases = append(report.ProhibitedPhrases, phrase) - report.Violations = append(report.Violations, fmt.Sprintf("检测到敷衍违规词: '%s'", phrase)) + // 违规说明勿回写禁用原词,避免修复指令/自检再次自污染 + report.Violations = append(report.Violations, "检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)") report.Score -= 20.0 report.Passed = false } @@ -155,6 +156,77 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q 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) { + return true + } + searchFrom = abs + len(phrase) + } +} + +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] + + metaMarkers := []string{ + "写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写", + "负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准", + } + for _, m := range metaMarkers { + if strings.Contains(line, m) { + return true + } + } + + prefixStart := idx - 48 + if prefixStart < 0 { + prefixStart = 0 + } + prefix := content[prefixStart:idx] + negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"} + for _, n := range negMarkers { + if strings.Contains(prefix, n) { + return true + } + } + // 现场形态:无"不做展开""从略""篇幅有限"等敷衍套话 + if strings.Contains(prefix, "无\"") || strings.Contains(prefix, "无「") || strings.Contains(prefix, "无'") || + strings.Contains(prefix, "无“") { + return true + } + _ = phrase + return false +} + // GenerateManifest 扫描工作区核心文件生成 SHA-256 指纹与防篡改审计清单 func (q *QualityGate) GenerateManifest(wsDir string, docKey string, report QualityReport) (*Manifest, error) { now := time.Now() @@ -276,7 +348,7 @@ func (q *QualityGate) EnsureQualityOrRepair( acceptCriteria = append(acceptCriteria, "补齐缺失章节: "+strings.Join(report.MissingSections, ", ")) } if len(report.ProhibitedPhrases) > 0 { - acceptCriteria = append(acceptCriteria, "清除敷衍违规词: "+strings.Join(report.ProhibitedPhrases, ", ")) + acceptCriteria = append(acceptCriteria, "清除正文中实际使用的省略/敷衍写法;自检表勿复述禁用原词") } for _, v := range report.Violations { acceptCriteria = append(acceptCriteria, "解决违规点: "+v) @@ -302,7 +374,7 @@ func (q *QualityGate) EnsureQualityOrRepair( 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(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v))) } // 治理:停滞升级——上轮修复产物与再上轮几乎相同,注入反重复指令换策略 if stallHinted { diff --git a/internal/squad/quality_gate_test.go b/internal/squad/quality_gate_test.go index 0fc71fbe..7fe224b2 100644 --- a/internal/squad/quality_gate_test.go +++ b/internal/squad/quality_gate_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -201,3 +202,69 @@ func TestQualityGate_SystemReqsAndTestManager(t *testing.T) { t.Errorf("expected compliant test_manager to pass, got violations: %v", rep4.Violations) } } + +// 现场证据 output.md:204 — 终稿自检表否定性引用禁词,不得误报为敷衍违规。 +func TestQualityGate_NegatedSelfCheckCitation_NoFalsePositive(t *testing.T) { + tmpDir := t.TempDir() + engine := squad.NewWorkspaceEngine() + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: tmpDir, + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + ProjectName: "医疗设备", + TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n", + }) + if err != nil { + t.Fatalf("PrepareWorkspace: %v", err) + } + gate := squad.NewQualityGate() + + // 与 data/dev/workspaces/bizdev/intake_1789308825666278807/output.md 第204行同构 + realChecklistLine := `| 写作禁令遵守情况 | ✅ 通过 | 无"不做展开""从略""篇幅有限"等敷衍套话 |` + content := `# 用户初始需求收集表 + +## 1. 客户与项目画像 +本项目客户为专业医疗机构〔材料: 需求表 §1〕。四段式展开详细论述客户背景与项目边界。 + +## 2. 预期用途 +用于临床辅助诊断与生理信号监测,覆盖病房与门诊场景。 + +## 3. 待确认清单 +- 供电模块认证周期待窗口确认。 + +### 附录C:审核记录(Review Record) + +| 审核项 | 审核结论 | 说明 | +|-------|---------|------| +| 全文逻辑一致性 | ✅ 通过 | 结论→正文→解读完整 | +` + realChecklistLine + "\n" + + report, err := gate.Validate(ws.WorkDir, "intake", content) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if !report.Passed { + t.Fatalf("negated self-check must not fail gate; violations=%v phrases=%v score=%.0f", + report.Violations, report.ProhibitedPhrases, report.Score) + } + if len(report.ProhibitedPhrases) != 0 { + t.Fatalf("expected no prohibited hits on meta citation, got %v", report.ProhibitedPhrases) + } + + // 真敷衍仍须命中 + hedging := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处篇幅有限,不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n" + bad, err := gate.Validate(ws.WorkDir, "intake", hedging) + if err != nil { + t.Fatalf("Validate hedging: %v", err) + } + if bad.Passed || len(bad.ProhibitedPhrases) == 0 { + t.Fatalf("real hedging must fail; passed=%v phrases=%v", bad.Passed, bad.ProhibitedPhrases) + } + for _, v := range bad.Violations { + for _, p := range squad.ProhibitedPhrases { + if strings.Contains(v, p) { + t.Fatalf("violation text must not echo banned phrase %q: %s", p, v) + } + } + } +} diff --git a/internal/squad/repair_budget_test.go b/internal/squad/repair_budget_test.go new file mode 100644 index 00000000..a72f32aa --- /dev/null +++ b/internal/squad/repair_budget_test.go @@ -0,0 +1,25 @@ +package squad + +import "testing" + +func TestRepairBudgetForSquadTask_ProductContract(t *testing.T) { + // 受控文档小队任务:即使客户端省略 auto_repair=false,也必须给修复预算 + got := repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: false}) + if got != DefaultMaxRepairRounds { + t.Fatalf("intake AutoRepair=false budget=%d want %d", got, DefaultMaxRepairRounds) + } + if DefaultMaxRepairRounds < 1 { + t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds) + } + + // 无 TaskKey 且未开 AutoRepair:保持 0(非受控路径) + got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: false}) + if got != 0 { + t.Fatalf("empty TaskKey without AutoRepair budget=%d want 0", got) + } + + got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: true}) + if got != DefaultMaxRepairRounds { + t.Fatalf("AutoRepair=true budget=%d want %d", got, DefaultMaxRepairRounds) + } +} diff --git a/internal/squad/roles.go b/internal/squad/roles.go index 1bbefada..0c0f28fb 100644 --- a/internal/squad/roles.go +++ b/internal/squad/roles.go @@ -107,7 +107,7 @@ func (r *RoleRegistry) registerDefaults() { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满", "撰写正式交付文档正文终稿"}, - ForbiddenActions: []string{"严禁写不做展开或从略", "严禁随意篡改前序事实"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"}, InstructionsFile: "configs/agents/writer.md", }, { diff --git a/internal/squad/service.go b/internal/squad/service.go index 9437c2f3..aa4b56ee 100644 --- a/internal/squad/service.go +++ b/internal/squad/service.go @@ -267,10 +267,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR if streamer != nil { streamer.EmitPhase("quality_gate", "审核主管正在核验交付物规范并执行质量门禁把关...", "leader", 0, 0, 85) } - maxRetries := 0 - if req.AutoRepair { - maxRetries = 2 - } + maxRetries := repairBudgetForSquadTask(req) report, err := s.gate.EnsureQualityOrRepair(ctx, workDir, req.TaskKey, s.driver, backend, maxRetries, execOpts) if err != nil { // 记录门禁错误但容错返回当前正文 @@ -298,6 +295,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR if backendName == "stub" { finalBytes, _ = os.ReadFile(filepath.Join(workDir, "template.md")) _ = os.WriteFile(finalOutPath, finalBytes, 0o644) + // stub 终稿以 template 为准:门禁须对齐最终交付物,避免对中间草稿误伤 Success/Materialize + if reReport, reErr := s.gate.Validate(workDir, req.TaskKey, string(finalBytes)); reErr == nil { + report = reReport + manifest, _ = s.gate.GenerateManifest(workDir, req.TaskKey, *report) + } } bb, _ := s.driver.LoadBlackboard(workDir) @@ -354,13 +356,25 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR WorkDir: workDir, BackendUsed: backendName, Duration: time.Since(start), - Success: execRes.Success, + Success: execRes.Success && (report == nil || report.Passed), QualityReport: report, Manifest: manifest, AdvisorBrief: advisorBrief, }, nil } +// repairBudgetForSquadTask 产品契约:有 TaskKey 的受控文档小队任务默认启用修复轮预算, +// 不依赖客户端 auto_repair(浏览器可省略且默认为 false)。 +func repairBudgetForSquadTask(req SquadRunRequest) int { + if strings.TrimSpace(req.TaskKey) != "" { + return DefaultMaxRepairRounds + } + if req.AutoRepair { + return DefaultMaxRepairRounds + } + return 0 +} + // ExecuteWorkflow 执行既有工作区工作流 func (s *Service) ExecuteWorkflow(ctx context.Context, wsDir string, backendName string, opts ExecuteOptions) (*SquadRunResult, error) { start := time.Now() diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index d72e4ea7..396d7e17 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -299,7 +299,7 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar // 3. agent.md agentContent := req.AgentContext if strings.TrimSpace(agentContent) == "" { - agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作、禁止'不做展开'、'从略'\n" + agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作;各章节须实质展开,不得以篇幅或省略写法跳过应写内容\n" } _ = os.WriteFile(filepath.Join(ctxDir, "agent.md"), []byte(agentContent), 0o644) contextItems = append(contextItems, ContextItem{ @@ -538,7 +538,7 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq taskBuilder.WriteString("## 交付契约与写作纪律\n") taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName)) taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n") - taskBuilder.WriteString("2. 禁止使用'不做展开'、'从略'、'篇幅有限'等省略敷衍套话。\n") + taskBuilder.WriteString("2. 各章节必须实质展开;禁止以篇幅借口或省略写法跳过应写细节(自检时勿复述禁用原词)。\n") taskBuilder.WriteString("3. 关键事实必须标注出处,未知项列入待确认清单。\n") if step.Role == "organizer" { taskBuilder.WriteString("4. 从材料提取的客观字段,请在当前工作目录下输出 `fields.json`。\n") @@ -585,7 +585,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满"}, - ForbiddenActions: []string{"严禁写不做展开或从略", "严禁随意篡改前序事实"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"}, }, { Role: "leader", @@ -633,7 +633,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"按计划合成调研报告", "全面四段式展开"}, - ForbiddenActions: []string{"严禁写不做展开或从略"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节"}, }, { Role: "leader", @@ -681,7 +681,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格按 system_reqs.md 模板成文", "正文结构完整", "填满追溯表"}, - ForbiddenActions: []string{"严禁残留占位符", "严禁写不做展开"}, + ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, }, { Role: "leader", @@ -729,7 +729,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"严格按 test_manager.md 模板成文", "用例主表翔实完整"}, - ForbiddenActions: []string{"严禁残留占位符", "严禁写不做展开"}, + ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, }, { Role: "leader", @@ -797,7 +797,7 @@ func DefaultWorkflowSteps(taskKey string) []StepSpec { Needs: []string{"tasks/01_organize/output.md"}, Accept: []string{ "按 template.md 各章节严格四段式展开", - "正文翔实,禁止写'不做展开'", + "正文翔实,各节须实质展开", "文末集中附录字段快照与待确认清单", }, }, diff --git a/internal/squad/workspace_test.go b/internal/squad/workspace_test.go index 2964bc31..4bd8ab06 100644 --- a/internal/squad/workspace_test.go +++ b/internal/squad/workspace_test.go @@ -58,7 +58,7 @@ func TestWorkspaceEngine_PrepareWorkspace(t *testing.T) { Tools: []string{"read", "write", "edit"}, BashPermission: "deny", Responsibilities: []string{"撰写正文"}, - ForbiddenActions: []string{"严禁写不做展开"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节"}, }, { Role: "leader", diff --git a/templates/docs/feasibility_dev.md b/templates/docs/feasibility_dev.md index 58076cde..ee66d70a 100644 --- a/templates/docs/feasibility_dev.md +++ b/templates/docs/feasibility_dev.md @@ -6,7 +6,7 @@ ## 结论 -(技术可行性判断、最关键工程风险、Go / 有条件 Go / No-Go 建议及否决条件。不替用户拍板。后文工程化差距与风险表必须支撑本结论。禁止「不做展开」。) +(技术可行性判断、最关键工程风险、Go / 有条件 Go / No-Go 建议及否决条件。不替用户拍板。后文工程化差距与风险表必须支撑本结论。禁止以省略写法跳过应写内容。) ## 技术说明 diff --git a/templates/docs/feasibility_reg.md b/templates/docs/feasibility_reg.md index 1caaf460..57d0e4c1 100644 --- a/templates/docs/feasibility_reg.md +++ b/templates/docs/feasibility_reg.md @@ -6,7 +6,7 @@ ## 结论 -(推荐路径、最关键不确定项、该路径的失效条件。明确写「需向审评窗口或第三方法规顾问确认」。不承诺取证时间。后文分类、路径对比、临床策略必须支撑本结论。禁止「不做展开」。) +(推荐路径、最关键不确定项、该路径的失效条件。明确写「需向审评窗口或第三方法规顾问确认」。不承诺取证时间。后文分类、路径对比、临床策略必须支撑本结论。禁止以省略写法跳过应写内容。) ## 分类判定 diff --git a/templates/docs/intake.md b/templates/docs/intake.md index 67e5938c..14bc0f51 100644 --- a/templates/docs/intake.md +++ b/templates/docs/intake.md @@ -6,11 +6,11 @@ ## 结论 -(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或写「不做展开」。) +(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或以省略写法跳过应写内容。) ## 产品与使用场景 -(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止「本节不做展开」「略」等省略套话。) +(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止用省略套话跳过本节。) ## 客户诉求与约束 diff --git a/templates/docs/research.md b/templates/docs/research.md index 2a400934..5500bfd5 100644 --- a/templates/docs/research.md +++ b/templates/docs/research.md @@ -6,7 +6,7 @@ ## 结论 -(先写对项目的直接含义,再点市场格局、关键竞品、法规要点。每条含义对应后文一节,不要与后文打架。找不到的写「未检索到公开信息」及已尝试路径。禁止「不做展开」「略」。) +(先写对项目的直接含义,再点市场格局、关键竞品、法规要点。每条含义对应后文一节,不要与后文打架。找不到的写「未检索到公开信息」及已尝试路径。禁止以省略写法跳过应写内容。) ## 市场 @@ -18,7 +18,7 @@ ## 竞品对比 -(按技术路线分组。表格至少填已知行;空表须说明为何为空。性能列标注厂商宣称或文献报道。正文解读:谁接近本产品、差异在哪、对定价和注册的含义。禁止「竞品不做展开」。) +(按技术路线分组。表格至少填已知行;空表须说明为何为空。性能列标注厂商宣称或文献报道。正文解读:谁接近本产品、差异在哪、对定价和注册的含义。禁止对竞品对比一笔带过。) | 产品/厂商 | 技术路线 | 关键性能(厂商宣称) | 获批状态 | 备注 | | --- | --- | --- | --- | --- | diff --git a/web/src/App.tsx b/web/src/App.tsx index 4e29cd22..ee4381b9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2662,7 +2662,10 @@ export default function App() { taskWorkspaceId: ev.done?.workspace_id, ...(finishedRunId ? { taskRunId: finishedRunId } : {}), }); - await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId); + // quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换 + if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') { + await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId); + } await chatWindow.refresh().catch(() => undefined); })(); break; @@ -2695,7 +2698,7 @@ export default function App() { } if (remote && isRemoteTaskTerminal(remote.status)) { settled = true; - const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'completed'; + const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed'; patchAsst({ status: mapped, livePhaseLabel: undefined, @@ -2703,7 +2706,9 @@ export default function App() { taskRunId: existingRunId, }); taskRunStream.setThinking(sessionId, false); - await syncControlledDocsAfterTask(task.taskKey || 'intake'); + if (mapped === 'completed' || mapped === 'pending_author_confirmation') { + await syncControlledDocsAfterTask(task.taskKey || 'intake'); + } return; } } @@ -2737,7 +2742,18 @@ export default function App() { if (runId) { try { const remote = await getTaskRun(runId); - if (remote.status === 'completed' || remote.status === 'quality_failed') { + if (remote.status === 'quality_failed') { + settled = true; + patchAsst({ + status: 'quality_failed', + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: runId, + }); + taskRunStream.setThinking(sessionId, false); + return; + } + if (remote.status === 'completed') { settled = true; patchAsst({ status: 'completed', diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 9049f9cc..aae11add 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -26,6 +26,8 @@ export function mapTaskStatusToMessageStatus( case 'pending_author_confirmation': return 'pending_author_confirmation'; case 'quality_failed': + // 门禁未过必须保持失败态,不得映射 completed + return 'quality_failed'; case 'completed': case '': return 'completed'; @@ -43,6 +45,8 @@ export function assistantTaskStatusLabel(status: AssistantMessageStatus | undefi switch (status) { case 'pending_author_confirmation': return '待编制人确认'; + case 'quality_failed': + return '质量未通过'; case 'failed': return '执行失败'; case 'cancelled': diff --git a/web/src/components/chat/chatWindowArtifact.test.ts b/web/src/components/chat/chatWindowArtifact.test.ts index 9d06ef80..2357e0ef 100644 --- a/web/src/components/chat/chatWindowArtifact.test.ts +++ b/web/src/components/chat/chatWindowArtifact.test.ts @@ -30,7 +30,9 @@ describe('mapRecordToMessage artifact projection', () => { expect(msg.content).not.toBe('任务执行中…'); expect(msg.taskArtifact?.doc_id).toBe('doc_proj56_intake'); - expect(msg.status).toBe('completed'); + expect(msg.status).toBe('quality_failed'); + expect(msg.status).not.toBe('completed'); + expect(assistantTaskStatusLabel(msg.status)).toBe('质量未通过'); }); it('maps pending_author_confirmation after history refresh (not completed)', () => { @@ -101,7 +103,7 @@ describe('mapRecordToMessage artifact projection', () => { 'pending_author_confirmation', ); expect(mapTaskStatusToMessageStatus('completed', 'assistant')).toBe('completed'); - expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('completed'); + expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('quality_failed'); expect(mapTaskStatusToMessageStatus('failed', 'assistant')).toBe('failed'); expect(mapTaskStatusToMessageStatus('running', 'assistant')).toBe('streaming_status'); expect(mapTaskStatusToMessageStatus('pending_author_confirmation', 'user')).toBeUndefined(); diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 2a096d29..a351c542 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -116,6 +116,7 @@ export type AssistantMessageStatus = | 'completed' | 'pending_author_confirmation' | 'failed' + | 'quality_failed' | 'cancelled'; export interface ChatErrorInfo {