KTD1 second step (behavior preserved): - squad defines ProgressSink (EmitPhase/Detail/Deliverable/Summary/Briefing) with its own DetailEvent/ToolEvent types; zero internal/stream imports left - bizdev assembly maps port to SSE streamer (squad_progress_adapter) and injects via ctx at the four squad entry points - process_bridge tests rewritten as port-contract tests (captureSink) - verified: build+vet clean, squad/bizdev/archtest green
768 lines
27 KiB
Go
768 lines
27 KiB
Go
package bizdev
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/conflictresolve"
|
||
"chat-agent-system/internal/contracts/vocabulary"
|
||
"chat-agent-system/internal/docduty"
|
||
"chat-agent-system/internal/docgov"
|
||
"chat-agent-system/internal/docrevision"
|
||
"chat-agent-system/internal/intent"
|
||
"chat-agent-system/internal/kb"
|
||
"chat-agent-system/internal/notification"
|
||
"chat-agent-system/internal/projectcontext"
|
||
"chat-agent-system/internal/projectworkflow"
|
||
"chat-agent-system/internal/squad"
|
||
"chat-agent-system/internal/stream"
|
||
"chat-agent-system/internal/workflowdef"
|
||
"chat-agent-system/internal/workspace"
|
||
)
|
||
|
||
func (s *Service) materializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) {
|
||
if s != nil && s.materializer != nil {
|
||
return s.materializer.MaterializeAgentOutput(ctx, req)
|
||
}
|
||
if s == nil || s.docRev == nil {
|
||
return nil, fmt.Errorf("bizdev: document revision unavailable")
|
||
}
|
||
return s.docRev.MaterializeAgentOutput(ctx, req)
|
||
}
|
||
|
||
var SupportedTasks = squad.SupportedTasks
|
||
|
||
var SupportedStages = []StageMeta{
|
||
{
|
||
Stage: StageV01_Triage,
|
||
Title: "V0.1 闪电摸底与盲区探测",
|
||
Description: "快速梳理客户诉求与约束,建立基准技术假设,生成首期澄清微信话术与初始需求表",
|
||
Tasks: []string{"bd_intake"},
|
||
},
|
||
{
|
||
Stage: StageV05_Feasibility,
|
||
Title: "V0.5 方案成型与法规对齐",
|
||
Description: "法规与注册路径初筛、竞品调研、系统初步架构设计与关键选型建议",
|
||
Tasks: []string{"bd_research", "bd_feasibility_dev", "bd_feasibility_reg", "bd_user_requirements", "bd_prelim_design"},
|
||
},
|
||
{
|
||
Stage: StageV10_Quoting,
|
||
Title: "V1.0 工程拆解与商业锁定",
|
||
Description: "全维度 WBS 工时拆解、Excel 定价核算、正式 CDO 商务报价单与谈判防御条款建议",
|
||
Tasks: []string{"bd_cdo_quote"},
|
||
},
|
||
}
|
||
|
||
func taskIs(key string, wants ...string) bool {
|
||
for _, want := range wants {
|
||
if workflowdef.SameTaskKey(key, want) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func resolveDocStage(req TaskRunRequest) string {
|
||
if sk := strings.TrimSpace(req.StageKey); sk != "" {
|
||
return sk
|
||
}
|
||
switch workflowdef.ImplementationTaskKey(strings.TrimSpace(req.TaskKey)) {
|
||
case "intake":
|
||
return string(StageV01_Triage)
|
||
case "research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design":
|
||
return string(StageV05_Feasibility)
|
||
case "cdo_quote":
|
||
return string(StageV10_Quoting)
|
||
default:
|
||
return string(StageV05_Feasibility)
|
||
}
|
||
}
|
||
|
||
const rdevPrerequisiteMessage = "请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程"
|
||
|
||
func (s *Service) ensureRDevMainCompleted(ctx context.Context, projectID int64) error {
|
||
if projectID <= 0 {
|
||
return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage)
|
||
}
|
||
ok, err := s.hasCompletedMainInstance(ctx, projectID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) hasCompletedMainInstance(ctx context.Context, projectID int64) (bool, error) {
|
||
if s.completedMainHook != nil {
|
||
return s.completedMainHook(ctx, projectID)
|
||
}
|
||
if s.pool == nil {
|
||
return false, nil
|
||
}
|
||
var ok bool
|
||
err := s.pool.QueryRow(ctx, `
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM project_workflow_instances
|
||
WHERE project_id = $1
|
||
AND status = 'completed'
|
||
AND config_snapshot->>'workflow_definition_key' = 'main'
|
||
)
|
||
`, projectID).Scan(&ok)
|
||
return ok, err
|
||
}
|
||
|
||
// ErrUnboundControlledProject 表示受控目录任务没有项目绑定,不能静默落到隔离工作区。
|
||
var ErrUnboundControlledProject = errors.New("会话未绑定项目,受控交付物无法入账")
|
||
|
||
// controlledDeliverableRequiresProject 覆盖发布目录里的受控任务(旧键与新键)和研发设计控制 rd_*。
|
||
// 未知自由任务两边都原样返回,不在此列。
|
||
func controlledDeliverableRequiresProject(taskKey string) bool {
|
||
key := strings.TrimSpace(taskKey)
|
||
if key == "" {
|
||
return false
|
||
}
|
||
if strings.HasPrefix(key, "rd_") {
|
||
return true
|
||
}
|
||
return workflowdef.CanonicalTaskKey(key) != key || workflowdef.ImplementationTaskKey(key) != key
|
||
}
|
||
|
||
func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) {
|
||
if controlledDeliverableRequiresProject(req.TaskKey) && req.ProjectID <= 0 {
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: squad.TaskArtifactTitle(req.TaskKey),
|
||
Success: false,
|
||
ErrorReason: ErrUnboundControlledProject.Error(),
|
||
}, ErrUnboundControlledProject
|
||
}
|
||
|
||
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, req.TaskKey); err != nil {
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
Success: false,
|
||
ErrorReason: err.Error(),
|
||
}, err
|
||
}
|
||
|
||
if strings.HasPrefix(strings.TrimSpace(req.TaskKey), "rd_") {
|
||
if err := s.ensureRDevMainCompleted(ctx, req.ProjectID); err != nil {
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
Success: false,
|
||
ErrorReason: err.Error(),
|
||
}, err
|
||
}
|
||
}
|
||
|
||
materials, inputManifest, err := s.ResolveMaterials(ctx, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if req.RunID != "" && inputManifest != nil {
|
||
inputManifest["run_id"] = req.RunID
|
||
}
|
||
|
||
// 针对 system_reqs(智能产品经理):若未显式提供 materials,自动抽取项目事实黑板/集体上下文为需求输入
|
||
if workflowdef.SameTaskKey(req.TaskKey, "system_reqs") && len(materials) == 0 && s.ctxMgr != nil && req.ProjectID > 0 {
|
||
coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID)
|
||
if err == nil && coll != nil {
|
||
var b strings.Builder
|
||
customer := coll.BlackboardFacts["customer_name"]
|
||
if customer == "" {
|
||
customer = req.CustomerName
|
||
}
|
||
device := coll.BlackboardFacts["device_type"]
|
||
if device == "" {
|
||
device = req.DeviceType
|
||
}
|
||
intendedUse := coll.BlackboardFacts["intended_use"]
|
||
if intendedUse == "" {
|
||
intendedUse = req.IntendedUse
|
||
}
|
||
targetMarket := coll.BlackboardFacts["target_market"]
|
||
if targetMarket == "" {
|
||
targetMarket = req.TargetMarket
|
||
}
|
||
|
||
b.WriteString(fmt.Sprintf("# 项目需求背景事实与上下文输入\n\n- 项目名称: %s\n- 客户名称: %s\n- 产品类型: %s\n- 预期用途: %s\n- 目标市场: %s\n\n",
|
||
coll.ProjectName, customer, device, intendedUse, targetMarket))
|
||
|
||
if len(coll.BlackboardFacts) > 0 {
|
||
b.WriteString("## 已提取项目事实与技术参数\n")
|
||
for k, v := range coll.BlackboardFacts {
|
||
b.WriteString(fmt.Sprintf("- %s: %s\n", k, v))
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
if len(coll.MilestoneDecisions) > 0 {
|
||
b.WriteString("## 关键里程碑与团队决策\n")
|
||
for _, d := range coll.MilestoneDecisions {
|
||
b.WriteString(fmt.Sprintf("- [%s] %s: %s\n", d.UserName, d.ActivityType, d.Title))
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
materials = append(materials, squad.MaterialItem{
|
||
Filename: "project_requirements_context.md",
|
||
Content: []byte(b.String()),
|
||
})
|
||
}
|
||
}
|
||
|
||
// 自动补充知识库检索 (如针对 research、feasibility_reg、cleanroom_spec、prelim_design、system_reqs、test_manager 等阶段)
|
||
hits := req.KnowledgeHits
|
||
if len(hits) == 0 && taskIs(req.TaskKey, "research", "feasibility_reg", "cleanroom_spec", "prelim_design", "system_reqs", "test_manager") {
|
||
device := req.DeviceType
|
||
if device == "" && req.Fields != nil {
|
||
device = req.Fields["device_type"]
|
||
}
|
||
market := req.TargetMarket
|
||
if market == "" && req.Fields != nil {
|
||
market = req.Fields["target_market"]
|
||
}
|
||
searchQuery := strings.TrimSpace(req.ProjectName + " " + device + " " + market)
|
||
if searchQuery != "" {
|
||
streamer := stream.FromContext(ctx)
|
||
if streamer != nil {
|
||
streamer.EmitPhase("knowledge_retrieval", fmt.Sprintf("项目助手正在检索权威法规知识库并筛选适用条款(关键词: %s)...", searchQuery), "regulatory", 0, 0, 10)
|
||
}
|
||
if kbHits, err := s.kb.Search(ctx, searchQuery, kb.SearchOptions{Limit: 5}); err == nil && len(kbHits) > 0 {
|
||
if streamer != nil {
|
||
streamer.EmitPhase("knowledge_retrieved", fmt.Sprintf("法规知识库初筛完成,精准命中 %d 条国家与行业标准条款", len(kbHits)), "regulatory", 0, 0, 15)
|
||
var hitLines []string
|
||
for _, h := range kbHits {
|
||
hitLines = append(hitLines, fmt.Sprintf("- [%s] %s (%s)", h.DocCode, h.Title, h.CiteKey))
|
||
}
|
||
streamer.EmitDetail("knowledge_hits", "knowledge", "regulatory", "命中法规标准清单", strings.Join(hitLines, "\n"), true)
|
||
}
|
||
for _, kh := range kbHits {
|
||
hits = append(hits, squad.KnowledgeHit{
|
||
DocCode: kh.DocCode,
|
||
Category: kh.Category,
|
||
Title: kh.Title,
|
||
HeadingPath: kh.HeadingPath,
|
||
Content: kh.Content,
|
||
CiteKey: kh.CiteKey,
|
||
Source: kh.Source,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 文档治理与认领拦截前置判定
|
||
artifactTitle := squad.TaskArtifactTitle(req.TaskKey)
|
||
docID, docErr := s.ResolveAndValidateDocID(ctx, req)
|
||
if docErr != nil {
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: docErr.Error(),
|
||
}, docErr
|
||
}
|
||
// 智能测试工程师:统一项目上下文快照;SRS 仅为可选高权威来源
|
||
var upstreamSRDoc *docgov.DocumentRecord
|
||
if taskIs(req.TaskKey, "test_manager", "system_reqs") {
|
||
priorDocs := make([]squad.PriorDocItem, 0)
|
||
facts := map[string]string{}
|
||
if req.Fields != nil {
|
||
for k, v := range req.Fields {
|
||
facts[k] = v
|
||
}
|
||
}
|
||
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||
if coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && coll != nil {
|
||
for k, v := range coll.BlackboardFacts {
|
||
facts[k] = v
|
||
}
|
||
}
|
||
}
|
||
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
|
||
if s.docGov != nil && req.ProjectID > 0 {
|
||
if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == vocabulary.StateApproved {
|
||
upstreamSRDoc = doc
|
||
if doc.WorkDir != "" {
|
||
contentPath := filepath.Join(doc.WorkDir, workspace.DeliverableKey)
|
||
if b, readErr := os.ReadFile(contentPath); readErr == nil && len(b) > 0 {
|
||
priorDocs = append(priorDocs, squad.PriorDocItem{
|
||
DocKey: "system_reqs", Title: "系统需求规格书", Content: string(b), Stage: "approved",
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID)
|
||
ctxSvc := projectcontext.NewService(s.pool)
|
||
snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
|
||
ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID,
|
||
ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs,
|
||
Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions,
|
||
})
|
||
if snap != nil {
|
||
snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate
|
||
snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess
|
||
}
|
||
if snap != nil {
|
||
if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 {
|
||
materials = append(materials, extra...)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 研发设计控制:项目上下文快照 + 同项目已批准文档摘要注入
|
||
if strings.HasPrefix(strings.TrimSpace(req.TaskKey), "rd_") && req.ProjectID > 0 {
|
||
priorDocs := make([]squad.PriorDocItem, 0)
|
||
facts := map[string]string{}
|
||
if req.Fields != nil {
|
||
for k, v := range req.Fields {
|
||
facts[k] = v
|
||
}
|
||
}
|
||
if s.ctxMgr != nil {
|
||
if coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && coll != nil {
|
||
for k, v := range coll.BlackboardFacts {
|
||
facts[k] = v
|
||
}
|
||
}
|
||
}
|
||
// 优先注入已批准 SR(PM 或 rd_system_reqs)
|
||
for _, key := range []string{"system_reqs", "pm_system_reqs", "rd_system_reqs"} {
|
||
docID := fmt.Sprintf("doc_proj%d_%s", req.ProjectID, key)
|
||
if s.docGov == nil {
|
||
continue
|
||
}
|
||
doc, err := s.docGov.GetDocument(ctx, docID)
|
||
if err != nil || doc == nil || doc.CurrentState != vocabulary.StateApproved || doc.WorkDir == "" {
|
||
continue
|
||
}
|
||
if b, readErr := os.ReadFile(filepath.Join(doc.WorkDir, workspace.DeliverableKey)); readErr == nil && len(b) > 0 {
|
||
priorDocs = append(priorDocs, squad.PriorDocItem{
|
||
DocKey: key, Title: doc.Title, Content: string(b), Stage: "approved",
|
||
})
|
||
}
|
||
}
|
||
dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID)
|
||
ctxSvc := projectcontext.NewService(s.pool)
|
||
snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
|
||
ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID,
|
||
ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs,
|
||
Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions,
|
||
})
|
||
if snap != nil {
|
||
snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate
|
||
snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess
|
||
if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 {
|
||
materials = append(materials, extra...)
|
||
}
|
||
}
|
||
}
|
||
|
||
duties, dutyPolicy, governed := resolveTaskDocumentDuties(ctx, s.pool, req, s.instanceDuty)
|
||
authorID, authorName := duties.Author.UserID, duties.Author.Name
|
||
reviewerID, reviewerName := duties.Reviewer.UserID, duties.Reviewer.Name
|
||
approverID, approverName := duties.Approver.UserID, duties.Approver.Name
|
||
if authorName == "" {
|
||
authorName = "编制工程师"
|
||
}
|
||
if reviewerName == "" && reviewerID > 0 {
|
||
reviewerName = "审核负责人"
|
||
}
|
||
if approverName == "" && approverID > 0 {
|
||
approverName = "批准负责人"
|
||
}
|
||
|
||
var docRec *docgov.DocumentRecord
|
||
var leaseHolderID string
|
||
var leaseAcquired bool
|
||
var appliedProposalID string
|
||
|
||
if s.docGov != nil && strings.TrimSpace(docID) != "" {
|
||
docStage := resolveDocStage(req)
|
||
var createErr error
|
||
if s.createDocumentHook != nil {
|
||
docRec, createErr = s.createDocumentHook(
|
||
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
|
||
docStage, req.WorkDir,
|
||
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
|
||
)
|
||
} else {
|
||
docRec, createErr = s.docGov.CreateOrGetDocument(
|
||
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
|
||
docStage, req.WorkDir,
|
||
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
|
||
)
|
||
}
|
||
if createErr != nil {
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: "create document ledger failed: " + createErr.Error(),
|
||
DocRecord: docRec,
|
||
}, createErr
|
||
}
|
||
if governed && docRec != nil {
|
||
if lockErr := s.docGov.LockDocumentDuty(ctx, docRec.DocID, dutyPolicy); lockErr != nil {
|
||
log.Printf("[docgov] lock document duty %s failed: %v", docRec.DocID, lockErr)
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: "lock document duty failed: " + lockErr.Error(),
|
||
DocRecord: docRec,
|
||
}, lockErr
|
||
}
|
||
docRec.DutyLocked = true
|
||
docRec.DutyPolicy = dutyPolicy
|
||
}
|
||
if s.pool != nil && req.ProjectID > 0 {
|
||
assigned, _, healErr := docgov.HealProjectDocuments(ctx, s.pool, req.ProjectID)
|
||
if healErr != nil {
|
||
log.Printf("[docduty] heal project %d after generate failed: %v", req.ProjectID, healErr)
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: "document duty heal failed: " + healErr.Error(),
|
||
DocRecord: docRec,
|
||
}, healErr
|
||
}
|
||
// 受实例治理的文档不得用项目默认职责覆盖内存态返回值。
|
||
if !governed && docRec != nil {
|
||
members, creator, loadErr := docduty.LoadProjectMembers(ctx, s.pool, req.ProjectID)
|
||
if loadErr != nil {
|
||
log.Printf("[docduty] load members project %d after generate failed: %v", req.ProjectID, loadErr)
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: "document duty load failed: " + loadErr.Error(),
|
||
DocRecord: docRec,
|
||
}, loadErr
|
||
}
|
||
next, _ := docduty.HealBinding(bindingFromDoc(docRec), assigned, docduty.ActiveMemberIDs(members, creator))
|
||
writeBindingToDoc(docRec, next)
|
||
}
|
||
}
|
||
|
||
if req.ForceDirectEdit {
|
||
if docRec != nil && docRec.IsLocked {
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止 force_direct_edit", docRec.CurrentState),
|
||
DocRecord: docRec,
|
||
}, vocabulary.ErrDocumentLocked
|
||
}
|
||
// 已认领文档必须有编制人已同意的提案;未认领可直接强制覆写
|
||
if docRec != nil && docRec.AuthorClaimed {
|
||
prop, _ := s.docGov.FindApprovedProposal(ctx, docID)
|
||
if prop == nil {
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: "force_direct_edit 需要编制人已同意的变更提案 (approved_by_author)",
|
||
DocRecord: docRec,
|
||
}, vocabulary.ErrForceEditNotAllowed
|
||
}
|
||
appliedProposalID = prop.ProposalID
|
||
}
|
||
} else if docRec != nil {
|
||
checkRes, checkErr := s.docGov.CheckModificationPermission(ctx, docID, vocabulary.RoleSystem)
|
||
if checkErr != nil {
|
||
if errors.Is(checkErr, vocabulary.ErrDocumentLocked) || errors.Is(checkErr, vocabulary.ErrDocumentBusy) {
|
||
reason := checkErr.Error()
|
||
if checkRes != nil && checkRes.Reason != "" {
|
||
reason = checkRes.Reason
|
||
}
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: reason,
|
||
DocRecord: docRec,
|
||
}, checkErr
|
||
}
|
||
}
|
||
if checkRes != nil && checkRes.RequiresProposal {
|
||
// 文档处于已认领状态,必须拦截直接修改,生成变更提案待编制人拍板
|
||
prop := checkRes.PendingProposal
|
||
if prop == nil {
|
||
proposedChanges := fmt.Sprintf("基于项目最新输入【%s - %s】优化交付物技术评估与法规条款", req.DeviceType, req.IntendedUse)
|
||
impactAnalysis := "更新正文关键章节及提取字段,可能对前序设计基线产生联动影响"
|
||
riskAssessment := "修改已认领的交付物结论,需由编制负责人核准技术风险与合规结论"
|
||
prop, _ = s.docGov.ProposeModification(ctx, docID, "agent_squad", proposedChanges, impactAnalysis, riskAssessment)
|
||
}
|
||
|
||
if s.notify != nil && prop != nil && docRec.AuthorID > 0 {
|
||
_ = s.notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
|
||
UserID: docRec.AuthorID,
|
||
ProjectID: req.ProjectID,
|
||
DigestKind: notification.DigestKindProposalRequired,
|
||
DocID: docID,
|
||
DocTitle: artifactTitle,
|
||
Severity: notification.SeverityActionRequired,
|
||
ActorID: req.UserID,
|
||
ActorName: "agent_squad",
|
||
Summary: prop.ProposedChanges,
|
||
ProposalID: prop.ProposalID,
|
||
})
|
||
}
|
||
streamer := stream.FromContext(ctx)
|
||
if streamer != nil {
|
||
streamer.EmitGovernance(stream.GovernancePayload{
|
||
DocID: docID,
|
||
Action: "proposal_required",
|
||
CurrentState: string(docRec.CurrentState),
|
||
IsLocked: docRec.IsLocked,
|
||
AuthorClaimed: docRec.AuthorClaimed,
|
||
ProposalID: prop.ProposalID,
|
||
ProposedChanges: prop.ProposedChanges,
|
||
ImpactSummary: prop.ImpactAnalysis,
|
||
RiskSummary: prop.RiskAssessment,
|
||
NotificationTitle: fmt.Sprintf("文档【%s】已被认领,变更已拦截", artifactTitle),
|
||
NotificationMsg: "系统已拦截智能体小队直接静默覆写,并自动生成变更提案等待编制负责人拍板授权",
|
||
})
|
||
streamer.EmitSummary(
|
||
fmt.Sprintf("⚠️ 文档【%s】变更拦截", artifactTitle),
|
||
fmt.Sprintf("该文档已由编制负责人认领。本次调整需拍板提案 [%s],确认后方可覆写更新。", prop.ProposalID),
|
||
80, "V0.5",
|
||
)
|
||
}
|
||
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: "文档已被编制负责人认领,系统已拦截自动覆写并生成变更提案,请编制负责人拍板确认",
|
||
DocRecord: docRec,
|
||
PendingProposal: prop,
|
||
RequiresAuthorApproval: true,
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
// 通过权限检查后获取小队编辑租约(提案拦截路径不持锁)
|
||
leaseHolderID = strings.TrimSpace(req.RunID)
|
||
if leaseHolderID == "" {
|
||
leaseHolderID = fmt.Sprintf("run_%d", time.Now().UnixNano())
|
||
}
|
||
leaseTTL := req.Timeout
|
||
if leaseTTL <= 0 && s.cfg != nil {
|
||
leaseTTL = s.cfg.AgentTimeout
|
||
}
|
||
if leaseTTL <= 0 {
|
||
leaseTTL = vocabulary.DefaultSquadLeaseTTL
|
||
}
|
||
leased, leaseErr := s.docGov.AcquireLease(ctx, docgov.LeaseAcquireRequest{
|
||
DocID: docID,
|
||
HolderType: vocabulary.LeaseHolderSquad,
|
||
HolderID: leaseHolderID,
|
||
WorkDir: req.WorkDir,
|
||
TTL: leaseTTL,
|
||
})
|
||
if leaseErr != nil {
|
||
reason := leaseErr.Error()
|
||
if leased != nil {
|
||
docRec = leased
|
||
}
|
||
return &TaskRunResult{
|
||
TaskKey: req.TaskKey,
|
||
ArtifactTitle: artifactTitle,
|
||
WorkDir: req.WorkDir,
|
||
Success: false,
|
||
ErrorReason: reason,
|
||
DocRecord: docRec,
|
||
}, leaseErr
|
||
}
|
||
docRec = leased
|
||
leaseAcquired = true
|
||
|
||
streamer := stream.FromContext(ctx)
|
||
if streamer != nil {
|
||
streamer.EmitGovernance(stream.GovernancePayload{
|
||
DocID: docID,
|
||
Action: "lease_acquired",
|
||
CurrentState: string(docRec.CurrentState),
|
||
IsLocked: docRec.IsLocked,
|
||
AuthorClaimed: docRec.AuthorClaimed,
|
||
NotificationTitle: fmt.Sprintf("文档【%s】编辑租约已获取", artifactTitle),
|
||
NotificationMsg: "智能体小队正在生成/更新该文档,期间禁止人工编辑与批注",
|
||
})
|
||
}
|
||
|
||
stopHB := make(chan struct{})
|
||
defer close(stopHB)
|
||
go func() {
|
||
ticker := time.NewTicker(time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-stopHB:
|
||
return
|
||
case <-ticker.C:
|
||
hbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
_, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID, leaseTTL)
|
||
cancel()
|
||
}
|
||
}
|
||
}()
|
||
defer func() {
|
||
if leaseAcquired {
|
||
relCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
_, _ = s.docGov.ReleaseLease(relCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID)
|
||
cancel()
|
||
if streamer := stream.FromContext(ctx); streamer != nil {
|
||
streamer.EmitGovernance(stream.GovernancePayload{
|
||
DocID: docID,
|
||
Action: "lease_released",
|
||
NotificationTitle: fmt.Sprintf("文档【%s】编辑租约已释放", artifactTitle),
|
||
NotificationMsg: "文档已可继续人工编辑或批注",
|
||
})
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// 混合上下文装配 (Context Blender)
|
||
userCtxStr := req.UserContext
|
||
memBank := req.MemoryBankFiles
|
||
if memBank == nil {
|
||
memBank = make(map[string]string)
|
||
}
|
||
|
||
blackboardFacts := map[string]string{}
|
||
if s.ctxMgr != nil {
|
||
blended, err := s.buildBlendedSquadContextForUser(ctx, req.ProjectID, req.UserID, req.TaskKey, "squad")
|
||
if err == nil && blended != nil {
|
||
if userCtxStr == "" && blended.PersonalizedGuidance != "" {
|
||
userCtxStr = blended.PersonalizedGuidance
|
||
}
|
||
if blended.PersonalizedGuidance != "" {
|
||
memBank["user_preferences.md"] = blended.PersonalizedGuidance
|
||
}
|
||
if len(blended.KeyDecisions) > 0 {
|
||
memBank["team_decisions.md"] = "# 项目团队关键决策与里程碑记录\n\n" + strings.Join(blended.KeyDecisions, "\n")
|
||
}
|
||
for k, v := range blended.BlackboardFacts {
|
||
blackboardFacts[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
dialoguePkg, conflictExps, dlgErr := s.buildDialoguePackageForRun(ctx, req, blackboardFacts)
|
||
if dlgErr != nil {
|
||
log.Printf("dialogue_ctx_package_failed project_id=%d err=%v", req.ProjectID, dlgErr)
|
||
}
|
||
|
||
priorDocs := req.PriorDocs
|
||
if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 {
|
||
priorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, priorDocs)
|
||
}
|
||
|
||
squadReq := squad.SquadRunRequest{
|
||
TaskKey: req.TaskKey,
|
||
Title: req.Title,
|
||
ProjectName: req.ProjectName,
|
||
CustomerName: req.CustomerName,
|
||
DeviceType: req.DeviceType,
|
||
IntendedUse: req.IntendedUse,
|
||
TargetMarket: req.TargetMarket,
|
||
Fields: req.Fields,
|
||
Materials: materials,
|
||
Backend: req.Backend,
|
||
Timeout: req.Timeout,
|
||
WorkDir: req.WorkDir,
|
||
CustomSteps: req.CustomSteps,
|
||
AutoRepair: autoRepairForControlledSquad(req.AutoRepair, docID, docRec),
|
||
KnowledgeHits: hits,
|
||
PriorDocs: priorDocs,
|
||
UserContext: userCtxStr,
|
||
MemoryBankFiles: memBank,
|
||
DialoguePackage: dialoguePkg,
|
||
SessionID: req.SessionID,
|
||
UserID: req.UserID,
|
||
ProjectID: req.ProjectID,
|
||
RunID: req.RunID,
|
||
}
|
||
|
||
res, err := s.squad.ExecuteTask(withSquadProgress(ctx), squadReq)
|
||
if err != nil {
|
||
if res != nil {
|
||
out := &TaskRunResult{
|
||
TaskKey: res.TaskKey,
|
||
ArtifactTitle: res.ArtifactTitle,
|
||
WorkDir: res.WorkDir,
|
||
BackendUsed: res.BackendUsed,
|
||
Duration: res.Duration,
|
||
Success: false,
|
||
ErrorReason: res.ErrorReason,
|
||
AdvisorBrief: res.AdvisorBrief,
|
||
}
|
||
appendContextConflictCards(out, req, conflictExps)
|
||
return out, err
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
outRes, finErr := s.finalizeSquadDeliverable(ctx, req, res, docID, docRec, appliedProposalID, upstreamSRDoc)
|
||
if outRes != nil {
|
||
appendContextConflictCards(outRes, req, conflictExps)
|
||
}
|
||
return outRes, finErr
|
||
}
|
||
|
||
// appendContextConflictCards projects trigger_user HITL verdicts onto the run
|
||
// result so the transport layer can surface confirmation cards to the user.
|
||
// Verdicts exist regardless of task success; cards are capped by the engine's
|
||
// MaxCardsPerTask and deduped per (run, fact key).
|
||
func appendContextConflictCards(out *TaskRunResult, req TaskRunRequest, exps []*intent.PendingExpectation) {
|
||
if out == nil || len(exps) == 0 {
|
||
return
|
||
}
|
||
seen := map[string]struct{}{}
|
||
for _, exp := range exps {
|
||
if exp == nil || exp.TargetAction != "confirm_context_fact" {
|
||
continue
|
||
}
|
||
factKey := exp.Payload["fact_key"]
|
||
if _, dup := seen[factKey]; dup {
|
||
continue
|
||
}
|
||
seen[factKey] = struct{}{}
|
||
out.PendingContextConflicts = append(out.PendingContextConflicts, ContextConflictCard{
|
||
ExpectationID: exp.ExpectationID,
|
||
FactKey: factKey,
|
||
WinningValue: exp.Payload["winning_value"],
|
||
Prompt: exp.Prompt,
|
||
Snippet: exp.ContextSnippet,
|
||
})
|
||
if len(out.PendingContextConflicts) >= conflictresolve.MaxCardsPerTask {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// qualityBlocksControlledProjection 质量门禁未过时禁止任何正式受控投影。
|
||
// 独立于 Success,避免 Success 误为 true 时仍 Materialize。
|