Files
Agent-System/internal/bizdev/service.go
T
WeiCHEN 08af12b936 merge: feature/g2-g3-exception-clearance-2026-09-22
Keep G1 cleared sql_write; land G2/G3 path clearances; baseline to zero.
2026-09-22 22:08:53 +08:00

1615 lines
58 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package bizdev
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/config"
"chat-agent-system/internal/contracts/vocabulary"
"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/materialindex"
"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"
"chat-agent-system/internal/workspace"
"github.com/jackc/pgx/v5/pgxpool"
)
type Service struct {
cfg *config.Config
kb *kb.Client
squad *squad.Service
ctxMgr *ctxmgr.ContextManager
cas *storage.CAS
materialIndex *materialindex.Pipeline
docGov *docgov.GovernanceEngine
docRev *docrevision.Service
freeDoc *freedoc.Service
projectWorkflow *projectworkflow.Service
pool *pgxpool.Pool
schemaVersion int64
stageGate StageAccessGate
materializer AgentOutputMaterializer
parts Parts
// 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)
// completedMainHook 仅测试注入:覆盖「main 实例已 completed」查询。
completedMainHook func(ctx context.Context, projectID int64) (bool, error)
// instanceDuty 可选:活跃工作流实例对 taskKey 的治理职责(由 app 装配,避免导入 projectinstance)。
instanceDuty InstanceDutySource
}
// InstanceDutySource 返回项目当前活跃实例对 taskKey 的治理结论。
// governed=false 表示未受实例治理,调用方应回退项目默认职责逻辑。
type InstanceDutySource interface {
ResolveGovernedDuty(ctx context.Context, projectID int64, taskKey string) (author, reviewer, approver int64, dutyPolicy string, governed bool, err error)
}
// SetInstanceDutySource 注入实例职责解析源(组合根可选装配)。
func (s *Service) SetInstanceDutySource(src InstanceDutySource) {
if s == nil {
return
}
s.instanceDuty = src
}
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
func NewService(cfg *config.Config) (*Service, error) {
if cfg == nil {
return nil, errors.New("bizdev: config is required (data roots come from config, not package defaults)")
}
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.SetDutySlotWriter(docgov.DutySlotWriter{})
_ = authzStore.BackfillCreatorMembers(poolCtx)
if ver, verErr := db.CurrentVersion(poolCtx, pool); verErr == nil {
schemaVersion = ver
}
}
}
}
var casEmbedder storage.Embedder
if cfg.EmbeddingEndpointURL != "" {
casEmbedder = kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel)
}
casStore, _ := storage.NewCAS(filepath.Join(cfg.DataDir, "storage"), pool, casEmbedder)
ctxMgr := ctxmgr.NewContextManager(pool, casStore, ctxmgr.DefaultTimeDecayConfig())
if cfg.EmbeddingEndpointURL != "" {
ctxMgr.SetEmbedder(kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel))
}
matIndex := materialindex.NewPipeline(cfg, pool)
docGov := docgov.NewGovernanceEngine(pool)
docRev := docrevision.NewService(pool)
docRev.DataDir = cfg.DataDir
docRev.SetRootPointerProjector(docrevision.CompositeRootPointerProjector{
Controlled: func(ctx context.Context, scope uow.TxScope, root *docrevision.DocRootPointers) error {
return (docgov.Repository{}).ProjectHeadFull(ctx, scope, root.DocID,
root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo, root.RevisionSchemaVer, root.ContentSHA256)
},
Freeform: func(ctx context.Context, scope uow.TxScope, root *docrevision.DocRootPointers) error {
return (freedoc.Repository{}).ProjectHead(ctx, scope, root.DocID,
root.LedgerVersionID, root.HeadSnapshotID, root.HeadRevisionNo, root.RevisionSchemaVer)
},
})
docRev.SetDeliverableStore(workspace.DirDeliverable{})
freeDoc := freedoc.NewService(pool, ctxMgr)
freeDoc.SetDocRevision(docRev)
pwStore := projectworkflow.NewStore(pool)
wfStore := workflowdef.NewStore(pool)
if pool != nil && workflowdef.TablesReady(context.Background(), pool) {
if seedErr := workflowdef.BootstrapSeeds(context.Background(), pool, casStore, "templates/docs", 1); seedErr != nil {
slog.Warn("workflow seed bootstrap failed", "error", seedErr)
}
}
flatCat := workflowdef.NewCatalog(context.Background(), wfStore, newDefaultFlatCatalog())
pwSvc := projectworkflow.NewService(pwStore, adaptFlatCatalog(flatCat))
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,
materialIndex: matIndex,
docGov: docGov,
docRev: docRev,
freeDoc: freeDoc,
projectWorkflow: pwSvc,
pool: pool,
schemaVersion: schemaVersion,
}
svc.parts = Parts{
Pool: pool,
Gov: docGov,
Rev: docRev,
Ctx: ctxMgr,
CAS: casStore,
MaterialIndex: matIndex,
PW: pwSvc,
FreeDoc: freeDoc,
KB: svc.kb,
}
// 注册终审批准回调:自动切片向量化沉淀为往期标杆案例
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, workspace.DeliverableKey),
filepath.Join(cfg.DataDir, doc.WorkDir, workspace.DeliverableKey),
filepath.Join(cfg.DataDir, workspace.WorkspacesDirName, doc.WorkDir, workspace.DeliverableKey),
}
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 {
// 若未在默认工作区直接发现 deliverable,尝试深度探测当前数据目录下的工作区文件
pattern := filepath.Join(cfg.DataDir, workspace.WorkspacesDirName, "**", doc.TaskKey, workspace.DeliverableKey)
matches, _ := filepath.Glob(pattern)
if len(matches) == 0 {
pattern2 := filepath.Join(cfg.DataDir, "**", doc.TaskKey, workspace.DeliverableKey)
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 && workflowdef.SameTaskKey(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
}
// AgentOutputMaterializer is the production materialize path. Runtime injects
// *docflow.Commands so candidate-ready/commit envelopes share the unique UoW.
type AgentOutputMaterializer interface {
MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error)
}
func (s *Service) SetAgentMaterializer(m AgentOutputMaterializer) {
if s != nil {
s.materializer = m
}
}
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 (s *Service) ListTasks() []TaskMeta {
return s.squad.ListTasks()
}
// resolveDocStage 解析写入文档账本的 Stage 字段。
// 优先请求透传的冻结实例阶段键;否则按 main 三阶段任务映射;再回落 V0.5_feasibility。
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
}
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
}
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)
}
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)
}
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.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 执行指定成熟度阶段
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, instanceDuty InstanceDutySource) (docduty.Binding, string, bool) {
if instanceDuty != nil && req.ProjectID > 0 && strings.TrimSpace(req.TaskKey) != "" {
authorID, reviewerID, approverID, dutyPolicy, governed, err := instanceDuty.ResolveGovernedDuty(ctx, req.ProjectID, req.TaskKey)
if err == nil && governed {
bound := docduty.Binding{
Author: docduty.Holder{UserID: authorID},
Reviewer: docduty.Holder{UserID: reviewerID},
Approver: docduty.Holder{UserID: approverID},
}
if pool != nil {
if members, creator, loadErr := docduty.LoadProjectMembers(ctx, pool, req.ProjectID); loadErr == nil {
bound.Author.Name = holderName(members, creator, authorID, req.AuthorName)
bound.Reviewer.Name = holderName(members, creator, reviewerID, req.ReviewerName)
bound.Approver.Name = holderName(members, creator, approverID, req.ApproverName)
}
}
if bound.Author.Name == "" {
bound.Author.Name = strings.TrimSpace(req.AuthorName)
}
if bound.Reviewer.Name == "" {
bound.Reviewer.Name = strings.TrimSpace(req.ReviewerName)
}
if bound.Approver.Name == "" {
bound.Approver.Name = strings.TrimSpace(req.ApproverName)
}
return bound, dutyPolicy, true
}
}
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, "", false
}
}
creator := docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
return docduty.Resolve(nil, creator, reqDuties), "", false
}
func holderName(members []docduty.Member, creator docduty.Holder, userID int64, fallback string) string {
if userID <= 0 {
return strings.TrimSpace(fallback)
}
if creator.UserID == userID && strings.TrimSpace(creator.Name) != "" {
return strings.TrimSpace(creator.Name)
}
for _, m := range members {
if m.UserID == userID && strings.TrimSpace(m.Name) != "" {
return strings.TrimSpace(m.Name)
}
}
return strings.TrimSpace(fallback)
}
func lineageVersion(doc *docgov.DocumentRecord) int {
if doc != nil && doc.Version > 0 {
return doc.Version
}
return 1
}
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
}