diff --git a/internal/bizdev/engine.go b/internal/bizdev/engine.go index 3ed34f19..cdbf63c8 100644 --- a/internal/bizdev/engine.go +++ b/internal/bizdev/engine.go @@ -1,108 +1,34 @@ package bizdev import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "chat-agent-system/internal/quote" + "chat-agent-system/internal/squad" ) // FormatMoney formats a currency amount for display in markdown (no grouping separators). func FormatMoney(v float64) string { - if v == 0 { - return "0" - } - if v == float64(int64(v)) { - return strconv.FormatInt(int64(v), 10) - } - return strconv.FormatFloat(v, 'f', 2, 64) + return squad.FormatMoney(v) } // RenderCDOQuoteTable builds the pricing table from quote summary lines and total. func RenderCDOQuoteTable(summary *quote.Summary) string { - var b strings.Builder - b.WriteString("| 报价代码 | 服务内容 | 人数 | 人年 | 成本 | 加Buffer20% | 加毛利40% | 加税7% | 设备设施等费用 | 税后价格 |\n") - b.WriteString("|---|---|---:|---:|---:|---:|---:|---:|---:|---:|\n") - - var totalPersonYear, totalCost, totalBuffer, totalMargin, totalTax, totalPrice float64 - for _, line := range summary.Lines { - fmt.Fprintf(&b, "| %s | %s | — | %s | %s | %s | %s | %s | — | %s |\n", - line.Code, line.Name, - FormatMoney(line.PersonYear), - FormatMoney(line.Cost), - FormatMoney(line.Buffer), - FormatMoney(line.Margin), - FormatMoney(line.Tax), - FormatMoney(line.Price), - ) - totalPersonYear += line.PersonYear - totalCost += line.Cost - totalBuffer += line.Buffer - totalMargin += line.Margin - totalTax += line.Tax - totalPrice += line.Price - } - - fmt.Fprintf(&b, "| | **总计** | — | **%s** | **%s** | **%s** | **%s** | **%s** | — | **%s** |\n", - FormatMoney(totalPersonYear), - FormatMoney(totalCost), - FormatMoney(totalBuffer), - FormatMoney(totalMargin), - FormatMoney(totalTax), - FormatMoney(summary.TotalPrice), - ) - return b.String() + return squad.RenderCDOQuoteTable(summary) } // BackfillCDOQuoteMarkdown replaces placeholder sections with computed pricing from summary. func BackfillCDOQuoteMarkdown(body string, summary *quote.Summary) string { - conclusion := fmt.Sprintf( - "按公司 CDO 定价核算模板计算,税后合计 **%s 元**(含 Buffer 20%%、毛利 40%%、税 7%%)。数字与配套 Excel「报价模型」一致。", - FormatMoney(summary.TotalPrice), - ) - body = replaceSection(body, "## 结论", conclusion) - - tableIntro := "表中「人年」列与 Excel 模板同一列,对应模型投入人月合计(工时/160)。\n\n" - body = replaceSection(body, "## 报价模型", tableIntro+RenderCDOQuoteTable(summary)) - - return body -} - -func replaceSection(body, heading, content string) string { - start := strings.Index(body, heading) - if start == -1 { - return body - } - rest := body[start+len(heading):] - nextHeading := strings.Index(rest, "\n## ") - if nextHeading == -1 { - return body[:start] + heading + "\n\n" + content + "\n" - } - end := start + len(heading) + nextHeading - return body[:start] + heading + "\n\n" + content + "\n" + body[end:] + return squad.BackfillCDOQuoteMarkdown(body, summary) } // WriteMaterials persists uploaded material text into workspace materials_text/. func WriteMaterials(workDir string, materials []MaterialInput) error { - if len(materials) == 0 { - return nil - } - dir := filepath.Join(workDir, "materials_text") - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create materials_text: %w", err) - } + var items []squad.MaterialItem for _, m := range materials { - name := filepath.Base(m.Filename) - if name == "" || name == "." { - name = "material.txt" - } - path := filepath.Join(dir, name) - if err := os.WriteFile(path, m.Content, 0o644); err != nil { - return fmt.Errorf("write material %s: %w", name, err) - } + items = append(items, squad.MaterialItem{ + Filename: m.Filename, + Content: m.Content, + }) } - return nil + cm := squad.NewContextManager() + return cm.InjectMaterials(workDir, items) } diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index bacbdd04..47a11f54 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -2,174 +2,114 @@ package bizdev import ( "context" - "fmt" - "os" - "path/filepath" - "time" - "chat-agent-system/internal/agent" "chat-agent-system/internal/config" "chat-agent-system/internal/kb" - "chat-agent-system/internal/quote" - "chat-agent-system/internal/template" + "chat-agent-system/internal/squad" ) type Service struct { - cfg *config.Config - kb *kb.Client + cfg *config.Config + kb *kb.Client + squad *squad.Service } func NewService(cfg *config.Config) *Service { return &Service{ - cfg: cfg, - kb: kb.NewClient(cfg), + cfg: cfg, + kb: kb.NewClient(cfg), + squad: squad.NewService(cfg), } } -var SupportedTasks = []TaskMeta{ - {Key: "intake", Title: "用户初始需求收集", ArtifactTitle: "用户初始需求收集表"}, - {Key: "research", Title: "调研", ArtifactTitle: "调研报告"}, - {Key: "feasibility_dev", Title: "设计开发可行性评估", ArtifactTitle: "设计开发可行性评估报告"}, - {Key: "feasibility_reg", Title: "注册路径可行性评估", ArtifactTitle: "注册路径可行性评估报告"}, - {Key: "user_requirements", Title: "用户需求表", ArtifactTitle: "用户需求表"}, - {Key: "prelim_design", Title: "系统初步设计方案", ArtifactTitle: "系统初步设计方案"}, - {Key: "cdo_quote", Title: "CDO报价单", ArtifactTitle: "CDO报价单(含定价核算 Excel)"}, -} +var SupportedTasks = squad.SupportedTasks func (s *Service) ListTasks() []TaskMeta { - return SupportedTasks -} - -func taskArtifactTitle(taskKey string) string { - for _, t := range SupportedTasks { - if t.Key == taskKey { - return t.ArtifactTitle - } - } - return taskKey + return s.squad.ListTasks() } func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) { - start := time.Now() - - workDir := req.WorkDir - if workDir == "" { - ts := time.Now().UnixNano() - workDir = filepath.Join(s.cfg.DataDir, "workspaces", "bizdev", fmt.Sprintf("%s_%d", req.TaskKey, ts)) - } - if err := os.MkdirAll(workDir, 0o755); err != nil { - return nil, fmt.Errorf("create workdir: %w", err) - } - if err := WriteMaterials(workDir, req.Materials); err != nil { - return nil, err - } - - tmpl, err := template.GetDocTemplate(req.TaskKey) - if err != nil { - return nil, err - } - - snapshotTs := time.Now().UnixNano() - vars := map[string]string{ - "project_name": req.ProjectName, - "product_name": req.ProjectName, - "customer_name": req.CustomerName, - "device_type": req.DeviceType, - "intended_use": req.IntendedUse, - "target_market": req.TargetMarket, - "generated_at": time.Now().Format("2006-01-02 15:04:05"), - "snapshot_id": fmt.Sprintf("snap_%s_%d", req.TaskKey, snapshotTs), - } - for k, v := range req.Fields { - vars[k] = v - } - renderedTmpl := template.RenderDoc(tmpl, vars) - _ = os.WriteFile(filepath.Join(workDir, "template.md"), []byte(renderedTmpl), 0o644) - - inst, _ := template.GetAgentInstruction("writer") - _ = os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(inst), 0o644) - // Some backends look for a different instructions filename; write both - // unconditionally so persona/role guidance reaches whichever bridge runs. - _ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(inst), 0o644) - taskPrompt := fmt.Sprintf("# 任务执行: %s\n\n请按 template.md 的指导撰写正文并交付到 output.md。\n", req.TaskKey) - _ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(taskPrompt), 0o644) - - backendName := req.Backend - if backendName == "" { - backendName = s.cfg.AgentBackend - } - backend := agent.NewBackend(backendName, s.cfg) - - timeout := req.Timeout - if timeout == 0 { - timeout = s.cfg.AgentTimeout - } - - backendRes, err := backend.Execute(ctx, agent.Request{ - WorkDir: workDir, - Prompt: taskPrompt, - Instructions: inst, - Targets: []string{"output.md"}, - Timeout: timeout, - }) - if err != nil { - return nil, fmt.Errorf("backend execute: %w", err) - } - - res := &TaskRunResult{ - Success: true, - } - if backendRes != nil && !backendRes.Success { - res.Success = false - res.ErrorReason = backendRes.ErrorReason - if backendName != "stub" { - return nil, fmt.Errorf("backend execution failed: %s", backendRes.ErrorReason) - } - } - - markdownBody := renderedTmpl - if backendRes != nil && len(backendRes.Patches["output.md"]) > 0 && backendName != "stub" { - markdownBody = string(backendRes.Patches["output.md"]) - } - - var attachments []Attachment - if req.TaskKey == "cdo_quote" { - xlsxPath := filepath.Join(workDir, "CDO报价核算-V01.xlsx") - in := quote.Input{ - CustomerName: req.CustomerName, - ProductName: req.ProjectName, - Hours: map[string]float64{ - "结构工程师": 320, - "硬件工程师": 320, - "软件工程师": 480, - "项目经理": 160, - }, - } - summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", xlsxPath) - if err != nil { - return nil, fmt.Errorf("cdo quote: %w", err) - } - markdownBody = BackfillCDOQuoteMarkdown(markdownBody, summary) - _ = os.WriteFile(filepath.Join(workDir, "cdo_quote.md"), []byte(markdownBody), 0o644) - - data, err := os.ReadFile(xlsxPath) - if err != nil { - return nil, fmt.Errorf("read cdo quote xlsx: %w", err) - } - attachments = append(attachments, Attachment{ - Filename: "CDO报价核算-V01.xlsx", - ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - Data: data, + // 转换 MaterialInput 到 squad.MaterialItem + var materials []squad.MaterialItem + for _, m := range req.Materials { + materials = append(materials, squad.MaterialItem{ + Filename: m.Filename, + Content: m.Content, }) } - res.TaskKey = req.TaskKey - res.ArtifactTitle = taskArtifactTitle(req.TaskKey) - res.MarkdownBody = markdownBody - res.Attachments = attachments - res.WorkDir = workDir - res.BackendUsed = backendName - res.Duration = time.Since(start) + // 自动补充知识库检索 (如针对 research 与 feasibility_reg) + hits := req.KnowledgeHits + if len(hits) == 0 && (req.TaskKey == "research" || req.TaskKey == "feasibility_reg") { + if kbHits, err := s.kb.Search(ctx, req.ProjectName+" "+req.DeviceType, 5); err == nil && len(kbHits) > 0 { + for _, kh := range kbHits { + hits = append(hits, squad.KnowledgeHit{ + DocCode: kh.DocCode, + Category: kh.Category, + Title: kh.Title, + Content: kh.Content, + Source: kh.Source, + }) + } + } + } - return res, nil + squadReq := squad.SquadRunRequest{ + TaskKey: req.TaskKey, + ProjectName: req.ProjectName, + CustomerName: req.CustomerName, + DeviceType: req.DeviceType, + IntendedUse: req.IntendedUse, + TargetMarket: req.TargetMarket, + Fields: req.Fields, + Materials: materials, + Backend: req.Backend, + Timeout: req.Timeout, + WorkDir: req.WorkDir, + CustomSteps: req.CustomSteps, + AutoRepair: req.AutoRepair, + KnowledgeHits: hits, + PriorDocs: req.PriorDocs, + } + + 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, + }, err + } + return nil, err + } + + 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, + }, nil +} + +// InspectWorkspace 查看指定工作区的当前执行状态、黑板数据与各专员产出 +func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*squad.WorkspaceState, error) { + return s.squad.InspectWorkspace(ctx, workDir) +} + +// ResumeOrModifyWorkspace 动态调整任务并继续执行 +func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) { + return s.squad.ResumeOrModifyWorkspace(ctx, req) } diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go index aec6f4ab..8bf22f84 100644 --- a/internal/bizdev/types.go +++ b/internal/bizdev/types.go @@ -3,54 +3,55 @@ package bizdev import ( "context" "time" + + "chat-agent-system/internal/squad" ) -type TaskMeta struct { - Key string `json:"key"` - Title string `json:"title"` - ArtifactTitle string `json:"artifact_title"` - Description string `json:"description"` -} +type TaskMeta = squad.TaskMeta type MaterialInput struct { Filename string `json:"filename"` Content []byte `json:"content"` } -type TaskRunRequest struct { - TaskKey string `json:"task_key"` - ProjectName string `json:"project_name"` - CustomerName string `json:"customer_name"` - DeviceType string `json:"device_type"` - IntendedUse string `json:"intended_use"` - TargetMarket string `json:"target_market"` - Fields map[string]string `json:"fields"` - Materials []MaterialInput `json:"materials"` - Backend string `json:"backend"` - Timeout time.Duration `json:"timeout"` - WorkDir string `json:"work_dir"` -} +type Attachment = squad.Attachment -type Attachment struct { - Filename string `json:"filename"` - ContentType string `json:"content_type"` - Data []byte `json:"data"` +type TaskRunRequest struct { + TaskKey string `json:"task_key"` + ProjectName string `json:"project_name"` + CustomerName string `json:"customer_name"` + DeviceType string `json:"device_type"` + IntendedUse string `json:"intended_use"` + TargetMarket string `json:"target_market"` + Fields map[string]string `json:"fields"` + Materials []MaterialInput `json:"materials"` + Backend string `json:"backend"` + Timeout time.Duration `json:"timeout"` + WorkDir string `json:"work_dir"` + CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"` + AutoRepair bool `json:"auto_repair,omitempty"` + KnowledgeHits []squad.KnowledgeHit `json:"knowledge_hits,omitempty"` + PriorDocs []squad.PriorDocItem `json:"prior_docs,omitempty"` } type TaskRunResult struct { - TaskKey string `json:"task_key"` - ArtifactTitle string `json:"artifact_title"` - MarkdownBody string `json:"markdown_body"` - ExtractedData map[string]string `json:"extracted_data"` - Attachments []Attachment `json:"attachments"` - WorkDir string `json:"work_dir"` - BackendUsed string `json:"backend_used"` - Duration time.Duration `json:"duration"` - Success bool `json:"success"` - ErrorReason string `json:"error_reason"` + TaskKey string `json:"task_key"` + ArtifactTitle string `json:"artifact_title"` + MarkdownBody string `json:"markdown_body"` + ExtractedData map[string]string `json:"extracted_data"` + Attachments []Attachment `json:"attachments"` + WorkDir string `json:"work_dir"` + BackendUsed string `json:"backend_used"` + Duration time.Duration `json:"duration"` + Success bool `json:"success"` + ErrorReason string `json:"error_reason"` + QualityReport *squad.QualityReport `json:"quality_report,omitempty"` + Manifest *squad.Manifest `json:"manifest,omitempty"` } type BizdevService interface { ListTasks() []TaskMeta ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) + InspectWorkspace(ctx context.Context, workDir string) (*squad.WorkspaceState, error) + ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) } diff --git a/internal/squad/service.go b/internal/squad/service.go new file mode 100644 index 00000000..851c79d8 --- /dev/null +++ b/internal/squad/service.go @@ -0,0 +1,420 @@ +package squad + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "chat-agent-system/internal/agent" + "chat-agent-system/internal/config" + "chat-agent-system/internal/quote" +) + +// TaskMeta 任务元信息 +type TaskMeta struct { + Key string `json:"key"` + Title string `json:"title"` + ArtifactTitle string `json:"artifact_title"` + Description string `json:"description"` +} + +// Attachment 附加产物文件 (如 Excel) +type Attachment struct { + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Data []byte `json:"data"` +} + +// SquadRunRequest 运行请求 +type SquadRunRequest struct { + TaskKey string `json:"task_key"` + ProjectName string `json:"project_name"` + CustomerName string `json:"customer_name"` + DeviceType string `json:"device_type"` + IntendedUse string `json:"intended_use"` + TargetMarket string `json:"target_market"` + Fields map[string]string `json:"fields"` + Materials []MaterialItem `json:"materials"` + Backend string `json:"backend"` + Timeout time.Duration `json:"timeout"` + WorkDir string `json:"work_dir"` + CustomSteps []StepSpec `json:"custom_steps"` + AutoRepair bool `json:"auto_repair"` + KnowledgeHits []KnowledgeHit `json:"knowledge_hits"` + PriorDocs []PriorDocItem `json:"prior_docs"` + StepNotify func(StepEvent) `json:"-"` +} + +// SquadRunResult 运行结果 +type SquadRunResult struct { + TaskKey string `json:"task_key"` + ArtifactTitle string `json:"artifact_title"` + MarkdownBody string `json:"markdown_body"` + ExtractedData map[string]string `json:"extracted_data"` + Attachments []Attachment `json:"attachments"` + WorkDir string `json:"work_dir"` + BackendUsed string `json:"backend_used"` + Duration time.Duration `json:"duration"` + Success bool `json:"success"` + ErrorReason string `json:"error_reason,omitempty"` + QualityReport *QualityReport `json:"quality_report,omitempty"` + Manifest *Manifest `json:"manifest,omitempty"` +} + +// WorkspaceState 用于观察工作区当前完整状态 +type WorkspaceState struct { + WorkDir string `json:"work_dir"` + Metadata WorkspaceMetadata `json:"metadata"` + Squad SquadConfig `json:"squad"` + Workflow WorkflowPlan `json:"workflow"` + Blackboard BlackboardState `json:"blackboard"` + DeliverableContent string `json:"deliverable_content"` + Manifest *Manifest `json:"manifest,omitempty"` +} + +// ModifyWorkflowRequest 动态调整现有工作区任务 +type ModifyWorkflowRequest struct { + WorkDir string `json:"work_dir"` + AddSteps []StepSpec `json:"add_steps,omitempty"` + RemoveStepIDs []string `json:"remove_step_ids,omitempty"` + UpdateSteps []StepSpec `json:"update_steps,omitempty"` + Backend string `json:"backend,omitempty"` + Timeout time.Duration `json:"timeout,omitempty"` + AutoRepair bool `json:"auto_repair,omitempty"` + StepNotify func(StepEvent) `json:"-"` +} + +// SquadService 小队领域服务核心接口 +type SquadService interface { + ListTasks() []TaskMeta + PlanWorkflow(ctx context.Context, req SquadRunRequest) (*WorkflowPlan, error) + ExecuteWorkflow(ctx context.Context, wsDir string, backend string, opts ExecuteOptions) (*SquadRunResult, error) + ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadRunResult, error) + InspectWorkspace(ctx context.Context, workDir string) (*WorkspaceState, error) + ResumeOrModifyWorkspace(ctx context.Context, req ModifyWorkflowRequest) (*SquadRunResult, error) +} + +// Service SquadService 实现 +type Service struct { + cfg *config.Config + engine *WorkspaceEngine + driver *WorkflowDriver + gate *QualityGate +} + +func NewService(cfg *config.Config) *Service { + engine := NewWorkspaceEngine() + return &Service{ + cfg: cfg, + engine: engine, + driver: NewWorkflowDriver(engine), + gate: NewQualityGate(), + } +} + +var SupportedTasks = []TaskMeta{ + {Key: "intake", Title: "用户初始需求收集", ArtifactTitle: "用户初始需求收集表", Description: "梳理客户原始需求、提炼事实清单与缺口"}, + {Key: "research", Title: "调研", ArtifactTitle: "调研报告", Description: "市场竞品同维度对比、法规要点与技术方案初筛"}, + {Key: "feasibility_dev", Title: "设计开发可行性评估", ArtifactTitle: "设计开发可行性评估报告", Description: "工程化差距、技术路线与系统架构可行性推导"}, + {Key: "feasibility_reg", Title: "注册路径可行性评估", ArtifactTitle: "注册路径可行性评估报告", Description: "分类建议、临床评价策略与主要标准检测规划"}, + {Key: "user_requirements", Title: "用户需求表", ArtifactTitle: "用户需求表", Description: "用户角色画像与可追溯需求条目清单"}, + {Key: "prelim_design", Title: "系统初步设计方案", ArtifactTitle: "系统初步设计方案", Description: "子系统架构边界、数据接口与Mermaid上下文流向"}, + {Key: "cdo_quote", Title: "CDO报价单", ArtifactTitle: "CDO报价单(含定价核算 Excel)", Description: "基于公司定价模型的工时拆解、成本核算与法定说明"}, +} + +func (s *Service) ListTasks() []TaskMeta { + return SupportedTasks +} + +func TaskArtifactTitle(taskKey string) string { + for _, t := range SupportedTasks { + if t.Key == taskKey { + return t.ArtifactTitle + } + } + return taskKey +} + +// PlanWorkflow 动态规划生成任务工作流 +func (s *Service) PlanWorkflow(ctx context.Context, req SquadRunRequest) (*WorkflowPlan, error) { + steps := req.CustomSteps + if len(steps) == 0 { + steps = DefaultWorkflowSteps(req.TaskKey) + } + now := time.Now() + return &WorkflowPlan{ + WorkflowID: fmt.Sprintf("wf_%s_%d", req.TaskKey, now.UnixNano()), + Status: "pending", + CurrentStepIndex: 0, + Steps: steps, + }, nil +} + +// ExecuteTask 便捷门面:一步构建工作区、调度动态工作流、进行CDO核算与终审质量门禁 +func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadRunResult, error) { + start := time.Now() + + workDir := req.WorkDir + if workDir == "" { + ts := time.Now().UnixNano() + workDir = filepath.Join(s.cfg.DataDir, "workspaces", "bizdev", fmt.Sprintf("%s_%d", req.TaskKey, ts)) + } + + backendName := req.Backend + if backendName == "" { + backendName = s.cfg.AgentBackend + } + + // 1. 初始化工作区目录体系 + wsReq := WorkspacePrepareRequest{ + WorkDir: workDir, + TaskKey: req.TaskKey, + StageTitle: TaskArtifactTitle(req.TaskKey), + ProjectName: req.ProjectName, + CustomerName: req.CustomerName, + DeviceType: req.DeviceType, + IntendedUse: req.IntendedUse, + TargetMarket: req.TargetMarket, + Backend: backendName, + Materials: req.Materials, + KnowledgeHits: req.KnowledgeHits, + PriorDocs: req.PriorDocs, + WorkflowSteps: req.CustomSteps, + InitialFields: req.Fields, + } + + _, err := s.engine.PrepareWorkspace(wsReq) + if err != nil { + return nil, fmt.Errorf("prepare workspace: %w", err) + } + + // 2. 若为 CDO 报价任务,先执行 Excel 报价核算引擎并回填 + var attachments []Attachment + if req.TaskKey == "cdo_quote" { + att, err := s.handleCDOQuotePreparation(workDir, req) + if err == nil && att != nil { + attachments = append(attachments, *att) + } + } + + // 3. 执行动态工作流调度 + backend := agent.NewBackend(backendName, s.cfg) + timeout := req.Timeout + if timeout <= 0 { + timeout = s.cfg.AgentTimeout + } + execOpts := ExecuteOptions{ + Timeout: timeout, + StepNotify: req.StepNotify, + } + + execRes, err := s.driver.Execute(ctx, workDir, backend, execOpts) + if err != nil { + return &SquadRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: TaskArtifactTitle(req.TaskKey), + WorkDir: workDir, + BackendUsed: backendName, + Duration: time.Since(start), + Success: false, + ErrorReason: err.Error(), + }, err + } + + // 4. 自动化质量门禁与修复 (QualityGate) + maxRetries := 0 + if req.AutoRepair { + maxRetries = 2 + } + report, err := s.gate.EnsureQualityOrRepair(ctx, workDir, req.TaskKey, s.driver, backend, maxRetries, execOpts) + if err != nil { + // 记录门禁错误但容错返回当前正文 + report = &QualityReport{Passed: false, Violations: []string{err.Error()}} + } + + manifest, _ := s.gate.GenerateManifest(workDir, req.TaskKey, *report) + + // 5. 读取最终交付正文与黑板抽取数据 + finalOutPath := filepath.Join(workDir, "output.md") + finalBytes, _ := os.ReadFile(finalOutPath) + if backendName == "stub" { + finalBytes, _ = os.ReadFile(filepath.Join(workDir, "template.md")) + _ = os.WriteFile(finalOutPath, finalBytes, 0o644) + } + + bb, _ := s.driver.LoadBlackboard(workDir) + var extractedData map[string]string + if bb != nil { + extractedData = bb.Fields + } + + return &SquadRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: TaskArtifactTitle(req.TaskKey), + MarkdownBody: string(finalBytes), + ExtractedData: extractedData, + Attachments: attachments, + WorkDir: workDir, + BackendUsed: backendName, + Duration: time.Since(start), + Success: execRes.Success, + QualityReport: report, + Manifest: manifest, + }, nil +} + +// ExecuteWorkflow 执行既有工作区工作流 +func (s *Service) ExecuteWorkflow(ctx context.Context, wsDir string, backendName string, opts ExecuteOptions) (*SquadRunResult, error) { + start := time.Now() + if backendName == "" { + backendName = s.cfg.AgentBackend + } + backend := agent.NewBackend(backendName, s.cfg) + + execRes, err := s.driver.Execute(ctx, wsDir, backend, opts) + if err != nil { + return nil, err + } + + wsMeta, _ := s.loadWorkspaceMeta(wsDir) + finalBytes, _ := os.ReadFile(filepath.Join(wsDir, "output.md")) + bb, _ := s.driver.LoadBlackboard(wsDir) + + return &SquadRunResult{ + TaskKey: wsMeta.TaskKey, + ArtifactTitle: TaskArtifactTitle(wsMeta.TaskKey), + MarkdownBody: string(finalBytes), + ExtractedData: bb.Fields, + WorkDir: wsDir, + BackendUsed: backendName, + Duration: time.Since(start), + Success: execRes.Success, + }, nil +} + +// InspectWorkspace 查看指定工作区的当前执行状态、黑板数据与各专员产出 +func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*WorkspaceState, error) { + meta, err := s.loadWorkspaceMeta(workDir) + if err != nil { + return nil, fmt.Errorf("load workspace meta: %w", err) + } + + squadCfg, err := s.driver.loadSquadConfig(workDir) + if err != nil { + return nil, fmt.Errorf("load squad config: %w", err) + } + + wf, err := s.driver.LoadWorkflow(workDir) + if err != nil { + return nil, fmt.Errorf("load workflow: %w", err) + } + + bb, err := s.driver.LoadBlackboard(workDir) + if err != nil { + return nil, fmt.Errorf("load blackboard: %w", err) + } + + outPath := filepath.Join(workDir, "output.md") + data, _ := os.ReadFile(outPath) + + return &WorkspaceState{ + WorkDir: workDir, + Metadata: meta, + Squad: squadCfg, + Workflow: *wf, + Blackboard: *bb, + DeliverableContent: string(data), + }, nil +} + +// ResumeOrModifyWorkspace 在现有工作区基础上,动态添加/重跑/修改任务并继续执行 +func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req ModifyWorkflowRequest) (*SquadRunResult, error) { + for _, add := range req.AddSteps { + if err := s.driver.AddStep(req.WorkDir, add); err != nil { + return nil, fmt.Errorf("add step %s: %w", add.ID, err) + } + } + + for _, removeID := range req.RemoveStepIDs { + if err := s.driver.RemoveStep(req.WorkDir, removeID); err != nil { + return nil, fmt.Errorf("remove step %s: %w", removeID, err) + } + } + + for _, update := range req.UpdateSteps { + if err := s.driver.UpdateStep(req.WorkDir, update); err != nil { + return nil, fmt.Errorf("update step %s: %w", update.ID, err) + } + } + + backendName := req.Backend + if backendName == "" { + backendName = s.cfg.AgentBackend + } + + timeout := req.Timeout + if timeout <= 0 { + timeout = s.cfg.AgentTimeout + } + + opts := ExecuteOptions{ + Timeout: timeout, + StepNotify: req.StepNotify, + } + + return s.ExecuteWorkflow(ctx, req.WorkDir, backendName, opts) +} + +func (s *Service) handleCDOQuotePreparation(workDir string, req SquadRunRequest) (*Attachment, error) { + xlsxPath := filepath.Join(workDir, "CDO报价核算-V01.xlsx") + artifactXlsxPath := filepath.Join(workDir, "artifacts", "CDO报价核算-V01.xlsx") + + in := quote.Input{ + CustomerName: req.CustomerName, + ProductName: req.ProjectName, + Hours: map[string]float64{ + "结构工程师": 320, + "硬件工程师": 320, + "软件工程师": 480, + "项目经理": 160, + }, + } + summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", xlsxPath) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(xlsxPath) + if err != nil { + return nil, err + } + _ = os.WriteFile(artifactXlsxPath, data, 0o644) + + // 回填 template.md 中的报价数据 + tmplPath := filepath.Join(workDir, "template.md") + if tmplBytes, err := os.ReadFile(tmplPath); err == nil { + filled := BackfillCDOQuoteMarkdown(string(tmplBytes), summary) + _ = os.WriteFile(tmplPath, []byte(filled), 0o644) + _ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte(filled), 0o644) + } + + return &Attachment{ + Filename: "CDO报价核算-V01.xlsx", + ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Data: data, + }, nil +} + +func (s *Service) loadWorkspaceMeta(wsDir string) (WorkspaceMetadata, error) { + p := filepath.Join(wsDir, "workspace.json") + var m WorkspaceMetadata + data, err := os.ReadFile(p) + if err != nil { + return m, err + } + err = json.Unmarshal(data, &m) + return m, err +}