Commit 87a2cee7a43b59501864ae8aad966840859d321d

Authored by yibo
1 parent 3943354b

fix(task-decomposition): experience-lookup 关键词打分噪音压过信号(真实回归发现)

46 号文档 Phase 2 真实端到端回归(真实公司 p2verify0902 + 真实 goal/task/
Claude CLI)第一次跑就复现一个真缺陷:原始打分用「交集计数、>0 就算命中」,
一条长任务描述几乎必然跟经验库里的高频工程词汇偶然重合几个 bigram,导致
一个贪吃蛇任务被"验证修复要重新触发流程"这类无关的工程经验污染成
reasoning_source=memory,压过了本该触发的 deep research 分支。

改成 containment ratio(命中词数 / 经验块自身词数,对经验块归一化而不是
对查询归一化),阈值用真实噪音案例(~3%-4%)和合成相关案例(~16%-32%)
校准。补回归测试固定复现这次真事故场景。修完重启服务用第二条真实任务
(Markdown 转 HTML 工具)重新验证:reasoning_source 正确变成
deep_research,两轮调用(subtask-planning → org-hunt → subtask-planning)
真实跑通,全程零残留 CLI 子进程。46 号文档补了真实回归记录,包括一个
如实记录的局限:org-hunt 那次调用没有工具调用痕迹,更接近"多问一个角色
的第二意见"而非真的做了外部检索。

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