@240xu/dsh-websearch 2.8.1 → 2.8.3
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/README.md +22 -0
- package/lib/backends/exa.js +8 -4
- package/lib/multiquery.js +8 -1
- package/lib/prompting.js +1 -1
- package/lib/provider.js +42 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -253,3 +253,25 @@ v2.7.0 缓存/熔断/历史三项的依据与刻意取舍(含查证过的反
|
|
|
253
253
|
## License | 许可
|
|
254
254
|
|
|
255
255
|
MIT © 2026 240xu
|
|
256
|
+
|
|
257
|
+
## 2.8.2 · Bug 猎场修复(P1×3 + 回归测试)
|
|
258
|
+
|
|
259
|
+
- **[P1] 零结果伪造失败**:所有后端成功但 0 命中时,原实现落进 `all enabled
|
|
260
|
+
backends failed (0) - `(计数自相矛盾、detail 空)并丢弃 mergedContent(Tavily
|
|
261
|
+
直答)。成功即空 → 正常返回 `{sources:[]}` + history 记录。
|
|
262
|
+
- **[P1] limiter 死锁**:`Promise.resolve(fn())` 先执行 fn——同步抛出(如 baseURL
|
|
263
|
+
校验在建 timeout 前)逃出后 `running` 永不递减,泄漏 ≥ concurrency 后所有任务
|
|
264
|
+
永久排队且超时机制未武装(实测 HUNG exit=13)。改 `Promise.resolve().then(fn)`。
|
|
265
|
+
- **[P1] exa category 漏门控**:tavily/searxng 的 deepCoverage 增强都门控,唯独
|
|
266
|
+
exa 漏了——默认关 deepCoverage 也注入 category 收窄结果,与 README 承诺相反。
|
|
267
|
+
- 回归测试 3 条(零结果/limiter 不死锁带 HUNG 超时护栏/exa 双态门控);
|
|
268
|
+
`createLimiter` 导出供测试。89/89。
|
|
269
|
+
|
|
270
|
+
## 2.8.3 · Bug 猎场修复第二批(P2×3 + 测试)
|
|
271
|
+
|
|
272
|
+
- **[P2] 整形空查询早拒**:寒暄独词("搜索"/"search"/"查询")被剥空后不再扇出、
|
|
273
|
+
不入缓存(原来空串共享缓存键 + 落入 all-backends-failed(0) 误导错误);
|
|
274
|
+
ZH 寒暄表补裸「搜索」(原 `搜索一下?` 要求带“一”漏掉裸词)。
|
|
275
|
+
- **[P2] CJK 分隔符词内保护**:和/比较/对比 须**任一侧**有空白/标点边界才切——
|
|
276
|
+
词内裸和(柔和光线/和平精英)不再切断实体;单侧空格的真分隔("sftp 和面板")照切。
|
|
277
|
+
- 回归测试 ×4(零结果/limiter 死锁/exa 门控/CJK 保护/空查询拒),91/91。
|
package/lib/backends/exa.js
CHANGED
|
@@ -42,10 +42,14 @@ export const exaBackend = {
|
|
|
42
42
|
query: request.query,
|
|
43
43
|
numResults: request.maxResults ?? 8,
|
|
44
44
|
};
|
|
45
|
-
// v2.8.0: category inference
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
// v2.8.0: category inference — deepCoverage-gated like tavily (searchDepth
|
|
46
|
+
// advanced) and searxng (category sweep): 2.8.1 审计发现此处漏了门控,
|
|
47
|
+
// 默认关着 deepCoverage 也注入 category,把结果悄悄收窄到单类目,与 README
|
|
48
|
+
// 「deepCoverage 深度覆盖」承诺相反。
|
|
49
|
+
if (request.deepCoverage === true) {
|
|
50
|
+
const category = inferExaCategory(request.query);
|
|
51
|
+
if (category) args.category = category;
|
|
52
|
+
}
|
|
49
53
|
hooks.recordRequest?.({
|
|
50
54
|
endpoint: EXA_ENDPOINT,
|
|
51
55
|
toolName: EXA_TOOL,
|
package/lib/multiquery.js
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
// Separators per the v2.8 spec: "vs", "and", 比较, 对比, 和 — plus sentence
|
|
10
10
|
// punctuation for the long-query case.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// 2.8.2(P2):CJK 分隔符必须两侧有空白才切——裸 "和"(无空格)常在词内
|
|
13
|
+
// (柔和/和平/和尚),原无边界写法会把实体拦腰切断
|
|
14
|
+
// ("柔和光线和自然光" → 变体丢 "柔和";"和平精英" → 切出 "平精英")。
|
|
15
|
+
// 显式空格分隔的 "A 和 B" 与顿号/标点分隔仍正常切。
|
|
16
|
+
// 2.8.2(P2 定稿):CJK 分隔符须**任一侧**有空白/标点边界才切——词内裸和
|
|
17
|
+
// (柔和/和平/和尚,两侧都无空白)不切,保住实体;"sftp 和面板"(单侧空格)
|
|
18
|
+
// 这类真分隔照切。
|
|
19
|
+
const SEPARATOR_SPLIT_RE = /\s+(?:vs\.?|and)\s+|\s+(?:比较|对比|和)|(?:比较|对比|和)\s+|\s*[、;;。!?!?|]+\s*/i;
|
|
13
20
|
const COMPLEX_MARK_RE = /(?:\bvs\.?\b|\band\b|比较|对比|和)/i;
|
|
14
21
|
|
|
15
22
|
/** Sub-query hard cap (original + 2 variants max). */
|
package/lib/prompting.js
CHANGED
|
@@ -30,7 +30,7 @@ export const QUERY_MAX_CHARS = 1500;
|
|
|
30
30
|
const FILLER_PREFIX_RE =
|
|
31
31
|
/^(?:please|pls|kindly|could you(?: please)?|can you(?: please)?|help me(?: to)?|search for|search|look up|google|find me|find|i(?:'d| would)? like to know|tell me|what is|who is|when is|where is|"|「|『)\s*/i;
|
|
32
32
|
const FILLER_PREFIX_RE_ZH =
|
|
33
|
-
/^(
|
|
33
|
+
/^(?:请|麻烦|帮我?|帮忙|帮我查一?下|查一?下|查询|搜索一?下?|搜一下?|查一下?|帮我找|找一下?|我想知道|请问)/;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* 确定性查询整形:剥离口语前缀(循环多次以处理叠加寒暄)、压缩空白、
|
package/lib/provider.js
CHANGED
|
@@ -82,7 +82,8 @@ export function getBackendBreaker(id) {
|
|
|
82
82
|
* Simple concurrency limiter using a semaphore pattern.
|
|
83
83
|
* No external dependency needed.
|
|
84
84
|
*/
|
|
85
|
-
|
|
85
|
+
// 2.8.2: exported for P1 regression tests (limiter sync-throw deadlock)
|
|
86
|
+
export function createLimiter(concurrency) {
|
|
86
87
|
const queue = [];
|
|
87
88
|
let running = 0;
|
|
88
89
|
|
|
@@ -95,7 +96,12 @@ function createLimiter(concurrency) {
|
|
|
95
96
|
while (running < concurrency && queue.length > 0) {
|
|
96
97
|
const { fn, resolve, reject } = queue.shift();
|
|
97
98
|
running++;
|
|
98
|
-
|
|
99
|
+
// 2.8.2(P1):必须 .then(fn) 而非 Promise.resolve(fn())——后者先执行
|
|
100
|
+
// fn(),同步抛出(如 runBackend 的 baseURL 校验在创建 timeout 之前 throw)
|
|
101
|
+
// 会逃出本函数:running 已 ++ 却永不 --(finally 没挂上),leaked 计数
|
|
102
|
+
// ≥ concurrency 后所有后续任务永久排队,且 timeoutId 未创建 → 超时也救不了
|
|
103
|
+
// (实测全挂起 exit=13)。.then(fn) 把同步抛出转为 rejection 落进 finally。
|
|
104
|
+
Promise.resolve().then(fn).then(resolve).catch(reject).finally(() => {
|
|
99
105
|
running--;
|
|
100
106
|
process();
|
|
101
107
|
});
|
|
@@ -190,6 +196,15 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends, cac
|
|
|
190
196
|
// 确定性归一 —— 剥离寒暄前缀、压缩空白、截断到 1500 字符;
|
|
191
197
|
// 专名/版本号/日期原样保留,不改写语义。
|
|
192
198
|
effReq.query = shapeQuery(effReq.query);
|
|
199
|
+
// 2.8.2(P2):整形后为空("搜索"/"search" 等寒暄独词被剥光)→ 早拒。
|
|
200
|
+
// 不拒会:①空串扇出全部后端 ②不同原始查询共享 "" 缓存键(别名污染)
|
|
201
|
+
// ③落入 all-backends-failed(0) 的误导错误。
|
|
202
|
+
if (!effReq.query) {
|
|
203
|
+
throw new WebError(
|
|
204
|
+
"unified search: empty query after shaping (the request contained only fillers like \"search\")",
|
|
205
|
+
"WEB_PROVIDER_ERROR",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
193
208
|
if (Object.keys(filters).length > 0) effReq = { ...effReq, filters };
|
|
194
209
|
const maxResults = effReq.maxResults;
|
|
195
210
|
const concurrency = opts.concurrency ?? 6;
|
|
@@ -435,6 +450,31 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends, cac
|
|
|
435
450
|
};
|
|
436
451
|
}
|
|
437
452
|
|
|
453
|
+
// 2.8.2(P1):零结果 ≠ 全部失败。所有后端成功但确实 0 条命中(冷僻查询/
|
|
454
|
+
// 过滤后为空)时,原实现无条件落入下面的 throw,模型端拿到
|
|
455
|
+
// `all enabled backends failed (0) - `(detail 空、计数自相矛盾),且丢弃已
|
|
456
|
+
// 拿到的 mergedContent(如 Tavily 直答)。成功即空 → 正常返回空结果。
|
|
457
|
+
if (allFailures.length === 0) {
|
|
458
|
+
let emptyContent = perVariant.find((v) => v.mergedContent !== undefined)?.mergedContent;
|
|
459
|
+
if (opts.resultHeader !== false && emptyContent !== undefined) {
|
|
460
|
+
const header = resultHeader(typeof request?.query === "string" && /[\u4e00-\u9fff]/.test(request.query) ? "zh" : "en");
|
|
461
|
+
emptyContent = header + "\n\n" + emptyContent;
|
|
462
|
+
}
|
|
463
|
+
history?.record({
|
|
464
|
+
query: request?.query,
|
|
465
|
+
time: Date.now(),
|
|
466
|
+
resultCount: 0,
|
|
467
|
+
backendsOk: allTelemetry.filter((t) => t.ok).length,
|
|
468
|
+
backendsTotal: allTelemetry.length,
|
|
469
|
+
});
|
|
470
|
+
if (cacheKey && cache) cache.set(cacheKey, { sources: [], content: emptyContent, truncated: false });
|
|
471
|
+
return {
|
|
472
|
+
sources: [],
|
|
473
|
+
...(emptyContent !== undefined ? { content: emptyContent } : {}),
|
|
474
|
+
truncated: false,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
438
478
|
// All enabled backends failed - synthesize one meaningful error that
|
|
439
479
|
// names each backend's own reason so the caller (model or user) can act.
|
|
440
480
|
const detail = allFailures.slice(0, 4)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@240xu/dsh-websearch",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.3",
|
|
4
4
|
"description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search. 统一网页搜索聚合:11 后端并发扇出、去重合并,零依赖。See CHANGELOG.md for version history.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|