package bizdev import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time" "chat-agent-system/internal/app/uow" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" "chat-agent-system/internal/workflowdef" ) 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 } // squadCommitActor 让受控账本提交通过编制人职责门。 // 触发者不是编制人时,以文档编制人作为 AutoCommitUserID,并把触发者写入 OnBehalfOf。 // 触发者或编制人缺失时保持原触发者,避免把 0 写成代理身份。 func squadCommitActor(triggerUserID int64, docRec *docgov.DocumentRecord) (int64, *int64) { if docRec == nil || docRec.AuthorID <= 0 || triggerUserID <= 0 || docRec.AuthorID == triggerUserID { return triggerUserID, nil } onBehalf := triggerUserID return docRec.AuthorID, &onBehalf } func qualityFailedErrorReason(report *squad.QualityReport) string { if report == nil { return "质量门禁未通过" } if len(report.Violations) > 0 { return "质量门禁未通过:" + strings.Join(report.Violations, ";") } return fmt.Sprintf("质量门禁未通过(评分 %.0f/100)", report.Score) } // finalizeSquadDeliverable 处理小队返回后的受控文档投影边界。 // 质量失败必须在 SetDocumentWorkDir / Materialize / MarkDocumentContentUpdated / 完成活动之前短路。 func (s *Service) finalizeSquadDeliverable( ctx context.Context, req TaskRunRequest, res *squad.SquadRunResult, docID string, docRec *docgov.DocumentRecord, appliedProposalID string, upstreamSRDoc *docgov.DocumentRecord, ) (*TaskRunResult, error) { if res == nil { return &TaskRunResult{TaskKey: req.TaskKey, Success: false, ErrorReason: "小队无返回结果"}, nil } // 质量门禁短路:保留隔离工作区与可见失败摘要,不改正式 doc work_dir/head/hash,不激活账本版本。 if qualityBlocksControlledProjection(res.QualityReport) { return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: qualityFailedErrorReason(res.QualityReport), QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief, DocRecord: docRec, }, nil } // RecordActivity for "完成交付物" must NOT run here: Materialize may still fail or // 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 { _ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir) dataRoot := "." if s.cfg != nil && s.cfg.DataDir != "" { dataRoot = s.cfg.DataDir } // DOCREV: materialize first; only committed heads may update DocGov hash / proposal applied. // Squad may have already written candidate body to workDir/output.md — pending must restore ledger head. if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" { actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} commitUserID, onBehalfOf := squadCommitActor(req.UserID, docRec) mat, matErr := s.materializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody, ProjectID: req.ProjectID, WorkDir: res.WorkDir, ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor, OnBehalfOf: onBehalfOf, AutoCommitUserID: commitUserID, }) if matErr != nil { reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr) if errors.Is(matErr, uow.ErrPermissionDenied) { reason = fmt.Sprintf("触发用户非文档编制人,账本提交被拒: %v", matErr) } return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: reason, QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief, DocRecord: docRec, }, matErr } if mat != nil && mat.Candidate != nil && mat.Candidate.Status == docrevision.CandidateNeedsConfirmation { if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil { reason := fmt.Sprintf("高风险待确认:恢复账本 head 到事实源失败: %v", err) return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: reason, DocRecord: docRec, }, err } headSHA := "" if mat.State != nil { headSHA = mat.State.HeadSHA256 } if headSHA != "" { if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil { reason := fmt.Sprintf("高风险待确认:恢复 document_records 哈希失败: %v", err) return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: reason, DocRecord: docRec, }, err } } if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { docRec = latest } risk := "" if mat.Report != nil { risk = mat.Report.RiskLevel } var pendCand, pendRep int64 if mat.Candidate != nil { pendCand = mat.Candidate.ID if mat.Candidate.ChangeReportID != nil { pendRep = *mat.Candidate.ChangeReportID } } note := fmt.Sprintf( "高风险变更(%s)待编制人确认:候选 #%d。正式正文仍为当前账本 head,候选正文仅保留在不可变快照中;请打开「待确认报告」确认提交或拒绝。", risk, pendCand, ) s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, fmt.Sprintf("交付物【%s】候选待编制人确认", res.ArtifactTitle), map[string]any{ "task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID, "pending_agent_candidate_id": pendCand, "pending_change_report_id": pendRep, "pending_risk_level": risk, "awaiting_author_confirmation": true, }, true) return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: true, QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief, DocRecord: docRec, // RequiresAuthorApproval signals "needs human decision"; distinguish from DocGov // proposal intercept via PendingAgentCandidateID (no PendingProposal). RequiresAuthorApproval: true, PendingAgentCandidateID: pendCand, PendingChangeReportID: pendRep, PendingRiskLevel: risk, PendingConfirmationNote: note, }, nil } // Committed (auto-commit or ready path): project head then update DocGov. if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil { reason := fmt.Sprintf("账本已提交但工作区投影失败: %v", err) return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: reason, DocRecord: docRec, }, err } headSHA := "" if mat != nil && mat.State != nil { headSHA = mat.State.HeadSHA256 } if headSHA == "" { sha := sha256.Sum256([]byte(res.MarkdownBody)) headSHA = hex.EncodeToString(sha[:]) } if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil { reason := fmt.Sprintf("账本已提交但 DocGov 哈希投影失败: %v", err) return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: reason, DocRecord: docRec, }, err } if appliedProposalID != "" { if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); err != nil { reason := fmt.Sprintf("账本已提交但变更提案标记失败(可重试): %v", err) return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: reason, DocRecord: docRec, }, err } } if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { docRec = latest } s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle), map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true) } else { // No docrevision: preserve prior best-effort hash update (CLI/legacy). sha := sha256.Sum256([]byte(res.MarkdownBody)) shaStr := hex.EncodeToString(sha[:]) _ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false) if appliedProposalID != "" { if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); err != nil { reason := fmt.Sprintf("正文已更新但变更提案标记失败(可重试): %v", err) return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: false, ErrorReason: reason, DocRecord: docRec, }, err } } if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { docRec = latest } s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle), map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true) } } else if res.Success { // Uncontrolled / no DocGov path: squad success is the completion boundary. s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle), map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir}, true) } if res.Success && workflowdef.SameTaskKey(req.TaskKey, "test_manager") && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 { _ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{ ProjectID: req.ProjectID, DownstreamDocID: docID, DownstreamVersion: lineageVersion(docRec), UpstreamDocID: upstreamSRDoc.DocID, UpstreamVersion: lineageVersion(upstreamSRDoc), UpstreamSHA256: upstreamSRDoc.ContentSHA256, IsStale: false, }) } // Stale propagation only after fact source committed (not while pending confirmation). if res.Success && workflowdef.SameTaskKey(req.TaskKey, "system_reqs") && s.projectWorkflow != nil && req.ProjectID > 0 && (s.docRev == nil || (docRec != nil && docRec.ContentSHA256 != "")) { sha := sha256.Sum256([]byte(res.MarkdownBody)) shaStr := hex.EncodeToString(sha[:]) if docRec != nil && docRec.ContentSHA256 != "" { shaStr = docRec.ContentSHA256 } _, _ = s.projectWorkflow.CheckAndPropagateStale(ctx, docID, shaStr, "上游系统需求规格书已更新,下游验证方案需复核") } return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, Success: res.Success, ErrorReason: res.ErrorReason, QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief, DocRecord: docRec, }, nil } // recordSquadDeliveryActivity writes project activity only for explicit outcomes. // Callers must not invoke this for Materialize/projection failures. func (s *Service) recordSquadDeliveryActivity(ctx context.Context, req TaskRunRequest, artifactTitle, title string, detail map[string]any, milestone bool) { if s == nil || s.ctxMgr == nil || req.ProjectID <= 0 || strings.TrimSpace(title) == "" { return } if detail == nil { detail = map[string]any{} } if _, ok := detail["task_key"]; !ok && req.TaskKey != "" { detail["task_key"] = req.TaskKey } if artifactTitle != "" { detail["artifact_title"] = artifactTitle } _, _ = s.ctxMgr.RecordActivity(ctx, req.ProjectID, req.UserID, "系统智能体小队", "operation", title, detail, 1.0, milestone) } // ExecuteStage 执行指定成熟度阶段