experience-lookup.test.js 6.39 KB
/**
 * 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('的');
  });
});