experience-lookup.js 3.86 KB
/**
 * experience-lookup —— 查经验库(46 号文档 Phase 2)。
 *
 * dream(29-DREAM,见 `dream-service.js`)把跨公司经验沉淀到本体根 `memory/experience.md`,
 * 但只是**单向写**——没有任何可查询的索引。这个模块补上"查"这一半,给 subtask-planning 用:
 * 关键词命中就当"经验库有答案",命中为零就是"查不到"(真实信号,不是异常,早期系统 dream
 * 还没攒够料时这是常态)。
 *
 * 查询范围收窄到**这家公司已雇佣的 agent**——他们才是真正会去执行这条 task 的角色,
 * 比空扫全系统本体贴题,也比"历史同类 goal 检索"(本轮不做,需要语义检索基础设施)便宜。
 * 打分用最朴素的关键词重叠计数,不引入任何依赖——"查不到"本身就是有效信号,没必要在
 * 一个准确度上限很低的检索层上堆复杂度。
 */
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)近似:把整句中文当一个词只会让任何两句话的重叠率
 * 趋近于零,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[]}}
 */
function queryExperience({ companyId, task, limit = 5 } = {}) {
  const queryTokens = tokenize(`${(task && task.title) || ''} ${(task && task.description) || ''}`);
  if (!companyId || !queryTokens.length) 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 = new Set(tokenize(`${block.title} ${block.body}`));
      const score = queryTokens.reduce((n, w) => n + (blockTokens.has(w) ? 1 : 0), 0);
      if (score > 0) 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 };