Move bizdev/freedoc/intent/projectworkflow/squad giant files into ≤800-line use-case siblings without behavior change; short tests green.
313 lines
13 KiB
Go
313 lines
13 KiB
Go
package bizdev
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"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
|
||
}
|
||
|
||
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}
|
||
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,
|
||
AutoCommitUserID: req.UserID,
|
||
})
|
||
if matErr != nil {
|
||
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 执行指定成熟度阶段
|