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/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 { configureSemantic, buildSemanticIndex, searchSemantic, rrfFuse } from "./semantic.js";
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("语义检索腿(本地 Ollama bge-m3):tools_search 用 BM25+语义 RRF 混合,中英跨语言命中"),
81
- ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
82
- embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
83
- embedTimeoutMs: z.number().min(100).default(8000).description("embedding 请求超时(毫秒;Ollama 慢时可调大)"),
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, // P0-1: bge-m3 semantic leg for tools_search (RRF hybrid)
149
+ semanticEnabled: true, // v0.3.0: 远程优先 → Ollama → BM25 的语义腿
126
150
  ollamaBase: "http://127.0.0.1:11434",
127
151
  embedModel: "bge-m3",
128
- embedTimeoutMs: 8000, // P3-2: embedding 请求超时(毫秒)
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
- // Semantic leg (P0-1): point the shared module at our config; the index is
264
- // built lazily on first tools_search call (doc embeddings cached on disk,
265
- // so the ~10s cold start happens once, then it's a cache hit).
266
- configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel, embedTimeoutMs: cfg.embedTimeoutMs });
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
- const getSemanticIndex = () => {
269
- if (!semIndexPromise) {
270
- semIndexPromise = (async () => {
271
- const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
272
- return buildSemanticIndex(docs);
273
- })();
274
- }
275
- return semIndexPromise;
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
- // Semantic leg (P0-1): RRF-fuse BM25 with local bge-m3 similarity.
520
- // Any failure (Ollama down / timeout / cache miss on error) degrades
521
- // to BM25-only — the hybrid must never be slower or worse than today.
657
+ // 语义腿(v0.3.0):远程优先,加权 RRF。索引在后台构建——这里绝不等待
658
+ // 冷启动(否则一次搜索能卡 30s+),尚未就绪就先返回 BM25 结果,
659
+ // 同时由 ensureSemanticIndex 继续在后台补,下一次搜索自然用上。
522
660
  if (cfg.semanticEnabled !== false) {
523
661
  try {
524
- const sem = await getSemanticIndex();
525
- if (sem?.available) {
526
- const docs = allTools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` }));
527
- const semHits = await searchSemantic(sem.vectors, docs, eq);
528
- fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim);
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
- const byName = new Map(allTools.map((t) => [t.name, t]));
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
- const tools = assembledTools.filter(
650
- (t) =>
651
- !isDenied(t?.name) &&
652
- (isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns)),
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
+ }