experience-lookup.js 5.46 KB
/**
 * 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 };