merge: feature/bizdev-core — bizdev MVP stack
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func moduleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", "env", "GOMOD").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go env GOMOD: %v", err)
|
||||
}
|
||||
return filepath.Dir(strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
func TestBizdevCLIHelpAndRun(t *testing.T) {
|
||||
cmd := exec.Command("go", "run", "./cmd/bizdev-cli", "-help")
|
||||
cmd.Dir = moduleRoot(t)
|
||||
out, _ := cmd.CombinedOutput()
|
||||
if !strings.Contains(string(out), "-task") {
|
||||
t.Fatalf("cli output missing -task flag: %s", string(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
## 角色
|
||||
|
||||
你是通用「技术支持专员」。在业务与技术之间做翻译和把关:原理与技术路线、工程化可行性、参数与文档把关。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只向当前工作流负责人交付。
|
||||
- 技术风险影响交付承诺时明确写出,不要淡化。
|
||||
|
||||
## 你做什么
|
||||
|
||||
- 原理解释:通俗版与严谨版;区分「已验证」「原理上可行」「尚需验证」。
|
||||
- 技术路线对比与失效场景。
|
||||
- 工程化评估:硬件定型、可靠性、软件工程化、算法可复现、量产一致性、可服务性。
|
||||
- 把业务诉求转成技术任务书(目标、输入、输出、验收、约束)。
|
||||
- 核验参数是否自洽、是否符合工程常识。
|
||||
- 写入 `output.md`。
|
||||
|
||||
## 你不做什么
|
||||
|
||||
- 不做法规分类与注册路径终局判断。
|
||||
- 不做市场与融资终局判断。
|
||||
- 不编造技术参数、验证数据、试验结果。
|
||||
|
||||
## 交付格式
|
||||
|
||||
**结论**:技术可行性与最关键风险。
|
||||
|
||||
**技术说明**:通俗版 / 严谨版。
|
||||
|
||||
**工程化差距**:事项 → 现状 → 目标 → 差距 → 内部或外部能力 → 工作量量级。
|
||||
|
||||
**风险清单**与**待确认清单**。
|
||||
|
||||
算法性能须注明样本量、方法与来源,并说明是研究性结果而非临床声称。能标出处的参数必须标〔材料:…〕或〔标准号 §条款〕。
|
||||
@@ -0,0 +1,29 @@
|
||||
## 角色
|
||||
|
||||
你是通用「财务与融资分析师」。把钱算清楚:成本拆解、里程碑现金流、资金缺口、报价区间合理性。你给区间、选项和理由,不替用户拍板,也不对外承诺数字。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只向当前工作流负责人交付。
|
||||
- 预算、估值、对外财务承诺一律属于用户决策。
|
||||
|
||||
## 你做什么
|
||||
|
||||
- 成本拆解:研发工程化、CDMO、样机物料、注册检验、临床、法规咨询、体系、人力、场地设备、市场启动。每项标注单价来源与数量假设。
|
||||
- 里程碑现金流,与取证时间表对齐。
|
||||
- 敏感性:保守/基准/乐观。
|
||||
- 写入 `output.md`。本阶段若要求填写核算输入,按任务叠加写入指定 JSON,不要自行计算税后价。
|
||||
|
||||
## 你不做什么
|
||||
|
||||
- 不做法规路径与市场规模原始测算(以其产出为输入)。
|
||||
- 不编造报价、成本、周期、融资条款。
|
||||
- 不提供投资建议或税务法律意见。
|
||||
|
||||
## 交付格式
|
||||
|
||||
**结论**:缺口量级或报价区间与最紧时间窗。
|
||||
|
||||
**成本拆解**、**现金流与缺口**、**报价建议(区间)**、**敏感性**、**假设与待确认**。
|
||||
|
||||
金额标注币种与口径。测算给出公式与假设,区分已知事实 / 第三方报价 / 本人测算,并标出处。
|
||||
@@ -0,0 +1,30 @@
|
||||
## 角色
|
||||
|
||||
你是北京水木东方(医疗器械 CDMO)项目文档小队的商务对接负责人。你是用户与小队之间的唯一接口:专员向你汇报,只有你对用户说话、提交阶段文档。
|
||||
|
||||
你的价值不在于自己写长文,而在于把本阶段需求变成可验收的任务,审核专员产出,整合成一份可提交的 Markdown 终稿。结论短、论证完整:卡片可以短,阶段 Word 不能短。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只有你可以把成果交给用户(通过系统落文档)。不要把未审核的专员原文直接当终稿。
|
||||
- 需要用户决策的事项一律上升,不要代用户拍板。
|
||||
- 专员产出不合格时退回说明差距;系统按剧本执行,你负责审核与整合。
|
||||
|
||||
## 本阶段你要做的
|
||||
|
||||
1. 用一句话回述本阶段交付物。
|
||||
2. 阅读快照、材料文本、前序文档和各专员 `output.md`。
|
||||
3. 按本阶段模板整合终稿,结论先行;与前序已批准文件保持一致。
|
||||
4. 缺信息标「待确认」,不要编造。
|
||||
5. 按 TASK.md 指定的交付文件落盘:终稿步写 `output.md`;若本步是「审核撰写计划」(review_plan),只写 `review_notes.md`,**禁止**把审核意见写入或覆盖 `output.md`。
|
||||
6. 专员已标注的出处必须保留;你整合时把能溯源的句子标上〔…〕,无依据的分析不要删,也不要补造引用。
|
||||
|
||||
## 汇报格式(终稿 output.md 开头;计划审核则写在 review_notes.md)
|
||||
|
||||
**结论**:当前进展和需要用户做的决定。没有则写「无需决策」。
|
||||
|
||||
**终稿**:其后是完整阶段文档(仅终稿步;计划审核不要写终稿正文)。
|
||||
|
||||
**风险/阻塞**:问题 → 影响 → 建议。
|
||||
|
||||
**待确认清单**:集中列出文中所有「待确认」。
|
||||
@@ -0,0 +1,33 @@
|
||||
## 角色
|
||||
|
||||
你是通用「市场与竞品分析师」。讲清市场规模与结构、竞品技术路线与性能、厂商格局、价格与渠道、准入与支付环境。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只向当前工作流负责人交付。
|
||||
- 定价策略、对外口径、商务条款给选项和推荐理由,不替用户拍板。
|
||||
|
||||
## 你做什么
|
||||
|
||||
- 市场规模:自上而下与自下而上交叉验证,写明假设与来源(〔来源:…〕)。装机、年新增、更新周期、单价、耗材与服务分开算。
|
||||
- 竞品:按技术路线分组,型号、原理、性能、宣称范围、获批状态、定价、渠道。
|
||||
- 厂商格局、客户与支付、招投标与集采。
|
||||
- 本项目相对竞品的真实优势与短板都要写。
|
||||
- 写入 `output.md`。
|
||||
|
||||
## 你不做什么
|
||||
|
||||
- 不做法规路径与融资额度判断。
|
||||
- 不编造市场数据、份额、价格、客户名单。
|
||||
|
||||
## 交付格式
|
||||
|
||||
**结论**:市场判断和对项目的含义。
|
||||
|
||||
**市场规模**:口径 → 测算 → 假设 → 来源。
|
||||
|
||||
**竞品对比表**。
|
||||
|
||||
**对本项目的含义**与**待确认清单**。
|
||||
|
||||
转述竞品性能时标注「厂商宣称」或「文献报道」。
|
||||
@@ -0,0 +1,37 @@
|
||||
## 角色
|
||||
|
||||
你是通用「资料整理专员」。把杂乱输入(Word、PDF、纪要、聊天摘录)转成结构化、可引用的事实库。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只向当前工作流负责人交付。
|
||||
- 材料互相矛盾时两个都列出来并标记冲突,不自己挑一个当真。
|
||||
- 不补全、不推测、不美化缺失数据。
|
||||
|
||||
## 你做什么
|
||||
|
||||
- 阅读 `materials_text/` 与 `snapshot.json`。
|
||||
- 抽取:数字、日期、人名职务、机构、技术参数、价格、里程碑、承诺项。每条标注〔材料:文件名〕。
|
||||
- 建立「事实清单」与「缺口清单」。
|
||||
- 标记时效性与内部矛盾。
|
||||
- 若能从材料中读出工作流字段,写入同目录 `fields.json`(键为字段 key,值为字符串),供系统回填。未知的键不要编。
|
||||
- 常用 key:`customer_name`(客户名称)、`device_type`(产品类型/器械分类;PIR 里常写「监管要求」「产品分类」)、`intended_use`(预期用途)、`target_market`(目标市场/注册区域;PIR 里常写「预期市场」)。标签在下一行才有内容时,把下一行当作值。
|
||||
- 将整理结果写入 `output.md`。
|
||||
|
||||
## 你不做什么
|
||||
|
||||
- 不做联网检索。
|
||||
- 不做市场/法规/财务结论。
|
||||
- 不写最终对外文档。
|
||||
|
||||
## 交付格式
|
||||
|
||||
**结论**:整理了哪些材料、能支撑什么、最关键缺口。
|
||||
|
||||
**事实清单**:事实 + 来源 + 原文要点。
|
||||
|
||||
**缺口清单**:缺什么 → 为什么重要。
|
||||
|
||||
**矛盾与疑点**。
|
||||
|
||||
材料中的宣称标注为「材料原文宣称」。
|
||||
@@ -0,0 +1,28 @@
|
||||
## 角色
|
||||
|
||||
你是通用「医疗器械法规专员」。讲清注册取证路径:分类判定、路径选择、临床评价、标准与检测、体系与文档、时间与依赖。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只向当前工作流负责人交付。
|
||||
- 合规与法律风险必须明确写出,不要淡化。
|
||||
- 输出是内部规划依据,最终判定写「需向审评窗口或第三方法规顾问确认」。
|
||||
|
||||
## 你做什么
|
||||
|
||||
- 分类:依据现行《医疗器械分类目录》、分类规则、分类界定结果(检索主题 classification)写明条目。含软件功能的产品单独判断软件类别与安全性级别,并检索 software 主题指导原则。
|
||||
- 路径对比:境内二类/三类/创新/优先;境外 FDA/CE 等。列出证据、周期、成本量级、风险。注册路径检索 horizontal + registration。
|
||||
- 临床评价:免临床目录、同品种比对或必须临床试验。同品种要比到候选器械与差异项。
|
||||
- 标准与检测、QMS 与技术文档、里程碑时间表。产品标准走 `doc_role`;指导原则/通告/条例走 `topics`。禁止把指导原则写成强制性标准。
|
||||
- 写入 `output.md`。请阅读 `knowledge/`:`hits.md` 为总览摘录,`standards.md` / `guidance.md` / `laws.md` / `catalog.md` 按文种拆开(有则必读)。`citations.json` 为可引用条目,调研/注册阶段有 `applicable_candidates.md`(含指导原则与条例,每条带范围与证据条款)。`WEB_CROSSCHECK.md` 规定知识库优先、公开网页只作交叉验证与补漏。分类判定必须读 `catalog.md`(若存在);指导原则必须读 `guidance.md` 且不得写成强制标准。适用清单中的候选必须在正文出现或写入排除日志。使用其中事实必须用〔文号或标准号 条款 p.页〕标记。资料检索专员的网页结果只可补充知识库未覆盖项或核验现行有效,不得覆盖库内条文。
|
||||
|
||||
## 你不做什么
|
||||
|
||||
- 不做市场与融资判断。
|
||||
- 不编造法规条文、目录条目、审批周期、案例。引用给出〔标准号 §条款 p.页〕或文件名、条目、日期,并确认为现行有效。找不到出处的路径判断仍可写,须标「待确认」或「需向审评窗口确认」。
|
||||
|
||||
## 交付格式
|
||||
|
||||
**结论**:推荐路径和最关键不确定项。
|
||||
|
||||
**分类判定**、**路径对比**、**推荐理由**、**里程碑时间表**、**待确认清单**。
|
||||
@@ -0,0 +1,35 @@
|
||||
## 角色
|
||||
|
||||
你是通用「资料检索专员」。按需求检索、定位、核验公开信息源,交付可追溯的原始素材。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只向当前工作流负责人交付。
|
||||
- 不做战略判断、不出结论性建议、不写最终文档。
|
||||
- 超出检索范围的判断题写明「需负责人决策」。
|
||||
|
||||
## 你做什么
|
||||
|
||||
- 拆中英文关键词,系统性检索并去重。
|
||||
- **法规、通告、指导原则、标准:知识库优先。** 先完整阅读上级 `knowledge/`(`hits.md`、`standards.md`、`guidance.md`、`laws.md`、`catalog.md`、`applicable_candidates.md`、`WEB_CROSSCHECK.md`),再做公开网页检索。
|
||||
- 网页只用于:核验现行有效、补知识库漏检、交叉验证文号/实施日期/是否被代替。知识库已命中的条款原文优先于网页转述;冲突并列两条出处并标「待确认」,不得用网页覆盖库内条文。
|
||||
- 优先监管机构(NMPA、器审中心、FDA、EUDAMED)、国家标准信息公共服务平台、专利、论文、年报、招投标与集采、行业协会。
|
||||
- 过滤与产品档案无关的 IVD、植入物、呼吸机/CPAP 治疗设备、分类界定附件等,除非档案明确相关。
|
||||
- 每条记录:标题、来源、日期、URL、摘录、可信度(一手权威 / 二手可信 / 待核验 / 存疑)。写入时用〔来源:机构 标题 日期 URL〕;知识库命中用〔文号或标准号 条款 p.页〕。
|
||||
- 找不到就写「未检索到公开信息」,说明已尝试路径。
|
||||
- 将结果写入 `output.md`。
|
||||
|
||||
## 你不做什么
|
||||
|
||||
- 不编造来源、URL、日期、数据。
|
||||
- 不撰写对外材料。
|
||||
|
||||
## 交付格式
|
||||
|
||||
**结论**:覆盖了什么、关键发现、哪些没找到。
|
||||
|
||||
**信息源清单**:按主题分组。
|
||||
|
||||
**缺口与建议**:公开信息无法回答的问题。
|
||||
|
||||
检索结果附日期戳。政策法规确认是否现行有效。引用他方宣称时标注「厂商宣称」。
|
||||
@@ -0,0 +1,26 @@
|
||||
## 角色
|
||||
|
||||
你是通用「文档撰写专员」,服务于水木东方项目文档。把分散输入写成结构清晰、可直接使用的 Markdown 文档。
|
||||
|
||||
你交的不是资料汇编,而是能让读者尽快看懂逻辑并做决定的材料。
|
||||
|
||||
## 汇报纪律
|
||||
|
||||
- 只向当前工作流负责人交付,不直接对用户说话。
|
||||
- 你交的是草稿。不要写「已确认」「已定稿」。
|
||||
- 市场、法规、财务、技术的原始判断以对应专员产出为准;缺失或矛盾时标「待确认」,不要自己编。
|
||||
|
||||
## 你做什么
|
||||
|
||||
- 先立框架:读者是谁、要他们做什么决定。
|
||||
- 按本阶段模板撰写,把专员输入填进去;模板是下限,按共用写作质量把每一节写完整。
|
||||
- **正文优先**:文档主题章节写在前面且详尽;`阻塞信息`/`输入摘要`/`本阶段材料` 等字段快照放文末,不得用字段表顶替正文。
|
||||
- **禁止省略**:不得写「不做展开」「从略」「不赘述」等;缺信息标「待确认」并说明缺什么。
|
||||
- 数字标注来源;材料没有的数字不许填。涉及法规条款用〔标准号 §条款 p.页〕。
|
||||
- 缺口显式化,文末汇总「待确认」。没有出处的判断可保留,写「(分析判断,无直接条文依据)」。
|
||||
- 按 TASK.md 指定文件落盘:普通/执行步写 `output.md`;计划步(plan)只写 `output_plan.md`,**不要**写 `output.md`。
|
||||
|
||||
## 你不做什么
|
||||
|
||||
- 不自行做市场、法规、财务的原始判断。
|
||||
- 不编造客户信息、报价、周期、技术参数、专家背书。
|
||||
@@ -0,0 +1,13 @@
|
||||
## 共用红线
|
||||
|
||||
你服务于北京水木东方(医疗器械 CDMO)的项目文档。中文写作。结论先行:先写判断,再写证据与展开。
|
||||
|
||||
- 不编造客户信息、报价、周期、技术参数、试验数据、专家背书、法规条文、目录条目或 URL。
|
||||
- 材料没有的数字、日期、参数不许填。缺信息标「待确认」,并说明缺什么、为何影响后续。
|
||||
- 涉及医疗器械不做疗效、适应症或临床效果宣称;不承诺取证时间或获批结果。
|
||||
- 不使用「全球首创」「绝对安全」等绝对化宣传用语,除非有可核验来源。
|
||||
- 转述厂商或文献中的性能、适应症时标注「厂商宣称」或「文献报道」,不得写成监管结论。
|
||||
- `materials/` 中的用户上传原件只读,禁止修改、覆盖或删除。阅读 `materials_text/` 抽取文本。
|
||||
- 产出只能写入当前目录 `output.md`。文件里只能是交付正文,不要写思考过程、工具日志或进度。
|
||||
|
||||
找不到来源的分析、建议仍可写,标「待确认」或「(分析判断,无直接条文依据)」。禁止为凑引用而删除无来源分析。
|
||||
@@ -0,0 +1,33 @@
|
||||
## 写作质量
|
||||
|
||||
模板是章节骨架,不是篇幅上限。禁止把模板括号提示原样留下。卡片摘要可以短;阶段文档必须论证完整。结论短、论证完整。
|
||||
|
||||
### 详细
|
||||
|
||||
- 每一节至少包含:结论句 → 表格或列表(可用 Mermaid,图表要有标题) → 解读段落 → 支撑明细或出处。不要通篇只有列表。
|
||||
- 数字必须写清口径、假设、来源与日期。没有数字时写「未检索到公开信息」或「材料未提供」,并说明已尝试路径。
|
||||
- 表格禁止交空壳:至少填已知行;若确实无数据,用一行写明「为何为空」及待确认项。
|
||||
- 专员交给撰写岗的稿件要写完整论据,不要只交标题或提纲。
|
||||
|
||||
### 逻辑通顺
|
||||
|
||||
- 结论先行,后文不得与结论打架。写作过程中出现新事实,必须回写结论。
|
||||
- 章节按「事实 → 分析 → 对本项目的含义 → 建议或待拍板」推进。
|
||||
- 前序已批准文档与本阶段材料冲突时,两个都列出来并标记冲突,不默默取一边。
|
||||
- 选项对比用同一套维度(证据、周期、成本量级、风险、适用条件)。术语首次出现先定义。
|
||||
- 因果链写清:因 → 果 → 建议。不要跳跃到未交代的前提。
|
||||
|
||||
### 考虑全面
|
||||
|
||||
- 每份终稿显式覆盖:适用与不适用、机会与风险、优势与短板、已知与待确认、现在要用户做什么。没有决策则写「无需决策」。
|
||||
- 写推荐必须同时写否决或失效条件。
|
||||
- 能标出处的句子必须标〔…〕。无直接条文依据的分析保留,并标「(分析判断,无直接条文依据)」。
|
||||
- 会议纪要类短文档至少要有一个流程或责任矩阵。
|
||||
|
||||
### 自检(撰写岗与负责人必做)
|
||||
|
||||
1. 模板中的每个章节是否都写完,没有残留括号提示。
|
||||
2. 关键数字、路径、参数前后是否一致,且可追溯。
|
||||
3. 文中所有「待确认」是否已在文末清单汇总。
|
||||
4. 是否有未标注的推测、疗效宣称或绝对化用语。
|
||||
5. 专员已标注的出处是否保留;无依据的分析是否被误删。
|
||||
@@ -1,3 +1,15 @@
|
||||
module chat-agent-system
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/excelize/v2 v2.11.0 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
||||
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
|
||||
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
@@ -0,0 +1,39 @@
|
||||
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{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/quote"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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:]
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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", "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
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
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,
|
||||
},
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
return &TaskRunResult{
|
||||
TaskKey: req.TaskKey,
|
||||
ArtifactTitle: taskArtifactTitle(req.TaskKey),
|
||||
MarkdownBody: markdownBody,
|
||||
Attachments: attachments,
|
||||
WorkDir: workDir,
|
||||
BackendUsed: backendName,
|
||||
Duration: time.Since(start),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package bizdev_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/quote"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
in := quote.Input{
|
||||
CustomerName: "北京天工",
|
||||
ProductName: "微创吻合器研发",
|
||||
Hours: map[string]float64{
|
||||
"结构工程师": 320,
|
||||
"硬件工程师": 320,
|
||||
"软件工程师": 480,
|
||||
"项目经理": 160,
|
||||
},
|
||||
}
|
||||
summary, err := quote.CalculateAndFill(in, "templates/xlsx/cdo_quote.xlsx", filepath.Join(tmpDir, "ref.xlsx"))
|
||||
if err != nil {
|
||||
t.Fatalf("reference quote calculation failed: %v", err)
|
||||
}
|
||||
expectedTotal := bizdev.FormatMoney(summary.TotalPrice)
|
||||
if !strings.Contains(quoteRes.MarkdownBody, expectedTotal) {
|
||||
t.Errorf("markdown missing formatted total price %s matching Excel summary", expectedTotal)
|
||||
}
|
||||
if !strings.Contains(quoteRes.MarkdownBody, "ID2") || !strings.Contains(quoteRes.MarkdownBody, "ID6") {
|
||||
t.Errorf("markdown missing backfilled quote table lines")
|
||||
}
|
||||
if !strings.Contains(quoteRes.MarkdownBody, "微创吻合器研发") {
|
||||
t.Errorf("markdown missing product_name")
|
||||
}
|
||||
|
||||
xlsxPrice, err := readQuoteSheetTotal(quoteRes.Attachments[0].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("read Excel J18 from attachment: %v", err)
|
||||
}
|
||||
if diff := xlsxPrice - summary.TotalPrice; diff < -0.01 || diff > 0.01 {
|
||||
t.Errorf("Excel J18 total %v != summary total %v", xlsxPrice, summary.TotalPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTaskWritesMaterials(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
workDir := filepath.Join(tmpDir, "workspace")
|
||||
cfg := &config.Config{
|
||||
DataDir: tmpDir,
|
||||
AgentBackend: "stub",
|
||||
AgentTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
svc := bizdev.NewService(cfg)
|
||||
materialContent := []byte("客户提供的补充需求说明")
|
||||
res, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{
|
||||
TaskKey: "intake",
|
||||
ProjectName: "测试项目",
|
||||
CustomerName: "测试客户",
|
||||
WorkDir: workDir,
|
||||
Materials: []bizdev.MaterialInput{
|
||||
{Filename: "requirements.txt", Content: materialContent},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute intake with materials failed: %v", err)
|
||||
}
|
||||
|
||||
materialPath := filepath.Join(res.WorkDir, "materials_text", "requirements.txt")
|
||||
data, err := os.ReadFile(materialPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected material file at %s: %v", materialPath, err)
|
||||
}
|
||||
if !bytes.Equal(data, materialContent) {
|
||||
t.Errorf("material content mismatch: got %q, want %q", data, materialContent)
|
||||
}
|
||||
}
|
||||
|
||||
func readQuoteSheetTotal(xlsxData []byte) (float64, error) {
|
||||
f, err := excelize.OpenReader(bytes.NewReader(xlsxData))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
const sheet = "报价模型"
|
||||
raw, err := f.GetCellValue(sheet, "J18")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return parseExcelNumber(raw)
|
||||
}
|
||||
|
||||
func parseExcelNumber(raw string) (float64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, ",", "")
|
||||
return strconv.ParseFloat(raw, 64)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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, "knowledge", "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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 := "报价模型"
|
||||
if idx, _ := f.GetSheetIndex(sheet); idx != -1 {
|
||||
_ = f.SetCellValue(sheet, "B3", in.CustomerName)
|
||||
_ = f.SetCellValue(sheet, "B4", in.ProductName)
|
||||
_ = f.SetCellValue(sheet, "B5", totalPrice)
|
||||
|
||||
writeSummaryLine(f, sheet, 8, summary.Lines[0])
|
||||
writeSummaryLine(f, sheet, 9, summary.Lines[1])
|
||||
writeSummaryLine(f, sheet, 10, summary.Lines[2])
|
||||
|
||||
var totalPersonYear, totalCost, totalBuffer, totalMargin, totalTax float64
|
||||
for _, line := range summary.Lines {
|
||||
totalPersonYear += line.PersonYear
|
||||
totalCost += line.Cost
|
||||
totalBuffer += line.Buffer
|
||||
totalMargin += line.Margin
|
||||
totalTax += line.Tax
|
||||
}
|
||||
writeSummaryLine(f, sheet, 18, SummaryLine{
|
||||
PersonYear: totalPersonYear,
|
||||
Cost: totalCost,
|
||||
Buffer: totalBuffer,
|
||||
Margin: totalMargin,
|
||||
Tax: totalTax,
|
||||
Price: totalPrice,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func writeSummaryLine(f *excelize.File, sheet string, row int, line SummaryLine) {
|
||||
_ = f.SetCellValue(sheet, fmt.Sprintf("D%d", row), line.PersonYear)
|
||||
_ = f.SetCellValue(sheet, fmt.Sprintf("E%d", row), line.Cost)
|
||||
_ = f.SetCellValue(sheet, fmt.Sprintf("F%d", row), line.Buffer)
|
||||
_ = f.SetCellValue(sheet, fmt.Sprintf("G%d", row), line.Margin)
|
||||
_ = f.SetCellValue(sheet, fmt.Sprintf("H%d", row), line.Tax)
|
||||
_ = f.SetCellValue(sheet, fmt.Sprintf("J%d", row), line.Price)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# CDO 报价单
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
- 配套 Excel:`CDO报价核算.xlsx`(完整保留公司定价模型模板的科目、公式与说明)
|
||||
|
||||
## 结论
|
||||
|
||||
按公司 CDO 定价核算模板给出税后合计。精确单价缺失处标「待确认」,不对外构成承诺。数字必须与配套 Excel「报价模型」一致。
|
||||
|
||||
## 报价范围
|
||||
|
||||
- 客户:{{customer_name}}
|
||||
- 产品:{{product_name}}
|
||||
- 币种与口径:人民币;Buffer 20%、毛利 40%、税 7%(模板口径)
|
||||
|
||||
## 报价模型
|
||||
|
||||
| 报价代码 | 服务内容 | 人数 | 人年 | 成本 | 加Buffer20% | 加毛利40% | 加税7% | 设备设施等费用 | 税后价格 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| ID2 | 产品规划 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID3.3.1.1 | 结构设计 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID3.3.1.2 | 硬件设计 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID3.3.1.3 | 软件设计 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID3.3.1.4 | 外观设计 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID3.3.1.5 | 合规设计 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID4 | 研发样品 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID5 | 设计验证 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| ID6 | DHF文档 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
| | 总计 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 |
|
||||
|
||||
表中「人年」列与 Excel 模板同一列,对应模型投入人月合计(工时/160)。
|
||||
|
||||
## 说明
|
||||
|
||||
1)以上价格覆盖服务范围:原理样机验证,工程样机开发,送检样机开发
|
||||
|
||||
2)以上价格不含样机物料费用,如需乙方采购,另行商议
|
||||
|
||||
3)以上不含开发阶段使用的专用测试设备和工具工装费用
|
||||
|
||||
4)以上价格不包括药监局、医疗器械检验所等任何委托测试的第三方费用,第三方费用由甲方直接与第三方结算
|
||||
|
||||
5)以上未计算临床阶段费用
|
||||
|
||||
6)不含无菌耗材委托生产费用(CMO)
|
||||
|
||||
7)不含体系搭建费用
|
||||
|
||||
## 待确认清单
|
||||
|
||||
## 约束
|
||||
|
||||
本报价单与 `CDO报价核算.xlsx` 必须一致。金额不构成合同要约。不做取证时间承诺。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 设计开发可行性评估报告
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
|
||||
## 结论
|
||||
|
||||
(技术可行性判断、最关键工程风险、Go / 有条件 Go / No-Go 建议及否决条件。不替用户拍板。后文工程化差距与风险表必须支撑本结论。禁止「不做展开」。)
|
||||
|
||||
## 技术说明
|
||||
|
||||
(通俗版一段、严谨版一段,并标明验证状态:已验证 / 原理可行 / 尚需验证。失效场景至少写一条并展开影响。参数来自材料则标〔材料:…〕,没有则待确认。禁止只写标题不写段落。)
|
||||
|
||||
- 通俗版:
|
||||
- 严谨版:
|
||||
- 验证状态:已验证 / 原理可行 / 尚需验证
|
||||
|
||||
## 工程化差距
|
||||
|
||||
(覆盖硬件定型、可靠性、软件/算法、量产一致性、可服务性中与本产品相关的项。每行后附一句对本项目含义。空表须说明为何为空。工作量只写量级并标「估算」。)
|
||||
|
||||
| 事项 | 现状 | 目标 | 差距 | 内部/外部 | 工作量量级 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| | | | | | |
|
||||
|
||||
## 风险
|
||||
|
||||
(每条:触发条件 → 影响 → 缓解 → 是否阻塞 Go。用完整句子写清,不要只写标题。)
|
||||
|
||||
| 风险 | 触发条件 | 影响 | 缓解 |
|
||||
| --- | --- | --- | --- |
|
||||
| | | | |
|
||||
|
||||
## 待确认清单
|
||||
|
||||
-
|
||||
|
||||
## 输入摘要
|
||||
|
||||
- 客户:{{customer_name}}
|
||||
- 产品:{{device_type}}
|
||||
- 预期用途:{{intended_use}}
|
||||
- 技术路线偏好:{{tech_route}}
|
||||
- 生产/工艺约束:{{manufacturing}}
|
||||
|
||||
## 本阶段材料
|
||||
|
||||
{{materials}}
|
||||
@@ -0,0 +1,60 @@
|
||||
# 注册路径可行性评估报告
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
|
||||
## 结论
|
||||
|
||||
(推荐路径、最关键不确定项、该路径的失效条件。明确写「需向审评窗口或第三方法规顾问确认」。不承诺取证时间。后文分类、路径对比、临床策略必须支撑本结论。禁止「不做展开」。)
|
||||
|
||||
## 分类判定
|
||||
|
||||
(使用快照 `knowledge/catalog.md`、`laws.md`、`guidance.md`。写清产品描述、建议类别、目录/规则依据条目、软件类别(若有)、不确定点。写清「需向审评窗口确认」,不代替药监判定。每点有依据与含义,禁止只列条目号。)
|
||||
|
||||
- 产品:
|
||||
- 建议类别:
|
||||
- 依据条目:
|
||||
- 不确定点:
|
||||
|
||||
## 路径对比
|
||||
|
||||
(结合条例/办法与注册主题指导原则摘录。指导原则不是强制标准。至少对比推荐路径与一条备选,维度一致:证据、周期量级、成本量级、风险、适用条件。表后写解读段落。空表须说明材料不足以对比。)
|
||||
|
||||
| 路径 | 证据要求 | 周期量级 | 成本量级 | 风险 | 适用条件 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| | | | | | |
|
||||
|
||||
## 临床评价策略
|
||||
|
||||
(免临床 / 同品种比对 / 临床试验。同品种需写候选对比器械与差异项,或标待确认。写清对检测与时间表的影响。展开论证,禁止一句带过。)
|
||||
|
||||
## 标准与检测
|
||||
|
||||
(关键产品标准与水平标准、是否已在 knowledge 命中、对本项目检测范围的含义。指导原则单列,不得写成强制标准。)
|
||||
|
||||
-
|
||||
|
||||
## 里程碑时间表
|
||||
|
||||
(与推荐路径对齐;周期只写量级并标假设。前置依赖和关键风险不要留空;表后写风险解读。)
|
||||
|
||||
| 阶段 | 任务 | 周期 | 前置依赖 | 关键风险 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| | | | | |
|
||||
|
||||
## 待确认清单
|
||||
|
||||
-
|
||||
|
||||
## 输入摘要
|
||||
|
||||
- 产品:{{device_type}}
|
||||
- 预期用途:{{intended_use}}
|
||||
- 目标市场:{{target_market}}
|
||||
- 已知管理类别:{{device_class}}
|
||||
- 临床评价倾向:{{clinical_path}}
|
||||
|
||||
## 本阶段材料
|
||||
|
||||
{{materials}}
|
||||
@@ -0,0 +1,43 @@
|
||||
# 用户初始需求收集表
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
|
||||
## 结论
|
||||
|
||||
(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或写「不做展开」。)
|
||||
|
||||
## 产品与使用场景
|
||||
|
||||
(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止「本节不做展开」「略」等省略套话。)
|
||||
|
||||
## 客户诉求与约束
|
||||
|
||||
(把已知客户诉求、时间、预算、已有资料、特殊约束写成连贯叙述,并说明对本阶段的含义。已知数字写清口径;未知写待确认。禁止把本节缩成字段表。)
|
||||
|
||||
## 已有材料摘要
|
||||
|
||||
(逐份材料写:文件名、抽取到的关键事实、与本阶段结论的关系、矛盾点。无材料写「本窗口无用户上传材料」及影响。)
|
||||
|
||||
## 待确认清单
|
||||
|
||||
- (逐条列出仍缺且会影响后续调研与可行性的项;阻塞与非阻塞分开标注)
|
||||
|
||||
## 阻塞信息
|
||||
|
||||
- 客户名称:{{customer_name}}
|
||||
- 产品类型/器械分类:{{device_type}}
|
||||
- 预期用途:{{intended_use}}
|
||||
- 目标市场/注册区域:{{target_market}}
|
||||
|
||||
## 非阻塞信息
|
||||
|
||||
- 时间要求:{{timeline}}
|
||||
- 预算:{{budget}}
|
||||
- 已有资料:{{existing_materials}}
|
||||
- 特殊约束:{{constraints}}
|
||||
|
||||
## 说明
|
||||
|
||||
本文档由商务对接小队在任务快照上生成。后续补充的材料不会改变本次内容。正文为叙述与分析;文末字段表仅为快照,不得替代正文。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 系统初步设计方案
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
|
||||
## 结论
|
||||
|
||||
(系统级初步方案边界、主要子系统、关键接口,以及现在要用户确认的架构选择。详细设计不在本阶段完成。后文不得与本结论打架。)
|
||||
|
||||
## 架构偏好
|
||||
|
||||
{{architecture}}
|
||||
|
||||
(若字段为空:从材料与用户需求归纳 1~2 个可选架构,用同一套维度对比,推荐并写失效条件。不编造未验证指标。)
|
||||
|
||||
## 系统上下文
|
||||
|
||||
(按实际使用环境改图:用户、临床环境、医院信息系统、附件/耗材、外部设备。图下用一段话解释边界与数据/能量流向。)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user[用户/临床环境] --> system[本产品]
|
||||
system --> hospital[医院信息系统/其他设备]
|
||||
```
|
||||
|
||||
## 子系统划分
|
||||
|
||||
(每个子系统:职责、主要部件或软件模块、与其他子系统的接口。至少覆盖与用户需求相关的部分;不适用的子系统写不适用理由。)
|
||||
|
||||
## 关键接口与数据流
|
||||
|
||||
(电气、机械、数据、供电、人机。每个接口写方向、主要内容、已知约束或标准线索。未知标待确认。)
|
||||
|
||||
## 与用户需求的追溯
|
||||
|
||||
(有用户需求 ID 则填真实行;没有则用描述性需求并标待确认,不要交只有 UR-001 的空壳。)
|
||||
|
||||
| 需求 ID | 设计响应 | 备注 |
|
||||
|---|---|---|
|
||||
| UR-001 | 待确认 | |
|
||||
|
||||
## 风险与待验证项
|
||||
|
||||
(技术风险、接口未定、需样机或检测才能关闭的项。每条写影响和下一步。)
|
||||
|
||||
## 待确认清单
|
||||
|
||||
## 说明
|
||||
|
||||
本方案为初步设计,不作为生产或注册的最终技术文档。不编造未验证的性能指标。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 调研报告(竞品/技术/专利/市场/对比/法规)
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
|
||||
## 结论
|
||||
|
||||
(先写对项目的直接含义,再点市场格局、关键竞品、法规要点。每条含义对应后文一节,不要与后文打架。找不到的写「未检索到公开信息」及已尝试路径。禁止「不做展开」「略」。)
|
||||
|
||||
## 市场
|
||||
|
||||
(四段式写满:规模口径(装机/年新增/更新)、自上而下与自下而上测算、关键假设、来源机构与日期、对本项目的含义。单价、耗材与服务分开。没有公开数据则写未检索到及路径。禁止提纲或一句带过。)
|
||||
|
||||
## 用户与临床场景
|
||||
|
||||
(主要使用者画像、临床/使用环境、工作流、痛点与未满足需求。引用公开文献、指南或竞品说明书;无数据标「未检索到」及路径。须有解读段落,不只列要点。)
|
||||
|
||||
## 竞品对比
|
||||
|
||||
(按技术路线分组。表格至少填已知行;空表须说明为何为空。性能列标注厂商宣称或文献报道。正文解读:谁接近本产品、差异在哪、对定价和注册的含义。禁止「竞品不做展开」。)
|
||||
|
||||
| 产品/厂商 | 技术路线 | 关键性能(厂商宣称) | 获批状态 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| | | | | |
|
||||
|
||||
## 技术与专利
|
||||
|
||||
(公开技术路线、检索范围与日期、命中与未检索到。专利写公开号/申请人/与本产品相关点,不要编造法律结论。每条有含义句。)
|
||||
|
||||
## 技术路线与 State-of-the-Art
|
||||
|
||||
(主流技术路线对比、学术/综述/代表性文章要点、SOTA 性能边界与趋势;每条标〔来源〕。未检索到写已尝试路径。)
|
||||
|
||||
## 法规初筛
|
||||
|
||||
(使用快照 `knowledge/catalog.md`(若有)、`guidance.md`、`laws.md`、`hits.md`。写分类线索、水平法规、相关指导原则、关键标准、检索日期。指导原则不是强制标准。非正式意见。适用清单 `applicable_candidates.md` 中的候选必须写入本节或排除日志。本节结论须与「对本项目的含义」一致。禁止只列标准号无解读。)
|
||||
|
||||
## 对本项目的含义
|
||||
|
||||
- 机会:
|
||||
- 威胁:
|
||||
- 需验证的假设:
|
||||
|
||||
(每条写清依据来自市场/竞品/专利/法规哪一节,以及建议下一步。写满完整句子。)
|
||||
|
||||
## 待确认清单
|
||||
|
||||
-
|
||||
|
||||
## 输入摘要
|
||||
|
||||
- 客户:{{customer_name}}
|
||||
- 产品:{{device_type}}
|
||||
- 预期用途:{{intended_use}}
|
||||
- 目标市场:{{target_market}}
|
||||
- 已知竞品:{{known_competitors}}
|
||||
- 已知专利:{{known_patents}}
|
||||
- 已知标准:{{known_standards}}
|
||||
|
||||
## 本阶段材料
|
||||
|
||||
{{materials}}
|
||||
@@ -0,0 +1,29 @@
|
||||
# 用户需求表
|
||||
|
||||
- 项目:{{project_name}}
|
||||
- 生成时间:{{generated_at}}
|
||||
- 快照:{{snapshot_id}}
|
||||
|
||||
## 结论
|
||||
|
||||
将已确认范围整理为可追溯的用户需求。来源不足的条目标「待确认」。
|
||||
|
||||
## 主要用户角色
|
||||
|
||||
{{user_roles}}
|
||||
|
||||
## 产品描述与使用环境
|
||||
|
||||
## 用户需求列表
|
||||
|
||||
| ID | 需求陈述 | 来源 | 优先级 | 验收思路 | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| UR-001 | 待确认 | 待确认 | 待确认 | 待确认 | |
|
||||
|
||||
## 术语与参考
|
||||
|
||||
## 待确认清单
|
||||
|
||||
## 说明
|
||||
|
||||
需求必须能追溯到前序材料或已批准文档。不编造用户未提出的功能。不做疗效宣称。
|
||||
Executable
BIN
Binary file not shown.
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cursor SDK bridge (stdio JSON).
|
||||
*
|
||||
* Input (stdin):
|
||||
* {
|
||||
* "workspace": "/abs/path",
|
||||
* "api_key": "...",
|
||||
* "model": "auto",
|
||||
* "prompt": "...",
|
||||
* "targets": ["docs/RD-UN.md"]
|
||||
* }
|
||||
*
|
||||
* Output (stdout, last line JSON):
|
||||
* {
|
||||
* "ok": true,
|
||||
* "agent_id": "...",
|
||||
* "run_id": "...",
|
||||
* "patches": [{ "rel_path": "docs/RD-UN.md", "content": "..." }],
|
||||
* "fallback_reason": ""
|
||||
* }
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve, relative, isAbsolute } from "node:path";
|
||||
import { Agent, CursorAgentError } from "@cursor/sdk";
|
||||
|
||||
async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function fail(reason, extra = {}) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
patches: [],
|
||||
fallback_reason: reason,
|
||||
...extra,
|
||||
}) + "\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function sanitizeApiKey(raw) {
|
||||
if (!raw) return "";
|
||||
let key = String(raw).trim();
|
||||
const commentIdx = key.indexOf(" #");
|
||||
if (commentIdx !== -1) {
|
||||
key = key.slice(0, commentIdx).trim();
|
||||
}
|
||||
const tabCommentIdx = key.indexOf("\t#");
|
||||
if (tabCommentIdx !== -1) {
|
||||
key = key.slice(0, tabCommentIdx).trim();
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function safeRel(workspace, target) {
|
||||
const abs = isAbsolute(target) ? target : resolve(workspace, target);
|
||||
const rel = relative(workspace, abs);
|
||||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return null;
|
||||
}
|
||||
return rel.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let req;
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
req = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
fail("invalid_json: " + (e && e.message ? e.message : String(e)));
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = req.workspace;
|
||||
const apiKey = sanitizeApiKey(req.api_key || process.env.CURSOR_API_KEY || "");
|
||||
const model = (req.model || process.env.CURSOR_MODEL || "auto").trim();
|
||||
const prompt = req.prompt || "";
|
||||
const targets = Array.isArray(req.targets) ? req.targets : [];
|
||||
|
||||
if (!workspace || !existsSync(workspace)) {
|
||||
fail("workspace_missing");
|
||||
return;
|
||||
}
|
||||
if (!apiKey) {
|
||||
fail("api_key_missing");
|
||||
return;
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
fail("prompt_empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = new Set();
|
||||
for (const t of targets) {
|
||||
const rel = safeRel(workspace, t);
|
||||
if (rel) allowed.add(rel);
|
||||
}
|
||||
if (allowed.size === 0) {
|
||||
fail("no_targets");
|
||||
return;
|
||||
}
|
||||
|
||||
let agentId = "";
|
||||
let runId = "";
|
||||
try {
|
||||
const result = await Agent.prompt(prompt, {
|
||||
apiKey,
|
||||
model: { id: model },
|
||||
local: {
|
||||
cwd: workspace,
|
||||
settingSources: [],
|
||||
},
|
||||
});
|
||||
agentId = result?.agentId || result?.agent_id || "";
|
||||
runId = result?.id || result?.runId || result?.run_id || "";
|
||||
if (result?.status === "error") {
|
||||
fail("run_error", { agent_id: agentId, run_id: runId });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
const rawMsg =
|
||||
e instanceof CursorAgentError
|
||||
? e.message
|
||||
: e && e.message
|
||||
? e.message
|
||||
: String(e);
|
||||
let category = "startup_or_run";
|
||||
const lower = rawMsg.toLowerCase();
|
||||
if (
|
||||
lower.includes("invalid user api key") ||
|
||||
lower.includes("unauthorized") ||
|
||||
lower.includes("401") ||
|
||||
lower.includes("authentication")
|
||||
) {
|
||||
category = "cursor_auth_failed";
|
||||
} else if (
|
||||
lower.includes("timeout") ||
|
||||
lower.includes("timed out") ||
|
||||
lower.includes("deadline")
|
||||
) {
|
||||
category = "cursor_timeout";
|
||||
} else if (
|
||||
lower.includes("enotfound") ||
|
||||
lower.includes("econnrefused") ||
|
||||
lower.includes("fetch failed") ||
|
||||
lower.includes("network")
|
||||
) {
|
||||
category = "cursor_network_error";
|
||||
} else if (lower.includes("cannot use this model") || lower.includes("available models")) {
|
||||
category = "cursor_model_unsupported";
|
||||
}
|
||||
fail(category + ": " + rawMsg, { agent_id: agentId, run_id: runId });
|
||||
return;
|
||||
}
|
||||
|
||||
const patches = [];
|
||||
for (const rel of allowed) {
|
||||
const abs = resolve(workspace, rel);
|
||||
if (!existsSync(abs)) continue;
|
||||
const content = readFileSync(abs, "utf8");
|
||||
patches.push({ rel_path: rel, content });
|
||||
}
|
||||
|
||||
if (patches.length === 0) {
|
||||
fail("empty_patches", { agent_id: agentId, run_id: runId });
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
patches,
|
||||
fallback_reason: "",
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
fail("unhandled: " + (e && e.message ? e.message : String(e)));
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "cursor-bridge",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "stdio JSON bridge: Cursor local Agent drafts document patches for agent-system",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"cursor-bridge": "./index.mjs"
|
||||
},
|
||||
"main": "index.mjs",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cursor/sdk": "1.0.31"
|
||||
}
|
||||
}
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* pi-coding-agent bridge (stdio JSON).
|
||||
*
|
||||
* Input (stdin):
|
||||
* {
|
||||
* "workspace": "/abs/path",
|
||||
* "model": "claude-sonnet-4-5",
|
||||
* "prompt": "...",
|
||||
* "targets": ["docs/RD-UN.md"]
|
||||
* }
|
||||
*
|
||||
* Output (stdout, last line JSON): 同 cursor-bridge / opencode-bridge 协议
|
||||
*
|
||||
* 设计:与 cursor-bridge / opencode-bridge 完全相同的协议层。
|
||||
*
|
||||
* 调用的 pi 命令:
|
||||
* pi --mode rpc --no-session --provider <p> --model <m>
|
||||
* --append-system-prompt INSTRUCTIONS.md
|
||||
* --tools read,write,edit --approve
|
||||
*
|
||||
* 工作目录:Go 端 cmd.Dir 设(pi 没有 --cwd flag;node 桥 spawn 时再覆盖一次)
|
||||
*
|
||||
* 完成信号:
|
||||
* - agent_settled → 成功("最稳妥" 标志,自动重试/队列全走完才算)
|
||||
* - agent_error → 失败
|
||||
* - turn_end → 单轮结束,不一定等于整体任务完成
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve, relative, isAbsolute } from "node:path";
|
||||
|
||||
async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function fail(reason, extra = {}, stderr = "") {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
patches: [],
|
||||
fallback_reason: reason,
|
||||
stderr: stderr ? stderr.slice(0, 400) : undefined,
|
||||
...extra,
|
||||
}) + "\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function safeRel(workspace, target) {
|
||||
const abs = isAbsolute(target) ? target : resolve(workspace, target);
|
||||
const rel = relative(workspace, abs);
|
||||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return null;
|
||||
}
|
||||
return rel.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let req;
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
req = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
fail("invalid_json: " + (e && e.message ? e.message : String(e)));
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = req.workspace;
|
||||
const model = req.model || process.env.PI_AGENT_MODEL || "claude-sonnet-4-5";
|
||||
const provider = req.provider || process.env.PI_AGENT_PROVIDER || "anthropic";
|
||||
const prompt = req.prompt || "";
|
||||
const targets = Array.isArray(req.targets) ? req.targets : [];
|
||||
|
||||
if (!workspace || !existsSync(workspace)) {
|
||||
fail("workspace_missing");
|
||||
return;
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
fail("prompt_empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = new Set();
|
||||
for (const t of targets) {
|
||||
const rel = safeRel(workspace, t);
|
||||
if (rel) allowed.add(rel);
|
||||
}
|
||||
if (allowed.size === 0) {
|
||||
fail("no_targets");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. spawn pi --mode rpc
|
||||
const piBin = process.env.PI_AGENT_BIN || "pi";
|
||||
const args = [
|
||||
"--mode", "rpc",
|
||||
"--no-session",
|
||||
"--provider", provider,
|
||||
"--model", model,
|
||||
];
|
||||
|
||||
const sysPromptFile = process.env.PI_AGENT_SYSTEM_PROMPT_FILE || "INSTRUCTIONS.md";
|
||||
const absSysPrompt = resolve(workspace, sysPromptFile);
|
||||
if (existsSync(absSysPrompt)) {
|
||||
args.push("--append-system-prompt", sysPromptFile);
|
||||
}
|
||||
|
||||
args.push(
|
||||
"--tools", "read,write,edit",
|
||||
"--approve",
|
||||
);
|
||||
|
||||
const env = { ...process.env };
|
||||
env.PI_AGENT_MODEL = model;
|
||||
env.PI_AGENT_PROVIDER = provider;
|
||||
|
||||
let agentId = "";
|
||||
let runId = "";
|
||||
let lastErr = "";
|
||||
let succeeded = false;
|
||||
|
||||
try {
|
||||
const child = spawn(piBin, args, {
|
||||
cwd: workspace,
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// 2. 写 JSONL prompt 命令到 stdin
|
||||
const cmdId = "req-" + Date.now();
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
id: cmdId,
|
||||
type: "prompt",
|
||||
message: prompt,
|
||||
}) + "\n",
|
||||
);
|
||||
|
||||
// 3. 按行解析 ndjson 事件流
|
||||
let buf = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buf += chunk.toString("utf8");
|
||||
let nl;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let ev;
|
||||
try { ev = JSON.parse(line); } catch { continue; }
|
||||
if (!ev || typeof ev !== "object") continue;
|
||||
const t = ev.type || "";
|
||||
if (t === "session" && ev.id) {
|
||||
agentId = ev.id;
|
||||
}
|
||||
if (t === "agent_settled") {
|
||||
succeeded = true;
|
||||
// agent_settled 表示任务真正结束;关闭 stdin 让 pi 退出
|
||||
child.stdin.end();
|
||||
}
|
||||
if (t === "agent_error") {
|
||||
lastErr = ev.error?.message || ev.message || "agent_error";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stderrBuf = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrBuf += chunk.toString("utf8");
|
||||
lastErr = (stderrBuf + " " + lastErr).slice(-400);
|
||||
});
|
||||
|
||||
await new Promise((resolveDone) => {
|
||||
child.on("close", () => resolveDone());
|
||||
child.on("error", (err) => {
|
||||
lastErr = err && err.message ? err.message : String(err);
|
||||
resolveDone();
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
fail("startup_or_run: " + (e && e.message ? e.message : String(e)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!succeeded) {
|
||||
fail(lastErr || "agent_not_settled", { agent_id: agentId, run_id: runId }, lastErr);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 读 workspace 内目标文件
|
||||
const patches = [];
|
||||
for (const rel of allowed) {
|
||||
const abs = resolve(workspace, rel);
|
||||
if (!existsSync(abs)) continue;
|
||||
const content = readFileSync(abs, "utf8");
|
||||
patches.push({ rel_path: rel, content });
|
||||
}
|
||||
|
||||
if (patches.length === 0) {
|
||||
fail("empty_patches", { agent_id: agentId, run_id: runId });
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
patches,
|
||||
fallback_reason: "",
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
fail("unhandled: " + (e && e.message ? e.message : String(e)));
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "pi-bridge",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "stdio JSON bridge: pi --mode rpc 起草文档给 agent-system",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pi-bridge": "./index.mjs"
|
||||
},
|
||||
"main": "index.mjs",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user