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.
Files changed (154) hide show
  1. package/README.en.md +73 -150
  2. package/README.es.md +73 -150
  3. package/README.ja.md +79 -156
  4. package/README.ko.md +78 -155
  5. package/README.md +80 -159
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +78 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +269 -63
  15. package/docs/ARGO_INTRO.md +5 -3
  16. package/docs/ENGINE_CATALOG.md +50 -39
  17. package/docs/MCP_SETUP.md +3 -1
  18. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  19. package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
  20. package/engines/specs/cfets.yaml +41 -0
  21. package/engines/specs/defillama.yaml +38 -0
  22. package/engines/specs/firecrawl.yaml +1 -1
  23. package/engines/specs/fxtwitter.yaml +5 -0
  24. package/engines/specs/gleif.yaml +55 -0
  25. package/engines/specs/hkex.yaml +40 -0
  26. package/engines/specs/keenable_free.yaml +36 -0
  27. package/engines/specs/shfe.yaml +39 -0
  28. package/engines/specs/stackexchange.yaml +2 -2
  29. package/engines/specs/tavily_free.yaml +56 -0
  30. package/engines/specs/yuanbao.yaml +93 -0
  31. package/mcp/clients.yaml +32 -0
  32. package/package.json +7 -2
  33. package/packages/dsh-plugin/README.md +25 -6
  34. package/packages/dsh-plugin/dsh/client.js +402 -47
  35. package/packages/dsh-plugin/dsh/diagnostics.js +97 -0
  36. package/packages/dsh-plugin/dsh/index.js +135 -1876
  37. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  38. package/packages/dsh-plugin/dsh/runner.js +666 -0
  39. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  40. package/packages/dsh-plugin/package.json +3 -3
  41. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  42. package/packages/dsh-plugin/test/client.test.js +177 -2
  43. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  44. package/packages/dsh-plugin/test/index.test.js +44 -0
  45. package/scripts/adaptive.py +126 -43
  46. package/scripts/article_meta.py +131 -0
  47. package/scripts/bench_tail_ab.py +281 -0
  48. package/scripts/bounded_run.py +91 -49
  49. package/scripts/budget.py +247 -0
  50. package/scripts/budget_cli.py +93 -0
  51. package/scripts/build_release_assets.sh +65 -0
  52. package/scripts/cache.py +60 -72
  53. package/scripts/ci_drift_gate.py +90 -0
  54. package/scripts/clock_bridge.py +22 -0
  55. package/scripts/content_boundary.py +46 -0
  56. package/scripts/content_security.py +119 -4
  57. package/scripts/content_signals.py +26 -5
  58. package/scripts/cross_lingual_trace.py +293 -0
  59. package/scripts/dispatch_result.py +105 -0
  60. package/scripts/engine_add.py +602 -0
  61. package/scripts/engine_dispatch.py +58 -86
  62. package/scripts/engine_env.py +5 -0
  63. package/scripts/engine_families.py +6 -0
  64. package/scripts/engine_policy.py +7 -7
  65. package/scripts/engines.py +21 -0
  66. package/scripts/engines_base.py +3 -3
  67. package/scripts/engines_builders.py +12 -0
  68. package/scripts/engines_builders_batch10.py +48 -3
  69. package/scripts/engines_builders_batch14.py +656 -0
  70. package/scripts/engines_builders_batch15.py +249 -0
  71. package/scripts/engines_builders_keyless.py +111 -0
  72. package/scripts/engines_builders_tech.py +0 -1
  73. package/scripts/evidence.py +31 -3
  74. package/scripts/evidence_loop.py +58 -22
  75. package/scripts/fetch_quality.py +13 -0
  76. package/scripts/fetch_v3.py +28 -3
  77. package/scripts/gen_routing_snapshot.py +11 -0
  78. package/scripts/http_client.py +24 -38
  79. package/scripts/install.ps1 +18 -0
  80. package/scripts/install.sh +16 -1
  81. package/scripts/job.py +3 -3
  82. package/scripts/local_seek.py +41 -5
  83. package/scripts/macro_countries.py +15 -0
  84. package/scripts/matrix_search_eval.py +192 -0
  85. package/scripts/mcp_compact.py +234 -0
  86. package/scripts/mcp_handlers.py +60 -642
  87. package/scripts/mcp_launch.sh +10 -8
  88. package/scripts/mcp_local.py +241 -0
  89. package/scripts/mcp_server.py +19 -14
  90. package/scripts/mcp_setup.py +33 -9
  91. package/scripts/mcp_tools.py +1 -0
  92. package/scripts/mcp_transport.py +1 -1
  93. package/scripts/mcp_warm.py +127 -0
  94. package/scripts/net_proxy.py +27 -0
  95. package/scripts/output_transparency.py +200 -0
  96. package/scripts/paid_policy.py +98 -0
  97. package/scripts/quota.py +158 -13
  98. package/scripts/recovery.py +7 -3
  99. package/scripts/recovery_runner.py +150 -0
  100. package/scripts/replay_eval.py +18 -0
  101. package/scripts/research.py +37 -21
  102. package/scripts/research_expand.py +24 -1
  103. package/scripts/retry_policy.py +76 -0
  104. package/scripts/route.py +6 -6
  105. package/scripts/route_cache.py +18 -0
  106. package/scripts/route_combo.py +26 -5
  107. package/scripts/route_domains.py +170 -31
  108. package/scripts/safe_conn.py +52 -0
  109. package/scripts/search.py +15 -27
  110. package/scripts/search_attribution.py +100 -3
  111. package/scripts/search_cli.py +24 -11
  112. package/scripts/search_entry.py +8 -1
  113. package/scripts/search_output.py +99 -15
  114. package/scripts/search_pipeline.py +62 -195
  115. package/scripts/search_rank.py +6 -0
  116. package/scripts/search_types.py +107 -9
  117. package/scripts/semantic_evidence.py +179 -3
  118. package/scripts/serp_guard.py +138 -7
  119. package/scripts/serp_kill_log.py +25 -2
  120. package/scripts/stats_cli.py +13 -1
  121. package/scripts/tail_completion.py +212 -0
  122. package/scripts/url_safety.py +126 -3
  123. package/scripts/usage_log.py +45 -0
  124. package/scripts/wide_research.py +45 -0
  125. package/scripts/zhihu_quota.py +139 -0
  126. package/sub-skills/local-search/bm25.py +27 -3
  127. package/sub-skills/local-search/cli_runner.py +331 -0
  128. package/sub-skills/local-search/parsers.py +328 -0
  129. package/sub-skills/local-search/search_v3.py +31 -674
  130. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  131. package/assets/readme/settings-keys.png +0 -0
  132. package/assets/readme/settings-tools.png +0 -0
  133. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  134. package/sub-skills/ego-search/references/install.md +0 -67
  135. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  136. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  137. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  138. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  139. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  140. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  141. package/sub-skills/local-seek/references/git-integration.md +0 -54
  142. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  143. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  144. package/sub-skills/local-seek/references/strategies.md +0 -52
  145. package/sub-skills/local-seek/references/structural-search.md +0 -59
  146. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  147. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  148. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  149. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  150. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  151. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  152. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  153. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  154. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -43,6 +43,31 @@ logger = logging.getLogger("unified_search.semantic_evidence")
43
43
 
44
44
  DEFAULT_ENDPOINT = "https://classifier.dev"
45
45
  DEFAULT_TIMEOUT_S = 5.0
46
+ # 未声明 transport 时的默认通道。jev = TypeSafe System One(当前唯一可用);
47
+ # classifier.dev 的匿名路径 2026-10-22 起回 403 proxy_requires_payment。
48
+ DEFAULT_TRANSPORT = "jev"
49
+
50
+ # ── JEV / System One 通道(TypeSafe)──────────────────────────────────────
51
+ # 官方 API:POST https://api.typesafe.ai/v1/systemone,Bearer 鉴权
52
+ # (docs.typesafe.ai/api/system-one)。与 classifier.dev 的 /v1/classify 是
53
+ # **两种形状**:那边一次发 N 条文本 + 一组标签;这边一次发一个 state + 若干
54
+ # 具名问题,每题独立作答(文档原话:questions are evaluated in parallel and
55
+ # in isolation)。所以接过来要做的是形状转换,不是换个 URL。
56
+ DEFAULT_JEV_ENDPOINT = "https://api.typesafe.ai/v1/systemone"
57
+ DEFAULT_JEV_MODEL = "jev-latest"
58
+ # key 候选链:argo 推荐名在前;TYPESAFE_API_KEY 是官方 SDK 与 JEV 插件
59
+ # (dsh-verbatim-compaction)用的名字,照文档配的人直接可用。
60
+ JEV_KEY_ENV = ("ARGO_JEV_API_KEY", "TYPESAFE_API_KEY")
61
+ # state 超过这个长度就带长上下文头。实测(2026-10-22 本机):
62
+ # 不带 TypeSafe-Danger-Zone-Long-Context:32K tokens 通过(32350)、
63
+ # 40K 中文字(≈40K tokens)回 `max_tokens_exceeded`(400)
64
+ # 带上:40K 字(40358 tokens)与 80K 字(80358 tokens)均通过
65
+ # 24000 字是保守拐点(正常路径不带头也稳),不是实测上限。
66
+ JEV_LONG_CONTEXT_CHARS = 24000
67
+ # 并发上限。官方 cookbook(classifying_rag_passages)取 4,理由是端点限流;
68
+ # 429/529 要求指数退避,所以不铺大。
69
+ JEV_MAX_WORKERS = 4
70
+
46
71
  # conf ≥ 0.7 才给结论:低于此值的判定不可信(B 实测 4 个严格错误全部 ≤0.67)
47
72
  SUPPORT_THRESHOLD = 0.7
48
73
  # 单条文本上限:核验链路取回的正文 ≤8000 字,再高无收益(A 实测 400→7798 字
@@ -100,20 +125,167 @@ def _timeout() -> float:
100
125
  return DEFAULT_TIMEOUT_S
101
126
 
102
127
 
128
+ def jev_api_key() -> str:
129
+ """JEV(TypeSafe)key:os.environ 优先,~/.config/argo/env 热读保底。"""
130
+ try:
131
+ from engine_env import get_env
132
+ return (get_env(list(JEV_KEY_ENV)) or "").strip()
133
+ except Exception: # pragma: no cover - 环境层不可用时不冒险
134
+ import os
135
+ for _n in JEV_KEY_ENV:
136
+ _v = (os.environ.get(_n) or "").strip()
137
+ if _v:
138
+ return _v
139
+ return ""
140
+
141
+
142
+ def _transport() -> str:
143
+ """通道选择:`jev`(TypeSafe System One)或 `classify`(classifier.dev)。
144
+
145
+ **纯配置驱动,不做「按键就绪自动选」**。初版按 jev_api_key() 是否就绪自动
146
+ 选,结果是通道取决于本机 ~/.config/argo/env 里有没有那把 key——同一份代码
147
+ 在开发机与 CI 上走不同分支,既有测试在开发机上会静默改道(实测:写进 key
148
+ 后 test_semantic_evidence 的 5 条 HttpClient 桩里 1 条改走 JEV 路径而挂)。
149
+ 机器状态不该改变代码路径。
150
+
151
+ 未声明时的默认是 jev:classifier.dev 的匿名路径 2026-10-22 起回 403
152
+ proxy_requires_payment,JEV 是当前可用的那条。
153
+ """
154
+ declared = str(_config().get("transport") or "").strip().lower()
155
+ return declared if declared in ("jev", "classify") else DEFAULT_TRANSPORT
156
+
157
+
158
+ def _jev_endpoint() -> str:
159
+ """JEV 端点:**独立于 classify 通道的 endpoint 字段**。
160
+
161
+ 不共用是刻意的坑修:config.yaml 里 `endpoint` 长期写的是
162
+ https://classifier.dev,JEV 通道若读同一个字段,就会把 System One 的
163
+ 请求体发到 classifier.dev,拿回「Invalid API key / Create a workspace
164
+ key at /app/keys」这种指向完全错误方向的 401(2026-10-22 实测踩中)。
165
+ 每个通道自带端点,杜绝「换 transport 忘了换 endpoint」。
166
+ """
167
+ return str(_config().get("jev_endpoint") or DEFAULT_JEV_ENDPOINT).rstrip("/")
168
+
169
+
170
+ def _jev_model() -> str:
171
+ return str(_config().get("jev_model") or _config().get("model")
172
+ or DEFAULT_JEV_MODEL)
173
+
174
+
175
+ def _jev_post(path_state: Any, questions: dict[str, Any], *,
176
+ timeout: float) -> Optional[dict[str, Any]]:
177
+ """一次 System One 请求;失败返回 None(fail-open 的全部出口都在这里)。"""
178
+ key = jev_api_key()
179
+ if not key:
180
+ logger.debug("JEV key 未配置(%s),语义层跳过", " / ".join(JEV_KEY_ENV))
181
+ return None
182
+ body = {
183
+ "model": _jev_model(),
184
+ "state": path_state,
185
+ "questions": questions,
186
+ }
187
+ # Content-Type 必须显式给:HttpClient.post 会把 dict 做 json.dumps,
188
+ # 但**从不设 Content-Type**(头只来自浏览器 UA 模板 + extra_headers)。
189
+ # 缺它时 FastAPI 侧把 body 当字符串,回
190
+ # 422 {"loc":["body"],"msg":"Input should be a valid dictionary…"}
191
+ # ——报错指向「请求体不是对象」,而真因是少了一个头(2026-10-22 实测踩中)。
192
+ headers = {"Authorization": f"Bearer {key}",
193
+ "Content-Type": "application/json"}
194
+ # 长上下文要显式带头,否则 state 一过 32K tokens 就 400 max_tokens_exceeded
195
+ if len(json.dumps(path_state, ensure_ascii=False)) > JEV_LONG_CONTEXT_CHARS:
196
+ headers["TypeSafe-Danger-Zone-Long-Context"] = "true"
197
+ try:
198
+ from http_client import HttpClient
199
+ resp = HttpClient(timeout=timeout, max_retries=1, jitter=False).post(
200
+ _jev_endpoint(), body, extra_headers=headers)
201
+ if resp.get("status") != 200:
202
+ logger.debug("JEV 非 200: %s %s", resp.get("status"),
203
+ str(resp.get("text") or resp.get("error") or "")[:200])
204
+ return None
205
+ return json.loads(resp.get("text") or "{}")
206
+ except Exception as e: # fail-open:语义层任何问题都不阻断核验
207
+ logger.debug("JEV 请求失败: %s", type(e).__name__)
208
+ return None
209
+
210
+
211
+ def _jev_choice(text: str, labels: tuple[str, ...] | list[str],
212
+ instructions: str | None, timeout: float
213
+ ) -> Optional[dict[str, Any]]:
214
+ """单条文本 → 一个 Choice 问题。
215
+
216
+ **每条一个请求**,不是「多段一个 state、每段一问」。理由在官方文档里:
217
+ jaggedness 那篇明写「state 里塞与决策无关的细节会降低准确率(context
218
+ rot)」,而判第 i 段时其余各段就是无关细节;cookbook 的
219
+ classifying_rag_passages 也是「One request per passage」。(我们试过 3 段
220
+ 合一,结果全对,但样本太小,不拿它赌准确率。)
221
+ """
222
+ out = _jev_post(text, {
223
+ "cls": {
224
+ "type": "choice",
225
+ "instructions": instructions or "Classify the state into one of the criteria.",
226
+ # criteria 的**值**是描述:jaggedness 说 instructions 与 criteria
227
+ # 不一致时模型会困惑,所以两边用同一句话,不给它两个版本。
228
+ "criteria": {str(lab): str(lab) for lab in labels},
229
+ },
230
+ }, timeout=timeout)
231
+ if not isinstance(out, dict):
232
+ return None
233
+ ans = (out.get("answers") or {}).get("cls")
234
+ if not isinstance(ans, dict) or not ans.get("choice"):
235
+ if isinstance(ans, dict):
236
+ logger.debug("JEV 答案缺 choice: %s", str(ans)[:120])
237
+ return None
238
+ return {
239
+ "label": str(ans.get("choice")),
240
+ "confidence": ans.get("confidence"),
241
+ "scores": ans.get("probabilities"),
242
+ }
243
+
244
+
245
+ def _classify_jev(texts: list[str], labels: tuple[str, ...] | list[str], *,
246
+ instructions: str | None, keep: list[tuple[int, str]],
247
+ timeout: float) -> Optional[list[dict[str, Any]]]:
248
+ """JEV 通道:每条文本一个请求,并发 4。
249
+
250
+ 任一条失败就在该位留 None(与 classifier.dev 通道「整批失败即 None」
251
+ 不同):一次核验有几条正文,坏一条不该把其余判定的价值也丢掉。全部失败
252
+ 时仍返回 None,调用方行为与未开启时一致。
253
+ """
254
+ from concurrent.futures import ThreadPoolExecutor
255
+ texts_kept = [t[:MAX_TEXT_CHARS] for _i, t in keep]
256
+ with ThreadPoolExecutor(max_workers=min(JEV_MAX_WORKERS, len(texts_kept))) as pool:
257
+ results = list(pool.map(
258
+ lambda t: _jev_choice(t, labels, instructions, timeout), texts_kept))
259
+ if not any(r for r in results):
260
+ return None
261
+ out: list[Optional[dict[str, Any]]] = [None] * len(texts)
262
+ for (idx, _t), r in zip(keep, results):
263
+ if isinstance(r, dict):
264
+ out[idx] = r
265
+ return out # type: ignore[return-value]
266
+
267
+
103
268
  def classify(texts: list[str], labels: tuple[str, ...] | list[str], *,
104
269
  instructions: str | None = None,
105
270
  timeout: float | None = None,
106
271
  tier: str | None = None) -> Optional[list[dict[str, Any]]]:
107
- """一次批量分类(≤1000 条)。任何异常/非 200/条数不匹配 → None。
272
+ """一次批量分类。任何异常/非 200/条数不匹配 → None。
108
273
 
109
274
  返回按输入顺序排列的 [{label, confidence, scores}];调用方负责判定阈值。
110
275
  空文本会被过滤后再发(空串会让整批 400),返回列表与输入等长、空缺位为 None。
276
+
277
+ 两个通道的实现分居 _classify_jev(TypeSafe,每条一请求)与下方内联的
278
+ classifier.dev 路径(一次发 N 条);开关判定见 _transport。
111
279
  """
112
280
  if not texts or len(labels) < 2:
113
281
  return None
114
282
  keep = [(i, t) for i, t in enumerate(texts) if (t or "").strip()]
115
283
  if not keep:
116
284
  return None
285
+ to = timeout or _timeout()
286
+ if _transport() == "jev":
287
+ return _classify_jev(texts, labels, instructions=instructions,
288
+ keep=keep, timeout=to)
117
289
  body: dict[str, Any] = {
118
290
  "labels": [str(x) for x in labels],
119
291
  "inputs": [t[:MAX_TEXT_CHARS] for _i, t in keep],
@@ -123,8 +295,12 @@ def classify(texts: list[str], labels: tuple[str, ...] | list[str], *,
123
295
  body["instructions"] = instructions
124
296
  try:
125
297
  from http_client import HttpClient
126
- resp = HttpClient(timeout=timeout or _timeout(), max_retries=1,
127
- jitter=False).post(_endpoint(), body)
298
+ # Content-Type 同 JEV 通道:HttpClient.post 不设它,缺了会被服务端
299
+ # 当字符串解析。这条路径此前只在 403(匿名要付费 key)处止步,没走到
300
+ # body 校验,所以同一个坑没暴露——配了有效 workspace key 就会现形。
301
+ resp = HttpClient(timeout=to, max_retries=1, jitter=False).post(
302
+ _endpoint(), body,
303
+ extra_headers={"Content-Type": "application/json"})
128
304
  if resp.get("status") != 200:
129
305
  logger.debug(f"语义分类非 200: {resp.get('status')} {resp.get('error')}")
130
306
  return None
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- """serp_guard — HTML SERP 垃圾结果守卫(判定规则单点)。
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 条全部与 query token 零交集 → 整页判垃圾(True)。
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 = _extract_tokens(query if isinstance(query, str) else "")
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
- if _extract_tokens(_result_text(item)) & q_tokens:
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,111 @@ 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
+
337
+
338
+ # ── 自适应放宽建议(2026-10-23 方案四)───────────────────────────────────
339
+
340
+ _RELAX_TAIL_RE = re.compile(
341
+ r"(?:\b\d{4}年?(?:度)?|\b\d{1,2}月|"
342
+ r"最新|最全|详细|完整|全面|2020|2021|2022|2023|2024|2025|2026|"
343
+ r"知乎|贴吧|论坛|大全|汇总|排行榜|一览|数据|报告|统计|分析|趋势)"
344
+ )
345
+
346
+
347
+ def suggest_relaxed_query(query: str) -> str:
348
+ """针对 SERP 守卫击杀场景的自适应放宽查询(剥离过度约束的修饰与年份词)。"""
349
+ if not query or not isinstance(query, str):
350
+ return ""
351
+ q = _RELAX_TAIL_RE.sub(" ", query)
352
+ q = re.sub(r"(?<=\s)[年月](?=\s|$)|^([年月]\s+)", " ", q)
353
+ tokens = re.findall(r"[\u4e00-\u9fffA-Za-z0-9]+", q)
354
+ stopwords = {"的", "了", "呢", "吧", "请问", "怎么", "如何", "一下", "关于", "有关", "求", "谁知道",
355
+ "the", "a", "an", "of", "for", "to", "how", "what"}
356
+ kept = [t for t in tokens if t.lower() not in stopwords]
357
+ relaxed = " ".join(kept).strip()
358
+ return relaxed or re.sub(r"\s+", " ", q).strip() or query
359
+
@@ -16,9 +16,11 @@ import threading
16
16
  from typing import Any
17
17
 
18
18
  # 判定规则与逃生门(ARGO_SERP_GUARD=0)在 serp_guard 单点;这里只登记
19
- # 哪些引擎接了守卫(解析上游 HTML 页最易被缓存 SERP 静默命中)。
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
@@ -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
- return {"samples": 0}
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()