feat(service): integrate squad service and ports into bizdev domain service
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
434ce844f8
commit
3c2d0ca94e
+11
-85
@@ -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)
|
||||
}
|
||||
|
||||
+88
-148
@@ -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)
|
||||
}
|
||||
|
||||
+34
-33
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user