argo-search 2.9.7 → 2.9.8
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 +58 -145
- package/README.es.md +58 -145
- package/README.ja.md +64 -151
- package/README.ko.md +63 -150
- package/README.md +65 -154
- 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 +74 -5
- package/bin/argo.js +33 -2
- package/config.yaml +219 -60
- 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/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 +6 -2
- package/packages/dsh-plugin/README.md +23 -5
- package/packages/dsh-plugin/dsh/client.js +304 -15
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +112 -1875
- 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 +2 -2
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +175 -1
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +44 -0
- package/scripts/adaptive.py +24 -1
- package/scripts/article_meta.py +131 -0
- 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/content_boundary.py +46 -0
- package/scripts/content_signals.py +26 -5
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_dispatch.py +49 -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 +54 -22
- package/scripts/fetch_v3.py +25 -0
- package/scripts/gen_routing_snapshot.py +11 -0
- 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 +82 -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/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/quota.py +158 -13
- 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 +7 -0
- package/scripts/route_domains.py +32 -19
- 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 +7 -1
- package/scripts/search_output.py +99 -15
- package/scripts/search_pipeline.py +42 -193
- package/scripts/search_types.py +107 -9
- package/scripts/semantic_evidence.py +179 -3
- package/scripts/serp_guard.py +115 -7
- package/scripts/serp_kill_log.py +25 -2
- package/scripts/stats_cli.py +13 -1
- 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/scripts/image_vision/vision_probe +0 -0
- package/scripts/local_engines.py +0 -321
- 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
package/scripts/serp_guard.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""serp_guard —
|
|
2
|
+
"""serp_guard — SERP / 模糊搜索结果的垃圾结果守卫(判定规则单点)。
|
|
3
3
|
|
|
4
4
|
事故背景(dsh-free-search issue #38/#35 同款事故,argo 的 local_* 引擎同样
|
|
5
5
|
暴露):html 引擎解析的是无 API 的网页 SERP,上游偶发把缓存 SERP / 与查询
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
完全无关的结果页当正常页返回;模糊搜索 API(people_daily 的 isFuzzy=True)
|
|
7
|
+
匹配不到时则回整页默认 feed。两种形态都是「答非所问但看起来正常」,解析层
|
|
8
|
+
照常抽出标题摘要,垃圾条目静默混进聚合,调用方把「答非所问」当成「网上
|
|
9
|
+
没有」——失败伪装成功是红线,这类混入是它的隐形变体。
|
|
9
10
|
|
|
10
11
|
裁决口径(全部量化,无模糊地带):
|
|
11
12
|
|
|
@@ -16,7 +17,13 @@
|
|
|
16
17
|
2. results 只取前 5 条;每条取 title+snippet。URL 不参与:缓存/跳转
|
|
17
18
|
链接的 URL 常回显 query 参数(uddg=、q=),会正好把垃圾页放行,
|
|
18
19
|
标题+摘要才是「这页在答什么」的信号。
|
|
19
|
-
3. 前 5
|
|
20
|
+
3. 前 5 条里没有任何一条覆盖 query token 的 20% → 整页判垃圾(True)。
|
|
21
|
+
只判「有没有一个词命中」不够:实测「2026年新能源汽车销量数据」经
|
|
22
|
+
local_bing 返回的五条全是 2026 年节假日/维基页面,只命中「2026」一个
|
|
23
|
+
数字 token,旧判据(有交集即放行)把整页垃圾放了过去,而核心词
|
|
24
|
+
「新能源汽车销量数据」全部缺席。覆盖率 1/9 远低于门槛。
|
|
25
|
+
中文查询在存在 CJK 二元组时**不把纯数字 token 计入分母**:年份是最
|
|
26
|
+
容易被无关页面顺带命中的词,不能拿它当相关性证据。
|
|
20
27
|
|
|
21
28
|
为什么调用方对判垃圾应整页丢弃而不是过滤后返回幸存者:前 5 条全无关说明
|
|
22
29
|
这一页整体答非所问(缓存页/错路由),幸存的一两条是同一页的噪声,过滤
|
|
@@ -43,6 +50,7 @@ from urllib.parse import urlparse
|
|
|
43
50
|
_RUN_RE = re.compile(r"[\u4e00-\u9fff]+|[a-z0-9]+")
|
|
44
51
|
_GUARD_WINDOW = 5 # 只看前 5 条:SERP 的「答非所问」看头部即可下结论
|
|
45
52
|
_MIN_QUERY_TOKENS = 2 # query token 少于此数 → 无裁决权,恒不拦
|
|
53
|
+
_MIN_COVERAGE = 0.2 # 单条结果至少要覆盖 20% 的 query token 才算「在答这题」
|
|
46
54
|
_SWITCH_ENV = "ARGO_SERP_GUARD"
|
|
47
55
|
|
|
48
56
|
|
|
@@ -69,6 +77,20 @@ def _result_text(item: object) -> str:
|
|
|
69
77
|
return f"{item.get('title') or ''} {item.get('snippet') or ''}"
|
|
70
78
|
|
|
71
79
|
|
|
80
|
+
def _query_tokens(query: str) -> set[str]:
|
|
81
|
+
"""query token;中文查询里纯数字不参与(年份不是相关性证据)。
|
|
82
|
+
|
|
83
|
+
有 CJK 二元组说明查询主体是中文短语,此时「2026」这类数字是最容易被
|
|
84
|
+
无关页面顺带命中的 token(节假日表、维基年表都带年份),留在分母里会
|
|
85
|
+
把覆盖率算高、把垃圾页放行。纯拉丁查询(如「2026 GDP」)没有这个问题,
|
|
86
|
+
数字是有信息的,照常保留。
|
|
87
|
+
"""
|
|
88
|
+
tokens = _extract_tokens(query)
|
|
89
|
+
if any("\u4e00" <= ch <= "\u9fff" for tok in tokens for ch in tok):
|
|
90
|
+
tokens = {tok for tok in tokens if not tok.isdigit()}
|
|
91
|
+
return tokens
|
|
92
|
+
|
|
93
|
+
|
|
72
94
|
def is_junk_serp(query: str, results: list[dict]) -> bool:
|
|
73
95
|
"""判一页 SERP 结果是否整体答非所问(口径见模块 docstring)。
|
|
74
96
|
|
|
@@ -77,7 +99,7 @@ def is_junk_serp(query: str, results: list[dict]) -> bool:
|
|
|
77
99
|
"""
|
|
78
100
|
if not guard_enabled():
|
|
79
101
|
return False
|
|
80
|
-
q_tokens =
|
|
102
|
+
q_tokens = _query_tokens(query if isinstance(query, str) else "")
|
|
81
103
|
if len(q_tokens) < _MIN_QUERY_TOKENS:
|
|
82
104
|
return False
|
|
83
105
|
if not isinstance(results, list):
|
|
@@ -86,7 +108,8 @@ def is_junk_serp(query: str, results: list[dict]) -> bool:
|
|
|
86
108
|
if not head:
|
|
87
109
|
return False
|
|
88
110
|
for item in head:
|
|
89
|
-
|
|
111
|
+
hit = _extract_tokens(_result_text(item)) & q_tokens
|
|
112
|
+
if len(hit) / len(q_tokens) >= _MIN_COVERAGE:
|
|
90
113
|
return False
|
|
91
114
|
return True
|
|
92
115
|
|
|
@@ -226,3 +249,88 @@ def drop_shell_urls(results: list) -> list:
|
|
|
226
249
|
continue
|
|
227
250
|
kept.append(r)
|
|
228
251
|
return kept
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── 内容农场与 AI 机翻垃圾站判定(2026-10-23)─────────────────────────────
|
|
255
|
+
#
|
|
256
|
+
# 依据 source_types_cn.json 的 content_farm_markers 与结构化特征模式,
|
|
257
|
+
# 识别并拦截低质搬运镜像、机翻爬虫站及流水线批量 AI 假稿。
|
|
258
|
+
|
|
259
|
+
_FARM_CONNECTORS = ("首先", "其次", "然后", "接下来", "最后", "综上所述")
|
|
260
|
+
_FARM_AI_PHRASES = ("业内人士表示", "专家分析", "消费者认为", "看完这篇你就懂了",
|
|
261
|
+
"看完你就懂了", "建议收藏", "综上所述")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def is_content_farm(url: str = "", title: str = "", snippet: str = "") -> bool:
|
|
265
|
+
"""判定某条目是否属于内容农场 / 机翻爬虫 / 批量流水线 AI 废话。
|
|
266
|
+
|
|
267
|
+
2026-10-23 接线前用 5 条正例 + 4 条边界标定,修正两处:
|
|
268
|
+
- 序数连接词单独出现**不判农场**:正常安装指南同样含「首先/其次/然后/
|
|
269
|
+
接下来/最后」(实测 5 个),旧判据 `hit_conn >= 4` 把它误杀了。现在
|
|
270
|
+
要求同时出现 ≥1 个 AI 套话(流水线稿的伴生特征)。
|
|
271
|
+
- 「自费 N 元实测」不再要求标题带「最新」:实测真实的虚假实测标题是
|
|
272
|
+
「自费5000元实测10款耳机」,不带「最新」,旧判据漏召。
|
|
273
|
+
"""
|
|
274
|
+
if not guard_enabled():
|
|
275
|
+
return False
|
|
276
|
+
cfg = _load_cn_source_types()
|
|
277
|
+
host = _normalize_domain(url)
|
|
278
|
+
host_bare = host[4:] if host.startswith("www.") else host
|
|
279
|
+
farm_markers = tuple(cfg.get("content_farm_markers") or ())
|
|
280
|
+
if any(host_bare == m or host == m or host_bare.endswith("." + m) or host.endswith("." + m)
|
|
281
|
+
for m in farm_markers):
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
low_url = (url or "").lower()
|
|
285
|
+
for pat in cfg.get("content_farm_url_patterns") or []:
|
|
286
|
+
if pat.lower() in low_url:
|
|
287
|
+
# 仅在非白名单顶级域下生效
|
|
288
|
+
if any(host.endswith(tld) for tld in (".top", ".xyz", ".site", ".cc", ".icu", ".work")):
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
# 标题模式:虚假实测 / 利益诱饵 + 年份刷新
|
|
292
|
+
t = (title or "").strip()
|
|
293
|
+
if t:
|
|
294
|
+
if re.search(r"自费\s*\d+\s*元?实测", t):
|
|
295
|
+
return True
|
|
296
|
+
if re.search(r"\d{4}\s*最新推荐\s*\d+\s*款.*看完这篇你就懂了", t):
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
# 摘要/正文模式:高度僵化的模板流水线
|
|
300
|
+
body = f"{t} {snippet or ''}".strip()
|
|
301
|
+
if body:
|
|
302
|
+
hit_conn = sum(1 for w in _FARM_CONNECTORS if w in body)
|
|
303
|
+
hit_ai = sum(1 for w in _FARM_AI_PHRASES if w in body)
|
|
304
|
+
if hit_conn >= 4 and hit_ai >= 1:
|
|
305
|
+
return True
|
|
306
|
+
if hit_ai >= 2 and hit_conn >= 1:
|
|
307
|
+
return True
|
|
308
|
+
if hit_ai >= 3:
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def is_unusable_url(url: str, title: str = "", snippet: str = "") -> bool:
|
|
315
|
+
"""是否为不可作为信源引用的条目(SERP壳 或 确凿内容农场)。"""
|
|
316
|
+
if is_serp_or_jump_url(url):
|
|
317
|
+
return True
|
|
318
|
+
return is_content_farm(url, title, snippet)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def filter_content_farms(results: list) -> list:
|
|
322
|
+
"""过滤结果列表里的内容农场条目。"""
|
|
323
|
+
if not results or not guard_enabled():
|
|
324
|
+
return results or []
|
|
325
|
+
kept = []
|
|
326
|
+
for r in results:
|
|
327
|
+
if not isinstance(r, dict):
|
|
328
|
+
continue
|
|
329
|
+
u = r.get("url") or ""
|
|
330
|
+
t = r.get("title") or ""
|
|
331
|
+
s = r.get("snippet") or ""
|
|
332
|
+
if is_content_farm(u, t, s):
|
|
333
|
+
continue
|
|
334
|
+
kept.append(r)
|
|
335
|
+
return kept
|
|
336
|
+
|
package/scripts/serp_kill_log.py
CHANGED
|
@@ -16,9 +16,11 @@ import threading
|
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
18
|
# 判定规则与逃生门(ARGO_SERP_GUARD=0)在 serp_guard 单点;这里只登记
|
|
19
|
-
# 哪些引擎接了守卫(解析上游 HTML
|
|
19
|
+
# 哪些引擎接了守卫(解析上游 HTML 页 / 模糊搜索接口最易被缓存 SERP 或
|
|
20
|
+
# 默认 feed 静默命中)。people_daily 的 isFuzzy=True 在匹配不到时返回整页
|
|
21
|
+
# 最新 feed(issue #18 复现 A),与 local_* 同属「答非所问但形态正常」。
|
|
20
22
|
SERP_GUARD_ENGINES = frozenset({
|
|
21
|
-
"local_bing", "local_baidu", "local_sogou",
|
|
23
|
+
"local_bing", "local_baidu", "local_sogou", "people_daily",
|
|
22
24
|
})
|
|
23
25
|
|
|
24
26
|
# 进程级 + 锁。并发搜索下 drain 可能拿到彼此的条目——条目只含引擎名与
|
|
@@ -49,3 +51,24 @@ def drain_serp_guard_kills() -> list[dict[str, Any]]:
|
|
|
49
51
|
kills = list(_KILLS)
|
|
50
52
|
_KILLS.clear()
|
|
51
53
|
return kills
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def clean_engine_lists(raw_results: dict[str, list[Any]],
|
|
57
|
+
query: str) -> list[list[dict[str, Any]]]:
|
|
58
|
+
"""raw_results{engine → results} → 去 error + SERP 守卫后的非空列表。
|
|
59
|
+
|
|
60
|
+
守卫必须同时覆盖缓存命中:per-engine 缓存走 engine_dispatch,不经过
|
|
61
|
+
engines.search,旧条目里的默认 feed/垃圾 SERP 会以 `ok-cached` 直接
|
|
62
|
+
返回(issue #18 的 14ms 复现)。融合前按引擎整页判定一次,缓存与网络
|
|
63
|
+
两条路径就收敛到同一口径;engines.search 里的守卫保留,因为它还要
|
|
64
|
+
阻止垃圾结果影响早停判定。
|
|
65
|
+
"""
|
|
66
|
+
out: list[list[dict[str, Any]]] = []
|
|
67
|
+
for eng, res in (raw_results or {}).items():
|
|
68
|
+
goods = [r for r in (res or []) if isinstance(r, dict) and "error" not in r]
|
|
69
|
+
if not goods:
|
|
70
|
+
continue
|
|
71
|
+
goods = serp_guard_apply(eng, query, goods)
|
|
72
|
+
if goods:
|
|
73
|
+
out.append(goods)
|
|
74
|
+
return out
|
package/scripts/stats_cli.py
CHANGED
|
@@ -29,7 +29,19 @@ from search_attribution import aggregate_slow_engines # noqa: E402
|
|
|
29
29
|
def _query_summary(n: int) -> dict:
|
|
30
30
|
rows = usage_log.tail("query", n)
|
|
31
31
|
if not rows:
|
|
32
|
-
|
|
32
|
+
# 空日志(CI 全新 runner / 首次安装)也要给稳定的字段形状:夜间账本
|
|
33
|
+
# 直接读 avg_elapsed_ms,此前这里只回 {"samples": 0},工作流每次
|
|
34
|
+
# 首次跑必炸 KeyError(issue #19)。指标用 None 而不是 0:0ms 会被
|
|
35
|
+
# 画成一条假曲线,None 才表示「没有样本」;调用方按 samples 判断。
|
|
36
|
+
return {
|
|
37
|
+
"samples": 0,
|
|
38
|
+
"result_rate": None,
|
|
39
|
+
"avg_elapsed_ms": None,
|
|
40
|
+
"recovered": 0,
|
|
41
|
+
"top_engines": [],
|
|
42
|
+
"slowest_engines": [],
|
|
43
|
+
"recent": [],
|
|
44
|
+
}
|
|
33
45
|
counts = [r.get("count", 0) for r in rows]
|
|
34
46
|
lat = [r.get("elapsed_ms") for r in rows if isinstance(r.get("elapsed_ms"), (int, float))]
|
|
35
47
|
engines: Counter = Counter()
|
package/scripts/wide_research.py
CHANGED
|
@@ -113,6 +113,40 @@ def normalize_tracks(raw: Any, maximum: int = 9) -> list[dict[str, Any]]:
|
|
|
113
113
|
return out
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
def build_cross_lingual_tracks(question: str, maximum: int = 4) -> list[dict[str, Any]]:
|
|
117
|
+
"""为溯源与多语言任务自动构建分阶段跨语言长程轨道。"""
|
|
118
|
+
q = (question or "").strip()
|
|
119
|
+
has_tracing = bool(re.search(
|
|
120
|
+
r"(?:溯源|起源|出处|最初|源头|提出者|发明|草案|历史沿革|origin|provenance|root cause|first proposed|specification|rfc|cve)",
|
|
121
|
+
q, re.I
|
|
122
|
+
))
|
|
123
|
+
tracks = []
|
|
124
|
+
tracks.append({
|
|
125
|
+
"id": "track-primary-lang",
|
|
126
|
+
"title": "原语言背景与核心定义",
|
|
127
|
+
"question": f"{q} 背景 定义 现状",
|
|
128
|
+
"rationale": "Primary language overview and conceptual framing.",
|
|
129
|
+
"depends_on": [],
|
|
130
|
+
})
|
|
131
|
+
if has_tracing or len(tracks) < maximum:
|
|
132
|
+
tracks.append({
|
|
133
|
+
"id": "track-origin-trace",
|
|
134
|
+
"title": "源头出处与权威一手规范长程溯源",
|
|
135
|
+
"question": f"{q} original paper inception specification first proposed RFC official repository",
|
|
136
|
+
"rationale": "Cross-lingual long-range tracing for primary sources, papers, or official specifications.",
|
|
137
|
+
"depends_on": ["track-primary-lang"],
|
|
138
|
+
})
|
|
139
|
+
tracks.append({
|
|
140
|
+
"id": "track-cross-reconciliation",
|
|
141
|
+
"title": "多语言证据交叉对账与互补验证",
|
|
142
|
+
"question": f"{q} milestone benchmark evaluation comparison timeline",
|
|
143
|
+
"rationale": "Cross-lingual reconciliation of evidence, timeline differences, and complementary facts.",
|
|
144
|
+
"depends_on": ["track-origin-trace"],
|
|
145
|
+
})
|
|
146
|
+
return tracks[:maximum]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
116
150
|
def stage_tracks(tracks: list[dict[str, Any]]) -> tuple[list[list[dict[str, Any]]], list[str]]:
|
|
117
151
|
"""轨道分层。work package 的分层逻辑与轨道同构:id/question/depends_on。"""
|
|
118
152
|
packages = [
|
|
@@ -255,6 +289,17 @@ def evaluate_gates(
|
|
|
255
289
|
if len(caveats) + len(unanswered) >= max(3, (len(sources) + 1) // 2):
|
|
256
290
|
warnings.append({"id": "high_uncertainty",
|
|
257
291
|
"detail": f"{len(caveats)} caveats and {len(unanswered)} unanswered questions exceed the evidence threshold."})
|
|
292
|
+
|
|
293
|
+
# 内容农场与镜像站占优检查
|
|
294
|
+
farm_markers = ("ctolib.com", "coder.work", "voidcn.com", "it1352.com", "oommake.com", "py4u.net",
|
|
295
|
+
"wenku.zone", "chinadmd.com", "jb51.net", "pc6.com", "onlinedown.net")
|
|
296
|
+
farm_hits = [s for s in sources if any(m in str(s.get("url", "")).lower() for m in farm_markers)]
|
|
297
|
+
if farm_hits and len(farm_hits) >= (len(sources) + 1) // 2:
|
|
298
|
+
warnings.append({
|
|
299
|
+
"id": "content_farm_dominant",
|
|
300
|
+
"detail": f"{len(farm_hits)} sources originate from content farms or SEO scraper mirrors; evidence quality severely degraded.",
|
|
301
|
+
})
|
|
302
|
+
|
|
258
303
|
cap = "low" if failures else ("medium" if warnings else "high")
|
|
259
304
|
return {"passed": not failures, "conclusion_cap": cap, "failures": failures, "warnings": warnings}
|
|
260
305
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""zhihu_quota.py — 知乎开放平台额度查询(官方 `/api/v1/quota`)。
|
|
3
|
+
|
|
4
|
+
为什么需要(2026-10-21):知乎各能力的每日免费额度是**账号级**的,同一账号的
|
|
5
|
+
多个 Access Secret 共用;本地按引擎计数会与真实额度漂移。官方额度查询不消耗
|
|
6
|
+
业务额度,是唯一权威来源。本模块只做读取与缓存,不参与路由决策(路由仍用
|
|
7
|
+
quota.py 的本地计数),避免每次搜索多一次外呼。
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
argo quota # 人读表
|
|
11
|
+
argo quota --json # 机器读
|
|
12
|
+
argo quota --refresh # 跳过 10 分钟缓存
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import urllib.request
|
|
21
|
+
|
|
22
|
+
from cli_io import dumps
|
|
23
|
+
from engine_env import get_env
|
|
24
|
+
from engines_base import http_open
|
|
25
|
+
|
|
26
|
+
API_URL = "https://developer.zhihu.com/api/v1/quota"
|
|
27
|
+
CACHE_TTL_S = 600
|
|
28
|
+
|
|
29
|
+
# APIID → argo 引擎名(与 config.yaml / quota_profiles.json 对齐)
|
|
30
|
+
API_TO_ENGINE = {
|
|
31
|
+
"global_search": "zhihu_global",
|
|
32
|
+
"zhihu_search": "zhihu",
|
|
33
|
+
"hot_list": "zhihu_hot",
|
|
34
|
+
"question_discovery": "zhihu_questions",
|
|
35
|
+
"zhida_openai": "zhida",
|
|
36
|
+
"tools": "zhihu_tools",
|
|
37
|
+
"knowledge": "zhihu_kb",
|
|
38
|
+
"user_data": "zhihu_user",
|
|
39
|
+
"creator": "zhihu_creator",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cache_path():
|
|
44
|
+
import argo_paths
|
|
45
|
+
return argo_paths.state_path("zhihu_quota.json")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _secret() -> str:
|
|
49
|
+
return get_env(["ARGO_ZHIHU_ACCESS_SECRET", "ZHIHU_ACCESS_SECRET"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_cache() -> dict | None:
|
|
53
|
+
try:
|
|
54
|
+
raw = json.loads(_cache_path().read_text(encoding="utf-8"))
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
if not isinstance(raw, dict):
|
|
58
|
+
return None
|
|
59
|
+
if time.time() - float(raw.get("ts") or 0) > CACHE_TTL_S:
|
|
60
|
+
return None
|
|
61
|
+
return raw
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _save_cache(items: list[dict]) -> None:
|
|
65
|
+
try:
|
|
66
|
+
import argo_paths
|
|
67
|
+
argo_paths.atomic_write_json(_cache_path(),
|
|
68
|
+
{"ts": time.time(), "items": items})
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fetch_quota(*, refresh: bool = False, timeout: float = 8.0) -> dict:
|
|
74
|
+
"""返回 {ok, items, error, cached};永不抛异常(观测层不阻断主路径)。"""
|
|
75
|
+
if not refresh:
|
|
76
|
+
cached = _load_cache()
|
|
77
|
+
if cached is not None:
|
|
78
|
+
return {"ok": True, "items": cached.get("items") or [], "cached": True}
|
|
79
|
+
secret = _secret()
|
|
80
|
+
if not secret:
|
|
81
|
+
return {"ok": False, "items": [], "cached": False,
|
|
82
|
+
"error": "缺少 ZHIHU_ACCESS_SECRET"}
|
|
83
|
+
req = urllib.request.Request(API_URL, headers={
|
|
84
|
+
"Authorization": f"Bearer {secret}",
|
|
85
|
+
"X-Request-Timestamp": str(int(time.time())),
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"User-Agent": "argo-search/2.6 (unified-search@local)",
|
|
88
|
+
})
|
|
89
|
+
try:
|
|
90
|
+
with http_open(req, timeout=timeout, engine="zhihu_quota") as resp:
|
|
91
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
return {"ok": False, "items": [], "cached": False,
|
|
94
|
+
"error": f"{type(exc).__name__}: {exc}"}
|
|
95
|
+
if data.get("Code") not in (0, None):
|
|
96
|
+
return {"ok": False, "items": [], "cached": False,
|
|
97
|
+
"error": f"Code={data.get('Code')} {data.get('Message') or ''}"}
|
|
98
|
+
items = []
|
|
99
|
+
for row in data.get("Data") or []:
|
|
100
|
+
if not isinstance(row, dict):
|
|
101
|
+
continue
|
|
102
|
+
api_id = str(row.get("APIID") or "")
|
|
103
|
+
items.append({
|
|
104
|
+
"api_id": api_id,
|
|
105
|
+
"engine": API_TO_ENGINE.get(api_id, api_id),
|
|
106
|
+
"name": row.get("APIName") or api_id,
|
|
107
|
+
"total": row.get("TotalQuota"),
|
|
108
|
+
"used": row.get("TotalUsed"),
|
|
109
|
+
"remaining": row.get("RemainingQuota"),
|
|
110
|
+
})
|
|
111
|
+
_save_cache(items)
|
|
112
|
+
return {"ok": True, "items": items, "cached": False}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv: list[str] | None = None) -> int:
|
|
116
|
+
ap = argparse.ArgumentParser(prog="argo quota",
|
|
117
|
+
description="知乎开放平台当日额度(官方 API)")
|
|
118
|
+
ap.add_argument("--json", action="store_true")
|
|
119
|
+
ap.add_argument("--refresh", action="store_true")
|
|
120
|
+
args = ap.parse_args(argv)
|
|
121
|
+
out = fetch_quota(refresh=args.refresh)
|
|
122
|
+
if args.json:
|
|
123
|
+
print(dumps(out))
|
|
124
|
+
return 0 if out.get("ok") else 1
|
|
125
|
+
if not out.get("ok"):
|
|
126
|
+
print(f"额度查询失败:{out.get('error')}")
|
|
127
|
+
return 1
|
|
128
|
+
print("知乎开放平台当日额度(北京时间 00:00 重置)")
|
|
129
|
+
print(f"{'引擎':<18}{'额度项':<20}{'总额':>8}{'已用':>8}{'剩余':>8}")
|
|
130
|
+
for row in out["items"]:
|
|
131
|
+
print(f"{row['engine']:<18}{row['name']:<20}"
|
|
132
|
+
f"{str(row['total']):>8}{str(row['used']):>8}{str(row['remaining']):>8}")
|
|
133
|
+
if out.get("cached"):
|
|
134
|
+
print("(10 分钟内缓存;--refresh 强制刷新)")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
raise SystemExit(main())
|
|
@@ -43,6 +43,7 @@ _CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
|
|
43
43
|
|
|
44
44
|
_K1 = 1.5
|
|
45
45
|
_B = 0.75
|
|
46
|
+
_DELTA = 0.5 # BM25+ 下界 (Lv & Zhai 2011):长文档命中词保底,消除过惩罚
|
|
46
47
|
# 混合权重:BM25 侧。位次先验占 1 - _W_BM25。
|
|
47
48
|
_W_BM25 = 0.6
|
|
48
49
|
# 旧位次衰减口径(max(0.7 - idx*0.05, 0.1))——先验的归一化基准。
|
|
@@ -79,9 +80,10 @@ class BM25:
|
|
|
79
80
|
引擎的 5 条结果上稳定得多——「含查询词的文档有多罕见」是跨源问题。
|
|
80
81
|
"""
|
|
81
82
|
|
|
82
|
-
def __init__(self, docs: list[list[str]], k1: float = _K1, b: float = _B):
|
|
83
|
+
def __init__(self, docs: list[list[str]], k1: float = _K1, b: float = _B, delta: float = _DELTA):
|
|
83
84
|
self.k1 = k1
|
|
84
85
|
self.b = b
|
|
86
|
+
self.delta = delta
|
|
85
87
|
self.n_docs = len(docs)
|
|
86
88
|
self.doc_len = [len(d) for d in docs]
|
|
87
89
|
self.avgdl = (sum(self.doc_len) / self.n_docs) if self.n_docs else 0.0
|
|
@@ -113,10 +115,30 @@ class BM25:
|
|
|
113
115
|
f = self.tf.get(t, {}).get(doc_idx, 0)
|
|
114
116
|
if not f:
|
|
115
117
|
continue
|
|
116
|
-
|
|
118
|
+
sat = (f * (self.k1 + 1.0)) / (f + denom_norm)
|
|
119
|
+
# BM25+ 饱和项下界增益(Lv & Zhai 2011)
|
|
120
|
+
total += self.idf(t) * (sat + self.delta)
|
|
117
121
|
return total
|
|
118
122
|
|
|
119
123
|
|
|
124
|
+
def _phrase_proximity_bonus(query: str, title: str, snippet: str) -> float:
|
|
125
|
+
"""词邻近度与短语连续匹配加成(归一化为 0.0~1.0)。
|
|
126
|
+
|
|
127
|
+
当完整查询短语在标题或摘要逐字紧密出现时给予增益,提升复合词精准命中率。
|
|
128
|
+
"""
|
|
129
|
+
q_low = (query or "").lower().strip()
|
|
130
|
+
if not q_low or len(q_low) < 2:
|
|
131
|
+
return 0.0
|
|
132
|
+
t_low = (title or "").lower()
|
|
133
|
+
s_low = (snippet or "").lower()
|
|
134
|
+
if q_low in t_low:
|
|
135
|
+
return 1.0 # 标题完整覆盖查询短语
|
|
136
|
+
if q_low in s_low:
|
|
137
|
+
return 0.5 # 摘要完整覆盖查询短语
|
|
138
|
+
return 0.0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
120
142
|
def _doc_text(r: dict[str, Any]) -> str:
|
|
121
143
|
"""参与相关度计算的文档文本:title 权重高于 snippet——做法是标题
|
|
122
144
|
拼两遍(TF 加成),不引入第二套字段权重机制。"""
|
|
@@ -153,7 +175,9 @@ def rerank(by_engine: dict[str, list[dict[str, Any]]], query: str,
|
|
|
153
175
|
for (eng, idx, r), bm in zip(flat, raw):
|
|
154
176
|
bm25_norm = (bm / max_raw) if max_raw > 0 else 0.0
|
|
155
177
|
prior_norm = _position_prior(idx) / _PRIOR_BASE
|
|
156
|
-
|
|
178
|
+
prox_val = _phrase_proximity_bonus(query, r.get("title", ""), r.get("snippet", ""))
|
|
179
|
+
rel_score = min(bm25_norm * 0.80 + prox_val * 0.20, 1.0) if bm25_norm > 0 else 0.0
|
|
180
|
+
r["score"] = round(w_bm25 * rel_score + (1.0 - w_bm25) * prior_norm, 3)
|
|
157
181
|
|
|
158
182
|
for eng in by_engine:
|
|
159
183
|
by_engine[eng].sort(
|