dsh-tool-folder 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/lib/bm25.js +127 -127
- package/lib/embed-remote.js +193 -0
- package/lib/index.js +218 -34
- package/lib/rerank.js +115 -0
- package/lib/semantic.js +273 -102
- package/package.json +56 -48
package/lib/index.js
CHANGED
|
@@ -40,7 +40,20 @@ import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, ser
|
|
|
40
40
|
import { ChainGuard, verdict, isExecTool } from "./chainguard.js";
|
|
41
41
|
import { matchCategory } from "./category.js";
|
|
42
42
|
import { toonifyValue } from "./toonify.js";
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
configureSemantic,
|
|
45
|
+
buildSemanticIndex,
|
|
46
|
+
searchSemantic,
|
|
47
|
+
searchSemanticCached,
|
|
48
|
+
embedQueryCached,
|
|
49
|
+
getCachedQueryVector,
|
|
50
|
+
rrfFuse,
|
|
51
|
+
adaptiveBmWeight,
|
|
52
|
+
DEFAULT_SEM_WEIGHT,
|
|
53
|
+
DEFAULT_BM_WEIGHT,
|
|
54
|
+
} from "./semantic.js";
|
|
55
|
+
import { rerankByBody, toolBodyText } from "./rerank.js";
|
|
56
|
+
import { remoteEmbedState } from "./embed-remote.js";
|
|
44
57
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
45
58
|
|
|
46
59
|
export const name = "tool-folder";
|
|
@@ -77,10 +90,21 @@ export const Config = z.object({
|
|
|
77
90
|
maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
|
|
78
91
|
feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
|
|
79
92
|
aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
|
|
80
|
-
semanticEnabled: z.boolean().default(true).description("
|
|
81
|
-
ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama
|
|
82
|
-
embedModel: z.string().default("bge-m3").description("embedding
|
|
83
|
-
embedTimeoutMs: z.number().min(100).default(
|
|
93
|
+
semanticEnabled: z.boolean().default(true).description("语义检索腿:tools_search 用 BM25+语义 加权 RRF 混合(远程 embedding 优先,失败回落本地 Ollama,再失败纯 BM25)"),
|
|
94
|
+
ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(远程 embedding 不可用时的本地兜底)"),
|
|
95
|
+
embedModel: z.string().default("bge-m3").description("本地 Ollama embedding 模型(兜底腿)"),
|
|
96
|
+
embedTimeoutMs: z.number().min(100).default(12000).description("embedding 请求超时(毫秒)"),
|
|
97
|
+
embedApiBase: z.string().default("https://ark.cn-beijing.volces.com/api/plan/v3").description("远程 embedding 端点(OpenAI 兼容;默认火山方舟)"),
|
|
98
|
+
embedApiModel: z.string().default("doubao-embedding-vision").description("远程 embedding 模型"),
|
|
99
|
+
embedApiKey: z.string().default("").description("远程 embedding key;留空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY"),
|
|
100
|
+
semanticWeight: z.number().min(0).max(20).default(3).description("RRF 中语义腿相对 BM25 的权重:1=等权,3=偏语义(默认),0=纯 BM25"),
|
|
101
|
+
adaptiveBm: z.boolean().default(true).description("自适应 BM25 权重:查询里有真实英文锚点(工具名/server/关键词)时 BM25 权重 1,纯中文查询降到 bmWeight(实测 6/18→11/18)"),
|
|
102
|
+
bmWeight: z.number().min(0).max(5).default(0.25).description("纯中文查询下 BM25 的 RRF 权重(adaptiveBm=false 时固定用这个值)"),
|
|
103
|
+
bodyRerank: z.boolean().default(false).description("正文精排:在召回候选集内用完整描述+参数 schema 重排(借鉴 SkillRouter 两阶段检索)。实测本语料 Hit@1 持平、Hit@3 -1,故默认关"),
|
|
104
|
+
bodyRerankWeight: z.number().min(0).max(1).default(0.35).description("正文精排权重:final=(1-w)·元数据名次 + w·正文覆盖"),
|
|
105
|
+
bodyRerankTopN: z.number().min(1).max(50).default(12).description("参与正文重排的候选数(不扩大候选,只在召回集内重排)"),
|
|
106
|
+
semanticDynamic: z.boolean().default(true).description("语义腿参与每轮自动注入:只用已缓存的查询向量,零额外延迟(首次查询由后台预热)"),
|
|
107
|
+
debugDump: z.string().default("").description("调试:把当前工具语料(名/描述/schema)dump 到该路径,用于离线评测(空=关闭)"),
|
|
84
108
|
disclosureBudget: z.number().min(0).max(200000).default(0)
|
|
85
109
|
.description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
|
|
86
110
|
maxWarmTools: z.number().min(0).max(50).default(0)
|
|
@@ -122,10 +146,21 @@ const DEFAULTS = {
|
|
|
122
146
|
"mcp__open-design": ["设计", "design"],
|
|
123
147
|
"mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
|
|
124
148
|
},
|
|
125
|
-
semanticEnabled: true, //
|
|
149
|
+
semanticEnabled: true, // v0.3.0: 远程优先 → Ollama → BM25 的语义腿
|
|
126
150
|
ollamaBase: "http://127.0.0.1:11434",
|
|
127
151
|
embedModel: "bge-m3",
|
|
128
|
-
embedTimeoutMs:
|
|
152
|
+
embedTimeoutMs: 12000, // v0.3.0: 远程 embedding 首页可能较慢,8s 偏紧
|
|
153
|
+
embedApiBase: "https://ark.cn-beijing.volces.com/api/plan/v3", // v0.3.0 远程语义腿
|
|
154
|
+
embedApiModel: "doubao-embedding-vision",
|
|
155
|
+
embedApiKey: "", // 空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY
|
|
156
|
+
semanticWeight: 3, // v0.3.0: RRF 语义权重(1=等权)
|
|
157
|
+
adaptiveBm: true, // v0.3.0: 纯中文查询自动降 BM25 权重
|
|
158
|
+
bmWeight: 0.25, // v0.3.0: 纯中文查询下的 BM25 权重(实测 1:1 融合只有 8/18)
|
|
159
|
+
bodyRerank: false, // v0.3.0: 实测 Hit@1 持平 / Hit@3 -1,默认关,可显式开
|
|
160
|
+
bodyRerankWeight: 0.35,
|
|
161
|
+
bodyRerankTopN: 12,
|
|
162
|
+
semanticDynamic: true, // v0.3.0: 语义腿参与自动注入(只用缓存查询向量)
|
|
163
|
+
debugDump: "", // v0.3.0: 工具语料 dump(离线评测用);也认 DSH_TOOL_FOLDER_DUMP 环境变量
|
|
129
164
|
disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
|
|
130
165
|
maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
|
|
131
166
|
fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
|
|
@@ -260,21 +295,112 @@ export function apply(ctx, config) {
|
|
|
260
295
|
return;
|
|
261
296
|
}
|
|
262
297
|
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
configureSemantic({
|
|
298
|
+
// 语义腿(v0.3.0):远程 embedding 优先,本地 Ollama 兜底。
|
|
299
|
+
// 索引在**后台**构建(首次约 30-40s,之后磁盘缓存命中 <100ms),
|
|
300
|
+
// 任何热路径都只读"已就绪的索引",绝不阻塞 assemble 或工具调用。
|
|
301
|
+
configureSemantic({
|
|
302
|
+
ollamaBase: cfg.ollamaBase,
|
|
303
|
+
embedModel: cfg.embedModel,
|
|
304
|
+
embedTimeoutMs: cfg.embedTimeoutMs,
|
|
305
|
+
remoteBase: cfg.embedApiBase,
|
|
306
|
+
remoteModel: cfg.embedApiModel,
|
|
307
|
+
remoteKey: cfg.embedApiKey,
|
|
308
|
+
});
|
|
309
|
+
let semIndex = null; // 已就绪的索引 {vectors, available}
|
|
267
310
|
let semIndexPromise = null;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
311
|
+
let semStamp = "";
|
|
312
|
+
|
|
313
|
+
// 文档正文按工具集缓存:toolBodyText 要拼描述 + 全部参数,243 个工具每轮重算
|
|
314
|
+
// 会给 assemble 热路径带来无谓开销。
|
|
315
|
+
let semDocsCache = null;
|
|
316
|
+
let semDocsStamp = "";
|
|
317
|
+
const semanticDocs = () => {
|
|
318
|
+
const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
|
|
319
|
+
if (semDocsCache && semDocsStamp === stamp) return semDocsCache;
|
|
320
|
+
semDocsStamp = stamp;
|
|
321
|
+
semDocsCache = allTools.map((t) => ({ id: t.name, text: toolBodyText(t) }));
|
|
322
|
+
return semDocsCache;
|
|
276
323
|
};
|
|
277
324
|
|
|
325
|
+
/** 触发后台构建(幂等、非阻塞)。工具集变化时按 stamp 重建。 */
|
|
326
|
+
const ensureSemanticIndex = () => {
|
|
327
|
+
if (!cfg.semanticEnabled || allTools.length === 0) return;
|
|
328
|
+
const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
|
|
329
|
+
if (semIndexPromise && stamp === semStamp) return;
|
|
330
|
+
semStamp = stamp;
|
|
331
|
+
semIndexPromise = (async () => {
|
|
332
|
+
const t0 = Date.now();
|
|
333
|
+
const docs = semanticDocs();
|
|
334
|
+
// dump 放在构建之前:语料采集不该依赖 embedding 是否成功
|
|
335
|
+
const dumpPath = cfg.debugDump || process.env.DSH_TOOL_FOLDER_DUMP || "";
|
|
336
|
+
if (dumpPath) {
|
|
337
|
+
try {
|
|
338
|
+
fs.mkdirSync(path.dirname(dumpPath), { recursive: true });
|
|
339
|
+
fs.writeFileSync(
|
|
340
|
+
dumpPath,
|
|
341
|
+
JSON.stringify({ ts: Date.now(), count: allTools.length, tools: allTools }, null, 2),
|
|
342
|
+
"utf8",
|
|
343
|
+
);
|
|
344
|
+
logger.info("debug dump written: %s", dumpPath);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
logger.warn("debug dump failed (%s)", e?.message);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
let built;
|
|
350
|
+
try {
|
|
351
|
+
built = await buildSemanticIndex(docs, cfg.embedTimeoutMs, {
|
|
352
|
+
onProgress: (done, total) => {
|
|
353
|
+
if (done === total || done % 40 === 0) {
|
|
354
|
+
logger.info("semantic index progress %d/%d", done, total);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
} catch (e) {
|
|
359
|
+
logger.warn("semantic index build failed (%s) — BM25 only", e?.message);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const st = remoteEmbedState();
|
|
363
|
+
logger.info(
|
|
364
|
+
"semantic index ready: %d/%d vectors (embedded=%d reused=%d) in %dms | provider=%s",
|
|
365
|
+
built.vectors.size, docs.length, built.embedded, built.reused, Date.now() - t0,
|
|
366
|
+
st.available ? "remote" : "ollama/offline",
|
|
367
|
+
);
|
|
368
|
+
if (!built.available && !built.vectors.size) {
|
|
369
|
+
logger.warn("semantic leg unavailable (no vectors) — tools_search degrades to BM25");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
semIndex = built;
|
|
373
|
+
return built;
|
|
374
|
+
})();
|
|
375
|
+
// 后台跑,别让未处理的 rejection 冒泡
|
|
376
|
+
semIndexPromise.catch?.(() => {});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// 查询向量后台预热(节流 15s):让 semanticDynamic 在下一轮就能用缓存
|
|
380
|
+
let lastPrefetch = 0;
|
|
381
|
+
const prefetchQuery = (query) => {
|
|
382
|
+
if (!cfg.semanticEnabled || !cfg.semanticDynamic || !query) return;
|
|
383
|
+
if (getCachedQueryVector(query)) return;
|
|
384
|
+
const now = Date.now();
|
|
385
|
+
if (now - lastPrefetch < 15_000) return;
|
|
386
|
+
lastPrefetch = now;
|
|
387
|
+
embedQueryCached(query, cfg.embedTimeoutMs).catch(() => {});
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// 首次 assemble 之后自动预热语义索引(allTools 只在 assemble 里才有值,
|
|
391
|
+
// 启动瞬间是空的)。每 15s 探一次,最多 5 分钟;unref 保证不阻止进程退出。
|
|
392
|
+
// 这样第一条 tools_search 之前索引通常已就绪,且不占启动时间。
|
|
393
|
+
let warmTicks = 0;
|
|
394
|
+
const warmTimer = setInterval(() => {
|
|
395
|
+
warmTicks += 1;
|
|
396
|
+
if (semIndex || semIndexPromise || warmTicks > 20) {
|
|
397
|
+
clearInterval(warmTimer);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (allTools.length) ensureSemanticIndex();
|
|
401
|
+
}, 15_000);
|
|
402
|
+
warmTimer.unref?.();
|
|
403
|
+
|
|
278
404
|
const fbPath = feedbackPath(cfg);
|
|
279
405
|
const feedback = loadFeedback(fbPath, logger);
|
|
280
406
|
let index = null;
|
|
@@ -447,6 +573,17 @@ export function apply(ctx, config) {
|
|
|
447
573
|
if (cfg.topK > 0 && index) {
|
|
448
574
|
for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
|
|
449
575
|
}
|
|
576
|
+
// v0.3.0: 语义腿参与自动注入。**只用已缓存的查询向量**——热路径绝不等网络,
|
|
577
|
+
// 所以这里不会拖慢 assemble,也不会撞 maxFoldMs。查询向量由 prefetchQuery
|
|
578
|
+
// 在后台预热(同一条消息的第二轮及以后通常已命中缓存)。
|
|
579
|
+
if (cfg.semanticDynamic && cfg.topK > 0 && semIndex?.available) {
|
|
580
|
+
try {
|
|
581
|
+
const hits = searchSemanticCached(semIndex.vectors, semanticDocs(), query);
|
|
582
|
+
for (const h of hits.slice(0, cfg.topK)) dyn.add(h.id);
|
|
583
|
+
} catch {
|
|
584
|
+
/* 语义腿失败绝不影响折叠 */
|
|
585
|
+
}
|
|
586
|
+
}
|
|
450
587
|
return dyn;
|
|
451
588
|
}
|
|
452
589
|
|
|
@@ -501,7 +638,7 @@ export function apply(ctx, config) {
|
|
|
501
638
|
if (!index) return { query: q, results: [], total: 0 };
|
|
502
639
|
// P0-②: 同 query 内容指纹的结果缓存——重复搜索返回同一结果集(漂移的
|
|
503
640
|
// 最小修)。进程级共享(非单会话隔离);空 query("*" 语义)不走缓存。
|
|
504
|
-
const cacheKey = `${lim}|${q}`;
|
|
641
|
+
const cacheKey = `${lim}|${q}|${indexStamp || ""}`;
|
|
505
642
|
if (q) {
|
|
506
643
|
const cached = searchCache.get(cacheKey);
|
|
507
644
|
if (cached) return cached;
|
|
@@ -515,23 +652,59 @@ export function apply(ctx, config) {
|
|
|
515
652
|
q,
|
|
516
653
|
search(index, eq, lim * 2).map((i) => ({ id: allTools[i].name })),
|
|
517
654
|
);
|
|
655
|
+
const byName = new Map(allTools.map((t) => [t.name, t]));
|
|
518
656
|
let fused = bm25Hits;
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
657
|
+
// 语义腿(v0.3.0):远程优先,加权 RRF。索引在后台构建——这里绝不等待
|
|
658
|
+
// 冷启动(否则一次搜索能卡 30s+),尚未就绪就先返回 BM25 结果,
|
|
659
|
+
// 同时由 ensureSemanticIndex 继续在后台补,下一次搜索自然用上。
|
|
522
660
|
if (cfg.semanticEnabled !== false) {
|
|
523
661
|
try {
|
|
524
|
-
|
|
525
|
-
if (
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
662
|
+
ensureSemanticIndex();
|
|
663
|
+
if (semIndex?.available) {
|
|
664
|
+
const semHits = await searchSemantic(semIndex.vectors, semanticDocs(), eq);
|
|
665
|
+
// 自适应 BM25 权重:纯中文查询下 BM25 只有 CJK bigram 噪声,
|
|
666
|
+
// 降权 0.25;有真实英文锚点(工具名/server/关键词)时保持 1。
|
|
667
|
+
const topTool = bm25Hits[0] ? byName.get(bm25Hits[0].id) : null;
|
|
668
|
+
const bw =
|
|
669
|
+
cfg.adaptiveBm === false
|
|
670
|
+
? cfg.bmWeight
|
|
671
|
+
: adaptiveBmWeight(q, bm25Hits[0]?.id, topTool?.description);
|
|
672
|
+
fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim, {
|
|
673
|
+
semWeight: cfg.semanticWeight,
|
|
674
|
+
bmWeight: bw,
|
|
675
|
+
});
|
|
529
676
|
}
|
|
530
677
|
} catch (e) {
|
|
531
678
|
logger.warn("semantic leg failed (%s) — BM25 only", e?.message);
|
|
532
679
|
}
|
|
533
680
|
}
|
|
534
|
-
|
|
681
|
+
|
|
682
|
+
// v0.3.0 正文精排(借鉴 SkillRouter 两阶段检索):在已召回候选集内用
|
|
683
|
+
// 完整描述 + 参数 schema 重排,**不扩大候选**(技能侧实测扩大候选会
|
|
684
|
+
// 让泛化词把无关项顶上来)。候选之外的 BM25/语义命中保持原序接在后面。
|
|
685
|
+
if (cfg.bodyRerank && fused.length > 1) {
|
|
686
|
+
try {
|
|
687
|
+
const topN = Math.min(cfg.bodyRerankTopN, fused.length);
|
|
688
|
+
const head = fused.slice(0, topN);
|
|
689
|
+
const tail = fused.slice(topN);
|
|
690
|
+
const reranked = rerankByBody(q, head, byName, { weight: cfg.bodyRerankWeight });
|
|
691
|
+
fused = [...reranked.map((r) => ({ id: r.id })), ...tail];
|
|
692
|
+
} catch (e) {
|
|
693
|
+
logger.warn("body rerank failed (%s) — keep fused order", e?.message);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// 精确名/名字串命中永远钉在最前(正文重排也不许把它挤下去)。
|
|
698
|
+
const exact = bm25Hits.filter((h) => {
|
|
699
|
+
const name = String(h.id || "").toLowerCase();
|
|
700
|
+
const needle = q.trim().toLowerCase();
|
|
701
|
+
return needle && (name === needle || name.endsWith(needle) || name.includes(needle));
|
|
702
|
+
});
|
|
703
|
+
if (exact.length) {
|
|
704
|
+
const pinned = new Set(exact.map((h) => h.id));
|
|
705
|
+
fused = [...exact, ...fused.filter((h) => !pinned.has(h.id))];
|
|
706
|
+
}
|
|
707
|
+
|
|
535
708
|
let results = fused.map((h) => {
|
|
536
709
|
const t = byName.get(h.id) || {};
|
|
537
710
|
return {
|
|
@@ -646,11 +819,18 @@ export function apply(ctx, config) {
|
|
|
646
819
|
// Meta tools are exempt from include so a whitelist can never hide the
|
|
647
820
|
// discovery tools themselves (that would break the always-visible loop).
|
|
648
821
|
const isMetaTool = (n) => n === "tools_search" || n === "tools_schema";
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
);
|
|
822
|
+
// v0.3.0: 按名字去重。宿主的组装结果在极端情况下可能重复(同一工具被两个
|
|
823
|
+
// 插件注册),重复项会让 BM25/RRF 把同一 id 的分数算两次——实测能把正确项
|
|
824
|
+
// 从第一挤到第三以外。
|
|
825
|
+
const tools = [];
|
|
826
|
+
const seenToolNames = new Set();
|
|
827
|
+
for (const t of assembledTools) {
|
|
828
|
+
if (isDenied(t?.name)) continue;
|
|
829
|
+
if (!(isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns))) continue;
|
|
830
|
+
if (seenToolNames.has(t.name)) continue;
|
|
831
|
+
seenToolNames.add(t.name);
|
|
832
|
+
tools.push(t);
|
|
833
|
+
}
|
|
654
834
|
if (tools.length === 0) return { ...assembled, tools };
|
|
655
835
|
|
|
656
836
|
const agentId = context?.agent?.id || "default";
|
|
@@ -689,6 +869,10 @@ export function apply(ctx, config) {
|
|
|
689
869
|
}
|
|
690
870
|
|
|
691
871
|
const query = extractQuery(context);
|
|
872
|
+
// v0.3.0:语义索引在后台构建(首次冷启动),查询向量也后台预热。
|
|
873
|
+
// 两处都是 fire-and-forget:assemble 是热路径,绝不因为 embedding 变慢。
|
|
874
|
+
ensureSemanticIndex();
|
|
875
|
+
prefetchQuery(query);
|
|
692
876
|
const dynNames = buildDynamicSet(tools, query);
|
|
693
877
|
|
|
694
878
|
// v0.2.2: warm 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
|
package/lib/rerank.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rerank.js — 正文精排(借鉴 SkillRouter 的「元数据召回 + 正文重排」两阶段)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么工具也需要「正文」:`tools_search` 的召回声只看 name + description,
|
|
5
|
+
* 而真正决定「该用哪个工具」的信息常常在**参数**里。例:
|
|
6
|
+
* mcp__playwright__browser_navigate vs browser_evaluate vs browser_take_screenshot
|
|
7
|
+
* 描述都是 "browser ...",参数才是 url / function / filename 的区别。
|
|
8
|
+
* 再如一堆 `*_search`:query 还是 keyword、有没有 repo/limit,全在参数描述里。
|
|
9
|
+
* L1 压缩还会把描述截到 200 字、参数描述砍到 120 字——被裁掉的正是这部分信号。
|
|
10
|
+
*
|
|
11
|
+
* 做法(与技能侧 v0.4.0 的 deep-search 同构):
|
|
12
|
+
* - 在 BM25/语义召回的**候选集内**重排,绝不扩大候选
|
|
13
|
+
* (技能侧实测:扩大候选会让泛化词把无关项顶上来,Hit@1 从 50% 掉到 25%)
|
|
14
|
+
* - body 打分 = 查询词在正文里的 IDF 覆盖率 + 整句命中加分
|
|
15
|
+
* - 融合:final = (1-w)·元数据名次分 + w·正文分
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { tokenize } from "./bm25.js";
|
|
19
|
+
|
|
20
|
+
const MAX_BODY_CHARS = 4000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 拼出一个工具的「正文」:完整描述(不截断)+ 参数名 + 参数描述 + 枚举 + server。
|
|
24
|
+
* @param {object} tool
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function toolBodyText(tool) {
|
|
28
|
+
if (!tool || typeof tool !== "object") return "";
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (tool.name) parts.push(String(tool.name));
|
|
31
|
+
if (tool.description) parts.push(String(tool.description));
|
|
32
|
+
const schema = tool.parameters || tool.inputSchema || null;
|
|
33
|
+
const props = schema && typeof schema === "object" ? schema.properties : null;
|
|
34
|
+
if (props && typeof props === "object") {
|
|
35
|
+
for (const [k, v] of Object.entries(props)) {
|
|
36
|
+
parts.push(k);
|
|
37
|
+
if (v && typeof v === "object") {
|
|
38
|
+
if (typeof v.title === "string") parts.push(v.title);
|
|
39
|
+
if (typeof v.description === "string") parts.push(v.description);
|
|
40
|
+
if (Array.isArray(v.enum)) parts.push(v.enum.slice(0, 12).join(" "));
|
|
41
|
+
if (v.items && typeof v.items === "object" && typeof v.items.description === "string") {
|
|
42
|
+
parts.push(v.items.description);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(schema.required) && schema.required.length) {
|
|
47
|
+
parts.push(`required ${schema.required.join(" ")}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (tool.server) parts.push(String(tool.server));
|
|
51
|
+
return parts.join(" ").slice(0, MAX_BODY_CHARS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 在候选集内按正文重排。
|
|
56
|
+
*
|
|
57
|
+
* @param {string} query
|
|
58
|
+
* @param {Array<{id: string}>} candidates 已按元数据名次排好序
|
|
59
|
+
* @param {Map<string, object>} byName
|
|
60
|
+
* @param {{weight?: number, bodyText?: Function}} [opts]
|
|
61
|
+
* @returns {Array<{id: string, score: number, metaRank: number, bodyScore: number}>}
|
|
62
|
+
*/
|
|
63
|
+
export function rerankByBody(query, candidates, byName, opts = {}) {
|
|
64
|
+
const list = Array.isArray(candidates) ? candidates.filter((c) => c && c.id) : [];
|
|
65
|
+
if (!list.length) return [];
|
|
66
|
+
const w = Number(opts.weight);
|
|
67
|
+
const weight = Number.isFinite(w) && w >= 0 && w <= 1 ? w : 0.35;
|
|
68
|
+
const bodyOf = typeof opts.bodyText === "function" ? opts.bodyText : toolBodyText;
|
|
69
|
+
|
|
70
|
+
const q = String(query || "").trim();
|
|
71
|
+
const qTokens = [...new Set(tokenize(q))];
|
|
72
|
+
const qLower = q.toLowerCase().replace(/\s+/g, " ");
|
|
73
|
+
|
|
74
|
+
if (!qTokens.length) {
|
|
75
|
+
return list.map((c, i) => ({ id: c.id, score: 1 / (1 + i), metaRank: i, bodyScore: 0 }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 候选集内的 df / idf(小集合上算,不依赖全局语料)
|
|
79
|
+
const bodies = new Map();
|
|
80
|
+
const df = new Map();
|
|
81
|
+
for (const c of list) {
|
|
82
|
+
const tool = byName.get(c.id);
|
|
83
|
+
const text = bodyOf(tool || { name: c.id });
|
|
84
|
+
const tokens = tokenize(text);
|
|
85
|
+
const set = new Set(tokens);
|
|
86
|
+
bodies.set(c.id, { text: text.toLowerCase(), tokens: set, len: tokens.length });
|
|
87
|
+
for (const t of set) df.set(t, (df.get(t) || 0) + 1);
|
|
88
|
+
}
|
|
89
|
+
const n = list.length;
|
|
90
|
+
const idf = (t) => Math.log((n - (df.get(t) || 0) + 0.5) / ((df.get(t) || 0) + 0.5) + 1);
|
|
91
|
+
// 分母只算「候选正文里真的出现过」的查询词。否则中文 bigram(工具描述多是英文,
|
|
92
|
+
// bigram 全 df=0)会把分母撑大,正文分被稀释到接近 0,精排等于没生效。
|
|
93
|
+
const matchedQ = qTokens.filter((t) => (df.get(t) || 0) > 0);
|
|
94
|
+
const qIdfSum = (matchedQ.length ? matchedQ.reduce((s, t) => s + idf(t), 0) : 0) || 1;
|
|
95
|
+
|
|
96
|
+
const scored = list.map((c, i) => {
|
|
97
|
+
const b = bodies.get(c.id);
|
|
98
|
+
if (!b) return { id: c.id, score: 1 / (1 + i), metaRank: i, bodyScore: 0 };
|
|
99
|
+
let hit = 0;
|
|
100
|
+
for (const t of qTokens) {
|
|
101
|
+
if (b.tokens.has(t)) hit += idf(t);
|
|
102
|
+
}
|
|
103
|
+
// 覆盖率 0..1,再乘一个「命中密度」修正:短正文命中更可信
|
|
104
|
+
let bodyScore = hit / qIdfSum;
|
|
105
|
+
if (bodyScore > 0 && b.len > 0) {
|
|
106
|
+
bodyScore *= 0.85 + 0.15 * Math.min(1, hit / Math.sqrt(b.len));
|
|
107
|
+
}
|
|
108
|
+
if (qLower.length >= 4 && b.text.includes(qLower)) bodyScore = Math.min(1, bodyScore + 0.15);
|
|
109
|
+
const metaNorm = 1 / (1 + i);
|
|
110
|
+
return { id: c.id, score: (1 - weight) * metaNorm + weight * bodyScore, metaRank: i, bodyScore };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
scored.sort((a, b) => b.score - a.score || a.metaRank - b.metaRank);
|
|
114
|
+
return scored;
|
|
115
|
+
}
|