/** * 46 号文档 Phase 2:experience-lookup 查经验库。 * * 夹具模式照抄 `dream-service.test.js`:真实磁盘下建一个 `zz-explookup-*` 测试 agentId, * 写 `memory/experience.md`(dream `writeDistilled` 落的同一种格式),`company_agents` * 表插入雇佣关系——测的是"读得到 dream 已经写好的经验"这一半,不是 dream 本身。 */ import { describe, it, expect, beforeEach, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-explookup-')); process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); const ROOT = path.resolve(__dirname, '../../..'); const AGENT = 'zz-explookup-body'; const OTHER_AGENT = 'zz-explookup-other'; const COMPANY = 'zz-explookup-co'; const BODY = path.join(ROOT, 'system', 'agents', AGENT); const OTHER_BODY = path.join(ROOT, 'system', 'agents', OTHER_AGENT); const { queryExperience, tokenize, readExperienceBlocks } = require('../../services/experience-lookup'); const wdb = require('../../services/work-graph-db'); function writeExperience(agentId, blocks) { const dir = path.join(ROOT, 'system', 'agents', agentId, 'memory'); fs.mkdirSync(dir, { recursive: true }); const body = blocks.map((b) => `### ${b.title}\n\n${b.body}\n\n> 提炼于 2026-09-01;原料:x:memory.md#1\n`).join('\n'); fs.writeFileSync(path.join(dir, 'experience.md'), `# 跨公司经验(dream 提炼)\n\n${body}`, 'utf-8'); } function hire(companyId, agentId) { const db = wdb.getDb(); const now = new Date().toISOString(); db.prepare(`INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`) .run(agentId, agentId, `system/agents/${agentId}`, now, now); db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now); } describe('experience-lookup · queryExperience', () => { beforeEach(() => { fs.rmSync(BODY, { recursive: true, force: true }); fs.rmSync(OTHER_BODY, { recursive: true, force: true }); }); afterAll(() => { fs.rmSync(BODY, { recursive: true, force: true }); fs.rmSync(OTHER_BODY, { recursive: true, force: true }); try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {} }); it('命中相关经验,按关键词重叠打分排序', () => { hire(COMPANY, AGENT); writeExperience(AGENT, [ { title: '贪吃蛇碰撞检测踩过的坑', body: '棋盘边界判断要在移动前做,不然会穿墙' }, { title: '完全不相关的经验', body: '发工资流程走财务系统,不归这里管' }, ]); const { matches, queriedAgents } = queryExperience({ companyId: COMPANY, task: { title: '实现贪吃蛇', description: '贪吃蛇游戏的碰撞检测和棋盘边界' }, }); expect(queriedAgents).toContain(AGENT); expect(matches.length).toBeGreaterThan(0); expect(matches[0].title).toBe('贪吃蛇碰撞检测踩过的坑'); expect(matches[0].agentId).toBe(AGENT); expect(matches[0].score).toBeGreaterThan(0); // 不相关的那条不该排在命中结果里靠前的位置(分数应明显更低或不命中) const irrelevant = matches.find((m) => m.title === '完全不相关的经验'); 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: '发工资流程走财务系统' }]); const { matches } = queryExperience({ companyId: COMPANY, task: { title: '实现贪吃蛇', description: '碰撞检测' }, }); expect(matches).toEqual([]); }); it('agent 没有 experience.md 文件时不报错,视为该 agent 无命中', () => { hire(COMPANY, AGENT); // 雇了但从没被 dream 写过经验 expect(() => queryExperience({ companyId: COMPANY, task: { title: '实现贪吃蛇', description: '碰撞检测' }, })).not.toThrow(); expect(readExperienceBlocks(AGENT)).toEqual([]); }); it('公司没有雇任何 agent 时返回空数组', () => { const { matches, queriedAgents } = queryExperience({ companyId: 'zz-explookup-empty-co', task: { title: '实现贪吃蛇', description: '碰撞检测' }, }); expect(matches).toEqual([]); expect(queriedAgents).toEqual([]); }); it('task 标题/描述都为空时返回空数组,不做无意义的全量扫描', () => { hire(COMPANY, AGENT); const { matches } = queryExperience({ companyId: COMPANY, task: {} }); expect(matches).toEqual([]); }); it('tokenize:拉丁词整段切,中文用双字滑窗(无分词依赖),单字符丢弃', () => { const tokens = tokenize('贪吃蛇 game 的 A 实现'); expect(tokens).toEqual(expect.arrayContaining(['贪吃', '吃蛇', 'game', '实现'])); expect(tokens).not.toContain('a'); expect(tokens).not.toContain('的'); }); });