argo-search 1.0.1 → 2.9.7

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 (463) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +822 -0
  3. package/README.es.md +864 -0
  4. package/README.ja.md +864 -0
  5. package/README.ko.md +864 -0
  6. package/README.md +867 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/settings-keys.png +0 -0
  12. package/assets/readme/settings-tools.png +0 -0
  13. package/assets/readme/why-better.svg +104 -0
  14. package/assets/readme/workflow.svg +83 -0
  15. package/backends/domain_profiles.json +2308 -96
  16. package/backends/engine_registry.yaml +3314 -503
  17. package/backends/query_synonyms_cn.json +35 -0
  18. package/backends/quota_profiles.json +2409 -161
  19. package/backends/source_types_cn.json +83 -0
  20. package/backends/text_lexicon.json +44 -0
  21. package/bin/argo +703 -0
  22. package/bin/argo.js +71 -39
  23. package/config.yaml +4976 -363
  24. package/cordis.patch.yml +21 -0
  25. package/data/countries.json +40 -0
  26. package/data/lang_matrix/lang_capability.json +842 -0
  27. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  28. package/data/regions_cn.json +1 -0
  29. package/docs/ADDING_NEW_ENGINE.md +184 -0
  30. package/docs/ARGO_INTRO.md +124 -0
  31. package/docs/DATA_SOURCE_GAPS.md +81 -0
  32. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  33. package/docs/ENGINE_CATALOG.md +564 -0
  34. package/docs/MCP_SETUP.md +106 -0
  35. package/docs/PLATFORM_PATHS.md +111 -0
  36. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  37. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  38. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  39. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  40. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  41. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  42. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  43. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  44. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  45. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  46. package/docs/SEARCH_ARCHIVE.md +115 -0
  47. package/docs/assets/architecture.svg +104 -0
  48. package/docs/assets/hero.svg +98 -0
  49. package/docs/engines/bangumi.md +27 -0
  50. package/docs/engines/biorxiv.md +27 -0
  51. package/docs/engines/clawhub.md +27 -0
  52. package/docs/engines/crt_sh.md +27 -0
  53. package/docs/engines/datacite.md +27 -0
  54. package/docs/engines/deps_dev.md +27 -0
  55. package/docs/engines/douban_movie.md +27 -0
  56. package/docs/engines/endoflife.md +27 -0
  57. package/docs/engines/flk_law.md +27 -0
  58. package/docs/engines/fxtwitter.md +27 -0
  59. package/docs/engines/google_news.md +27 -0
  60. package/docs/engines/gov_regulations.md +27 -0
  61. package/docs/engines/guqian.md +27 -0
  62. package/docs/engines/hackernews.md +27 -0
  63. package/docs/engines/iplant.md +27 -0
  64. package/docs/engines/keenable.md +27 -0
  65. package/docs/engines/met_museum.md +27 -0
  66. package/docs/engines/nasa_images.md +27 -0
  67. package/docs/engines/netease_music.md +27 -0
  68. package/docs/engines/npm.md +27 -0
  69. package/docs/engines/nvd.md +27 -0
  70. package/docs/engines/openreview.md +27 -0
  71. package/docs/engines/openstd.md +27 -0
  72. package/docs/engines/parallel_free.md +27 -0
  73. package/docs/engines/people_daily.md +27 -0
  74. package/docs/engines/realtime_index.md +27 -0
  75. package/docs/engines/sec_edgar.md +27 -0
  76. package/docs/engines/seltz.md +27 -0
  77. package/docs/engines/skillsmp.md +27 -0
  78. package/docs/engines/sspai.md +27 -0
  79. package/docs/engines/std_samr.md +27 -0
  80. package/docs/engines/train.md +27 -0
  81. package/docs/engines/un_comtrade.md +27 -0
  82. package/docs/engines/wikisource.md +27 -0
  83. package/docs/engines/zdic.md +27 -0
  84. package/docs/engines/zenodo.md +27 -0
  85. package/docs/engines/zhihu_hot_app.md +27 -0
  86. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  87. package/engines/README.md +10 -0
  88. package/engines/_template_cli.yaml +39 -0
  89. package/engines/_template_http.yaml +37 -0
  90. package/engines/plugins/_template_plugin.py +50 -0
  91. package/engines/specs/_cisa_kev.yaml +54 -0
  92. package/engines/specs/_osv.yaml +63 -0
  93. package/engines/specs/aljazeera.yaml +43 -0
  94. package/engines/specs/antara.yaml +43 -0
  95. package/engines/specs/artic.yaml +20 -0
  96. package/engines/specs/aviation_weather.yaml +39 -0
  97. package/engines/specs/bangumi.yaml +21 -0
  98. package/engines/specs/biorxiv.yaml +24 -0
  99. package/engines/specs/carbon_intensity.yaml +32 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/deps_dev.yaml +21 -0
  113. package/engines/specs/digup.yaml +36 -0
  114. package/engines/specs/doi.yaml +39 -0
  115. package/engines/specs/douban_movie.yaml +22 -0
  116. package/engines/specs/dw_news.yaml +47 -0
  117. package/engines/specs/egov_law.yaml +21 -0
  118. package/engines/specs/elpais.yaml +43 -0
  119. package/engines/specs/endoflife.yaml +21 -0
  120. package/engines/specs/energy_charts.yaml +21 -0
  121. package/engines/specs/factcheck_org.yaml +52 -0
  122. package/engines/specs/faz.yaml +43 -0
  123. package/engines/specs/federal_register.yaml +57 -0
  124. package/engines/specs/firecrawl.yaml +53 -0
  125. package/engines/specs/flk_law.yaml +24 -0
  126. package/engines/specs/folha.yaml +43 -0
  127. package/engines/specs/france24.yaml +44 -0
  128. package/engines/specs/full_fact.yaml +47 -0
  129. package/engines/specs/fxtwitter.yaml +31 -0
  130. package/engines/specs/gbfs_nyc.yaml +32 -0
  131. package/engines/specs/gdacs.yaml +21 -0
  132. package/engines/specs/gdelt.yaml +64 -0
  133. package/engines/specs/google_news.yaml +22 -0
  134. package/engines/specs/gov_regulations.yaml +38 -0
  135. package/engines/specs/guardian_rss.yaml +59 -0
  136. package/engines/specs/hurriyet.yaml +43 -0
  137. package/engines/specs/iplant.yaml +25 -0
  138. package/engines/specs/jikan.yaml +24 -0
  139. package/engines/specs/jolpica.yaml +20 -0
  140. package/engines/specs/k10plus.yaml +20 -0
  141. package/engines/specs/keenable.yaml +44 -0
  142. package/engines/specs/lefigaro.yaml +43 -0
  143. package/engines/specs/listenbrainz.yaml +19 -0
  144. package/engines/specs/matichon.yaml +43 -0
  145. package/engines/specs/met_museum.yaml +21 -0
  146. package/engines/specs/nasa_images.yaml +45 -0
  147. package/engines/specs/netease_music.yaml +38 -0
  148. package/engines/specs/nhk.yaml +43 -0
  149. package/engines/specs/nhtsa_vpic.yaml +20 -0
  150. package/engines/specs/noaa_swpc.yaml +20 -0
  151. package/engines/specs/nvd.yaml +38 -0
  152. package/engines/specs/obis.yaml +20 -0
  153. package/engines/specs/opencitations.yaml +52 -0
  154. package/engines/specs/openf1.yaml +21 -0
  155. package/engines/specs/openfoodfacts.yaml +45 -0
  156. package/engines/specs/openligadb.yaml +20 -0
  157. package/engines/specs/openreview.yaml +37 -0
  158. package/engines/specs/openstd.yaml +21 -0
  159. package/engines/specs/people_daily.yaml +22 -0
  160. package/engines/specs/realtime_index.yaml +34 -0
  161. package/engines/specs/ror.yaml +20 -0
  162. package/engines/specs/satnogs.yaml +20 -0
  163. package/engines/specs/sec_edgar.yaml +46 -0
  164. package/engines/specs/skillsmp.yaml +50 -0
  165. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  166. package/engines/specs/soilgrids.yaml +25 -0
  167. package/engines/specs/sspai.yaml +36 -0
  168. package/engines/specs/stackexchange.yaml +46 -0
  169. package/engines/specs/std_samr.yaml +21 -0
  170. package/engines/specs/tass.yaml +43 -0
  171. package/engines/specs/tle_mirror.yaml +21 -0
  172. package/engines/specs/train.yaml +37 -0
  173. package/engines/specs/tvmaze.yaml +20 -0
  174. package/engines/specs/twitter_syndication.yaml +31 -0
  175. package/engines/specs/un_comtrade.yaml +23 -0
  176. package/engines/specs/unpaywall.yaml +54 -0
  177. package/engines/specs/vnpress.yaml +43 -0
  178. package/engines/specs/weather.yaml +32 -0
  179. package/engines/specs/who_don.yaml +21 -0
  180. package/engines/specs/who_gho.yaml +20 -0
  181. package/engines/specs/wikimedia_commons.yaml +34 -0
  182. package/engines/specs/wikisource.yaml +22 -0
  183. package/engines/specs/worms.yaml +19 -0
  184. package/engines/specs/x_trending.yaml +34 -0
  185. package/engines/specs/yna.yaml +43 -0
  186. package/engines/specs/ynet.yaml +43 -0
  187. package/engines/specs/zbmath.yaml +40 -0
  188. package/engines/specs/zdic.yaml +22 -0
  189. package/engines/specs/zenodo.yaml +27 -0
  190. package/engines/specs/zhihu_hot_app.yaml +35 -0
  191. package/mcp/clients.yaml +74 -0
  192. package/package.json +51 -12
  193. package/packages/dsh-plugin/README.md +134 -0
  194. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  195. package/packages/dsh-plugin/dsh/client.js +1244 -0
  196. package/packages/dsh-plugin/dsh/index.js +2290 -0
  197. package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
  198. package/packages/dsh-plugin/package.json +38 -0
  199. package/packages/dsh-plugin/test/client.test.js +232 -0
  200. package/packages/dsh-plugin/test/config.test.js +30 -0
  201. package/packages/dsh-plugin/test/index.test.js +1338 -0
  202. package/references/query-templates.md +96 -0
  203. package/references/query-templates.yaml +118 -0
  204. package/scripts/ab_eval.py +219 -0
  205. package/scripts/ab_eval_p0p1.py +266 -0
  206. package/scripts/adaptive.py +271 -70
  207. package/scripts/answer.py +154 -0
  208. package/scripts/archive_run.py +591 -0
  209. package/scripts/argo_engine_registry.py +172 -0
  210. package/scripts/argo_paths.py +728 -0
  211. package/scripts/article.py +136 -0
  212. package/scripts/arxiv_source.py +201 -0
  213. package/scripts/badcase_ledger.py +252 -0
  214. package/scripts/batch_probe.py +241 -0
  215. package/scripts/benchmark.py +19 -0
  216. package/scripts/bounded_run.py +144 -0
  217. package/scripts/browser_auth.py +323 -0
  218. package/scripts/burstiness_detector.py +147 -0
  219. package/scripts/cache.py +1078 -67
  220. package/scripts/cache_guard.py +271 -0
  221. package/scripts/cache_key_vdom.py +56 -0
  222. package/scripts/cache_similarity.py +170 -0
  223. package/scripts/caliber_isolation.py +195 -0
  224. package/scripts/calibrate_lowquality.py +210 -0
  225. package/scripts/candidate_envelope.py +366 -0
  226. package/scripts/chrome_cdp.py +871 -0
  227. package/scripts/circuit_breaker.py +389 -0
  228. package/scripts/citations.py +334 -0
  229. package/scripts/cite_cli.py +59 -0
  230. package/scripts/cjk_tokens.py +95 -0
  231. package/scripts/clarify.py +47 -24
  232. package/scripts/cli_io.py +240 -0
  233. package/scripts/config.py +683 -45
  234. package/scripts/config_cache.py +402 -0
  235. package/scripts/conn_pool.py +220 -0
  236. package/scripts/content_security.py +685 -0
  237. package/scripts/content_signals.py +730 -0
  238. package/scripts/crawl.py +74 -35
  239. package/scripts/daemon.py +866 -0
  240. package/scripts/digup_bridge.py +214 -0
  241. package/scripts/engine_admission.py +366 -0
  242. package/scripts/engine_dispatch.py +1019 -0
  243. package/scripts/engine_env.py +534 -0
  244. package/scripts/engine_failure.py +411 -0
  245. package/scripts/engine_families.py +732 -0
  246. package/scripts/engine_policy.py +218 -0
  247. package/scripts/engine_requires.py +190 -0
  248. package/scripts/engine_status.py +322 -0
  249. package/scripts/engine_validate.py +638 -0
  250. package/scripts/engines.py +778 -294
  251. package/scripts/engines_base.py +1521 -0
  252. package/scripts/engines_builders.py +298 -0
  253. package/scripts/engines_builders_batch10.py +159 -0
  254. package/scripts/engines_builders_batch11.py +174 -0
  255. package/scripts/engines_builders_batch12.py +103 -0
  256. package/scripts/engines_builders_batch13.py +340 -0
  257. package/scripts/engines_builders_batch9.py +1468 -0
  258. package/scripts/engines_builders_cn.py +2283 -0
  259. package/scripts/engines_builders_data.py +2923 -0
  260. package/scripts/engines_builders_data_macro.py +775 -0
  261. package/scripts/engines_builders_feeds.py +255 -0
  262. package/scripts/engines_builders_intl.py +1239 -0
  263. package/scripts/engines_builders_search.py +440 -0
  264. package/scripts/engines_builders_tech.py +1360 -0
  265. package/scripts/evidence.py +588 -162
  266. package/scripts/evidence_loop.py +592 -0
  267. package/scripts/evidence_tier.py +298 -0
  268. package/scripts/except_sets.py +25 -0
  269. package/scripts/extract.py +55 -16
  270. package/scripts/fact_align.py +207 -0
  271. package/scripts/fact_crosscheck.py +230 -0
  272. package/scripts/fetch.py +25 -12
  273. package/scripts/fetch_browser.py +87 -0
  274. package/scripts/fetch_jsrun.py +122 -0
  275. package/scripts/fetch_quality.py +225 -0
  276. package/scripts/fetch_render_tinyfish.py +117 -0
  277. package/scripts/fetch_v3.py +1779 -0
  278. package/scripts/focus_extract.py +350 -0
  279. package/scripts/fulltext_store.py +169 -0
  280. package/scripts/fxtwitter_api.py +325 -0
  281. package/scripts/gen_engine_catalog.py +364 -0
  282. package/scripts/gen_native_tools.py +91 -0
  283. package/scripts/gen_routing_snapshot.py +184 -0
  284. package/scripts/health_check.py +140 -402
  285. package/scripts/health_probe.py +23 -8
  286. package/scripts/hot_state.py +134 -0
  287. package/scripts/http_client.py +1053 -0
  288. package/scripts/image_ops.py +430 -0
  289. package/scripts/image_vision/vision_probe +0 -0
  290. package/scripts/image_vision/vision_probe.swift +126 -0
  291. package/scripts/install.ps1 +148 -0
  292. package/scripts/install.sh +206 -0
  293. package/scripts/job.py +1186 -0
  294. package/scripts/jump_resolver.py +235 -0
  295. package/scripts/lang_capability.py +150 -0
  296. package/scripts/lang_detect.py +289 -0
  297. package/scripts/lang_matrix_probe.py +329 -0
  298. package/scripts/lang_pref.py +311 -0
  299. package/scripts/link_source.py +340 -0
  300. package/scripts/local_engines.py +321 -0
  301. package/scripts/local_image.py +970 -0
  302. package/scripts/local_seek.py +366 -0
  303. package/scripts/macro_countries.py +82 -0
  304. package/scripts/matrix_search_eval.py +1105 -0
  305. package/scripts/mcp_diag.py +8 -3
  306. package/scripts/mcp_handlers.py +1074 -0
  307. package/scripts/mcp_handlers_surface.py +157 -0
  308. package/scripts/mcp_launch.sh +122 -0
  309. package/scripts/mcp_server.py +118 -417
  310. package/scripts/mcp_setup.py +407 -0
  311. package/scripts/mcp_tools.py +330 -0
  312. package/scripts/mcp_transport.py +191 -0
  313. package/scripts/min_version_scan.py +335 -0
  314. package/scripts/net_proxy.py +258 -0
  315. package/scripts/network_aware.py +152 -0
  316. package/scripts/paper.py +274 -0
  317. package/scripts/pdf_extract.py +518 -0
  318. package/scripts/phrase_filter.py +66 -0
  319. package/scripts/plan.py +422 -0
  320. package/scripts/postprocess_signals.py +150 -0
  321. package/scripts/pre-commit +48 -0
  322. package/scripts/query_enhance.py +157 -0
  323. package/scripts/query_operators.py +196 -0
  324. package/scripts/query_rewriter.py +116 -7
  325. package/scripts/query_signals.py +300 -0
  326. package/scripts/query_templates.py +167 -0
  327. package/scripts/query_understanding.py +411 -0
  328. package/scripts/query_variants.py +298 -0
  329. package/scripts/quota.py +488 -76
  330. package/scripts/rank_signals.py +775 -0
  331. package/scripts/ranking_eval.py +319 -0
  332. package/scripts/readability_extract.py +433 -0
  333. package/scripts/recompute.py +415 -0
  334. package/scripts/recovery.py +616 -0
  335. package/scripts/recovery_l0.py +160 -0
  336. package/scripts/redskill/redskill_engine.py +332 -0
  337. package/scripts/regression_p0p1.py +318 -0
  338. package/scripts/replay_eval.py +640 -0
  339. package/scripts/rerank_tfidf.py +225 -0
  340. package/scripts/research.py +591 -403
  341. package/scripts/research_cli.py +361 -0
  342. package/scripts/research_dossier.py +492 -0
  343. package/scripts/research_expand.py +146 -0
  344. package/scripts/research_gates.py +272 -0
  345. package/scripts/research_report.py +209 -0
  346. package/scripts/research_strategy.py +37 -0
  347. package/scripts/research_work_packages.py +195 -0
  348. package/scripts/result_lang.py +311 -0
  349. package/scripts/robots_guard.py +259 -0
  350. package/scripts/route.py +921 -254
  351. package/scripts/route_bangs.py +40 -0
  352. package/scripts/route_cache.py +225 -0
  353. package/scripts/route_combo.py +910 -0
  354. package/scripts/route_domains.py +328 -0
  355. package/scripts/route_lang.py +605 -0
  356. package/scripts/route_log.py +63 -0
  357. package/scripts/route_policy.py +250 -0
  358. package/scripts/rrf_classic.py +66 -0
  359. package/scripts/screenshot.py +73 -0
  360. package/scripts/search.py +897 -405
  361. package/scripts/search_attribution.py +88 -0
  362. package/scripts/search_benchmark.py +407 -0
  363. package/scripts/search_cli.py +627 -0
  364. package/scripts/search_entry.py +389 -0
  365. package/scripts/search_output.py +606 -0
  366. package/scripts/search_pipeline.py +1077 -0
  367. package/scripts/search_rank.py +991 -0
  368. package/scripts/search_types.py +27 -12
  369. package/scripts/seek_locator.py +68 -0
  370. package/scripts/semantic_evidence.py +215 -0
  371. package/scripts/serp_guard.py +228 -0
  372. package/scripts/serp_kill_log.py +51 -0
  373. package/scripts/single_flight.py +120 -0
  374. package/scripts/social_engines/__init__.py +10 -0
  375. package/scripts/social_engines/bilibili_engine.py +131 -0
  376. package/scripts/social_engines/hackernews_engine.py +113 -0
  377. package/scripts/social_engines/reddit_engine.py +172 -0
  378. package/scripts/social_engines/twitter_engine.py +397 -0
  379. package/scripts/social_engines/v2ex_engine.py +101 -0
  380. package/scripts/social_engines/weibo_engine.py +128 -0
  381. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  382. package/scripts/social_engines/zhihu_engine.py +139 -0
  383. package/scripts/social_research.py +201 -0
  384. package/scripts/sogou_weixin_browser.py +318 -0
  385. package/scripts/source_ledger.py +249 -0
  386. package/scripts/stage_timing.py +58 -0
  387. package/scripts/stats_cli.py +95 -0
  388. package/scripts/straggler_policy.py +60 -0
  389. package/scripts/stylometry_detector.py +231 -0
  390. package/scripts/sync_backends.py +353 -0
  391. package/scripts/sync_installs.py +29 -0
  392. package/scripts/template_cli.py +127 -0
  393. package/scripts/text_lexicon.py +72 -0
  394. package/scripts/tfidf_router.py +109 -37
  395. package/scripts/time_utils.py +233 -0
  396. package/scripts/topic_research_profiles.py +643 -0
  397. package/scripts/train.py +737 -0
  398. package/scripts/tweet.py +596 -0
  399. package/scripts/url_canon.py +217 -0
  400. package/scripts/url_optimize.py +56 -0
  401. package/scripts/url_safety.py +236 -0
  402. package/scripts/usage_log.py +116 -0
  403. package/scripts/usage_log_clean.py +113 -0
  404. package/scripts/v2ex_nodes.py +311 -0
  405. package/scripts/variant_recall.py +188 -0
  406. package/scripts/watch.py +205 -0
  407. package/scripts/wechat_article.py +258 -0
  408. package/scripts/wide_research.py +402 -0
  409. package/scripts/wx.py +349 -0
  410. package/scripts/x_article_fetch.py +141 -0
  411. package/scripts/x_search.py +234 -0
  412. package/scripts/x_trending.py +189 -0
  413. package/scripts/yaml_load.py +67 -0
  414. package/sub-skills/ego-search/SKILL.md +253 -0
  415. package/sub-skills/ego-search/references/browser-runtime.md +43 -0
  416. package/sub-skills/ego-search/references/install.md +67 -0
  417. package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
  418. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  419. package/sub-skills/ego-search/scripts/merge.py +226 -0
  420. package/sub-skills/ego-search/scripts/quality.py +64 -0
  421. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  422. package/sub-skills/ego-search/scripts/safety.py +109 -0
  423. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  424. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  425. package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
  426. package/sub-skills/js-run/SKILL.md +67 -0
  427. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  428. package/sub-skills/js-run/scripts/shim.js +188 -0
  429. package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
  430. package/sub-skills/js-run/tests/test_jsrun.py +165 -0
  431. package/sub-skills/local-search/SKILL.md +50 -53
  432. package/sub-skills/local-search/bm25.py +162 -0
  433. package/sub-skills/local-search/config.yaml +152 -55
  434. package/sub-skills/local-search/engine_registry.py +91 -26
  435. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  436. package/sub-skills/local-search/local_search_adapter.py +13 -2
  437. package/sub-skills/local-search/parse_maps.yaml +40 -42
  438. package/sub-skills/local-search/search_v3.py +1182 -65
  439. package/sub-skills/local-search/smart_router.py +47 -7
  440. package/sub-skills/local-seek/SKILL.md +110 -0
  441. package/sub-skills/local-seek/config/domains.yaml +20 -0
  442. package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
  443. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
  444. package/sub-skills/local-seek/references/git-integration.md +54 -0
  445. package/sub-skills/local-seek/references/remote-handoff.md +46 -0
  446. package/sub-skills/local-seek/references/rg-recipes.md +96 -0
  447. package/sub-skills/local-seek/references/strategies.md +52 -0
  448. package/sub-skills/local-seek/references/structural-search.md +59 -0
  449. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  450. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  451. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  452. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  453. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  454. package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
  455. package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
  456. package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
  457. package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
  458. package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
  459. package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
  460. package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
  461. package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
  462. package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
  463. package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
@@ -0,0 +1,1105 @@
1
+ #!/usr/bin/env python3
2
+ """matrix_search_eval.py — 多语言 × 引擎族 × 场景 全面测试 harness
3
+
4
+ 沿 loho / regression_p0p1 思路:
5
+ - offline:语言检测、路由矩阵、recovery 检查、引擎族、lang_pref(无网络)
6
+ - live:多语金标 E2E(domain / primary / selection_hit / 污染)
7
+
8
+ 用法:
9
+ python3 scripts/matrix_search_eval.py --offline
10
+ python3 scripts/matrix_search_eval.py --live
11
+ python3 scripts/matrix_search_eval.py --all
12
+ python3 scripts/matrix_search_eval.py --all --report tests/matrix_search_report.json
13
+
14
+ 退出码:有 hard FAIL 则为 1(warn 不计入)。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ if __name__ == "__main__": from argo_paths import isolate_state_dir as _isol; _isol("argo-bench") # 状态隔离(2026-10-10 方案甲):真实搜索路径会写生产状态(夹具引擎已实测混入 circuit_breaker/usage_log),须先于 argo 状态模块导入,机制见 isolate_state_dir docstring
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+ from cli_io import dumps, dumps_pretty
28
+
29
+ _SCRIPTS = Path(__file__).resolve().parent
30
+ _ROOT = _SCRIPTS.parent
31
+ if str(_SCRIPTS) not in sys.path:
32
+ sys.path.insert(0, str(_SCRIPTS))
33
+
34
+
35
+ # 中文专用源:非中文主语言不得入 combo
36
+ _CN_ONLY = frozenset({
37
+ "bocha", "byted", "wechat_sogou", "zhihu", "zhihu_hot",
38
+ "baidu_hot", "toutiao_hot", "weibo", "xiaohongshu",
39
+ "sina_quote", "tencent_quote", "em_flow", "eastmoney",
40
+ "cls_telegraph", "jin10", "em_miaoxiang", "em_global_news",
41
+ "baidu_baike", "zh_wikipedia", "local_baidu", "local_sogou",
42
+ "weread", "douban_book", "juejin", "v2ex", "wenshu",
43
+ })
44
+
45
+ # recovery L3 禁止的无关垂直(与 recovery 检查保持一致)
46
+ _RECOVERY_POLLUTE = frozenset({
47
+ "pypi", "npm", "crates", "jin10", "cls_telegraph",
48
+ "sina_quote", "finviz", "pubchem", "steam",
49
+ })
50
+
51
+ # 日韩等语言引擎候选
52
+ _LANG_ENGINE_CANDIDATES = {
53
+ "ja": ("local_bing",),
54
+ "ko": ("local_bing",),
55
+ "cyrillic": ("local_bing", "anysearch"),
56
+ "thai": ("local_bing", "anysearch"),
57
+ "arabic": ("local_bing", "anysearch"),
58
+ "hebrew": ("local_bing", "anysearch"),
59
+ "greek": ("local_bing", "anysearch"),
60
+ "devanagari": ("local_bing", "anysearch"),
61
+ "en": ("anysearch", "local_bing", "octen", "exa"),
62
+ "zh": ("byted", "bocha", "local_bing", "anysearch", "zhihu_global"),
63
+ }
64
+
65
+
66
+ class Checker:
67
+ def __init__(self) -> None:
68
+ self.ok = 0
69
+ self.fail = 0
70
+ self.warn = 0
71
+ self.rows: list[dict[str, Any]] = []
72
+
73
+ def check(
74
+ self,
75
+ name: str,
76
+ cond: bool,
77
+ detail: str = "",
78
+ *,
79
+ soft: bool = False,
80
+ meta: dict[str, Any] | None = None,
81
+ ) -> bool:
82
+ if cond:
83
+ self.ok += 1
84
+ status = "PASS"
85
+ elif soft:
86
+ self.warn += 1
87
+ status = "WARN"
88
+ else:
89
+ self.fail += 1
90
+ status = "FAIL"
91
+ row: dict[str, Any] = {
92
+ "name": name,
93
+ "status": status,
94
+ "detail": detail,
95
+ }
96
+ if meta:
97
+ row["meta"] = meta
98
+ self.rows.append(row)
99
+ mark = {"PASS": "✓", "FAIL": "✗", "WARN": "~"}[status]
100
+ print(f" {mark} {name}" + (f" — {detail}" if detail else ""))
101
+ return cond
102
+
103
+
104
+ # ═══════════════════════════════════════════════════════════════════════════
105
+ # OFFLINE A — 语言检测
106
+ # ═══════════════════════════════════════════════════════════════════════════
107
+
108
+ LANG_DETECT_CASES: list[tuple[str, str, str]] = [
109
+ # (id, query, expect_lang)
110
+ ("ld_zh", "苹果股价最新行情", "zh"),
111
+ ("ld_zh_mix", "Python 异步编程", "zh"),
112
+ ("ld_en", "React hooks tutorial", "en"),
113
+ ("ld_en2", "hello world", "en"),
114
+ ("ld_ja_kana", "アニメ おすすめ", "ja"),
115
+ ("ld_ja_mix", "東京の天気予報", "ja"),
116
+ ("ld_ja_kata", "トヨタ 株価", "ja"),
117
+ ("ld_ko", "한국 영화 추천", "ko"),
118
+ ("ld_ko2", "삼성전자 주가", "ko"),
119
+ ("ld_latin", "café français", "latin"),
120
+ ("ld_cyr", "Как написать скрипт", "cyrillic"),
121
+ ("ld_cyr_mix", "Python курс", "cyrillic"),
122
+ ("ld_thai", "สวัสดีครับ", "thai"),
123
+ ("ld_ar", "مرحبا بالعالم", "arabic"),
124
+ ("ld_he", "שלום עולם", "hebrew"),
125
+ ("ld_el", "Καλημέρα", "greek"),
126
+ ("ld_hi", "नमस्ते दुनिया", "devanagari"),
127
+ # 纯汉字日文地名:当前可能判 zh(假名缺席)— soft 期望仍记为 ja 缺口
128
+ ("ld_ja_kanji_only", "宮崎駿", "ja"),
129
+ ]
130
+
131
+
132
+ def run_offline_lang(c: Checker) -> None:
133
+ print("\n== offline: language detection ==")
134
+ from lang_detect import detect_language, detect_script, engine_lang_param, language_features
135
+
136
+ for cid, q, expect in LANG_DETECT_CASES:
137
+ got = detect_language(q)
138
+ soft = cid == "ld_ja_kanji_only" and got == "zh"
139
+ c.check(
140
+ f"lang:{cid}",
141
+ got == expect,
142
+ detail=f"got={got} expect={expect} q={q!r}",
143
+ soft=soft,
144
+ meta={"query": q, "lang": got, "expect": expect},
145
+ )
146
+
147
+ # script 与 primary 同源
148
+ for q, lang, script in [
149
+ ("苹果股价", "zh", "cjk"),
150
+ ("アニメ", "ja", "kana"),
151
+ ("한국", "ko", "hangul"),
152
+ ("hello", "en", "latin"),
153
+ ("Как", "cyrillic", "cyrillic"),
154
+ ]:
155
+ f = language_features(q)
156
+ c.check(
157
+ f"script_align:{lang}",
158
+ f["primary_lang"] == lang and f["script"] == script,
159
+ detail=str(f),
160
+ )
161
+
162
+ # 引擎语言参数覆盖非 CJK
163
+ for q, param, expect_sub in [
164
+ ("アニメ", "setlang", "ja"),
165
+ ("한국", "hl", "ko"),
166
+ ("hello world", "setlang", "en"),
167
+ ("苹果", "setlang", "zh"),
168
+ ("Как дела", "setlang", "ru"),
169
+ ("مرحبا", "setlang", "ar"),
170
+ ("שלום", "lang", "he"),
171
+ ]:
172
+ v = engine_lang_param(param, q)
173
+ c.check(
174
+ f"lang_param:{param}:{q[:6]}",
175
+ expect_sub.lower() in (v or "").lower(),
176
+ detail=f"got={v}",
177
+ )
178
+
179
+
180
+ # ═══════════════════════════════════════════════════════════════════════════
181
+ # OFFLINE B — 路由矩阵(语言 × 场景)
182
+ # ═══════════════════════════════════════════════════════════════════════════
183
+
184
+ # soft=True:已知能力缺口(日韩垂直域、纯汉字日文),记 WARN 不阻断
185
+ ROUTE_MATRIX: list[dict[str, Any]] = [
186
+ # ── 金融 / 行情 ──
187
+ {"id": "R_zh_stock", "q": "贵州茅台股价", "lang": "zh", "domain": "stock_query",
188
+ "primary": "sina_quote", "scenario": "finance"},
189
+ {"id": "R_en_usstock", "q": "AAPL stock price", "lang": "en", "domain": "us_stock",
190
+ "primary": "finviz", "scenario": "finance"},
191
+ {"id": "R_zh_macro", "q": "中国 GDP", "lang": "zh", "domain": "macro_data",
192
+ "primary_any": ["worldbank", "nbs_stats", "fred"], "scenario": "macro"},
193
+ {"id": "R_zh_trade", "q": "中国 8541 2023 进出口", "lang": "zh", "domain": "trade_stats",
194
+ "primary": "un_comtrade", "scenario": "macro"},
195
+ {"id": "R_zh_security", "q": "log4j 漏洞 CVSS", "lang": "en", "domain": "security_search",
196
+ "primary": "nvd", "scenario": "tech"},
197
+ {"id": "R_en_cve", "q": "CVE-2021-44228 severity", "lang": "en", "domain": "security_search",
198
+ "primary": "nvd", "scenario": "tech"},
199
+ {"id": "R_zh_lifecycle", "q": "python 生命周期 停止支持", "lang": "zh", "domain": "lifecycle_search",
200
+ "primary": "endoflife", "scenario": "tech"},
201
+ {"id": "R_zh_package_intel", "q": "npm express 弃用", "lang": "en", "domain": "package_intel",
202
+ "primary": "deps_dev", "scenario": "tech"},
203
+ {"id": "R_en_macro", "q": "US CPI", "lang": "en", "domain": "macro_data",
204
+ "primary": "fred", "scenario": "macro"},
205
+ {"id": "R_ja_stock", "q": "トヨタ 株価", "lang": "ja", "domain": None,
206
+ "need_lang_engine": True, "forbid_cn": True, "scenario": "finance", "soft": True},
207
+ {"id": "R_ko_stock", "q": "삼성전자 주가", "lang": "ko", "domain": None,
208
+ "need_lang_engine": True, "forbid_cn": True, "scenario": "finance", "soft": True},
209
+
210
+ # ── 影视 ──
211
+ {"id": "R_en_film", "q": "Inception movie director", "lang": "en",
212
+ "domain": "film_search", "primary": "imdb", "scenario": "film"},
213
+ {"id": "R_zh_film", "q": "肖申克的救赎 主演", "lang": "zh",
214
+ "domain": "film_search", "primary": "imdb", "scenario": "film"},
215
+ {"id": "R_en_film2", "q": "Interstellar film cast", "lang": "en",
216
+ "domain": "film_search", "primary": "imdb", "scenario": "film"},
217
+ {"id": "R_ja_film_kana", "q": "宮崎駿 アニメ 映画", "lang": "ja",
218
+ "domain": "film_search", "primary": "imdb", "scenario": "film", "soft": True},
219
+ {"id": "R_ko_film", "q": "기생충 영화 감독", "lang": "ko",
220
+ "domain": "film_search", "primary": "imdb", "scenario": "film", "soft": True},
221
+
222
+ # ── 体育 ──
223
+ {"id": "R_zh_sports", "q": "梅西 俱乐部", "lang": "zh",
224
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports"},
225
+ {"id": "R_en_sports", "q": "LeBron James team", "lang": "en",
226
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports"},
227
+ {"id": "R_en_sports2", "q": "Cristiano Ronaldo club", "lang": "en",
228
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports"},
229
+ {"id": "R_zh_sports2", "q": "库里 球队", "lang": "zh",
230
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports"},
231
+ {"id": "R_en_wc", "q": "World Cup 2022 winner", "lang": "en",
232
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports"},
233
+ {"id": "R_ja_sports", "q": "メッシ クラブ", "lang": "ja",
234
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports", "soft": True},
235
+ {"id": "R_ko_sports", "q": "손흥민 팀", "lang": "ko",
236
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports", "soft": True},
237
+
238
+ # ── 地理 ──
239
+ {"id": "R_zh_geo", "q": "埃菲尔铁塔在哪", "lang": "zh",
240
+ "domain": "geo_places", "primary": "local_openstreetmap", "scenario": "geo"},
241
+ {"id": "R_en_geo", "q": "where is Eiffel Tower", "lang": "en",
242
+ "domain": "geo_places", "primary": "local_openstreetmap", "scenario": "geo"},
243
+ {"id": "R_zh_geo2", "q": "黄河 流经省份", "lang": "zh",
244
+ "domain": "geo_places", "primary": "local_openstreetmap", "scenario": "geo"},
245
+ {"id": "R_ja_geo", "q": "エッフェル塔 どこ", "lang": "ja",
246
+ "domain": "geo_places", "primary": "local_openstreetmap", "scenario": "geo", "soft": True},
247
+
248
+ # ── 组织 ──
249
+ {"id": "R_zh_org", "q": "国务院职能", "lang": "zh",
250
+ "domain": "org_entity", "primary": "wikidata", "scenario": "org"},
251
+ {"id": "R_en_org", "q": "NASA founding year", "lang": "en",
252
+ "domain": "org_entity", "primary": "wikidata", "scenario": "org"},
253
+ {"id": "R_en_org2", "q": "Apple Inc headquarters", "lang": "en",
254
+ "domain": "org_entity", "primary": "wikidata", "scenario": "org"},
255
+ {"id": "R_zh_org2", "q": "清华大学 创办年份", "lang": "zh",
256
+ "domain": "org_entity", "primary": "wikidata", "scenario": "org"},
257
+
258
+ # ── 媒体/音乐 ──
259
+ {"id": "R_en_media", "q": "Taylor Swift album", "lang": "en",
260
+ "domain": "media_search", "primary": "itunes", "scenario": "media"},
261
+ {"id": "R_zh_media", "q": "周杰伦 专辑", "lang": "zh",
262
+ "domain": "media_search", "primary": "itunes", "scenario": "media"},
263
+
264
+ # ── 天气 ──
265
+ {"id": "R_zh_weather", "q": "北京 天气", "lang": "zh",
266
+ "domain": "weather_query", "scenario": "weather"},
267
+ {"id": "R_en_weather", "q": "weather Tokyo", "lang": "en",
268
+ "domain": "weather_query", "scenario": "weather"},
269
+ {"id": "R_ja_weather", "q": "東京の天気", "lang": "ja",
270
+ "domain": "weather_query", "scenario": "weather", "soft": True},
271
+
272
+ # ── 学术 / 化学 / RFC / 代码 ──
273
+ {"id": "R_en_academic", "q": "transformer attention paper", "lang": "en",
274
+ "domain": "academic", "primary_any": ["arxiv", "crossref", "semantic_scholar"],
275
+ "scenario": "academic"},
276
+ {"id": "R_zh_chem", "q": "阿司匹林 分子式", "lang": "zh",
277
+ "domain": "chem_search", "primary": "pubchem", "scenario": "chem"},
278
+ # RFC 9110 字母过少,lang 可能判 other;只硬卡 domain/primary
279
+ {"id": "R_en_rfc", "q": "RFC 9110", "lang": None,
280
+ "domain": "rfc_search", "primary": "rfc_editor", "scenario": "rfc"},
281
+ {"id": "R_en_tech", "q": "React vs Vue", "lang": "en",
282
+ "domain": None, # english_tech / general
283
+ "forbid": list(_CN_ONLY - {"local_baidu", "local_sogou"}), # 放宽 local
284
+ "forbid_cn": True, "scenario": "tech"},
285
+ {"id": "R_zh_package", "q": "requests pypi", "lang": "en",
286
+ "domain": "package_search", "primary_any": ["pypi", "npm"], "scenario": "code",
287
+ "soft": True},
288
+ # 面查负例(2026-09-06 diffuse intent guard):长主题句含生态实体词但无
289
+ # 意图词 → 点查域让位,combo 不得被 pypi/npm/models_dev 锁死。
290
+ {"id": "R_en_pkg_diffuse", "q": "pnpm file: directory dependency no content hash reinstall",
291
+ "lang": "en", "domain": None, "forbid": ["pypi", "npm", "crates", "docker_hub"],
292
+ "scenario": "tech"},
293
+ {"id": "R_en_model_diffuse", "q": "DeepSeek Harness DSH 插件开发",
294
+ # 拉丁实体词主导,语言检测判 en(同 R_zh_package 计算方式);守卫只检查
295
+ # 域让位:ai_model/models_dev 不得锁死通用开发意图
296
+ "lang": "en", "domain": None, "forbid": ["models_dev", "huggingface"],
297
+ "scenario": "tech"},
298
+
299
+ # ── 通用多语 ──
300
+ {"id": "R_ja_gen", "q": "アニメ おすすめ", "lang": "ja",
301
+ "domain": None, "need_lang_engine": True, "forbid_cn": True, "scenario": "general"},
302
+ {"id": "R_ko_gen", "q": "한국 영화 추천", "lang": "ko",
303
+ "domain": None, "need_lang_engine": True, "forbid_cn": True, "scenario": "general"},
304
+ {"id": "R_cyr_gen", "q": "Как написать скрипт", "lang": "cyrillic",
305
+ "domain": None, "need_lang_engine": True, "forbid_cn": True, "scenario": "general"},
306
+ {"id": "R_ar_gen", "q": "أخبار التكنولوجيا", "lang": "arabic",
307
+ "domain": None, "need_lang_engine": True, "forbid_cn": True, "scenario": "general"},
308
+ {"id": "R_th_gen", "q": "อากาศ กรุงเทพ", "lang": "thai",
309
+ "domain": None, "need_lang_engine": True, "forbid_cn": True, "scenario": "general"},
310
+ {"id": "R_en_gen", "q": "hello world", "lang": "en",
311
+ "domain": None, "forbid_cn": True, "scenario": "general"},
312
+ {"id": "R_zh_gen", "q": "如何学习机器学习", "lang": "zh",
313
+ "domain": None, "scenario": "general"},
314
+
315
+ # ── 2026-09-02 金标增补:fast 单引擎冗余事故回归钉 ──
316
+ # 事故:英文技术无域查询 fast 保底为 anysearch 单引擎,首引擎上游波动
317
+ # 返回高计数垃圾时早停吞掉次引擎(execution 层守卫见 search.py
318
+ # _query_coverage_ok;此处钉路由层契约:保底 combo 必含 ≥2 个免费通用源)。
319
+ {"id": "R_en_tech_nodomain", "q": "Crawl4AI pruning content filter extraction",
320
+ "lang": "en", "domain": "general_search", "primary_any": ["anysearch", "local_bing"],
321
+ "forbid_cn": True, "scenario": "general"},
322
+ {"id": "R_en_tech_py", "q": "python asyncio tutorial", "lang": "en",
323
+ "domain": "english_tech", "primary_any": ["octen", "anysearch", "local_bing"],
324
+ "forbid_cn": True, "scenario": "general"},
325
+ {"id": "R_zh_food", "q": "附近好吃的本帮菜馆", "lang": "zh",
326
+ "domain": "chinese_general", "primary_any": ["bocha", "anysearch", "local_bing"],
327
+ "scenario": "general"},
328
+
329
+ # ── 2026-09-14 负向控制增补(OpenAI eval 视角 test-04)─────────────────
330
+ # 泛查询不得误触发垂直域/垂直引擎——正像「给现有应用加 Tailwind」不该
331
+ # 触发「新建 demo」skill:以下查询均不含垂直意图词,命中任何行情/漏洞/
332
+ # 影视/宏观/论文/生命周期/包情报专用源即判失败(结构化垂直源对泛查询
333
+ # 返回的全是不相关结果)。domain_not 防域误抢,forbid 防引擎漏入。
334
+ {"id": "N_en_cooking", "q": "how to cook pasta carbonara", "lang": "en",
335
+ "domain_not": ["stock_query", "us_stock", "macro_data", "trade_stats",
336
+ "security_search", "lifecycle_search", "package_intel",
337
+ "film_search"],
338
+ "forbid": ["sina_quote", "finviz", "eastmoney", "nvd", "imdb", "fred",
339
+ "worldbank", "nbs_stats", "un_comtrade", "endoflife",
340
+ "deps_dev", "openalex", "europepmc"],
341
+ "forbid_cn": True, "scenario": "negative"},
342
+ {"id": "N_zh_cooking", "q": "怎么把排骨炖得软烂", "lang": "zh",
343
+ "domain_not": ["stock_query", "us_stock", "macro_data", "trade_stats",
344
+ "security_search", "lifecycle_search", "package_intel",
345
+ "film_search"],
346
+ "forbid": ["sina_quote", "finviz", "eastmoney", "nvd", "imdb", "fred",
347
+ "worldbank", "nbs_stats", "un_comtrade", "endoflife",
348
+ "deps_dev", "openalex", "europepmc"],
349
+ "scenario": "negative"},
350
+ {"id": "N_en_history", "q": "who invented the printing press", "lang": "en",
351
+ "domain_not": ["stock_query", "us_stock", "macro_data", "trade_stats",
352
+ "security_search", "lifecycle_search", "package_intel",
353
+ "film_search"],
354
+ "forbid": ["sina_quote", "finviz", "eastmoney", "nvd", "imdb", "fred",
355
+ "worldbank", "nbs_stats", "un_comtrade", "endoflife",
356
+ "deps_dev", "openalex", "europepmc"],
357
+ # 不带 forbid_cn:实体问句合理路由到 org_entity(wikidata/wikipedia 族),
358
+ # 其中 zh_wikipedia 是有实测依据的语言中立成员(lang_capability:
359
+ # 「擅长 zh/en/fr/es…」,MediaWiki 跨语检索),对 en 查询不算误入。
360
+ # 负向门只钉「泛查询不进垂直域/垂直引擎」这条真不变量。
361
+ "scenario": "negative"},
362
+ {"id": "N_en_garden", "q": "how often should I water tomato plants",
363
+ "lang": "en",
364
+ "domain_not": ["stock_query", "us_stock", "macro_data", "trade_stats",
365
+ "security_search", "lifecycle_search", "package_intel",
366
+ "film_search"],
367
+ "forbid": ["sina_quote", "finviz", "eastmoney", "nvd", "imdb", "fred",
368
+ "worldbank", "nbs_stats", "un_comtrade", "endoflife",
369
+ "deps_dev", "openalex", "europepmc"],
370
+ "forbid_cn": True, "scenario": "negative"},
371
+ {"id": "N_zh_health", "q": "深蹲膝盖疼怎么恢复", "lang": "zh",
372
+ "domain_not": ["stock_query", "us_stock", "macro_data", "trade_stats",
373
+ "security_search", "lifecycle_search", "package_intel",
374
+ "film_search"],
375
+ "forbid": ["sina_quote", "finviz", "eastmoney", "nvd", "imdb", "fred",
376
+ "worldbank", "nbs_stats", "un_comtrade", "endoflife",
377
+ "deps_dev", "openalex", "europepmc"],
378
+ "scenario": "negative"},
379
+ {"id": "N_en_hobby", "q": "best wood for beginner woodworking", "lang": "en",
380
+ "domain_not": ["stock_query", "us_stock", "macro_data", "trade_stats",
381
+ "security_search", "lifecycle_search", "package_intel",
382
+ "film_search"],
383
+ "forbid": ["sina_quote", "finviz", "eastmoney", "nvd", "imdb", "fred",
384
+ "worldbank", "nbs_stats", "un_comtrade", "endoflife",
385
+ "deps_dev", "openalex", "europepmc"],
386
+ "forbid_cn": True, "scenario": "negative"},
387
+ ]
388
+
389
+
390
+ def _route_ok(case: dict[str, Any], d: dict[str, Any]) -> tuple[bool, str]:
391
+ combo = list(d.get("engines_combo") or [])
392
+ got_d = d.get("domain")
393
+ got_e = d.get("engine")
394
+ feat = d.get("features") or {}
395
+ got_lang = feat.get("primary_lang")
396
+ problems: list[str] = []
397
+
398
+ if case.get("lang") is not None and got_lang != case["lang"]:
399
+ # 纯汉字等已知误判允许 soft 外层处理;这里仍记问题
400
+ problems.append(f"lang={got_lang}!={case['lang']}")
401
+
402
+ if case.get("domain") is not None and got_d != case["domain"]:
403
+ problems.append(f"domain={got_d}!={case['domain']}")
404
+
405
+ # 负向控制(test-04 视角):泛查询不得命中列出的垂直域
406
+ domain_not = case.get("domain_not")
407
+ if domain_not and got_d in domain_not:
408
+ problems.append(f"domain 误命中: {got_d} in {domain_not}")
409
+
410
+ primary = case.get("primary")
411
+ if primary and not (got_e == primary or primary in combo[:2]):
412
+ problems.append(f"primary missing: eng={got_e} combo={combo}")
413
+
414
+ primary_any = case.get("primary_any")
415
+ if primary_any and not any(p == got_e or p in combo[:3] for p in primary_any):
416
+ problems.append(f"primary_any miss: eng={got_e} combo={combo}")
417
+
418
+ if case.get("need_lang_engine"):
419
+ cands = _LANG_ENGINE_CANDIDATES.get(case.get("lang") or "", ())
420
+ if cands and not any(e in combo for e in cands):
421
+ problems.append(f"no lang engine in {combo}")
422
+
423
+ forbid = set(case.get("forbid") or [])
424
+ if case.get("forbid_cn"):
425
+ forbid |= _CN_ONLY
426
+ # 中文主查询允许中文源
427
+ if case.get("lang") == "zh":
428
+ forbid -= _CN_ONLY
429
+ hit_forbid = [e for e in combo if e in forbid]
430
+ if hit_forbid:
431
+ problems.append(f"forbid={hit_forbid}")
432
+
433
+ ok = not problems
434
+ detail = (
435
+ f"domain={got_d} eng={got_e} combo={combo} lang={got_lang}"
436
+ + (f" | {'; '.join(problems)}" if problems else "")
437
+ )
438
+ return ok, detail
439
+
440
+
441
+ def run_offline_route(c: Checker) -> None:
442
+ print("\n== offline: route matrix (lang × scenario) ==")
443
+ from route import route_query
444
+ from unittest.mock import MagicMock, patch
445
+
446
+ # 离线金标验的是「路由语义」,不是运行时健康——真实熔断/配额状态
447
+ # 会随 live 探测漂移(bocha 曾被连续失败自动禁用致金标误报),
448
+ # 与单测「路由检查须打桩」同一教训。2026-09-07。
449
+ class _AllowAll:
450
+ def allow(self, eng):
451
+ return True, "closed"
452
+
453
+ def get_negative(self, *a, **k):
454
+ return None
455
+
456
+ def status(self, eng):
457
+ return {"state": "closed"}
458
+
459
+ def record_success(self, *a, **k):
460
+ pass
461
+
462
+ def record_failure(self, *a, **k):
463
+ pass
464
+
465
+ def set_negative(self, *a, **k):
466
+ pass
467
+
468
+ def clear_negative(self, *a, **k):
469
+ pass
470
+
471
+ by_scenario: dict[str, list[bool]] = {}
472
+ by_lang: dict[str, list[bool]] = {}
473
+
474
+ for case in ROUTE_MATRIX:
475
+ with patch("circuit_breaker.get_breaker", return_value=_AllowAll()), \
476
+ patch("quota.get_quota_manager", return_value=MagicMock()):
477
+ d = route_query(case["q"], mode="auto", depth="fast",
478
+ context="search")
479
+ ok, detail = _route_ok(case, d)
480
+ soft = bool(case.get("soft"))
481
+ c.check(
482
+ f"route:{case['id']}",
483
+ ok,
484
+ detail=detail,
485
+ soft=soft,
486
+ meta={
487
+ "id": case["id"],
488
+ "q": case["q"],
489
+ "scenario": case.get("scenario"),
490
+ "lang": case.get("lang"),
491
+ "domain": d.get("domain"),
492
+ "engine": d.get("engine"),
493
+ "combo": d.get("engines_combo"),
494
+ "primary_lang": (d.get("features") or {}).get("primary_lang"),
495
+ },
496
+ )
497
+ sc = case.get("scenario") or "other"
498
+ lg = case.get("lang") or "other"
499
+ by_scenario.setdefault(sc, []).append(ok)
500
+ by_lang.setdefault(lg, []).append(ok)
501
+
502
+ print("\n -- route coverage by scenario --")
503
+ for sc, vals in sorted(by_scenario.items()):
504
+ rate = sum(vals) / len(vals) if vals else 0
505
+ print(f" {sc:12} {sum(vals)}/{len(vals)} ({rate:.0%})")
506
+ print(" -- route coverage by lang --")
507
+ for lg, vals in sorted(by_lang.items()):
508
+ rate = sum(vals) / len(vals) if vals else 0
509
+ print(f" {lg:12} {sum(vals)}/{len(vals)} ({rate:.0%})")
510
+
511
+
512
+ # ═══════════════════════════════════════════════════════════════════════════
513
+ # OFFLINE C — recovery 检查
514
+ # ═══════════════════════════════════════════════════════════════════════════
515
+
516
+ def run_offline_recovery(c: Checker) -> None:
517
+ print("\n== offline: recovery gates ==")
518
+ from recovery import (
519
+ build_recovery_plan,
520
+ cross_lang_query,
521
+ pick_alternative_engines,
522
+ baseline_cross_query,
523
+ )
524
+
525
+ # L3:空 tried 时不得选污染垂直源
526
+ picks = pick_alternative_engines(
527
+ tried=["imdb"],
528
+ engines_fallback=["pypi", "npm", "jin10", "anysearch", "local_bing"],
529
+ )
530
+ c.check(
531
+ "rec_l3_no_pollute_film",
532
+ not any(e in _RECOVERY_POLLUTE for e in picks) and bool(picks),
533
+ detail=str(picks),
534
+ )
535
+ picks2 = pick_alternative_engines(
536
+ tried=["thesportsdb"],
537
+ engines_fallback=["crates", "pypi", "wikipedia", "anysearch"],
538
+ )
539
+ c.check(
540
+ "rec_l3_no_pollute_sports",
541
+ not any(e in _RECOVERY_POLLUTE for e in picks2) and bool(picks2),
542
+ detail=str(picks2),
543
+ )
544
+
545
+ # 同族可保留(knowledge)
546
+ picks3 = pick_alternative_engines(
547
+ tried=["wikipedia"],
548
+ engines_fallback=["wikidata", "pypi"],
549
+ max_n=2,
550
+ )
551
+ c.check(
552
+ "rec_l3_same_family_ok",
553
+ "pypi" not in picks3,
554
+ detail=str(picks3),
555
+ )
556
+
557
+ # 跨语言
558
+ for q, expect_cross in [
559
+ ("アニメ おすすめ", True),
560
+ ("한국 영화", True),
561
+ ("如何写爬虫", True),
562
+ ("Как дела", True),
563
+ ("hello world", False),
564
+ ("React vs Vue", False),
565
+ ]:
566
+ cq, ce = cross_lang_query(q)
567
+ has = bool(ce)
568
+ c.check(
569
+ f"rec_cross:{q[:12]}",
570
+ has is expect_cross,
571
+ detail=f"engines={ce!r}",
572
+ )
573
+
574
+ # plan 含 L4 cross_lang(非 fast)
575
+ plan = build_recovery_plan(
576
+ "アニメ おすすめ", ["anysearch"], ["bocha", "wikipedia"], mode="auto",
577
+ )
578
+ strategies = [s.strategy for s in plan]
579
+ c.check("rec_plan_cross_lang", "cross_lang" in strategies, detail=str(strategies))
580
+
581
+ plan_fast = build_recovery_plan(
582
+ "アニメ おすすめ", ["anysearch"], ["bocha"], mode="fast",
583
+ )
584
+ c.check(
585
+ "rec_fast_no_cross",
586
+ "cross_lang" not in [s.strategy for s in plan_fast],
587
+ detail=str([s.strategy for s in plan_fast]),
588
+ )
589
+
590
+ # baseline zh
591
+ cq, ce = baseline_cross_query("hello world", primary_lang="en", prefer=["zh", "en"])
592
+ c.check("rec_baseline_zh", bool(ce), detail=str(ce))
593
+ cq2, ce2 = baseline_cross_query("hello world", primary_lang="en", prefer=["en"])
594
+ c.check("rec_baseline_skip", not ce2, detail=str(ce2))
595
+
596
+
597
+ # ═══════════════════════════════════════════════════════════════════════════
598
+ # OFFLINE D — 引擎族 + 注册完整性
599
+ # ═══════════════════════════════════════════════════════════════════════════
600
+
601
+ FAMILY_EXPECT: dict[str, str] = {
602
+ "anysearch": "web_general",
603
+ "bocha": "web_general",
604
+ "local_bing": "web_general",
605
+ "arxiv": "academic",
606
+ "github": "code",
607
+ "pypi": "code",
608
+ "sina_quote": "finance_market",
609
+ "finviz": "finance_market",
610
+ "fred": "finance_macro",
611
+ "worldbank": "finance_macro",
612
+ "jin10": "news_flash",
613
+ "zhihu": "social",
614
+ "wikipedia": "knowledge",
615
+ "wikidata": "knowledge",
616
+ "baidu_baike": "knowledge",
617
+ "pubchem": "science_chem",
618
+ "imdb": "media_book",
619
+ "itunes": "media_book",
620
+ "thesportsdb": "sports",
621
+ "local_openstreetmap": "science_geo",
622
+ }
623
+
624
+
625
+ def run_offline_families(c: Checker) -> None:
626
+ print("\n== offline: engine families ==")
627
+ from engine_families import family_of, DEFAULT_FAMILY
628
+
629
+ for eng, expect in FAMILY_EXPECT.items():
630
+ got = family_of(eng)
631
+ soft = eng == "local_openstreetmap" and got != expect
632
+ c.check(
633
+ f"family:{eng}",
634
+ got == expect,
635
+ detail=f"got={got}",
636
+ soft=soft or (got == DEFAULT_FAMILY and expect != DEFAULT_FAMILY),
637
+ )
638
+
639
+ # 关键垂直源不得被误标 web_general(污染风险)
640
+ for eng in ("pypi", "npm", "jin10", "sina_quote", "imdb", "thesportsdb"):
641
+ got = family_of(eng)
642
+ c.check(
643
+ f"family_not_general:{eng}",
644
+ got != "web_general",
645
+ detail=f"got={got}",
646
+ )
647
+
648
+
649
+ def run_offline_lang_pref(c: Checker) -> None:
650
+ print("\n== offline: lang_pref ==")
651
+ from lang_pref import (
652
+ prefer_langs, system_lang, dominant_habit,
653
+ effective_engine_lang, record_query_lang, reset_habit_for_tests,
654
+ BASELINE_LANGS,
655
+ )
656
+
657
+ reset_habit_for_tests()
658
+ try:
659
+ c.check("pref_sys_zh", system_lang("zh_CN.UTF-8") == "zh")
660
+ c.check("pref_sys_ja", system_lang("ja_JP") == "ja")
661
+ c.check("pref_sys_ko", system_lang("ko_KR") == "ko")
662
+ c.check("pref_sys_ru", system_lang("ru_RU") == "cyrillic")
663
+
664
+ prefs = prefer_langs(
665
+ query_lang="ko", system="zh", habit="ja", use_live_habit=False,
666
+ )
667
+ c.check("pref_query_first", prefs[0] == "ko", detail=str(prefs))
668
+ for b in BASELINE_LANGS:
669
+ c.check(f"pref_baseline:{b}", b in prefs, detail=str(prefs))
670
+
671
+ c.check(
672
+ "pref_effective_strong",
673
+ effective_engine_lang("ja", ["zh", "en"]) == "ja",
674
+ )
675
+ c.check(
676
+ "pref_effective_weak",
677
+ effective_engine_lang("mixed", ["ja", "zh", "en"]) == "ja",
678
+ )
679
+
680
+ for _ in range(6):
681
+ record_query_lang("ja")
682
+ c.check("pref_habit", dominant_habit() == "ja")
683
+ finally:
684
+ reset_habit_for_tests()
685
+
686
+
687
+ def run_offline_reachability(c: Checker) -> None:
688
+ """可达性门:enabled 引擎必须被 ≥1 分发路径可达,防死源复发。
689
+
690
+ 五条分发路径:域 combo / 保底清单 / 选题 profiles / 研究 boosts /
691
+ TF-IDF 语义画像(documents 非空)。local_* 视为经 local_search 展开
692
+ 可达。不可达引擎逐个 WARN(soft)——anysearch/zhihu_global/uapi 三次
693
+ 死源事故的系统性防线。2026-09-07。
694
+
695
+ explicit_only 例外(2026-09-12):引擎声明 `explicit_only: true` 表示
696
+ 「设计上不进自动路由,按 --engine / 交接提示显式调用」——例如需密钥的
697
+ 付费源、或输入形态特殊(单条推文 URL 走 known-url 交接)的抽取型引擎。
698
+ 这类不算死源,但要求声明显式存在:漏声明会继续 WARN,防止把「忘了接线」
699
+ 伪装成「有意显式」。声明项单独列出,保持可见。
700
+ """
701
+ print("\n== offline: engine reachability gate ==")
702
+ import json as _json
703
+ from pathlib import Path as _Path
704
+ from config import load_config, get_engines, get_domains
705
+ from topic_research_profiles import list_profiles
706
+ from engine_policy import GENERAL_FREE_FALLBACK
707
+ import research as _research
708
+
709
+ specs = get_engines(load_config(), routable_only=False)
710
+ domains = get_domains(load_config())
711
+
712
+ reachable: set[str] = set()
713
+ for d in domains:
714
+ reachable |= set(d.get("engines_combo") or [])
715
+ reachable |= set(GENERAL_FREE_FALLBACK)
716
+ for p in list_profiles():
717
+ reachable |= set(p.get("engines") or [])
718
+ reachable |= (set(_research._RESEARCH_EN_BOOSTS)
719
+ | set(_research._RESEARCH_ACADEMIC_BOOSTS)
720
+ | set(_research._RESEARCH_JA_KO_BOOSTS))
721
+ reachable |= {"local_search"}
722
+
723
+ try:
724
+ dp = _json.loads(
725
+ (_Path(__file__).resolve().parent.parent
726
+ / "backends" / "domain_profiles.json").read_text(encoding="utf-8"))
727
+ reachable |= {k for k, v in dp.items()
728
+ if isinstance(v, dict) and (v.get("documents") or [])}
729
+ except Exception:
730
+ pass
731
+
732
+ declared_explicit = {
733
+ n for n, s in specs.items()
734
+ if isinstance(s, dict) and s.get("enabled", True) and s.get("explicit_only")}
735
+ # 声明与实际必须一致:声明了 explicit_only 却又进了自动分发路径,
736
+ # 说明两边有一边说谎(声明忘了撤,或接线时没看声明)。
737
+ contradictory = sorted(declared_explicit & reachable)
738
+ for n in contradictory:
739
+ c.check(f"reach_explicit_conflict:{n}", False, soft=True,
740
+ detail="声明 explicit_only 却进了自动分发路径——声明与实际不一致")
741
+ explicit_only = sorted(declared_explicit - reachable)
742
+ unreachable = sorted(
743
+ n for n, s in specs.items()
744
+ if isinstance(s, dict) and s.get("enabled", True)
745
+ and n not in reachable and not n.startswith("local_")
746
+ and not s.get("explicit_only"))
747
+ for n in unreachable:
748
+ c.check(f"reach:{n}", False,
749
+ detail="五条分发路径均不可达(死源)——接线或声明 explicit_only",
750
+ soft=True)
751
+ c.check("reach:summary", len(unreachable) == 0,
752
+ detail=(f"死源 {len(unreachable)} 个: {', '.join(unreachable) or '无'}"
753
+ f";声明 explicit_only {len(explicit_only)} 个: "
754
+ f"{', '.join(explicit_only) or '无'}"),
755
+ soft=True)
756
+
757
+
758
+ def run_offline(c: Checker) -> None:
759
+ run_offline_lang(c)
760
+ run_offline_route(c)
761
+ run_offline_recovery(c)
762
+ run_offline_families(c)
763
+ run_offline_lang_pref(c)
764
+ run_offline_reachability(c)
765
+
766
+
767
+ # ═══════════════════════════════════════════════════════════════════════════
768
+ # LIVE — 多语 × 场景 E2E
769
+ # ═══════════════════════════════════════════════════════════════════════════
770
+
771
+ # gold: 标题/摘要命中任一即可;pollute: source/title 命中则污染
772
+ LIVE_CASES: list[dict[str, Any]] = [
773
+ # film
774
+ {"id": "L_en_film", "q": "Inception movie director", "lang": "en",
775
+ "domain": "film_search", "primary": "imdb", "scenario": "film",
776
+ "gold": ["nolan", "inception", "christopher"], "pollute": ["pypi", "npm", "jin10"]},
777
+ {"id": "L_zh_film", "q": "肖申克的救赎 主演", "lang": "zh",
778
+ "domain": "film_search", "primary": "imdb", "scenario": "film",
779
+ "gold": ["shawshank", "redemption", "肖申克", "freeman", "robbins"],
780
+ "pollute": ["pypi", "npm"]},
781
+ # sports
782
+ {"id": "L_en_sports", "q": "Cristiano Ronaldo club", "lang": "en",
783
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports",
784
+ "gold": ["ronaldo", "cristiano", "al nassr", "portugal"],
785
+ "pollute": ["pypi", "npm", "jin10"]},
786
+ {"id": "L_zh_sports", "q": "库里 球队", "lang": "zh",
787
+ "domain": "sports_search", "primary": "thesportsdb", "scenario": "sports",
788
+ "gold": ["curry", "stephen", "warrior", "库里"],
789
+ "pollute": ["pypi", "npm"]},
790
+ # media
791
+ {"id": "L_zh_media", "q": "周杰伦 专辑", "lang": "zh",
792
+ "domain": "media_search", "primary": "itunes", "scenario": "media",
793
+ # musicbrainz 数据源返回繁体(周杰倫),金标含简繁两形(数据源事实,非引擎缺陷)
794
+ "gold": ["jay", "chou", "周杰伦", "周杰倫", "album", "叶惠美", "范特西"],
795
+ "pollute": ["pypi", "crates"]},
796
+ {"id": "L_en_media", "q": "Taylor Swift album", "lang": "en",
797
+ "domain": "media_search", "primary": "itunes", "scenario": "media",
798
+ "gold": ["taylor", "swift", "album", "midnights", "1989"],
799
+ "pollute": ["pypi"]},
800
+ # org / geo
801
+ {"id": "L_en_org", "q": "NASA founding year", "lang": "en",
802
+ "domain": "org_entity", "primary": "wikidata", "scenario": "org",
803
+ "gold": ["nasa", "1958", "space", "aeronautics"],
804
+ "pollute": ["pypi", "npm"]},
805
+ {"id": "L_en_geo", "q": "where is Eiffel Tower", "lang": "en",
806
+ "domain": "geo_places", "primary": "local_openstreetmap", "scenario": "geo",
807
+ "gold": ["eiffel", "paris", "france", "tower"],
808
+ "pollute": ["pypi"]},
809
+ # finance
810
+ {"id": "L_zh_stock", "q": "贵州茅台股价", "lang": "zh",
811
+ "domain": "stock_query", "primary": "sina_quote", "scenario": "finance",
812
+ "gold": ["茅台", "600519", "股价", "行情", "元"],
813
+ "pollute": ["pypi", "imdb"]},
814
+ # chem / rfc
815
+ {"id": "L_zh_chem", "q": "阿司匹林 分子式", "lang": "zh",
816
+ "domain": "chem_search", "primary": "pubchem", "scenario": "chem",
817
+ "gold": ["aspirin", "c9h8o4", "阿司匹林", "acetylsalicylic", "pubchem"],
818
+ "pollute": ["jin10", "imdb"]},
819
+ # multi-lang general(允许 recovery / 通用源,关注污染与语言)
820
+ {"id": "L_ja_gen", "q": "アニメ おすすめ", "lang": "ja",
821
+ "domain": None, "scenario": "general",
822
+ "gold": ["アニメ", "anime", "おすすめ", "漫画", "作品"],
823
+ "pollute": ["pypi", "npm", "sina_quote", "jin10"],
824
+ "timeout": 25},
825
+ {"id": "L_ko_gen", "q": "한국 영화 추천", "lang": "ko",
826
+ "domain": None, "scenario": "general",
827
+ "gold": ["영화", "movie", "korea", "한국", "film"],
828
+ "pollute": ["pypi", "npm", "sina_quote"],
829
+ "timeout": 25},
830
+ {"id": "L_cyr_gen", "q": "Как написать скрипт python", "lang": "cyrillic",
831
+ "domain": None, "scenario": "general",
832
+ "gold": ["python", "скрипт", "script", "программ"],
833
+ "pollute": ["sina_quote", "jin10", "imdb"],
834
+ "timeout": 25},
835
+ ]
836
+
837
+
838
+ def _blob_from_results(results: list[dict], used: list, eng: str) -> str:
839
+ parts: list[str] = []
840
+ for x in results:
841
+ for k in ("title", "snippet", "content", "url", "source", "engine"):
842
+ v = x.get(k)
843
+ if v:
844
+ parts.append(str(v))
845
+ parts.extend(str(u) for u in used)
846
+ parts.append(eng or "")
847
+ return " ".join(parts).lower()
848
+
849
+
850
+ def run_live(c: Checker) -> None:
851
+ print("\n== live: multilingual × scenario E2E ==")
852
+ from search import super_search
853
+
854
+ live_rows: list[dict[str, Any]] = []
855
+ for case in LIVE_CASES:
856
+ q = case["q"]
857
+ timeout = int(case.get("timeout") or 20)
858
+ t0 = time.perf_counter()
859
+ try:
860
+ r = super_search(
861
+ q, n=5, mode="auto", depth="fast",
862
+ skip_cache=True, timeout=timeout, envelope=False,
863
+ )
864
+ except Exception as exc: # noqa: BLE001
865
+ ms = int((time.perf_counter() - t0) * 1000)
866
+ c.check(f"live:{case['id']}", False, detail=f"exc={exc} ms={ms}")
867
+ live_rows.append({"id": case["id"], "q": q, "error": str(exc), "ms": ms})
868
+ continue
869
+
870
+ ms = int((time.perf_counter() - t0) * 1000)
871
+ results = r.get("results") or []
872
+ used = list(r.get("engines_used") or [])
873
+ eng = (r.get("engine") or "").lower()
874
+ domain_got = r.get("domain")
875
+ combo = r.get("engines_combo") or r.get("engines") or []
876
+ recovery = r.get("recovery")
877
+ blob = _blob_from_results(results, used, eng)
878
+
879
+ dom_ok = case.get("domain") is None or domain_got == case["domain"]
880
+ primary = case.get("primary")
881
+ eng_ok = True
882
+ if primary:
883
+ eng_ok = (
884
+ eng == primary
885
+ or primary in used
886
+ or primary in (combo or [])
887
+ or any(
888
+ primary in str(x.get("source") or x.get("engine") or "").lower()
889
+ for x in results
890
+ )
891
+ )
892
+
893
+ gold = [g.lower() for g in case.get("gold") or []]
894
+ gold_matched = next((g for g in gold if g in blob), "")
895
+ hit = bool(gold_matched) if gold else len(results) >= 1
896
+
897
+ pollute_keys = [p.lower() for p in case.get("pollute") or []]
898
+ # 污染:结果 source/engine 命中垂直脏源
899
+ pollute_hit: list[str] = []
900
+ for x in results:
901
+ src = f"{x.get('source') or ''} {x.get('engine') or ''}".lower()
902
+ for p in pollute_keys:
903
+ if p in src and p not in pollute_hit:
904
+ pollute_hit.append(p)
905
+ for u in used:
906
+ ul = str(u).lower()
907
+ for p in pollute_keys:
908
+ if p == ul and p not in pollute_hit:
909
+ # 若仅作为 recovery 未进 results 可不算污染;仅 results 源算
910
+ pass
911
+
912
+ # 严格:top results 的 source 不得为 pollute
913
+ pollute_ok = not pollute_hit
914
+
915
+ soft = case.get("scenario") == "general" and case.get("lang") in (
916
+ "ja", "ko", "cyrillic",
917
+ )
918
+ overall = dom_ok and eng_ok and hit and pollute_ok and len(results) >= 1
919
+ # general 多语:domain 不强求;eng_ok 默认 true
920
+ if case.get("domain") is None:
921
+ overall = hit and pollute_ok and len(results) >= 1
922
+
923
+ detail = (
924
+ f"dom={domain_got} eng={eng} n={len(results)} ms={ms} "
925
+ f"hit={hit} gold={gold_matched!r} pollute={pollute_hit} "
926
+ f"used={used} rec={recovery}"
927
+ )
928
+ c.check(
929
+ f"live:{case['id']}",
930
+ overall,
931
+ detail=detail,
932
+ soft=soft and not pollute_ok is False and not overall and hit is False,
933
+ meta={
934
+ "id": case["id"],
935
+ "q": q,
936
+ "scenario": case.get("scenario"),
937
+ "lang": case.get("lang"),
938
+ "dom_ok": dom_ok,
939
+ "eng_ok": eng_ok,
940
+ "hit": hit,
941
+ "pollute": pollute_hit,
942
+ "domain": domain_got,
943
+ "engine": eng,
944
+ "n": len(results),
945
+ "ms": ms,
946
+ "gold_matched": gold_matched,
947
+ "top1": (results[0].get("title") if results else None),
948
+ "sources": [
949
+ str(x.get("source") or x.get("engine") or "") for x in results[:5]
950
+ ],
951
+ "recovery": recovery,
952
+ },
953
+ )
954
+ live_rows.append({
955
+ "id": case["id"],
956
+ "q": q,
957
+ "dom_ok": dom_ok,
958
+ "eng_ok": eng_ok,
959
+ "hit": hit,
960
+ "pollute": pollute_hit,
961
+ "domain": domain_got,
962
+ "engine": eng,
963
+ "n": len(results),
964
+ "ms": ms,
965
+ "gold_matched": gold_matched,
966
+ "top1": (results[0].get("title") if results else None),
967
+ "recovery": recovery,
968
+ })
969
+
970
+ # live 汇总
971
+ if live_rows:
972
+ n = len(live_rows)
973
+ with_dom = [r for r in live_rows if "dom_ok" in r]
974
+ if with_dom:
975
+ print(
976
+ f"\n live summary: n={n} "
977
+ f"domain_acc={sum(1 for r in with_dom if r.get('dom_ok'))/len(with_dom):.0%} "
978
+ f"hit={sum(1 for r in with_dom if r.get('hit'))/len(with_dom):.0%} "
979
+ f"pollute_n={sum(1 for r in with_dom if r.get('pollute'))}"
980
+ )
981
+ c._live_rows = live_rows # type: ignore[attr-defined]
982
+
983
+
984
+ def _summarize(c: Checker) -> dict[str, Any]:
985
+ by_status: dict[str, int] = {"PASS": 0, "FAIL": 0, "WARN": 0}
986
+ by_section: dict[str, dict[str, int]] = {}
987
+ for row in c.rows:
988
+ st = row["status"]
989
+ by_status[st] = by_status.get(st, 0) + 1
990
+ sec = row["name"].split(":")[0]
991
+ by_section.setdefault(sec, {"PASS": 0, "FAIL": 0, "WARN": 0})
992
+ by_section[sec][st] = by_section[sec].get(st, 0) + 1
993
+
994
+ route_meta = [
995
+ r.get("meta") for r in c.rows
996
+ if r["name"].startswith("route:") and r.get("meta")
997
+ ]
998
+ scenario_stats: dict[str, dict[str, int]] = {}
999
+ lang_stats: dict[str, dict[str, int]] = {}
1000
+ for m in route_meta:
1001
+ if not m:
1002
+ continue
1003
+ sc = m.get("scenario") or "other"
1004
+ lg = m.get("lang") or "other"
1005
+ # find status from parent
1006
+ scenario_stats.setdefault(sc, {"ok": 0, "n": 0})
1007
+ lang_stats.setdefault(lg, {"ok": 0, "n": 0})
1008
+
1009
+ for r in c.rows:
1010
+ if not r["name"].startswith("route:"):
1011
+ continue
1012
+ m = r.get("meta") or {}
1013
+ sc = m.get("scenario") or "other"
1014
+ lg = m.get("lang") or "other"
1015
+ scenario_stats.setdefault(sc, {"ok": 0, "n": 0})
1016
+ lang_stats.setdefault(lg, {"ok": 0, "n": 0})
1017
+ scenario_stats[sc]["n"] += 1
1018
+ lang_stats[lg]["n"] += 1
1019
+ if r["status"] == "PASS":
1020
+ scenario_stats[sc]["ok"] += 1
1021
+ lang_stats[lg]["ok"] += 1
1022
+
1023
+ live_rows = getattr(c, "_live_rows", [])
1024
+ live_summary = None
1025
+ if live_rows:
1026
+ valid = [r for r in live_rows if "dom_ok" in r]
1027
+ n = len(valid) or 1
1028
+ live_summary = {
1029
+ "n": len(valid),
1030
+ "domain_acc": sum(1 for r in valid if r.get("dom_ok")) / n,
1031
+ "engine_acc": sum(1 for r in valid if r.get("eng_ok")) / n,
1032
+ "selection_hit": sum(1 for r in valid if r.get("hit")) / n,
1033
+ "domain_and_hit": sum(
1034
+ 1 for r in valid if r.get("dom_ok") and r.get("hit")
1035
+ ) / n,
1036
+ "pollute_n": sum(1 for r in valid if r.get("pollute")),
1037
+ }
1038
+
1039
+ return {
1040
+ "ok": c.ok,
1041
+ "fail": c.fail,
1042
+ "warn": c.warn,
1043
+ "by_status": by_status,
1044
+ "by_section": by_section,
1045
+ "route_by_scenario": scenario_stats,
1046
+ "route_by_lang": lang_stats,
1047
+ "live_summary": live_summary,
1048
+ "live_rows": live_rows,
1049
+ }
1050
+
1051
+
1052
+ def main() -> int:
1053
+ ap = argparse.ArgumentParser(description="多语言×引擎×场景 矩阵测试")
1054
+ ap.add_argument("--offline", action="store_true")
1055
+ ap.add_argument("--live", action="store_true")
1056
+ ap.add_argument("--all", action="store_true")
1057
+ ap.add_argument(
1058
+ "--report",
1059
+ default=str(_ROOT / "tests" / "matrix_search_report.json"),
1060
+ help="JSON 报告路径",
1061
+ )
1062
+ ap.add_argument("--json", action="store_true", help="stdout 打印摘要 JSON")
1063
+ args = ap.parse_args()
1064
+ if not (args.offline or args.live or args.all):
1065
+ args.offline = True
1066
+
1067
+ c = Checker()
1068
+ if args.offline or args.all:
1069
+ run_offline(c)
1070
+ if args.live or args.all:
1071
+ run_live(c)
1072
+
1073
+ summary = _summarize(c)
1074
+ print(f"\n== summary: {c.ok} PASS / {c.fail} FAIL / {c.warn} WARN ==")
1075
+ if summary.get("route_by_scenario"):
1076
+ print(" route by scenario:")
1077
+ for sc, v in sorted(summary["route_by_scenario"].items()):
1078
+ print(f" {sc:12} {v['ok']}/{v['n']}")
1079
+ if summary.get("route_by_lang"):
1080
+ print(" route by lang:")
1081
+ for lg, v in sorted(summary["route_by_lang"].items()):
1082
+ print(f" {lg:12} {v['ok']}/{v['n']}")
1083
+ if summary.get("live_summary"):
1084
+ ls = summary["live_summary"]
1085
+ print(
1086
+ f" live: n={ls['n']} domain_acc={ls['domain_acc']:.0%} "
1087
+ f"hit={ls['selection_hit']:.0%} pollute_n={ls['pollute_n']}"
1088
+ )
1089
+
1090
+ report = {
1091
+ "summary": summary,
1092
+ "rows": c.rows,
1093
+ }
1094
+ report_path = Path(args.report)
1095
+ report_path.parent.mkdir(parents=True, exist_ok=True)
1096
+ report_path.write_text(dumps_pretty(report), encoding="utf-8")
1097
+ print(f" report → {report_path}")
1098
+
1099
+ if args.json:
1100
+ print(dumps(summary))
1101
+ return 1 if c.fail else 0
1102
+
1103
+
1104
+ if __name__ == "__main__":
1105
+ raise SystemExit(main())