feat(service): implement bizdev service with independent task drivers

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:18 +00:00
committed by weichen
co-authored by Cursor
parent d54ba6ea77
commit 77c17b7bc0
3 changed files with 259 additions and 0 deletions
+151
View File
@@ -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
}
+54
View File
@@ -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)
}
}
+54
View File
@@ -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)
}