From 2d86d330e5016e2c666e4093b81e20bd57780061 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:19:20 +0000 Subject: [PATCH] fix(docgov): keep squad AutoRepair/Success semantics; force repair only on controlled docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小队修复预算只认 AutoRepair,Success 只表示执行完成;bizdev 在解析出 docRec 后强制开启修复,投影与阶段停链仍以 QualityReport 为准。 Co-authored-by: Cursor --- ...nt-revision-quality-gate-self-pollution.md | 2 +- ...09-13-document-revision-sse-idempotency.md | 1 + internal/bizdev/quality_projection_test.go | 13 ++++++ internal/bizdev/service.go | 10 ++++- internal/bizdev/stage_halt.go | 2 +- internal/bizdev/stage_quality_stop_test.go | 5 ++- internal/squad/repair_budget_test.go | 18 +++----- internal/squad/service.go | 43 +++++++++---------- 8 files changed, 55 insertions(+), 39 deletions(-) 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 index 1049bffd..2e20aff8 100644 --- 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 @@ -30,7 +30,7 @@ severity: high ## 整改(同分支 `fix/document-revision-sse-idempotency`) -- A:有 `TaskKey` 的受控小队任务服务端强制 `DefaultMaxRepairRounds`;handlers 产品契约 `AutoRepair: true` +- A:小队 `repairBudget` 只认 `AutoRepair`;bizdev 在解析出 `docID`+`docRec` 后强制开启;受控 HTTP `task_run_handlers` 仍设 `true`。小队 `Success` 仅表示执行完成,投影/阶段停链看 `QualityReport` - B:prompt 去原词;豁免仅限同一自检/规则行且同时具备 meta marker + 否定紧邻或引号引用(禁止 48 字窗口遇「无」即放过) - C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 - D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 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 8c7e2ca1..43faba04 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 @@ -39,6 +39,7 @@ mutex: docgov 9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用) 10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定 11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc +12. [x] 小队 AutoRepair/Success 语义恢复;受控修复仅在 bizdev docRec 与 HTTP 边界强制 ## 验收标准 diff --git a/internal/bizdev/quality_projection_test.go b/internal/bizdev/quality_projection_test.go index b0784d7b..13732b16 100644 --- a/internal/bizdev/quality_projection_test.go +++ b/internal/bizdev/quality_projection_test.go @@ -9,10 +9,23 @@ import ( "time" "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/squad" ) +func TestAutoRepairForControlledSquad(t *testing.T) { + if autoRepairForControlledSquad(false, "", nil) { + t.Fatal("uncontrolled must respect AutoRepair=false") + } + if !autoRepairForControlledSquad(true, "", nil) { + t.Fatal("uncontrolled AutoRepair=true must stay true") + } + if !autoRepairForControlledSquad(false, "doc_proj1_intake", &docgov.DocumentRecord{DocID: "doc_proj1_intake"}) { + t.Fatal("resolved controlled doc must force AutoRepair") + } +} + func TestQualityBlocksControlledProjection(t *testing.T) { if qualityBlocksControlledProjection(nil) { t.Fatal("nil report must not block") diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 66fdd254..b2e5c60c 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -698,7 +698,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun Timeout: req.Timeout, WorkDir: req.WorkDir, CustomSteps: req.CustomSteps, - AutoRepair: req.AutoRepair, + AutoRepair: autoRepairForControlledSquad(req.AutoRepair, docID, docRec), KnowledgeHits: hits, PriorDocs: priorDocs, UserContext: userCtxStr, @@ -735,6 +735,14 @@ func qualityBlocksControlledProjection(report *squad.QualityReport) bool { return report != nil && !report.Passed } +// autoRepairForControlledSquad 已解析出正式受控文档后强制开启修复;非受控调用仍尊重请求值。 +func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docgov.DocumentRecord) bool { + if strings.TrimSpace(docID) != "" && docRec != nil { + return true + } + return reqAutoRepair +} + func qualityFailedErrorReason(report *squad.QualityReport) string { if report == nil { return "质量门禁未通过" diff --git a/internal/bizdev/stage_halt.go b/internal/bizdev/stage_halt.go index 27f99bda..e3fed066 100644 --- a/internal/bizdev/stage_halt.go +++ b/internal/bizdev/stage_halt.go @@ -20,7 +20,7 @@ func (s *Service) executeStageTask(ctx context.Context, req TaskRunRequest) (*Ta } // classifyStageTaskHalt 阶段链在 ExecuteTask err=nil 后的中止判定。 -// 质量失败(含仅 Success=false + QualityReport.Passed=false)必须停链: +// 质量失败以 QualityReport 为准(小队 Success 仍可表示执行完成),必须停链: // 不吸收 ExtractedData、不追加 PriorDoc、不宣称阶段成功。 func classifyStageTaskHalt(res *TaskRunResult, tKey string) *stageTaskHalt { if res == nil { diff --git a/internal/bizdev/stage_quality_stop_test.go b/internal/bizdev/stage_quality_stop_test.go index 80bce120..5237e979 100644 --- a/internal/bizdev/stage_quality_stop_test.go +++ b/internal/bizdev/stage_quality_stop_test.go @@ -82,8 +82,9 @@ func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t sentinelKey: "poison_from_quality_fail", "device_type": "should_not_land_on_blackboard", }, - WorkDir: filepath.Join(stageWork, "research"), - Success: false, + WorkDir: filepath.Join(stageWork, "research"), + // 小队执行完成可为 true;阶段停链看 QualityReport,不依赖 Success=false + Success: true, ErrorReason: "质量门禁未通过:省略/敷衍写法", QualityReport: &squad.QualityReport{ Passed: false, diff --git a/internal/squad/repair_budget_test.go b/internal/squad/repair_budget_test.go index a72f32aa..53b8da22 100644 --- a/internal/squad/repair_budget_test.go +++ b/internal/squad/repair_budget_test.go @@ -2,24 +2,20 @@ package squad import "testing" -func TestRepairBudgetForSquadTask_ProductContract(t *testing.T) { - // 受控文档小队任务:即使客户端省略 auto_repair=false,也必须给修复预算 +func TestRepairBudgetForSquadTask_RespectsAutoRepairOnly(t *testing.T) { got := repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: false}) - if got != DefaultMaxRepairRounds { - t.Fatalf("intake AutoRepair=false budget=%d want %d", got, DefaultMaxRepairRounds) + if got != 0 { + t.Fatalf("TaskKey alone must not enable repair: budget=%d", got) } - 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}) + got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: true}) if got != DefaultMaxRepairRounds { t.Fatalf("AutoRepair=true budget=%d want %d", got, DefaultMaxRepairRounds) } + if DefaultMaxRepairRounds < 1 { + t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds) + } } diff --git a/internal/squad/service.go b/internal/squad/service.go index aa4b56ee..1a250c05 100644 --- a/internal/squad/service.go +++ b/internal/squad/service.go @@ -32,22 +32,22 @@ type Attachment struct { // SquadRunRequest 运行请求 type SquadRunRequest struct { - TaskKey string `json:"task_key"` - Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle) - ProjectName string `json:"project_name"` - CustomerName string `json:"customer_name"` - DeviceType string `json:"device_type"` - IntendedUse string `json:"intended_use"` - TargetMarket string `json:"target_market"` - Fields map[string]string `json:"fields"` - Materials []MaterialItem `json:"materials"` - Backend string `json:"backend"` - Timeout time.Duration `json:"timeout"` - WorkDir string `json:"work_dir"` - CustomSteps []StepSpec `json:"custom_steps"` - AutoRepair bool `json:"auto_repair"` - KnowledgeHits []KnowledgeHit `json:"knowledge_hits"` - PriorDocs []PriorDocItem `json:"prior_docs"` + TaskKey string `json:"task_key"` + Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle) + ProjectName string `json:"project_name"` + CustomerName string `json:"customer_name"` + DeviceType string `json:"device_type"` + IntendedUse string `json:"intended_use"` + TargetMarket string `json:"target_market"` + Fields map[string]string `json:"fields"` + Materials []MaterialItem `json:"materials"` + Backend string `json:"backend"` + Timeout time.Duration `json:"timeout"` + WorkDir string `json:"work_dir"` + CustomSteps []StepSpec `json:"custom_steps"` + AutoRepair bool `json:"auto_repair"` + KnowledgeHits []KnowledgeHit `json:"knowledge_hits"` + PriorDocs []PriorDocItem `json:"prior_docs"` UserContext string `json:"user_context,omitempty"` MemoryBankFiles map[string]string `json:"memory_bank_files,omitempty"` SessionID string `json:"session_id,omitempty"` @@ -295,7 +295,7 @@ 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 + // stub 终稿以 template 为准:QualityReport 对齐最终交付物(Success 仍只表示执行完成) if reReport, reErr := s.gate.Validate(workDir, req.TaskKey, string(finalBytes)); reErr == nil { report = reReport manifest, _ = s.gate.GenerateManifest(workDir, req.TaskKey, *report) @@ -356,19 +356,16 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR WorkDir: workDir, BackendUsed: backendName, Duration: time.Since(start), - Success: execRes.Success && (report == nil || report.Passed), + Success: execRes.Success, QualityReport: report, Manifest: manifest, AdvisorBrief: advisorBrief, }, nil } -// repairBudgetForSquadTask 产品契约:有 TaskKey 的受控文档小队任务默认启用修复轮预算, -// 不依赖客户端 auto_repair(浏览器可省略且默认为 false)。 +// repairBudgetForSquadTask 仅尊重请求方 AutoRepair,不因 TaskKey 非空而改写 CLI/桩/非受控语义。 +// 受控文档任务的修复预算由 bizdev/HTTP 在调用前强制 AutoRepair=true。 func repairBudgetForSquadTask(req SquadRunRequest) int { - if strings.TrimSpace(req.TaskKey) != "" { - return DefaultMaxRepairRounds - } if req.AutoRepair { return DefaultMaxRepairRounds }