From 77c17b7bc03601241f4e5137cab376a6fa19b4e9 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 16:38:55 +0800 Subject: [PATCH] feat(service): implement bizdev service with independent task drivers Co-authored-by: Cursor --- internal/bizdev/service.go | 151 ++++++++++++++++++++++++++++++++ internal/bizdev/service_test.go | 54 ++++++++++++ internal/bizdev/types.go | 54 ++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 internal/bizdev/service.go create mode 100644 internal/bizdev/service_test.go create mode 100644 internal/bizdev/types.go diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go new file mode 100644 index 00000000..a5e7647d --- /dev/null +++ b/internal/bizdev/service.go @@ -0,0 +1,151 @@ +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" +) + +type Service struct { + cfg *config.Config + kb *kb.Client +} + +func NewService(cfg *config.Config) *Service { + return &Service{ + cfg: cfg, + kb: kb.NewClient(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)"}, +} + +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 +} + +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", fmt.Sprintf("task_%s_%d", req.TaskKey, ts)) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + return nil, fmt.Errorf("create workdir: %w", err) + } + + tmpl, err := template.GetDocTemplate(req.TaskKey) + if err != nil { + return nil, err + } + + vars := map[string]string{ + "project_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"), + } + 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) + 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) + } + + 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, + }, + } + if _, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", xlsxPath); err != nil { + return nil, fmt.Errorf("cdo quote: %w", err) + } + 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, + }) + } + + return &TaskRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: taskArtifactTitle(req.TaskKey), + MarkdownBody: markdownBody, + Attachments: attachments, + WorkDir: workDir, + BackendUsed: backendName, + Duration: time.Since(start), + }, nil +} diff --git a/internal/bizdev/service_test.go b/internal/bizdev/service_test.go new file mode 100644 index 00000000..b0a591ae --- /dev/null +++ b/internal/bizdev/service_test.go @@ -0,0 +1,54 @@ +package bizdev_test + +import ( + "context" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" +) + +func TestExecuteTaskIndependent(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + DataDir: tmpDir, + AgentBackend: "stub", + AgentTimeout: 5 * time.Second, + } + + svc := bizdev.NewService(cfg) + + // 测试 1: 单独执行 intake (不依赖任何前序) + intakeRes, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{ + TaskKey: "intake", + ProjectName: "微创吻合器研发", + CustomerName: "北京天工", + DeviceType: "腔镜微创器械", + IntendedUse: "微创手术软组织缝合切除", + TargetMarket: "中国 (NMPA)", + }) + if err != nil { + t.Fatalf("execute intake failed: %v", err) + } + if !strings.Contains(intakeRes.MarkdownBody, "用户初始需求收集表") { + t.Errorf("intake response missing title") + } + + // 测试 2: 单独执行 cdo_quote (直接输出 Excel 附件) + quoteRes, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{ + TaskKey: "cdo_quote", + ProjectName: "微创吻合器研发", + CustomerName: "北京天工", + }) + if err != nil { + t.Fatalf("execute cdo_quote failed: %v", err) + } + if len(quoteRes.Attachments) == 0 { + t.Fatalf("cdo_quote must output Excel attachment") + } + if quoteRes.Attachments[0].Filename != "CDO报价核算-V01.xlsx" { + t.Errorf("unexpected attachment filename: %s", quoteRes.Attachments[0].Filename) + } +} diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go new file mode 100644 index 00000000..f27dff77 --- /dev/null +++ b/internal/bizdev/types.go @@ -0,0 +1,54 @@ +package bizdev + +import ( + "context" + "time" +) + +type TaskMeta struct { + Key string `json:"key"` + Title string `json:"title"` + ArtifactTitle string `json:"artifact_title"` + Description string `json:"description"` +} + +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 struct { + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Data []byte `json:"data"` +} + +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"` +} + +type BizdevService interface { + ListTasks() []TaskMeta + ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) +}