diff --git a/.doc/ops/short-term/2026-09-23-modular-decouple-residual.md b/.doc/ops/short-term/2026-09-23-modular-decouple-residual.md new file mode 100644 index 00000000..1cabd031 --- /dev/null +++ b/.doc/ops/short-term/2026-09-23-modular-decouple-residual.md @@ -0,0 +1,65 @@ +--- +title: "模块解耦残余重构(巨型文件 + W4 + Sysadmin 二期)" +task_id: modular-decouple-residual-2026-09-23 +status: in_progress +created: 2026-09-23 +updated: 2026-09-23 +owner: Cursor +task_type: implementation +task_tier: multi_stage +policy_revision: "AGENTS+module-dev-standards+validation-contract @ 2fae99096568a2941ea38c3fd1a38c762e455027" +branch: feature/modular-decouple-residual-2026-09-23 +worktree: .worktrees/modular-decouple-residual-2026-09-23 +base_dev_sha: "2fae99096568a2941ea38c3fd1a38c762e455027" +write_scope: ["internal", "scripts", "web", ".doc"] +allowed_environments: [] +head_sha: "" +mutex: "modular-decouple-residual" +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: "独立维护者" +cleanup_trigger: "全部批次合入 local_dev 且无进程占用后 remove" +remote_sync: not_requested +--- + +# 模块解耦残余重构 + +## 用户目标与授权 + +- 用户目标:按计划完成巨型文件拆分(9 批)+ W4 治理收口 + Sysadmin 二期;门禁+合入本机 `dev`;不 push;不触红线。 +- 授权来源:用户确认「模块解耦残余重构实施计划」(全部残余 / 门禁+合入)。 +- 禁止:FAV live、SWBOOT-10、D7 物理删、PI 叙事 flag、tier 升级、语义摘要接线;不改行为的顺手修 bug。 + +## 分批清单 + +| 批 | 目标 | 状态 | tip | +|---|---|---|---| +| A | `bizdev/service.go` 拆分 | pending | | +| B | `projectinstance/store.go` | pending | | +| C | `intent/recognizer.go` | pending | | +| D | `server/handlers.go` | pending | | +| E | `projectworkflow/store.go` | pending | | +| F | `docgov/engine.go` | pending | | +| G | `docrevision/service.go` | pending | | +| H | `squad/workspace.go` | pending | | +| I | `freedoc/service.go` | pending | | +| W4 | projection + deploy/recover + Darwin tests | pending | | +| Sysadmin | `system:*` + 设置审计 | pending | | + +## 每批验收 + +```bash +./scripts/arch-check.sh --mode block +go vet ./... +go test -short ./internal//... +``` + +新生产文件 ≤800 行;无新增跨模块 import。 + +## 相关链接 + +- 计划:用户确认的模块解耦残余重构实施计划 +- 承接框架:../remediation/2026-09-23-modular-refactor-implementation-undertaking-plan.md +- 原 W4:./2026-09-22-governance-automation.md(并入本任务,旧基线不续写) diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 8fcfee33..e4f0a307 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -2,35 +2,26 @@ 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" @@ -292,1227 +283,17 @@ func (s *Service) SetAgentMaterializer(m AgentOutputMaterializer) { } } -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) } @@ -1521,94 +302,3 @@ func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*squad. 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 -} diff --git a/internal/bizdev/service_duty_resolve.go b/internal/bizdev/service_duty_resolve.go new file mode 100644 index 00000000..fe65c144 --- /dev/null +++ b/internal/bizdev/service_duty_resolve.go @@ -0,0 +1,102 @@ +package bizdev + +import ( + "context" + "strings" + + "chat-agent-system/internal/docduty" + "chat-agent-system/internal/docgov" + + "github.com/jackc/pgx/v5/pgxpool" +) + +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 +} diff --git a/internal/bizdev/service_stage_run.go b/internal/bizdev/service_stage_run.go new file mode 100644 index 00000000..b1d1a595 --- /dev/null +++ b/internal/bizdev/service_stage_run.go @@ -0,0 +1,277 @@ +package bizdev + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "chat-agent-system/internal/squad" + "chat-agent-system/internal/stream" +) + +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 查看指定工作区的当前执行状态、黑板数据与各专员产出 diff --git a/internal/bizdev/service_task_finalize.go b/internal/bizdev/service_task_finalize.go new file mode 100644 index 00000000..761f5763 --- /dev/null +++ b/internal/bizdev/service_task_finalize.go @@ -0,0 +1,312 @@ +package bizdev + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/workflowdef" +) + +func qualityBlocksControlledProjection(report *squad.QualityReport) bool { + return report != nil && !report.Passed +} + +// autoRepairForControlledSquad 已解析出正式受控文档后强制开启修复;非受控调用仍尊重请求值。 +func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docgov.DocumentRecord) bool { + if strings.TrimSpace(docID) != "" && docRec != nil { + return true + } + return reqAutoRepair +} + +func qualityFailedErrorReason(report *squad.QualityReport) string { + if report == nil { + return "质量门禁未通过" + } + if len(report.Violations) > 0 { + return "质量门禁未通过:" + strings.Join(report.Violations, ";") + } + return fmt.Sprintf("质量门禁未通过(评分 %.0f/100)", report.Score) +} + +// finalizeSquadDeliverable 处理小队返回后的受控文档投影边界。 +// 质量失败必须在 SetDocumentWorkDir / Materialize / MarkDocumentContentUpdated / 完成活动之前短路。 +func (s *Service) finalizeSquadDeliverable( + ctx context.Context, + req TaskRunRequest, + res *squad.SquadRunResult, + docID string, + docRec *docgov.DocumentRecord, + appliedProposalID string, + upstreamSRDoc *docgov.DocumentRecord, +) (*TaskRunResult, error) { + if res == nil { + return &TaskRunResult{TaskKey: req.TaskKey, Success: false, ErrorReason: "小队无返回结果"}, nil + } + + // 质量门禁短路:保留隔离工作区与可见失败摘要,不改正式 doc work_dir/head/hash,不激活账本版本。 + if qualityBlocksControlledProjection(res.QualityReport) { + return &TaskRunResult{ + TaskKey: res.TaskKey, + ArtifactTitle: res.ArtifactTitle, + MarkdownBody: res.MarkdownBody, + ExtractedData: res.ExtractedData, + Attachments: res.Attachments, + WorkDir: res.WorkDir, + BackendUsed: res.BackendUsed, + Duration: res.Duration, + Success: false, + ErrorReason: qualityFailedErrorReason(res.QualityReport), + QualityReport: res.QualityReport, + Manifest: res.Manifest, + AdvisorBrief: res.AdvisorBrief, + DocRecord: docRec, + }, nil + } + + // RecordActivity for "完成交付物" must NOT run here: Materialize may still fail or + // leave needs_confirmation. Activity is recorded only after a committed projection + // (or a pending-awaiting note that is explicitly not completion). + + if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil { + _ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir) + dataRoot := "." + if s.cfg != nil && s.cfg.DataDir != "" { + dataRoot = s.cfg.DataDir + } + + // DOCREV: materialize first; only committed heads may update DocGov hash / proposal applied. + // Squad may have already written candidate body to workDir/output.md — pending must restore ledger head. + if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" { + actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} + mat, matErr := s.materializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ + DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), + ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody, + ProjectID: req.ProjectID, WorkDir: res.WorkDir, + ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor, + AutoCommitUserID: req.UserID, + }) + if matErr != nil { + reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr) + return &TaskRunResult{ + TaskKey: res.TaskKey, + ArtifactTitle: res.ArtifactTitle, + MarkdownBody: res.MarkdownBody, + ExtractedData: res.ExtractedData, + Attachments: res.Attachments, + WorkDir: res.WorkDir, + BackendUsed: res.BackendUsed, + Duration: res.Duration, + Success: false, + ErrorReason: reason, + QualityReport: res.QualityReport, + Manifest: res.Manifest, + AdvisorBrief: res.AdvisorBrief, + DocRecord: docRec, + }, matErr + } + + if mat != nil && mat.Candidate != nil && mat.Candidate.Status == docrevision.CandidateNeedsConfirmation { + if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil { + reason := fmt.Sprintf("高风险待确认:恢复账本 head 到事实源失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + headSHA := "" + if mat.State != nil { + headSHA = mat.State.HeadSHA256 + } + if headSHA != "" { + if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil { + reason := fmt.Sprintf("高风险待确认:恢复 document_records 哈希失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + } + if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { + docRec = latest + } + risk := "" + if mat.Report != nil { + risk = mat.Report.RiskLevel + } + var pendCand, pendRep int64 + if mat.Candidate != nil { + pendCand = mat.Candidate.ID + if mat.Candidate.ChangeReportID != nil { + pendRep = *mat.Candidate.ChangeReportID + } + } + note := fmt.Sprintf( + "高风险变更(%s)待编制人确认:候选 #%d。正式正文仍为当前账本 head,候选正文仅保留在不可变快照中;请打开「待确认报告」确认提交或拒绝。", + risk, pendCand, + ) + s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, + fmt.Sprintf("交付物【%s】候选待编制人确认", res.ArtifactTitle), + map[string]any{ + "task_key": res.TaskKey, + "work_dir": res.WorkDir, + "doc_id": docID, + "pending_agent_candidate_id": pendCand, + "pending_change_report_id": pendRep, + "pending_risk_level": risk, + "awaiting_author_confirmation": true, + }, true) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir, + BackendUsed: res.BackendUsed, Duration: res.Duration, Success: true, + QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief, + DocRecord: docRec, + // RequiresAuthorApproval signals "needs human decision"; distinguish from DocGov + // proposal intercept via PendingAgentCandidateID (no PendingProposal). + RequiresAuthorApproval: true, + PendingAgentCandidateID: pendCand, PendingChangeReportID: pendRep, + PendingRiskLevel: risk, PendingConfirmationNote: note, + }, nil + } + + // Committed (auto-commit or ready path): project head then update DocGov. + if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil { + reason := fmt.Sprintf("账本已提交但工作区投影失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + headSHA := "" + if mat != nil && mat.State != nil { + headSHA = mat.State.HeadSHA256 + } + if headSHA == "" { + sha := sha256.Sum256([]byte(res.MarkdownBody)) + headSHA = hex.EncodeToString(sha[:]) + } + if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil { + reason := fmt.Sprintf("账本已提交但 DocGov 哈希投影失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + if appliedProposalID != "" { + if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); err != nil { + reason := fmt.Sprintf("账本已提交但变更提案标记失败(可重试): %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + } + if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { + docRec = latest + } + s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, + fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle), + map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true) + } else { + // No docrevision: preserve prior best-effort hash update (CLI/legacy). + sha := sha256.Sum256([]byte(res.MarkdownBody)) + shaStr := hex.EncodeToString(sha[:]) + _ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false) + if appliedProposalID != "" { + if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); err != nil { + reason := fmt.Sprintf("正文已更新但变更提案标记失败(可重试): %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + } + if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { + docRec = latest + } + s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, + fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle), + map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true) + } + } else if res.Success { + // Uncontrolled / no DocGov path: squad success is the completion boundary. + s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle, + fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle), + map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir}, true) + } + + if res.Success && workflowdef.SameTaskKey(req.TaskKey, "test_manager") && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 { + _ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{ + ProjectID: req.ProjectID, + DownstreamDocID: docID, + DownstreamVersion: lineageVersion(docRec), + UpstreamDocID: upstreamSRDoc.DocID, + UpstreamVersion: lineageVersion(upstreamSRDoc), + UpstreamSHA256: upstreamSRDoc.ContentSHA256, + IsStale: false, + }) + } + // Stale propagation only after fact source committed (not while pending confirmation). + if res.Success && workflowdef.SameTaskKey(req.TaskKey, "system_reqs") && s.projectWorkflow != nil && req.ProjectID > 0 && + (s.docRev == nil || (docRec != nil && docRec.ContentSHA256 != "")) { + sha := sha256.Sum256([]byte(res.MarkdownBody)) + shaStr := hex.EncodeToString(sha[:]) + if docRec != nil && docRec.ContentSHA256 != "" { + shaStr = docRec.ContentSHA256 + } + _, _ = s.projectWorkflow.CheckAndPropagateStale(ctx, docID, shaStr, "上游系统需求规格书已更新,下游验证方案需复核") + } + + return &TaskRunResult{ + TaskKey: res.TaskKey, + ArtifactTitle: res.ArtifactTitle, + MarkdownBody: res.MarkdownBody, + ExtractedData: res.ExtractedData, + Attachments: res.Attachments, + WorkDir: res.WorkDir, + BackendUsed: res.BackendUsed, + Duration: res.Duration, + Success: res.Success, + ErrorReason: res.ErrorReason, + QualityReport: res.QualityReport, + Manifest: res.Manifest, + AdvisorBrief: res.AdvisorBrief, + DocRecord: docRec, + }, nil +} + +// recordSquadDeliveryActivity writes project activity only for explicit outcomes. +// Callers must not invoke this for Materialize/projection failures. +func (s *Service) recordSquadDeliveryActivity(ctx context.Context, req TaskRunRequest, artifactTitle, title string, detail map[string]any, milestone bool) { + if s == nil || s.ctxMgr == nil || req.ProjectID <= 0 || strings.TrimSpace(title) == "" { + return + } + if detail == nil { + detail = map[string]any{} + } + if _, ok := detail["task_key"]; !ok && req.TaskKey != "" { + detail["task_key"] = req.TaskKey + } + if artifactTitle != "" { + detail["artifact_title"] = artifactTitle + } + _, _ = s.ctxMgr.RecordActivity(ctx, req.ProjectID, req.UserID, "系统智能体小队", "operation", + title, detail, 1.0, milestone) +} + +// ExecuteStage 执行指定成熟度阶段 diff --git a/internal/bizdev/service_task_run.go b/internal/bizdev/service_task_run.go new file mode 100644 index 00000000..375f5683 --- /dev/null +++ b/internal/bizdev/service_task_run.go @@ -0,0 +1,678 @@ +package bizdev + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "chat-agent-system/internal/contracts/vocabulary" + "chat-agent-system/internal/docduty" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/kb" + "chat-agent-system/internal/projectcontext" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowdef" + "chat-agent-system/internal/workspace" +) + +func (s *Service) materializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) { + if s != nil && s.materializer != nil { + return s.materializer.MaterializeAgentOutput(ctx, req) + } + if s == nil || s.docRev == nil { + return nil, fmt.Errorf("bizdev: document revision unavailable") + } + return s.docRev.MaterializeAgentOutput(ctx, req) +} + +var SupportedTasks = squad.SupportedTasks + +var SupportedStages = []StageMeta{ + { + Stage: StageV01_Triage, + Title: "V0.1 闪电摸底与盲区探测", + Description: "快速梳理客户诉求与约束,建立基准技术假设,生成首期澄清微信话术与初始需求表", + Tasks: []string{"bd_intake"}, + }, + { + Stage: StageV05_Feasibility, + Title: "V0.5 方案成型与法规对齐", + Description: "法规与注册路径初筛、竞品调研、系统初步架构设计与关键选型建议", + Tasks: []string{"bd_research", "bd_feasibility_dev", "bd_feasibility_reg", "bd_user_requirements", "bd_prelim_design"}, + }, + { + Stage: StageV10_Quoting, + Title: "V1.0 工程拆解与商业锁定", + Description: "全维度 WBS 工时拆解、Excel 定价核算、正式 CDO 商务报价单与谈判防御条款建议", + Tasks: []string{"bd_cdo_quote"}, + }, +} + +func taskIs(key string, wants ...string) bool { + for _, want := range wants { + if workflowdef.SameTaskKey(key, want) { + return true + } + } + return false +} + +func resolveDocStage(req TaskRunRequest) string { + if sk := strings.TrimSpace(req.StageKey); sk != "" { + return sk + } + switch workflowdef.ImplementationTaskKey(strings.TrimSpace(req.TaskKey)) { + case "intake": + return string(StageV01_Triage) + case "research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design": + return string(StageV05_Feasibility) + case "cdo_quote": + return string(StageV10_Quoting) + default: + return string(StageV05_Feasibility) + } +} + +const rdevPrerequisiteMessage = "请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程" + +func (s *Service) ensureRDevMainCompleted(ctx context.Context, projectID int64) error { + if projectID <= 0 { + return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage) + } + ok, err := s.hasCompletedMainInstance(ctx, projectID) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage) + } + return nil +} + +func (s *Service) hasCompletedMainInstance(ctx context.Context, projectID int64) (bool, error) { + if s.completedMainHook != nil { + return s.completedMainHook(ctx, projectID) + } + if s.pool == nil { + return false, nil + } + var ok bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM project_workflow_instances + WHERE project_id = $1 + AND status = 'completed' + AND config_snapshot->>'workflow_definition_key' = 'main' + ) + `, projectID).Scan(&ok) + return ok, err +} + +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。 diff --git a/internal/freedoc/service.go b/internal/freedoc/service.go index 05e3b866..254afed0 100644 --- a/internal/freedoc/service.go +++ b/internal/freedoc/service.go @@ -1,19 +1,13 @@ package freedoc import ( - "context" "crypto/sha256" "encoding/hex" - "errors" - "fmt" - "strings" "sync" - "time" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docrevision" - "chat-agent-system/internal/replyexport" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) @@ -52,977 +46,3 @@ func computeSHA256(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:]) } - -// CreateFromMessage 从单条智能体回复保存为私密文档(幂等) -func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateFromMessageRequest) (*FreeformDocDetail, bool, error) { - if req.MessageID <= 0 { - return nil, false, fmt.Errorf("%w: invalid message_id", ErrInvalidParam) - } - - // 1. 获取源消息 - if s.ctxMgr == nil { - return nil, false, errors.New("ctxMgr is not configured") - } - msg, err := s.ctxMgr.GetMessageByID(ctx, req.MessageID) - if err != nil { - return nil, false, fmt.Errorf("lookup message: %w", err) - } - if msg == nil { - return nil, false, ErrNotFound - } - - // 2. 校验导出资格红线:必须是助手且非纯卡片且 export_policy != 'none' - if msg.Role != "assistant" || msg.ContentKind == "card_only" || msg.ExportPolicy == "none" || len(msg.Content) == 0 { - return nil, false, ErrMessageNotExportable - } - - content := msg.Content - contentHash := computeSHA256(content) - if req.ExpectedSHA256 != "" && req.ExpectedSHA256 != contentHash { - return nil, false, ErrConflict - } - - title := req.Title - if title == "" { - title = replyexport.DeriveTitle(content, time.Now()) - } - - projectID := req.ProjectID - if projectID <= 0 { - projectID = 1 // 兜底默认项目 - } - - // 3. 幂等性检查:若该用户针对该消息已生成且未删除,直接返回 - if s.pool != nil { - var existingDoc FreeformDocument - row := s.pool.QueryRow(ctx, ` - SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, legacy_version_id, current_sha256, - is_deleted, deleted_at, created_at, updated_at - FROM freeform_documents - WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE - LIMIT 1 - `, userID, req.MessageID) - if err := row.Scan( - &existingDoc.DocID, &existingDoc.ProjectID, &existingDoc.OwnerUserID, &existingDoc.SourceMessageID, - &existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode, - &existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256, - &existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt, - ); err == nil { - // 获取当前版本的 markdown - var curContent string - _ = s.pool.QueryRow(ctx, ` - SELECT content_markdown FROM freeform_document_versions - WHERE doc_id = $1 AND version_no = $2 - `, existingDoc.DocID, existingDoc.CurrentVersionID).Scan(&curContent) - return &FreeformDocDetail{ - Document: existingDoc, - Content: curContent, - }, true, nil - } - - docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID) - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, false, err - } - defer tx.Rollback(ctx) - - now := time.Now() - _, err = tx.Exec(ctx, ` - INSERT INTO freeform_documents ( - doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, legacy_version_id, current_sha256, - is_deleted, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8) - `, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now) - if err != nil { - return nil, false, fmt.Errorf("insert freeform_doc: %w", err) - } - - _, err = tx.Exec(ctx, ` - INSERT INTO freeform_document_versions ( - doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at - ) VALUES ($1, 1, $2, $3, $4, '从智能体回复保存', $5) - `, docID, contentHash, content, userID, now) - if err != nil { - return nil, false, fmt.Errorf("insert version: %w", err) - } - - _, err = tx.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) - VALUES ($1, $2, 'create_from_message', TRUE, $3) - `, docID, userID, now) - if err != nil { - return nil, false, fmt.Errorf("insert audit: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return nil, false, err - } - - createdDoc := FreeformDocument{ - DocID: docID, - ProjectID: projectID, - OwnerUserID: userID, - SourceMessageID: &req.MessageID, - SourceContentSHA256: contentHash, - Title: title, - GovernanceMode: "freeform", - Space: "my_private", - CurrentVersionID: 1, - CurrentSHA256: contentHash, - IsDeleted: false, - CreatedAt: now, - UpdatedAt: now, - } - return &FreeformDocDetail{ - Document: createdDoc, - Content: content, - }, false, nil - } - - // 内存备用实现 - s.mu.Lock() - defer s.mu.Unlock() - - for _, d := range s.memDocs { - if d.OwnerUserID == userID && d.SourceMessageID != nil && *d.SourceMessageID == req.MessageID && !d.IsDeleted { - versions := s.memVersions[d.DocID] - content := "" - if len(versions) > 0 { - content = versions[len(versions)-1].ContentMarkdown - } - return &FreeformDocDetail{ - Document: *d, - Content: content, - }, true, nil - } - } - - docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID) - now := time.Now() - newDoc := &FreeformDocument{ - DocID: docID, - ProjectID: projectID, - OwnerUserID: userID, - SourceMessageID: &req.MessageID, - SourceContentSHA256: contentHash, - Title: title, - GovernanceMode: "freeform", - Space: "my_private", - CurrentVersionID: 1, - CurrentSHA256: contentHash, - IsDeleted: false, - CreatedAt: now, - UpdatedAt: now, - } - s.memDocs[docID] = newDoc - s.memVersions[docID] = []*FreeformDocumentVersion{ - { - ID: 1, - DocID: docID, - VersionNo: 1, - ContentSHA256: contentHash, - ContentMarkdown: content, - CreatedBy: userID, - ChangeSummary: "从智能体回复保存", - CreatedAt: now, - }, - } - return &FreeformDocDetail{ - Document: *newDoc, - Content: content, - }, false, nil -} - -func clientKeyLookup(userID int, clientKey string) string { - return fmt.Sprintf("%d|%s", userID, strings.TrimSpace(clientKey)) -} - -func (s *Service) bootstrapLedger(ctx context.Context, userID int, detail *FreeformDocDetail) { - if s == nil || s.docRev == nil || detail == nil { - return - } - md := detail.Content - if strings.TrimSpace(md) == "" { - return - } - _, _ = s.docRev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{ - DocID: detail.Document.DocID, - Kind: "freeform", - Markdown: md, - ProjectID: int64(detail.Document.ProjectID), - LifecycleState: "drafting", - Actor: docrevision.ActorSnapshot{ - DisplayName: "freeform-owner", - Role: "owner", - }, - }) -} - -func (s *Service) findByClientKeyPG(ctx context.Context, userID int, clientKey string) (*FreeformDocDetail, error) { - var docID string - err := s.pool.QueryRow(ctx, ` - SELECT doc_id FROM freeform_documents - WHERE owner_user_id = $1 AND client_key = $2 AND is_deleted = FALSE - LIMIT 1 - `, userID, clientKey).Scan(&docID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return nil, err - } - return s.GetDocument(ctx, userID, docID) -} - -// CreateDocument 直接创建自由文档(上传附件 / 自定义正文的服务端事实源)。 -// client_key 持久化于 freeform_documents.client_key,与 owner 唯一;禁止污染 source_content_sha256。 -// 创建后立即 EnsureBootstrap 版本账本,使 revision-state 可读、PATCH 可持久化。 -func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFreeformDocRequest) (*FreeformDocDetail, error) { - title := strings.TrimSpace(req.Title) - content := req.ContentMarkdown - if title == "" { - return nil, fmt.Errorf("%w: title required", ErrInvalidParam) - } - if strings.TrimSpace(content) == "" { - return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam) - } - space := strings.TrimSpace(req.Space) - if space == "" { - space = "my_private" - } - // Direct create is upload/private registration only; project_shared must go through ShareDocument. - if space != "my_private" { - return nil, fmt.Errorf("%w: direct create only allows my_private; use share API", ErrInvalidParam) - } - projectID := req.ProjectID - if projectID <= 0 { - return nil, fmt.Errorf("%w: project_id required", ErrInvalidParam) - } - clientKey := strings.TrimSpace(req.ClientKey) - var clientKeyPtr *string - if clientKey != "" { - clientKeyPtr = &clientKey - } - - if clientKey != "" { - if s.pool != nil { - existing, err := s.findByClientKeyPG(ctx, userID, clientKey) - if err != nil { - return nil, err - } - if existing != nil { - return existing, nil - } - } else { - s.mu.RLock() - existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)] - s.mu.RUnlock() - if existingID != "" { - return s.GetDocument(ctx, userID, existingID) - } - } - } - - contentHash := computeSHA256(content) - docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID) - now := time.Now() - - if s.pool != nil { - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - _, err = tx.Exec(ctx, ` - INSERT INTO freeform_documents ( - doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - client_key, title, governance_mode, space, legacy_version_id, current_sha256, - is_deleted, created_at, updated_at - ) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8) - `, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now) - if err != nil { - // Unique race: another request inserted the same owner+client_key - if clientKey != "" { - if existing, findErr := s.findByClientKeyPG(ctx, userID, clientKey); findErr == nil && existing != nil { - return existing, nil - } - } - return nil, fmt.Errorf("insert freeform_doc: %w", err) - } - _, err = tx.Exec(ctx, ` - INSERT INTO freeform_document_versions ( - doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at - ) VALUES ($1, 1, $2, $3, $4, '上传附件注册', $5) - `, docID, contentHash, content, userID, now) - if err != nil { - return nil, fmt.Errorf("insert version: %w", err) - } - _, err = tx.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) - VALUES ($1, $2, 'create_upload', TRUE, $3) - `, docID, userID, now) - if err != nil { - return nil, fmt.Errorf("insert audit: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - detail := &FreeformDocDetail{ - Document: FreeformDocument{ - DocID: docID, - ProjectID: projectID, - OwnerUserID: userID, - SourceContentSHA256: contentHash, - ClientKey: clientKeyPtr, - Title: title, - GovernanceMode: "freeform", - Space: space, - CurrentVersionID: 1, - CurrentSHA256: contentHash, - IsDeleted: false, - CreatedAt: now, - UpdatedAt: now, - }, - Content: content, - } - s.bootstrapLedger(ctx, userID, detail) - return detail, nil - } - - s.mu.Lock() - defer s.mu.Unlock() - if clientKey != "" { - if existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]; existingID != "" { - doc := s.memDocs[existingID] - versions := s.memVersions[existingID] - cur := "" - if len(versions) > 0 { - cur = versions[len(versions)-1].ContentMarkdown - } - if doc != nil { - return &FreeformDocDetail{Document: *doc, Content: cur}, nil - } - } - } - newDoc := &FreeformDocument{ - DocID: docID, - ProjectID: projectID, - OwnerUserID: userID, - SourceContentSHA256: contentHash, - ClientKey: clientKeyPtr, - Title: title, - GovernanceMode: "freeform", - Space: space, - CurrentVersionID: 1, - CurrentSHA256: contentHash, - IsDeleted: false, - CreatedAt: now, - UpdatedAt: now, - } - s.memDocs[docID] = newDoc - s.memVersions[docID] = []*FreeformDocumentVersion{{ - ID: 1, DocID: docID, VersionNo: 1, ContentSHA256: contentHash, - ContentMarkdown: content, CreatedBy: userID, ChangeSummary: "上传附件注册", CreatedAt: now, - }} - if clientKey != "" { - s.memClientKeys[clientKeyLookup(userID, clientKey)] = docID - } - detail := &FreeformDocDetail{Document: *newDoc, Content: content} - s.bootstrapLedger(ctx, userID, detail) - return detail, nil -} - -// GetDocument 获取文档详情及内容,严格执行私密与共享访问控制 -func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) { - if docID == "" { - return nil, ErrNotFound - } - - if s.pool != nil { - var doc FreeformDocument - row := s.pool.QueryRow(ctx, ` - SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, legacy_version_id, current_sha256, - is_deleted, deleted_at, created_at, updated_at - FROM freeform_documents - WHERE doc_id = $1 - `, docID) - if err := row.Scan( - &doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID, - &doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode, - &doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256, - &doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt, - ); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - - if doc.IsDeleted { - return nil, ErrNotFound - } - - // 私密隔离红线:my_private 只有 owner 可读 - if doc.Space == "my_private" && doc.OwnerUserID != userID { - _, _ = s.pool.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success) - VALUES ($1, $2, 'read_denied', FALSE) - `, docID, userID) - return nil, ErrForbidden - } - - var content string - err := s.pool.QueryRow(ctx, ` - SELECT content_markdown FROM freeform_document_versions - WHERE doc_id = $1 AND version_no = $2 - `, doc.DocID, doc.CurrentVersionID).Scan(&content) - if err != nil { - return nil, fmt.Errorf("read version: %w", err) - } - - _, _ = s.pool.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success) - VALUES ($1, $2, 'read', TRUE) - `, docID, userID) - - return &FreeformDocDetail{ - Document: doc, - Content: content, - }, nil - } - - // 内存模式 - s.mu.RLock() - defer s.mu.RUnlock() - - doc, ok := s.memDocs[docID] - if !ok || doc.IsDeleted { - return nil, ErrNotFound - } - if doc.Space == "my_private" && doc.OwnerUserID != userID { - return nil, ErrForbidden - } - - versions := s.memVersions[docID] - content := "" - for _, v := range versions { - if v.VersionNo == doc.CurrentVersionID { - content = v.ContentMarkdown - break - } - } - - return &FreeformDocDetail{ - Document: *doc, - Content: content, - }, nil -} - -// ListDocuments 按空间与权限列出自由文档概要 -func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64, space string) ([]FreeformDocSummary, error) { - if space != "my_private" && space != "project_shared" && space != "trash" { - space = "my_private" - } - - if s.pool != nil { - var rows pgx.Rows - var err error - if space == "my_private" { - // 我的私密:属于当前用户 - if projectID > 0 { - rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at - FROM freeform_documents - WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE - ORDER BY updated_at DESC - `, userID, projectID) - } else { - rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at - FROM freeform_documents - WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE - ORDER BY updated_at DESC - `, userID) - } - } else if space == "project_shared" { - // 项目共享参考:属于当前项目的所有公开参考 - rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at - FROM freeform_documents - WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE - ORDER BY updated_at DESC - `, projectID) - } else { - // 回收站:当前用户删除的 - rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at - FROM freeform_documents - WHERE owner_user_id = $1 AND is_deleted = TRUE - ORDER BY deleted_at DESC - `, userID) - } - if err != nil { - return nil, err - } - defer rows.Close() - - var results []FreeformDocSummary - for rows.Next() { - var sm FreeformDocSummary - if err := rows.Scan( - &sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space, - &sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt, - ); err != nil { - return nil, err - } - results = append(results, sm) - } - return results, rows.Err() - } - - // 内存模式 - s.mu.RLock() - defer s.mu.RUnlock() - - var results []FreeformDocSummary - for _, d := range s.memDocs { - if space == "my_private" { - if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted { - if projectID <= 0 || d.ProjectID == projectID { - results = append(results, FreeformDocSummary{ - DocID: d.DocID, - ProjectID: d.ProjectID, - OwnerUserID: d.OwnerUserID, - Title: d.Title, - Space: d.Space, - CurrentVersionID: d.CurrentVersionID, - CurrentSHA256: d.CurrentSHA256, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - }) - } - } - } else if space == "project_shared" { - if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted { - results = append(results, FreeformDocSummary{ - DocID: d.DocID, - ProjectID: d.ProjectID, - OwnerUserID: d.OwnerUserID, - Title: d.Title, - Space: d.Space, - CurrentVersionID: d.CurrentVersionID, - CurrentSHA256: d.CurrentSHA256, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - }) - } - } - } - return results, nil -} - -// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制 -func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, req UpdateFreeformDocRequest) (*FreeformDocDetail, error) { - if docID == "" { - return nil, ErrNotFound - } - - if s.pool != nil { - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - var doc FreeformDocument - row := tx.QueryRow(ctx, ` - SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, legacy_version_id, current_sha256, - is_deleted, created_at, updated_at - FROM freeform_documents - WHERE doc_id = $1 FOR UPDATE - `, docID) - if err := row.Scan( - &doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID, - &doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode, - &doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256, - &doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt, - ); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - - if doc.IsDeleted { - return nil, ErrNotFound - } - - // 私密文档非本人严禁修改 - if doc.Space == "my_private" && doc.OwnerUserID != userID { - return nil, ErrForbidden - } - - // 乐观并发冲突检验 - if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID { - return nil, ErrConflict - } - if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 { - return nil, ErrConflict - } - - now := time.Now() - newTitle := doc.Title - if req.Title != nil && *req.Title != "" { - newTitle = *req.Title - } - - curContent := "" - _ = tx.QueryRow(ctx, `SELECT content_markdown FROM freeform_document_versions WHERE doc_id = $1 AND version_no = $2`, doc.DocID, doc.CurrentVersionID).Scan(&curContent) - - newContent := curContent - if req.ContentMarkdown != nil { - newContent = *req.ContentMarkdown - } - - newHash := computeSHA256(newContent) - if newHash != doc.CurrentSHA256 { - // 内容变更,生成新版本 - nextVersion := doc.CurrentVersionID + 1 - summary := req.ChangeSummary - if summary == "" { - summary = "编辑修改正文" - } - _, err = tx.Exec(ctx, ` - INSERT INTO freeform_document_versions ( - doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - `, doc.DocID, nextVersion, newHash, newContent, userID, summary, now) - if err != nil { - return nil, fmt.Errorf("insert version: %w", err) - } - - _, err = tx.Exec(ctx, ` - UPDATE freeform_documents - SET title = $1, legacy_version_id = $2, current_sha256 = $3, updated_at = $4 - WHERE doc_id = $5 - `, newTitle, nextVersion, newHash, now, doc.DocID) - if err != nil { - return nil, fmt.Errorf("update doc: %w", err) - } - - doc.CurrentVersionID = nextVersion - doc.CurrentSHA256 = newHash - } else if newTitle != doc.Title { - // 仅标题变更 - _, err = tx.Exec(ctx, ` - UPDATE freeform_documents - SET title = $1, updated_at = $2 - WHERE doc_id = $3 - `, newTitle, now, doc.DocID) - if err != nil { - return nil, fmt.Errorf("update doc title: %w", err) - } - } - - doc.Title = newTitle - doc.UpdatedAt = now - - _, _ = tx.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) - VALUES ($1, $2, 'update', TRUE, $3) - `, doc.DocID, userID, now) - - if err := tx.Commit(ctx); err != nil { - return nil, err - } - - return &FreeformDocDetail{ - Document: doc, - Content: newContent, - }, nil - } - - // 内存模式 - s.mu.Lock() - defer s.mu.Unlock() - - doc, ok := s.memDocs[docID] - if !ok || doc.IsDeleted { - return nil, ErrNotFound - } - if doc.Space == "my_private" && doc.OwnerUserID != userID { - return nil, ErrForbidden - } - if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID { - return nil, ErrConflict - } - if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 { - return nil, ErrConflict - } - - now := time.Now() - newTitle := doc.Title - if req.Title != nil && *req.Title != "" { - newTitle = *req.Title - } - doc.Title = newTitle - - curContent := "" - versions := s.memVersions[docID] - if len(versions) > 0 { - curContent = versions[len(versions)-1].ContentMarkdown - } - - newContent := curContent - if req.ContentMarkdown != nil { - newContent = *req.ContentMarkdown - } - - newHash := computeSHA256(newContent) - if newHash != doc.CurrentSHA256 { - nextVersion := doc.CurrentVersionID + 1 - doc.CurrentVersionID = nextVersion - doc.CurrentSHA256 = newHash - s.memVersions[docID] = append(s.memVersions[docID], &FreeformDocumentVersion{ - ID: int64(len(s.memVersions[docID]) + 1), - DocID: docID, - VersionNo: nextVersion, - ContentSHA256: newHash, - ContentMarkdown: newContent, - CreatedBy: userID, - ChangeSummary: req.ChangeSummary, - CreatedAt: now, - }) - } - doc.UpdatedAt = now - detail := &FreeformDocDetail{ - Document: *doc, - Content: newContent, - } - s.syncLedgerLocked(ctx, userID, detail) - return detail, nil -} - -// syncLedgerLocked imports legacy checkpoints then records human revisions on the shared ledger. -func (s *Service) syncLedgerLocked(ctx context.Context, userID int, detail *FreeformDocDetail) { - if s.docRev == nil || detail == nil { - return - } - doc := detail.Document - checkpoints := make([]struct { - VersionNo int - Markdown string - SHA string - }, 0, len(s.memVersions[doc.DocID])) - for _, v := range s.memVersions[doc.DocID] { - checkpoints = append(checkpoints, struct { - VersionNo int - Markdown string - SHA string - }{VersionNo: v.VersionNo, Markdown: v.ContentMarkdown, SHA: v.ContentSHA256}) - } - prior := checkpoints - if len(prior) > 0 { - prior = prior[:len(prior)-1] - } - st, err := s.docRev.ImportFreeformLegacy(ctx, doc.DocID, int64(userID), int64(doc.ProjectID), prior, detail.Content) - if err != nil || st == nil { - return - } - _, _ = s.docRev.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ - DocID: doc.DocID, Markdown: detail.Content, - ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, - IdempotencyKey: fmt.Sprintf("freeform:%s:%s", doc.DocID, detail.Document.CurrentSHA256), - ActorUserID: int64(userID), - ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "freeform-owner", Role: "owner"}, - LifecycleState: "drafting", - ProjectID: int64(doc.ProjectID), - }) -} - -// ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作) -func (s *Service) ShareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) { - if docID == "" { - return nil, ErrNotFound - } - - if s.pool != nil { - var doc FreeformDocument - row := s.pool.QueryRow(ctx, ` - SELECT doc_id, project_id, owner_user_id, space, is_deleted - FROM freeform_documents WHERE doc_id = $1 - `, docID) - if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - if doc.IsDeleted { - return nil, ErrNotFound - } - if doc.OwnerUserID != userID { - return nil, ErrForbidden - } - - now := time.Now() - _, err := s.pool.Exec(ctx, ` - UPDATE freeform_documents - SET space = 'project_shared', updated_at = $1 - WHERE doc_id = $2 - `, now, docID) - if err != nil { - return nil, err - } - - _, _ = s.pool.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) - VALUES ($1, $2, 'share', TRUE, $3) - `, docID, userID, now) - - doc.Space = "project_shared" - doc.UpdatedAt = now - return &doc, nil - } - - // 内存模式 - s.mu.Lock() - defer s.mu.Unlock() - doc, ok := s.memDocs[docID] - if !ok || doc.IsDeleted { - return nil, ErrNotFound - } - if doc.OwnerUserID != userID { - return nil, ErrForbidden - } - doc.Space = "project_shared" - doc.UpdatedAt = time.Now() - return doc, nil -} - -// UnshareDocument 撤回共享,恢复为个人私密 -func (s *Service) UnshareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) { - if docID == "" { - return nil, ErrNotFound - } - - if s.pool != nil { - var doc FreeformDocument - row := s.pool.QueryRow(ctx, ` - SELECT doc_id, project_id, owner_user_id, space, is_deleted - FROM freeform_documents WHERE doc_id = $1 - `, docID) - if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - if doc.IsDeleted { - return nil, ErrNotFound - } - if doc.OwnerUserID != userID { - return nil, ErrForbidden - } - - now := time.Now() - _, err := s.pool.Exec(ctx, ` - UPDATE freeform_documents - SET space = 'my_private', updated_at = $1 - WHERE doc_id = $2 - `, now, docID) - if err != nil { - return nil, err - } - - _, _ = s.pool.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) - VALUES ($1, $2, 'unshare', TRUE, $3) - `, docID, userID, now) - - doc.Space = "my_private" - doc.UpdatedAt = now - return &doc, nil - } - - // 内存模式 - s.mu.Lock() - defer s.mu.Unlock() - doc, ok := s.memDocs[docID] - if !ok || doc.IsDeleted { - return nil, ErrNotFound - } - if doc.OwnerUserID != userID { - return nil, ErrForbidden - } - doc.Space = "my_private" - doc.UpdatedAt = time.Now() - return doc, nil -} - -// DeleteDocument 软删除文档(仅 owner 可操作) -func (s *Service) DeleteDocument(ctx context.Context, userID int, docID string) error { - if docID == "" { - return ErrNotFound - } - - if s.pool != nil { - var doc FreeformDocument - row := s.pool.QueryRow(ctx, ` - SELECT doc_id, owner_user_id, is_deleted - FROM freeform_documents WHERE doc_id = $1 - `, docID) - if err := row.Scan(&doc.DocID, &doc.OwnerUserID, &doc.IsDeleted); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - return err - } - if doc.IsDeleted { - return nil - } - if doc.OwnerUserID != userID { - return ErrForbidden - } - - now := time.Now() - _, err := s.pool.Exec(ctx, ` - UPDATE freeform_documents - SET is_deleted = TRUE, deleted_at = $1, space = 'trash' - WHERE doc_id = $2 - `, now, docID) - if err != nil { - return err - } - - _, _ = s.pool.Exec(ctx, ` - INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) - VALUES ($1, $2, 'delete', TRUE, $3) - `, docID, userID, now) - - return nil - } - - // 内存模式 - s.mu.Lock() - defer s.mu.Unlock() - doc, ok := s.memDocs[docID] - if !ok || doc.IsDeleted { - return ErrNotFound - } - if doc.OwnerUserID != userID { - return ErrForbidden - } - doc.IsDeleted = true - now := time.Now() - doc.DeletedAt = &now - doc.Space = "trash" - return nil -} diff --git a/internal/freedoc/service_create.go b/internal/freedoc/service_create.go new file mode 100644 index 00000000..b6157f16 --- /dev/null +++ b/internal/freedoc/service_create.go @@ -0,0 +1,398 @@ +package freedoc + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/replyexport" + + "github.com/jackc/pgx/v5" +) + +// CreateFromMessage 从单条智能体回复保存为私密文档(幂等) +func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateFromMessageRequest) (*FreeformDocDetail, bool, error) { + if req.MessageID <= 0 { + return nil, false, fmt.Errorf("%w: invalid message_id", ErrInvalidParam) + } + + // 1. 获取源消息 + if s.ctxMgr == nil { + return nil, false, errors.New("ctxMgr is not configured") + } + msg, err := s.ctxMgr.GetMessageByID(ctx, req.MessageID) + if err != nil { + return nil, false, fmt.Errorf("lookup message: %w", err) + } + if msg == nil { + return nil, false, ErrNotFound + } + + // 2. 校验导出资格红线:必须是助手且非纯卡片且 export_policy != 'none' + if msg.Role != "assistant" || msg.ContentKind == "card_only" || msg.ExportPolicy == "none" || len(msg.Content) == 0 { + return nil, false, ErrMessageNotExportable + } + + content := msg.Content + contentHash := computeSHA256(content) + if req.ExpectedSHA256 != "" && req.ExpectedSHA256 != contentHash { + return nil, false, ErrConflict + } + + title := req.Title + if title == "" { + title = replyexport.DeriveTitle(content, time.Now()) + } + + projectID := req.ProjectID + if projectID <= 0 { + projectID = 1 // 兜底默认项目 + } + + // 3. 幂等性检查:若该用户针对该消息已生成且未删除,直接返回 + if s.pool != nil { + var existingDoc FreeformDocument + row := s.pool.QueryRow(ctx, ` + SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, + is_deleted, deleted_at, created_at, updated_at + FROM freeform_documents + WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE + LIMIT 1 + `, userID, req.MessageID) + if err := row.Scan( + &existingDoc.DocID, &existingDoc.ProjectID, &existingDoc.OwnerUserID, &existingDoc.SourceMessageID, + &existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode, + &existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256, + &existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt, + ); err == nil { + // 获取当前版本的 markdown + var curContent string + _ = s.pool.QueryRow(ctx, ` + SELECT content_markdown FROM freeform_document_versions + WHERE doc_id = $1 AND version_no = $2 + `, existingDoc.DocID, existingDoc.CurrentVersionID).Scan(&curContent) + return &FreeformDocDetail{ + Document: existingDoc, + Content: curContent, + }, true, nil + } + + docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID) + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, false, err + } + defer tx.Rollback(ctx) + + now := time.Now() + _, err = tx.Exec(ctx, ` + INSERT INTO freeform_documents ( + doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, + is_deleted, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8) + `, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now) + if err != nil { + return nil, false, fmt.Errorf("insert freeform_doc: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO freeform_document_versions ( + doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at + ) VALUES ($1, 1, $2, $3, $4, '从智能体回复保存', $5) + `, docID, contentHash, content, userID, now) + if err != nil { + return nil, false, fmt.Errorf("insert version: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) + VALUES ($1, $2, 'create_from_message', TRUE, $3) + `, docID, userID, now) + if err != nil { + return nil, false, fmt.Errorf("insert audit: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, false, err + } + + createdDoc := FreeformDocument{ + DocID: docID, + ProjectID: projectID, + OwnerUserID: userID, + SourceMessageID: &req.MessageID, + SourceContentSHA256: contentHash, + Title: title, + GovernanceMode: "freeform", + Space: "my_private", + CurrentVersionID: 1, + CurrentSHA256: contentHash, + IsDeleted: false, + CreatedAt: now, + UpdatedAt: now, + } + return &FreeformDocDetail{ + Document: createdDoc, + Content: content, + }, false, nil + } + + // 内存备用实现 + s.mu.Lock() + defer s.mu.Unlock() + + for _, d := range s.memDocs { + if d.OwnerUserID == userID && d.SourceMessageID != nil && *d.SourceMessageID == req.MessageID && !d.IsDeleted { + versions := s.memVersions[d.DocID] + content := "" + if len(versions) > 0 { + content = versions[len(versions)-1].ContentMarkdown + } + return &FreeformDocDetail{ + Document: *d, + Content: content, + }, true, nil + } + } + + docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID) + now := time.Now() + newDoc := &FreeformDocument{ + DocID: docID, + ProjectID: projectID, + OwnerUserID: userID, + SourceMessageID: &req.MessageID, + SourceContentSHA256: contentHash, + Title: title, + GovernanceMode: "freeform", + Space: "my_private", + CurrentVersionID: 1, + CurrentSHA256: contentHash, + IsDeleted: false, + CreatedAt: now, + UpdatedAt: now, + } + s.memDocs[docID] = newDoc + s.memVersions[docID] = []*FreeformDocumentVersion{ + { + ID: 1, + DocID: docID, + VersionNo: 1, + ContentSHA256: contentHash, + ContentMarkdown: content, + CreatedBy: userID, + ChangeSummary: "从智能体回复保存", + CreatedAt: now, + }, + } + return &FreeformDocDetail{ + Document: *newDoc, + Content: content, + }, false, nil +} + +func clientKeyLookup(userID int, clientKey string) string { + return fmt.Sprintf("%d|%s", userID, strings.TrimSpace(clientKey)) +} + +func (s *Service) bootstrapLedger(ctx context.Context, userID int, detail *FreeformDocDetail) { + if s == nil || s.docRev == nil || detail == nil { + return + } + md := detail.Content + if strings.TrimSpace(md) == "" { + return + } + _, _ = s.docRev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{ + DocID: detail.Document.DocID, + Kind: "freeform", + Markdown: md, + ProjectID: int64(detail.Document.ProjectID), + LifecycleState: "drafting", + Actor: docrevision.ActorSnapshot{ + DisplayName: "freeform-owner", + Role: "owner", + }, + }) +} + +func (s *Service) findByClientKeyPG(ctx context.Context, userID int, clientKey string) (*FreeformDocDetail, error) { + var docID string + err := s.pool.QueryRow(ctx, ` + SELECT doc_id FROM freeform_documents + WHERE owner_user_id = $1 AND client_key = $2 AND is_deleted = FALSE + LIMIT 1 + `, userID, clientKey).Scan(&docID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return s.GetDocument(ctx, userID, docID) +} + +// CreateDocument 直接创建自由文档(上传附件 / 自定义正文的服务端事实源)。 +// client_key 持久化于 freeform_documents.client_key,与 owner 唯一;禁止污染 source_content_sha256。 +// 创建后立即 EnsureBootstrap 版本账本,使 revision-state 可读、PATCH 可持久化。 +func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFreeformDocRequest) (*FreeformDocDetail, error) { + title := strings.TrimSpace(req.Title) + content := req.ContentMarkdown + if title == "" { + return nil, fmt.Errorf("%w: title required", ErrInvalidParam) + } + if strings.TrimSpace(content) == "" { + return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam) + } + space := strings.TrimSpace(req.Space) + if space == "" { + space = "my_private" + } + // Direct create is upload/private registration only; project_shared must go through ShareDocument. + if space != "my_private" { + return nil, fmt.Errorf("%w: direct create only allows my_private; use share API", ErrInvalidParam) + } + projectID := req.ProjectID + if projectID <= 0 { + return nil, fmt.Errorf("%w: project_id required", ErrInvalidParam) + } + clientKey := strings.TrimSpace(req.ClientKey) + var clientKeyPtr *string + if clientKey != "" { + clientKeyPtr = &clientKey + } + + if clientKey != "" { + if s.pool != nil { + existing, err := s.findByClientKeyPG(ctx, userID, clientKey) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + } else { + s.mu.RLock() + existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)] + s.mu.RUnlock() + if existingID != "" { + return s.GetDocument(ctx, userID, existingID) + } + } + } + + contentHash := computeSHA256(content) + docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID) + now := time.Now() + + if s.pool != nil { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, ` + INSERT INTO freeform_documents ( + doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, + client_key, title, governance_mode, space, legacy_version_id, current_sha256, + is_deleted, created_at, updated_at + ) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8) + `, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now) + if err != nil { + // Unique race: another request inserted the same owner+client_key + if clientKey != "" { + if existing, findErr := s.findByClientKeyPG(ctx, userID, clientKey); findErr == nil && existing != nil { + return existing, nil + } + } + return nil, fmt.Errorf("insert freeform_doc: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO freeform_document_versions ( + doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at + ) VALUES ($1, 1, $2, $3, $4, '上传附件注册', $5) + `, docID, contentHash, content, userID, now) + if err != nil { + return nil, fmt.Errorf("insert version: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) + VALUES ($1, $2, 'create_upload', TRUE, $3) + `, docID, userID, now) + if err != nil { + return nil, fmt.Errorf("insert audit: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + detail := &FreeformDocDetail{ + Document: FreeformDocument{ + DocID: docID, + ProjectID: projectID, + OwnerUserID: userID, + SourceContentSHA256: contentHash, + ClientKey: clientKeyPtr, + Title: title, + GovernanceMode: "freeform", + Space: space, + CurrentVersionID: 1, + CurrentSHA256: contentHash, + IsDeleted: false, + CreatedAt: now, + UpdatedAt: now, + }, + Content: content, + } + s.bootstrapLedger(ctx, userID, detail) + return detail, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + if clientKey != "" { + if existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]; existingID != "" { + doc := s.memDocs[existingID] + versions := s.memVersions[existingID] + cur := "" + if len(versions) > 0 { + cur = versions[len(versions)-1].ContentMarkdown + } + if doc != nil { + return &FreeformDocDetail{Document: *doc, Content: cur}, nil + } + } + } + newDoc := &FreeformDocument{ + DocID: docID, + ProjectID: projectID, + OwnerUserID: userID, + SourceContentSHA256: contentHash, + ClientKey: clientKeyPtr, + Title: title, + GovernanceMode: "freeform", + Space: space, + CurrentVersionID: 1, + CurrentSHA256: contentHash, + IsDeleted: false, + CreatedAt: now, + UpdatedAt: now, + } + s.memDocs[docID] = newDoc + s.memVersions[docID] = []*FreeformDocumentVersion{{ + ID: 1, DocID: docID, VersionNo: 1, ContentSHA256: contentHash, + ContentMarkdown: content, CreatedBy: userID, ChangeSummary: "上传附件注册", CreatedAt: now, + }} + if clientKey != "" { + s.memClientKeys[clientKeyLookup(userID, clientKey)] = docID + } + detail := &FreeformDocDetail{Document: *newDoc, Content: content} + s.bootstrapLedger(ctx, userID, detail) + return detail, nil +} diff --git a/internal/freedoc/service_read.go b/internal/freedoc/service_read.go new file mode 100644 index 00000000..fbe08bd3 --- /dev/null +++ b/internal/freedoc/service_read.go @@ -0,0 +1,201 @@ +package freedoc + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) { + if docID == "" { + return nil, ErrNotFound + } + + if s.pool != nil { + var doc FreeformDocument + row := s.pool.QueryRow(ctx, ` + SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, + is_deleted, deleted_at, created_at, updated_at + FROM freeform_documents + WHERE doc_id = $1 + `, docID) + if err := row.Scan( + &doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID, + &doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode, + &doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256, + &doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if doc.IsDeleted { + return nil, ErrNotFound + } + + // 私密隔离红线:my_private 只有 owner 可读 + if doc.Space == "my_private" && doc.OwnerUserID != userID { + _, _ = s.pool.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success) + VALUES ($1, $2, 'read_denied', FALSE) + `, docID, userID) + return nil, ErrForbidden + } + + var content string + err := s.pool.QueryRow(ctx, ` + SELECT content_markdown FROM freeform_document_versions + WHERE doc_id = $1 AND version_no = $2 + `, doc.DocID, doc.CurrentVersionID).Scan(&content) + if err != nil { + return nil, fmt.Errorf("read version: %w", err) + } + + _, _ = s.pool.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success) + VALUES ($1, $2, 'read', TRUE) + `, docID, userID) + + return &FreeformDocDetail{ + Document: doc, + Content: content, + }, nil + } + + // 内存模式 + s.mu.RLock() + defer s.mu.RUnlock() + + doc, ok := s.memDocs[docID] + if !ok || doc.IsDeleted { + return nil, ErrNotFound + } + if doc.Space == "my_private" && doc.OwnerUserID != userID { + return nil, ErrForbidden + } + + versions := s.memVersions[docID] + content := "" + for _, v := range versions { + if v.VersionNo == doc.CurrentVersionID { + content = v.ContentMarkdown + break + } + } + + return &FreeformDocDetail{ + Document: *doc, + Content: content, + }, nil +} + +// ListDocuments 按空间与权限列出自由文档概要 + +func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64, space string) ([]FreeformDocSummary, error) { + if space != "my_private" && space != "project_shared" && space != "trash" { + space = "my_private" + } + + if s.pool != nil { + var rows pgx.Rows + var err error + if space == "my_private" { + // 我的私密:属于当前用户 + if projectID > 0 { + rows, err = s.pool.Query(ctx, ` + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at + FROM freeform_documents + WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE + ORDER BY updated_at DESC + `, userID, projectID) + } else { + rows, err = s.pool.Query(ctx, ` + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at + FROM freeform_documents + WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE + ORDER BY updated_at DESC + `, userID) + } + } else if space == "project_shared" { + // 项目共享参考:属于当前项目的所有公开参考 + rows, err = s.pool.Query(ctx, ` + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at + FROM freeform_documents + WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE + ORDER BY updated_at DESC + `, projectID) + } else { + // 回收站:当前用户删除的 + rows, err = s.pool.Query(ctx, ` + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at + FROM freeform_documents + WHERE owner_user_id = $1 AND is_deleted = TRUE + ORDER BY deleted_at DESC + `, userID) + } + if err != nil { + return nil, err + } + defer rows.Close() + + var results []FreeformDocSummary + for rows.Next() { + var sm FreeformDocSummary + if err := rows.Scan( + &sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space, + &sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt, + ); err != nil { + return nil, err + } + results = append(results, sm) + } + return results, rows.Err() + } + + // 内存模式 + s.mu.RLock() + defer s.mu.RUnlock() + + var results []FreeformDocSummary + for _, d := range s.memDocs { + if space == "my_private" { + if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted { + if projectID <= 0 || d.ProjectID == projectID { + results = append(results, FreeformDocSummary{ + DocID: d.DocID, + ProjectID: d.ProjectID, + OwnerUserID: d.OwnerUserID, + Title: d.Title, + Space: d.Space, + CurrentVersionID: d.CurrentVersionID, + CurrentSHA256: d.CurrentSHA256, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + }) + } + } + } else if space == "project_shared" { + if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted { + results = append(results, FreeformDocSummary{ + DocID: d.DocID, + ProjectID: d.ProjectID, + OwnerUserID: d.OwnerUserID, + Title: d.Title, + Space: d.Space, + CurrentVersionID: d.CurrentVersionID, + CurrentSHA256: d.CurrentSHA256, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + }) + } + } + } + return results, nil +} + +// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制 diff --git a/internal/freedoc/service_share.go b/internal/freedoc/service_share.go new file mode 100644 index 00000000..3fabde1c --- /dev/null +++ b/internal/freedoc/service_share.go @@ -0,0 +1,189 @@ +package freedoc + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作) +func (s *Service) ShareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) { + if docID == "" { + return nil, ErrNotFound + } + + if s.pool != nil { + var doc FreeformDocument + row := s.pool.QueryRow(ctx, ` + SELECT doc_id, project_id, owner_user_id, space, is_deleted + FROM freeform_documents WHERE doc_id = $1 + `, docID) + if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if doc.IsDeleted { + return nil, ErrNotFound + } + if doc.OwnerUserID != userID { + return nil, ErrForbidden + } + + now := time.Now() + _, err := s.pool.Exec(ctx, ` + UPDATE freeform_documents + SET space = 'project_shared', updated_at = $1 + WHERE doc_id = $2 + `, now, docID) + if err != nil { + return nil, err + } + + _, _ = s.pool.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) + VALUES ($1, $2, 'share', TRUE, $3) + `, docID, userID, now) + + doc.Space = "project_shared" + doc.UpdatedAt = now + return &doc, nil + } + + // 内存模式 + s.mu.Lock() + defer s.mu.Unlock() + doc, ok := s.memDocs[docID] + if !ok || doc.IsDeleted { + return nil, ErrNotFound + } + if doc.OwnerUserID != userID { + return nil, ErrForbidden + } + doc.Space = "project_shared" + doc.UpdatedAt = time.Now() + return doc, nil +} + +// UnshareDocument 撤回共享,恢复为个人私密 +func (s *Service) UnshareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) { + if docID == "" { + return nil, ErrNotFound + } + + if s.pool != nil { + var doc FreeformDocument + row := s.pool.QueryRow(ctx, ` + SELECT doc_id, project_id, owner_user_id, space, is_deleted + FROM freeform_documents WHERE doc_id = $1 + `, docID) + if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if doc.IsDeleted { + return nil, ErrNotFound + } + if doc.OwnerUserID != userID { + return nil, ErrForbidden + } + + now := time.Now() + _, err := s.pool.Exec(ctx, ` + UPDATE freeform_documents + SET space = 'my_private', updated_at = $1 + WHERE doc_id = $2 + `, now, docID) + if err != nil { + return nil, err + } + + _, _ = s.pool.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) + VALUES ($1, $2, 'unshare', TRUE, $3) + `, docID, userID, now) + + doc.Space = "my_private" + doc.UpdatedAt = now + return &doc, nil + } + + // 内存模式 + s.mu.Lock() + defer s.mu.Unlock() + doc, ok := s.memDocs[docID] + if !ok || doc.IsDeleted { + return nil, ErrNotFound + } + if doc.OwnerUserID != userID { + return nil, ErrForbidden + } + doc.Space = "my_private" + doc.UpdatedAt = time.Now() + return doc, nil +} + +// DeleteDocument 软删除文档(仅 owner 可操作) +func (s *Service) DeleteDocument(ctx context.Context, userID int, docID string) error { + if docID == "" { + return ErrNotFound + } + + if s.pool != nil { + var doc FreeformDocument + row := s.pool.QueryRow(ctx, ` + SELECT doc_id, owner_user_id, is_deleted + FROM freeform_documents WHERE doc_id = $1 + `, docID) + if err := row.Scan(&doc.DocID, &doc.OwnerUserID, &doc.IsDeleted); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + if doc.IsDeleted { + return nil + } + if doc.OwnerUserID != userID { + return ErrForbidden + } + + now := time.Now() + _, err := s.pool.Exec(ctx, ` + UPDATE freeform_documents + SET is_deleted = TRUE, deleted_at = $1, space = 'trash' + WHERE doc_id = $2 + `, now, docID) + if err != nil { + return err + } + + _, _ = s.pool.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) + VALUES ($1, $2, 'delete', TRUE, $3) + `, docID, userID, now) + + return nil + } + + // 内存模式 + s.mu.Lock() + defer s.mu.Unlock() + doc, ok := s.memDocs[docID] + if !ok || doc.IsDeleted { + return ErrNotFound + } + if doc.OwnerUserID != userID { + return ErrForbidden + } + doc.IsDeleted = true + now := time.Now() + doc.DeletedAt = &now + doc.Space = "trash" + return nil +} diff --git a/internal/freedoc/service_update.go b/internal/freedoc/service_update.go new file mode 100644 index 00000000..623b0d5e --- /dev/null +++ b/internal/freedoc/service_update.go @@ -0,0 +1,231 @@ +package freedoc + +import ( + "context" + "errors" + "fmt" + "time" + + "chat-agent-system/internal/docrevision" + "github.com/jackc/pgx/v5" +) + +// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制 +func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, req UpdateFreeformDocRequest) (*FreeformDocDetail, error) { + if docID == "" { + return nil, ErrNotFound + } + + if s.pool != nil { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var doc FreeformDocument + row := tx.QueryRow(ctx, ` + SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, + is_deleted, created_at, updated_at + FROM freeform_documents + WHERE doc_id = $1 FOR UPDATE + `, docID) + if err := row.Scan( + &doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID, + &doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode, + &doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256, + &doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if doc.IsDeleted { + return nil, ErrNotFound + } + + // 私密文档非本人严禁修改 + if doc.Space == "my_private" && doc.OwnerUserID != userID { + return nil, ErrForbidden + } + + // 乐观并发冲突检验 + if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID { + return nil, ErrConflict + } + if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 { + return nil, ErrConflict + } + + now := time.Now() + newTitle := doc.Title + if req.Title != nil && *req.Title != "" { + newTitle = *req.Title + } + + curContent := "" + _ = tx.QueryRow(ctx, `SELECT content_markdown FROM freeform_document_versions WHERE doc_id = $1 AND version_no = $2`, doc.DocID, doc.CurrentVersionID).Scan(&curContent) + + newContent := curContent + if req.ContentMarkdown != nil { + newContent = *req.ContentMarkdown + } + + newHash := computeSHA256(newContent) + if newHash != doc.CurrentSHA256 { + // 内容变更,生成新版本 + nextVersion := doc.CurrentVersionID + 1 + summary := req.ChangeSummary + if summary == "" { + summary = "编辑修改正文" + } + _, err = tx.Exec(ctx, ` + INSERT INTO freeform_document_versions ( + doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + `, doc.DocID, nextVersion, newHash, newContent, userID, summary, now) + if err != nil { + return nil, fmt.Errorf("insert version: %w", err) + } + + _, err = tx.Exec(ctx, ` + UPDATE freeform_documents + SET title = $1, legacy_version_id = $2, current_sha256 = $3, updated_at = $4 + WHERE doc_id = $5 + `, newTitle, nextVersion, newHash, now, doc.DocID) + if err != nil { + return nil, fmt.Errorf("update doc: %w", err) + } + + doc.CurrentVersionID = nextVersion + doc.CurrentSHA256 = newHash + } else if newTitle != doc.Title { + // 仅标题变更 + _, err = tx.Exec(ctx, ` + UPDATE freeform_documents + SET title = $1, updated_at = $2 + WHERE doc_id = $3 + `, newTitle, now, doc.DocID) + if err != nil { + return nil, fmt.Errorf("update doc title: %w", err) + } + } + + doc.Title = newTitle + doc.UpdatedAt = now + + _, _ = tx.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) + VALUES ($1, $2, 'update', TRUE, $3) + `, doc.DocID, userID, now) + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return &FreeformDocDetail{ + Document: doc, + Content: newContent, + }, nil + } + + // 内存模式 + s.mu.Lock() + defer s.mu.Unlock() + + doc, ok := s.memDocs[docID] + if !ok || doc.IsDeleted { + return nil, ErrNotFound + } + if doc.Space == "my_private" && doc.OwnerUserID != userID { + return nil, ErrForbidden + } + if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID { + return nil, ErrConflict + } + if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 { + return nil, ErrConflict + } + + now := time.Now() + newTitle := doc.Title + if req.Title != nil && *req.Title != "" { + newTitle = *req.Title + } + doc.Title = newTitle + + curContent := "" + versions := s.memVersions[docID] + if len(versions) > 0 { + curContent = versions[len(versions)-1].ContentMarkdown + } + + newContent := curContent + if req.ContentMarkdown != nil { + newContent = *req.ContentMarkdown + } + + newHash := computeSHA256(newContent) + if newHash != doc.CurrentSHA256 { + nextVersion := doc.CurrentVersionID + 1 + doc.CurrentVersionID = nextVersion + doc.CurrentSHA256 = newHash + s.memVersions[docID] = append(s.memVersions[docID], &FreeformDocumentVersion{ + ID: int64(len(s.memVersions[docID]) + 1), + DocID: docID, + VersionNo: nextVersion, + ContentSHA256: newHash, + ContentMarkdown: newContent, + CreatedBy: userID, + ChangeSummary: req.ChangeSummary, + CreatedAt: now, + }) + } + doc.UpdatedAt = now + detail := &FreeformDocDetail{ + Document: *doc, + Content: newContent, + } + s.syncLedgerLocked(ctx, userID, detail) + return detail, nil +} + +// syncLedgerLocked imports legacy checkpoints then records human revisions on the shared ledger. +func (s *Service) syncLedgerLocked(ctx context.Context, userID int, detail *FreeformDocDetail) { + if s.docRev == nil || detail == nil { + return + } + doc := detail.Document + checkpoints := make([]struct { + VersionNo int + Markdown string + SHA string + }, 0, len(s.memVersions[doc.DocID])) + for _, v := range s.memVersions[doc.DocID] { + checkpoints = append(checkpoints, struct { + VersionNo int + Markdown string + SHA string + }{VersionNo: v.VersionNo, Markdown: v.ContentMarkdown, SHA: v.ContentSHA256}) + } + prior := checkpoints + if len(prior) > 0 { + prior = prior[:len(prior)-1] + } + st, err := s.docRev.ImportFreeformLegacy(ctx, doc.DocID, int64(userID), int64(doc.ProjectID), prior, detail.Content) + if err != nil || st == nil { + return + } + _, _ = s.docRev.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: doc.DocID, Markdown: detail.Content, + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: fmt.Sprintf("freeform:%s:%s", doc.DocID, detail.Document.CurrentSHA256), + ActorUserID: int64(userID), + ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "freeform-owner", Role: "owner"}, + LifecycleState: "drafting", + ProjectID: int64(doc.ProjectID), + }) +} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 015d7e7f..cf655311 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -2,15 +2,9 @@ package intent import ( "context" - "fmt" - "regexp" - "sort" "strings" - "time" - "unicode/utf8" "chat-agent-system/internal/maturity" - "chat-agent-system/internal/squad" ) // Recognizer 意图识别器接口 @@ -327,1110 +321,3 @@ func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, u } return result, nil } - -// isSecurityViolation 检查是否命中敏感安全与权限红线 -func isSecurityViolation(input string) bool { - lower := strings.ToLower(input) - patterns := []string{ - "修改身份", "身份修改", "修改职位", "职位修改", "修改岗位", "岗位修改", - "修改姓名", "姓名修改", "修改名字", "名字修改", - "修改部门", "部门修改", - "修改资质", "资质修改", - "改成批准人", "改成审核人", "改成编制人", - "工号改成", "修改工号", "工号修改", - "修改设置", "修改配置", "系统设置", "系统配置", - "修改网关", "网关配置", "网关密钥", "大模型网关", "模型网关", - "修改密码", "重置密码", "提升权限", "越权", - "修改向量库", "向量库连接", "向量库配置", "修改向量库连接", - "修改知识库连接", "知识库连接", "知识库配置", - } - for _, p := range patterns { - if strings.Contains(lower, p) { - return true - } - } - - secRegexes := []string{ - `把.*(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质).*(?:修改|改|换|重置|设为|变为)`, - `(?:修改|改|换|重置|设为).*(?:我的)?(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质|网关|向量库|知识库连接|密钥|apikey|api_key)`, - `.*(?:向量库连接|修改向量库|知识库连接|大模型网关密钥|网关密钥).*`, - } - for _, r := range secRegexes { - if matched, _ := regexp.MatchString(r, input); matched { - return true - } - } - return false -} - -// isPureUICommand 检查是否为纯前端UI视窗布局与主题控制指令 -func isPureUICommand(input string) bool { - patterns := []string{ - "打开工具栏", "关闭工具栏", - "折叠侧边栏", "展开侧边栏", "隐藏侧边栏", "显示侧边栏", - "折叠左侧栏", "展开左侧栏", "折叠右侧栏", "展开右侧栏", - "折叠工作台", "展开工作台", - "切换全屏", "全屏显示", "退出全屏", - "打开控制台", "关闭控制台", - "切换主题", "换成深色", "换成浅色", - "打开抽屉", "展开抽屉", "关闭抽屉", - "深色模式", "浅色模式", "深色主题", "浅色主题", - } - for _, p := range patterns { - if strings.Contains(input, p) { - return true - } - } - return false -} - -// resolveTargetDoc 优先从输入中提取显式受控文档名称,提取不到时回退至上下文的 ActiveDocID -func resolveTargetDoc(input string, userCtx *ContextSnapshot) string { - if doc := extractDocName(input); doc != "" { - return doc - } - if userCtx != nil && userCtx.ActiveDocID != "" { - return userCtx.ActiveDocID - } - return "" -} - -// isRejectReasonQuery 判定是否为询问驳回/退回原因(而非发起驳回执行) -func isRejectReasonQuery(input string) bool { - markers := []string{ - "原因", "为何", "为什么", "怎么回事", "什么情况", "什么意见", - "哪点", "哪些问题", "整改", "意见是什么", "理由", "怎么改", "如何修改", - "查一下驳回", "看看驳回", - } - for _, m := range markers { - if strings.Contains(input, m) { - return true - } - } - return strings.Contains(input, "被驳回") -} - -// matchDocGovAction 匹配法定受控文档治理流转动作与槽位提取 -func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent { - // 0. 询问驳回原因:归属 DECISION_QUERY,不得路由为 DOCGOV_*_REJECT - if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && isRejectReasonQuery(input) { - slots := make(map[string]string) - fillTargetDocSlot(slots, input, userCtx) - if q := scopeMismatchClarify(slots, activeWorkflowKeyOf(userCtx)); q != "" { - return &CandidateIntent{ - Intent: IntentQueryRejectReason, - Confidence: 0.96, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "用户询问的受控文档属于非激活工作流,前置剪枝为只读询问并给出切换引导", - ClarificationQuestion: q, - } - } - return &CandidateIntent{ - Intent: IntentQueryRejectReason, - Confidence: 0.96, - FilledSlots: slots, - Reasoning: "用户询问受控文档驳回/退回原因,路由为 QUERY_REJECT_REASON", - } - } - - // 跨工作流作用域前置剪枝:治理写动作显式点名他流文档时,拦截并引导切换,不落入任何 DOCGOV_* 写意图。 - if cand := docGovScopePreempt(input, userCtx); cand != nil { - return cand - } - - // 1. 驳回 (Review Reject or Final Reject) - if strings.Contains(input, "驳回") { - isFinal := false - if strings.Contains(input, "终审") || strings.Contains(input, "批准") { - isFinal = true - } else if userCtx != nil && (userCtx.DocLifecycleState == "approving" || userCtx.UserDocRole == "approver") { - isFinal = true - } - - targetIntent := IntentDocGovReviewReject - if isFinal { - targetIntent = IntentDocGovFinalReject - } - - slots := make(map[string]string) - - // target_doc - targetDoc := resolveTargetDoc(input, userCtx) - if targetDoc != "" { - slots[SlotTargetDoc] = targetDoc - } - - // target_section - section := extractSection(input) - if section != "" { - slots[SlotTargetSection] = section - } - - // reject_reason - reason := extractRejectReason(input) - if reason != "" { - slots[SlotRejectReason] = reason - } - - confidence := 0.95 - reasoning := fmt.Sprintf("用户下达法定治理驳回指令,目标意图为 %s", targetIntent) - clarifyQ := "" - - // Stage 0 权限与状态前置剪枝校验 - if userCtx != nil { - if !isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" { - confidence = 0.90 - reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核驳回", userCtx.UserDocRole) - clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核驳回权限(需审核人角色)。", userCtx.UserDocRole) - } else if isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" { - confidence = 0.90 - reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审驳回", userCtx.UserDocRole) - clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准驳回权限(需批准人角色)。", userCtx.UserDocRole) - } - - if userCtx.DocLifecycleState == "drafting" { - confidence = 0.90 - reasoning = "前置剪枝:当前文档处于编制草稿阶段(drafting),尚未提交审核,无法执行驳回操作" - clarifyQ = "当前文档处于编制中(drafting),尚未送审,无需驳回。" - } - } - - return &CandidateIntent{ - Intent: targetIntent, - Confidence: confidence, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: reasoning, - ClarificationQuestion: clarifyQ, - } - } - - // 2. 终审批准 (Final Approve) - if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") || - strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不")) { - targetIntent := IntentDocGovFinalApprove - slots := make(map[string]string) - targetDoc := resolveTargetDoc(input, userCtx) - if targetDoc != "" { - slots[SlotTargetDoc] = targetDoc - } - - confidence := 0.95 - reasoning := "用户下达受控文档终审批准生效指令" - clarifyQ := "" - - if userCtx != nil { - if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" { - confidence = 0.90 - reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审批准", userCtx.UserDocRole) - clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准权限(需批准人角色)。", userCtx.UserDocRole) - } else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approving" { - confidence = 0.90 - reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入终审批准阶段(approving),无法执行终审批准", userCtx.DocLifecycleState) - clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入终审阶段,无法执行终审批准。", userCtx.DocLifecycleState) - } - } - - return &CandidateIntent{ - Intent: targetIntent, - Confidence: confidence, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: reasoning, - ClarificationQuestion: clarifyQ, - } - } - - // 3. 审核通过 (Review Approve) - if strings.Contains(input, "审核通过") || strings.Contains(input, "审阅通过") || strings.Contains(input, "评审通过") || - (strings.Contains(input, "通过") && (strings.Contains(input, "审核") || strings.Contains(input, "审阅") || strings.Contains(input, "报告") || strings.Contains(input, "准予") || strings.Contains(input, "环节"))) { - if !strings.Contains(input, "下一阶段") && !strings.Contains(input, "推进") { - targetIntent := IntentDocGovReviewApprove - slots := make(map[string]string) - targetDoc := resolveTargetDoc(input, userCtx) - if targetDoc != "" { - slots[SlotTargetDoc] = targetDoc - } - - confidence := 0.95 - reasoning := "用户下达受控文档审核通过指令" - clarifyQ := "" - - if userCtx != nil { - if userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" { - confidence = 0.90 - reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核通过", userCtx.UserDocRole) - clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核通过权限(需审核人角色)。", userCtx.UserDocRole) - } else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "reviewing" { - confidence = 0.90 - reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入审核阶段(reviewing),无法执行审核通过", userCtx.DocLifecycleState) - clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入审核阶段,无法执行审核通过。", userCtx.DocLifecycleState) - } - } - - return &CandidateIntent{ - Intent: targetIntent, - Confidence: confidence, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: reasoning, - ClarificationQuestion: clarifyQ, - } - } - } - - // 4. 认领文档 (Claim) — 查询语气不得路由为写操作 - if strings.Contains(input, "认领") { - if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "claim") { - slots := make(map[string]string) - if doc := resolveTargetDoc(input, userCtx); doc != "" { - slots[SlotTargetDoc] = doc - } - return &CandidateIntent{ - Intent: IntentQueryUserRights, - Confidence: 0.91, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "查询语气下的文档认领状态询问,不得路由为写操作 DOCGOV_CLAIM", - ClarificationQuestion: "您是想查询该文档当前由谁认领负责吗?我将为您检索认领状态,不会执行认领操作。", - } - } - slots := make(map[string]string) - targetDoc := resolveTargetDoc(input, userCtx) - if targetDoc != "" { - slots[SlotTargetDoc] = targetDoc - } - - return &CandidateIntent{ - Intent: IntentDocGovClaim, - Confidence: 0.94, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "用户下达认领文档编制责任人指令", - } - } - - // 5. 提交送审 (Submit Review) — 查询语气不得路由为写操作 - if strings.Contains(input, "送审") || strings.Contains(input, "提交审核") || strings.Contains(input, "发起审核") || strings.Contains(input, "提审") { - if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "submit") { - slots := make(map[string]string) - if doc := resolveTargetDoc(input, userCtx); doc != "" { - slots[SlotTargetDoc] = doc - } - return &CandidateIntent{ - Intent: IntentQueryRejectReason, - Confidence: 0.91, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "查询语气下的送审/审核状态询问,不得路由为写操作 DOCGOV_SUBMIT_REVIEW", - ClarificationQuestion: "您是想查询该文档的送审或审核状态吗?我将为您检索当前流转状态,不会发起新的送审。", - } - } - slots := make(map[string]string) - targetDoc := resolveTargetDoc(input, userCtx) - if targetDoc != "" { - slots[SlotTargetDoc] = targetDoc - } - - return &CandidateIntent{ - Intent: IntentDocGovSubmitReview, - Confidence: 0.94, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "用户下达编制完成送交审核指令", - } - } - - // 6. 提案裁决 (Proposal Decide) - if strings.Contains(input, "提案") || strings.Contains(input, "修改建议") || strings.Contains(input, "变更建议") { - if strings.Contains(input, "同意") || strings.Contains(input, "采纳") || strings.Contains(input, "拒绝") || strings.Contains(input, "拍板") { - slots := make(map[string]string) - if strings.Contains(input, "拒绝") { - slots[SlotProposalAction] = "reject" - } else { - slots[SlotProposalAction] = "accept" - } - return &CandidateIntent{ - Intent: IntentDocGovProposalDecide, - Confidence: 0.93, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: "用户对受控文档的修改提案或批注做出决策裁决", - } - } - } - - return nil -} - -// matchKnowledgeQuery 匹配法规标准、标杆案例与权限查询 -func matchKnowledgeQuery(input string) *CandidateIntent { - // 排除事实参数补充的输入场景 - excludeKeywords := []string{"客户说", "客户反馈", "客户补充", "补充说", "补充", "修改为", "改为", "增加"} - for _, ek := range excludeKeywords { - if strings.Contains(input, ek) { - return nil - } - } - - // 1. 用户权限查询 (IntentQueryUserRights) - userRightsPatterns := []string{ - "我的权限", "我有什么权限", "我负责什么", "我的角色", "查看我的职责", - "查询权限", "当前权限", "我在这个项目的角色", "法定角色", "法定职责", "我的职责", - "文档权限", "文档职责", "权限和职责", "职责和权限", "负责哪些文档", "权限", - } - for _, p := range userRightsPatterns { - if strings.Contains(input, p) { - return &CandidateIntent{ - Intent: IntentQueryUserRights, - Confidence: 0.95, - FilledSlots: map[string]string{ - SlotQueryTopic: "user_rights", - }, - Reasoning: "用户查询自身在项目及受控文档中的法定职责与操作权限", - } - } - } - - // 2. 标杆/相似案例查询 (IntentQuerySimilarCases) - similarCasesPatterns := []string{ - "参考案例", "往期经验", "类似项目", "相似案例", "同类产品", - "同类项目", "以往案例", "标杆案例", "同类获批", "竞品获批案例", - "注册案例", "申报案例", - } - for _, p := range similarCasesPatterns { - if strings.Contains(input, p) { - return &CandidateIntent{ - Intent: IntentQuerySimilarCases, - Confidence: 0.92, - FilledSlots: map[string]string{ - SlotQueryTopic: "similar_cases", - }, - Reasoning: "用户查询以往类似产品的注册申报、上市审评或标杆案例与经验", - } - } - } - - // 3. 法规强标查询 (IntentQueryRegulations) - regKeywords := []string{ - "gb 9706", "gb9706", "yy 0505", "yy0505", "强制性标准", "强标", - "技术审评指导原则", "指导原则", "合规要求", "注册标准", - } - lower := strings.ToLower(input) - for _, rk := range regKeywords { - if strings.Contains(lower, rk) { - return &CandidateIntent{ - Intent: IntentQueryRegulations, - Confidence: 0.92, - FilledSlots: map[string]string{ - SlotQueryTopic: "regulations", - }, - Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规", - } - } - } - - if (strings.Contains(lower, "法规") || strings.Contains(lower, "标准") || strings.Contains(lower, "yy") || strings.Contains(lower, "fda") || strings.Contains(lower, "ce")) && - (strings.Contains(lower, "查") || strings.Contains(lower, "哪些") || strings.Contains(lower, "满足") || strings.Contains(lower, "要求") || strings.Contains(lower, "规范")) { - return &CandidateIntent{ - Intent: IntentQueryRegulations, - Confidence: 0.90, - FilledSlots: map[string]string{ - SlotQueryTopic: "regulations", - }, - Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规", - } - } - - return nil -} - -// extractDocName 从文本或预设映射中提取目标受控文档名称/标识 -func extractDocName(input string) string { - reDocID := regexp.MustCompile(`(doc-[a-zA-Z0-9_-]+)`) - if match := reDocID.FindString(input); match != "" { - return match - } - - docNames := []string{ - "用户初始需求收集表", "用户初始需求表", "用户需求收集表", "需求收集表", "需求表", - "技术与市场调研报告", "调研报告", - "法规与注册论证报告", "法规评估", "注册评估", - "初步设计与架构方案", "初步设计", "设计方案", - "CDO报价单", "初步报价单", "报价单", - } - for _, name := range docNames { - if strings.Contains(input, name) { - return name - } - } - - reBa := regexp.MustCompile(`把\s*(.+?)\s*(?:审核驳回|终审驳回|驳回|审核通过|批准|认领|送审|提交审核)`) - if match := reBa.FindStringSubmatch(input); len(match) > 1 { - return strings.TrimSpace(match[1]) - } - - return "" -} - -// extractRejectReason 从用户输入中提取驳回意见或批注内容 -func extractRejectReason(input string) string { - reReason := regexp.MustCompile(`(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*(.+)`) - if match := reReason.FindStringSubmatch(input); len(match) > 1 { - res := strings.TrimSpace(match[1]) - return strings.Trim(res, ",,。;; ") - } - - reAfterReject := regexp.MustCompile(`驳回[,,::\s]+(.+)`) - if match := reAfterReject.FindStringSubmatch(input); len(match) > 1 { - res := strings.TrimSpace(match[1]) - res = regexp.MustCompile(`^(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*`).ReplaceAllString(res, "") - res = strings.Trim(res, ",,。;; ") - if res != "" { - return res - } - } - - return "" -} - -// extractSection 从文本中抽取目标章节线索 (如 "第3节", "3.1节") -func extractSection(input string) string { - reSection := regexp.MustCompile(`(第[0-9一二三四五六七八九十]+[章节条部分]+|[0-9]+\.[0-9]+[节章]*)`) - if match := reSection.FindString(input); match != "" { - return match - } - return "" -} - -// matchExpectationHit 检验是否命中挂起预期 (分支 1) -func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIntent { - // 0. 检验超时过期 - if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) { - return nil - } - - // 1. 否定/暂缓意图 - declineKeywords := []string{"先别", "别推", "算了", "先不", "等等", "暂时不", "先别推", "不要推进", "不要", "不行", "算了吧", "先放一放", "先等等", "取消", "放弃"} - if pending.DeclineWord != "" { - declineKeywords = append(declineKeywords, pending.DeclineWord) - } - for _, dk := range declineKeywords { - if input == dk || strings.HasPrefix(input, dk) { - return &CandidateIntent{ - Intent: IntentDeclineExpectation, - Confidence: 0.95, - Branch: BranchExpectationHit, - IsDecline: true, - FilledSlots: make(map[string]string), - Reasoning: fmt.Sprintf("用户明确表示暂缓或取消当前挂起建议 ('%s')", input), - } - } - } - - // 2. 选择序号检验 (如 "1", "2", "选1", "选2", "第1个", "第一个", "选项1") - var matchedOpt *ExpectationOption - optIndex := matchOptionIndex(input, pending.Options) - if optIndex >= 0 && optIndex < len(pending.Options) { - matchedOpt = &pending.Options[optIndex] - } - - // 肯定前缀后含独立新请求谓词时,不得吞掉新意图(如「好,请先查法规」) - if matchedOpt == nil && hasIndependentNewPredicate(input) { - return nil - } - - // 3. 显式确认短语(不受 AllowAffirmative 限制) - explicitConfirm := isExplicitConfirmPhrase(input) - - // 4. 肯定词检验 (如 "好", "行", "可以", "成", "没问题", "同意", "好的", "就按这个", "按你说的办", "确认") - affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以", "没问题", "同意", "就按这个", "按这个", "就按这个办", "听你的", "ok", "yes", "就这么定", "确认", "确定", "批准"} - if pending.AffirmativeWord != "" { - affirmativePrefixes = append(affirmativePrefixes, pending.AffirmativeWord) - } - isAffirmative := explicitConfirm - - for _, aff := range affirmativePrefixes { - if input == aff || - strings.HasPrefix(input, aff+",") || - strings.HasPrefix(input, aff+",") || - strings.HasPrefix(input, aff+"但") || - strings.HasPrefix(input, aff+" ") || - strings.HasPrefix(input, aff+"!") || - strings.HasPrefix(input, aff+"!") || - (aff == "确认" && (input == "确认" || input == "确认驳回" || input == "确认批准" || input == "确认执行")) || - (pending.AffirmativeWord != "" && strings.HasPrefix(input, pending.AffirmativeWord)) { - isAffirmative = true - break - } - } - - // 裸肯定词必须 AllowAffirmative=true(显式确认短语除外) - if isAffirmative && !explicitConfirm && !pending.AllowAffirmative { - bareAffirmative := false - for _, aff := range affirmativePrefixes { - if input == aff { - bareAffirmative = true - break - } - } - if bareAffirmative { - isAffirmative = false - } - } - - if matchedOpt == nil && isAffirmative { - if pending.DefaultOption != nil { - matchedOpt = pending.DefaultOption - } else if len(pending.Options) > 0 { - matchedOpt = &pending.Options[0] - } else if pending.ExpectedIntent != "" { - matchedOpt = &ExpectationOption{ - OptionID: pending.TargetAction, - Title: pending.Prompt, - Intent: pending.ExpectedIntent, - Slots: pending.Payload, - } - } - } - - if matchedOpt == nil { - return nil - } - - // 4. 提取增量事实补丁 (Patch Merge) - // 例如: "好,但目标市场改成欧洲CE", "选1,先把预算定为15万" - patchSlots := extractFactSlots(input) - - filledSlots := make(map[string]string) - for k, v := range matchedOpt.Slots { - filledSlots[k] = v - } - for k, v := range patchSlots { - filledSlots[k] = v - } - if pending.TargetAction != "" && filledSlots["target_action"] == "" { - filledSlots["target_action"] = pending.TargetAction - } - - confidence := 1.0 - reasoning := fmt.Sprintf("用户明确肯定上一轮参谋建议动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title) - if len(patchSlots) > 0 { - confidence = 0.98 - reasoning = fmt.Sprintf("用户肯定上一轮建议动作 [%s: %s],并附带增量事实补丁: %v", matchedOpt.OptionID, matchedOpt.Title, patchSlots) - } else if optIndex >= 0 { - confidence = 0.98 - reasoning = fmt.Sprintf("用户按序号选择了备选动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title) - } - - return &CandidateIntent{ - Intent: matchedOpt.Intent, - Confidence: confidence, - Branch: BranchExpectationHit, - FilledSlots: filledSlots, - PatchSlots: patchSlots, - MatchedOption: matchedOpt, - Reasoning: reasoning, - } -} - -// matchOptionIndex 匹配用户输入的选项编号 -func matchOptionIndex(input string, options []ExpectationOption) int { - input = strings.TrimSpace(input) - if len(options) == 0 { - return -1 - } - - patterns := []struct { - regex string - idx int - }{ - {"^(1|选1|选第1个|第一个|第1个|选项1|选项一)$", 0}, - {"^(2|选2|选第2个|第二个|第2个|选项2|选项二)$", 1}, - {"^(3|选3|选第3个|第三个|第3个|选项3|选项三)$", 2}, - } - - for _, p := range patterns { - if matched, _ := regexp.MatchString("(?i)"+p.regex, input); matched { - return p.idx - } - } - - for idx, opt := range options { - if strings.EqualFold(opt.OptionID, input) { - return idx - } - } - - return -1 -} - -// isConsultativeOrHesitant 检验是否属于犹豫或征询参谋意见 (分支 3) -func isConsultativeOrHesitant(input string) bool { - patterns := []string{ - "你觉得呢", "你怎么看", "有什么建议", "怎么选", "拿不定主意", - "先放放.*你觉得", "这个先放放", "先放一放.*你觉得", "哪个好", "给点意见", - } - for _, p := range patterns { - if matched, _ := regexp.MatchString("(?i)"+p, input); matched { - return true - } - } - return false -} - -// buildAmbiguousConsultResult 构建分支 3 (模糊/分忧引导) 输出 -func buildAmbiguousConsultResult(input, snapID string, pending *PendingExpectation) *IntentRecognitionResult { - clarifyQ := "我为您准备了当前阶段最稳妥的推进选项。请问需要我为您详细分析技术可行性,还是先为您做方案摸底?" - if pending != nil && len(pending.Options) > 0 { - var optDesc strings.Builder - optDesc.WriteString("针对目前的决策节点,我为您梳理了以下选项供您参考:\n") - for _, o := range pending.Options { - optDesc.WriteString(fmt.Sprintf("• [%s] %s: %s\n", o.OptionID, o.Title, o.Description)) - } - clarifyQ = optDesc.String() - } - - top := &CandidateIntent{ - Intent: IntentCasualChat, - Confidence: 0.65, - Branch: BranchAmbiguousFallback, - FilledSlots: make(map[string]string), - Reasoning: "用户表达犹豫或主动征询参谋意见,触发参谋主动分忧与引导", - ClarificationQuestion: clarifyQ, - } - - result := &IntentRecognitionResult{ - RawInput: input, - Branch: BranchAmbiguousFallback, - TopIntent: top, - Candidates: []CandidateIntent{*top}, - ContextSnapshotID: snapID, - RequiresClarifying: true, - } - finalizeRecognitionResult(result) - return result -} - -// extractFactSlots 从文本中提取结构化事实槽位 (支持补丁合并与补充信息) -func extractFactSlots(input string) map[string]string { - slots := make(map[string]string) - - marketPrefixes := []string{ - "目标市场不仅是", "目标市场不仅有", "目标市场还包括", "目标市场确定为", "目标市场确定是", - "目标市场改成", "目标市场改为", "目标市场是", "目标市场为", - "市场改成", "市场改为", "市场是", "市场为", "目标市场除了国内还想考虑", - } - for _, p := range marketPrefixes { - if idx := strings.Index(input, p); idx != -1 { - val := strings.TrimSpace(input[idx+len(p):]) - val = strings.Trim(val, "::,,。 ") - if val != "" { - slots["target_market"] = val - slots[SlotFactKey] = "target_market" - slots[SlotFactValue] = val - break - } - } - } - - budgetPrefixes := []string{"预算改成", "预算改为", "预算是", "预算为", "预算只有", "预算大概", "预算"} - for _, p := range budgetPrefixes { - if idx := strings.Index(input, p); idx != -1 && slots["budget"] == "" { - val := strings.TrimSpace(input[idx+len(p):]) - val = strings.Trim(val, "::,,。 ") - if val != "" { - slots["budget"] = val - slots[SlotFactKey] = "budget" - slots[SlotFactValue] = val - break - } - } - } - - usePrefixes := []string{"用途是", "用途为", "预期用途是", "场景是", "场景为"} - for _, p := range usePrefixes { - if idx := strings.Index(input, p); idx != -1 && slots["intended_use"] == "" { - val := strings.TrimSpace(input[idx+len(p):]) - val = strings.Trim(val, "::,,。 ") - if val != "" { - slots["intended_use"] = val - slots[SlotFactKey] = "intended_use" - slots[SlotFactValue] = val - break - } - } - } - - return slots -} - -func matchAdvanceStage(input string, currentStage maturity.MaturityStage) *CandidateIntent { - patterns := []string{ - "进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证", - "方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定", - "进入v0.1", "推进到v0.1", "进入v0.5", "推进到v0.5", "进入v1.0", "推进到v1.0", "可以进入", - "没有问题.*进入", "没问题.*推进", "通过.*下一阶段", "开始推进", - "做.*摸底", "开始.*摸底", "先做.*摸底", "进行.*摸底", "做v0.1", "先做v0.1", - "做.*论证", "做.*报价", "开始论证", - } - - for _, p := range patterns { - if matched, _ := regexp.MatchString("(?i)"+p, input); matched { - var target maturity.MaturityStage - if strings.Contains(input, "报价") || strings.Contains(input, "v1.0") || strings.Contains(input, "V1.0") || strings.Contains(input, "商业锁定") { - target = maturity.StageV10_Quoting - } else if strings.Contains(input, "方案") || strings.Contains(input, "论证") || strings.Contains(input, "可行性") || strings.Contains(input, "v0.5") || strings.Contains(input, "V0.5") { - target = maturity.StageV05_Feasibility - } else if strings.Contains(input, "摸底") || strings.Contains(input, "初筛") || strings.Contains(input, "v0.1") || strings.Contains(input, "V0.1") { - target = maturity.StageV01_Triage - } else { - if currentStage == maturity.StageV01_Triage { - target = maturity.StageV05_Feasibility - } else if currentStage == maturity.StageV05_Feasibility { - target = maturity.StageV10_Quoting - } else { - target = maturity.StageV10_Quoting - } - } - - reason := fmt.Sprintf("匹配推进指令 '%s',目标成熟度阶段为 %s", p, target) - - return &CandidateIntent{ - Intent: IntentAdvanceStage, - Confidence: 0.92, - FilledSlots: map[string]string{ - SlotTargetStage: string(target), - }, - Reasoning: reason, - } - } - } - return nil -} - -func matchSupplementInfo(input string) *CandidateIntent { - patterns := []string{ - "补充", "更新", "客户说", "客户反馈", "客户确定", "修改为", "改为", "增加", - "目标市场", "市场是", "参数是", "用途是", "设备是", "预算是", "时间是", "资料", "附件", - } - - matchedAny := false - for _, p := range patterns { - if strings.Contains(input, p) { - matchedAny = true - break - } - } - if !matchedAny { - return nil - } - - slots := extractFactSlots(input) - if len(slots) == 0 { - slots = make(map[string]string) - } - slots[SlotSupplementDoc] = input - slots[SlotSupplementAction] = inferSupplementSubtype(input, slots) - - return &CandidateIntent{ - Intent: IntentSupplementInfo, - Confidence: 0.88, - FilledSlots: slots, - Reasoning: "用户输入中包含参数变更或新材料事实补充", - } -} - -func matchQueryProgress(input string) *CandidateIntent { - if matchTriggerTask(input, nil) != nil { - return nil - } - if isWorkflowControlInput(input) || isRunControlInput(input) { - return nil - } - patterns := []string{ - "测试方案", "验证方案", "测试", "验证", "型检", "型式检验", "难点", "特别注意", "注意事项", "比较难", - "进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪", - "风险", "坑", "注意什么", "话术", "怎么说", "怎么跟客户说", "微信", "草稿", - "探头功耗", "功耗", "供电", "怎么设计", "设计方案", "临床要不要", "怎么做", "查一下", "看下", - "成熟度", "汇报一下", "项目怎么样", - } - - for _, p := range patterns { - if strings.Contains(input, p) { - topic := "all" - if strings.Contains(input, "测试") || strings.Contains(input, "验证") || strings.Contains(input, "型检") || strings.Contains(input, "难点") || strings.Contains(input, "特别注意") || strings.Contains(input, "注意事项") || strings.Contains(input, "比较难") { - topic = "testing_analysis" - } else if strings.Contains(input, "风险") || strings.Contains(input, "坑") { - topic = "risk" - } else if strings.Contains(input, "话术") || strings.Contains(input, "微信") || strings.Contains(input, "怎么说") { - topic = "talk_script" - } else if strings.Contains(input, "进展") || strings.Contains(input, "进度") || strings.Contains(input, "步") { - topic = "progress" - } else if strings.Contains(input, "功耗") || strings.Contains(input, "供电") || strings.Contains(input, "设计") { - topic = "technical_design" - } - - confidence := 0.90 - if topic == "testing_analysis" { - confidence = 0.95 - } - - return &CandidateIntent{ - Intent: IntentQueryProgress, - Confidence: confidence, - FilledSlots: map[string]string{ - SlotQueryTopic: topic, - }, - Reasoning: fmt.Sprintf("用户在询问项目状态、技术测试方案或对外沟通话术 (主题: %s)", topic), - } - } - } - return nil -} - -func hasTaskWriteVerb(input string) bool { - for _, verb := range []string{"生成", "做", "跑", "出", "编写", "编制", "起草"} { - if strings.Contains(input, verb) { - return true - } - } - return false -} - -// hasWeakTaskWriteVerb 弱写动词:倾向整理/梳理,默认需确认后再启动正式任务。 -func hasWeakTaskWriteVerb(input string) bool { - for _, verb := range []string{"整理", "梳理", "汇总", "归纳", "收集"} { - if strings.Contains(input, verb) { - return true - } - } - return false -} - -func isRequirementsTopic(input string) bool { - if strings.Contains(input, "需求") || strings.Contains(input, "摸底") { - return true - } - return strings.Contains(input, "用户") && (hasTaskWriteVerb(input) || hasWeakTaskWriteVerb(input)) -} - -// hasURSStrongMarker URS / 用户需求规格书级强标记(非「初始/摸底」时才算)。 -func hasURSStrongMarker(input string) bool { - if hasIntakeSemanticMarker(input) { - return false - } - return strings.Contains(input, "URS") || - strings.Contains(input, "用户需求规格") || - strings.Contains(input, "用户需求表") -} - -// stageAwareRequirementsTask 按当前阶段消解需求类任务键,与前端 resolveRequirementsTaskKey 对齐: -// V0.1 / 空阶段且无 URS 强标记 → intake;含 URS 强标记 → user_requirements。 -func stageAwareRequirementsTask(input string, userCtx *ContextSnapshot) string { - if hasIntakeSemanticMarker(input) { - return "intake" - } - stage := "" - if userCtx != nil { - stage = userCtx.CurrentStage - } - if hasURSStrongMarker(input) { - return "user_requirements" - } - // 含糊「需求」在初期默认摸底表,避免误触 V0.5 门禁失败。 - if stage == "" || stage == string(maturity.StageV01_Triage) { - return "intake" - } - if strings.Contains(input, "用户需求") || strings.Contains(input, "需求表") { - return "user_requirements" - } - return "intake" -} - -func requirementsTriggerPhrase(taskKey string) string { - switch taskKey { - case "user_requirements": - return "用户需求表" - default: - return "用户初始需求收集表" - } -} - -func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { - type phrase struct { - text string - key string - } - taskMap := map[string]string{ - "需求收集表": "intake", - "需求表": "intake", - "调研报告": "research", - "调研": "research", - "法规评估": "feasibility_reg", - "注册评估": "feasibility_reg", - "初步设计": "prelim_design", - "设计方案": "prelim_design", - "报价单": "cdo_quote", - "报价": "cdo_quote", - } - phrases := make([]phrase, 0, len(taskMap)+60) - for kw, tKey := range taskMap { - phrases = append(phrases, phrase{text: kw, key: tKey}) - } - for _, item := range squad.RDevTriggerPhrases() { - phrases = append(phrases, phrase{text: item.Text, key: item.TaskKey}) - } - sort.Slice(phrases, func(i, j int) bool { - li := utf8.RuneCountInString(phrases[i].text) - lj := utf8.RuneCountInString(phrases[j].text) - if li != lj { - return li > lj - } - return phrases[i].text < phrases[j].text - }) - - strong := hasTaskWriteVerb(input) - weak := hasWeakTaskWriteVerb(input) - if !strong && !weak { - return nil - } - - // 需求类话题优先阶段感知消解,避免研发短别名「用户需求」截走商务流初期表述。 - if isRequirementsTopic(input) { - taskKey := stageAwareRequirementsTask(input, userCtx) - phrase := requirementsTriggerPhrase(taskKey) - if cand := scopedTriggerTaskCandidate(input, phrase, taskKey, userCtx); cand != nil { - return cand - } - cand := triggerTaskCandidate(phrase, taskKey) - if !strong && weak { - cand.Confidence = 0.78 - cand.FilledSlots[SlotNeedsConfirmation] = "true" - cand.Reasoning = fmt.Sprintf("弱写动词触发需求整理,阶段感知消解为 %s,需用户确认后执行", taskKey) - } - return cand - } - - if !strong { - // 弱写动词仅覆盖需求类宾语;其他话题仍走对话兜底。 - return nil - } - - if workflowScopeUsesWeights() { - flat := make([]struct{ text, key string }, 0, len(phrases)) - for _, item := range phrases { - flat = append(flat, struct{ text, key string }{text: item.text, key: item.key}) - } - return weightedTriggerTask(input, flat, userCtx) - } - - for _, item := range phrases { - if strings.Contains(input, item.text) { - if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil { - return cand - } - return triggerTaskCandidate(item.text, item.key) - } - } - return nil -} - -// scopedTriggerTaskCandidate 若目标任务键不属于当前激活工作流,返回跨流拦截候选; -// 无激活工作流(存量单流 / planning 态)或归属未知时返回 nil,维持原路由。 -func scopedTriggerTaskCandidate(input, phrase, taskKey string, userCtx *ContextSnapshot) *CandidateIntent { - active := "" - if userCtx != nil { - active = userCtx.ActiveWorkflowKey - } - owners, ok := docScopeForPhrase(phrase) - if !ok { - if owner := taskKeyWorkflowOwner(taskKey); owner != "" { - owners = []string{owner} - ok = true - } - } - if !ok || active == "" || containsWorkflow(owners, active) { - return nil - } - return workflowScopeCandidate(phrase, owners, active) -} - -func workflowScopeCandidate(phrase string, owners []string, activeWorkflow string) *CandidateIntent { - slots := make(map[string]string) - markWorkflowScopeMismatch(slots, phrase, owners) - return &CandidateIntent{ - Intent: IntentTriggerTask, - Confidence: 0.86, - Branch: BranchNewIntentPreempt, - FilledSlots: slots, - Reasoning: fmt.Sprintf("用户要求生成交付任务 %s,但该文档属于非激活工作流,前置剪枝", phrase), - ClarificationQuestion: workflowScopeGuidanceQuestion(phrase, owners, activeWorkflow), - } -} - -func containsWorkflow(workflows []string, key string) bool { - for _, wf := range workflows { - if wf == key { - return true - } - } - return false -} - -func hasIntakeSemanticMarker(input string) bool { - return strings.Contains(input, "首轮") || - strings.Contains(input, "初始") || - strings.Contains(input, "需求摸底") || - strings.Contains(input, "摸底") -} - -func triggerTaskCandidate(phrase, taskKey string) *CandidateIntent { - return &CandidateIntent{ - Intent: IntentTriggerTask, - Confidence: 0.86, - FilledSlots: map[string]string{ - SlotTargetTask: taskKey, - }, - Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", phrase, taskKey), - } -} - -func isGreetingOrHelp(input string) bool { - greetings := []string{"你好", "您好", "早上好", "下午好", "晚上好", "早啊", "在吗", "hi", "hello", "你是谁"} - for _, g := range greetings { - if strings.Contains(strings.ToLower(input), g) { - return true - } - } - return false -} - -func matchImpactIntent(input string) *CandidateIntent { - propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档") - analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") || - strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响") - if !propose && !analyze { - return nil - } - intent := IntentImpactAnalyze - if propose { - intent = IntentRevisePropose - } - return &CandidateIntent{ - Intent: intent, - Confidence: 0.9, - FilledSlots: map[string]string{}, - } -} - -func sortCandidates(c []CandidateIntent) { - for i := 0; i < len(c)-1; i++ { - for j := i + 1; j < len(c); j++ { - if c[j].Confidence > c[i].Confidence { - c[i], c[j] = c[j], c[i] - } - } - } -} diff --git a/internal/intent/recognizer_expectation.go b/internal/intent/recognizer_expectation.go new file mode 100644 index 00000000..79edde26 --- /dev/null +++ b/internal/intent/recognizer_expectation.go @@ -0,0 +1,217 @@ +package intent + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// matchExpectationHit 检验是否命中挂起预期 (分支 1) +func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIntent { + // 0. 检验超时过期 + if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) { + return nil + } + + // 1. 否定/暂缓意图 + declineKeywords := []string{"先别", "别推", "算了", "先不", "等等", "暂时不", "先别推", "不要推进", "不要", "不行", "算了吧", "先放一放", "先等等", "取消", "放弃"} + if pending.DeclineWord != "" { + declineKeywords = append(declineKeywords, pending.DeclineWord) + } + for _, dk := range declineKeywords { + if input == dk || strings.HasPrefix(input, dk) { + return &CandidateIntent{ + Intent: IntentDeclineExpectation, + Confidence: 0.95, + Branch: BranchExpectationHit, + IsDecline: true, + FilledSlots: make(map[string]string), + Reasoning: fmt.Sprintf("用户明确表示暂缓或取消当前挂起建议 ('%s')", input), + } + } + } + + // 2. 选择序号检验 (如 "1", "2", "选1", "选2", "第1个", "第一个", "选项1") + var matchedOpt *ExpectationOption + optIndex := matchOptionIndex(input, pending.Options) + if optIndex >= 0 && optIndex < len(pending.Options) { + matchedOpt = &pending.Options[optIndex] + } + + // 肯定前缀后含独立新请求谓词时,不得吞掉新意图(如「好,请先查法规」) + if matchedOpt == nil && hasIndependentNewPredicate(input) { + return nil + } + + // 3. 显式确认短语(不受 AllowAffirmative 限制) + explicitConfirm := isExplicitConfirmPhrase(input) + + // 4. 肯定词检验 (如 "好", "行", "可以", "成", "没问题", "同意", "好的", "就按这个", "按你说的办", "确认") + affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以", "没问题", "同意", "就按这个", "按这个", "就按这个办", "听你的", "ok", "yes", "就这么定", "确认", "确定", "批准"} + if pending.AffirmativeWord != "" { + affirmativePrefixes = append(affirmativePrefixes, pending.AffirmativeWord) + } + isAffirmative := explicitConfirm + + for _, aff := range affirmativePrefixes { + if input == aff || + strings.HasPrefix(input, aff+",") || + strings.HasPrefix(input, aff+",") || + strings.HasPrefix(input, aff+"但") || + strings.HasPrefix(input, aff+" ") || + strings.HasPrefix(input, aff+"!") || + strings.HasPrefix(input, aff+"!") || + (aff == "确认" && (input == "确认" || input == "确认驳回" || input == "确认批准" || input == "确认执行")) || + (pending.AffirmativeWord != "" && strings.HasPrefix(input, pending.AffirmativeWord)) { + isAffirmative = true + break + } + } + + // 裸肯定词必须 AllowAffirmative=true(显式确认短语除外) + if isAffirmative && !explicitConfirm && !pending.AllowAffirmative { + bareAffirmative := false + for _, aff := range affirmativePrefixes { + if input == aff { + bareAffirmative = true + break + } + } + if bareAffirmative { + isAffirmative = false + } + } + + if matchedOpt == nil && isAffirmative { + if pending.DefaultOption != nil { + matchedOpt = pending.DefaultOption + } else if len(pending.Options) > 0 { + matchedOpt = &pending.Options[0] + } else if pending.ExpectedIntent != "" { + matchedOpt = &ExpectationOption{ + OptionID: pending.TargetAction, + Title: pending.Prompt, + Intent: pending.ExpectedIntent, + Slots: pending.Payload, + } + } + } + + if matchedOpt == nil { + return nil + } + + // 4. 提取增量事实补丁 (Patch Merge) + // 例如: "好,但目标市场改成欧洲CE", "选1,先把预算定为15万" + patchSlots := extractFactSlots(input) + + filledSlots := make(map[string]string) + for k, v := range matchedOpt.Slots { + filledSlots[k] = v + } + for k, v := range patchSlots { + filledSlots[k] = v + } + if pending.TargetAction != "" && filledSlots["target_action"] == "" { + filledSlots["target_action"] = pending.TargetAction + } + + confidence := 1.0 + reasoning := fmt.Sprintf("用户明确肯定上一轮参谋建议动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title) + if len(patchSlots) > 0 { + confidence = 0.98 + reasoning = fmt.Sprintf("用户肯定上一轮建议动作 [%s: %s],并附带增量事实补丁: %v", matchedOpt.OptionID, matchedOpt.Title, patchSlots) + } else if optIndex >= 0 { + confidence = 0.98 + reasoning = fmt.Sprintf("用户按序号选择了备选动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title) + } + + return &CandidateIntent{ + Intent: matchedOpt.Intent, + Confidence: confidence, + Branch: BranchExpectationHit, + FilledSlots: filledSlots, + PatchSlots: patchSlots, + MatchedOption: matchedOpt, + Reasoning: reasoning, + } +} + +// matchOptionIndex 匹配用户输入的选项编号 +func matchOptionIndex(input string, options []ExpectationOption) int { + input = strings.TrimSpace(input) + if len(options) == 0 { + return -1 + } + + patterns := []struct { + regex string + idx int + }{ + {"^(1|选1|选第1个|第一个|第1个|选项1|选项一)$", 0}, + {"^(2|选2|选第2个|第二个|第2个|选项2|选项二)$", 1}, + {"^(3|选3|选第3个|第三个|第3个|选项3|选项三)$", 2}, + } + + for _, p := range patterns { + if matched, _ := regexp.MatchString("(?i)"+p.regex, input); matched { + return p.idx + } + } + + for idx, opt := range options { + if strings.EqualFold(opt.OptionID, input) { + return idx + } + } + + return -1 +} + +// isConsultativeOrHesitant 检验是否属于犹豫或征询参谋意见 (分支 3) +func isConsultativeOrHesitant(input string) bool { + patterns := []string{ + "你觉得呢", "你怎么看", "有什么建议", "怎么选", "拿不定主意", + "先放放.*你觉得", "这个先放放", "先放一放.*你觉得", "哪个好", "给点意见", + } + for _, p := range patterns { + if matched, _ := regexp.MatchString("(?i)"+p, input); matched { + return true + } + } + return false +} + +// buildAmbiguousConsultResult 构建分支 3 (模糊/分忧引导) 输出 +func buildAmbiguousConsultResult(input, snapID string, pending *PendingExpectation) *IntentRecognitionResult { + clarifyQ := "我为您准备了当前阶段最稳妥的推进选项。请问需要我为您详细分析技术可行性,还是先为您做方案摸底?" + if pending != nil && len(pending.Options) > 0 { + var optDesc strings.Builder + optDesc.WriteString("针对目前的决策节点,我为您梳理了以下选项供您参考:\n") + for _, o := range pending.Options { + optDesc.WriteString(fmt.Sprintf("• [%s] %s: %s\n", o.OptionID, o.Title, o.Description)) + } + clarifyQ = optDesc.String() + } + + top := &CandidateIntent{ + Intent: IntentCasualChat, + Confidence: 0.65, + Branch: BranchAmbiguousFallback, + FilledSlots: make(map[string]string), + Reasoning: "用户表达犹豫或主动征询参谋意见,触发参谋主动分忧与引导", + ClarificationQuestion: clarifyQ, + } + + result := &IntentRecognitionResult{ + RawInput: input, + Branch: BranchAmbiguousFallback, + TopIntent: top, + Candidates: []CandidateIntent{*top}, + ContextSnapshotID: snapID, + RequiresClarifying: true, + } + finalizeRecognitionResult(result) + return result +} diff --git a/internal/intent/recognizer_helpers.go b/internal/intent/recognizer_helpers.go new file mode 100644 index 00000000..b83fb7bf --- /dev/null +++ b/internal/intent/recognizer_helpers.go @@ -0,0 +1,161 @@ +package intent + +import ( + "regexp" + "strings" +) + +// isSecurityViolation 检查是否命中敏感安全与权限红线 +func isSecurityViolation(input string) bool { + lower := strings.ToLower(input) + patterns := []string{ + "修改身份", "身份修改", "修改职位", "职位修改", "修改岗位", "岗位修改", + "修改姓名", "姓名修改", "修改名字", "名字修改", + "修改部门", "部门修改", + "修改资质", "资质修改", + "改成批准人", "改成审核人", "改成编制人", + "工号改成", "修改工号", "工号修改", + "修改设置", "修改配置", "系统设置", "系统配置", + "修改网关", "网关配置", "网关密钥", "大模型网关", "模型网关", + "修改密码", "重置密码", "提升权限", "越权", + "修改向量库", "向量库连接", "向量库配置", "修改向量库连接", + "修改知识库连接", "知识库连接", "知识库配置", + } + for _, p := range patterns { + if strings.Contains(lower, p) { + return true + } + } + + secRegexes := []string{ + `把.*(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质).*(?:修改|改|换|重置|设为|变为)`, + `(?:修改|改|换|重置|设为).*(?:我的)?(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质|网关|向量库|知识库连接|密钥|apikey|api_key)`, + `.*(?:向量库连接|修改向量库|知识库连接|大模型网关密钥|网关密钥).*`, + } + for _, r := range secRegexes { + if matched, _ := regexp.MatchString(r, input); matched { + return true + } + } + return false +} + +// isPureUICommand 检查是否为纯前端UI视窗布局与主题控制指令 +func isPureUICommand(input string) bool { + patterns := []string{ + "打开工具栏", "关闭工具栏", + "折叠侧边栏", "展开侧边栏", "隐藏侧边栏", "显示侧边栏", + "折叠左侧栏", "展开左侧栏", "折叠右侧栏", "展开右侧栏", + "折叠工作台", "展开工作台", + "切换全屏", "全屏显示", "退出全屏", + "打开控制台", "关闭控制台", + "切换主题", "换成深色", "换成浅色", + "打开抽屉", "展开抽屉", "关闭抽屉", + "深色模式", "浅色模式", "深色主题", "浅色主题", + } + for _, p := range patterns { + if strings.Contains(input, p) { + return true + } + } + return false +} + +// resolveTargetDoc 优先从输入中提取显式受控文档名称,提取不到时回退至上下文的 ActiveDocID +func resolveTargetDoc(input string, userCtx *ContextSnapshot) string { + if doc := extractDocName(input); doc != "" { + return doc + } + if userCtx != nil && userCtx.ActiveDocID != "" { + return userCtx.ActiveDocID + } + return "" +} + +// isRejectReasonQuery 判定是否为询问驳回/退回原因(而非发起驳回执行) +func isRejectReasonQuery(input string) bool { + markers := []string{ + "原因", "为何", "为什么", "怎么回事", "什么情况", "什么意见", + "哪点", "哪些问题", "整改", "意见是什么", "理由", "怎么改", "如何修改", + "查一下驳回", "看看驳回", + } + for _, m := range markers { + if strings.Contains(input, m) { + return true + } + } + return strings.Contains(input, "被驳回") +} + +// extractFactSlots 从文本中提取结构化事实槽位 (支持补丁合并与补充信息) +func extractFactSlots(input string) map[string]string { + slots := make(map[string]string) + + marketPrefixes := []string{ + "目标市场不仅是", "目标市场不仅有", "目标市场还包括", "目标市场确定为", "目标市场确定是", + "目标市场改成", "目标市场改为", "目标市场是", "目标市场为", + "市场改成", "市场改为", "市场是", "市场为", "目标市场除了国内还想考虑", + } + for _, p := range marketPrefixes { + if idx := strings.Index(input, p); idx != -1 { + val := strings.TrimSpace(input[idx+len(p):]) + val = strings.Trim(val, "::,,。 ") + if val != "" { + slots["target_market"] = val + slots[SlotFactKey] = "target_market" + slots[SlotFactValue] = val + break + } + } + } + + budgetPrefixes := []string{"预算改成", "预算改为", "预算是", "预算为", "预算只有", "预算大概", "预算"} + for _, p := range budgetPrefixes { + if idx := strings.Index(input, p); idx != -1 && slots["budget"] == "" { + val := strings.TrimSpace(input[idx+len(p):]) + val = strings.Trim(val, "::,,。 ") + if val != "" { + slots["budget"] = val + slots[SlotFactKey] = "budget" + slots[SlotFactValue] = val + break + } + } + } + + usePrefixes := []string{"用途是", "用途为", "预期用途是", "场景是", "场景为"} + for _, p := range usePrefixes { + if idx := strings.Index(input, p); idx != -1 && slots["intended_use"] == "" { + val := strings.TrimSpace(input[idx+len(p):]) + val = strings.Trim(val, "::,,。 ") + if val != "" { + slots["intended_use"] = val + slots[SlotFactKey] = "intended_use" + slots[SlotFactValue] = val + break + } + } + } + + return slots +} + +func isGreetingOrHelp(input string) bool { + greetings := []string{"你好", "您好", "早上好", "下午好", "晚上好", "早啊", "在吗", "hi", "hello", "你是谁"} + for _, g := range greetings { + if strings.Contains(strings.ToLower(input), g) { + return true + } + } + return false +} + +func sortCandidates(c []CandidateIntent) { + for i := 0; i < len(c)-1; i++ { + for j := i + 1; j < len(c); j++ { + if c[j].Confidence > c[i].Confidence { + c[i], c[j] = c[j], c[i] + } + } + } +} diff --git a/internal/intent/recognizer_matchers_docgov.go b/internal/intent/recognizer_matchers_docgov.go new file mode 100644 index 00000000..ef442fbe --- /dev/null +++ b/internal/intent/recognizer_matchers_docgov.go @@ -0,0 +1,260 @@ +package intent + +import ( + "fmt" + "strings" +) + +// matchDocGovAction 匹配法定受控文档治理流转动作与槽位提取 +func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent { + // 0. 询问驳回原因:归属 DECISION_QUERY,不得路由为 DOCGOV_*_REJECT + if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && isRejectReasonQuery(input) { + slots := make(map[string]string) + fillTargetDocSlot(slots, input, userCtx) + if q := scopeMismatchClarify(slots, activeWorkflowKeyOf(userCtx)); q != "" { + return &CandidateIntent{ + Intent: IntentQueryRejectReason, + Confidence: 0.96, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "用户询问的受控文档属于非激活工作流,前置剪枝为只读询问并给出切换引导", + ClarificationQuestion: q, + } + } + return &CandidateIntent{ + Intent: IntentQueryRejectReason, + Confidence: 0.96, + FilledSlots: slots, + Reasoning: "用户询问受控文档驳回/退回原因,路由为 QUERY_REJECT_REASON", + } + } + + // 跨工作流作用域前置剪枝:治理写动作显式点名他流文档时,拦截并引导切换,不落入任何 DOCGOV_* 写意图。 + if cand := docGovScopePreempt(input, userCtx); cand != nil { + return cand + } + + // 1. 驳回 (Review Reject or Final Reject) + if strings.Contains(input, "驳回") { + isFinal := false + if strings.Contains(input, "终审") || strings.Contains(input, "批准") { + isFinal = true + } else if userCtx != nil && (userCtx.DocLifecycleState == "approving" || userCtx.UserDocRole == "approver") { + isFinal = true + } + + targetIntent := IntentDocGovReviewReject + if isFinal { + targetIntent = IntentDocGovFinalReject + } + + slots := make(map[string]string) + + // target_doc + targetDoc := resolveTargetDoc(input, userCtx) + if targetDoc != "" { + slots[SlotTargetDoc] = targetDoc + } + + // target_section + section := extractSection(input) + if section != "" { + slots[SlotTargetSection] = section + } + + // reject_reason + reason := extractRejectReason(input) + if reason != "" { + slots[SlotRejectReason] = reason + } + + confidence := 0.95 + reasoning := fmt.Sprintf("用户下达法定治理驳回指令,目标意图为 %s", targetIntent) + clarifyQ := "" + + // Stage 0 权限与状态前置剪枝校验 + if userCtx != nil { + if !isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核驳回", userCtx.UserDocRole) + clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核驳回权限(需审核人角色)。", userCtx.UserDocRole) + } else if isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审驳回", userCtx.UserDocRole) + clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准驳回权限(需批准人角色)。", userCtx.UserDocRole) + } + + if userCtx.DocLifecycleState == "drafting" { + confidence = 0.90 + reasoning = "前置剪枝:当前文档处于编制草稿阶段(drafting),尚未提交审核,无法执行驳回操作" + clarifyQ = "当前文档处于编制中(drafting),尚未送审,无需驳回。" + } + } + + return &CandidateIntent{ + Intent: targetIntent, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, + } + } + + // 2. 终审批准 (Final Approve) + if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") || + strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不")) { + targetIntent := IntentDocGovFinalApprove + slots := make(map[string]string) + targetDoc := resolveTargetDoc(input, userCtx) + if targetDoc != "" { + slots[SlotTargetDoc] = targetDoc + } + + confidence := 0.95 + reasoning := "用户下达受控文档终审批准生效指令" + clarifyQ := "" + + if userCtx != nil { + if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审批准", userCtx.UserDocRole) + clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准权限(需批准人角色)。", userCtx.UserDocRole) + } else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approving" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入终审批准阶段(approving),无法执行终审批准", userCtx.DocLifecycleState) + clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入终审阶段,无法执行终审批准。", userCtx.DocLifecycleState) + } + } + + return &CandidateIntent{ + Intent: targetIntent, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, + } + } + + // 3. 审核通过 (Review Approve) + if strings.Contains(input, "审核通过") || strings.Contains(input, "审阅通过") || strings.Contains(input, "评审通过") || + (strings.Contains(input, "通过") && (strings.Contains(input, "审核") || strings.Contains(input, "审阅") || strings.Contains(input, "报告") || strings.Contains(input, "准予") || strings.Contains(input, "环节"))) { + if !strings.Contains(input, "下一阶段") && !strings.Contains(input, "推进") { + targetIntent := IntentDocGovReviewApprove + slots := make(map[string]string) + targetDoc := resolveTargetDoc(input, userCtx) + if targetDoc != "" { + slots[SlotTargetDoc] = targetDoc + } + + confidence := 0.95 + reasoning := "用户下达受控文档审核通过指令" + clarifyQ := "" + + if userCtx != nil { + if userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核通过", userCtx.UserDocRole) + clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核通过权限(需审核人角色)。", userCtx.UserDocRole) + } else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "reviewing" { + confidence = 0.90 + reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入审核阶段(reviewing),无法执行审核通过", userCtx.DocLifecycleState) + clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入审核阶段,无法执行审核通过。", userCtx.DocLifecycleState) + } + } + + return &CandidateIntent{ + Intent: targetIntent, + Confidence: confidence, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: reasoning, + ClarificationQuestion: clarifyQ, + } + } + } + + // 4. 认领文档 (Claim) — 查询语气不得路由为写操作 + if strings.Contains(input, "认领") { + if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "claim") { + slots := make(map[string]string) + if doc := resolveTargetDoc(input, userCtx); doc != "" { + slots[SlotTargetDoc] = doc + } + return &CandidateIntent{ + Intent: IntentQueryUserRights, + Confidence: 0.91, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "查询语气下的文档认领状态询问,不得路由为写操作 DOCGOV_CLAIM", + ClarificationQuestion: "您是想查询该文档当前由谁认领负责吗?我将为您检索认领状态,不会执行认领操作。", + } + } + slots := make(map[string]string) + targetDoc := resolveTargetDoc(input, userCtx) + if targetDoc != "" { + slots[SlotTargetDoc] = targetDoc + } + + return &CandidateIntent{ + Intent: IntentDocGovClaim, + Confidence: 0.94, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "用户下达认领文档编制责任人指令", + } + } + + // 5. 提交送审 (Submit Review) — 查询语气不得路由为写操作 + if strings.Contains(input, "送审") || strings.Contains(input, "提交审核") || strings.Contains(input, "发起审核") || strings.Contains(input, "提审") { + if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "submit") { + slots := make(map[string]string) + if doc := resolveTargetDoc(input, userCtx); doc != "" { + slots[SlotTargetDoc] = doc + } + return &CandidateIntent{ + Intent: IntentQueryRejectReason, + Confidence: 0.91, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "查询语气下的送审/审核状态询问,不得路由为写操作 DOCGOV_SUBMIT_REVIEW", + ClarificationQuestion: "您是想查询该文档的送审或审核状态吗?我将为您检索当前流转状态,不会发起新的送审。", + } + } + slots := make(map[string]string) + targetDoc := resolveTargetDoc(input, userCtx) + if targetDoc != "" { + slots[SlotTargetDoc] = targetDoc + } + + return &CandidateIntent{ + Intent: IntentDocGovSubmitReview, + Confidence: 0.94, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "用户下达编制完成送交审核指令", + } + } + + // 6. 提案裁决 (Proposal Decide) + if strings.Contains(input, "提案") || strings.Contains(input, "修改建议") || strings.Contains(input, "变更建议") { + if strings.Contains(input, "同意") || strings.Contains(input, "采纳") || strings.Contains(input, "拒绝") || strings.Contains(input, "拍板") { + slots := make(map[string]string) + if strings.Contains(input, "拒绝") { + slots[SlotProposalAction] = "reject" + } else { + slots[SlotProposalAction] = "accept" + } + return &CandidateIntent{ + Intent: IntentDocGovProposalDecide, + Confidence: 0.93, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "用户对受控文档的修改提案或批注做出决策裁决", + } + } + } + + return nil +} diff --git a/internal/intent/recognizer_matchers_kb.go b/internal/intent/recognizer_matchers_kb.go new file mode 100644 index 00000000..e6ff6e0d --- /dev/null +++ b/internal/intent/recognizer_matchers_kb.go @@ -0,0 +1,146 @@ +package intent + +import ( + "regexp" + "strings" +) + +// matchKnowledgeQuery 匹配法规标准、标杆案例与权限查询 +func matchKnowledgeQuery(input string) *CandidateIntent { + // 排除事实参数补充的输入场景 + excludeKeywords := []string{"客户说", "客户反馈", "客户补充", "补充说", "补充", "修改为", "改为", "增加"} + for _, ek := range excludeKeywords { + if strings.Contains(input, ek) { + return nil + } + } + + // 1. 用户权限查询 (IntentQueryUserRights) + userRightsPatterns := []string{ + "我的权限", "我有什么权限", "我负责什么", "我的角色", "查看我的职责", + "查询权限", "当前权限", "我在这个项目的角色", "法定角色", "法定职责", "我的职责", + "文档权限", "文档职责", "权限和职责", "职责和权限", "负责哪些文档", "权限", + } + for _, p := range userRightsPatterns { + if strings.Contains(input, p) { + return &CandidateIntent{ + Intent: IntentQueryUserRights, + Confidence: 0.95, + FilledSlots: map[string]string{ + SlotQueryTopic: "user_rights", + }, + Reasoning: "用户查询自身在项目及受控文档中的法定职责与操作权限", + } + } + } + + // 2. 标杆/相似案例查询 (IntentQuerySimilarCases) + similarCasesPatterns := []string{ + "参考案例", "往期经验", "类似项目", "相似案例", "同类产品", + "同类项目", "以往案例", "标杆案例", "同类获批", "竞品获批案例", + "注册案例", "申报案例", + } + for _, p := range similarCasesPatterns { + if strings.Contains(input, p) { + return &CandidateIntent{ + Intent: IntentQuerySimilarCases, + Confidence: 0.92, + FilledSlots: map[string]string{ + SlotQueryTopic: "similar_cases", + }, + Reasoning: "用户查询以往类似产品的注册申报、上市审评或标杆案例与经验", + } + } + } + + // 3. 法规强标查询 (IntentQueryRegulations) + regKeywords := []string{ + "gb 9706", "gb9706", "yy 0505", "yy0505", "强制性标准", "强标", + "技术审评指导原则", "指导原则", "合规要求", "注册标准", + } + lower := strings.ToLower(input) + for _, rk := range regKeywords { + if strings.Contains(lower, rk) { + return &CandidateIntent{ + Intent: IntentQueryRegulations, + Confidence: 0.92, + FilledSlots: map[string]string{ + SlotQueryTopic: "regulations", + }, + Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规", + } + } + } + + if (strings.Contains(lower, "法规") || strings.Contains(lower, "标准") || strings.Contains(lower, "yy") || strings.Contains(lower, "fda") || strings.Contains(lower, "ce")) && + (strings.Contains(lower, "查") || strings.Contains(lower, "哪些") || strings.Contains(lower, "满足") || strings.Contains(lower, "要求") || strings.Contains(lower, "规范")) { + return &CandidateIntent{ + Intent: IntentQueryRegulations, + Confidence: 0.90, + FilledSlots: map[string]string{ + SlotQueryTopic: "regulations", + }, + Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规", + } + } + + return nil +} + +// extractDocName 从文本或预设映射中提取目标受控文档名称/标识 +func extractDocName(input string) string { + reDocID := regexp.MustCompile(`(doc-[a-zA-Z0-9_-]+)`) + if match := reDocID.FindString(input); match != "" { + return match + } + + docNames := []string{ + "用户初始需求收集表", "用户初始需求表", "用户需求收集表", "需求收集表", "需求表", + "技术与市场调研报告", "调研报告", + "法规与注册论证报告", "法规评估", "注册评估", + "初步设计与架构方案", "初步设计", "设计方案", + "CDO报价单", "初步报价单", "报价单", + } + for _, name := range docNames { + if strings.Contains(input, name) { + return name + } + } + + reBa := regexp.MustCompile(`把\s*(.+?)\s*(?:审核驳回|终审驳回|驳回|审核通过|批准|认领|送审|提交审核)`) + if match := reBa.FindStringSubmatch(input); len(match) > 1 { + return strings.TrimSpace(match[1]) + } + + return "" +} + +// extractRejectReason 从用户输入中提取驳回意见或批注内容 +func extractRejectReason(input string) string { + reReason := regexp.MustCompile(`(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*(.+)`) + if match := reReason.FindStringSubmatch(input); len(match) > 1 { + res := strings.TrimSpace(match[1]) + return strings.Trim(res, ",,。;; ") + } + + reAfterReject := regexp.MustCompile(`驳回[,,::\s]+(.+)`) + if match := reAfterReject.FindStringSubmatch(input); len(match) > 1 { + res := strings.TrimSpace(match[1]) + res = regexp.MustCompile(`^(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*`).ReplaceAllString(res, "") + res = strings.Trim(res, ",,。;; ") + if res != "" { + return res + } + } + + return "" +} + +// extractSection 从文本中抽取目标章节线索 (如 "第3节", "3.1节") +func extractSection(input string) string { + reSection := regexp.MustCompile(`(第[0-9一二三四五六七八九十]+[章节条部分]+|[0-9]+\.[0-9]+[节章]*)`) + if match := reSection.FindString(input); match != "" { + return match + } + return "" +} diff --git a/internal/intent/recognizer_matchers_task.go b/internal/intent/recognizer_matchers_task.go new file mode 100644 index 00000000..2197fc57 --- /dev/null +++ b/internal/intent/recognizer_matchers_task.go @@ -0,0 +1,361 @@ +package intent + +import ( + "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "chat-agent-system/internal/maturity" + "chat-agent-system/internal/squad" +) + +func matchAdvanceStage(input string, currentStage maturity.MaturityStage) *CandidateIntent { + patterns := []string{ + "进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证", + "方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定", + "进入v0.1", "推进到v0.1", "进入v0.5", "推进到v0.5", "进入v1.0", "推进到v1.0", "可以进入", + "没有问题.*进入", "没问题.*推进", "通过.*下一阶段", "开始推进", + "做.*摸底", "开始.*摸底", "先做.*摸底", "进行.*摸底", "做v0.1", "先做v0.1", + "做.*论证", "做.*报价", "开始论证", + } + + for _, p := range patterns { + if matched, _ := regexp.MatchString("(?i)"+p, input); matched { + var target maturity.MaturityStage + if strings.Contains(input, "报价") || strings.Contains(input, "v1.0") || strings.Contains(input, "V1.0") || strings.Contains(input, "商业锁定") { + target = maturity.StageV10_Quoting + } else if strings.Contains(input, "方案") || strings.Contains(input, "论证") || strings.Contains(input, "可行性") || strings.Contains(input, "v0.5") || strings.Contains(input, "V0.5") { + target = maturity.StageV05_Feasibility + } else if strings.Contains(input, "摸底") || strings.Contains(input, "初筛") || strings.Contains(input, "v0.1") || strings.Contains(input, "V0.1") { + target = maturity.StageV01_Triage + } else { + if currentStage == maturity.StageV01_Triage { + target = maturity.StageV05_Feasibility + } else if currentStage == maturity.StageV05_Feasibility { + target = maturity.StageV10_Quoting + } else { + target = maturity.StageV10_Quoting + } + } + + reason := fmt.Sprintf("匹配推进指令 '%s',目标成熟度阶段为 %s", p, target) + + return &CandidateIntent{ + Intent: IntentAdvanceStage, + Confidence: 0.92, + FilledSlots: map[string]string{ + SlotTargetStage: string(target), + }, + Reasoning: reason, + } + } + } + return nil +} + +func matchSupplementInfo(input string) *CandidateIntent { + patterns := []string{ + "补充", "更新", "客户说", "客户反馈", "客户确定", "修改为", "改为", "增加", + "目标市场", "市场是", "参数是", "用途是", "设备是", "预算是", "时间是", "资料", "附件", + } + + matchedAny := false + for _, p := range patterns { + if strings.Contains(input, p) { + matchedAny = true + break + } + } + if !matchedAny { + return nil + } + + slots := extractFactSlots(input) + if len(slots) == 0 { + slots = make(map[string]string) + } + slots[SlotSupplementDoc] = input + slots[SlotSupplementAction] = inferSupplementSubtype(input, slots) + + return &CandidateIntent{ + Intent: IntentSupplementInfo, + Confidence: 0.88, + FilledSlots: slots, + Reasoning: "用户输入中包含参数变更或新材料事实补充", + } +} + +func matchQueryProgress(input string) *CandidateIntent { + if matchTriggerTask(input, nil) != nil { + return nil + } + if isWorkflowControlInput(input) || isRunControlInput(input) { + return nil + } + patterns := []string{ + "测试方案", "验证方案", "测试", "验证", "型检", "型式检验", "难点", "特别注意", "注意事项", "比较难", + "进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪", + "风险", "坑", "注意什么", "话术", "怎么说", "怎么跟客户说", "微信", "草稿", + "探头功耗", "功耗", "供电", "怎么设计", "设计方案", "临床要不要", "怎么做", "查一下", "看下", + "成熟度", "汇报一下", "项目怎么样", + } + + for _, p := range patterns { + if strings.Contains(input, p) { + topic := "all" + if strings.Contains(input, "测试") || strings.Contains(input, "验证") || strings.Contains(input, "型检") || strings.Contains(input, "难点") || strings.Contains(input, "特别注意") || strings.Contains(input, "注意事项") || strings.Contains(input, "比较难") { + topic = "testing_analysis" + } else if strings.Contains(input, "风险") || strings.Contains(input, "坑") { + topic = "risk" + } else if strings.Contains(input, "话术") || strings.Contains(input, "微信") || strings.Contains(input, "怎么说") { + topic = "talk_script" + } else if strings.Contains(input, "进展") || strings.Contains(input, "进度") || strings.Contains(input, "步") { + topic = "progress" + } else if strings.Contains(input, "功耗") || strings.Contains(input, "供电") || strings.Contains(input, "设计") { + topic = "technical_design" + } + + confidence := 0.90 + if topic == "testing_analysis" { + confidence = 0.95 + } + + return &CandidateIntent{ + Intent: IntentQueryProgress, + Confidence: confidence, + FilledSlots: map[string]string{ + SlotQueryTopic: topic, + }, + Reasoning: fmt.Sprintf("用户在询问项目状态、技术测试方案或对外沟通话术 (主题: %s)", topic), + } + } + } + return nil +} + +func hasTaskWriteVerb(input string) bool { + for _, verb := range []string{"生成", "做", "跑", "出", "编写", "编制", "起草"} { + if strings.Contains(input, verb) { + return true + } + } + return false +} + +// hasWeakTaskWriteVerb 弱写动词:倾向整理/梳理,默认需确认后再启动正式任务。 +func hasWeakTaskWriteVerb(input string) bool { + for _, verb := range []string{"整理", "梳理", "汇总", "归纳", "收集"} { + if strings.Contains(input, verb) { + return true + } + } + return false +} + +func isRequirementsTopic(input string) bool { + if strings.Contains(input, "需求") || strings.Contains(input, "摸底") { + return true + } + return strings.Contains(input, "用户") && (hasTaskWriteVerb(input) || hasWeakTaskWriteVerb(input)) +} + +// hasURSStrongMarker URS / 用户需求规格书级强标记(非「初始/摸底」时才算)。 +func hasURSStrongMarker(input string) bool { + if hasIntakeSemanticMarker(input) { + return false + } + return strings.Contains(input, "URS") || + strings.Contains(input, "用户需求规格") || + strings.Contains(input, "用户需求表") +} + +// stageAwareRequirementsTask 按当前阶段消解需求类任务键,与前端 resolveRequirementsTaskKey 对齐: +// V0.1 / 空阶段且无 URS 强标记 → intake;含 URS 强标记 → user_requirements。 +func stageAwareRequirementsTask(input string, userCtx *ContextSnapshot) string { + if hasIntakeSemanticMarker(input) { + return "intake" + } + stage := "" + if userCtx != nil { + stage = userCtx.CurrentStage + } + if hasURSStrongMarker(input) { + return "user_requirements" + } + // 含糊「需求」在初期默认摸底表,避免误触 V0.5 门禁失败。 + if stage == "" || stage == string(maturity.StageV01_Triage) { + return "intake" + } + if strings.Contains(input, "用户需求") || strings.Contains(input, "需求表") { + return "user_requirements" + } + return "intake" +} + +func requirementsTriggerPhrase(taskKey string) string { + switch taskKey { + case "user_requirements": + return "用户需求表" + default: + return "用户初始需求收集表" + } +} + +func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { + type phrase struct { + text string + key string + } + taskMap := map[string]string{ + "需求收集表": "intake", + "需求表": "intake", + "调研报告": "research", + "调研": "research", + "法规评估": "feasibility_reg", + "注册评估": "feasibility_reg", + "初步设计": "prelim_design", + "设计方案": "prelim_design", + "报价单": "cdo_quote", + "报价": "cdo_quote", + } + phrases := make([]phrase, 0, len(taskMap)+60) + for kw, tKey := range taskMap { + phrases = append(phrases, phrase{text: kw, key: tKey}) + } + for _, item := range squad.RDevTriggerPhrases() { + phrases = append(phrases, phrase{text: item.Text, key: item.TaskKey}) + } + sort.Slice(phrases, func(i, j int) bool { + li := utf8.RuneCountInString(phrases[i].text) + lj := utf8.RuneCountInString(phrases[j].text) + if li != lj { + return li > lj + } + return phrases[i].text < phrases[j].text + }) + + strong := hasTaskWriteVerb(input) + weak := hasWeakTaskWriteVerb(input) + if !strong && !weak { + return nil + } + + // 需求类话题优先阶段感知消解,避免研发短别名「用户需求」截走商务流初期表述。 + if isRequirementsTopic(input) { + taskKey := stageAwareRequirementsTask(input, userCtx) + phrase := requirementsTriggerPhrase(taskKey) + if cand := scopedTriggerTaskCandidate(input, phrase, taskKey, userCtx); cand != nil { + return cand + } + cand := triggerTaskCandidate(phrase, taskKey) + if !strong && weak { + cand.Confidence = 0.78 + cand.FilledSlots[SlotNeedsConfirmation] = "true" + cand.Reasoning = fmt.Sprintf("弱写动词触发需求整理,阶段感知消解为 %s,需用户确认后执行", taskKey) + } + return cand + } + + if !strong { + // 弱写动词仅覆盖需求类宾语;其他话题仍走对话兜底。 + return nil + } + + if workflowScopeUsesWeights() { + flat := make([]struct{ text, key string }, 0, len(phrases)) + for _, item := range phrases { + flat = append(flat, struct{ text, key string }{text: item.text, key: item.key}) + } + return weightedTriggerTask(input, flat, userCtx) + } + + for _, item := range phrases { + if strings.Contains(input, item.text) { + if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil { + return cand + } + return triggerTaskCandidate(item.text, item.key) + } + } + return nil +} + +// scopedTriggerTaskCandidate 若目标任务键不属于当前激活工作流,返回跨流拦截候选; +// 无激活工作流(存量单流 / planning 态)或归属未知时返回 nil,维持原路由。 +func scopedTriggerTaskCandidate(input, phrase, taskKey string, userCtx *ContextSnapshot) *CandidateIntent { + active := "" + if userCtx != nil { + active = userCtx.ActiveWorkflowKey + } + owners, ok := docScopeForPhrase(phrase) + if !ok { + if owner := taskKeyWorkflowOwner(taskKey); owner != "" { + owners = []string{owner} + ok = true + } + } + if !ok || active == "" || containsWorkflow(owners, active) { + return nil + } + return workflowScopeCandidate(phrase, owners, active) +} + +func workflowScopeCandidate(phrase string, owners []string, activeWorkflow string) *CandidateIntent { + slots := make(map[string]string) + markWorkflowScopeMismatch(slots, phrase, owners) + return &CandidateIntent{ + Intent: IntentTriggerTask, + Confidence: 0.86, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: fmt.Sprintf("用户要求生成交付任务 %s,但该文档属于非激活工作流,前置剪枝", phrase), + ClarificationQuestion: workflowScopeGuidanceQuestion(phrase, owners, activeWorkflow), + } +} + +func containsWorkflow(workflows []string, key string) bool { + for _, wf := range workflows { + if wf == key { + return true + } + } + return false +} + +func hasIntakeSemanticMarker(input string) bool { + return strings.Contains(input, "首轮") || + strings.Contains(input, "初始") || + strings.Contains(input, "需求摸底") || + strings.Contains(input, "摸底") +} + +func triggerTaskCandidate(phrase, taskKey string) *CandidateIntent { + return &CandidateIntent{ + Intent: IntentTriggerTask, + Confidence: 0.86, + FilledSlots: map[string]string{ + SlotTargetTask: taskKey, + }, + Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", phrase, taskKey), + } +} + +func matchImpactIntent(input string) *CandidateIntent { + propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档") + analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") || + strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响") + if !propose && !analyze { + return nil + } + intent := IntentImpactAnalyze + if propose { + intent = IntentRevisePropose + } + return &CandidateIntent{ + Intent: intent, + Confidence: 0.9, + FilledSlots: map[string]string{}, + } +} diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 456950d4..37db764f 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -2,14 +2,7 @@ package projectworkflow import ( "context" - "encoding/json" - "errors" - "fmt" - "strings" - "sync" - "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -55,1181 +48,3 @@ func NewStore(pool *pgxpool.Pool) Store { type pgStore struct { pool *pgxpool.Pool } - -func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, error) { - now := time.Now() - p.CreatedAt = now - p.UpdatedAt = now - if p.Status == "" { - p.Status = "active" - } - if p.Environment == "" { - p.Environment = "dev" - } - if p.CurrentStage == "" { - p.CurrentStage = "V0.1_triage" - } - if p.LifecycleStatus == "" { - p.LifecycleStatus = "planning" - } - if p.WorkflowSelectionVersion <= 0 { - p.WorkflowSelectionVersion = 1 - } - - query := ` - INSERT INTO projects ( - code, name, customer_name, device_type, intended_use, target_market, - product_domain, project_type_key, compliance_profile_key, governance_profile, - team_mode, execution_mode, data_sensitivity, network_policy, budget_class, - problem_statement, target_users, target_outcome, product_shape, - current_stage, environment, status, creator_user_id, lifecycle_status, - active_workflow_key, workflow_selection_version, workflow_plan_revision, - client_key, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13, $14, $15, - $16, $17, $18, $19, - $20, $21, $22, $23, $24, - $25, $26, $27, - NULLIF(trim($28), ''), $29, $30 - ) - RETURNING id - ` - var clientKey any - if strings.TrimSpace(p.ClientKey) != "" { - clientKey = strings.TrimSpace(p.ClientKey) - } else { - clientKey = nil - } - err := s.pool.QueryRow( - ctx, query, - p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket, - p.ProductDomain, p.ProjectTypeKey, p.ComplianceProfileKey, p.GovernanceProfile, - p.TeamMode, p.ExecutionMode, p.DataSensitivity, p.NetworkPolicy, p.BudgetClass, - p.ProblemStatement, p.TargetUsers, p.TargetOutcome, p.ProductShape, - p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus, - p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision, - clientKey, p.CreatedAt, p.UpdatedAt, - ).Scan(&p.ID) - if err != nil { - return nil, fmt.Errorf("insert project failed: %w", err) - } - - return p, nil -} - -func (s *pgStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) { - clientKey = strings.TrimSpace(clientKey) - if creatorUserID <= 0 || clientKey == "" { - return nil, ErrProjectNotFound - } - query := ` - SELECT id, code, name, customer_name, device_type, intended_use, target_market, - COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), - COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), - COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), - COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), - COALESCE(product_shape, ''), - current_stage, environment, status, COALESCE(creator_user_id, 1), - COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), - COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), - active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at, - COALESCE(client_key, '') - FROM projects - WHERE creator_user_id = $1 AND client_key = $2 - LIMIT 1 - ` - p := &Project{} - err := s.pool.QueryRow(ctx, query, creatorUserID, clientKey).Scan( - &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, - &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, - &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, - &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, - &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, - &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, - &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, - &p.ClientKey, - ) - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrProjectNotFound - } - if err != nil { - return nil, fmt.Errorf("get project by client_key failed: %w", err) - } - return p, nil -} - -func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) { - name = strings.TrimSpace(name) - if creatorUserID <= 0 || name == "" { - return nil, ErrProjectNotFound - } - query := ` - SELECT id, code, name, customer_name, device_type, intended_use, target_market, - COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), - COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), - COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), - COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), - COALESCE(product_shape, ''), - current_stage, environment, status, COALESCE(creator_user_id, 1), - COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), - COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), - active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at, - COALESCE(client_key, '') - FROM projects - WHERE creator_user_id = $1 AND lower(trim(name)) = lower(trim($2)) - ORDER BY id ASC - LIMIT 1 - ` - p := &Project{} - err := s.pool.QueryRow(ctx, query, creatorUserID, name).Scan( - &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, - &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, - &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, - &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, - &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, - &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, - &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, - &p.ClientKey, - ) - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrProjectNotFound - } - if err != nil { - return nil, fmt.Errorf("find project by creator+name failed: %w", err) - } - return p, nil -} - -func (s *pgStore) FindProjectByName(ctx context.Context, name string) (*Project, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, ErrProjectNotFound - } - query := ` - SELECT id, code, name, customer_name, device_type, intended_use, target_market, - COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), - COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), - COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), - COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), - COALESCE(product_shape, ''), - current_stage, environment, status, COALESCE(creator_user_id, 1), - COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), - COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), - active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at, - COALESCE(client_key, '') - FROM projects - WHERE lower(trim(name)) = lower(trim($1)) - AND COALESCE(status, 'active') <> 'demo_retired' - ORDER BY id ASC - LIMIT 1 - ` - p := &Project{} - err := s.pool.QueryRow(ctx, query, name).Scan( - &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, - &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, - &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, - &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, - &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, - &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, - &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, - &p.ClientKey, - ) - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrProjectNotFound - } - if err != nil { - return nil, fmt.Errorf("find project by name failed: %w", err) - } - return p, nil -} - -func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { - query := ` - SELECT id, code, name, customer_name, device_type, intended_use, target_market, - COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), - COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), - COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), - COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), - COALESCE(product_shape, ''), - current_stage, environment, status, COALESCE(creator_user_id, 1), - COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), - COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), - active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at - FROM projects - WHERE id = $1 - ` - p := &Project{} - err := s.pool.QueryRow(ctx, query, id).Scan( - &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, - &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, - &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, - &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, - &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, - &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, - &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrProjectNotFound - } - if err != nil { - return nil, fmt.Errorf("query project failed: %w", err) - } - return p, nil -} - -func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) { - query := ` - SELECT id, code, name, customer_name, device_type, intended_use, target_market, - current_stage, environment, status, COALESCE(creator_user_id, 1), - COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), - COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), - active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at - FROM projects - WHERE code = $1 - ` - p := &Project{} - err := s.pool.QueryRow(ctx, query, code).Scan( - &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, - &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, - &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrProjectNotFound - } - if err != nil { - return nil, fmt.Errorf("query project by code failed: %w", err) - } - return p, nil -} - -func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) { - // 仅创建人或 active 项目成员可见(移除 creator_id=1/NULL 公开规则) - query := ` - SELECT DISTINCT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market, - p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0), - COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''), - COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0), - p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at - FROM projects p - LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1 AND pm.status = 'active' - WHERE (p.creator_user_id = $1 OR pm.user_id IS NOT NULL) - AND COALESCE(p.status, 'active') <> 'demo_retired' - ORDER BY p.id DESC - ` - rows, err := s.pool.Query(ctx, query, userID) - if err != nil { - return nil, fmt.Errorf("list projects failed: %w", err) - } - defer rows.Close() - - var list []*Project - for rows.Next() { - p := &Project{} - if err := rows.Scan( - &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, - &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, - &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, - ); err != nil { - return nil, err - } - list = append(list, p) - } - return list, rows.Err() -} - -func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) { - query := ` - SELECT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market, - p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0), - COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''), - COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0), - p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at - FROM projects p - WHERE COALESCE(p.status, 'active') <> 'demo_retired' - ORDER BY p.id DESC - ` - rows, err := s.pool.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("list all projects failed: %w", err) - } - defer rows.Close() - - var list []*Project - for rows.Next() { - p := &Project{} - if err := rows.Scan( - &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, - &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, - &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, - ); err != nil { - return nil, err - } - list = append(list, p) - } - return list, rows.Err() -} - -func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { - query := ` - SELECT id, project_id, workflow_key, status, enabled, created_at, updated_at - FROM project_workflows - WHERE project_id = $1 - ORDER BY id ASC - ` - rows, err := s.pool.Query(ctx, query, projectID) - if err != nil { - return nil, fmt.Errorf("list project workflows failed: %w", err) - } - defer rows.Close() - - var list []*ProjectWorkflow - for rows.Next() { - pw := &ProjectWorkflow{} - if err := rows.Scan(&pw.ID, &pw.ProjectID, &pw.WorkflowKey, &pw.Status, &pw.Enabled, &pw.CreatedAt, &pw.UpdatedAt); err != nil { - return nil, err - } - list = append(list, pw) - } - return list, rows.Err() -} - -func (s *pgStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error { - for _, key := range defaultKeys { - status := "ready" - query := ` - INSERT INTO project_workflows (project_id, workflow_key, status, enabled, created_at, updated_at) - VALUES ($1, $2, $3, true, now(), now()) - ON CONFLICT (project_id, workflow_key) DO NOTHING - ` - if _, err := s.pool.Exec(ctx, query, projectID, key, status); err != nil { - return fmt.Errorf("ensure workflow %s failed: %w", key, err) - } - } - return nil -} - -func (s *pgStore) UpdateActiveWorkflow( - ctx context.Context, - projectID int64, - toWorkflowKey string, - expectedVersion int, - changedBy int64, - reason string, -) (*Project, *WorkflowActivationAudit, error) { - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, nil, fmt.Errorf("begin transaction failed: %w", err) - } - defer tx.Rollback(ctx) //nolint:errcheck - - // 1. 行级排他锁项目记录,读取当前版本与激活状态 - var curVersion int - var curKey string - var creatorID int64 - lockQuery := ` - SELECT workflow_selection_version, active_workflow_key, COALESCE(creator_user_id, 1) - FROM projects - WHERE id = $1 - FOR UPDATE - ` - err = tx.QueryRow(ctx, lockQuery, projectID).Scan(&curVersion, &curKey, &creatorID) - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil, ErrProjectNotFound - } - if err != nil { - return nil, nil, fmt.Errorf("lock project failed: %w", err) - } - - // 2. 校验权限 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换) - isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020) - if creatorID > 0 && changedBy > 0 && creatorID != changedBy && changedBy != 1 && !isReviewOrDemo { - return nil, nil, ErrForbiddenNotCreator - } - - // 3. 校验乐观版本 - if expectedVersion > 0 && expectedVersion != curVersion { - if isReviewOrDemo { - // 在评审与演示模式下自动采用当前版本自增,避免前端多端/调试导致的版本冲突阻断 - expectedVersion = curVersion - } else { - return nil, nil, ErrConcurrentConflict - } - } - - // 若已经是目标工作流,幂等返回 - if curKey == toWorkflowKey { - p, err := s.GetProjectByID(ctx, projectID) - if err != nil { - return nil, nil, err - } - return p, nil, nil - } - - nextVersion := curVersion + 1 - now := time.Now() - - // 4. 更新 projects 表激活信息 - updateProjectQuery := ` - UPDATE projects - SET active_workflow_key = $1, - workflow_selection_version = $2, - active_workflow_changed_at = $3, - active_workflow_changed_by = $4, - updated_at = $3 - WHERE id = $5 - ` - if _, err := tx.Exec(ctx, updateProjectQuery, toWorkflowKey, nextVersion, now, changedBy, projectID); err != nil { - return nil, nil, fmt.Errorf("update project active workflow failed: %w", err) - } - - // 5. 更新 project_workflows 表状态 (旧 active -> ready, 新 -> active) - _, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'ready', updated_at = now() WHERE project_id = $1 AND status = 'active'`, projectID) - _, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'active', updated_at = now() WHERE project_id = $1 AND workflow_key = $2`, projectID, toWorkflowKey) - - // 6. 追加审计日志 - auditQuery := ` - INSERT INTO workflow_activation_audits ( - project_id, from_workflow_key, to_workflow_key, changed_by, - version_before, version_after, reason, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id - ` - audit := &WorkflowActivationAudit{ - ProjectID: projectID, - FromWorkflowKey: curKey, - ToWorkflowKey: toWorkflowKey, - ChangedBy: changedBy, - VersionBefore: curVersion, - VersionAfter: nextVersion, - Reason: reason, - CreatedAt: now, - } - if err := tx.QueryRow(ctx, auditQuery, - audit.ProjectID, audit.FromWorkflowKey, audit.ToWorkflowKey, audit.ChangedBy, - audit.VersionBefore, audit.VersionAfter, audit.Reason, audit.CreatedAt, - ).Scan(&audit.ID); err != nil { - return nil, nil, fmt.Errorf("insert audit log failed: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return nil, nil, fmt.Errorf("commit active workflow transaction failed: %w", err) - } - - updatedProject, err := s.GetProjectByID(ctx, projectID) - if err != nil { - return nil, nil, err - } - return updatedProject, audit, nil -} - -func (s *pgStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) { - manifestJSON, _ := json.Marshal(run.InputManifest) - query := ` - INSERT INTO workflow_runs ( - run_id, project_id, workflow_key, status, started_by, stage_key, task_key, - input_manifest, output_doc_id, output_summary, error_message, started_at, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now()) - RETURNING id, created_at - ` - now := time.Now() - run.StartedAt = &now - err := s.pool.QueryRow( - ctx, query, - run.RunID, run.ProjectID, run.WorkflowKey, run.Status, run.StartedBy, - run.StageKey, run.TaskKey, manifestJSON, run.OutputDocID, run.OutputSummary, - run.ErrorMessage, run.StartedAt, - ).Scan(&run.ID, &run.CreatedAt) - if err != nil { - return nil, fmt.Errorf("create workflow run failed: %w", err) - } - return run, nil -} - -func (s *pgStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error { - now := time.Now() - query := ` - UPDATE workflow_runs - SET status = $1, - output_doc_id = CASE WHEN $2 <> '' THEN $2 ELSE output_doc_id END, - output_summary = CASE WHEN $3 <> '' THEN $3 ELSE output_summary END, - error_message = $4, - finished_at = $5 - WHERE run_id = $6 - ` - _, err := s.pool.Exec(ctx, query, status, outputDocID, summary, errMsg, now, runID) - return err -} - -func (s *pgStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) { - query := ` - SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key, - input_manifest, output_doc_id, output_summary, error_message, started_at, finished_at, created_at - FROM workflow_runs - WHERE run_id = $1 - ` - run := &WorkflowRun{} - var manifestRaw []byte - err := s.pool.QueryRow(ctx, query, runID).Scan( - &run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy, - &run.StageKey, &run.TaskKey, &manifestRaw, &run.OutputDocID, &run.OutputSummary, - &run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return nil, errors.New("workflow run not found") - } - if err != nil { - return nil, err - } - if len(manifestRaw) > 0 { - _ = json.Unmarshal(manifestRaw, &run.InputManifest) - } - return run, nil -} - -func (s *pgStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) { - if limit <= 0 { - limit = 20 - } - query := ` - SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key, - output_doc_id, output_summary, error_message, started_at, finished_at, created_at - FROM workflow_runs - WHERE project_id = $1 AND ($2 = '' OR workflow_key = $2) - ORDER BY id DESC - LIMIT $3 - ` - rows, err := s.pool.Query(ctx, query, projectID, workflowKey, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var list []*WorkflowRun - for rows.Next() { - run := &WorkflowRun{} - if err := rows.Scan( - &run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy, - &run.StageKey, &run.TaskKey, &run.OutputDocID, &run.OutputSummary, - &run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt, - ); err != nil { - return nil, err - } - list = append(list, run) - } - return list, rows.Err() -} - -func (s *pgStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { - query := ` - INSERT INTO document_lineage ( - project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version, - upstream_sha256, is_stale, stale_reason, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) - ON CONFLICT (downstream_doc_id, downstream_version, upstream_doc_id, upstream_version) - DO UPDATE SET upstream_sha256 = EXCLUDED.upstream_sha256, - is_stale = EXCLUDED.is_stale, - stale_reason = EXCLUDED.stale_reason - ` - _, err := s.pool.Exec( - ctx, query, - lineage.ProjectID, lineage.DownstreamDocID, lineage.DownstreamVersion, - lineage.UpstreamDocID, lineage.UpstreamVersion, lineage.UpstreamSHA256, - lineage.IsStale, lineage.StaleReason, - ) - return err -} - -func (s *pgStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) { - query := ` - SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, - upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at - FROM document_lineage - WHERE downstream_doc_id = $1 AND ($2 <= 0 OR downstream_version = $2) - ORDER BY id DESC - ` - rows, err := s.pool.Query(ctx, query, docID, version) - if err != nil { - return nil, err - } - defer rows.Close() - - var list []*DocumentLineage - for rows.Next() { - l := &DocumentLineage{} - if err := rows.Scan( - &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, - &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, - ); err != nil { - return nil, err - } - list = append(list, l) - } - return list, rows.Err() -} - -func (s *pgStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { - query := ` - SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, - upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at - FROM document_lineage - WHERE project_id = $1 AND downstream_doc_id = $2 AND ($3 <= 0 OR downstream_version = $3) - ORDER BY id DESC - ` - rows, err := s.pool.Query(ctx, query, projectID, docID, version) - if err != nil { - return nil, err - } - defer rows.Close() - - var list []*DocumentLineage - for rows.Next() { - l := &DocumentLineage{} - if err := rows.Scan( - &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, - &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, - ); err != nil { - return nil, err - } - list = append(list, l) - } - return list, rows.Err() -} - -func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { - query := ` - SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, - upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at - FROM document_lineage - WHERE upstream_doc_id = $1 - ORDER BY id DESC - ` - rows, err := s.pool.Query(ctx, query, upstreamDocID) - if err != nil { - return nil, err - } - defer rows.Close() - - var list []*DocumentLineage - for rows.Next() { - l := &DocumentLineage{} - if err := rows.Scan( - &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, - &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, - ); err != nil { - return nil, err - } - list = append(list, l) - } - return list, rows.Err() -} - -func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) { - now := time.Now() - query := ` - UPDATE document_lineage - SET is_stale = true, - stale_reason = $1, - stale_detected_at = $2 - WHERE upstream_doc_id = $3 AND is_stale = false - RETURNING id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at - ` - rows, err := s.pool.Query(ctx, query, staleReason, now, upstreamDocID) - if err != nil { - return nil, err - } - defer rows.Close() - - var updated []*DocumentLineage - for rows.Next() { - l := &DocumentLineage{} - if err := rows.Scan( - &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, - &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, - ); err != nil { - return nil, err - } - updated = append(updated, l) - } - return updated, rows.Err() -} - -// ============================================================================= -// 纯内存 Fallback Store 实现(用于极速单元测试及无 Postgres 运行态) -// ============================================================================= - -type memStore struct { - mu sync.RWMutex - projects map[int64]*Project - members map[int64]map[int64]struct{} // projectID -> set of active member userIDs - byClientKey map[string]int64 // creatorID|clientKey -> projectID - workflows map[int64]map[string]*ProjectWorkflow - runs map[string]*WorkflowRun - audits []*WorkflowActivationAudit - lineages []*DocumentLineage - nextProjID int64 - nextWFID int64 - nextRunID int64 - nextLineageID int64 -} - -func newMemStore() *memStore { - m := &memStore{ - projects: make(map[int64]*Project), - members: make(map[int64]map[int64]struct{}), - byClientKey: make(map[string]int64), - workflows: make(map[int64]map[string]*ProjectWorkflow), - runs: make(map[string]*WorkflowRun), - nextProjID: 1000, - nextWFID: 1, - nextRunID: 1, - nextLineageID: 1, - } - now := time.Now() - m.projects[1] = &Project{ - ID: 1, - Code: "PROJ-001", - Name: "医用超声高频能量手术集成系统", - CustomerName: "北医三院创新转化中心", - DeviceType: "III类医疗器械", - IntendedUse: "用于外科手术中软组织的切割与血管止血", - TargetMarket: "中国 NMPA + 欧盟 CE MDR", - CurrentStage: "stage_1_spec", - Environment: "dev", - Status: "active", - CreatorUserID: 1, - ActiveWorkflowKey: "main", - WorkflowSelectionVersion: 1, - CreatedAt: now, - UpdatedAt: now, - } - m.projects[5201] = &Project{ - ID: 5201, - Code: "CUSA-NEUROPULSE", - Name: "神经外科双频超声吸引系统 (CUSA-NeuroPulse)", - CustomerName: "天坛医院神经外科研究所", - DeviceType: "III类医疗器械", - IntendedUse: "用于神经外科颅内脑肿瘤精准超声粉碎吸引与止血", - TargetMarket: "中国 NMPA + 欧盟 CE MDR", - CurrentStage: "stage_1_spec", - Environment: "dev", - Status: "active", - CreatorUserID: 1, - ActiveWorkflowKey: "main", - WorkflowSelectionVersion: 1, - CreatedAt: now, - UpdatedAt: now, - } - return m -} - -func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, error) { - m.mu.Lock() - defer m.mu.Unlock() - - m.nextProjID++ - p.ID = m.nextProjID - if p.Status == "" { - p.Status = "active" - } - if p.LifecycleStatus == "" { - p.LifecycleStatus = "planning" - } - if p.WorkflowSelectionVersion <= 0 { - p.WorkflowSelectionVersion = 1 - } - now := time.Now() - p.CreatedAt = now - p.UpdatedAt = now - - cpy := *p - m.projects[p.ID] = &cpy - if p.CreatorUserID > 0 { - if m.members[p.ID] == nil { - m.members[p.ID] = make(map[int64]struct{}) - } - m.members[p.ID][p.CreatorUserID] = struct{}{} - if key := strings.TrimSpace(p.ClientKey); key != "" { - m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID - } - } - return p, nil -} - -// AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN). -func (m *memStore) AddProjectMember(projectID, userID int64) { - if projectID <= 0 || userID <= 0 { - return - } - m.mu.Lock() - defer m.mu.Unlock() - if m.members[projectID] == nil { - m.members[projectID] = make(map[int64]struct{}) - } - m.members[projectID][userID] = struct{}{} -} - -func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) { - clientKey = strings.TrimSpace(clientKey) - if creatorUserID <= 0 || clientKey == "" { - return nil, ErrProjectNotFound - } - m.mu.RLock() - defer m.mu.RUnlock() - id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)] - if !ok { - return nil, ErrProjectNotFound - } - p, ok := m.projects[id] - if !ok { - return nil, ErrProjectNotFound - } - cpy := *p - return &cpy, nil -} - -func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) { - name = strings.TrimSpace(name) - if creatorUserID <= 0 || name == "" { - return nil, ErrProjectNotFound - } - m.mu.RLock() - defer m.mu.RUnlock() - want := strings.ToLower(name) - for _, p := range m.projects { - if p.CreatorUserID == creatorUserID && strings.ToLower(strings.TrimSpace(p.Name)) == want { - cpy := *p - return &cpy, nil - } - } - return nil, ErrProjectNotFound -} - -func (m *memStore) FindProjectByName(ctx context.Context, name string) (*Project, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, ErrProjectNotFound - } - m.mu.RLock() - defer m.mu.RUnlock() - want := strings.ToLower(name) - for _, p := range m.projects { - if strings.EqualFold(p.Status, "demo_retired") { - continue - } - if strings.ToLower(strings.TrimSpace(p.Name)) == want { - cpy := *p - return &cpy, nil - } - } - return nil, ErrProjectNotFound -} - -func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { - m.mu.RLock() - defer m.mu.RUnlock() - p, ok := m.projects[id] - if !ok { - return nil, ErrProjectNotFound - } - cpy := *p - return &cpy, nil -} - -func (m *memStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) { - m.mu.RLock() - defer m.mu.RUnlock() - for _, p := range m.projects { - if p.Code == code { - cpy := *p - return &cpy, nil - } - } - return nil, ErrProjectNotFound -} - -func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var list []*Project - for _, p := range m.projects { - if p.CreatorUserID == userID { - cpy := *p - list = append(list, &cpy) - continue - } - if members, ok := m.members[p.ID]; ok { - if _, isMember := members[userID]; isMember { - cpy := *p - list = append(list, &cpy) - } - } - } - return list, nil -} - -func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) { - m.mu.RLock() - defer m.mu.RUnlock() - list := make([]*Project, 0, len(m.projects)) - for _, p := range m.projects { - cpy := *p - list = append(list, &cpy) - } - return list, nil -} - -func (m *memStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { - m.mu.RLock() - defer m.mu.RUnlock() - wfs, ok := m.workflows[projectID] - if !ok { - return nil, nil - } - var list []*ProjectWorkflow - for _, pw := range wfs { - cpy := *pw - list = append(list, &cpy) - } - return list, nil -} - -func (m *memStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error { - m.mu.Lock() - defer m.mu.Unlock() - if _, ok := m.workflows[projectID]; !ok { - m.workflows[projectID] = make(map[string]*ProjectWorkflow) - } - proj := m.projects[projectID] - activeKey := "" - if proj != nil { - activeKey = proj.ActiveWorkflowKey - } - - for _, key := range defaultKeys { - if _, exists := m.workflows[projectID][key]; !exists { - m.nextWFID++ - status := "ready" - if key == activeKey { - status = "active" - } - now := time.Now() - m.workflows[projectID][key] = &ProjectWorkflow{ - ID: m.nextWFID, - ProjectID: projectID, - WorkflowKey: key, - Status: status, - Enabled: true, - CreatedAt: now, - UpdatedAt: now, - } - } - } - return nil -} - -func (m *memStore) UpdateActiveWorkflow( - ctx context.Context, - projectID int64, - toWorkflowKey string, - expectedVersion int, - changedBy int64, - reason string, -) (*Project, *WorkflowActivationAudit, error) { - m.mu.Lock() - defer m.mu.Unlock() - - p, ok := m.projects[projectID] - if !ok { - return nil, nil, ErrProjectNotFound - } - - // 权限检查 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换) - isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020) - if p.CreatorUserID > 0 && changedBy > 0 && p.CreatorUserID != changedBy && changedBy != 1 && !isReviewOrDemo { - return nil, nil, ErrForbiddenNotCreator - } - - // 乐观锁检查 - if expectedVersion > 0 && expectedVersion != p.WorkflowSelectionVersion { - return nil, nil, ErrConcurrentConflict - } - - // 幂等 - if p.ActiveWorkflowKey == toWorkflowKey { - cpy := *p - return &cpy, nil, nil - } - - curVersion := p.WorkflowSelectionVersion - nextVersion := curVersion + 1 - now := time.Now() - - fromKey := p.ActiveWorkflowKey - p.ActiveWorkflowKey = toWorkflowKey - p.WorkflowSelectionVersion = nextVersion - p.ActiveWorkflowChangedAt = &now - p.ActiveWorkflowChangedBy = &changedBy - p.UpdatedAt = now - - // 更新挂载的工作流状态 - if wfs, ok := m.workflows[projectID]; ok { - for k, wf := range wfs { - if k == toWorkflowKey { - wf.Status = "active" - wf.UpdatedAt = now - } else if wf.Status == "active" { - wf.Status = "ready" - wf.UpdatedAt = now - } - } - } - - audit := &WorkflowActivationAudit{ - ID: int64(len(m.audits) + 1), - ProjectID: projectID, - FromWorkflowKey: fromKey, - ToWorkflowKey: toWorkflowKey, - ChangedBy: changedBy, - VersionBefore: curVersion, - VersionAfter: nextVersion, - Reason: reason, - CreatedAt: now, - } - m.audits = append(m.audits, audit) - - cpy := *p - return &cpy, audit, nil -} - -func (m *memStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.nextRunID++ - run.ID = m.nextRunID - now := time.Now() - run.CreatedAt = now - run.StartedAt = &now - cpy := *run - m.runs[run.RunID] = &cpy - return run, nil -} - -func (m *memStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error { - m.mu.Lock() - defer m.mu.Unlock() - run, ok := m.runs[runID] - if !ok { - return errors.New("workflow run not found") - } - now := time.Now() - run.Status = status - if outputDocID != "" { - run.OutputDocID = outputDocID - } - if summary != "" { - run.OutputSummary = summary - } - run.ErrorMessage = errMsg - run.FinishedAt = &now - return nil -} - -func (m *memStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) { - m.mu.RLock() - defer m.mu.RUnlock() - run, ok := m.runs[runID] - if !ok { - return nil, errors.New("workflow run not found") - } - cpy := *run - return &cpy, nil -} - -func (m *memStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var list []*WorkflowRun - for _, r := range m.runs { - if r.ProjectID == projectID && (workflowKey == "" || r.WorkflowKey == workflowKey) { - cpy := *r - list = append(list, &cpy) - } - } - return list, nil -} - -func (m *memStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { - m.mu.Lock() - defer m.mu.Unlock() - m.nextLineageID++ - lineage.ID = m.nextLineageID - lineage.CreatedAt = time.Now() - cpy := *lineage - m.lineages = append(m.lineages, &cpy) - return nil -} - -func (m *memStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var list []*DocumentLineage - for _, l := range m.lineages { - if l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) { - cpy := *l - list = append(list, &cpy) - } - } - return list, nil -} - -func (m *memStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var list []*DocumentLineage - for _, l := range m.lineages { - if l.ProjectID == projectID && l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) { - cpy := *l - list = append(list, &cpy) - } - } - return list, nil -} - -func (m *memStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { - m.mu.RLock() - defer m.mu.RUnlock() - var list []*DocumentLineage - for _, l := range m.lineages { - if l.UpstreamDocID == upstreamDocID { - cpy := *l - list = append(list, &cpy) - } - } - return list, nil -} - -func (m *memStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) { - m.mu.Lock() - defer m.mu.Unlock() - now := time.Now() - var updated []*DocumentLineage - for _, l := range m.lineages { - if l.UpstreamDocID == upstreamDocID && !l.IsStale { - l.IsStale = true - l.StaleReason = staleReason - l.StaleDetectedAt = &now - cpy := *l - updated = append(updated, &cpy) - } - } - return updated, nil -} diff --git a/internal/projectworkflow/store_mem.go b/internal/projectworkflow/store_mem.go new file mode 100644 index 00000000..3888db4a --- /dev/null +++ b/internal/projectworkflow/store_mem.go @@ -0,0 +1,482 @@ +package projectworkflow + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +// ============================================================================= +// 纯内存 Fallback Store 实现(用于极速单元测试及无 Postgres 运行态) +// ============================================================================= + +type memStore struct { + mu sync.RWMutex + projects map[int64]*Project + members map[int64]map[int64]struct{} // projectID -> set of active member userIDs + byClientKey map[string]int64 // creatorID|clientKey -> projectID + workflows map[int64]map[string]*ProjectWorkflow + runs map[string]*WorkflowRun + audits []*WorkflowActivationAudit + lineages []*DocumentLineage + nextProjID int64 + nextWFID int64 + nextRunID int64 + nextLineageID int64 +} + +func newMemStore() *memStore { + m := &memStore{ + projects: make(map[int64]*Project), + members: make(map[int64]map[int64]struct{}), + byClientKey: make(map[string]int64), + workflows: make(map[int64]map[string]*ProjectWorkflow), + runs: make(map[string]*WorkflowRun), + nextProjID: 1000, + nextWFID: 1, + nextRunID: 1, + nextLineageID: 1, + } + now := time.Now() + m.projects[1] = &Project{ + ID: 1, + Code: "PROJ-001", + Name: "医用超声高频能量手术集成系统", + CustomerName: "北医三院创新转化中心", + DeviceType: "III类医疗器械", + IntendedUse: "用于外科手术中软组织的切割与血管止血", + TargetMarket: "中国 NMPA + 欧盟 CE MDR", + CurrentStage: "stage_1_spec", + Environment: "dev", + Status: "active", + CreatorUserID: 1, + ActiveWorkflowKey: "main", + WorkflowSelectionVersion: 1, + CreatedAt: now, + UpdatedAt: now, + } + m.projects[5201] = &Project{ + ID: 5201, + Code: "CUSA-NEUROPULSE", + Name: "神经外科双频超声吸引系统 (CUSA-NeuroPulse)", + CustomerName: "天坛医院神经外科研究所", + DeviceType: "III类医疗器械", + IntendedUse: "用于神经外科颅内脑肿瘤精准超声粉碎吸引与止血", + TargetMarket: "中国 NMPA + 欧盟 CE MDR", + CurrentStage: "stage_1_spec", + Environment: "dev", + Status: "active", + CreatorUserID: 1, + ActiveWorkflowKey: "main", + WorkflowSelectionVersion: 1, + CreatedAt: now, + UpdatedAt: now, + } + return m +} + +func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.nextProjID++ + p.ID = m.nextProjID + if p.Status == "" { + p.Status = "active" + } + if p.LifecycleStatus == "" { + p.LifecycleStatus = "planning" + } + if p.WorkflowSelectionVersion <= 0 { + p.WorkflowSelectionVersion = 1 + } + now := time.Now() + p.CreatedAt = now + p.UpdatedAt = now + + cpy := *p + m.projects[p.ID] = &cpy + if p.CreatorUserID > 0 { + if m.members[p.ID] == nil { + m.members[p.ID] = make(map[int64]struct{}) + } + m.members[p.ID][p.CreatorUserID] = struct{}{} + if key := strings.TrimSpace(p.ClientKey); key != "" { + m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID + } + } + return p, nil +} + +// AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN). +func (m *memStore) AddProjectMember(projectID, userID int64) { + if projectID <= 0 || userID <= 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if m.members[projectID] == nil { + m.members[projectID] = make(map[int64]struct{}) + } + m.members[projectID][userID] = struct{}{} +} + +func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) { + clientKey = strings.TrimSpace(clientKey) + if creatorUserID <= 0 || clientKey == "" { + return nil, ErrProjectNotFound + } + m.mu.RLock() + defer m.mu.RUnlock() + id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)] + if !ok { + return nil, ErrProjectNotFound + } + p, ok := m.projects[id] + if !ok { + return nil, ErrProjectNotFound + } + cpy := *p + return &cpy, nil +} + +func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) { + name = strings.TrimSpace(name) + if creatorUserID <= 0 || name == "" { + return nil, ErrProjectNotFound + } + m.mu.RLock() + defer m.mu.RUnlock() + want := strings.ToLower(name) + for _, p := range m.projects { + if p.CreatorUserID == creatorUserID && strings.ToLower(strings.TrimSpace(p.Name)) == want { + cpy := *p + return &cpy, nil + } + } + return nil, ErrProjectNotFound +} + +func (m *memStore) FindProjectByName(ctx context.Context, name string) (*Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrProjectNotFound + } + m.mu.RLock() + defer m.mu.RUnlock() + want := strings.ToLower(name) + for _, p := range m.projects { + if strings.EqualFold(p.Status, "demo_retired") { + continue + } + if strings.ToLower(strings.TrimSpace(p.Name)) == want { + cpy := *p + return &cpy, nil + } + } + return nil, ErrProjectNotFound +} + +func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.projects[id] + if !ok { + return nil, ErrProjectNotFound + } + cpy := *p + return &cpy, nil +} + +func (m *memStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + for _, p := range m.projects { + if p.Code == code { + cpy := *p + return &cpy, nil + } + } + return nil, ErrProjectNotFound +} + +func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*Project + for _, p := range m.projects { + if p.CreatorUserID == userID { + cpy := *p + list = append(list, &cpy) + continue + } + if members, ok := m.members[p.ID]; ok { + if _, isMember := members[userID]; isMember { + cpy := *p + list = append(list, &cpy) + } + } + } + return list, nil +} + +func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + list := make([]*Project, 0, len(m.projects)) + for _, p := range m.projects { + cpy := *p + list = append(list, &cpy) + } + return list, nil +} + +func (m *memStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { + m.mu.RLock() + defer m.mu.RUnlock() + wfs, ok := m.workflows[projectID] + if !ok { + return nil, nil + } + var list []*ProjectWorkflow + for _, pw := range wfs { + cpy := *pw + list = append(list, &cpy) + } + return list, nil +} + +func (m *memStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.workflows[projectID]; !ok { + m.workflows[projectID] = make(map[string]*ProjectWorkflow) + } + proj := m.projects[projectID] + activeKey := "" + if proj != nil { + activeKey = proj.ActiveWorkflowKey + } + + for _, key := range defaultKeys { + if _, exists := m.workflows[projectID][key]; !exists { + m.nextWFID++ + status := "ready" + if key == activeKey { + status = "active" + } + now := time.Now() + m.workflows[projectID][key] = &ProjectWorkflow{ + ID: m.nextWFID, + ProjectID: projectID, + WorkflowKey: key, + Status: status, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } + } + } + return nil +} + +func (m *memStore) UpdateActiveWorkflow( + ctx context.Context, + projectID int64, + toWorkflowKey string, + expectedVersion int, + changedBy int64, + reason string, +) (*Project, *WorkflowActivationAudit, error) { + m.mu.Lock() + defer m.mu.Unlock() + + p, ok := m.projects[projectID] + if !ok { + return nil, nil, ErrProjectNotFound + } + + // 权限检查 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换) + isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020) + if p.CreatorUserID > 0 && changedBy > 0 && p.CreatorUserID != changedBy && changedBy != 1 && !isReviewOrDemo { + return nil, nil, ErrForbiddenNotCreator + } + + // 乐观锁检查 + if expectedVersion > 0 && expectedVersion != p.WorkflowSelectionVersion { + return nil, nil, ErrConcurrentConflict + } + + // 幂等 + if p.ActiveWorkflowKey == toWorkflowKey { + cpy := *p + return &cpy, nil, nil + } + + curVersion := p.WorkflowSelectionVersion + nextVersion := curVersion + 1 + now := time.Now() + + fromKey := p.ActiveWorkflowKey + p.ActiveWorkflowKey = toWorkflowKey + p.WorkflowSelectionVersion = nextVersion + p.ActiveWorkflowChangedAt = &now + p.ActiveWorkflowChangedBy = &changedBy + p.UpdatedAt = now + + // 更新挂载的工作流状态 + if wfs, ok := m.workflows[projectID]; ok { + for k, wf := range wfs { + if k == toWorkflowKey { + wf.Status = "active" + wf.UpdatedAt = now + } else if wf.Status == "active" { + wf.Status = "ready" + wf.UpdatedAt = now + } + } + } + + audit := &WorkflowActivationAudit{ + ID: int64(len(m.audits) + 1), + ProjectID: projectID, + FromWorkflowKey: fromKey, + ToWorkflowKey: toWorkflowKey, + ChangedBy: changedBy, + VersionBefore: curVersion, + VersionAfter: nextVersion, + Reason: reason, + CreatedAt: now, + } + m.audits = append(m.audits, audit) + + cpy := *p + return &cpy, audit, nil +} + +func (m *memStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.nextRunID++ + run.ID = m.nextRunID + now := time.Now() + run.CreatedAt = now + run.StartedAt = &now + cpy := *run + m.runs[run.RunID] = &cpy + return run, nil +} + +func (m *memStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error { + m.mu.Lock() + defer m.mu.Unlock() + run, ok := m.runs[runID] + if !ok { + return errors.New("workflow run not found") + } + now := time.Now() + run.Status = status + if outputDocID != "" { + run.OutputDocID = outputDocID + } + if summary != "" { + run.OutputSummary = summary + } + run.ErrorMessage = errMsg + run.FinishedAt = &now + return nil +} + +func (m *memStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) { + m.mu.RLock() + defer m.mu.RUnlock() + run, ok := m.runs[runID] + if !ok { + return nil, errors.New("workflow run not found") + } + cpy := *run + return &cpy, nil +} + +func (m *memStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*WorkflowRun + for _, r := range m.runs { + if r.ProjectID == projectID && (workflowKey == "" || r.WorkflowKey == workflowKey) { + cpy := *r + list = append(list, &cpy) + } + } + return list, nil +} + +func (m *memStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { + m.mu.Lock() + defer m.mu.Unlock() + m.nextLineageID++ + lineage.ID = m.nextLineageID + lineage.CreatedAt = time.Now() + cpy := *lineage + m.lineages = append(m.lineages, &cpy) + return nil +} + +func (m *memStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*DocumentLineage + for _, l := range m.lineages { + if l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) { + cpy := *l + list = append(list, &cpy) + } + } + return list, nil +} + +func (m *memStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*DocumentLineage + for _, l := range m.lineages { + if l.ProjectID == projectID && l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) { + cpy := *l + list = append(list, &cpy) + } + } + return list, nil +} + +func (m *memStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*DocumentLineage + for _, l := range m.lineages { + if l.UpstreamDocID == upstreamDocID { + cpy := *l + list = append(list, &cpy) + } + } + return list, nil +} + +func (m *memStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) { + m.mu.Lock() + defer m.mu.Unlock() + now := time.Now() + var updated []*DocumentLineage + for _, l := range m.lineages { + if l.UpstreamDocID == upstreamDocID && !l.IsStale { + l.IsStale = true + l.StaleReason = staleReason + l.StaleDetectedAt = &now + cpy := *l + updated = append(updated, &cpy) + } + } + return updated, nil +} diff --git a/internal/projectworkflow/store_pg_lineage.go b/internal/projectworkflow/store_pg_lineage.go new file mode 100644 index 00000000..3735e530 --- /dev/null +++ b/internal/projectworkflow/store_pg_lineage.go @@ -0,0 +1,140 @@ +package projectworkflow + +import ( + "context" + "time" +) + +func (s *pgStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { + query := ` + INSERT INTO document_lineage ( + project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version, + upstream_sha256, is_stale, stale_reason, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) + ON CONFLICT (downstream_doc_id, downstream_version, upstream_doc_id, upstream_version) + DO UPDATE SET upstream_sha256 = EXCLUDED.upstream_sha256, + is_stale = EXCLUDED.is_stale, + stale_reason = EXCLUDED.stale_reason + ` + _, err := s.pool.Exec( + ctx, query, + lineage.ProjectID, lineage.DownstreamDocID, lineage.DownstreamVersion, + lineage.UpstreamDocID, lineage.UpstreamVersion, lineage.UpstreamSHA256, + lineage.IsStale, lineage.StaleReason, + ) + return err +} + +func (s *pgStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) { + query := ` + SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, + upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at + FROM document_lineage + WHERE downstream_doc_id = $1 AND ($2 <= 0 OR downstream_version = $2) + ORDER BY id DESC + ` + rows, err := s.pool.Query(ctx, query, docID, version) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []*DocumentLineage + for rows.Next() { + l := &DocumentLineage{} + if err := rows.Scan( + &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, + &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, + ); err != nil { + return nil, err + } + list = append(list, l) + } + return list, rows.Err() +} + +func (s *pgStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) { + query := ` + SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, + upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at + FROM document_lineage + WHERE project_id = $1 AND downstream_doc_id = $2 AND ($3 <= 0 OR downstream_version = $3) + ORDER BY id DESC + ` + rows, err := s.pool.Query(ctx, query, projectID, docID, version) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []*DocumentLineage + for rows.Next() { + l := &DocumentLineage{} + if err := rows.Scan( + &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, + &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, + ); err != nil { + return nil, err + } + list = append(list, l) + } + return list, rows.Err() +} + +func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { + query := ` + SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, + upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at + FROM document_lineage + WHERE upstream_doc_id = $1 + ORDER BY id DESC + ` + rows, err := s.pool.Query(ctx, query, upstreamDocID) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []*DocumentLineage + for rows.Next() { + l := &DocumentLineage{} + if err := rows.Scan( + &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, + &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, + ); err != nil { + return nil, err + } + list = append(list, l) + } + return list, rows.Err() +} + +func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) { + now := time.Now() + query := ` + UPDATE document_lineage + SET is_stale = true, + stale_reason = $1, + stale_detected_at = $2 + WHERE upstream_doc_id = $3 AND is_stale = false + RETURNING id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at + ` + rows, err := s.pool.Query(ctx, query, staleReason, now, upstreamDocID) + if err != nil { + return nil, err + } + defer rows.Close() + + var updated []*DocumentLineage + for rows.Next() { + l := &DocumentLineage{} + if err := rows.Scan( + &l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID, + &l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt, + ); err != nil { + return nil, err + } + updated = append(updated, l) + } + return updated, rows.Err() +} diff --git a/internal/projectworkflow/store_pg_project.go b/internal/projectworkflow/store_pg_project.go new file mode 100644 index 00000000..7dac4018 --- /dev/null +++ b/internal/projectworkflow/store_pg_project.go @@ -0,0 +1,333 @@ +package projectworkflow + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, error) { + now := time.Now() + p.CreatedAt = now + p.UpdatedAt = now + if p.Status == "" { + p.Status = "active" + } + if p.Environment == "" { + p.Environment = "dev" + } + if p.CurrentStage == "" { + p.CurrentStage = "V0.1_triage" + } + if p.LifecycleStatus == "" { + p.LifecycleStatus = "planning" + } + if p.WorkflowSelectionVersion <= 0 { + p.WorkflowSelectionVersion = 1 + } + + query := ` + INSERT INTO projects ( + code, name, customer_name, device_type, intended_use, target_market, + product_domain, project_type_key, compliance_profile_key, governance_profile, + team_mode, execution_mode, data_sensitivity, network_policy, budget_class, + problem_statement, target_users, target_outcome, product_shape, + current_stage, environment, status, creator_user_id, lifecycle_status, + active_workflow_key, workflow_selection_version, workflow_plan_revision, + client_key, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, $23, $24, + $25, $26, $27, + NULLIF(trim($28), ''), $29, $30 + ) + RETURNING id + ` + var clientKey any + if strings.TrimSpace(p.ClientKey) != "" { + clientKey = strings.TrimSpace(p.ClientKey) + } else { + clientKey = nil + } + err := s.pool.QueryRow( + ctx, query, + p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket, + p.ProductDomain, p.ProjectTypeKey, p.ComplianceProfileKey, p.GovernanceProfile, + p.TeamMode, p.ExecutionMode, p.DataSensitivity, p.NetworkPolicy, p.BudgetClass, + p.ProblemStatement, p.TargetUsers, p.TargetOutcome, p.ProductShape, + p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus, + p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision, + clientKey, p.CreatedAt, p.UpdatedAt, + ).Scan(&p.ID) + if err != nil { + return nil, fmt.Errorf("insert project failed: %w", err) + } + + return p, nil +} + +func (s *pgStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) { + clientKey = strings.TrimSpace(clientKey) + if creatorUserID <= 0 || clientKey == "" { + return nil, ErrProjectNotFound + } + query := ` + SELECT id, code, name, customer_name, device_type, intended_use, target_market, + COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), + COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), + COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), + COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), + COALESCE(product_shape, ''), + current_stage, environment, status, COALESCE(creator_user_id, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), + active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at, + COALESCE(client_key, '') + FROM projects + WHERE creator_user_id = $1 AND client_key = $2 + LIMIT 1 + ` + p := &Project{} + err := s.pool.QueryRow(ctx, query, creatorUserID, clientKey).Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, + &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, + &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, + &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + &p.ClientKey, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + if err != nil { + return nil, fmt.Errorf("get project by client_key failed: %w", err) + } + return p, nil +} + +func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) { + name = strings.TrimSpace(name) + if creatorUserID <= 0 || name == "" { + return nil, ErrProjectNotFound + } + query := ` + SELECT id, code, name, customer_name, device_type, intended_use, target_market, + COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), + COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), + COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), + COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), + COALESCE(product_shape, ''), + current_stage, environment, status, COALESCE(creator_user_id, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), + active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at, + COALESCE(client_key, '') + FROM projects + WHERE creator_user_id = $1 AND lower(trim(name)) = lower(trim($2)) + ORDER BY id ASC + LIMIT 1 + ` + p := &Project{} + err := s.pool.QueryRow(ctx, query, creatorUserID, name).Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, + &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, + &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, + &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + &p.ClientKey, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + if err != nil { + return nil, fmt.Errorf("find project by creator+name failed: %w", err) + } + return p, nil +} + +func (s *pgStore) FindProjectByName(ctx context.Context, name string) (*Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrProjectNotFound + } + query := ` + SELECT id, code, name, customer_name, device_type, intended_use, target_market, + COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), + COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), + COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), + COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), + COALESCE(product_shape, ''), + current_stage, environment, status, COALESCE(creator_user_id, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), + active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at, + COALESCE(client_key, '') + FROM projects + WHERE lower(trim(name)) = lower(trim($1)) + AND COALESCE(status, 'active') <> 'demo_retired' + ORDER BY id ASC + LIMIT 1 + ` + p := &Project{} + err := s.pool.QueryRow(ctx, query, name).Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, + &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, + &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, + &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + &p.ClientKey, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + if err != nil { + return nil, fmt.Errorf("find project by name failed: %w", err) + } + return p, nil +} + +func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) { + query := ` + SELECT id, code, name, customer_name, device_type, intended_use, target_market, + COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''), + COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''), + COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''), + COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''), + COALESCE(product_shape, ''), + current_stage, environment, status, COALESCE(creator_user_id, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), + active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at + FROM projects + WHERE id = $1 + ` + p := &Project{} + err := s.pool.QueryRow(ctx, query, id).Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey, + &p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode, + &p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass, + &p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + if err != nil { + return nil, fmt.Errorf("query project failed: %w", err) + } + return p, nil +} + +func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) { + query := ` + SELECT id, code, name, customer_name, device_type, intended_use, target_market, + current_stage, environment, status, COALESCE(creator_user_id, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), + active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at + FROM projects + WHERE code = $1 + ` + p := &Project{} + err := s.pool.QueryRow(ctx, query, code).Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProjectNotFound + } + if err != nil { + return nil, fmt.Errorf("query project by code failed: %w", err) + } + return p, nil +} + +func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) { + // 仅创建人或 active 项目成员可见(移除 creator_id=1/NULL 公开规则) + query := ` + SELECT DISTINCT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market, + p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0), + COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''), + COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0), + p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at + FROM projects p + LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1 AND pm.status = 'active' + WHERE (p.creator_user_id = $1 OR pm.user_id IS NOT NULL) + AND COALESCE(p.status, 'active') <> 'demo_retired' + ORDER BY p.id DESC + ` + rows, err := s.pool.Query(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("list projects failed: %w", err) + } + defer rows.Close() + + var list []*Project + for rows.Next() { + p := &Project{} + if err := rows.Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + ); err != nil { + return nil, err + } + list = append(list, p) + } + return list, rows.Err() +} + +func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) { + query := ` + SELECT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market, + p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0), + COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''), + COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0), + p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at + FROM projects p + WHERE COALESCE(p.status, 'active') <> 'demo_retired' + ORDER BY p.id DESC + ` + rows, err := s.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("list all projects failed: %w", err) + } + defer rows.Close() + + var list []*Project + for rows.Next() { + p := &Project{} + if err := rows.Scan( + &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, + &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + ); err != nil { + return nil, err + } + list = append(list, p) + } + return list, rows.Err() +} diff --git a/internal/projectworkflow/store_pg_runs.go b/internal/projectworkflow/store_pg_runs.go new file mode 100644 index 00000000..7ffece53 --- /dev/null +++ b/internal/projectworkflow/store_pg_runs.go @@ -0,0 +1,108 @@ +package projectworkflow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +func (s *pgStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) { + manifestJSON, _ := json.Marshal(run.InputManifest) + query := ` + INSERT INTO workflow_runs ( + run_id, project_id, workflow_key, status, started_by, stage_key, task_key, + input_manifest, output_doc_id, output_summary, error_message, started_at, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now()) + RETURNING id, created_at + ` + now := time.Now() + run.StartedAt = &now + err := s.pool.QueryRow( + ctx, query, + run.RunID, run.ProjectID, run.WorkflowKey, run.Status, run.StartedBy, + run.StageKey, run.TaskKey, manifestJSON, run.OutputDocID, run.OutputSummary, + run.ErrorMessage, run.StartedAt, + ).Scan(&run.ID, &run.CreatedAt) + if err != nil { + return nil, fmt.Errorf("create workflow run failed: %w", err) + } + return run, nil +} + +func (s *pgStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error { + now := time.Now() + query := ` + UPDATE workflow_runs + SET status = $1, + output_doc_id = CASE WHEN $2 <> '' THEN $2 ELSE output_doc_id END, + output_summary = CASE WHEN $3 <> '' THEN $3 ELSE output_summary END, + error_message = $4, + finished_at = $5 + WHERE run_id = $6 + ` + _, err := s.pool.Exec(ctx, query, status, outputDocID, summary, errMsg, now, runID) + return err +} + +func (s *pgStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) { + query := ` + SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key, + input_manifest, output_doc_id, output_summary, error_message, started_at, finished_at, created_at + FROM workflow_runs + WHERE run_id = $1 + ` + run := &WorkflowRun{} + var manifestRaw []byte + err := s.pool.QueryRow(ctx, query, runID).Scan( + &run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy, + &run.StageKey, &run.TaskKey, &manifestRaw, &run.OutputDocID, &run.OutputSummary, + &run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("workflow run not found") + } + if err != nil { + return nil, err + } + if len(manifestRaw) > 0 { + _ = json.Unmarshal(manifestRaw, &run.InputManifest) + } + return run, nil +} + +func (s *pgStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) { + if limit <= 0 { + limit = 20 + } + query := ` + SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key, + output_doc_id, output_summary, error_message, started_at, finished_at, created_at + FROM workflow_runs + WHERE project_id = $1 AND ($2 = '' OR workflow_key = $2) + ORDER BY id DESC + LIMIT $3 + ` + rows, err := s.pool.Query(ctx, query, projectID, workflowKey, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []*WorkflowRun + for rows.Next() { + run := &WorkflowRun{} + if err := rows.Scan( + &run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy, + &run.StageKey, &run.TaskKey, &run.OutputDocID, &run.OutputSummary, + &run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt, + ); err != nil { + return nil, err + } + list = append(list, run) + } + return list, rows.Err() +} diff --git a/internal/projectworkflow/store_pg_workflow.go b/internal/projectworkflow/store_pg_workflow.go new file mode 100644 index 00000000..12a53675 --- /dev/null +++ b/internal/projectworkflow/store_pg_workflow.go @@ -0,0 +1,163 @@ +package projectworkflow + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { + query := ` + SELECT id, project_id, workflow_key, status, enabled, created_at, updated_at + FROM project_workflows + WHERE project_id = $1 + ORDER BY id ASC + ` + rows, err := s.pool.Query(ctx, query, projectID) + if err != nil { + return nil, fmt.Errorf("list project workflows failed: %w", err) + } + defer rows.Close() + + var list []*ProjectWorkflow + for rows.Next() { + pw := &ProjectWorkflow{} + if err := rows.Scan(&pw.ID, &pw.ProjectID, &pw.WorkflowKey, &pw.Status, &pw.Enabled, &pw.CreatedAt, &pw.UpdatedAt); err != nil { + return nil, err + } + list = append(list, pw) + } + return list, rows.Err() +} + +func (s *pgStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error { + for _, key := range defaultKeys { + status := "ready" + query := ` + INSERT INTO project_workflows (project_id, workflow_key, status, enabled, created_at, updated_at) + VALUES ($1, $2, $3, true, now(), now()) + ON CONFLICT (project_id, workflow_key) DO NOTHING + ` + if _, err := s.pool.Exec(ctx, query, projectID, key, status); err != nil { + return fmt.Errorf("ensure workflow %s failed: %w", key, err) + } + } + return nil +} + +func (s *pgStore) UpdateActiveWorkflow( + ctx context.Context, + projectID int64, + toWorkflowKey string, + expectedVersion int, + changedBy int64, + reason string, +) (*Project, *WorkflowActivationAudit, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, nil, fmt.Errorf("begin transaction failed: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + // 1. 行级排他锁项目记录,读取当前版本与激活状态 + var curVersion int + var curKey string + var creatorID int64 + lockQuery := ` + SELECT workflow_selection_version, active_workflow_key, COALESCE(creator_user_id, 1) + FROM projects + WHERE id = $1 + FOR UPDATE + ` + err = tx.QueryRow(ctx, lockQuery, projectID).Scan(&curVersion, &curKey, &creatorID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, ErrProjectNotFound + } + if err != nil { + return nil, nil, fmt.Errorf("lock project failed: %w", err) + } + + // 2. 校验权限 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换) + isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020) + if creatorID > 0 && changedBy > 0 && creatorID != changedBy && changedBy != 1 && !isReviewOrDemo { + return nil, nil, ErrForbiddenNotCreator + } + + // 3. 校验乐观版本 + if expectedVersion > 0 && expectedVersion != curVersion { + if isReviewOrDemo { + // 在评审与演示模式下自动采用当前版本自增,避免前端多端/调试导致的版本冲突阻断 + expectedVersion = curVersion + } else { + return nil, nil, ErrConcurrentConflict + } + } + + // 若已经是目标工作流,幂等返回 + if curKey == toWorkflowKey { + p, err := s.GetProjectByID(ctx, projectID) + if err != nil { + return nil, nil, err + } + return p, nil, nil + } + + nextVersion := curVersion + 1 + now := time.Now() + + // 4. 更新 projects 表激活信息 + updateProjectQuery := ` + UPDATE projects + SET active_workflow_key = $1, + workflow_selection_version = $2, + active_workflow_changed_at = $3, + active_workflow_changed_by = $4, + updated_at = $3 + WHERE id = $5 + ` + if _, err := tx.Exec(ctx, updateProjectQuery, toWorkflowKey, nextVersion, now, changedBy, projectID); err != nil { + return nil, nil, fmt.Errorf("update project active workflow failed: %w", err) + } + + // 5. 更新 project_workflows 表状态 (旧 active -> ready, 新 -> active) + _, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'ready', updated_at = now() WHERE project_id = $1 AND status = 'active'`, projectID) + _, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'active', updated_at = now() WHERE project_id = $1 AND workflow_key = $2`, projectID, toWorkflowKey) + + // 6. 追加审计日志 + auditQuery := ` + INSERT INTO workflow_activation_audits ( + project_id, from_workflow_key, to_workflow_key, changed_by, + version_before, version_after, reason, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id + ` + audit := &WorkflowActivationAudit{ + ProjectID: projectID, + FromWorkflowKey: curKey, + ToWorkflowKey: toWorkflowKey, + ChangedBy: changedBy, + VersionBefore: curVersion, + VersionAfter: nextVersion, + Reason: reason, + CreatedAt: now, + } + if err := tx.QueryRow(ctx, auditQuery, + audit.ProjectID, audit.FromWorkflowKey, audit.ToWorkflowKey, audit.ChangedBy, + audit.VersionBefore, audit.VersionAfter, audit.Reason, audit.CreatedAt, + ).Scan(&audit.ID); err != nil { + return nil, nil, fmt.Errorf("insert audit log failed: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, nil, fmt.Errorf("commit active workflow transaction failed: %w", err) + } + + updatedProject, err := s.GetProjectByID(ctx, projectID) + if err != nil { + return nil, nil, err + } + return updatedProject, audit, nil +} diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index b1599549..1304bd7f 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -1,7 +1,6 @@ package squad import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -9,7 +8,6 @@ import ( "time" "chat-agent-system/internal/template" - "chat-agent-system/internal/workflowdef" ) // WorkspacePrepareRequest 工作区准备请求 @@ -259,893 +257,3 @@ func (e *WorkspaceEngine) PrepareWorkspace(req WorkspacePrepareRequest) (*Worksp Workflow: wf, }, nil } - -// buildContextSystem 构建具有权威度说明的 context/ 体系 -func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepareRequest) error { - ctxDir := filepath.Join(workDir, "context") - _ = os.MkdirAll(filepath.Join(ctxDir, "materials_text"), 0o755) - _ = os.MkdirAll(filepath.Join(ctxDir, "knowledge"), 0o755) - _ = os.MkdirAll(filepath.Join(ctxDir, "prior_docs"), 0o755) - _ = os.MkdirAll(filepath.Join(ctxDir, "memory"), 0o755) - - var contextItems []ContextItem - - // 1. user.md - userContent := req.UserContext - if strings.TrimSpace(userContent) == "" { - userContent = fmt.Sprintf("# 用户角色与偏好\n\n- 用户角色: 商务与技术负责人\n- 决策风格: 严谨可追溯,注重四段式论据\n- 授权边界: 预算与商业承诺必须经由用户拍板\n") - } - _ = os.WriteFile(filepath.Join(ctxDir, "user.md"), []byte(userContent), 0o644) - contextItems = append(contextItems, ContextItem{ - File: "context/user.md", - Description: "操作用户的业务角色、决策授权边界与协同偏好", - AuthorityLevel: "authoritative", - Guidance: "必须尊重用户设定的目标市场与边界,用户未决事项一律列入待确认清单,严禁越权替用户拍板", - }) - - // 2. project.md - projContent := req.ProjectContext - if strings.TrimSpace(projContent) == "" { - projContent = fmt.Sprintf("# 项目全局基线\n\n- 项目名称: %s\n- 客户名称: %s\n- 产品类型: %s\n- 预期用途: %s\n- 目标市场: %s\n", - req.ProjectName, req.CustomerName, req.DeviceType, req.IntendedUse, req.TargetMarket) - } - _ = os.WriteFile(filepath.Join(ctxDir, "project.md"), []byte(projContent), 0o644) - contextItems = append(contextItems, ContextItem{ - File: "context/project.md", - Description: "项目立项章程与全局事实基线", - AuthorityLevel: "authoritative", - Guidance: "本项目核心前提。所有后续分析论证不得推翻已锁定的项目名称与核心预期用途", - }) - - // 3. agent.md - agentContent := req.AgentContext - if strings.TrimSpace(agentContent) == "" { - agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作;各章节须实质展开,不得以篇幅或省略写法跳过应写内容\n" - } - _ = os.WriteFile(filepath.Join(ctxDir, "agent.md"), []byte(agentContent), 0o644) - contextItems = append(contextItems, ContextItem{ - File: "context/agent.md", - Description: "智能体通用协同纪律与引用要求", - AuthorityLevel: "authoritative", - Guidance: "所有小队角色必须严格执行的专业守则与写作禁令", - }) - - // 4. materials_text/ - if len(req.Materials) > 0 { - var allBuilder strings.Builder - for _, m := range req.Materials { - name := filepath.Base(m.Filename) - if name == "" || name == "." { - name = "material.txt" - } - - // 自动提取纯文本并注入页码锚点 - textContent, err := ExtractTextWithAnchors(name, m.Content) - if err != nil || strings.TrimSpace(textContent) == "" { - textContent = string(m.Content) - } - textBytes := []byte(textContent) - - p := filepath.Join(ctxDir, "materials_text", name) - _ = os.WriteFile(p, textBytes, 0o644) - // 若原文件扩展名不是 .txt / .md,同时生成一份 .txt 方便智能体直接阅读 - if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") { - _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", name+".txt"), textBytes, 0o644) - } - - // Also write to root materials_text/ for backward compatibility - rootMatDir := filepath.Join(workDir, "materials_text") - _ = os.MkdirAll(rootMatDir, 0o755) - _ = os.WriteFile(filepath.Join(rootMatDir, name), textBytes, 0o644) - if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") { - _ = os.WriteFile(filepath.Join(rootMatDir, name+".txt"), textBytes, 0o644) - } - - allBuilder.WriteString(fmt.Sprintf("\n\n=== 文件: %s ===\n\n", name)) - allBuilder.WriteString(textContent) - - contextItems = append(contextItems, ContextItem{ - File: filepath.ToSlash(filepath.Join("context", "materials_text", name)), - Description: fmt.Sprintf("用户上传的输入材料文本: %s", name), - AuthorityLevel: "primary_evidence", - Guidance: "核心原始证据。提取参数需标明出处;材料内部矛盾需在矛盾表中并列呈现", - }) - } - _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "_all.txt"), []byte(allBuilder.String()), 0o644) - _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料清单\n\n已汇总在 `context/materials_text/_all.txt`,亦可单独查阅各文件。\n"), 0o644) - } else { - _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料说明\n\n本项目当前未提供独立的附加文本材料,所有事实依据以 `context/project.md`(项目全局基线)、`context/user.md`(用户要求与偏好)以及 `context/knowledge/hits.md`(法规标准知识库)为准。\n"), 0o644) - } - - // 5. knowledge/ - _ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "WEB_CROSSCHECK.md"), []byte("# 法规检索与交叉核对原则\n\n1. 知识库条目(hits.md)为首要法规事实依据。\n2. 如需查询外部信息,以国家药监局官方分类目录为主。\n"), 0o644) - if len(req.KnowledgeHits) > 0 { - var kbBuilder strings.Builder - kbBuilder.WriteString("# 权威法规与技术标准引用库 (Regulatory Knowledge Hits)\n\n") - kbBuilder.WriteString("以下为针对本项目研发与注册需求命中的权威法规、国家标准(GB)与行业标准(YY)条款。\n") - kbBuilder.WriteString("在输出交付物时,请结合上下文使用,并显式标注【引用标记 (CiteKey)】标明出处。\n\n") - for _, hit := range req.KnowledgeHits { - kbBuilder.WriteString(fmt.Sprintf("### [%s] %s %s\n\n", strings.ToUpper(hit.Category), hit.DocCode, hit.Title)) - if hit.HeadingPath != "" { - kbBuilder.WriteString(fmt.Sprintf("- **章节路径**: %s\n", hit.HeadingPath)) - } - if hit.CiteKey != "" { - kbBuilder.WriteString(fmt.Sprintf("- **引用标记 (CiteKey)**: `%s`\n", hit.CiteKey)) - } - kbBuilder.WriteString(fmt.Sprintf("- **来源**: %s\n\n", hit.Source)) - kbBuilder.WriteString(hit.Content + "\n\n---\n\n") - } - _ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "hits.md"), []byte(kbBuilder.String()), 0o644) - contextItems = append(contextItems, ContextItem{ - File: "context/knowledge/hits.md", - Description: "法规知识库中命中的医疗器械分类、国家标准与指导原则", - AuthorityLevel: "reference_standard", - Guidance: "合规与技术论据源。引用必须标注标准号与引用标记 (CiteKey);涉及审批结论需标注'需向药监局审评中心确认'", - }) - } - - // 6. prior_docs/ - if len(req.PriorDocs) > 0 { - var indexBuilder strings.Builder - indexBuilder.WriteString("# 前序阶段交付物索引\n\n") - for _, doc := range req.PriorDocs { - filename := doc.Filename - if filename == "" { - filename = doc.DocKey + ".md" - } - p := filepath.Join(ctxDir, "prior_docs", filename) - _ = os.WriteFile(p, []byte(doc.Content), 0o644) - indexBuilder.WriteString(fmt.Sprintf("- [%s] `%s`: %s (阶段: %s)\n", doc.DocKey, filename, doc.Title, doc.Stage)) - - contextItems = append(contextItems, ContextItem{ - File: filepath.ToSlash(filepath.Join("context", "prior_docs", filename)), - Description: fmt.Sprintf("前序已归档文档: %s (%s)", doc.Title, doc.DocKey), - AuthorityLevel: "primary_evidence", - Guidance: "前序阶段已批准的结论基准。本阶段内容应与前序结论保持自洽", - }) - } - _ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "index.md"), []byte(indexBuilder.String()), 0o644) - } else { - _ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "README.md"), []byte("# 前序交付物说明\n\n本项目当前处于启动阶段,暂无已归档的前序阶段交付物。\n"), 0o644) - } - - if req.TemplateContent != "" { - _ = os.WriteFile(filepath.Join(ctxDir, "template.md"), []byte(req.TemplateContent), 0o644) - } - - // 7. memory/ - if len(req.MemoryBankFiles) > 0 { - for k, v := range req.MemoryBankFiles { - p := filepath.Join(ctxDir, "memory", k) - _ = os.WriteFile(p, []byte(v), 0o644) - } - } else { - // 默认四大核心资产骨架 - _ = os.WriteFile(filepath.Join(ctxDir, "memory", "projectbrief.md"), []byte(fmt.Sprintf("# 项目章程\n- 项目: %s\n- 客户: %s\n", req.ProjectName, req.CustomerName)), 0o644) - _ = os.WriteFile(filepath.Join(ctxDir, "memory", "activeContext.md"), []byte(fmt.Sprintf("# 当前焦点\n- 阶段: %s\n- 时间: %s\n", req.TaskKey, time.Now().Format("2006-01-02 15:04"))), 0o644) - _ = os.WriteFile(filepath.Join(ctxDir, "memory", "systemPatterns.md"), []byte("# 规范与模式\n- 遵循四段式结构与严格角色边界\n- 单一真实源原则\n"), 0o644) - _ = os.WriteFile(filepath.Join(ctxDir, "memory", "progress.md"), []byte(fmt.Sprintf("# 进度\n- 当前正在进行: %s\n", req.StageTitle)), 0o644) - } - contextItems = append(contextItems, ContextItem{ - File: "context/memory/", - Description: "长期项目记忆银行 (ActiveContext, Progress, ProjectBrief, SystemPatterns)", - AuthorityLevel: "supplementary", - Guidance: "协同上下文背景参考,防止上下文遗忘", - }) - - // 8. 写入 context/index.json - ctxIndex := ContextIndex{Items: contextItems} - if err := writeJSON(filepath.Join(ctxDir, "index.json"), ctxIndex); err != nil { - return err - } - - // 9. 写入 context/README.md 指引 - readmeContent := `# 上下文使用指引 (Context Reading Guide) - -本目录为当前智能体任务的自包含上下文沙箱。所有智能体专员在执行任务时,必须按照以下权威度等级(Authority Level)采纳信息: - -| 权威度级别 | 适用文件类别 | 采信与处理规则 | -|---|---|---| -| **authoritative** | ` + "`user.md`" + `, ` + "`project.md`" + `, ` + "`agent.md`" + ` | **最高权威**。必须绝对遵循;严禁越权替用户拍板,未决项列入待确认清单 | -| **primary_evidence** | ` + "`materials_text/*`" + `, ` + "`prior_docs/*`" + ` | **原始事实证据**。引用必须标明出处条文;若材料间存在矛盾,并列呈现 | -| **reference_standard** | ` + "`knowledge/hits.md`" + ` | **标准与法规依据**。必须指明法规/标准代码;涉及监管裁定标注需窗口核实 | -| **supplementary** | ` + "`memory/*`" + ` | **补充记忆背景**。仅供背景了解,不作为唯一支撑证据 | - ---- -` - return os.WriteFile(filepath.Join(ctxDir, "README.md"), []byte(readmeContent), 0o644) -} - -// buildSubWorkspaces 为 workflow 中的各个子任务构建独立沙箱 -func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, squadCfg SquadConfig, renderedTmpl string) error { - agentMap := make(map[string]AgentSpec) - for _, a := range squadCfg.Agents { - agentMap[a.Role] = a - } - - for _, step := range wf.Steps { - subDir := filepath.Join(workDir, filepath.FromSlash(step.SubWorkspace)) - if err := os.MkdirAll(subDir, 0o755); err != nil { - return err - } - - // 1. 生成 AGENTS.md:优先 AgentSpec.InstructionsFile,再按角色名回落,再 inline 渲染 - var agentsBuilder strings.Builder - spec := agentMap[step.Role] - var inst string - var err error - if file := strings.TrimSpace(spec.InstructionsFile); file != "" { - inst, err = template.GetAgentInstructionFile(file) - } - if err != nil || strings.TrimSpace(inst) == "" { - inst, err = template.GetAgentInstruction(step.Role) - } - if err == nil && strings.TrimSpace(inst) != "" { - agentsBuilder.WriteString(inst) - } else { - agentsBuilder.WriteString(fmt.Sprintf("# 角色: %s (%s)\n\n", spec.Name, spec.Role)) - if len(spec.Responsibilities) > 0 { - agentsBuilder.WriteString("## 核心职责\n") - for _, r := range spec.Responsibilities { - agentsBuilder.WriteString(fmt.Sprintf("- %s\n", r)) - } - agentsBuilder.WriteString("\n") - } - if len(spec.ForbiddenActions) > 0 { - agentsBuilder.WriteString("## 严禁行为(红线)\n") - for _, f := range spec.ForbiddenActions { - agentsBuilder.WriteString(fmt.Sprintf("- ❌ %s\n", f)) - } - agentsBuilder.WriteString("\n") - } - } - _ = os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(agentsBuilder.String()), 0o644) - _ = os.WriteFile(filepath.Join(subDir, "INSTRUCTIONS.md"), []byte(agentsBuilder.String()), 0o644) - - // 2. 生成 TASK.md - deliverableName := "output.md" - if step.Mode == "plan" || step.Mode == "replan" { - deliverableName = "output_plan.md" - } else if step.Mode == "review_plan" { - deliverableName = "review_notes.md" - } - - var taskBuilder strings.Builder - if step.InputLayout == InputLayoutSnapshot { - if err := SnapshotInputsIntoSubdir(workDir, subDir, step.Needs, deliverableName); err != nil { - return err - } - taskText := BuildContainedTaskMarkdown(step, deliverableName) - if TaskMarkdownEscapesParent(taskText) { - return fmt.Errorf("snapshot TASK.md must not reference parent paths") - } - if err := writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(taskText)); err != nil { - return err - } - inputRefs := map[string]any{ - "step_id": step.ID, - "role": step.Role, - "needs": step.Needs, - "target_file": deliverableName, - "input_layout": InputLayoutSnapshot, - "forbid_parent_paths": true, - } - _ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs) - continue - } - - taskBuilder.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title)) - taskBuilder.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID)) - taskBuilder.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role)) - taskBuilder.WriteString(fmt.Sprintf("- **执行模式**: `%s`\n", step.Mode)) - taskBuilder.WriteString(fmt.Sprintf("- **交付产物**: `%s`\n\n", deliverableName)) - - taskBuilder.WriteString("## 环境与输入导航\n") - taskBuilder.WriteString("请仔细查阅上级目录资源:\n") - taskBuilder.WriteString("- `../../context/index.json` 及 `../../context/README.md`:阅读上下文指引\n") - taskBuilder.WriteString("- `../../template.md`:自描述交付模板(嵌入四段式与格式约束)\n") - taskBuilder.WriteString("- `../../blackboard.json`:前序专员抽取的结构化事实与缺口\n") - taskBuilder.WriteString("- `../../context/project.md`:项目全局立项基线与产品定义\n") - taskBuilder.WriteString("- `../../context/user.md`:用户个性化工作偏好与决策约束\n") - taskBuilder.WriteString("- `../../context/materials_text/README.md`:原始材料全文或基线说明\n") - taskBuilder.WriteString("- `../../context/knowledge/hits.md`:法规与标准命中快照 (若存在)\n\n") - - if len(step.Needs) > 0 { - taskBuilder.WriteString("## 前序依赖产出\n") - for _, n := range step.Needs { - taskBuilder.WriteString(fmt.Sprintf("- `../../%s`\n", filepath.ToSlash(n))) - } - taskBuilder.WriteString("\n") - } - - if len(step.Accept) > 0 { - taskBuilder.WriteString("## 验收标准(Checklist)\n") - for _, a := range step.Accept { - taskBuilder.WriteString(fmt.Sprintf("- [ ] %s\n", a)) - } - taskBuilder.WriteString("\n") - } - - if strings.HasPrefix(wf.TaskKey, "rd_") { - overlay := RDevStageOverlay(wf.TaskKey, step.Role, rdevTierFromWorkspace(workDir)) - if overlay != "" { - _ = os.WriteFile(filepath.Join(subDir, "OVERLAY.md"), []byte(overlay+"\n"), 0o644) - taskBuilder.WriteString("## 阶段纪律(overlay)\n") - taskBuilder.WriteString("写作纪律与粒度下限见同目录 `OVERLAY.md`。模板只规定章节结构,不得用 overlay 改写章节骨架。\n\n") - } - } - - taskBuilder.WriteString("## 交付契约与写作纪律\n") - taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName)) - taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n") - taskBuilder.WriteString("2. 各章节必须实质展开;禁止以篇幅借口或省略写法跳过应写细节(自检时勿复述禁用原词)。\n") - taskBuilder.WriteString("3. 关键事实必须标注出处,未知项列入待确认清单。\n") - if step.Role == "organizer" { - taskBuilder.WriteString("4. 从材料提取的客观字段,请在当前工作目录下输出 `fields.json`。\n") - } - - _ = os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(taskBuilder.String()), 0o644) - - // 3. 写入 input_refs.json - inputRefs := map[string]any{ - "step_id": step.ID, - "role": step.Role, - "needs": step.Needs, - "target_file": deliverableName, - } - _ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs) - - // 4. 创建便捷软链接,使得当前目录下无论是读 template.md、context/ 还是 ../../ 均可访问 - _ = os.Symlink(filepath.Join("..", "..", "template.md"), filepath.Join(subDir, "template.md")) - _ = os.Symlink(filepath.Join("..", "..", "context"), filepath.Join(subDir, "context")) - _ = os.Symlink(filepath.Join("..", "..", "blackboard.json"), filepath.Join(subDir, "blackboard.json")) - } - - return nil -} - -// DefaultSquadAgents 根据任务类型返回默认小队成员编制 -func DefaultSquadAgents(taskKey string) []AgentSpec { - if strings.HasPrefix(taskKey, "rd_") { - return rdevSquadAgents(taskKey, "") - } - taskKey = workflowdef.ImplementationTaskKey(taskKey) - switch taskKey { - case "intake": - return []AgentSpec{ - { - Role: "organizer", - Name: "材料整理专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"材料事实抽取", "缺口识别", "字段结构化回填"}, - ForbiddenActions: []string{"严禁测算价格", "严禁出具法规终结裁定", "严禁撰写正文终稿"}, - }, - { - Role: "writer", - Name: "文档撰写专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满"}, - ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"}, - }, - { - Role: "leader", - Name: "商务对接负责人", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"计划审核", "正文终审", "待确认清单汇总", "对外汇报"}, - ForbiddenActions: []string{"严禁代用户做商业拍板", "严禁直接交付未审核初稿"}, - }, - } - case "research": - return []AgentSpec{ - { - Role: "searcher", - Name: "资料检索专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"公开资料检索", "法规标准核对", "出处与日期标注"}, - ForbiddenActions: []string{"严禁编造URL与标准号", "未检索到须标明尝试路径"}, - }, - { - Role: "market", - Name: "市场与竞品分析师", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"竞品同维度对比", "市场规模测算", "机会与风险分析"}, - ForbiddenActions: []string{"严禁编造市场份额与价格", "必须采用统一对比维度"}, - }, - { - Role: "regulatory", - Name: "医疗器械法规专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"器械分类初筛", "注册路径比对", "关键标准提炼"}, - ForbiddenActions: []string{"严禁代做行政终局裁定", "必须标注需向窗口确认"}, - }, - { - Role: "writer", - Name: "文档撰写专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"按计划合成调研报告", "全面四段式展开"}, - ForbiddenActions: []string{"严禁以省略写法跳过应写章节"}, - }, - { - Role: "leader", - Name: "商务对接负责人", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"审核撰写计划", "终审报告", "质量把关"}, - ForbiddenActions: []string{"严禁代用户做商业拍板"}, - }, - } - case "system_reqs": - return []AgentSpec{ - { - Role: "organizer", - Name: "需求梳理专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"用户需求抽取", "产品定位基线对齐", "UR需求池建立"}, - ForbiddenActions: []string{"严禁随意篡改原始用户需求"}, - }, - { - Role: "engineer", - Name: "系统工程架构师", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"系统需求分解", "验证方式定义", "接口定义", "requirements.json 导出"}, - ForbiddenActions: []string{"严禁遗漏验证方式", "严禁需求编号重复"}, - }, - { - Role: "regulatory", - Name: "法规合规审查专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"医疗器械强标比对", "适用性与理由论证"}, - ForbiddenActions: []string{"严禁引用虚构标准号"}, - }, - { - Role: "writer", - Name: "系统需求文档编制专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"严格按 pm_system_reqs.md 模板成文", "正文结构完整", "填满追溯表"}, - ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, - }, - { - Role: "leader", - Name: "智能产品经理主管", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"系统需求规格书终审", "追溯一致性把关", "质量门禁验收"}, - ForbiddenActions: []string{"严禁放行未覆盖验证方式的需求"}, - }, - } - case "test_manager": - return []AgentSpec{ - { - Role: "organizer", - Name: "需求追溯分析员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"上游系统需求解析", "需求全集锁定", "追溯关系初始化"}, - ForbiddenActions: []string{"严禁遗漏任一条上游需求"}, - }, - { - Role: "engineer", - Name: "系统测试工程师", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"系统验证用例设计", "判据与接受准则", "test_cases.json 导出"}, - ForbiddenActions: []string{"严禁测试方法泛化为仅写标准号", "严禁无判定准则"}, - }, - { - Role: "regulatory", - Name: "试验标准审查员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"检验方法符合性评估", "检测设备与环境要求审查"}, - ForbiddenActions: []string{"严禁使用过期作废标准"}, - }, - { - Role: "writer", - Name: "系统测试方案编制专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"严格按 tm_system_verification.md 模板成文", "用例主表翔实完整"}, - ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, - }, - { - Role: "leader", - Name: "智能测试经理主管", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"系统验证方案终审", "全覆盖无静默遗漏门禁把关", "质量签署"}, - ForbiddenActions: []string{"严禁放行覆盖率不足 100% 的方案"}, - }, - } - default: - return []AgentSpec{ - { - Role: "organizer", - Name: "材料整理专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"事实整理与基线对齐"}, - }, - { - Role: "writer", - Name: "文档撰写专员", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"正文撰写"}, - }, - { - Role: "leader", - Name: "商务对接负责人", - Model: "claude-3-7-sonnet", - Tools: []string{"read", "write", "edit"}, - BashPermission: "deny", - Responsibilities: []string{"终审与把关"}, - }, - } - } -} - -// DefaultWorkflowSteps 根据任务类型返回最佳实践编排步骤 -func DefaultWorkflowSteps(taskKey string) []StepSpec { - if strings.HasPrefix(taskKey, "rd_") { - return rdevWorkflowSteps(taskKey, "") - } - taskKey = workflowdef.ImplementationTaskKey(taskKey) - switch taskKey { - case "intake": - return []StepSpec{ - { - ID: "step_01_organize", - Role: "organizer", - Title: "抽取材料事实并回填字段", - SubWorkspace: "tasks/01_organize", - Mode: "execute", - Accept: []string{ - "抽取材料中的客户身份与产品定义", - "回填 fields.json 结构化数据", - "向 blackboard 写入 facts 与 gaps", - }, - }, - { - ID: "step_02_draft", - Role: "writer", - Title: "撰写用户初始需求收集表初稿", - SubWorkspace: "tasks/02_draft", - Mode: "execute", - Needs: []string{"tasks/01_organize/output.md"}, - Accept: []string{ - "按 template.md 各章节严格四段式展开", - "正文翔实,各节须实质展开", - "文末集中附录字段快照与待确认清单", - }, - }, - { - ID: "step_03_review", - Role: "leader", - Title: "审核并定稿交付", - SubWorkspace: "tasks/03_review", - Mode: "execute", - Needs: []string{"tasks/02_draft/output.md"}, - Accept: []string{ - "核验全文逻辑一致性与出处标注", - "汇编待确认清单并给出下一步建议", - "交付终稿 output.md", - }, - }, - } - case "research": - return []StepSpec{ - { - ID: "step_01_search", - Role: "searcher", - Title: "公开资料与标准检索", - SubWorkspace: "tasks/01_search", - Mode: "execute", - Parallel: true, - Accept: []string{ - "检索纪要附带明确出处或未检索到说明", - "对本项目含义逐条阐述", - }, - }, - { - ID: "step_02_market", - Role: "market", - Title: "市场格局与竞品同维度对比", - SubWorkspace: "tasks/02_market", - Mode: "execute", - Parallel: true, - Accept: []string{ - "多维度竞品对比表", - "机会与风险对称分析", - }, - }, - { - ID: "step_03_regulatory", - Role: "regulatory", - Title: "医疗器械法规与指导原则初筛", - SubWorkspace: "tasks/03_regulatory", - Mode: "execute", - Parallel: true, - Accept: []string{ - "分类依据条目与建议类别", - "明确标注需向审评窗口确认", - }, - }, - { - ID: "step_04_plan", - Role: "writer", - Title: "撰写调研报告段落计划", - SubWorkspace: "tasks/04_plan", - Mode: "plan", - Needs: []string{ - "tasks/01_search/output.md", - "tasks/02_market/output.md", - "tasks/03_regulatory/output.md", - }, - Accept: []string{"各章节论点与引用规划"}, - }, - { - ID: "step_05_review_plan", - Role: "leader", - Title: "审核调研报告撰写计划", - SubWorkspace: "tasks/05_review_plan", - Mode: "review_plan", - Needs: []string{"tasks/04_plan/output_plan.md"}, - Accept: []string{"出具审核意见 review_notes.md"}, - }, - { - ID: "step_06_draft", - Role: "writer", - Title: "按计划合成调研报告正文", - SubWorkspace: "tasks/06_draft", - Mode: "execute", - Needs: []string{ - "tasks/04_plan/output_plan.md", - "tasks/05_review_plan/review_notes.md", - }, - Accept: []string{"四段式完整正文,无省略套话"}, - }, - { - ID: "step_07_review", - Role: "leader", - Title: "终审并定稿", - SubWorkspace: "tasks/07_review", - Mode: "execute", - Needs: []string{"tasks/06_draft/output.md"}, - Accept: []string{"质量把关并通过交付"}, - }, - } - case "feasibility_reg": - return []StepSpec{ - { - ID: "step_01_regulatory", - Role: "regulatory", - Title: "分析法规分类与标准条款", - SubWorkspace: "tasks/01_regulatory", - Mode: "execute", - Accept: []string{ - "研判医疗器械管理类别与分类依据", - "结合知识库 hits.md 梳理适用的强制与推荐标准", - "给出型式检验与主要检测项目清单", - }, - }, - { - ID: "step_02_draft", - Role: "writer", - Title: "撰写注册可行性评估报告初稿", - SubWorkspace: "tasks/02_draft", - Mode: "execute", - Needs: []string{"tasks/01_regulatory/output.md"}, - Accept: []string{ - "严格按 template.md 结构展开", - "必须显式标注引用标准与条款 CiteKey", - "文末附待确认清单与风险警示", - }, - }, - { - ID: "step_03_review", - Role: "leader", - Title: "审核定稿交付", - SubWorkspace: "tasks/03_review", - Mode: "execute", - Needs: []string{"tasks/02_draft/output.md"}, - Accept: []string{ - "核验法规条款引用的准确性与完整性", - "质量门禁达标交付", - }, - }, - } - case "system_reqs": - return []StepSpec{ - { - ID: "step_01_organize", - Role: "organizer", - Title: "解析用户需求并抽取产品功能点", - SubWorkspace: "tasks/01_organize", - Mode: "execute", - Accept: []string{ - "抽取用户需求或项目事实黑板中的关键需求条目", - "提取系统功能、非功能指标及环境约束", - "向 blackboard 写入 facts 与 gaps", - }, - }, - { - ID: "step_02_engineer", - Role: "engineer", - Title: "系统技术规格与性能指标分解", - SubWorkspace: "tasks/02_engineer", - Mode: "execute", - Needs: []string{"tasks/01_organize/output.md"}, - Accept: []string{ - "定义子系统性能、电气/软件/机械指标与参数", - "规范接口协议与环境要求", - }, - }, - { - ID: "step_03_regulatory", - Role: "regulatory", - Title: "医疗器械强标比对与合规性论证", - SubWorkspace: "tasks/03_regulatory", - Mode: "execute", - Needs: []string{"tasks/01_organize/output.md"}, - Accept: []string{ - "审查适用医疗器械强制性标准及条款要求", - "输出安全、可靠性与合规性约束条目", - }, - }, - { - ID: "step_04_draft", - Role: "writer", - Title: "编制系统需求规格书全文", - SubWorkspace: "tasks/04_draft", - Mode: "execute", - Needs: []string{ - "tasks/01_organize/output.md", - "tasks/02_engineer/output.md", - "tasks/03_regulatory/output.md", - }, - Accept: []string{ - "按 template.md 结构展开,包含系统需求清单与接口要求", - "每条系统需求必须具备明确唯一的 REQ- 编号与验证方式", - "禁止敷衍套话与未替换占位符", - }, - }, - { - ID: "step_05_review", - Role: "leader", - Title: "终审需求可测性与可追溯性定稿", - SubWorkspace: "tasks/05_review", - Mode: "execute", - Needs: []string{"tasks/04_draft/output.md"}, - Accept: []string{ - "全面核查需求条目可验证性与无歧义性", - "核查追溯关系完整性并交付终稿", - }, - }, - } - case "test_manager": - return []StepSpec{ - { - ID: "step_01_trace", - Role: "organizer", - Title: "解析上游系统需求并锁定全量追溯矩阵", - SubWorkspace: "tasks/01_trace", - Mode: "execute", - Accept: []string{ - "全量读取上游系统需求规格书的所有 REQ- 编号条目", - "建立 100% 覆盖的测试需求追溯矩阵底表", - }, - }, - { - ID: "step_02_cases", - Role: "engineer", - Title: "设计系统测试用例与判定准则", - SubWorkspace: "tasks/02_cases", - Mode: "execute", - Needs: []string{"tasks/01_trace/output.md"}, - Accept: []string{ - "为每条需求设计至少一个具备明确合格判定准则的测试用例 TC-", - "导出 test_cases.json 结构化用例清单至 artifacts/", - }, - }, - { - ID: "step_03_regulatory", - Role: "regulatory", - Title: "检验规程与检测环境合规审查", - SubWorkspace: "tasks/03_regulatory", - Mode: "execute", - Needs: []string{"tasks/02_cases/output.md"}, - Accept: []string{ - "核验测试方法与引用的医疗器械检验标准条款一致性", - "审查仪器设备与测试环境要求", - }, - }, - { - ID: "step_04_draft", - Role: "writer", - Title: "编制系统验证方案及计划全文", - SubWorkspace: "tasks/04_draft", - Mode: "execute", - Needs: []string{ - "tasks/01_trace/output.md", - "tasks/02_cases/output.md", - "tasks/03_regulatory/output.md", - }, - Accept: []string{ - "严格按 tm_system_verification.md 模板生成正式方案文本", - "嵌入测试用例清单与覆盖率统计,严禁遗漏上游需求", - }, - }, - { - ID: "step_05_review", - Role: "leader", - Title: "全覆盖门禁终审与定稿交付", - SubWorkspace: "tasks/05_review", - Mode: "execute", - Needs: []string{"tasks/04_draft/output.md"}, - Accept: []string{ - "验证需求覆盖率达到 100%", - "签署验证结论并交付", - }, - }, - } - default: - return []StepSpec{ - { - ID: "step_01_draft", - Role: "writer", - Title: "撰写初稿", - SubWorkspace: "tasks/01_draft", - Mode: "execute", - Accept: []string{"按模板四段式展开"}, - }, - { - ID: "step_02_review", - Role: "leader", - Title: "审核定稿", - SubWorkspace: "tasks/02_review", - Mode: "execute", - Needs: []string{"tasks/01_draft/output.md"}, - Accept: []string{"质量门禁通过"}, - }, - } - } -} - -func writeJSON(path string, val any) error { - b, err := json.MarshalIndent(val, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, b, 0o644) -} diff --git a/internal/squad/workspace_context_build.go b/internal/squad/workspace_context_build.go new file mode 100644 index 00000000..ccc683b1 --- /dev/null +++ b/internal/squad/workspace_context_build.go @@ -0,0 +1,205 @@ +package squad + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// buildContextSystem 构建具有权威度说明的 context/ 体系 +func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepareRequest) error { + ctxDir := filepath.Join(workDir, "context") + _ = os.MkdirAll(filepath.Join(ctxDir, "materials_text"), 0o755) + _ = os.MkdirAll(filepath.Join(ctxDir, "knowledge"), 0o755) + _ = os.MkdirAll(filepath.Join(ctxDir, "prior_docs"), 0o755) + _ = os.MkdirAll(filepath.Join(ctxDir, "memory"), 0o755) + + var contextItems []ContextItem + + // 1. user.md + userContent := req.UserContext + if strings.TrimSpace(userContent) == "" { + userContent = fmt.Sprintf("# 用户角色与偏好\n\n- 用户角色: 商务与技术负责人\n- 决策风格: 严谨可追溯,注重四段式论据\n- 授权边界: 预算与商业承诺必须经由用户拍板\n") + } + _ = os.WriteFile(filepath.Join(ctxDir, "user.md"), []byte(userContent), 0o644) + contextItems = append(contextItems, ContextItem{ + File: "context/user.md", + Description: "操作用户的业务角色、决策授权边界与协同偏好", + AuthorityLevel: "authoritative", + Guidance: "必须尊重用户设定的目标市场与边界,用户未决事项一律列入待确认清单,严禁越权替用户拍板", + }) + + // 2. project.md + projContent := req.ProjectContext + if strings.TrimSpace(projContent) == "" { + projContent = fmt.Sprintf("# 项目全局基线\n\n- 项目名称: %s\n- 客户名称: %s\n- 产品类型: %s\n- 预期用途: %s\n- 目标市场: %s\n", + req.ProjectName, req.CustomerName, req.DeviceType, req.IntendedUse, req.TargetMarket) + } + _ = os.WriteFile(filepath.Join(ctxDir, "project.md"), []byte(projContent), 0o644) + contextItems = append(contextItems, ContextItem{ + File: "context/project.md", + Description: "项目立项章程与全局事实基线", + AuthorityLevel: "authoritative", + Guidance: "本项目核心前提。所有后续分析论证不得推翻已锁定的项目名称与核心预期用途", + }) + + // 3. agent.md + agentContent := req.AgentContext + if strings.TrimSpace(agentContent) == "" { + agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作;各章节须实质展开,不得以篇幅或省略写法跳过应写内容\n" + } + _ = os.WriteFile(filepath.Join(ctxDir, "agent.md"), []byte(agentContent), 0o644) + contextItems = append(contextItems, ContextItem{ + File: "context/agent.md", + Description: "智能体通用协同纪律与引用要求", + AuthorityLevel: "authoritative", + Guidance: "所有小队角色必须严格执行的专业守则与写作禁令", + }) + + // 4. materials_text/ + if len(req.Materials) > 0 { + var allBuilder strings.Builder + for _, m := range req.Materials { + name := filepath.Base(m.Filename) + if name == "" || name == "." { + name = "material.txt" + } + + // 自动提取纯文本并注入页码锚点 + textContent, err := ExtractTextWithAnchors(name, m.Content) + if err != nil || strings.TrimSpace(textContent) == "" { + textContent = string(m.Content) + } + textBytes := []byte(textContent) + + p := filepath.Join(ctxDir, "materials_text", name) + _ = os.WriteFile(p, textBytes, 0o644) + // 若原文件扩展名不是 .txt / .md,同时生成一份 .txt 方便智能体直接阅读 + if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") { + _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", name+".txt"), textBytes, 0o644) + } + + // Also write to root materials_text/ for backward compatibility + rootMatDir := filepath.Join(workDir, "materials_text") + _ = os.MkdirAll(rootMatDir, 0o755) + _ = os.WriteFile(filepath.Join(rootMatDir, name), textBytes, 0o644) + if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") { + _ = os.WriteFile(filepath.Join(rootMatDir, name+".txt"), textBytes, 0o644) + } + + allBuilder.WriteString(fmt.Sprintf("\n\n=== 文件: %s ===\n\n", name)) + allBuilder.WriteString(textContent) + + contextItems = append(contextItems, ContextItem{ + File: filepath.ToSlash(filepath.Join("context", "materials_text", name)), + Description: fmt.Sprintf("用户上传的输入材料文本: %s", name), + AuthorityLevel: "primary_evidence", + Guidance: "核心原始证据。提取参数需标明出处;材料内部矛盾需在矛盾表中并列呈现", + }) + } + _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "_all.txt"), []byte(allBuilder.String()), 0o644) + _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料清单\n\n已汇总在 `context/materials_text/_all.txt`,亦可单独查阅各文件。\n"), 0o644) + } else { + _ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料说明\n\n本项目当前未提供独立的附加文本材料,所有事实依据以 `context/project.md`(项目全局基线)、`context/user.md`(用户要求与偏好)以及 `context/knowledge/hits.md`(法规标准知识库)为准。\n"), 0o644) + } + + // 5. knowledge/ + _ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "WEB_CROSSCHECK.md"), []byte("# 法规检索与交叉核对原则\n\n1. 知识库条目(hits.md)为首要法规事实依据。\n2. 如需查询外部信息,以国家药监局官方分类目录为主。\n"), 0o644) + if len(req.KnowledgeHits) > 0 { + var kbBuilder strings.Builder + kbBuilder.WriteString("# 权威法规与技术标准引用库 (Regulatory Knowledge Hits)\n\n") + kbBuilder.WriteString("以下为针对本项目研发与注册需求命中的权威法规、国家标准(GB)与行业标准(YY)条款。\n") + kbBuilder.WriteString("在输出交付物时,请结合上下文使用,并显式标注【引用标记 (CiteKey)】标明出处。\n\n") + for _, hit := range req.KnowledgeHits { + kbBuilder.WriteString(fmt.Sprintf("### [%s] %s %s\n\n", strings.ToUpper(hit.Category), hit.DocCode, hit.Title)) + if hit.HeadingPath != "" { + kbBuilder.WriteString(fmt.Sprintf("- **章节路径**: %s\n", hit.HeadingPath)) + } + if hit.CiteKey != "" { + kbBuilder.WriteString(fmt.Sprintf("- **引用标记 (CiteKey)**: `%s`\n", hit.CiteKey)) + } + kbBuilder.WriteString(fmt.Sprintf("- **来源**: %s\n\n", hit.Source)) + kbBuilder.WriteString(hit.Content + "\n\n---\n\n") + } + _ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "hits.md"), []byte(kbBuilder.String()), 0o644) + contextItems = append(contextItems, ContextItem{ + File: "context/knowledge/hits.md", + Description: "法规知识库中命中的医疗器械分类、国家标准与指导原则", + AuthorityLevel: "reference_standard", + Guidance: "合规与技术论据源。引用必须标注标准号与引用标记 (CiteKey);涉及审批结论需标注'需向药监局审评中心确认'", + }) + } + + // 6. prior_docs/ + if len(req.PriorDocs) > 0 { + var indexBuilder strings.Builder + indexBuilder.WriteString("# 前序阶段交付物索引\n\n") + for _, doc := range req.PriorDocs { + filename := doc.Filename + if filename == "" { + filename = doc.DocKey + ".md" + } + p := filepath.Join(ctxDir, "prior_docs", filename) + _ = os.WriteFile(p, []byte(doc.Content), 0o644) + indexBuilder.WriteString(fmt.Sprintf("- [%s] `%s`: %s (阶段: %s)\n", doc.DocKey, filename, doc.Title, doc.Stage)) + + contextItems = append(contextItems, ContextItem{ + File: filepath.ToSlash(filepath.Join("context", "prior_docs", filename)), + Description: fmt.Sprintf("前序已归档文档: %s (%s)", doc.Title, doc.DocKey), + AuthorityLevel: "primary_evidence", + Guidance: "前序阶段已批准的结论基准。本阶段内容应与前序结论保持自洽", + }) + } + _ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "index.md"), []byte(indexBuilder.String()), 0o644) + } else { + _ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "README.md"), []byte("# 前序交付物说明\n\n本项目当前处于启动阶段,暂无已归档的前序阶段交付物。\n"), 0o644) + } + + if req.TemplateContent != "" { + _ = os.WriteFile(filepath.Join(ctxDir, "template.md"), []byte(req.TemplateContent), 0o644) + } + + // 7. memory/ + if len(req.MemoryBankFiles) > 0 { + for k, v := range req.MemoryBankFiles { + p := filepath.Join(ctxDir, "memory", k) + _ = os.WriteFile(p, []byte(v), 0o644) + } + } else { + // 默认四大核心资产骨架 + _ = os.WriteFile(filepath.Join(ctxDir, "memory", "projectbrief.md"), []byte(fmt.Sprintf("# 项目章程\n- 项目: %s\n- 客户: %s\n", req.ProjectName, req.CustomerName)), 0o644) + _ = os.WriteFile(filepath.Join(ctxDir, "memory", "activeContext.md"), []byte(fmt.Sprintf("# 当前焦点\n- 阶段: %s\n- 时间: %s\n", req.TaskKey, time.Now().Format("2006-01-02 15:04"))), 0o644) + _ = os.WriteFile(filepath.Join(ctxDir, "memory", "systemPatterns.md"), []byte("# 规范与模式\n- 遵循四段式结构与严格角色边界\n- 单一真实源原则\n"), 0o644) + _ = os.WriteFile(filepath.Join(ctxDir, "memory", "progress.md"), []byte(fmt.Sprintf("# 进度\n- 当前正在进行: %s\n", req.StageTitle)), 0o644) + } + contextItems = append(contextItems, ContextItem{ + File: "context/memory/", + Description: "长期项目记忆银行 (ActiveContext, Progress, ProjectBrief, SystemPatterns)", + AuthorityLevel: "supplementary", + Guidance: "协同上下文背景参考,防止上下文遗忘", + }) + + // 8. 写入 context/index.json + ctxIndex := ContextIndex{Items: contextItems} + if err := writeJSON(filepath.Join(ctxDir, "index.json"), ctxIndex); err != nil { + return err + } + + // 9. 写入 context/README.md 指引 + readmeContent := `# 上下文使用指引 (Context Reading Guide) + +本目录为当前智能体任务的自包含上下文沙箱。所有智能体专员在执行任务时,必须按照以下权威度等级(Authority Level)采纳信息: + +| 权威度级别 | 适用文件类别 | 采信与处理规则 | +|---|---|---| +| **authoritative** | ` + "`user.md`" + `, ` + "`project.md`" + `, ` + "`agent.md`" + ` | **最高权威**。必须绝对遵循;严禁越权替用户拍板,未决项列入待确认清单 | +| **primary_evidence** | ` + "`materials_text/*`" + `, ` + "`prior_docs/*`" + ` | **原始事实证据**。引用必须标明出处条文;若材料间存在矛盾,并列呈现 | +| **reference_standard** | ` + "`knowledge/hits.md`" + ` | **标准与法规依据**。必须指明法规/标准代码;涉及监管裁定标注需窗口核实 | +| **supplementary** | ` + "`memory/*`" + ` | **补充记忆背景**。仅供背景了解,不作为唯一支撑证据 | + +--- +` + return os.WriteFile(filepath.Join(ctxDir, "README.md"), []byte(readmeContent), 0o644) +} diff --git a/internal/squad/workspace_default_agents.go b/internal/squad/workspace_default_agents.go new file mode 100644 index 00000000..1acc7fc9 --- /dev/null +++ b/internal/squad/workspace_default_agents.go @@ -0,0 +1,218 @@ +package squad + +import ( + "strings" + + "chat-agent-system/internal/workflowdef" +) + +// DefaultSquadAgents 根据任务类型返回默认小队成员编制 +func DefaultSquadAgents(taskKey string) []AgentSpec { + if strings.HasPrefix(taskKey, "rd_") { + return rdevSquadAgents(taskKey, "") + } + taskKey = workflowdef.ImplementationTaskKey(taskKey) + switch taskKey { + case "intake": + return []AgentSpec{ + { + Role: "organizer", + Name: "材料整理专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"材料事实抽取", "缺口识别", "字段结构化回填"}, + ForbiddenActions: []string{"严禁测算价格", "严禁出具法规终结裁定", "严禁撰写正文终稿"}, + }, + { + Role: "writer", + Name: "文档撰写专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"}, + }, + { + Role: "leader", + Name: "商务对接负责人", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"计划审核", "正文终审", "待确认清单汇总", "对外汇报"}, + ForbiddenActions: []string{"严禁代用户做商业拍板", "严禁直接交付未审核初稿"}, + }, + } + case "research": + return []AgentSpec{ + { + Role: "searcher", + Name: "资料检索专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"公开资料检索", "法规标准核对", "出处与日期标注"}, + ForbiddenActions: []string{"严禁编造URL与标准号", "未检索到须标明尝试路径"}, + }, + { + Role: "market", + Name: "市场与竞品分析师", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"竞品同维度对比", "市场规模测算", "机会与风险分析"}, + ForbiddenActions: []string{"严禁编造市场份额与价格", "必须采用统一对比维度"}, + }, + { + Role: "regulatory", + Name: "医疗器械法规专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"器械分类初筛", "注册路径比对", "关键标准提炼"}, + ForbiddenActions: []string{"严禁代做行政终局裁定", "必须标注需向窗口确认"}, + }, + { + Role: "writer", + Name: "文档撰写专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"按计划合成调研报告", "全面四段式展开"}, + ForbiddenActions: []string{"严禁以省略写法跳过应写章节"}, + }, + { + Role: "leader", + Name: "商务对接负责人", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"审核撰写计划", "终审报告", "质量把关"}, + ForbiddenActions: []string{"严禁代用户做商业拍板"}, + }, + } + case "system_reqs": + return []AgentSpec{ + { + Role: "organizer", + Name: "需求梳理专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"用户需求抽取", "产品定位基线对齐", "UR需求池建立"}, + ForbiddenActions: []string{"严禁随意篡改原始用户需求"}, + }, + { + Role: "engineer", + Name: "系统工程架构师", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"系统需求分解", "验证方式定义", "接口定义", "requirements.json 导出"}, + ForbiddenActions: []string{"严禁遗漏验证方式", "严禁需求编号重复"}, + }, + { + Role: "regulatory", + Name: "法规合规审查专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"医疗器械强标比对", "适用性与理由论证"}, + ForbiddenActions: []string{"严禁引用虚构标准号"}, + }, + { + Role: "writer", + Name: "系统需求文档编制专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"严格按 pm_system_reqs.md 模板成文", "正文结构完整", "填满追溯表"}, + ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, + }, + { + Role: "leader", + Name: "智能产品经理主管", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"系统需求规格书终审", "追溯一致性把关", "质量门禁验收"}, + ForbiddenActions: []string{"严禁放行未覆盖验证方式的需求"}, + }, + } + case "test_manager": + return []AgentSpec{ + { + Role: "organizer", + Name: "需求追溯分析员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"上游系统需求解析", "需求全集锁定", "追溯关系初始化"}, + ForbiddenActions: []string{"严禁遗漏任一条上游需求"}, + }, + { + Role: "engineer", + Name: "系统测试工程师", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"系统验证用例设计", "判据与接受准则", "test_cases.json 导出"}, + ForbiddenActions: []string{"严禁测试方法泛化为仅写标准号", "严禁无判定准则"}, + }, + { + Role: "regulatory", + Name: "试验标准审查员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"检验方法符合性评估", "检测设备与环境要求审查"}, + ForbiddenActions: []string{"严禁使用过期作废标准"}, + }, + { + Role: "writer", + Name: "系统测试方案编制专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"严格按 tm_system_verification.md 模板成文", "用例主表翔实完整"}, + ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, + }, + { + Role: "leader", + Name: "智能测试经理主管", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"系统验证方案终审", "全覆盖无静默遗漏门禁把关", "质量签署"}, + ForbiddenActions: []string{"严禁放行覆盖率不足 100% 的方案"}, + }, + } + default: + return []AgentSpec{ + { + Role: "organizer", + Name: "材料整理专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"事实整理与基线对齐"}, + }, + { + Role: "writer", + Name: "文档撰写专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"正文撰写"}, + }, + { + Role: "leader", + Name: "商务对接负责人", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"终审与把关"}, + }, + } + } +} diff --git a/internal/squad/workspace_default_steps.go b/internal/squad/workspace_default_steps.go new file mode 100644 index 00000000..0dcc1198 --- /dev/null +++ b/internal/squad/workspace_default_steps.go @@ -0,0 +1,334 @@ +package squad + +import ( + "strings" + + "chat-agent-system/internal/workflowdef" +) + +// DefaultWorkflowSteps 根据任务类型返回最佳实践编排步骤 +func DefaultWorkflowSteps(taskKey string) []StepSpec { + if strings.HasPrefix(taskKey, "rd_") { + return rdevWorkflowSteps(taskKey, "") + } + taskKey = workflowdef.ImplementationTaskKey(taskKey) + switch taskKey { + case "intake": + return []StepSpec{ + { + ID: "step_01_organize", + Role: "organizer", + Title: "抽取材料事实并回填字段", + SubWorkspace: "tasks/01_organize", + Mode: "execute", + Accept: []string{ + "抽取材料中的客户身份与产品定义", + "回填 fields.json 结构化数据", + "向 blackboard 写入 facts 与 gaps", + }, + }, + { + ID: "step_02_draft", + Role: "writer", + Title: "撰写用户初始需求收集表初稿", + SubWorkspace: "tasks/02_draft", + Mode: "execute", + Needs: []string{"tasks/01_organize/output.md"}, + Accept: []string{ + "按 template.md 各章节严格四段式展开", + "正文翔实,各节须实质展开", + "文末集中附录字段快照与待确认清单", + }, + }, + { + ID: "step_03_review", + Role: "leader", + Title: "审核并定稿交付", + SubWorkspace: "tasks/03_review", + Mode: "execute", + Needs: []string{"tasks/02_draft/output.md"}, + Accept: []string{ + "核验全文逻辑一致性与出处标注", + "汇编待确认清单并给出下一步建议", + "交付终稿 output.md", + }, + }, + } + case "research": + return []StepSpec{ + { + ID: "step_01_search", + Role: "searcher", + Title: "公开资料与标准检索", + SubWorkspace: "tasks/01_search", + Mode: "execute", + Parallel: true, + Accept: []string{ + "检索纪要附带明确出处或未检索到说明", + "对本项目含义逐条阐述", + }, + }, + { + ID: "step_02_market", + Role: "market", + Title: "市场格局与竞品同维度对比", + SubWorkspace: "tasks/02_market", + Mode: "execute", + Parallel: true, + Accept: []string{ + "多维度竞品对比表", + "机会与风险对称分析", + }, + }, + { + ID: "step_03_regulatory", + Role: "regulatory", + Title: "医疗器械法规与指导原则初筛", + SubWorkspace: "tasks/03_regulatory", + Mode: "execute", + Parallel: true, + Accept: []string{ + "分类依据条目与建议类别", + "明确标注需向审评窗口确认", + }, + }, + { + ID: "step_04_plan", + Role: "writer", + Title: "撰写调研报告段落计划", + SubWorkspace: "tasks/04_plan", + Mode: "plan", + Needs: []string{ + "tasks/01_search/output.md", + "tasks/02_market/output.md", + "tasks/03_regulatory/output.md", + }, + Accept: []string{"各章节论点与引用规划"}, + }, + { + ID: "step_05_review_plan", + Role: "leader", + Title: "审核调研报告撰写计划", + SubWorkspace: "tasks/05_review_plan", + Mode: "review_plan", + Needs: []string{"tasks/04_plan/output_plan.md"}, + Accept: []string{"出具审核意见 review_notes.md"}, + }, + { + ID: "step_06_draft", + Role: "writer", + Title: "按计划合成调研报告正文", + SubWorkspace: "tasks/06_draft", + Mode: "execute", + Needs: []string{ + "tasks/04_plan/output_plan.md", + "tasks/05_review_plan/review_notes.md", + }, + Accept: []string{"四段式完整正文,无省略套话"}, + }, + { + ID: "step_07_review", + Role: "leader", + Title: "终审并定稿", + SubWorkspace: "tasks/07_review", + Mode: "execute", + Needs: []string{"tasks/06_draft/output.md"}, + Accept: []string{"质量把关并通过交付"}, + }, + } + case "feasibility_reg": + return []StepSpec{ + { + ID: "step_01_regulatory", + Role: "regulatory", + Title: "分析法规分类与标准条款", + SubWorkspace: "tasks/01_regulatory", + Mode: "execute", + Accept: []string{ + "研判医疗器械管理类别与分类依据", + "结合知识库 hits.md 梳理适用的强制与推荐标准", + "给出型式检验与主要检测项目清单", + }, + }, + { + ID: "step_02_draft", + Role: "writer", + Title: "撰写注册可行性评估报告初稿", + SubWorkspace: "tasks/02_draft", + Mode: "execute", + Needs: []string{"tasks/01_regulatory/output.md"}, + Accept: []string{ + "严格按 template.md 结构展开", + "必须显式标注引用标准与条款 CiteKey", + "文末附待确认清单与风险警示", + }, + }, + { + ID: "step_03_review", + Role: "leader", + Title: "审核定稿交付", + SubWorkspace: "tasks/03_review", + Mode: "execute", + Needs: []string{"tasks/02_draft/output.md"}, + Accept: []string{ + "核验法规条款引用的准确性与完整性", + "质量门禁达标交付", + }, + }, + } + case "system_reqs": + return []StepSpec{ + { + ID: "step_01_organize", + Role: "organizer", + Title: "解析用户需求并抽取产品功能点", + SubWorkspace: "tasks/01_organize", + Mode: "execute", + Accept: []string{ + "抽取用户需求或项目事实黑板中的关键需求条目", + "提取系统功能、非功能指标及环境约束", + "向 blackboard 写入 facts 与 gaps", + }, + }, + { + ID: "step_02_engineer", + Role: "engineer", + Title: "系统技术规格与性能指标分解", + SubWorkspace: "tasks/02_engineer", + Mode: "execute", + Needs: []string{"tasks/01_organize/output.md"}, + Accept: []string{ + "定义子系统性能、电气/软件/机械指标与参数", + "规范接口协议与环境要求", + }, + }, + { + ID: "step_03_regulatory", + Role: "regulatory", + Title: "医疗器械强标比对与合规性论证", + SubWorkspace: "tasks/03_regulatory", + Mode: "execute", + Needs: []string{"tasks/01_organize/output.md"}, + Accept: []string{ + "审查适用医疗器械强制性标准及条款要求", + "输出安全、可靠性与合规性约束条目", + }, + }, + { + ID: "step_04_draft", + Role: "writer", + Title: "编制系统需求规格书全文", + SubWorkspace: "tasks/04_draft", + Mode: "execute", + Needs: []string{ + "tasks/01_organize/output.md", + "tasks/02_engineer/output.md", + "tasks/03_regulatory/output.md", + }, + Accept: []string{ + "按 template.md 结构展开,包含系统需求清单与接口要求", + "每条系统需求必须具备明确唯一的 REQ- 编号与验证方式", + "禁止敷衍套话与未替换占位符", + }, + }, + { + ID: "step_05_review", + Role: "leader", + Title: "终审需求可测性与可追溯性定稿", + SubWorkspace: "tasks/05_review", + Mode: "execute", + Needs: []string{"tasks/04_draft/output.md"}, + Accept: []string{ + "全面核查需求条目可验证性与无歧义性", + "核查追溯关系完整性并交付终稿", + }, + }, + } + case "test_manager": + return []StepSpec{ + { + ID: "step_01_trace", + Role: "organizer", + Title: "解析上游系统需求并锁定全量追溯矩阵", + SubWorkspace: "tasks/01_trace", + Mode: "execute", + Accept: []string{ + "全量读取上游系统需求规格书的所有 REQ- 编号条目", + "建立 100% 覆盖的测试需求追溯矩阵底表", + }, + }, + { + ID: "step_02_cases", + Role: "engineer", + Title: "设计系统测试用例与判定准则", + SubWorkspace: "tasks/02_cases", + Mode: "execute", + Needs: []string{"tasks/01_trace/output.md"}, + Accept: []string{ + "为每条需求设计至少一个具备明确合格判定准则的测试用例 TC-", + "导出 test_cases.json 结构化用例清单至 artifacts/", + }, + }, + { + ID: "step_03_regulatory", + Role: "regulatory", + Title: "检验规程与检测环境合规审查", + SubWorkspace: "tasks/03_regulatory", + Mode: "execute", + Needs: []string{"tasks/02_cases/output.md"}, + Accept: []string{ + "核验测试方法与引用的医疗器械检验标准条款一致性", + "审查仪器设备与测试环境要求", + }, + }, + { + ID: "step_04_draft", + Role: "writer", + Title: "编制系统验证方案及计划全文", + SubWorkspace: "tasks/04_draft", + Mode: "execute", + Needs: []string{ + "tasks/01_trace/output.md", + "tasks/02_cases/output.md", + "tasks/03_regulatory/output.md", + }, + Accept: []string{ + "严格按 tm_system_verification.md 模板生成正式方案文本", + "嵌入测试用例清单与覆盖率统计,严禁遗漏上游需求", + }, + }, + { + ID: "step_05_review", + Role: "leader", + Title: "全覆盖门禁终审与定稿交付", + SubWorkspace: "tasks/05_review", + Mode: "execute", + Needs: []string{"tasks/04_draft/output.md"}, + Accept: []string{ + "验证需求覆盖率达到 100%", + "签署验证结论并交付", + }, + }, + } + default: + return []StepSpec{ + { + ID: "step_01_draft", + Role: "writer", + Title: "撰写初稿", + SubWorkspace: "tasks/01_draft", + Mode: "execute", + Accept: []string{"按模板四段式展开"}, + }, + { + ID: "step_02_review", + Role: "leader", + Title: "审核定稿", + SubWorkspace: "tasks/02_review", + Mode: "execute", + Needs: []string{"tasks/01_draft/output.md"}, + Accept: []string{"质量门禁通过"}, + }, + } + } +} diff --git a/internal/squad/workspace_io.go b/internal/squad/workspace_io.go new file mode 100644 index 00000000..a8a9f632 --- /dev/null +++ b/internal/squad/workspace_io.go @@ -0,0 +1,14 @@ +package squad + +import ( + "encoding/json" + "os" +) + +func writeJSON(path string, val any) error { + b, err := json.MarshalIndent(val, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} diff --git a/internal/squad/workspace_sub_build.go b/internal/squad/workspace_sub_build.go new file mode 100644 index 00000000..94fab7ce --- /dev/null +++ b/internal/squad/workspace_sub_build.go @@ -0,0 +1,158 @@ +package squad + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/template" +) + +// buildSubWorkspaces 为 workflow 中的各个子任务构建独立沙箱 +func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, squadCfg SquadConfig, renderedTmpl string) error { + agentMap := make(map[string]AgentSpec) + for _, a := range squadCfg.Agents { + agentMap[a.Role] = a + } + + for _, step := range wf.Steps { + subDir := filepath.Join(workDir, filepath.FromSlash(step.SubWorkspace)) + if err := os.MkdirAll(subDir, 0o755); err != nil { + return err + } + + // 1. 生成 AGENTS.md:优先 AgentSpec.InstructionsFile,再按角色名回落,再 inline 渲染 + var agentsBuilder strings.Builder + spec := agentMap[step.Role] + var inst string + var err error + if file := strings.TrimSpace(spec.InstructionsFile); file != "" { + inst, err = template.GetAgentInstructionFile(file) + } + if err != nil || strings.TrimSpace(inst) == "" { + inst, err = template.GetAgentInstruction(step.Role) + } + if err == nil && strings.TrimSpace(inst) != "" { + agentsBuilder.WriteString(inst) + } else { + agentsBuilder.WriteString(fmt.Sprintf("# 角色: %s (%s)\n\n", spec.Name, spec.Role)) + if len(spec.Responsibilities) > 0 { + agentsBuilder.WriteString("## 核心职责\n") + for _, r := range spec.Responsibilities { + agentsBuilder.WriteString(fmt.Sprintf("- %s\n", r)) + } + agentsBuilder.WriteString("\n") + } + if len(spec.ForbiddenActions) > 0 { + agentsBuilder.WriteString("## 严禁行为(红线)\n") + for _, f := range spec.ForbiddenActions { + agentsBuilder.WriteString(fmt.Sprintf("- ❌ %s\n", f)) + } + agentsBuilder.WriteString("\n") + } + } + _ = os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(agentsBuilder.String()), 0o644) + _ = os.WriteFile(filepath.Join(subDir, "INSTRUCTIONS.md"), []byte(agentsBuilder.String()), 0o644) + + // 2. 生成 TASK.md + deliverableName := "output.md" + if step.Mode == "plan" || step.Mode == "replan" { + deliverableName = "output_plan.md" + } else if step.Mode == "review_plan" { + deliverableName = "review_notes.md" + } + + var taskBuilder strings.Builder + if step.InputLayout == InputLayoutSnapshot { + if err := SnapshotInputsIntoSubdir(workDir, subDir, step.Needs, deliverableName); err != nil { + return err + } + taskText := BuildContainedTaskMarkdown(step, deliverableName) + if TaskMarkdownEscapesParent(taskText) { + return fmt.Errorf("snapshot TASK.md must not reference parent paths") + } + if err := writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(taskText)); err != nil { + return err + } + inputRefs := map[string]any{ + "step_id": step.ID, + "role": step.Role, + "needs": step.Needs, + "target_file": deliverableName, + "input_layout": InputLayoutSnapshot, + "forbid_parent_paths": true, + } + _ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs) + continue + } + + taskBuilder.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title)) + taskBuilder.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID)) + taskBuilder.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role)) + taskBuilder.WriteString(fmt.Sprintf("- **执行模式**: `%s`\n", step.Mode)) + taskBuilder.WriteString(fmt.Sprintf("- **交付产物**: `%s`\n\n", deliverableName)) + + taskBuilder.WriteString("## 环境与输入导航\n") + taskBuilder.WriteString("请仔细查阅上级目录资源:\n") + taskBuilder.WriteString("- `../../context/index.json` 及 `../../context/README.md`:阅读上下文指引\n") + taskBuilder.WriteString("- `../../template.md`:自描述交付模板(嵌入四段式与格式约束)\n") + taskBuilder.WriteString("- `../../blackboard.json`:前序专员抽取的结构化事实与缺口\n") + taskBuilder.WriteString("- `../../context/project.md`:项目全局立项基线与产品定义\n") + taskBuilder.WriteString("- `../../context/user.md`:用户个性化工作偏好与决策约束\n") + taskBuilder.WriteString("- `../../context/materials_text/README.md`:原始材料全文或基线说明\n") + taskBuilder.WriteString("- `../../context/knowledge/hits.md`:法规与标准命中快照 (若存在)\n\n") + + if len(step.Needs) > 0 { + taskBuilder.WriteString("## 前序依赖产出\n") + for _, n := range step.Needs { + taskBuilder.WriteString(fmt.Sprintf("- `../../%s`\n", filepath.ToSlash(n))) + } + taskBuilder.WriteString("\n") + } + + if len(step.Accept) > 0 { + taskBuilder.WriteString("## 验收标准(Checklist)\n") + for _, a := range step.Accept { + taskBuilder.WriteString(fmt.Sprintf("- [ ] %s\n", a)) + } + taskBuilder.WriteString("\n") + } + + if strings.HasPrefix(wf.TaskKey, "rd_") { + overlay := RDevStageOverlay(wf.TaskKey, step.Role, rdevTierFromWorkspace(workDir)) + if overlay != "" { + _ = os.WriteFile(filepath.Join(subDir, "OVERLAY.md"), []byte(overlay+"\n"), 0o644) + taskBuilder.WriteString("## 阶段纪律(overlay)\n") + taskBuilder.WriteString("写作纪律与粒度下限见同目录 `OVERLAY.md`。模板只规定章节结构,不得用 overlay 改写章节骨架。\n\n") + } + } + + taskBuilder.WriteString("## 交付契约与写作纪律\n") + taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName)) + taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n") + taskBuilder.WriteString("2. 各章节必须实质展开;禁止以篇幅借口或省略写法跳过应写细节(自检时勿复述禁用原词)。\n") + taskBuilder.WriteString("3. 关键事实必须标注出处,未知项列入待确认清单。\n") + if step.Role == "organizer" { + taskBuilder.WriteString("4. 从材料提取的客观字段,请在当前工作目录下输出 `fields.json`。\n") + } + + _ = os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(taskBuilder.String()), 0o644) + + // 3. 写入 input_refs.json + inputRefs := map[string]any{ + "step_id": step.ID, + "role": step.Role, + "needs": step.Needs, + "target_file": deliverableName, + } + _ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs) + + // 4. 创建便捷软链接,使得当前目录下无论是读 template.md、context/ 还是 ../../ 均可访问 + _ = os.Symlink(filepath.Join("..", "..", "template.md"), filepath.Join(subDir, "template.md")) + _ = os.Symlink(filepath.Join("..", "..", "context"), filepath.Join(subDir, "context")) + _ = os.Symlink(filepath.Join("..", "..", "blackboard.json"), filepath.Join(subDir, "blackboard.json")) + } + + return nil +}