experience-lookup.js
5.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/**
* 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 };