1004 lines
33 KiB
Go
1004 lines
33 KiB
Go
package bizdev
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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/docgov"
|
|
"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
|
|
freeDoc *freedoc.Service
|
|
projectWorkflow *projectworkflow.Service
|
|
pool *pgxpool.Pool
|
|
schemaVersion int64
|
|
stageGate StageAccessGate
|
|
}
|
|
|
|
// 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)
|
|
freeDoc := freedoc.NewService(pool, ctxMgr)
|
|
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)
|
|
|
|
svc := &Service{
|
|
cfg: cfg,
|
|
kb: kb.NewClient(cfg),
|
|
squad: squad.NewService(cfg),
|
|
ctxMgr: ctxMgr,
|
|
cas: casStore,
|
|
docGov: docGov,
|
|
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) 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...)
|
|
}
|
|
}
|
|
}
|
|
|
|
authorID := req.AuthorID
|
|
if authorID == 0 {
|
|
authorID = req.UserID
|
|
}
|
|
authorName := req.AuthorName
|
|
if authorName == "" {
|
|
authorName = "编制工程师"
|
|
}
|
|
reviewerID := req.ReviewerID
|
|
if reviewerID == 0 {
|
|
reviewerID = 2
|
|
}
|
|
reviewerName := req.ReviewerName
|
|
if reviewerName == "" {
|
|
reviewerName = "审核负责人"
|
|
}
|
|
approverID := req.ApproverID
|
|
if approverID == 0 {
|
|
approverID = 3
|
|
}
|
|
approverName := req.ApproverName
|
|
if approverName == "" {
|
|
approverName = "批准负责人"
|
|
}
|
|
|
|
var docRec *docgov.DocumentRecord
|
|
var leaseHolderID string
|
|
var leaseAcquired bool
|
|
var appliedProposalID string
|
|
|
|
if s.docGov != nil {
|
|
docRec, _ = s.docGov.CreateOrGetDocument(
|
|
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
|
|
"V0.5_feasibility", req.WorkDir,
|
|
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
|
|
)
|
|
|
|
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 = 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: req.AutoRepair,
|
|
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
|
|
}
|
|
|
|
if res.Success && s.ctxMgr != nil && req.ProjectID > 0 {
|
|
_, _ = s.ctxMgr.RecordActivity(ctx, req.ProjectID, req.UserID, "系统智能体小队", "operation",
|
|
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
|
|
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir},
|
|
1.0, true)
|
|
}
|
|
|
|
if res.Success && s.docGov != nil {
|
|
_ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir)
|
|
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
|
shaStr := hex.EncodeToString(sha[:])
|
|
_ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false)
|
|
if appliedProposalID != "" {
|
|
_ = s.docGov.MarkProposalApplied(ctx, appliedProposalID)
|
|
}
|
|
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
|
docRec = latest
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 {
|
|
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
|
shaStr := hex.EncodeToString(sha[:])
|
|
_, _ = 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
|
|
}
|
|
|
|
// 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.ExecuteTask(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 res != nil && res.RequiresAuthorApproval {
|
|
return &StageRunResult{
|
|
Stage: req.Stage,
|
|
StageTitle: targetMeta.Title,
|
|
Success: false,
|
|
WorkDir: workDir,
|
|
Duration: time.Since(start),
|
|
ErrorReason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey),
|
|
SubArtifacts: append(executedArtifacts, res),
|
|
}, 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)
|
|
}
|