/** * experience-lookup —— 查经验库(46 号文档 Phase 2)。 * * dream(29-DREAM,见 `dream-service.js`)把跨公司经验沉淀到本体根 `memory/experience.md`, * 但只是**单向写**——没有任何可查询的索引。这个模块补上"查"这一半,给 subtask-planning 用: * 关键词命中就当"经验库有答案",命中为零就是"查不到"(真实信号,不是异常,早期系统 dream * 还没攒够料时这是常态)。 * * 查询范围收窄到**这家公司已雇佣的 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'); const locator = require('./agent-locator'); 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)近似:把整句中文当一个词只会让任何两句话的重叠率 * 趋近于零,bigram 至少能在"碰撞检测"vs"碰撞检测踩过的坑"这种真实重叠场景下命中。 * 拉丁字母/数字按非字母数字切分,整段当一个词。单字符(不管中英文)噪音大于信号,丢掉。 */ function tokenize(text) { const raw = String(text || '').toLowerCase(); const tokens = new Set(); for (const w of raw.split(/[^a-z0-9]+/)) { if (w.length > 1) tokens.add(w); } for (const run of raw.match(/[一-龥]+/g) || []) { for (let i = 0; i < run.length - 1; i += 1) tokens.add(run.slice(i, i + 2)); } return Array.from(tokens); } /** * 读一个本体的 `experience.md`,按 `### 标题` 分块(与 `dream-service.auditDistilled` * 同一种切法——两处都在解析 `writeDistilled` 落的同一份格式,切法不能各写一套)。 */ function readExperienceBlocks(agentId) { const file = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, EXPERIENCE_FILE); let text; try { text = fs.readFileSync(file, 'utf-8'); } catch { return []; } return text.split(/\n### /).slice(1).map((block) => { const lines = block.split('\n'); const title = lines[0].trim(); const body = lines.slice(1).join('\n').replace(/^> 提炼于.*$/gm, '').trim(); return { agentId, title, body }; }).filter((b) => b.title); } /** * @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 = new Set(tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`)); if (!companyId || !queryTokens.size) return { matches: [], queriedAgents: [] }; let agentIds = []; try { const { listCompanyAgents } = require('./company-service'); agentIds = [...new Set(listCompanyAgents(companyId).map((r) => r.system_agent_id))]; } catch { return { matches: [], queriedAgents: [] }; } const matches = []; for (const agentId of agentIds) { for (const block of readExperienceBlocks(agentId)) { 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); return { matches: matches.slice(0, limit), queriedAgents: agentIds }; } module.exports = { queryExperience, tokenize, readExperienceBlocks };