From 0832d7caa9e970448b2957134cc5c76906ad0bc2 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 16:23:34 +0800 Subject: [PATCH] docs(plan): add bizdev squad and templates implementation plan Co-authored-by: Cursor --- .../plans/2026-09-07-bizdev-squad.md | 1507 +++++++++++++++++ 1 file changed, 1507 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-07-bizdev-squad.md diff --git a/docs/superpowers/plans/2026-09-07-bizdev-squad.md b/docs/superpowers/plans/2026-09-07-bizdev-squad.md new file mode 100644 index 00000000..ac8d30b9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-bizdev-squad.md @@ -0,0 +1,1507 @@ +# 商务对接智能体小队与自描述模板模块实施计划 (Implementation Plan) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 复现 `@weichen-agent-system` 中的商务对接阶段智能体小队与自描述文档模板体系,构建上层高内聚解耦的调用抽象层(Go Package API + CLI),实现无依赖的独立任务执行驱动、CDO 报价 Excel 核算引擎、双智能体后端接入(cursor-cli 与 pi-coding-agent)及已有法规知识库复用。 + +**Architecture:** 采用端口与适配器架构(Ports & Adapters)。上层提供统一门面 `BizdevService` 与 `bizdev-cli`;核心领域层提供无任务依赖的驱动引擎 `TaskDriver`;底层适配器解耦为模板加载器、Excelize 报价引擎、智能体执行适配器(Cursor / Pi / Stub)及法规库 Postgres 客户端。 + +**Tech Stack:** Go 1.22+, `github.com/xuri/excelize/v2`, `github.com/jackc/pgx/v5`, `github.com/pgvector/pgvector-go`, Node.js (v26+ for cursor-bridge & pi-bridge), Docker (共享知识库容器 `agent-regkb:55432`)。 + +## Global Constraints + +- 任务之间完全独立,严禁在调度层强制硬编码前后依赖关系。 +- CDO 报价任务必须输出 `CDO报价核算-Vxx.xlsx`,且数字与 7 条法定说明与 Markdown 报价单 100% 保持自洽。 +- 遵循 Conventional Commits 与渐进式提交规范 (`chore(config)`, `feat(template)`, `feat(quote)`, `feat(agent)`, `feat(service)`, `test(e2e)`)。 +- 所有路径保持相对路径,工作区目录隔离在 `data/dev/` 下。 + +--- + +### Task 1: 项目基础脚手架与配置加载器 (Scaffolding & Config) + +**Files:** +- Create: `go.mod` +- Create: `internal/config/config.go` +- Test: `internal/config/config_test.go` + +**Interfaces:** +- Produces: `config.Config`, `config.Load(path string) (*Config, error)` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/config/config_test.go +package config_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "chat-agent-system/internal/config" +) + +func TestConfigLoadDefaultsAndFile(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + envContent := `ENV=dev +DATA_DIR=./data/test_data +AGENT_BACKEND=pi-agent +AGENT_TIMEOUT=15m +KB_DB_PORT=55432 +` + if err := os.WriteFile(envPath, []byte(envContent), 0o644); err != nil { + t.Fatalf("failed to write test env: %v", err) + } + + cfg, err := config.Load(envPath) + if err != nil { + t.Fatalf("unexpected error loading config: %v", err) + } + + if cfg.Env != "dev" { + t.Errorf("expected Env 'dev', got %q", cfg.Env) + } + if cfg.DataDir != "./data/test_data" { + t.Errorf("expected DataDir './data/test_data', got %q", cfg.DataDir) + } + if cfg.AgentBackend != "pi-agent" { + t.Errorf("expected AgentBackend 'pi-agent', got %q", cfg.AgentBackend) + } + if cfg.AgentTimeout != 15*time.Minute { + t.Errorf("expected AgentTimeout 15m, got %v", cfg.AgentTimeout) + } + if cfg.KbDbPort != 55432 { + t.Errorf("expected KbDbPort 55432, got %d", cfg.KbDbPort) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/... -v` +Expected: FAIL with compilation error (module not found or package config not implemented). + +- [ ] **Step 3: Write minimal implementation** + +Initialize Go module and implement `internal/config/config.go`: + +```bash +go mod init chat-agent-system +``` + +```go +// internal/config/config.go +package config + +import ( + "bufio" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Env string `json:"env"` + DataDir string `json:"data_dir"` + AgentBackend string `json:"agent_backend"` + AgentTimeout time.Duration `json:"agent_timeout"` + CursorAPIKey string `json:"cursor_api_key"` + CursorModel string `json:"cursor_model"` + CursorBin string `json:"cursor_bin"` + CursorBridgeBin string `json:"cursor_bridge_bin"` + PiAgentBin string `json:"pi_agent_bin"` + PiAgentBridgeBin string `json:"pi_agent_bridge_bin"` + PiAgentProvider string `json:"pi_agent_provider"` + PiAgentModel string `json:"pi_agent_model"` + KbDbHost string `json:"kb_db_host"` + KbDbPort int `json:"kb_db_port"` + KbDbUser string `json:"kb_db_user"` + KbDbPassword string `json:"kb_db_password"` + KbDbName string `json:"kb_db_name"` +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + Env: "dev", + DataDir: "./data/dev", + AgentBackend: "pi-agent", + AgentTimeout: 30 * time.Minute, + CursorModel: "auto", + CursorBin: "/usr/local/bin/cursor", + CursorBridgeBin: "tools/cursor-bridge/index.mjs", + PiAgentBin: "/opt/homebrew/bin/pi", + PiAgentBridgeBin: "tools/pi-bridge/index.mjs", + PiAgentProvider: "anthropic", + PiAgentModel: "claude-3-7-sonnet", + KbDbHost: "127.0.0.1", + KbDbPort: 55432, + KbDbUser: "regkb", + KbDbPassword: "shared_kb_secret_pass", + KbDbName: "regulations", + } + + f, err := os.Open(path) + if err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if commentIdx := strings.Index(val, " #"); commentIdx != -1 { + val = strings.TrimSpace(val[:commentIdx]) + } + + switch key { + case "ENV": + cfg.Env = val + case "DATA_DIR": + cfg.DataDir = val + case "AGENT_BACKEND": + cfg.AgentBackend = val + case "AGENT_TIMEOUT": + if d, err := time.ParseDuration(val); err == nil { + cfg.AgentTimeout = d + } + case "CURSOR_API_KEY": + cfg.CursorAPIKey = val + case "CURSOR_MODEL": + cfg.CursorModel = val + case "CURSOR_BIN": + cfg.CursorBin = val + case "CURSOR_BRIDGE_BIN": + cfg.CursorBridgeBin = val + case "PI_AGENT_BIN": + cfg.PiAgentBin = val + case "PI_AGENT_BRIDGE_BIN": + cfg.PiAgentBridgeBin = val + case "PI_AGENT_PROVIDER": + cfg.PiAgentProvider = val + case "PI_AGENT_MODEL": + cfg.PiAgentModel = val + case "KB_DB_HOST": + cfg.KbDbHost = val + case "KB_DB_PORT": + if p, err := strconv.Atoi(val); err == nil { + cfg.KbDbPort = p + } + case "KB_DB_USER": + cfg.KbDbUser = val + case "KB_DB_PASSWORD": + cfg.KbDbPassword = val + case "KB_DB_NAME": + cfg.KbDbName = val + } + } + } + + return cfg, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod internal/config/ +git commit -m "chore(config): initialize go module and configuration loader" +``` + +--- + +### Task 2: 法规知识库检索客户端 (Knowledge Base Client) + +**Files:** +- Create: `internal/kb/models.go` +- Create: `internal/kb/client.go` +- Test: `internal/kb/client_test.go` + +**Interfaces:** +- Consumes: `config.Config` +- Produces: `kb.Client`, `kb.HitItem`, `client.Search(ctx, query, limit) ([]HitItem, error)`, `client.MaterializeSnapshot(dir, hits) error` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/kb/client_test.go +package kb_test + +import ( + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/kb" +) + +func TestMaterializeSnapshot(t *testing.T) { + tmpDir := t.TempDir() + hits := []kb.HitItem{ + { + Category: "standards", + DocCode: "GB 9706.1-2020", + Title: "医用电气设备 第1部分:基本安全和基本性能的通用要求", + Content: "医用电气设备必须具备防电击安全保护...", + Source: "知识库检索", + }, + { + Category: "guidance", + DocCode: "有源手术器械指导原则", + Title: "有源手术器械注册审查指导原则", + Content: "明确器械分类与临床评价依据...", + Source: "知识库检索", + }, + } + + err := kb.MaterializeSnapshot(tmpDir, hits) + if err != nil { + t.Fatalf("failed to materialize kb snapshot: %v", err) + } + + hitsFile := filepath.Join(tmpDir, "hits.md") + if _, err := os.Stat(hitsFile); err != nil { + t.Fatalf("expected hits.md to exist: %v", err) + } + + content, _ := os.ReadFile(hitsFile) + if !containsString(string(content), "GB 9706.1-2020") { + t.Errorf("hits.md missing GB 9706.1-2020") + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && s != "" && (s == substr || filepath.Base(s) != "" && stringContains(s, substr)) +} + +func stringContains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/kb/... -v` +Expected: FAIL with compilation errors (package kb does not exist). + +- [ ] **Step 3: Write minimal implementation** + +```go +// internal/kb/models.go +package kb + +type HitItem struct { + Category string `json:"category"` // standards, guidance, laws, catalog + DocCode string `json:"doc_code"` + Title string `json:"title"` + Content string `json:"content"` + Source string `json:"source"` +} +``` + +```go +// internal/kb/client.go +package kb + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/config" +) + +type Client struct { + cfg *config.Config +} + +func NewClient(cfg *config.Config) *Client { + return &Client{cfg: cfg} +} + +func (c *Client) Search(ctx context.Context, query string, limit int) ([]HitItem, error) { + // 基础查询:当未开启或处于离线测试时,返回标准结构 + return []HitItem{}, nil +} + +// MaterializeSnapshot 将检索命中的法规写入任务工作空间的 knowledge/ 目录 +func MaterializeSnapshot(dir string, hits []HitItem) error { + kbDir := filepath.Join(dir, "knowledge") + if err := os.MkdirAll(kbDir, 0o755); err != nil { + return err + } + + var sb strings.Builder + sb.WriteString("# 法规与指导原则检索快照\n\n") + sb.WriteString("以下为针对本项目命中的参考法规标准条文,请结合上下文使用,标注〔出处〕。\n\n") + + for _, h := range hits { + sb.WriteString(fmt.Sprintf("### [%s] %s %s\n", strings.ToUpper(h.Category), h.DocCode, h.Title)) + sb.WriteString(h.Content + "\n\n") + sb.WriteString(fmt.Sprintf("> 来源: %s\n\n---\n\n", h.Source)) + } + + return os.WriteFile(filepath.Join(kbDir, "hits.md"), []byte(sb.String()), 0o644) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/kb/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/kb/ +git commit -m "feat(kb): add regulation knowledge base client with snapshot materialization" +``` + +--- + +### Task 3: 自描述文档模板与角色指令体系 (Templates & Personas) + +**Files:** +- Create: `templates/docs/intake.md` +- Create: `templates/docs/research.md` +- Create: `templates/docs/feasibility_dev.md` +- Create: `templates/docs/feasibility_reg.md` +- Create: `templates/docs/user_requirements.md` +- Create: `templates/docs/prelim_design.md` +- Create: `templates/docs/cdo_quote.md` +- Create: `templates/xlsx/cdo_quote.xlsx` (从 `.refs/` 迁移) +- Create: `configs/agents/{leader,writer,searcher,organizer,engineer,market,regulatory,finance}.md` +- Create: `configs/shared/{writing,compliance}.md` +- Create: `internal/template/loader.go` +- Create: `internal/template/render.go` +- Test: `internal/template/loader_test.go` + +**Interfaces:** +- Produces: `template.GetDocTemplate(name string) (string, error)`, `template.GetAgentInstruction(role string) (string, error)`, `template.RenderDoc(tmpl string, vars map[string]string) string` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/template/loader_test.go +package template_test + +import ( + "strings" + "testing" + + "chat-agent-system/internal/template" +) + +func TestLoadDocTemplatesAndAgents(t *testing.T) { + tmpl, err := template.GetDocTemplate("intake") + if err != nil { + t.Fatalf("failed to load intake template: %v", err) + } + if !strings.Contains(tmpl, "用户初始需求收集表") { + t.Errorf("template intake.md missing title header") + } + if !strings.Contains(tmpl, "写满四段式") { + t.Errorf("template intake.md missing self-describing guidance '写满四段式'") + } + + inst, err := template.GetAgentInstruction("leader") + if err != nil { + t.Fatalf("failed to load leader agent instructions: %v", err) + } + if !strings.Contains(inst, "商务对接负责人") { + t.Errorf("leader.md missing expected role definition") + } + + rendered := template.RenderDoc(tmpl, map[string]string{ + "project_name": "测试手术机器人", + "customer_name": "水木医疗", + }) + if !strings.Contains(rendered, "测试手术机器人") { + t.Errorf("render failed to replace project_name") + } + if !strings.Contains(rendered, "水木医疗") { + t.Errorf("render failed to replace customer_name") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/template/... -v` +Expected: FAIL with compilation errors. + +- [ ] **Step 3: Write minimal implementation** + +1. 拷贝模板文件与专员角色 Markdown 文件: +```bash +mkdir -p templates/docs templates/xlsx configs/agents configs/shared +cp .refs/weichen-agent-system/templates/docs/*.md templates/docs/ +cp .refs/weichen-agent-system/templates/xlsx/cdo_quote.xlsx templates/xlsx/ +cp .refs/weichen-agent-system/configs/agents/*.md configs/agents/ +cp .refs/weichen-agent-system/configs/shared/*.md configs/shared/ +``` + +2. 实现 `internal/template/loader.go` 与 `internal/template/render.go`: + +```go +// internal/template/loader.go +package template + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func findProjectRoot() string { + wd, _ := os.Getwd() + dir := wd + for i := 0; i < 10; i++ { + if _, err := os.Stat(filepath.Join(dir, "templates", "docs")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return wd +} + +func GetDocTemplate(name string) (string, error) { + name = strings.TrimSuffix(name, ".md") + root := findProjectRoot() + path := filepath.Join(root, "templates", "docs", name+".md") + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read doc template %s: %w", name, err) + } + return string(b), nil +} + +func GetAgentInstruction(role string) (string, error) { + role = strings.TrimSuffix(role, ".md") + root := findProjectRoot() + path := filepath.Join(root, "configs", "agents", role+".md") + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read agent instruction %s: %w", role, err) + } + + inst := string(b) + writingPath := filepath.Join(root, "configs", "shared", "writing.md") + if wb, err := os.ReadFile(writingPath); err == nil { + inst += "\n\n" + string(wb) + } + return inst, nil +} +``` + +```go +// internal/template/render.go +package template + +import ( + "strings" +) + +func RenderDoc(tmpl string, vars map[string]string) string { + res := tmpl + for k, v := range vars { + placeholder := "{{" + k + "}}" + res = strings.ReplaceAll(res, placeholder, v) + } + return res +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/template/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add templates/ configs/ internal/template/ +git commit -m "feat(template): add self-describing templates and agent personas" +``` + +--- + +### Task 4: CDO 报价 Excel 核算引擎 (Excelize Quote Engine) + +**Files:** +- Create: `internal/quote/models.go` +- Create: `internal/quote/quote.go` +- Test: `internal/quote/quote_test.go` + +**Interfaces:** +- Consumes: `templates/xlsx/cdo_quote.xlsx` +- Produces: `quote.Input`, `quote.CalculateAndFill(in Input, templateXlsx string, outPath string) (*quote.Summary, error)` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/quote/quote_test.go +package quote_test + +import ( + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/quote" +) + +func TestCDOQuoteGeneration(t *testing.T) { + tmpDir := t.TempDir() + outXlsx := filepath.Join(tmpDir, "CDO报价核算-V01.xlsx") + + in := quote.Input{ + CustomerName: "水木测试医疗", + ProductName: "腹腔微创手术导航系统", + Author: "商务小队测试", + Hours: map[string]float64{ + "结构工程师": 320, + "硬件工程师": 320, + "软件工程师": 480, + "项目经理": 160, + }, + } + + summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", outXlsx) + if err != nil { + t.Fatalf("quote calculation failed: %v", err) + } + + if _, err := os.Stat(outXlsx); err != nil { + t.Fatalf("expected output xlsx to exist: %v", err) + } + + if summary.TotalPrice <= 0 { + t.Errorf("expected positive total price, got %f", summary.TotalPrice) + } + if len(summary.Notes) != 7 { + t.Errorf("expected 7 statutory notes, got %d", len(summary.Notes)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/quote/... -v` +Expected: FAIL with compilation errors. + +- [ ] **Step 3: Write minimal implementation** + +Install `excelize`: +```bash +go get github.com/xuri/excelize/v2 +``` + +Implement `internal/quote/models.go` and `internal/quote/quote.go`: + +```go +// internal/quote/models.go +package quote + +type Input struct { + CustomerName string `json:"customer_name"` + ProductName string `json:"product_name"` + Author string `json:"author"` + Hours map[string]float64 `json:"hours"` +} + +type SummaryLine struct { + Code string `json:"code"` + Name string `json:"name"` + PersonYear float64 `json:"person_year"` + Cost float64 `json:"cost"` + Buffer float64 `json:"buffer"` + Margin float64 `json:"margin"` + Tax float64 `json:"tax"` + Price float64 `json:"price"` +} + +type Summary struct { + CustomerName string `json:"customer_name"` + ProductName string `json:"product_name"` + Lines []SummaryLine `json:"lines"` + TotalPrice float64 `json:"total_price"` + Notes []string `json:"notes"` +} +``` + +```go +// internal/quote/quote.go +package quote + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/xuri/excelize/v2" +) + +var StatutoryNotes = []string{ + "1)以上价格覆盖服务范围:原理样机验证,工程样机开发,送检样机开发", + "2)以上价格不含样机物料费用,如需乙方采购,另行商议", + "3)以上不含开发阶段使用的专用测试设备和工具工装费用", + "4)以上价格不包括药监局、医疗器械检验所等任何委托测试的第三方费用,第三方费用由甲方直接与第三方结算", + "5)以上未计算临床阶段费用", + "6)不含无菌耗材委托生产费用(CMO)", + "7)不含体系搭建费用", +} + +const ( + bufferRate = 0.20 + marginRate = 0.40 + taxRate = 0.07 + hoursPerPM = 160.0 +) + +func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary, error) { + if _, err := os.Stat(templateXlsx); err != nil { + // 容错寻找项目根路径模板 + wd, _ := os.Getwd() + dir := wd + for i := 0; i < 10; i++ { + p := filepath.Join(dir, templateXlsx) + if _, err := os.Stat(p); err == nil { + templateXlsx = p + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + + f, err := excelize.OpenFile(templateXlsx) + if err != nil { + return nil, fmt.Errorf("open template %s: %w", templateXlsx, err) + } + defer f.Close() + + var totalHours float64 + for _, h := range in.Hours { + totalHours += h + } + + pm := totalHours / hoursPerPM + baseCost := totalHours * 150.0 // 平均工时单价 + bufferVal := baseCost * bufferRate + marginVal := (baseCost + bufferVal) * marginRate + preTax := baseCost + bufferVal + marginVal + taxVal := preTax * taxRate + totalPrice := preTax + taxVal + + summary := &Summary{ + CustomerName: in.CustomerName, + ProductName: in.ProductName, + TotalPrice: totalPrice, + Notes: StatutoryNotes, + Lines: []SummaryLine{ + {Code: "ID2", Name: "产品规划", PersonYear: pm * 0.1, Cost: baseCost * 0.1, Buffer: bufferVal * 0.1, Margin: marginVal * 0.1, Tax: taxVal * 0.1, Price: totalPrice * 0.1}, + {Code: "ID3.3.1", Name: "设计开发工程化", PersonYear: pm * 0.7, Cost: baseCost * 0.7, Buffer: bufferVal * 0.7, Margin: marginVal * 0.7, Tax: taxVal * 0.7, Price: totalPrice * 0.7}, + {Code: "ID6", Name: "DHF文档编写", PersonYear: pm * 0.2, Cost: baseCost * 0.2, Buffer: bufferVal * 0.2, Margin: marginVal * 0.2, Tax: taxVal * 0.2, Price: totalPrice * 0.2}, + }, + } + + // 填入单元格(如果 Sheet 存在) + sheet := "报价模型" + if idx, _ := f.GetSheetIndex(sheet); idx != -1 { + _ = f.SetCellValue(sheet, "B3", in.CustomerName) + _ = f.SetCellValue(sheet, "B4", in.ProductName) + } + + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return nil, err + } + if err := f.SaveAs(outPath); err != nil { + return nil, fmt.Errorf("save filled quote xlsx: %w", err) + } + + return summary, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/quote/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/quote/ go.mod go.sum +git commit -m "feat(quote): implement CDO quote Excel calculation and fill engine" +``` + +--- + +### Task 5: 智能体后端适配层与桥接脚本 (Agent Backends & Bridges) + +**Files:** +- Create: `tools/pi-bridge/index.mjs` +- Create: `tools/cursor-bridge/index.mjs` +- Create: `internal/agent/backend.go` +- Create: `internal/agent/stub.go` +- Create: `internal/agent/piagent.go` +- Create: `internal/agent/cursor.go` +- Test: `internal/agent/backend_test.go` + +**Interfaces:** +- Produces: `agent.Backend`, `agent.NewBackend(name string, cfg *config.Config) Backend`, `backend.Execute(ctx, req) (*Result, error)` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/agent/backend_test.go +package agent_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "chat-agent-system/internal/agent" + "chat-agent-system/internal/config" +) + +func TestStubBackendExecution(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{AgentBackend: "stub"} + + backend := agent.NewBackend("stub", cfg) + if backend.Name() != "stub" { + t.Errorf("expected stub backend, got %s", backend.Name()) + } + + req := agent.Request{ + WorkDir: tmpDir, + Prompt: "请编写用户初始需求收集表", + Targets: []string{"output.md"}, + Timeout: 5 * time.Second, + } + + res, err := backend.Execute(context.Background(), req) + if err != nil { + t.Fatalf("stub execute failed: %v", err) + } + if !res.Success { + t.Fatalf("expected success") + } + + outputPath := filepath.Join(tmpDir, "output.md") + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected output.md to exist: %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/agent/... -v` +Expected: FAIL with compilation errors. + +- [ ] **Step 3: Write minimal implementation** + +1. 部署 bridge 脚本: +```bash +mkdir -p tools/pi-bridge tools/cursor-bridge +cp .refs/agent-system/tools/pi-bridge/index.mjs tools/pi-bridge/ +chmod +x tools/pi-bridge/index.mjs +cp .refs/agent-system/tools/cursor-bridge/index.mjs tools/cursor-bridge/ +chmod +x tools/cursor-bridge/index.mjs +``` + +2. 编写 `internal/agent/backend.go`, `stub.go`, `piagent.go`, `cursor.go`: + +```go +// internal/agent/backend.go +package agent + +import ( + "context" + "time" + + "chat-agent-system/internal/config" +) + +type Request struct { + WorkDir string `json:"work_dir"` + Prompt string `json:"prompt"` + Instructions string `json:"instructions"` + Targets []string `json:"targets"` + Timeout time.Duration `json:"timeout"` +} + +type Result struct { + Success bool `json:"success"` + Patches map[string][]byte `json:"patches"` + Logs string `json:"logs"` + ErrorReason string `json:"error_reason"` +} + +type Backend interface { + Name() string + Execute(ctx context.Context, req Request) (*Result, error) +} + +func NewBackend(name string, cfg *config.Config) Backend { + switch name { + case "pi-agent": + return &PiAgentBackend{cfg: cfg} + case "cursor-cli": + return &CursorCLIBackend{cfg: cfg} + default: + return &StubBackend{} + } +} +``` + +```go +// internal/agent/stub.go +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" +) + +type StubBackend struct{} + +func (s *StubBackend) Name() string { return "stub" } + +func (s *StubBackend) Execute(ctx context.Context, req Request) (*Result, error) { + patches := make(map[string][]byte) + for _, target := range req.Targets { + content := []byte(fmt.Sprintf("# 模拟生成结果\n\n- 目标文件: %s\n- 提示词摘要: %s\n", target, req.Prompt)) + outPath := filepath.Join(req.WorkDir, target) + _ = os.MkdirAll(filepath.Dir(outPath), 0o755) + _ = os.WriteFile(outPath, content, 0o644) + patches[target] = content + } + + return &Result{ + Success: true, + Patches: patches, + Logs: "Stub agent execution completed.", + }, nil +} +``` + +```go +// internal/agent/piagent.go +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "chat-agent-system/internal/config" +) + +type PiAgentBackend struct { + cfg *config.Config +} + +func (p *PiAgentBackend) Name() string { return "pi-agent" } + +func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, error) { + if len(req.Targets) == 0 { + req.Targets = []string{"output.md"} + } + + payload, _ := json.Marshal(map[string]any{ + "workspace": req.WorkDir, + "model": p.cfg.PiAgentModel, + "provider": p.cfg.PiAgentProvider, + "prompt": req.Prompt, + "targets": req.Targets, + }) + + bridgeBin := p.cfg.PiAgentBridgeBin + if !filepath.IsAbs(bridgeBin) { + wd, _ := os.Getwd() + bridgeBin = filepath.Join(wd, bridgeBin) + } + + timeout := req.Timeout + if timeout <= 0 { + timeout = p.cfg.AgentTimeout + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(runCtx, bridgeBin) + cmd.Dir = req.WorkDir + cmd.Stdin = bytes.NewReader(payload) + cmd.Env = append(os.Environ(), + "PI_AGENT_BIN="+p.cfg.PiAgentBin, + "PI_AGENT_MODEL="+p.cfg.PiAgentModel, + "PI_AGENT_PROVIDER="+p.cfg.PiAgentProvider, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return &Result{ + Success: false, + ErrorReason: fmt.Sprintf("pi execution error: %v, stderr: %s", err, stderr.String()), + }, nil + } + + patches := make(map[string][]byte) + for _, t := range req.Targets { + p := filepath.Join(req.WorkDir, t) + if b, err := os.ReadFile(p); err == nil { + patches[t] = b + } + } + + return &Result{ + Success: true, + Patches: patches, + Logs: stdout.String(), + }, nil +} +``` + +```go +// internal/agent/cursor.go +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "chat-agent-system/internal/config" +) + +type CursorCLIBackend struct { + cfg *config.Config +} + +func (c *CursorCLIBackend) Name() string { return "cursor-cli" } + +func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, error) { + if len(req.Targets) == 0 { + req.Targets = []string{"output.md"} + } + + payload, _ := json.Marshal(map[string]any{ + "workspace": req.WorkDir, + "api_key": c.cfg.CursorAPIKey, + "model": c.cfg.CursorModel, + "prompt": req.Prompt, + "targets": req.Targets, + }) + + bridgeBin := c.cfg.CursorBridgeBin + if !filepath.IsAbs(bridgeBin) { + wd, _ := os.Getwd() + bridgeBin = filepath.Join(wd, bridgeBin) + } + + timeout := req.Timeout + if timeout <= 0 { + timeout = c.cfg.AgentTimeout + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(runCtx, bridgeBin) + cmd.Dir = req.WorkDir + cmd.Stdin = bytes.NewReader(payload) + cmd.Env = append(os.Environ(), + "CURSOR_API_KEY="+c.cfg.CursorAPIKey, + "CURSOR_MODEL="+c.cfg.CursorModel, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return &Result{ + Success: false, + ErrorReason: fmt.Sprintf("cursor execution error: %v, stderr: %s", err, stderr.String()), + }, nil + } + + patches := make(map[string][]byte) + for _, t := range req.Targets { + p := filepath.Join(req.WorkDir, t) + if b, err := os.ReadFile(p); err == nil { + patches[t] = b + } + } + + return &Result{ + Success: true, + Patches: patches, + Logs: stdout.String(), + }, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/agent/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tools/ internal/agent/ +git commit -m "feat(agent): implement cursor-cli, pi-agent, and stub backend adapters" +``` + +--- + +### Task 6: 商务对接领域核心与独立任务驱动 (Bizdev Core & Task Drivers) + +**Files:** +- Create: `internal/bizdev/types.go` +- Create: `internal/bizdev/service.go` +- Create: `internal/bizdev/engine.go` +- Test: `internal/bizdev/service_test.go` + +**Interfaces:** +- Consumes: `internal/template`, `internal/quote`, `internal/agent`, `internal/kb`, `internal/config` +- Produces: `bizdev.NewService(cfg) BizdevService`, `service.ExecuteTask(ctx, req) (*TaskRunResult, error)` + +- [ ] **Step 1: Write the failing test** + +```go +// internal/bizdev/service_test.go +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) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/bizdev/... -v` +Expected: FAIL with compilation errors. + +- [ ] **Step 3: Write minimal implementation** + +```go +// internal/bizdev/types.go +package bizdev + +import "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) +} +``` + +```go +// internal/bizdev/service.go +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 (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) { + start := time.Now() + + // 1. 准备沙箱工作区 + 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) + } + + // 2. 加载模板并注入变量 + 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) + + // 3. 注入角色设定与指令 + 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) + + // 4. 选择后端并执行 + backendName := req.Backend + if backendName == "" { + backendName = s.cfg.AgentBackend + } + backend := agent.NewBackend(backendName, s.cfg) + + backendRes, err := backend.Execute(ctx, agent.Request{ + WorkDir: workDir, + Prompt: taskPrompt, + Instructions: inst, + Targets: []string{"output.md"}, + Timeout: req.Timeout, + }) + if err != nil { + return nil, fmt.Errorf("backend execute: %w", err) + } + + markdownBody := renderedTmpl + if backendRes != nil && len(backendRes.Patches["output.md"]) > 0 { + markdownBody = string(backendRes.Patches["output.md"]) + } + + // 5. CDO 任务输出 Excel 附件 + 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, + }, + } + _, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", xlsxPath) + if err == nil { + data, _ := os.ReadFile(xlsxPath) + attachments = append(attachments, Attachment{ + Filename: "CDO报价核算-V01.xlsx", + ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Data: data, + }) + } + } + + return &TaskRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: req.TaskKey, + MarkdownBody: markdownBody, + Attachments: attachments, + WorkDir: workDir, + BackendUsed: backendName, + Duration: time.Since(start), + }, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/bizdev/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/bizdev/ +git commit -m "feat(service): implement bizdev service with independent task drivers" +``` + +--- + +### Task 7: CLI 调用工具与全链路独立验证 (CLI Runner & E2E Validation) + +**Files:** +- Create: `cmd/bizdev-cli/main.go` +- Test: `cmd/bizdev-cli/main_test.go` + +**Interfaces:** +- Produces: `cmd/bizdev-cli` binary, flags: `-task`, `-backend`, `-project`, `-customer` + +- [ ] **Step 1: Write the failing test** + +```go +// cmd/bizdev-cli/main_test.go +package main_test + +import ( + "os/exec" + "strings" + "testing" +) + +func TestBizdevCLIHelpAndRun(t *testing.T) { + cmd := exec.Command("go", "run", "./cmd/bizdev-cli", "-help") + out, _ := cmd.CombinedOutput() + if !strings.Contains(string(out), "-task") { + t.Fatalf("cli output missing -task flag: %s", string(out)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./cmd/bizdev-cli/... -v` +Expected: FAIL with compilation error (main package not found). + +- [ ] **Step 3: Write minimal implementation** + +```go +// cmd/bizdev-cli/main.go +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" +) + +func main() { + taskKey := flag.String("task", "intake", "任务标识 (intake, research, cdo_quote 等)") + backend := flag.String("backend", "stub", "智能体后端 (stub, pi-agent, cursor-cli)") + project := flag.String("project", "医疗器械研发演示项目", "项目名称") + customer := flag.String("customer", "北京水木东方", "客户名称") + envPath := flag.String("env", ".env", "环境配置文件路径") + flag.Parse() + + cfg, err := config.Load(*envPath) + if err != nil { + fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) + os.Exit(1) + } + + if *backend != "" { + cfg.AgentBackend = *backend + } + + svc := bizdev.NewService(cfg) + res, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{ + TaskKey: *taskKey, + ProjectName: *project, + CustomerName: *customer, + Backend: *backend, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "任务执行失败: %v\n", err) + os.Exit(1) + } + + fmt.Printf("=== 任务执行成功 [%s] ===\n", res.TaskKey) + fmt.Printf("耗时: %v\n", res.Duration) + fmt.Printf("工作区: %s\n", res.WorkDir) + fmt.Printf("使用的后端: %s\n", res.BackendUsed) + if len(res.Attachments) > 0 { + fmt.Printf("附加产物:\n") + for _, a := range res.Attachments { + p := filepath.Join(res.WorkDir, a.Filename) + fmt.Printf(" - %s (%d bytes) -> %s\n", a.Filename, len(a.Data), p) + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./cmd/bizdev-cli/... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add cmd/bizdev-cli/ +git commit -m "feat(cli): add bizdev-cli runner tool and integration tests" +```