argo-search 2.9.7 → 2.9.9
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.en.md +73 -150
- package/README.es.md +73 -150
- package/README.ja.md +79 -156
- package/README.ko.md +78 -155
- package/README.md +80 -159
- package/SKILL.md +3 -3
- package/assets/readme/hero.svg +2 -2
- package/backends/domain_profiles.json +48 -0
- package/backends/engine_registry.yaml +118 -4
- package/backends/quota_profiles.json +92 -12
- package/backends/source_types_cn.json +27 -1
- package/bin/argo +78 -5
- package/bin/argo.js +33 -2
- package/config.yaml +269 -63
- package/docs/ARGO_INTRO.md +5 -3
- package/docs/ENGINE_CATALOG.md +50 -39
- package/docs/MCP_SETUP.md +3 -1
- package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
- package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
- package/engines/specs/cfets.yaml +41 -0
- package/engines/specs/defillama.yaml +38 -0
- package/engines/specs/firecrawl.yaml +1 -1
- package/engines/specs/fxtwitter.yaml +5 -0
- package/engines/specs/gleif.yaml +55 -0
- package/engines/specs/hkex.yaml +40 -0
- package/engines/specs/keenable_free.yaml +36 -0
- package/engines/specs/shfe.yaml +39 -0
- package/engines/specs/stackexchange.yaml +2 -2
- package/engines/specs/tavily_free.yaml +56 -0
- package/engines/specs/yuanbao.yaml +93 -0
- package/mcp/clients.yaml +32 -0
- package/package.json +7 -2
- package/packages/dsh-plugin/README.md +25 -6
- package/packages/dsh-plugin/dsh/client.js +402 -47
- package/packages/dsh-plugin/dsh/diagnostics.js +97 -0
- package/packages/dsh-plugin/dsh/index.js +135 -1876
- package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +3 -3
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +177 -2
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +44 -0
- package/scripts/adaptive.py +126 -43
- package/scripts/article_meta.py +131 -0
- package/scripts/bench_tail_ab.py +281 -0
- package/scripts/bounded_run.py +91 -49
- package/scripts/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/cache.py +60 -72
- package/scripts/ci_drift_gate.py +90 -0
- package/scripts/clock_bridge.py +22 -0
- package/scripts/content_boundary.py +46 -0
- package/scripts/content_security.py +119 -4
- package/scripts/content_signals.py +26 -5
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/dispatch_result.py +105 -0
- package/scripts/engine_add.py +602 -0
- package/scripts/engine_dispatch.py +58 -86
- package/scripts/engine_env.py +5 -0
- package/scripts/engine_families.py +6 -0
- package/scripts/engine_policy.py +7 -7
- package/scripts/engines.py +21 -0
- package/scripts/engines_base.py +3 -3
- package/scripts/engines_builders.py +12 -0
- package/scripts/engines_builders_batch10.py +48 -3
- package/scripts/engines_builders_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -0
- package/scripts/engines_builders_keyless.py +111 -0
- package/scripts/engines_builders_tech.py +0 -1
- package/scripts/evidence.py +31 -3
- package/scripts/evidence_loop.py +58 -22
- package/scripts/fetch_quality.py +13 -0
- package/scripts/fetch_v3.py +28 -3
- package/scripts/gen_routing_snapshot.py +11 -0
- package/scripts/http_client.py +24 -38
- package/scripts/install.ps1 +18 -0
- package/scripts/install.sh +16 -1
- package/scripts/job.py +3 -3
- package/scripts/local_seek.py +41 -5
- package/scripts/macro_countries.py +15 -0
- package/scripts/matrix_search_eval.py +192 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_handlers.py +60 -642
- package/scripts/mcp_launch.sh +10 -8
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +19 -14
- package/scripts/mcp_setup.py +33 -9
- package/scripts/mcp_tools.py +1 -0
- package/scripts/mcp_transport.py +1 -1
- package/scripts/mcp_warm.py +127 -0
- package/scripts/net_proxy.py +27 -0
- package/scripts/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/quota.py +158 -13
- package/scripts/recovery.py +7 -3
- package/scripts/recovery_runner.py +150 -0
- package/scripts/replay_eval.py +18 -0
- package/scripts/research.py +37 -21
- package/scripts/research_expand.py +24 -1
- package/scripts/retry_policy.py +76 -0
- package/scripts/route.py +6 -6
- package/scripts/route_cache.py +18 -0
- package/scripts/route_combo.py +26 -5
- package/scripts/route_domains.py +170 -31
- package/scripts/safe_conn.py +52 -0
- package/scripts/search.py +15 -27
- package/scripts/search_attribution.py +100 -3
- package/scripts/search_cli.py +24 -11
- package/scripts/search_entry.py +8 -1
- package/scripts/search_output.py +99 -15
- package/scripts/search_pipeline.py +62 -195
- package/scripts/search_rank.py +6 -0
- package/scripts/search_types.py +107 -9
- package/scripts/semantic_evidence.py +179 -3
- package/scripts/serp_guard.py +138 -7
- package/scripts/serp_kill_log.py +25 -2
- package/scripts/stats_cli.py +13 -1
- package/scripts/tail_completion.py +212 -0
- package/scripts/url_safety.py +126 -3
- package/scripts/usage_log.py +45 -0
- package/scripts/wide_research.py +45 -0
- package/scripts/zhihu_quota.py +139 -0
- package/sub-skills/local-search/bm25.py +27 -3
- package/sub-skills/local-search/cli_runner.py +331 -0
- package/sub-skills/local-search/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +31 -674
- package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/sub-skills/ego-search/references/browser-runtime.md +0 -43
- package/sub-skills/ego-search/references/install.md +0 -67
- package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
- package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
- package/sub-skills/js-run/tests/test_jsrun.py +0 -165
- package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
- package/sub-skills/local-seek/references/git-integration.md +0 -54
- package/sub-skills/local-seek/references/remote-handoff.md +0 -46
- package/sub-skills/local-seek/references/rg-recipes.md +0 -96
- package/sub-skills/local-seek/references/strategies.md +0 -52
- package/sub-skills/local-seek/references/structural-search.md +0 -59
- package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
- package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
- package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
- package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
- package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
- package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taxueseek/argo-dsh",
|
|
3
|
-
"version": "2.9.
|
|
4
|
-
"description": "Argo 阿尔戈 — DSH plugin:argo 原生搜索/抓取工具 + web_search seam + wide_research 编排;自带设置页(接入/工具/API 密钥/研究/高级);argo MCP 19 工具全量面按需挂载。安装:dsh plugin --profile web add \"github:taxueseek/argo#main&path:packages/dsh-plugin\"",
|
|
3
|
+
"version": "2.9.9",
|
|
4
|
+
"description": "Argo 阿尔戈 — DSH plugin:argo 原生搜索/抓取工具 + web_search seam + wide_research 编排;自带设置页(接入/工具/API 密钥/研究/高级);argo MCP 19 工具全量面按需挂载。安装:dsh plugin --profile web add @taxueseek/argo-dsh(或从 GitHub main 装:dsh plugin --profile web add \"github:taxueseek/argo#main&path:packages/dsh-plugin\")",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dsh/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"node": ">=18"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"check": "node --check dsh/index.js && node --check dsh/client.js",
|
|
35
|
+
"check": "node --check dsh/index.js && node --check dsh/runner.js && node --check dsh/wide-research.js && node --check dsh/diagnostics.js && node --check dsh/client.js",
|
|
36
36
|
"test": "node --test test/*.test.js"
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# sync-local.sh — 把本仓 packages/dsh-plugin 同步进本机已安装的 DSH profile。
|
|
3
|
+
#
|
|
4
|
+
# 为什么需要:profile 的依赖是 `file:`,pnpm 安装时是**复制**进
|
|
5
|
+
# node_modules,不是软链。改完源码不跑本脚本,DSH 设置页读到的仍是旧
|
|
6
|
+
# bundle(2026-10-22 实测:缓存开关与付费额度都已改,面板仍显示旧版)。
|
|
7
|
+
#
|
|
8
|
+
# 用法:
|
|
9
|
+
# packages/dsh-plugin/scripts/sync-local.sh # 同步 web+desktop(存在才同步)
|
|
10
|
+
# packages/dsh-plugin/scripts/sync-local.sh web # 只同步指定 profile
|
|
11
|
+
# packages/dsh-plugin/scripts/sync-local.sh --check # 只比对,不写;有漂移退出 1
|
|
12
|
+
# packages/dsh-plugin/scripts/sync-local.sh --restart # 同步后重启 DSH web
|
|
13
|
+
#
|
|
14
|
+
# 环境变量:DSH_HOME 覆盖 ~/.dsh;DSH_PROFILES 逗号分隔(默认 web,desktop)。
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
18
|
+
DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
|
|
19
|
+
MODE="sync"
|
|
20
|
+
RESTART=0
|
|
21
|
+
PROFILES=()
|
|
22
|
+
|
|
23
|
+
for arg in "$@"; do
|
|
24
|
+
case "$arg" in
|
|
25
|
+
--check) MODE="check" ;;
|
|
26
|
+
--restart) RESTART=1 ;;
|
|
27
|
+
-h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
|
28
|
+
*) PROFILES+=("$arg") ;;
|
|
29
|
+
esac
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
if [ ${#PROFILES[@]} -eq 0 ]; then
|
|
33
|
+
IFS=',' read -r -a PROFILES <<<"${DSH_PROFILES:-web,desktop}"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
TS="$(date +%Y%m%d-%H%M%S)"
|
|
37
|
+
drift=0
|
|
38
|
+
|
|
39
|
+
for p in "${PROFILES[@]}"; do
|
|
40
|
+
DEST="$DSH_HOME/profiles/$p/node_modules/@taxueseek/argo-dsh"
|
|
41
|
+
if [ ! -d "$DEST" ]; then
|
|
42
|
+
echo "[skip] $p: 未安装 $DEST"
|
|
43
|
+
continue
|
|
44
|
+
fi
|
|
45
|
+
if [ "$MODE" = check ]; then
|
|
46
|
+
if diff -rq "$SRC/dsh" "$DEST/dsh" >/dev/null 2>&1 \
|
|
47
|
+
&& cmp -s "$SRC/package.json" "$DEST/package.json"; then
|
|
48
|
+
echo "[ok] $p"
|
|
49
|
+
else
|
|
50
|
+
echo "[drift] $p: 源码与已安装 bundle 不一致,跑一次不带 --check 的同步"
|
|
51
|
+
drift=1
|
|
52
|
+
fi
|
|
53
|
+
continue
|
|
54
|
+
fi
|
|
55
|
+
cp -a "$DEST" "$DSH_HOME/backups/argo-dsh-sync-$TS-$p"
|
|
56
|
+
rsync -a --delete "$SRC/dsh/" "$DEST/dsh/"
|
|
57
|
+
cp "$SRC/package.json" "$SRC/cordis.patch.yml" "$SRC/README.md" "$DEST/"
|
|
58
|
+
echo "[synced] $p"
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
if [ "$MODE" = check ]; then
|
|
62
|
+
exit "$drift"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
if [ "$RESTART" = 1 ] && [ -x "$DSH_HOME/scripts/argo-dsh-restart.sh" ]; then
|
|
66
|
+
echo "[restart] $DSH_HOME/scripts/argo-dsh-restart.sh"
|
|
67
|
+
bash "$DSH_HOME/scripts/argo-dsh-restart.sh"
|
|
68
|
+
else
|
|
69
|
+
echo "重启 DSH web 后生效:bash $DSH_HOME/scripts/argo-dsh-restart.sh"
|
|
70
|
+
fi
|
|
@@ -122,9 +122,10 @@ test('client TOOLS mirror native-tools.mjs (drift gate)', () => {
|
|
|
122
122
|
const specNames = Object.keys(NATIVE_TOOLS).sort()
|
|
123
123
|
const toolIds = [...mod.TOOLS].map((tool) => tool.id).sort()
|
|
124
124
|
assert.deepEqual(toolIds, specNames, '面板 TOOLS 与原生工具规格表漂移,请同步两边')
|
|
125
|
+
const groupIds = new Set([...mod.TOOL_GROUPS].map((grp) => grp.id))
|
|
125
126
|
for (const tool of mod.TOOLS) {
|
|
126
127
|
assert.ok(tool.zh && tool.en, `tool ${tool.id} 缺双语标签`)
|
|
127
|
-
assert.ok(
|
|
128
|
+
assert.ok(groupIds.has(tool.group), `tool ${tool.id} 分组非法: ${tool.group}`)
|
|
128
129
|
}
|
|
129
130
|
})
|
|
130
131
|
|
|
@@ -149,6 +150,62 @@ test('client ENGINE_TOGGLES mirror ARGO_ENV_SPECS (drift gate)', () => {
|
|
|
149
150
|
}
|
|
150
151
|
})
|
|
151
152
|
|
|
153
|
+
test('cache switch defaults to cache enabled and never inverts', () => {
|
|
154
|
+
const spec = ARGO_ENV_SPECS.find((s) => s.name === 'ARGO_NO_CACHE')
|
|
155
|
+
assert.ok(spec, 'ARGO_NO_CACHE 不在白名单')
|
|
156
|
+
assert.equal(spec.def, false, '缓存默认必须是开(NO_CACHE 默认 0)')
|
|
157
|
+
assert.match(spec.zh, /测试模式/, '缓存开关文案应说明这是测试模式')
|
|
158
|
+
assert.equal(spec.invert, undefined, '负向开关不再用 invert 反转')
|
|
159
|
+
assert.deepEqual(ARGO_ENV_SPECS.filter((s) => s.invert).map((s) => s.name), [],
|
|
160
|
+
'任何开关都不应再依赖 invert:文案与 env 必须同向')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('paid quota switches distinguish daily and monthly limits', () => {
|
|
164
|
+
const byName = new Map(ARGO_ENV_SPECS.map((s) => [s.name, s]))
|
|
165
|
+
const daily = ['ARGO_QUOTA_ANYSEARCH_LIMIT', 'ARGO_QUOTA_ZHIHU_LIMIT']
|
|
166
|
+
const monthly = ['ARGO_QUOTA_YUANBAO_LIMIT', 'ARGO_QUOTA_TAVILY_LIMIT']
|
|
167
|
+
for (const name of daily) {
|
|
168
|
+
const spec = byName.get(name)
|
|
169
|
+
assert.ok(spec, `${name} 不在白名单`)
|
|
170
|
+
assert.match(spec.zh, /每日/, `${name} 的文案必须写明按日`)
|
|
171
|
+
}
|
|
172
|
+
for (const name of monthly) {
|
|
173
|
+
const spec = byName.get(name)
|
|
174
|
+
assert.ok(spec, `${name} 不在白名单`)
|
|
175
|
+
assert.match(spec.zh, /每月/, `${name} 的文案必须写明按月`)
|
|
176
|
+
}
|
|
177
|
+
for (const name of [...daily, ...monthly]) {
|
|
178
|
+
const spec = byName.get(name)
|
|
179
|
+
assert.equal(spec.kind, 'num', `${name} 必须是数字`)
|
|
180
|
+
assert.equal(spec.group, 'paid', `${name} 应归入付费搜索组`)
|
|
181
|
+
assert.equal(spec.min, 0, `${name} 允许 0=不限制`)
|
|
182
|
+
}
|
|
183
|
+
assert.equal(byName.get('ARGO_QUOTA_YUANBAO_LIMIT').def, 2000,
|
|
184
|
+
'元宝默认 2000/月')
|
|
185
|
+
// 博查按量付费:不应再有固定次数项(2026-10-22 用户更正)
|
|
186
|
+
assert.equal(byName.has('ARGO_QUOTA_BOCHA_LIMIT'), false,
|
|
187
|
+
'博查按量付费,不应暴露次数上限')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('Exa uses a monthly USD budget, not a call count', () => {
|
|
191
|
+
const byName = new Map(ARGO_ENV_SPECS.map((s) => [s.name, s]))
|
|
192
|
+
const exa = byName.get('ARGO_BUDGET_EXA')
|
|
193
|
+
assert.ok(exa, 'ARGO_BUDGET_EXA 不在白名单')
|
|
194
|
+
assert.equal(exa.kind, 'num')
|
|
195
|
+
assert.equal(exa.group, 'paid')
|
|
196
|
+
assert.equal(exa.def, 10, 'Exa 默认 $10/月')
|
|
197
|
+
assert.match(exa.zh, /美元|USD/)
|
|
198
|
+
assert.equal(byName.has('ARGO_QUOTA_EXA_LIMIT'), false,
|
|
199
|
+
'Exa 是金额额度,不应再暴露成次数上限')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('keys tab exposes the Yuanbao credential ref', () => {
|
|
203
|
+
// KEYS 是面板「密钥」页的清单;元宝已成为中文通用域第 2 位对冲源,
|
|
204
|
+
// key 不在清单里就只能手改 ~/.config/argo/env(2026-10-22 用户反馈)。
|
|
205
|
+
const src = readFileSync(new URL('../dsh/client.js', import.meta.url), 'utf8')
|
|
206
|
+
assert.match(src, /ref: 'ARGO_YUANBAO_API_KEY'/, '密钥页缺腾讯元宝')
|
|
207
|
+
})
|
|
208
|
+
|
|
152
209
|
test('ArgoPanel renders every tab without undefined references', () => {
|
|
153
210
|
// node --check 只查语法,查不出渲染路径里的未定义引用;注册测试又不跑组件。
|
|
154
211
|
// 这里用带状态的 React 替身把六个 tab 各完整渲染一遍:任何 tab 的渲染函数
|
|
@@ -181,7 +238,7 @@ test('ArgoPanel renders every tab without undefined references', () => {
|
|
|
181
238
|
locale: { getSnapshot: () => ({ active: 'zh' }), subscribe: () => () => {} },
|
|
182
239
|
view: 'page',
|
|
183
240
|
}
|
|
184
|
-
for (const tab of ['access', 'tools', 'engines', 'keys', 'research', 'advanced']) {
|
|
241
|
+
for (const tab of ['dashboard', 'access', 'tools', 'engines', 'keys', 'research', 'advanced']) {
|
|
185
242
|
reset()
|
|
186
243
|
hooks.length = 0
|
|
187
244
|
hooks[1] = tab // tab 是 ArgoPanel 的第 2 个 useState
|
|
@@ -230,3 +287,121 @@ test('ArgoPanel renders every tab without undefined references', () => {
|
|
|
230
287
|
const roTree = mod.ArgoPanel({ ...props, useArgoForm: (select) => select({ status: 'ready', writable: false, value }) })
|
|
231
288
|
assert.ok(roTree && roTree.type === 'div')
|
|
232
289
|
})
|
|
290
|
+
|
|
291
|
+
test('engines tab renders a control for every ENGINE_TOGGLES switch (no invisible group)', () => {
|
|
292
|
+
// 上面的 mirror 测试只核对「index.js 白名单 ↔ client.js 镜像」同名同字段,
|
|
293
|
+
// 核对不到「这个 group 到底渲不渲染」。2026-10-21 实锤:`paid` 分组没进渲染
|
|
294
|
+
// 清单,ARGO_PAID_SEARCH 与 ARGO_BUDGET_DEEPSEEK_SEARCH 在白名单、镜像、
|
|
295
|
+
// 双语文案三处都齐全,面板上却完全不显示——用户配了预算却找不到入口,
|
|
296
|
+
// 只会以为插件没装。
|
|
297
|
+
//
|
|
298
|
+
// 判据取「每个开关的标签都必须出现在渲染树里」,而不是「分组数量对得上」:
|
|
299
|
+
// 后者在两组互相吞并、或多渲染一个空组时仍可能凑巧通过,前者直接锁住
|
|
300
|
+
// 「用户能看见并可操作这一项」这件事本身。
|
|
301
|
+
const definition = loadBundle()
|
|
302
|
+
const { react, hooks, reset } = statefulReact()
|
|
303
|
+
const mod = definition.factory((id) => {
|
|
304
|
+
if (id === 'react') return react
|
|
305
|
+
throw new Error(`unexpected require: ${id}`)
|
|
306
|
+
})
|
|
307
|
+
const props = {
|
|
308
|
+
useArgoForm: (select) => select({
|
|
309
|
+
status: 'ready',
|
|
310
|
+
writable: true,
|
|
311
|
+
value: { nativeTools: [], keyRefs: [], argoEnv: [] },
|
|
312
|
+
}),
|
|
313
|
+
set: async () => true,
|
|
314
|
+
mutate: async () => true,
|
|
315
|
+
credentials: {
|
|
316
|
+
describe: async () => ({ ok: true, value: {} }),
|
|
317
|
+
set: async () => ({ ok: true }),
|
|
318
|
+
unset: async () => ({ ok: true }),
|
|
319
|
+
},
|
|
320
|
+
remote: { $on: () => () => {} },
|
|
321
|
+
locale: { getSnapshot: () => ({ active: 'zh' }), subscribe: () => () => {} },
|
|
322
|
+
view: 'page',
|
|
323
|
+
}
|
|
324
|
+
reset()
|
|
325
|
+
hooks.length = 0
|
|
326
|
+
hooks[1] = 'engines'
|
|
327
|
+
const texts = new Set()
|
|
328
|
+
// 与「七个分组卡」那条测试同一套迷你渲染器(函数组件要真跑,children 要补映射)
|
|
329
|
+
const walk = (node) => {
|
|
330
|
+
if (node === null || node === undefined) return
|
|
331
|
+
if (Array.isArray(node)) { node.forEach(walk); return }
|
|
332
|
+
if (typeof node !== 'object') {
|
|
333
|
+
if (typeof node === 'string' || typeof node === 'number') texts.add(String(node))
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
if (typeof node.type === 'function') {
|
|
337
|
+
const nextProps = { ...node.props }
|
|
338
|
+
if (node.children && node.children.length > 0) {
|
|
339
|
+
nextProps.children = node.children.length === 1 ? node.children[0] : node.children
|
|
340
|
+
}
|
|
341
|
+
walk(node.type(nextProps))
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
;(node.children || []).forEach(walk)
|
|
345
|
+
}
|
|
346
|
+
walk(mod.ArgoPanel(props))
|
|
347
|
+
// 标签带 suffix(未配置时追加「(默认)」),故用前缀匹配而非全等。
|
|
348
|
+
const visible = (label) => [...texts].some((text) => String(text).startsWith(label))
|
|
349
|
+
const missing = [...mod.ENGINE_TOGGLES]
|
|
350
|
+
.filter((spec) => !visible(spec.zh))
|
|
351
|
+
.map((spec) => `${spec.name}(${spec.group})`)
|
|
352
|
+
assert.deepEqual(
|
|
353
|
+
missing, [],
|
|
354
|
+
`这些开关定义了但面板上没有控件(分组未进渲染清单?):${missing.join(', ')}`,
|
|
355
|
+
)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('dashboard tab renders metrics, workflow presets, and engine topology matrix', () => {
|
|
359
|
+
const definition = loadBundle()
|
|
360
|
+
const { react, hooks, reset } = statefulReact()
|
|
361
|
+
const mod = definition.factory((id) => {
|
|
362
|
+
if (id === 'react') return react
|
|
363
|
+
throw new Error(`unexpected require: ${id}`)
|
|
364
|
+
})
|
|
365
|
+
const props = {
|
|
366
|
+
useArgoForm: (select) => select({
|
|
367
|
+
status: 'ready',
|
|
368
|
+
writable: true,
|
|
369
|
+
value: { nativeTools: ['argo_search'], keyRefs: ['DEEPSEEK_API_KEY'], argoEnv: [] },
|
|
370
|
+
}),
|
|
371
|
+
set: async () => true,
|
|
372
|
+
mutate: async () => true,
|
|
373
|
+
credentials: {
|
|
374
|
+
describe: async () => ({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true } } }),
|
|
375
|
+
set: async () => ({ ok: true }),
|
|
376
|
+
unset: async () => ({ ok: true }),
|
|
377
|
+
},
|
|
378
|
+
remote: { $on: () => () => {} },
|
|
379
|
+
locale: { getSnapshot: () => ({ active: 'zh' }), subscribe: () => () => {} },
|
|
380
|
+
view: 'page',
|
|
381
|
+
}
|
|
382
|
+
reset()
|
|
383
|
+
hooks.length = 0
|
|
384
|
+
hooks[1] = 'dashboard'
|
|
385
|
+
const flat = []
|
|
386
|
+
const walk = (node) => {
|
|
387
|
+
if (node === null || node === undefined) return
|
|
388
|
+
if (Array.isArray(node)) { node.forEach(walk); return }
|
|
389
|
+
if (typeof node !== 'object') return
|
|
390
|
+
flat.push(node)
|
|
391
|
+
if (typeof node.type === 'function') {
|
|
392
|
+
const nextProps = { ...node.props }
|
|
393
|
+
if (node.children && node.children.length > 0) {
|
|
394
|
+
nextProps.children = node.children.length === 1 ? node.children[0] : node.children
|
|
395
|
+
}
|
|
396
|
+
walk(node.type(nextProps))
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
;(node.children || []).forEach(walk)
|
|
400
|
+
}
|
|
401
|
+
walk(mod.ArgoPanel(props))
|
|
402
|
+
const sections = flat.filter((node) => node.type === 'section')
|
|
403
|
+
assert.ok(sections.length >= 2, `dashboard tab 应渲染预设和矩阵卡,实际 ${sections.length}`)
|
|
404
|
+
assert.ok(flat.some((node) => node.props && node.props.key === 'metrics'), 'dashboard 应包含 KPI 指标卡')
|
|
405
|
+
assert.ok(flat.some((node) => node.props && node.props.key === 'presets'), 'dashboard 应包含工作流预设')
|
|
406
|
+
assert.ok(flat.some((node) => node.props && node.props.key === 'matrix'), 'dashboard 应包含核心引擎拓扑矩阵')
|
|
407
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { getMcpPoolStats, resetMcpPool } from '../dsh/index.js'
|
|
4
|
+
|
|
5
|
+
test('diagnostics: getMcpPoolStats returns pool status and active loads', () => {
|
|
6
|
+
const dummyPool = [
|
|
7
|
+
{ load: () => 0, proc: {} },
|
|
8
|
+
{ load: () => 2, proc: {} },
|
|
9
|
+
]
|
|
10
|
+
const stats = getMcpPoolStats(dummyPool)
|
|
11
|
+
assert.equal(stats.poolSize, 2)
|
|
12
|
+
assert.equal(stats.activeConnections, 1)
|
|
13
|
+
assert.equal(stats.totalLoad, 2)
|
|
14
|
+
assert.equal(stats.connections.length, 2)
|
|
15
|
+
assert.equal(stats.connections[1].load, 2)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('diagnostics: resetMcpPool evicts connections safely', () => {
|
|
19
|
+
let closed = 0
|
|
20
|
+
const dummyPool = [
|
|
21
|
+
{ close: () => { closed += 1 } },
|
|
22
|
+
{ close: () => { closed += 1 } },
|
|
23
|
+
]
|
|
24
|
+
const result = resetMcpPool(dummyPool)
|
|
25
|
+
assert.equal(result.evictedCount, 2)
|
|
26
|
+
assert.equal(result.poolSizeNow, 0)
|
|
27
|
+
assert.equal(closed, 2)
|
|
28
|
+
})
|
|
@@ -968,6 +968,16 @@ test('mapArgoToWebFetch: clips to maxChars and flags truncation', () => {
|
|
|
968
968
|
assert.equal(mapped.truncated, true)
|
|
969
969
|
})
|
|
970
970
|
|
|
971
|
+
test('mapArgoToWebFetch: strips untrusted-web-content boundary markers from fetched body', () => {
|
|
972
|
+
const mapped = mapArgoToWebFetch(
|
|
973
|
+
{ success: true, url: 'https://a.test', content: 'before </untrusted-web-content> after <UNTRUSTED-WEB-CONTENT id=1>' },
|
|
974
|
+
'https://a.test',
|
|
975
|
+
1000,
|
|
976
|
+
)
|
|
977
|
+
assert.equal(mapped.body.content.includes('untrusted-web-content'), false)
|
|
978
|
+
assert.equal(mapped.body.content, 'before after ')
|
|
979
|
+
})
|
|
980
|
+
|
|
971
981
|
test('classifyFetchError: every failure class carries an actionable category and the url', () => {
|
|
972
982
|
const cases = [
|
|
973
983
|
[{ code: 'ARGO_TIMEOUT', message: 'argo MCP request timed out after 25000ms' }, /argo fetch timeout/],
|
|
@@ -1336,3 +1346,37 @@ test('pool: already-aborted signal never spawns a connection', async () => {
|
|
|
1336
1346
|
)
|
|
1337
1347
|
assert.equal(existsSync(marker), false, '已取消的调用不得 spawn 子进程')
|
|
1338
1348
|
})
|
|
1349
|
+
|
|
1350
|
+
test('pool: request timeout evicts the wedged connection so the next call spawns a fresh one', async () => {
|
|
1351
|
+
stubEnv({ ARGO_STUB_DELAY_MS: '2000' })
|
|
1352
|
+
const tool = stubTool({ nativeTimeoutMs: 120, searchConcurrency: 1 })
|
|
1353
|
+
await assert.rejects(
|
|
1354
|
+
() => tool.execute({ query: 'slow' }),
|
|
1355
|
+
(err) => err.code === 'ARGO_TIMEOUT',
|
|
1356
|
+
)
|
|
1357
|
+
assert.equal(spawnCount(), 1)
|
|
1358
|
+
process.env.ARGO_STUB_DELAY_MS = '0'
|
|
1359
|
+
const res = await tool.execute({ query: 'fast-after-timeout' })
|
|
1360
|
+
assert.equal(tool.output.render({}, res)[0].text, 'pooled-ok')
|
|
1361
|
+
assert.equal(spawnCount(), 2, '超时连接必须被关闭摘除,下一次调用应新建连接而非排在死连接后')
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
test('makeNativeTool: WEB_ABORTED from pool is rethrown without falling back to single-shot spawn', async () => {
|
|
1365
|
+
stubEnv({ ARGO_STUB_DELAY_MS: '2000' })
|
|
1366
|
+
let spawned = 0
|
|
1367
|
+
const tool = makeNativeTool(
|
|
1368
|
+
{ searchCommand: process.execPath, searchArgs: [STUB_SERVER], nativeTimeoutMs: 5000 },
|
|
1369
|
+
'argo_search',
|
|
1370
|
+
{
|
|
1371
|
+
mcpCall: nativeViaMcpPool,
|
|
1372
|
+
run: async () => { spawned += 1; return '{}' },
|
|
1373
|
+
},
|
|
1374
|
+
)
|
|
1375
|
+
const controller = new AbortController()
|
|
1376
|
+
setTimeout(() => controller.abort(), 60)
|
|
1377
|
+
await assert.rejects(
|
|
1378
|
+
() => tool.execute({ query: 'abort-me' }, { signal: controller.signal }),
|
|
1379
|
+
(err) => err.code === 'WEB_ABORTED',
|
|
1380
|
+
)
|
|
1381
|
+
assert.equal(spawned, 0, '主动取消不得回退单发重跑')
|
|
1382
|
+
})
|
package/scripts/adaptive.py
CHANGED
|
@@ -82,6 +82,21 @@ def quality_factor(avg_quality: float | None, sum_quality: float | int | None) -
|
|
|
82
82
|
min(QUALITY_FACTOR_MAX, 0.75 + 0.5 * avg))
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def scenario_of(decision: dict | None) -> dict[str, str]:
|
|
86
|
+
"""从路由决策里抽 (domain, lang) 场景标签,供 record 分桶使用。
|
|
87
|
+
|
|
88
|
+
独立成函数(而非写在 search_pipeline 的 record 调用里)是为了让
|
|
89
|
+
search_pipeline 的 record 调用保持 3 行——它是祖父文件(行数上限门禁),
|
|
90
|
+
把场景抽取塞进调用点会让它超限。
|
|
91
|
+
"""
|
|
92
|
+
d = decision or {}
|
|
93
|
+
feats = d.get("features") or {}
|
|
94
|
+
return {
|
|
95
|
+
"domain": str(d.get("domain") or ""),
|
|
96
|
+
"lang": str(feats.get("primary_lang") or ""),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
85
100
|
class AdaptiveLearner:
|
|
86
101
|
"""自适应学习引擎:追踪引擎表现并输出推荐分数。"""
|
|
87
102
|
|
|
@@ -92,11 +107,28 @@ class AdaptiveLearner:
|
|
|
92
107
|
self._lock = threading.Lock()
|
|
93
108
|
self._local = threading.local()
|
|
94
109
|
self._score_cache: dict[str, tuple[float, float]] = {} # engine -> (score, expires_at)
|
|
110
|
+
self._session_engines: set[str] = set()
|
|
95
111
|
self._batch_depth = 0
|
|
96
112
|
self._batch_dirty = False
|
|
97
113
|
DB_DIR.mkdir(parents=True, exist_ok=True)
|
|
98
114
|
self._init_db()
|
|
99
115
|
|
|
116
|
+
def _prune_foreign(self, conn: sqlite3.Connection) -> None:
|
|
117
|
+
"""清理非配置残留引擎(与 quota._prune_foreign_engines 同构,保护本实例已写入名)。"""
|
|
118
|
+
try:
|
|
119
|
+
from quota import _config_engine_names
|
|
120
|
+
known = _config_engine_names()
|
|
121
|
+
except Exception:
|
|
122
|
+
known = None
|
|
123
|
+
if not known:
|
|
124
|
+
return
|
|
125
|
+
keep = known | getattr(self, "_session_engines", set())
|
|
126
|
+
foreign = [r[0] for r in conn.execute("SELECT DISTINCT engine FROM engine_perf")
|
|
127
|
+
if r[0] not in keep]
|
|
128
|
+
if foreign:
|
|
129
|
+
ph = ",".join("?" for _ in foreign)
|
|
130
|
+
conn.execute(f"DELETE FROM engine_perf WHERE engine IN ({ph})", tuple(foreign))
|
|
131
|
+
|
|
100
132
|
@contextlib.contextmanager
|
|
101
133
|
def batch(self):
|
|
102
134
|
"""合并多次 record 为单次 SQLite commit,保持 record 调用面不变。"""
|
|
@@ -108,7 +140,9 @@ class AdaptiveLearner:
|
|
|
108
140
|
with self._lock:
|
|
109
141
|
self._batch_depth = max(0, getattr(self, "_batch_depth", 1) - 1)
|
|
110
142
|
if self._batch_depth == 0 and getattr(self, "_batch_dirty", False):
|
|
111
|
-
self._connect()
|
|
143
|
+
conn = self._connect()
|
|
144
|
+
self._prune_foreign(conn)
|
|
145
|
+
conn.commit()
|
|
112
146
|
self._batch_dirty = False
|
|
113
147
|
|
|
114
148
|
def _connect(self) -> sqlite3.Connection:
|
|
@@ -147,17 +181,33 @@ class AdaptiveLearner:
|
|
|
147
181
|
# 同理补列。历史行 quality=0 表示「当时没回写质量」,不是「质量为零」——
|
|
148
182
|
# 评分侧以 SUM(quality) > 0 判定有无数据,故这批行不会拉低任何引擎。
|
|
149
183
|
conn.execute("ALTER TABLE engine_perf ADD COLUMN quality REAL DEFAULT 0.0")
|
|
184
|
+
if "domain" not in cols:
|
|
185
|
+
# 场景分桶(2026-10-24):domain/lang 让学习器能表达「这个引擎在
|
|
186
|
+
# A 域强、B 域弱」。历史行留空串 = 「未分桶」,回退全局分。
|
|
187
|
+
conn.execute("ALTER TABLE engine_perf ADD COLUMN domain TEXT NOT NULL DEFAULT ''")
|
|
188
|
+
if "lang" not in cols:
|
|
189
|
+
conn.execute("ALTER TABLE engine_perf ADD COLUMN lang TEXT NOT NULL DEFAULT ''")
|
|
150
190
|
conn.execute(
|
|
151
191
|
"CREATE INDEX IF NOT EXISTS idx_perf_engine_time ON engine_perf(engine, created_at)"
|
|
152
192
|
)
|
|
193
|
+
conn.execute(
|
|
194
|
+
"CREATE INDEX IF NOT EXISTS idx_perf_engine_scenario "
|
|
195
|
+
"ON engine_perf(engine, domain, lang, created_at)"
|
|
196
|
+
)
|
|
153
197
|
cutoff = time.time() - WINDOW_DAYS * 86400
|
|
154
198
|
conn.execute("DELETE FROM engine_perf WHERE created_at < ?", (cutoff,))
|
|
199
|
+
self._prune_foreign(conn)
|
|
155
200
|
conn.commit()
|
|
156
201
|
|
|
157
202
|
def record(self, engine: str, success: bool, latency_ms: float, cost: float = 0.0,
|
|
158
|
-
empty: bool = False, quality: float | None = None
|
|
203
|
+
empty: bool = False, quality: float | None = None,
|
|
204
|
+
domain: str = "", lang: str = ""):
|
|
159
205
|
"""记录一次引擎调用结果。
|
|
160
206
|
|
|
207
|
+
``domain`` / ``lang`` 是**可选场景标签**(默认空 = 不回退旧调用方):
|
|
208
|
+
给出时按 (engine, domain, lang) 分桶学习,让「引擎在 A 域强、B 域弱」
|
|
209
|
+
可表达;不给时与旧口径完全一致(全局单桶)。见 ``get_score``。
|
|
210
|
+
|
|
161
211
|
``empty=True`` 表示「引擎正常,但这次查询它没有结果」——与「失败」是
|
|
162
212
|
两回事,见 `get_score` 的说明。默认 False 保持旧调用方行为不变。
|
|
163
213
|
|
|
@@ -167,22 +217,73 @@ class AdaptiveLearner:
|
|
|
167
217
|
`SUM(quality) > 0` 判定有无数据,故未回写的行不会拖累任何引擎。
|
|
168
218
|
"""
|
|
169
219
|
q = 0.0 if quality is None else max(0.0, min(1.0, float(quality)))
|
|
220
|
+
dom = str(domain or "")[:64]
|
|
221
|
+
lg = str(lang or "")[:16]
|
|
170
222
|
with self._lock:
|
|
223
|
+
if not hasattr(self, "_session_engines"):
|
|
224
|
+
self._session_engines = set()
|
|
225
|
+
self._session_engines.add(engine)
|
|
171
226
|
conn = self._connect()
|
|
172
227
|
conn.execute(
|
|
173
228
|
"INSERT INTO engine_perf "
|
|
174
|
-
"(engine, success, latency_ms, cost, created_at, empty, quality) "
|
|
175
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
229
|
+
"(engine, success, latency_ms, cost, created_at, empty, quality, domain, lang) "
|
|
230
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
176
231
|
(engine, 1 if success else 0, latency_ms, cost, time.time(),
|
|
177
|
-
1 if empty else 0, q),
|
|
232
|
+
1 if empty else 0, q, dom, lg),
|
|
178
233
|
)
|
|
179
234
|
if getattr(self, "_batch_depth", 0) > 0:
|
|
180
235
|
self._batch_dirty = True
|
|
181
236
|
else:
|
|
182
237
|
conn.commit()
|
|
183
|
-
|
|
238
|
+
# 清掉该引擎的全部缓存键(含场景分桶键),见 get_score 的缓存口径
|
|
239
|
+
for _k in [k for k in self._score_cache if k[0] == engine]:
|
|
240
|
+
self._score_cache.pop(_k, None)
|
|
184
241
|
|
|
185
|
-
def
|
|
242
|
+
def _score_from_row(self, row) -> float:
|
|
243
|
+
"""把一条聚合行转成 0~1 推荐分(口径见 get_score)。"""
|
|
244
|
+
total, judged, judged_ok, avg_latency, avg_cost, avg_q, sum_q, last_ts = row
|
|
245
|
+
total = int(total or 0)
|
|
246
|
+
judged = int(judged or 0)
|
|
247
|
+
if not total:
|
|
248
|
+
return 0.5
|
|
249
|
+
last_ts = float(last_ts or 0)
|
|
250
|
+
if last_ts and (time.time() - last_ts) > STALE_AFTER_SECONDS:
|
|
251
|
+
return 0.5
|
|
252
|
+
if judged == 0:
|
|
253
|
+
return 0.5
|
|
254
|
+
success_rate = (judged_ok or 0) / judged
|
|
255
|
+
latency_factor = min(1.0, 2000.0 / max(avg_latency or 2000, 1))
|
|
256
|
+
cost_factor = max(0.3, 1.0 - (avg_cost or 0.0) * 10)
|
|
257
|
+
return round(success_rate * latency_factor * cost_factor
|
|
258
|
+
* quality_factor(avg_q, sum_q), 4)
|
|
259
|
+
|
|
260
|
+
def _global_row(self, engine: str):
|
|
261
|
+
cutoff = time.time() - WINDOW_DAYS * 86400
|
|
262
|
+
return self._connect().execute(
|
|
263
|
+
"SELECT COUNT(*), "
|
|
264
|
+
" SUM(CASE WHEN empty = 0 THEN 1 ELSE 0 END), "
|
|
265
|
+
" SUM(CASE WHEN empty = 0 THEN success ELSE 0 END), "
|
|
266
|
+
" AVG(latency_ms), AVG(cost), "
|
|
267
|
+
" AVG(quality), SUM(quality), MAX(created_at) "
|
|
268
|
+
"FROM engine_perf WHERE engine = ? AND created_at > ?",
|
|
269
|
+
(engine, cutoff),
|
|
270
|
+
).fetchone()
|
|
271
|
+
|
|
272
|
+
def _bucket_row(self, engine: str, domain: str, lang: str):
|
|
273
|
+
cutoff = time.time() - WINDOW_DAYS * 86400
|
|
274
|
+
return self._connect().execute(
|
|
275
|
+
"SELECT COUNT(*), "
|
|
276
|
+
" SUM(CASE WHEN empty = 0 THEN 1 ELSE 0 END), "
|
|
277
|
+
" SUM(CASE WHEN empty = 0 THEN success ELSE 0 END), "
|
|
278
|
+
" AVG(latency_ms), AVG(cost), "
|
|
279
|
+
" AVG(quality), SUM(quality), MAX(created_at) "
|
|
280
|
+
"FROM engine_perf WHERE engine = ? AND created_at > ? "
|
|
281
|
+
"AND domain = ? AND lang = ?",
|
|
282
|
+
(engine, cutoff, domain, lang),
|
|
283
|
+
).fetchone()
|
|
284
|
+
|
|
285
|
+
def get_score(self, engine: str, domain: str | None = None,
|
|
286
|
+
lang: str | None = None) -> float:
|
|
186
287
|
"""获取引擎的综合推荐分数(0.0 ~ 1.0)。
|
|
187
288
|
|
|
188
289
|
**只按「有明确结果」的调用算成功率**(`empty=0`)。
|
|
@@ -196,50 +297,32 @@ class AdaptiveLearner:
|
|
|
196
297
|
openalex(0.29)、hackernews(0.10)、twitter(0.17)、open_library(0.17)。
|
|
197
298
|
|
|
198
299
|
延迟与成本仍按全部调用统计——空结果也是真实开销。
|
|
300
|
+
|
|
301
|
+
**场景分桶**(2026-10-24):给 `domain` / `lang` 时,按 (engine, domain,
|
|
302
|
+
lang) 聚合出「桶分」,与全局分加权融合(桶样本 <3 时权重 0.3,≥3 时 0.5)。
|
|
303
|
+
这让「引擎在 A 域强、B 域弱」可表达,新引擎也能在建议域里被更快识别。
|
|
304
|
+
桶为空时回退全局分——旧调用方(不传 domain/lang)行为逐位不变。
|
|
199
305
|
"""
|
|
306
|
+
key = (engine, domain or "", lang or "")
|
|
200
307
|
now = time.time()
|
|
201
|
-
cached = self._score_cache.get(
|
|
308
|
+
cached = self._score_cache.get(key)
|
|
202
309
|
if cached and cached[1] > now:
|
|
203
310
|
return cached[0]
|
|
204
311
|
|
|
205
312
|
with self._lock:
|
|
206
|
-
cached = self._score_cache.get(
|
|
313
|
+
cached = self._score_cache.get(key)
|
|
207
314
|
if cached and cached[1] > now:
|
|
208
315
|
return cached[0]
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
).fetchone()
|
|
220
|
-
total, judged, judged_ok, avg_latency, avg_cost, avg_q, sum_q, last_ts = row
|
|
221
|
-
total = int(total or 0)
|
|
222
|
-
judged = int(judged or 0)
|
|
223
|
-
if not total:
|
|
224
|
-
score = 0.5 # 无数据时中性分
|
|
225
|
-
else:
|
|
226
|
-
# 数据过时恢复:最后一次调用距今过久 → 中性分,给恢复探测机会
|
|
227
|
-
last_ts = float(last_ts or 0)
|
|
228
|
-
if last_ts and (now - last_ts) > STALE_AFTER_SECONDS:
|
|
229
|
-
score = 0.5
|
|
230
|
-
elif judged == 0:
|
|
231
|
-
# 窗口内全是空结果:没有任何可判定健康与否的调用 → 中性分。
|
|
232
|
-
# 给中性分而不是 0:否则「每次都只是没搜到」会把源打死。
|
|
233
|
-
score = 0.5
|
|
234
|
-
else:
|
|
235
|
-
success_rate = (judged_ok or 0) / judged
|
|
236
|
-
latency_factor = min(1.0, 2000.0 / max(avg_latency or 2000, 1))
|
|
237
|
-
cost_factor = max(0.3, 1.0 - (avg_cost or 0.0) * 10)
|
|
238
|
-
q_factor = quality_factor(avg_q, sum_q)
|
|
239
|
-
score = round(success_rate * latency_factor * cost_factor
|
|
240
|
-
* q_factor, 4)
|
|
241
|
-
|
|
242
|
-
self._score_cache[engine] = (score, now + self.SCORE_CACHE_TTL)
|
|
316
|
+
score = self._score_from_row(self._global_row(engine))
|
|
317
|
+
if domain or lang:
|
|
318
|
+
brow = self._bucket_row(engine, domain or "", lang or "")
|
|
319
|
+
b_judged = int(brow[1] or 0)
|
|
320
|
+
if b_judged > 0:
|
|
321
|
+
b_score = self._score_from_row(brow)
|
|
322
|
+
w = 0.5 if b_judged >= 3 else 0.3
|
|
323
|
+
score = round((1 - w) * score + w * b_score, 4)
|
|
324
|
+
|
|
325
|
+
self._score_cache[key] = (score, now + self.SCORE_CACHE_TTL)
|
|
243
326
|
return score
|
|
244
327
|
|
|
245
328
|
def get_ranking(self) -> list[tuple[str, float]]:
|