Remove now reconciles document slots and returns ERR_DUTY_SYNC on failure. CreateOrGetDocument errors abort the task. The frontend refreshes workflow docs after add, update, restore, and remove. Co-authored-by: Cursor <cursoragent@cursor.com>
1383 lines
49 KiB
Go
1383 lines
49 KiB
Go
package bizdev
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/authz"
|
||
"chat-agent-system/internal/config"
|
||
"chat-agent-system/internal/ctxmgr"
|
||
"chat-agent-system/internal/db"
|
||
"chat-agent-system/internal/docduty"
|
||
"chat-agent-system/internal/docgov"
|
||
"chat-agent-system/internal/docrevision"
|
||
"chat-agent-system/internal/freedoc"
|
||
"chat-agent-system/internal/kb"
|
||
"chat-agent-system/internal/projectcontext"
|
||
"chat-agent-system/internal/projectworkflow"
|
||
"chat-agent-system/internal/squad"
|
||
"chat-agent-system/internal/storage"
|
||
"chat-agent-system/internal/stream"
|
||
"chat-agent-system/internal/workflowdef"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
type Service struct {
|
||
cfg *config.Config
|
||
kb *kb.Client
|
||
squad *squad.Service
|
||
ctxMgr *ctxmgr.ContextManager
|
||
cas *storage.CAS
|
||
docGov *docgov.GovernanceEngine
|
||
docRev *docrevision.Service
|
||
freeDoc *freedoc.Service
|
||
projectWorkflow *projectworkflow.Service
|
||
pool *pgxpool.Pool
|
||
schemaVersion int64
|
||
stageGate StageAccessGate
|
||
// stageTaskHook 仅测试注入:覆盖 ExecuteStage 子任务执行,验证链中止边界。
|
||
stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error)
|
||
// createDocumentHook 仅测试注入:覆盖账本创建失败路径。
|
||
createDocumentHook func(ctx context.Context, docID string, projectID int64, taskKey, title, stage, workDir string, authorID int64, authorName string, reviewerID int64, reviewerName string, approverID int64, approverName string) (*docgov.DocumentRecord, error)
|
||
}
|
||
|
||
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
|
||
func NewService(cfg *config.Config) (*Service, error) {
|
||
if cfg == nil {
|
||
cfg = &config.Config{
|
||
DataDir: "./data/dev",
|
||
}
|
||
}
|
||
var pool *pgxpool.Pool
|
||
var schemaVersion int64
|
||
dbConfigured := cfg.ContextDbHost != ""
|
||
if dbConfigured {
|
||
poolCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
p, err := pgxpool.New(poolCtx, cfg.ContextDSN())
|
||
if err != nil {
|
||
if !cfg.AllowInMemory {
|
||
return nil, fmt.Errorf("database connect failed: %w", err)
|
||
}
|
||
} else if pingErr := p.Ping(poolCtx); pingErr != nil {
|
||
p.Close()
|
||
if !cfg.AllowInMemory {
|
||
return nil, fmt.Errorf("database ping failed: %w", pingErr)
|
||
}
|
||
} else {
|
||
pool = p
|
||
migrationsDir := db.ResolveMigrationsDir()
|
||
if migErr := db.RunMigrations(poolCtx, pool, migrationsDir); migErr != nil {
|
||
pool.Close()
|
||
if !cfg.AllowInMemory {
|
||
return nil, fmt.Errorf("database migration failed: %w", migErr)
|
||
}
|
||
} else {
|
||
authzStore := authz.NewAuthorizer(pool)
|
||
_ = authzStore.BackfillCreatorMembers(poolCtx)
|
||
if ver, verErr := db.CurrentVersion(poolCtx, pool); verErr == nil {
|
||
schemaVersion = ver
|
||
}
|
||
}
|
||
}
|
||
}
|
||
casStore, _ := storage.NewCAS(filepath.Join(cfg.DataDir, "storage"), pool, nil)
|
||
ctxMgr := ctxmgr.NewContextManager(pool, casStore, ctxmgr.DefaultTimeDecayConfig())
|
||
if cfg.EmbeddingEndpointURL != "" {
|
||
ctxMgr.SetEmbedder(kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel))
|
||
}
|
||
docGov := docgov.NewGovernanceEngine(pool)
|
||
docRev := docrevision.NewService(pool)
|
||
docRev.DataDir = cfg.DataDir
|
||
freeDoc := freedoc.NewService(pool, ctxMgr)
|
||
freeDoc.SetDocRevision(docRev)
|
||
pwStore := projectworkflow.NewStore(pool)
|
||
fallbackCatalog := projectworkflow.NewDefaultCatalog()
|
||
wfStore := workflowdef.NewStore(pool)
|
||
if pool != nil && workflowdef.TablesReady(context.Background(), pool) {
|
||
_ = workflowdef.BootstrapSeeds(context.Background(), pool, casStore, "templates/docs", 1)
|
||
}
|
||
catalog := workflowdef.NewCatalog(context.Background(), wfStore, fallbackCatalog)
|
||
pwSvc := projectworkflow.NewService(pwStore, catalog)
|
||
docRev.ImpactResolver = func(ctx context.Context, docID string) []docrevision.DocImpact {
|
||
if pwSvc == nil {
|
||
return nil
|
||
}
|
||
lineages, err := pwSvc.Store().GetLineagesForUpstream(ctx, docID)
|
||
if err != nil || len(lineages) == 0 {
|
||
return nil
|
||
}
|
||
down := make([]struct {
|
||
DocID string
|
||
Reason string
|
||
}, 0, len(lineages))
|
||
for _, ln := range lineages {
|
||
if ln == nil {
|
||
continue
|
||
}
|
||
reason := ln.StaleReason
|
||
if reason == "" {
|
||
reason = "上游文档内容变更,下游需复核"
|
||
}
|
||
down = append(down, struct {
|
||
DocID string
|
||
Reason string
|
||
}{DocID: ln.DownstreamDocID, Reason: reason})
|
||
}
|
||
return docrevision.ImpactFromLineage(down)
|
||
}
|
||
|
||
svc := &Service{
|
||
cfg: cfg,
|
||
kb: kb.NewClient(cfg),
|
||
squad: squad.NewService(cfg),
|
||
ctxMgr: ctxMgr,
|
||
cas: casStore,
|
||
docGov: docGov,
|
||
docRev: docRev,
|
||
freeDoc: freeDoc,
|
||
projectWorkflow: pwSvc,
|
||
pool: pool,
|
||
schemaVersion: schemaVersion,
|
||
}
|
||
|
||
// 注册终审批准回调:自动切片向量化沉淀为往期标杆案例
|
||
docGov.SetOnApprovedHook(func(ctx context.Context, doc *docgov.DocumentRecord) {
|
||
if ctxMgr == nil || doc == nil {
|
||
return
|
||
}
|
||
var contentPath string
|
||
if doc.WorkDir != "" {
|
||
candidates := []string{
|
||
doc.WorkDir,
|
||
filepath.Join(doc.WorkDir, "output.md"),
|
||
filepath.Join(cfg.DataDir, doc.WorkDir, "output.md"),
|
||
filepath.Join(cfg.DataDir, "workspaces", doc.WorkDir, "output.md"),
|
||
}
|
||
for _, p := range candidates {
|
||
if b, err := os.ReadFile(p); err == nil && len(b) > 0 {
|
||
contentPath = p
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if contentPath != "" {
|
||
if b, err := os.ReadFile(contentPath); err == nil && len(b) > 0 {
|
||
_, _ = ctxMgr.IndexApprovedDocument(ctx, doc, string(b))
|
||
}
|
||
} else {
|
||
// 若未在默认工作区直接发现 output.md,尝试深度探测当前数据目录下的工作区文件
|
||
pattern := filepath.Join(cfg.DataDir, "workspaces", "**", doc.TaskKey, "output.md")
|
||
matches, _ := filepath.Glob(pattern)
|
||
if len(matches) == 0 {
|
||
pattern2 := filepath.Join(cfg.DataDir, "**", doc.TaskKey, "output.md")
|
||
matches, _ = filepath.Glob(pattern2)
|
||
}
|
||
if len(matches) > 0 {
|
||
if b, err := os.ReadFile(matches[len(matches)-1]); err == nil && len(b) > 0 {
|
||
_, _ = ctxMgr.IndexApprovedDocument(ctx, doc, string(b))
|
||
}
|
||
}
|
||
}
|
||
|
||
if pwSvc != nil && doc.TaskKey == "system_reqs" {
|
||
_, _ = pwSvc.CheckAndPropagateStale(ctx, doc.DocID, doc.ContentSHA256, fmt.Sprintf("上游系统需求规格书 V%d 已正式批准,已标记关联下游验证方案为待复核(stale)", doc.Version))
|
||
}
|
||
})
|
||
|
||
return svc, nil
|
||
}
|
||
|
||
// SchemaVersion 返回启动时缓存的数据库 schema 版本(goose)。
|
||
func (s *Service) SchemaVersion() int64 {
|
||
if s == nil {
|
||
return 0
|
||
}
|
||
return s.schemaVersion
|
||
}
|
||
|
||
// LiveSchemaVersion 从数据库实时读取当前 schema 版本;失败时回退缓存值。
|
||
func (s *Service) LiveSchemaVersion(ctx context.Context) int64 {
|
||
if s == nil {
|
||
return 0
|
||
}
|
||
if s.pool == nil {
|
||
return s.schemaVersion
|
||
}
|
||
ver, err := db.CurrentVersion(ctx, s.pool)
|
||
if err != nil {
|
||
return s.schemaVersion
|
||
}
|
||
return ver
|
||
}
|
||
|
||
// DatabaseReady 数据库是否已连接且迁移完成。
|
||
func (s *Service) DatabaseReady() bool {
|
||
return s != nil && s.pool != nil
|
||
}
|
||
|
||
func (s *Service) ContextManager() *ctxmgr.ContextManager {
|
||
return s.ctxMgr
|
||
}
|
||
|
||
func (s *Service) CAS() *storage.CAS {
|
||
return s.cas
|
||
}
|
||
|
||
func (s *Service) DocGov() *docgov.GovernanceEngine {
|
||
return s.docGov
|
||
}
|
||
|
||
func (s *Service) DocRevision() *docrevision.Service {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
return s.docRev
|
||
}
|
||
|
||
func (s *Service) ProjectWorkflow() *projectworkflow.Service {
|
||
return s.projectWorkflow
|
||
}
|
||
|
||
func (s *Service) Pool() *pgxpool.Pool {
|
||
return s.pool
|
||
}
|
||
|
||
func (s *Service) FreeformDoc() *freedoc.Service {
|
||
return s.freeDoc
|
||
}
|
||
|
||
func (s *Service) KB() *kb.Client {
|
||
return s.kb
|
||
}
|
||
|
||
var SupportedTasks = squad.SupportedTasks
|
||
|
||
var SupportedStages = []StageMeta{
|
||
{
|
||
Stage: StageV01_Triage,
|
||
Title: "V0.1 闪电摸底与盲区探测",
|
||
Description: "快速梳理客户诉求与约束,建立基准技术假设,生成首期澄清微信话术与初始需求表",
|
||
Tasks: []string{"intake"},
|
||
},
|
||
{
|
||
Stage: StageV05_Feasibility,
|
||
Title: "V0.5 方案成型与法规对齐",
|
||
Description: "法规与注册路径初筛、竞品调研、系统初步架构设计与关键选型建议",
|
||
Tasks: []string{"research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"},
|
||
},
|
||
{
|
||
Stage: StageV10_Quoting,
|
||
Title: "V1.0 工程拆解与商业锁定",
|
||
Description: "全维度 WBS 工时拆解、Excel 定价核算、正式 CDO 商务报价单与谈判防御条款建议",
|
||
Tasks: []string{"cdo_quote"},
|
||
},
|
||
}
|
||
|
||
func (s *Service) ListTasks() []TaskMeta {
|
||
return s.squad.ListTasks()
|
||
}
|
||
|
||
func (s *Service) ListStages() []StageMeta {
|
||
return SupportedStages
|
||
}
|
||
|
||
func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) {
|
||
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, req.TaskKey); 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 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 && (req.TaskKey == "research" || req.TaskKey == "feasibility_reg" || req.TaskKey == "cleanroom_spec" || req.TaskKey == "prelim_design" || req.TaskKey == "system_reqs" || req.TaskKey == "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 req.TaskKey == "test_manager" || req.TaskKey == "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 == docgov.StateApproved {
|
||
upstreamSRDoc = doc
|
||
if doc.WorkDir != "" {
|
||
contentPath := filepath.Join(doc.WorkDir, "output.md")
|
||
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...)
|
||
}
|
||
}
|
||
}
|
||
|
||
duties := resolveTaskDocumentDuties(ctx, s.pool, req)
|
||
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) != "" {
|
||
var createErr error
|
||
if s.createDocumentHook != nil {
|
||
docRec, createErr = s.createDocumentHook(
|
||
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
|
||
"V0.5_feasibility", req.WorkDir,
|
||
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
|
||
)
|
||
} else {
|
||
docRec, createErr = s.docGov.CreateOrGetDocument(
|
||
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
|
||
"V0.5_feasibility", 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 s.pool != nil && req.ProjectID > 0 {
|
||
assigned, _, healErr := docduty.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 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,
|
||
}, docgov.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,
|
||
}, docgov.ErrForceEditNotAllowed
|
||
}
|
||
appliedProposalID = prop.ProposalID
|
||
}
|
||
} else if docRec != nil {
|
||
checkRes, checkErr := s.docGov.CheckModificationPermission(ctx, docID, docgov.RoleSystem)
|
||
if checkErr != nil {
|
||
if errors.Is(checkErr, docgov.ErrDocumentLocked) || errors.Is(checkErr, docgov.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)
|
||
}
|
||
|
||
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 = docgov.DefaultSquadLeaseTTL
|
||
}
|
||
leased, leaseErr := s.docGov.AcquireLease(ctx, docgov.LeaseAcquireRequest{
|
||
DocID: docID,
|
||
HolderType: docgov.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:
|
||
_, _ = s.docGov.HeartbeatLease(context.Background(), docID, string(docgov.LeaseHolderSquad), leaseHolderID, leaseTTL)
|
||
}
|
||
}
|
||
}()
|
||
defer func() {
|
||
if leaseAcquired {
|
||
_, _ = s.docGov.ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderSquad), leaseHolderID)
|
||
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)
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
SessionID: req.SessionID,
|
||
UserID: req.UserID,
|
||
ProjectID: req.ProjectID,
|
||
RunID: req.RunID,
|
||
}
|
||
|
||
res, err := s.squad.ExecuteTask(ctx, squadReq)
|
||
if err != nil {
|
||
if res != nil {
|
||
return &TaskRunResult{
|
||
TaskKey: res.TaskKey,
|
||
ArtifactTitle: res.ArtifactTitle,
|
||
WorkDir: res.WorkDir,
|
||
BackendUsed: res.BackendUsed,
|
||
Duration: res.Duration,
|
||
Success: false,
|
||
ErrorReason: res.ErrorReason,
|
||
AdvisorBrief: res.AdvisorBrief,
|
||
}, err
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
return s.finalizeSquadDeliverable(ctx, req, res, docID, docRec, appliedProposalID, upstreamSRDoc)
|
||
}
|
||
|
||
// qualityBlocksControlledProjection 质量门禁未过时禁止任何正式受控投影。
|
||
// 独立于 Success,避免 Success 误为 true 时仍 Materialize。
|
||
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.docRev.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 && req.TaskKey == "test_manager" && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 {
|
||
_ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{
|
||
ProjectID: req.ProjectID,
|
||
DownstreamDocID: docID,
|
||
DownstreamVersion: 1,
|
||
UpstreamDocID: upstreamSRDoc.DocID,
|
||
UpstreamVersion: upstreamSRDoc.Version,
|
||
UpstreamSHA256: upstreamSRDoc.ContentSHA256,
|
||
IsStale: false,
|
||
})
|
||
}
|
||
// Stale propagation only after fact source committed (not while pending confirmation).
|
||
if res.Success && 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 执行指定成熟度阶段
|
||
func (s *Service) ExecuteStage(ctx context.Context, req StageRunRequest) (*StageRunResult, error) {
|
||
return s.executeStageInternal(ctx, req)
|
||
}
|
||
|
||
func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) (*StageRunResult, error) {
|
||
start := time.Now()
|
||
|
||
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, string(req.Stage)); err != nil {
|
||
return &StageRunResult{
|
||
Stage: req.Stage,
|
||
Success: false,
|
||
ErrorReason: err.Error(),
|
||
}, err
|
||
}
|
||
|
||
// 1. 确定工作区目录基准
|
||
stageKey := string(req.Stage)
|
||
if stageKey == "" {
|
||
stageKey = string(StageV01_Triage)
|
||
req.Stage = StageV01_Triage
|
||
}
|
||
|
||
workDir := req.WorkDir
|
||
if workDir == "" {
|
||
workDir = filepath.Join(s.cfg.DataDir, "workspaces", "bizdev", fmt.Sprintf("stage_%s_%d", stageKey, time.Now().UnixNano()))
|
||
}
|
||
_ = os.MkdirAll(workDir, 0o755)
|
||
|
||
// 2. 匹配阶段元数据与任务序列
|
||
var targetMeta *StageMeta
|
||
for _, sm := range SupportedStages {
|
||
if sm.Stage == req.Stage {
|
||
targetMeta = &sm
|
||
break
|
||
}
|
||
}
|
||
if targetMeta == nil {
|
||
targetMeta = &SupportedStages[0]
|
||
}
|
||
|
||
streamer := stream.FromContext(ctx)
|
||
if streamer != nil {
|
||
streamer.EmitPhase("stage_started", fmt.Sprintf("开始推进商务成熟度阶段【%s】(包含 %d 项任务序列)...", req.Stage, len(targetMeta.Tasks)), "coordinator", 0, len(targetMeta.Tasks), 5)
|
||
}
|
||
|
||
// 3. 汇总当前阶段累积的事实与前序文档
|
||
currentFields := make(map[string]string)
|
||
|
||
// 若存在项目协同管理器,优先载入已沉淀的项目事实与黑板
|
||
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||
if projCtx, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && projCtx != nil {
|
||
for k, v := range projCtx.BlackboardFacts {
|
||
currentFields[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
if req.Fields != nil {
|
||
for k, v := range req.Fields {
|
||
currentFields[k] = v
|
||
}
|
||
}
|
||
if req.ProjectName != "" {
|
||
currentFields["project_name"] = req.ProjectName
|
||
}
|
||
if req.CustomerName != "" {
|
||
currentFields["customer_name"] = req.CustomerName
|
||
}
|
||
if req.DeviceType != "" {
|
||
currentFields["device_type"] = req.DeviceType
|
||
}
|
||
if req.IntendedUse != "" {
|
||
currentFields["intended_use"] = req.IntendedUse
|
||
}
|
||
if req.TargetMarket != "" {
|
||
currentFields["target_market"] = req.TargetMarket
|
||
}
|
||
|
||
accumulatedPriorDocs := append([]squad.PriorDocItem{}, req.PriorDocs...)
|
||
if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 {
|
||
accumulatedPriorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, accumulatedPriorDocs)
|
||
}
|
||
var executedArtifacts []*TaskRunResult
|
||
|
||
// 4. 按顺序执行当前阶段的子任务序列,并将每一轮产出作为后续任务的 PriorDoc 和事实补充
|
||
for _, tKey := range targetMeta.Tasks {
|
||
subWorkDir := filepath.Join(workDir, tKey)
|
||
tReq := TaskRunRequest{
|
||
TaskKey: tKey,
|
||
ProjectName: req.ProjectName,
|
||
CustomerName: req.CustomerName,
|
||
DeviceType: req.DeviceType,
|
||
IntendedUse: req.IntendedUse,
|
||
TargetMarket: req.TargetMarket,
|
||
Fields: currentFields,
|
||
Materials: req.Materials,
|
||
Backend: req.Backend,
|
||
Timeout: req.Timeout,
|
||
WorkDir: subWorkDir,
|
||
AutoRepair: req.AutoRepair,
|
||
KnowledgeHits: req.KnowledgeHits,
|
||
PriorDocs: accumulatedPriorDocs,
|
||
SessionID: req.SessionID,
|
||
ProjectID: req.ProjectID,
|
||
UserID: req.UserID,
|
||
RunID: req.RunID,
|
||
AuthorID: req.AuthorID,
|
||
AuthorName: req.AuthorName,
|
||
ReviewerID: req.ReviewerID,
|
||
ReviewerName: req.ReviewerName,
|
||
ApproverID: req.ApproverID,
|
||
ApproverName: req.ApproverName,
|
||
ForceDirectEdit: req.ForceDirectEdit,
|
||
}
|
||
|
||
res, err := s.executeStageTask(ctx, tReq)
|
||
if err != nil {
|
||
return &StageRunResult{
|
||
Stage: req.Stage,
|
||
StageTitle: targetMeta.Title,
|
||
Success: false,
|
||
WorkDir: workDir,
|
||
Duration: time.Since(start),
|
||
ErrorReason: fmt.Sprintf("task %s failed: %v", tKey, err),
|
||
SubArtifacts: executedArtifacts,
|
||
}, err
|
||
}
|
||
|
||
if halt := classifyStageTaskHalt(res, tKey); halt != nil {
|
||
out := &StageRunResult{
|
||
Stage: req.Stage,
|
||
StageTitle: targetMeta.Title,
|
||
Success: false,
|
||
WorkDir: workDir,
|
||
Duration: time.Since(start),
|
||
ErrorReason: halt.reason,
|
||
PrimaryArtifact: res,
|
||
SubArtifacts: append(executedArtifacts, res),
|
||
}
|
||
if halt.pending {
|
||
out.PendingAgentCandidateID = res.PendingAgentCandidateID
|
||
out.PendingChangeReportID = res.PendingChangeReportID
|
||
out.PendingRiskLevel = res.PendingRiskLevel
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
executedArtifacts = append(executedArtifacts, res)
|
||
|
||
// 吸收回填事实(仅已提交且过门禁的产物)
|
||
if res.ExtractedData != nil {
|
||
for k, v := range res.ExtractedData {
|
||
if v != "" {
|
||
currentFields[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
// 追加前序文档供同阶段后续任务使用
|
||
accumulatedPriorDocs = append(accumulatedPriorDocs, squad.PriorDocItem{
|
||
DocKey: tKey,
|
||
Title: res.ArtifactTitle,
|
||
Filename: fmt.Sprintf("%s.md", tKey),
|
||
Content: res.MarkdownBody,
|
||
Stage: string(req.Stage),
|
||
})
|
||
}
|
||
|
||
// 5. 确定主交付物并组装阶段级参谋简报
|
||
var primaryArtifact *TaskRunResult
|
||
if len(executedArtifacts) > 0 {
|
||
primaryArtifact = executedArtifacts[len(executedArtifacts)-1]
|
||
}
|
||
|
||
stageBrief := primaryArtifact.AdvisorBrief
|
||
if stageBrief != nil {
|
||
// 覆盖校准为当前阶段等级
|
||
switch req.Stage {
|
||
case StageV01_Triage:
|
||
stageBrief.MaturityLevel = "V0.1"
|
||
case StageV05_Feasibility:
|
||
stageBrief.MaturityLevel = "V0.5"
|
||
case StageV10_Quoting:
|
||
stageBrief.MaturityLevel = "V1.0"
|
||
}
|
||
}
|
||
|
||
// 持久化阶段级元数据与事实黑板,便于 L2 上下文装载与对话系统读取
|
||
stageMetaRecord := squad.WorkspaceMetadata{
|
||
ProjectName: req.ProjectName,
|
||
CustomerName: req.CustomerName,
|
||
DeviceType: req.DeviceType,
|
||
IntendedUse: req.IntendedUse,
|
||
TargetMarket: req.TargetMarket,
|
||
TaskKey: string(req.Stage),
|
||
Status: "completed",
|
||
CreatedAt: start,
|
||
}
|
||
if b, err := json.MarshalIndent(stageMetaRecord, "", " "); err == nil {
|
||
_ = os.WriteFile(filepath.Join(workDir, "workspace.json"), b, 0o644)
|
||
}
|
||
|
||
bbRecord := squad.BlackboardState{
|
||
Fields: currentFields,
|
||
}
|
||
if b, err := json.MarshalIndent(bbRecord, "", " "); err == nil {
|
||
_ = os.WriteFile(filepath.Join(workDir, "blackboard.json"), b, 0o644)
|
||
}
|
||
|
||
if stageBrief != nil {
|
||
if b, err := json.MarshalIndent(stageBrief, "", " "); err == nil {
|
||
_ = os.WriteFile(filepath.Join(workDir, "advisor_brief.json"), b, 0o644)
|
||
}
|
||
}
|
||
|
||
// 将本阶段累积沉淀的事实回填到 ContextManager,供下一阶段或后续对话读取
|
||
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||
_ = s.ctxMgr.UpdateProjectBlackboard(ctx, req.ProjectID, currentFields)
|
||
_ = s.ctxMgr.UpdateProjectStage(ctx, req.ProjectID, string(req.Stage))
|
||
authorName := req.AuthorName
|
||
if authorName == "" {
|
||
authorName = "系统协调员"
|
||
}
|
||
_, _ = s.ctxMgr.RecordActivity(
|
||
ctx,
|
||
req.ProjectID,
|
||
req.UserID,
|
||
authorName,
|
||
"stage_advance",
|
||
fmt.Sprintf("阶段跃迁至【%s】", targetMeta.Title),
|
||
map[string]any{
|
||
"stage": string(req.Stage),
|
||
"duration_ms": time.Since(start).Milliseconds(),
|
||
"artifacts": len(executedArtifacts),
|
||
"device_type": req.DeviceType,
|
||
"target_market": req.TargetMarket,
|
||
},
|
||
1.0,
|
||
true, // 里程碑决策,豁免衰减
|
||
)
|
||
}
|
||
|
||
if streamer != nil {
|
||
streamer.EmitPhase("stage_completed", fmt.Sprintf("商务成熟度阶段【%s】全部任务协同推演完成!", req.Stage), "coordinator", len(targetMeta.Tasks), len(targetMeta.Tasks), 100)
|
||
if stageBrief != nil {
|
||
streamer.EmitSummary(targetMeta.Title+"完成", stageBrief.MaturityHeadline, stageBrief.MaturityScore, stageBrief.MaturityLevel)
|
||
streamer.EmitBriefing(stageBrief)
|
||
}
|
||
}
|
||
|
||
return &StageRunResult{
|
||
Stage: req.Stage,
|
||
StageTitle: targetMeta.Title,
|
||
PrimaryArtifact: primaryArtifact,
|
||
SubArtifacts: executedArtifacts,
|
||
AdvisorBrief: stageBrief,
|
||
Duration: time.Since(start),
|
||
Success: true,
|
||
WorkDir: workDir,
|
||
}, nil
|
||
}
|
||
|
||
// InspectWorkspace 查看指定工作区的当前执行状态、黑板数据与各专员产出
|
||
func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*squad.WorkspaceState, error) {
|
||
return s.squad.InspectWorkspace(ctx, workDir)
|
||
}
|
||
|
||
// ResumeOrModifyWorkspace 动态调整任务并继续执行
|
||
func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) {
|
||
return s.squad.ResumeOrModifyWorkspace(ctx, req)
|
||
}
|
||
|
||
func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest) docduty.Binding {
|
||
reqDuties := docduty.RequestDuties{
|
||
AuthorID: req.AuthorID, AuthorName: req.AuthorName,
|
||
ReviewerID: req.ReviewerID, ReviewerName: req.ReviewerName,
|
||
ApproverID: req.ApproverID, ApproverName: req.ApproverName,
|
||
}
|
||
if reqDuties.AuthorID == 0 {
|
||
reqDuties.AuthorID = req.UserID
|
||
}
|
||
if pool != nil && req.ProjectID > 0 {
|
||
if bound, err := docduty.ResolveProject(ctx, pool, req.ProjectID, reqDuties); err == nil {
|
||
if bound.Author.UserID == 0 && req.UserID > 0 {
|
||
bound.Author = docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
|
||
}
|
||
return bound
|
||
}
|
||
}
|
||
creator := docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
|
||
return docduty.Resolve(nil, creator, reqDuties)
|
||
}
|
||
|
||
func bindingFromDoc(doc *docgov.DocumentRecord) docduty.Binding {
|
||
if doc == nil {
|
||
return docduty.Binding{}
|
||
}
|
||
return docduty.Binding{
|
||
Author: docduty.Holder{UserID: doc.AuthorID, Name: doc.AuthorName},
|
||
Reviewer: docduty.Holder{UserID: doc.ReviewerID, Name: doc.ReviewerName},
|
||
Approver: docduty.Holder{UserID: doc.ApproverID, Name: doc.ApproverName},
|
||
}
|
||
}
|
||
|
||
func writeBindingToDoc(doc *docgov.DocumentRecord, bound docduty.Binding) {
|
||
if doc == nil {
|
||
return
|
||
}
|
||
doc.AuthorID, doc.AuthorName = bound.Author.UserID, bound.Author.Name
|
||
doc.ReviewerID, doc.ReviewerName = bound.Reviewer.UserID, bound.Reviewer.Name
|
||
doc.ApproverID, doc.ApproverName = bound.Approver.UserID, bound.Approver.Name
|
||
}
|