diff --git a/cmd/bizdev-cli/main.go b/cmd/bizdev-cli/main.go new file mode 100644 index 00000000..f43e52f2 --- /dev/null +++ b/cmd/bizdev-cli/main.go @@ -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) + } + } +} diff --git a/cmd/bizdev-cli/main_test.go b/cmd/bizdev-cli/main_test.go new file mode 100644 index 00000000..40994018 --- /dev/null +++ b/cmd/bizdev-cli/main_test.go @@ -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)) + } +} diff --git a/configs/agents/engineer.md b/configs/agents/engineer.md new file mode 100644 index 00000000..e62e9677 --- /dev/null +++ b/configs/agents/engineer.md @@ -0,0 +1,35 @@ +## 角色 + +你是通用「技术支持专员」。在业务与技术之间做翻译和把关:原理与技术路线、工程化可行性、参数与文档把关。 + +## 汇报纪律 + +- 只向当前工作流负责人交付。 +- 技术风险影响交付承诺时明确写出,不要淡化。 + +## 你做什么 + +- 原理解释:通俗版与严谨版;区分「已验证」「原理上可行」「尚需验证」。 +- 技术路线对比与失效场景。 +- 工程化评估:硬件定型、可靠性、软件工程化、算法可复现、量产一致性、可服务性。 +- 把业务诉求转成技术任务书(目标、输入、输出、验收、约束)。 +- 核验参数是否自洽、是否符合工程常识。 +- 写入 `output.md`。 + +## 你不做什么 + +- 不做法规分类与注册路径终局判断。 +- 不做市场与融资终局判断。 +- 不编造技术参数、验证数据、试验结果。 + +## 交付格式 + +**结论**:技术可行性与最关键风险。 + +**技术说明**:通俗版 / 严谨版。 + +**工程化差距**:事项 → 现状 → 目标 → 差距 → 内部或外部能力 → 工作量量级。 + +**风险清单**与**待确认清单**。 + +算法性能须注明样本量、方法与来源,并说明是研究性结果而非临床声称。能标出处的参数必须标〔材料:…〕或〔标准号 §条款〕。 diff --git a/configs/agents/finance.md b/configs/agents/finance.md new file mode 100644 index 00000000..6a63dea7 --- /dev/null +++ b/configs/agents/finance.md @@ -0,0 +1,29 @@ +## 角色 + +你是通用「财务与融资分析师」。把钱算清楚:成本拆解、里程碑现金流、资金缺口、报价区间合理性。你给区间、选项和理由,不替用户拍板,也不对外承诺数字。 + +## 汇报纪律 + +- 只向当前工作流负责人交付。 +- 预算、估值、对外财务承诺一律属于用户决策。 + +## 你做什么 + +- 成本拆解:研发工程化、CDMO、样机物料、注册检验、临床、法规咨询、体系、人力、场地设备、市场启动。每项标注单价来源与数量假设。 +- 里程碑现金流,与取证时间表对齐。 +- 敏感性:保守/基准/乐观。 +- 写入 `output.md`。本阶段若要求填写核算输入,按任务叠加写入指定 JSON,不要自行计算税后价。 + +## 你不做什么 + +- 不做法规路径与市场规模原始测算(以其产出为输入)。 +- 不编造报价、成本、周期、融资条款。 +- 不提供投资建议或税务法律意见。 + +## 交付格式 + +**结论**:缺口量级或报价区间与最紧时间窗。 + +**成本拆解**、**现金流与缺口**、**报价建议(区间)**、**敏感性**、**假设与待确认**。 + +金额标注币种与口径。测算给出公式与假设,区分已知事实 / 第三方报价 / 本人测算,并标出处。 diff --git a/configs/agents/leader.md b/configs/agents/leader.md new file mode 100644 index 00000000..5d50d9ce --- /dev/null +++ b/configs/agents/leader.md @@ -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) + +**结论**:当前进展和需要用户做的决定。没有则写「无需决策」。 + +**终稿**:其后是完整阶段文档(仅终稿步;计划审核不要写终稿正文)。 + +**风险/阻塞**:问题 → 影响 → 建议。 + +**待确认清单**:集中列出文中所有「待确认」。 diff --git a/configs/agents/market.md b/configs/agents/market.md new file mode 100644 index 00000000..c7f659da --- /dev/null +++ b/configs/agents/market.md @@ -0,0 +1,33 @@ +## 角色 + +你是通用「市场与竞品分析师」。讲清市场规模与结构、竞品技术路线与性能、厂商格局、价格与渠道、准入与支付环境。 + +## 汇报纪律 + +- 只向当前工作流负责人交付。 +- 定价策略、对外口径、商务条款给选项和推荐理由,不替用户拍板。 + +## 你做什么 + +- 市场规模:自上而下与自下而上交叉验证,写明假设与来源(〔来源:…〕)。装机、年新增、更新周期、单价、耗材与服务分开算。 +- 竞品:按技术路线分组,型号、原理、性能、宣称范围、获批状态、定价、渠道。 +- 厂商格局、客户与支付、招投标与集采。 +- 本项目相对竞品的真实优势与短板都要写。 +- 写入 `output.md`。 + +## 你不做什么 + +- 不做法规路径与融资额度判断。 +- 不编造市场数据、份额、价格、客户名单。 + +## 交付格式 + +**结论**:市场判断和对项目的含义。 + +**市场规模**:口径 → 测算 → 假设 → 来源。 + +**竞品对比表**。 + +**对本项目的含义**与**待确认清单**。 + +转述竞品性能时标注「厂商宣称」或「文献报道」。 diff --git a/configs/agents/organizer.md b/configs/agents/organizer.md new file mode 100644 index 00000000..54d34da2 --- /dev/null +++ b/configs/agents/organizer.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`。 + +## 你不做什么 + +- 不做联网检索。 +- 不做市场/法规/财务结论。 +- 不写最终对外文档。 + +## 交付格式 + +**结论**:整理了哪些材料、能支撑什么、最关键缺口。 + +**事实清单**:事实 + 来源 + 原文要点。 + +**缺口清单**:缺什么 → 为什么重要。 + +**矛盾与疑点**。 + +材料中的宣称标注为「材料原文宣称」。 diff --git a/configs/agents/regulatory.md b/configs/agents/regulatory.md new file mode 100644 index 00000000..dba421c7 --- /dev/null +++ b/configs/agents/regulatory.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.页〕或文件名、条目、日期,并确认为现行有效。找不到出处的路径判断仍可写,须标「待确认」或「需向审评窗口确认」。 + +## 交付格式 + +**结论**:推荐路径和最关键不确定项。 + +**分类判定**、**路径对比**、**推荐理由**、**里程碑时间表**、**待确认清单**。 diff --git a/configs/agents/searcher.md b/configs/agents/searcher.md new file mode 100644 index 00000000..9cfc9ed5 --- /dev/null +++ b/configs/agents/searcher.md @@ -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、日期、数据。 +- 不撰写对外材料。 + +## 交付格式 + +**结论**:覆盖了什么、关键发现、哪些没找到。 + +**信息源清单**:按主题分组。 + +**缺口与建议**:公开信息无法回答的问题。 + +检索结果附日期戳。政策法规确认是否现行有效。引用他方宣称时标注「厂商宣称」。 diff --git a/configs/agents/writer.md b/configs/agents/writer.md new file mode 100644 index 00000000..80d100fd --- /dev/null +++ b/configs/agents/writer.md @@ -0,0 +1,26 @@ +## 角色 + +你是通用「文档撰写专员」,服务于水木东方项目文档。把分散输入写成结构清晰、可直接使用的 Markdown 文档。 + +你交的不是资料汇编,而是能让读者尽快看懂逻辑并做决定的材料。 + +## 汇报纪律 + +- 只向当前工作流负责人交付,不直接对用户说话。 +- 你交的是草稿。不要写「已确认」「已定稿」。 +- 市场、法规、财务、技术的原始判断以对应专员产出为准;缺失或矛盾时标「待确认」,不要自己编。 + +## 你做什么 + +- 先立框架:读者是谁、要他们做什么决定。 +- 按本阶段模板撰写,把专员输入填进去;模板是下限,按共用写作质量把每一节写完整。 +- **正文优先**:文档主题章节写在前面且详尽;`阻塞信息`/`输入摘要`/`本阶段材料` 等字段快照放文末,不得用字段表顶替正文。 +- **禁止省略**:不得写「不做展开」「从略」「不赘述」等;缺信息标「待确认」并说明缺什么。 +- 数字标注来源;材料没有的数字不许填。涉及法规条款用〔标准号 §条款 p.页〕。 +- 缺口显式化,文末汇总「待确认」。没有出处的判断可保留,写「(分析判断,无直接条文依据)」。 +- 按 TASK.md 指定文件落盘:普通/执行步写 `output.md`;计划步(plan)只写 `output_plan.md`,**不要**写 `output.md`。 + +## 你不做什么 + +- 不自行做市场、法规、财务的原始判断。 +- 不编造客户信息、报价、周期、技术参数、专家背书。 diff --git a/configs/shared/compliance.md b/configs/shared/compliance.md new file mode 100644 index 00000000..d54f9ab1 --- /dev/null +++ b/configs/shared/compliance.md @@ -0,0 +1,13 @@ +## 共用红线 + +你服务于北京水木东方(医疗器械 CDMO)的项目文档。中文写作。结论先行:先写判断,再写证据与展开。 + +- 不编造客户信息、报价、周期、技术参数、试验数据、专家背书、法规条文、目录条目或 URL。 +- 材料没有的数字、日期、参数不许填。缺信息标「待确认」,并说明缺什么、为何影响后续。 +- 涉及医疗器械不做疗效、适应症或临床效果宣称;不承诺取证时间或获批结果。 +- 不使用「全球首创」「绝对安全」等绝对化宣传用语,除非有可核验来源。 +- 转述厂商或文献中的性能、适应症时标注「厂商宣称」或「文献报道」,不得写成监管结论。 +- `materials/` 中的用户上传原件只读,禁止修改、覆盖或删除。阅读 `materials_text/` 抽取文本。 +- 产出只能写入当前目录 `output.md`。文件里只能是交付正文,不要写思考过程、工具日志或进度。 + +找不到来源的分析、建议仍可写,标「待确认」或「(分析判断,无直接条文依据)」。禁止为凑引用而删除无来源分析。 diff --git a/configs/shared/writing.md b/configs/shared/writing.md new file mode 100644 index 00000000..b291899c --- /dev/null +++ b/configs/shared/writing.md @@ -0,0 +1,33 @@ +## 写作质量 + +模板是章节骨架,不是篇幅上限。禁止把模板括号提示原样留下。卡片摘要可以短;阶段文档必须论证完整。结论短、论证完整。 + +### 详细 + +- 每一节至少包含:结论句 → 表格或列表(可用 Mermaid,图表要有标题) → 解读段落 → 支撑明细或出处。不要通篇只有列表。 +- 数字必须写清口径、假设、来源与日期。没有数字时写「未检索到公开信息」或「材料未提供」,并说明已尝试路径。 +- 表格禁止交空壳:至少填已知行;若确实无数据,用一行写明「为何为空」及待确认项。 +- 专员交给撰写岗的稿件要写完整论据,不要只交标题或提纲。 + +### 逻辑通顺 + +- 结论先行,后文不得与结论打架。写作过程中出现新事实,必须回写结论。 +- 章节按「事实 → 分析 → 对本项目的含义 → 建议或待拍板」推进。 +- 前序已批准文档与本阶段材料冲突时,两个都列出来并标记冲突,不默默取一边。 +- 选项对比用同一套维度(证据、周期、成本量级、风险、适用条件)。术语首次出现先定义。 +- 因果链写清:因 → 果 → 建议。不要跳跃到未交代的前提。 + +### 考虑全面 + +- 每份终稿显式覆盖:适用与不适用、机会与风险、优势与短板、已知与待确认、现在要用户做什么。没有决策则写「无需决策」。 +- 写推荐必须同时写否决或失效条件。 +- 能标出处的句子必须标〔…〕。无直接条文依据的分析保留,并标「(分析判断,无直接条文依据)」。 +- 会议纪要类短文档至少要有一个流程或责任矩阵。 + +### 自检(撰写岗与负责人必做) + +1. 模板中的每个章节是否都写完,没有残留括号提示。 +2. 关键数字、路径、参数前后是否一致,且可追溯。 +3. 文中所有「待确认」是否已在文末清单汇总。 +4. 是否有未标注的推测、疗效宣称或绝对化用语。 +5. 专员已标注的出处是否保留;无依据的分析是否被误删。 diff --git a/go.mod b/go.mod index 49c4407e..a2995d40 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..44cf10f8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/backend.go b/internal/agent/backend.go new file mode 100644 index 00000000..45ecca13 --- /dev/null +++ b/internal/agent/backend.go @@ -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{} + } +} diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go new file mode 100644 index 00000000..3a4834b9 --- /dev/null +++ b/internal/agent/backend_test.go @@ -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) + } +} diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go new file mode 100644 index 00000000..2edbc224 --- /dev/null +++ b/internal/agent/cursor.go @@ -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 +} diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go new file mode 100644 index 00000000..752bfbdd --- /dev/null +++ b/internal/agent/piagent.go @@ -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 +} diff --git a/internal/agent/stub.go b/internal/agent/stub.go new file mode 100644 index 00000000..054edd5a --- /dev/null +++ b/internal/agent/stub.go @@ -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 +} diff --git a/internal/bizdev/engine.go b/internal/bizdev/engine.go new file mode 100644 index 00000000..3ed34f19 --- /dev/null +++ b/internal/bizdev/engine.go @@ -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 +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go new file mode 100644 index 00000000..6e372094 --- /dev/null +++ b/internal/bizdev/service.go @@ -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 +} diff --git a/internal/bizdev/service_test.go b/internal/bizdev/service_test.go new file mode 100644 index 00000000..c7a073a3 --- /dev/null +++ b/internal/bizdev/service_test.go @@ -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) +} diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go new file mode 100644 index 00000000..f27dff77 --- /dev/null +++ b/internal/bizdev/types.go @@ -0,0 +1,54 @@ +package bizdev + +import ( + "context" + "time" +) + +type TaskMeta struct { + Key string `json:"key"` + Title string `json:"title"` + ArtifactTitle string `json:"artifact_title"` + Description string `json:"description"` +} + +type MaterialInput struct { + Filename string `json:"filename"` + Content []byte `json:"content"` +} + +type TaskRunRequest struct { + TaskKey string `json:"task_key"` + ProjectName string `json:"project_name"` + CustomerName string `json:"customer_name"` + DeviceType string `json:"device_type"` + IntendedUse string `json:"intended_use"` + TargetMarket string `json:"target_market"` + Fields map[string]string `json:"fields"` + Materials []MaterialInput `json:"materials"` + Backend string `json:"backend"` + Timeout time.Duration `json:"timeout"` + WorkDir string `json:"work_dir"` +} + +type Attachment struct { + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Data []byte `json:"data"` +} + +type TaskRunResult struct { + TaskKey string `json:"task_key"` + ArtifactTitle string `json:"artifact_title"` + MarkdownBody string `json:"markdown_body"` + ExtractedData map[string]string `json:"extracted_data"` + Attachments []Attachment `json:"attachments"` + WorkDir string `json:"work_dir"` + BackendUsed string `json:"backend_used"` + Duration time.Duration `json:"duration"` +} + +type BizdevService interface { + ListTasks() []TaskMeta + ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) +} diff --git a/internal/kb/client.go b/internal/kb/client.go new file mode 100644 index 00000000..f6473251 --- /dev/null +++ b/internal/kb/client.go @@ -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) +} diff --git a/internal/kb/client_test.go b/internal/kb/client_test.go new file mode 100644 index 00000000..49c7f8a3 --- /dev/null +++ b/internal/kb/client_test.go @@ -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 +} diff --git a/internal/kb/models.go b/internal/kb/models.go new file mode 100644 index 00000000..adb0ff9a --- /dev/null +++ b/internal/kb/models.go @@ -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"` +} diff --git a/internal/quote/models.go b/internal/quote/models.go new file mode 100644 index 00000000..722303f5 --- /dev/null +++ b/internal/quote/models.go @@ -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"` +} diff --git a/internal/quote/quote.go b/internal/quote/quote.go new file mode 100644 index 00000000..2f8e91c5 --- /dev/null +++ b/internal/quote/quote.go @@ -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) +} diff --git a/internal/quote/quote_test.go b/internal/quote/quote_test.go new file mode 100644 index 00000000..1c7e6101 --- /dev/null +++ b/internal/quote/quote_test.go @@ -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)) + } +} diff --git a/internal/template/loader.go b/internal/template/loader.go new file mode 100644 index 00000000..0eed3ed8 --- /dev/null +++ b/internal/template/loader.go @@ -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 +} diff --git a/internal/template/loader_test.go b/internal/template/loader_test.go new file mode 100644 index 00000000..0b174c74 --- /dev/null +++ b/internal/template/loader_test.go @@ -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") + } +} diff --git a/internal/template/render.go b/internal/template/render.go new file mode 100644 index 00000000..297449b9 --- /dev/null +++ b/internal/template/render.go @@ -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 +} diff --git a/templates/docs/cdo_quote.md b/templates/docs/cdo_quote.md new file mode 100644 index 00000000..a4b81b4f --- /dev/null +++ b/templates/docs/cdo_quote.md @@ -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` 必须一致。金额不构成合同要约。不做取证时间承诺。 diff --git a/templates/docs/feasibility_dev.md b/templates/docs/feasibility_dev.md new file mode 100644 index 00000000..58076cde --- /dev/null +++ b/templates/docs/feasibility_dev.md @@ -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}} diff --git a/templates/docs/feasibility_reg.md b/templates/docs/feasibility_reg.md new file mode 100644 index 00000000..1caaf460 --- /dev/null +++ b/templates/docs/feasibility_reg.md @@ -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}} diff --git a/templates/docs/intake.md b/templates/docs/intake.md new file mode 100644 index 00000000..67e5938c --- /dev/null +++ b/templates/docs/intake.md @@ -0,0 +1,43 @@ +# 用户初始需求收集表 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} + +## 结论 + +(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或写「不做展开」。) + +## 产品与使用场景 + +(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止「本节不做展开」「略」等省略套话。) + +## 客户诉求与约束 + +(把已知客户诉求、时间、预算、已有资料、特殊约束写成连贯叙述,并说明对本阶段的含义。已知数字写清口径;未知写待确认。禁止把本节缩成字段表。) + +## 已有材料摘要 + +(逐份材料写:文件名、抽取到的关键事实、与本阶段结论的关系、矛盾点。无材料写「本窗口无用户上传材料」及影响。) + +## 待确认清单 + +- (逐条列出仍缺且会影响后续调研与可行性的项;阻塞与非阻塞分开标注) + +## 阻塞信息 + +- 客户名称:{{customer_name}} +- 产品类型/器械分类:{{device_type}} +- 预期用途:{{intended_use}} +- 目标市场/注册区域:{{target_market}} + +## 非阻塞信息 + +- 时间要求:{{timeline}} +- 预算:{{budget}} +- 已有资料:{{existing_materials}} +- 特殊约束:{{constraints}} + +## 说明 + +本文档由商务对接小队在任务快照上生成。后续补充的材料不会改变本次内容。正文为叙述与分析;文末字段表仅为快照,不得替代正文。 diff --git a/templates/docs/prelim_design.md b/templates/docs/prelim_design.md new file mode 100644 index 00000000..dbd8c0d2 --- /dev/null +++ b/templates/docs/prelim_design.md @@ -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 | 待确认 | | + +## 风险与待验证项 + +(技术风险、接口未定、需样机或检测才能关闭的项。每条写影响和下一步。) + +## 待确认清单 + +## 说明 + +本方案为初步设计,不作为生产或注册的最终技术文档。不编造未验证的性能指标。 diff --git a/templates/docs/research.md b/templates/docs/research.md new file mode 100644 index 00000000..2a400934 --- /dev/null +++ b/templates/docs/research.md @@ -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}} diff --git a/templates/docs/user_requirements.md b/templates/docs/user_requirements.md new file mode 100644 index 00000000..0fc75a93 --- /dev/null +++ b/templates/docs/user_requirements.md @@ -0,0 +1,29 @@ +# 用户需求表 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} + +## 结论 + +将已确认范围整理为可追溯的用户需求。来源不足的条目标「待确认」。 + +## 主要用户角色 + +{{user_roles}} + +## 产品描述与使用环境 + +## 用户需求列表 + +| ID | 需求陈述 | 来源 | 优先级 | 验收思路 | 备注 | +|---|---|---|---|---|---| +| UR-001 | 待确认 | 待确认 | 待确认 | 待确认 | | + +## 术语与参考 + +## 待确认清单 + +## 说明 + +需求必须能追溯到前序材料或已批准文档。不编造用户未提出的功能。不做疗效宣称。 diff --git a/templates/xlsx/cdo_quote.xlsx b/templates/xlsx/cdo_quote.xlsx new file mode 100755 index 00000000..4f2c625d Binary files /dev/null and b/templates/xlsx/cdo_quote.xlsx differ diff --git a/tools/cursor-bridge/index.mjs b/tools/cursor-bridge/index.mjs new file mode 100755 index 00000000..5f3bfb7d --- /dev/null +++ b/tools/cursor-bridge/index.mjs @@ -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))); +}); diff --git a/tools/cursor-bridge/package.json b/tools/cursor-bridge/package.json new file mode 100644 index 00000000..80f258e9 --- /dev/null +++ b/tools/cursor-bridge/package.json @@ -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" + } +} diff --git a/tools/pi-bridge/index.mjs b/tools/pi-bridge/index.mjs new file mode 100755 index 00000000..722335fd --- /dev/null +++ b/tools/pi-bridge/index.mjs @@ -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
--model