diff --git a/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md b/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md index ebc9008..c506c8f 100644 --- a/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md +++ b/docs/todo/system-design/46-mandatory-planning-and-task-decomposition.md @@ -1,6 +1,6 @@ # 46 · Planning 应作为每个任务的默认前置步骤,覆盖三类信息来源 -> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-9.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。**Phase 2 已实现(收窄版,2026-09-02,见「Phase 2 实现记录」)**:经验检索(新查询能力,扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭),单测覆盖,同样还没做真实端到端回归。"历史同类 goal 检索"这条来源本轮明确收窄未做。**Phase 3-5 涉及核心调度引擎范式级改动(任务池认领)+ 全新子任务级预算模型,风险量级不同,本轮不做,另起设计讨论** +> **状态**: 📝 设计中——四个关键决策已拍板。**Phase 0 已完成**(2026-09-01 真实回归验证):链路能收敛、44 号文档修复确认生效,发现的独立缺陷已拆到 [47 号文档](./47-itemized-subtask-materialization-goal-benchmark-fallback.md) 并修复。**Phase 1 代码已实现**(2026-09-02,见「Phase 1 实现记录」):新 agent `subtask-planning` + `enqueueTaskRun` 挂钩(收窄为显式 opt-in)+ 复用 R-9.3 任务级共享索引对接下游,单测覆盖,**但还没做真实端到端回归**(且过程中出过一次真实 CLI 子进程泄漏事故,已修复并加了测试环境防护,见该节)。**Phase 2 已实现且已做真实端到端回归(收窄版,2026-09-02,见「Phase 2 实现记录」)**:经验检索(新查询能力,扫已雇 agent 的 dream 沉淀)+ 按需 deep research(图外调用 org-hunt,默认关闭)。真实回归第一次就跑出一个真缺陷(关键词打分噪音压过信号,导致"经验库有答案"分支误触发)并已修复验证;两条真实任务的分解内容质量都好,但 deep research 分支目前更接近"多问一个角色的第二意见"而非真的做了外部检索(org-hunt 那次调用没有工具调用痕迹),如实记在实现记录里。"历史同类 goal 检索"这条来源本轮明确收窄未做。**Phase 3-5 涉及核心调度引擎范式级改动(任务池认领)+ 全新子任务级预算模型,风险量级不同,本轮不做,另起设计讨论** > **日期**: 2026-09-01 > **触发场景**: 用户复盘 [43 号文档](./43-task-decomposition-granularity.md)(goal 分解粒度)时,追问"为什么本地和线上所有公司的任务看板都没有子任务分解"。核实后发现:不是巧合或配置遗漏,`acceptance_mode='itemized'` 在当前系统里**从未被真正触发并落库过一次**——追问引出了比 43 号文档更大的架构缺口:43 号文档只解决"验收要不要拆细",而用户认为**规划(把一件事拆成第一步/第二步/第三步)本身应该是每个任务默认必经的前置步骤,不是一个可选的、可以被判"不需要"的例外**。 > **格式对齐** `27-requirement.md`/`43-task-decomposition-granularity.md`:原话照搬 + 结构化复述,现状调研另起一节。 @@ -304,6 +304,14 @@ goal 创建 2. **Deep research 复用 org-hunt,但用轻量提示词,不套用它的训练场景三件套契约**(`knowhow_raw.md`/`crawl_config.json`/`seed_data.json` ≥50 条)。原因:那套契约是为 FDE 训练场景校准的,直接套用在"人在等一条 task 入队"这种时间敏感场景会不可控地慢。org-hunt 本身的图节点契约、`deliverable.json` 模板都没动。 3. **Deep research 触发默认关闭**(`ENABLE_DEEP_RESEARCH` 环境变量)。org-hunt 的延迟/成功率在"日常任务规划、时间敏感"场景下从未验证过,且 Phase 1 已经因为一个新触发点疏于隔离出过真事故。这跟 Phase 1 `withSubtaskPlanning` 默认 `false` 是同一个谨慎级别——机制已实现且单测覆盖,但真实调用需要用户手动开关、挑单个任务验证。 -### 遗留:还没做真实端到端回归 +### 真实端到端回归(2026-09-02,已完成) + +用真实测试公司 `p2verify0902`(真实 org graph、真实 goal→task→dispatch)+ `ENABLE_DEEP_RESEARCH=1` 跑了两条真实任务,全程 `ps aux` 确认零残留 CLI 子进程。 + +**发现一个真实缺陷(已修复)**:第一条真实任务(贪吃蛇小游戏)跑完,`reasoning_source` 落成 `"memory"`,但 `experience_refs` 列出的命中全是 `ceo`/`orggraph-planning` 关于"校验失败怎么兜底路由""修复要重新触发验证"这类**跟游戏开发毫无关系**的工程流程经验——查了实际打分才发现根因:原始实现"关键词交集计数、只要 >0 就算命中",一条 100+ 词的任务描述几乎必然跟经验库里的高频工程词汇("验证""检测""流程")偶然重合 1-3 个 bigram,噪音就压过了信号,**直接吃掉了本该触发的调研分支**——这正是 Phase 2 acceptance 标准要求确认的"分支判断正确",第一次真跑就没通过。 + +修法:打分改成"这条经验自己的词里有多少比例被 task 提到"(对经验块词数归一化的 containment ratio,不是对查询词数),阈值(命中 ≥2 词且比例 ≥12%)拿真实噪音案例(约 3%-4%)和合成的真正相关案例(约 16%-32%)校准。补了一条回归测试固定复现这个真事故场景。修完重启服务、用第二条真实任务(Markdown 转 HTML 工具)重跑:`reasoning_source` 正确变成 `"deep_research"`、`experience_refs` 正确为空、`research_used: true`——分支判断修复生效,且日志确认真的走了两轮调用(subtask-planning → org-hunt → subtask-planning)。 + +**两条真实任务的分解内容质量都相当好**:贪吃蛇任务给出"先定义状态 → 渲染循环 → 输入监听 → 移动逻辑 → 吃食物 → 碰撞检测"六步,依赖关系合理;Markdown 工具任务甚至体现出"代码块围栏必须最先扫描保护,否则块内的 `#`/`**`/`[]` 会被后续正则误转换"这种真实的解析器实现顺序知识。这直接回答了 Phase 1/2 一直悬而未决的"内容有没有用"——回答是有用。 -跟 Phase 1 一样,机制通不通已经用注入桩验证(三条 `reasoning_source` 分支、经验命中/未命中、调研开/关/成功/失败都有独立测试覆盖)。**没做的是**:真实开一次 `ENABLE_DEEP_RESEARCH=1`,挑一个真实任务,确认①经验检索真的能从生产环境的 dream 沉淀里查到有用内容(目前多数本体 dream 还没攒够料,可能长期查不到,这是预期状态不是 bug);②org-hunt 用轻量提示词跑起来的真实延迟、真实调研质量;③两轮调用叠加后的真实 token/时间成本。不在本轮自动测试范围内,需要用户后续单独决定何时验证。 +**一个诚实的局限,没有掩盖**:查了 org-hunt 那次调用的日志,全程没有 `l1-web-fetch` 工具调用痕迹——它大概率是直接用自己训练知识回答的,没有真的去查外部资料。轻量提示词只要求"给一段总结",没有强制要求引用可验证来源、没有强制要求真的调用检索工具。这意味着当前的 `"deep_research"` 分支实际提供的价值更接近"多问一个角色的第二意见",不是名副其实的外部调研——如果以后要坐实"真的查了外部资料"这件事,需要在提示词里明确要求工具调用证据(比如强制要求 sources 是真实可验证 URL),这次没有额外去做,留给以后需要更强调研可信度时再补。 diff --git a/server/services/experience-lookup.js b/server/services/experience-lookup.js index cef4feb..fdc37db 100644 --- a/server/services/experience-lookup.js +++ b/server/services/experience-lookup.js @@ -8,8 +8,18 @@ * * 查询范围收窄到**这家公司已雇佣的 agent**——他们才是真正会去执行这条 task 的角色, * 比空扫全系统本体贴题,也比"历史同类 goal 检索"(本轮不做,需要语义检索基础设施)便宜。 - * 打分用最朴素的关键词重叠计数,不引入任何依赖——"查不到"本身就是有效信号,没必要在 - * 一个准确度上限很低的检索层上堆复杂度。 + * + * ── 打分:踩过一次真事故,记下来 ────────────────────────────────────────── + * 第一版用「原始交集计数、只要 >0 就算命中」,真实回归时(46 号文档 Phase 2 实现记录) + * 用一个真实贪吃蛇任务查真实 dream 数据,命中的全是 `ceo`/`orggraph-planning` 关于 + * "校验失败怎么兜底路由""修复要重新触发验证"这类**跟游戏开发毫无关系**的工程流程经验—— + * 命中原因是任务描述长(切出 100+ 个 bigram),跟经验库里的通用工程词汇("验证""检测""流程" + * 这类高频 bigram)偶然重合了 1-3 个,就被当成"经验库有答案",直接压过了本该触发的调研分支。 + * + * 现在改用**"这条经验自己的词里有多少比例被 task 提到"**(对经验块的词数归一化,不是对 + * 查询词数):查询长不该稀释信号,被稀释的应该是"提到了这条经验一星半点"这种弱命中。 + * 阈值(`MIN_CONTAINMENT_RATIO`/`MIN_MATCH_TOKENS`)是拿真实噪音案例(约 3%-4% 命中率)和一个 + * 合成的真正相关案例(约 16%-32% 命中率)校准出来的,不是拍脑袋——细节见该次真实回归记录。 */ const fs = require('fs'); const path = require('path'); @@ -18,6 +28,11 @@ const { CROSS_COMPANY_MEMORY } = require('./dream-service'); const EXPERIENCE_FILE = 'experience.md'; +/** 一条经验至少要命中这么多词,单个偶然重合的 bigram 不算数 */ +const MIN_MATCH_TOKENS = 2; +/** 命中词数 / 这条经验自己的总词数,至少要到这个比例才算"查到了"(校准依据见文件头注释) */ +const MIN_CONTAINMENT_RATIO = 0.12; + /** * 切词。中文没有空格分词,不引入分词依赖(也没这个必要——"查不到"本身就是有效信号), * 用最朴素的字符双字滑窗(bigram)近似:把整句中文当一个词只会让任何两句话的重叠率 @@ -56,10 +71,11 @@ function readExperienceBlocks(agentId) { * @param {{companyId: string, task: object, limit?: number}} ctx * @returns {{matches: Array<{agentId: string, title: string, body: string, score: number}>, * queriedAgents: string[]}} + * `score`:命中词数 / 这条经验自身词数(0-1 之间的比例,不是原始计数——见文件头注释) */ function queryExperience({ companyId, task, limit = 5 } = {}) { - const queryTokens = tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`); - if (!companyId || !queryTokens.length) return { matches: [], queriedAgents: [] }; + const queryTokens = new Set(tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`)); + if (!companyId || !queryTokens.size) return { matches: [], queriedAgents: [] }; let agentIds = []; try { @@ -72,9 +88,11 @@ function queryExperience({ companyId, task, limit = 5 } = {}) { const matches = []; for (const agentId of agentIds) { for (const block of readExperienceBlocks(agentId)) { - const blockTokens = new Set(tokenize(`${block.title} ${block.body}`)); - const score = queryTokens.reduce((n, w) => n + (blockTokens.has(w) ? 1 : 0), 0); - if (score > 0) matches.push({ ...block, score }); + const blockTokens = tokenize(`${block.title} ${block.body}`); + if (!blockTokens.length) continue; + const hit = blockTokens.filter((w) => queryTokens.has(w)).length; + const score = hit / blockTokens.length; + if (hit >= MIN_MATCH_TOKENS && score >= MIN_CONTAINMENT_RATIO) matches.push({ ...block, score }); } } matches.sort((a, b) => b.score - a.score); diff --git a/server/tests/unit/experience-lookup.test.js b/server/tests/unit/experience-lookup.test.js index 8b6321f..d4cd65e 100644 --- a/server/tests/unit/experience-lookup.test.js +++ b/server/tests/unit/experience-lookup.test.js @@ -75,6 +75,25 @@ describe('experience-lookup · queryExperience', () => { if (irrelevant) expect(irrelevant.score).toBeLessThan(matches[0].score); }); + it('长任务描述 + 通用工程词汇的经验只是偶然重合 1-2 个 bigram 时不算命中' + + '(真实回归事故复现:46 号文档 Phase 2 真实端到端验证时,一个贪吃蛇任务把' + + '"验证修复要重新触发流程"这类完全不相关的工程经验当成了"经验库有答案")', () => { + hire(COMPANY, AGENT); + writeExperience(AGENT, [ + { title: '验证修复要重新触发流程看真实产出', body: '不能只信别人汇报,要亲自跑一遍确认修复真的生效' }, + ]); + + const longTask = { + title: '实现命令行贪吃蛇游戏', + description: '用 Node.js 实现一个可以在终端里运行和游玩的贪吃蛇游戏。要求:使用方向键控制蛇的移动' + + '方向;蛇吃到食物后身体变长,并在场景中重新生成新的食物;蛇撞到边界或撞到自己身体时游戏立即' + + '结束,并在结束时显示本局得分。达成判据:在终端执行启动命令后能看到游戏画面并用方向键操作蛇' + + '移动;吃到食物后蛇身变长且得分增加;撞墙或撞到自己身体后游戏停止并打印出最终分数。', + }; + const { matches } = queryExperience({ companyId: COMPANY, task: longTask }); + expect(matches).toEqual([]); + }); + it('没有命中时返回空数组,不是异常', () => { hire(COMPANY, AGENT); writeExperience(AGENT, [{ title: '完全不相关的经验', body: '发工资流程走财务系统' }]);