argo-search 1.0.1 → 2.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (472) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +735 -0
  3. package/README.es.md +777 -0
  4. package/README.ja.md +777 -0
  5. package/README.ko.md +777 -0
  6. package/README.md +778 -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/why-better.svg +104 -0
  12. package/assets/readme/workflow.svg +83 -0
  13. package/backends/domain_profiles.json +2356 -96
  14. package/backends/engine_registry.yaml +3428 -503
  15. package/backends/query_synonyms_cn.json +35 -0
  16. package/backends/quota_profiles.json +2488 -160
  17. package/backends/source_types_cn.json +109 -0
  18. package/backends/text_lexicon.json +44 -0
  19. package/bin/argo +772 -0
  20. package/bin/argo.js +102 -39
  21. package/config.yaml +5139 -367
  22. package/cordis.patch.yml +21 -0
  23. package/data/countries.json +40 -0
  24. package/data/lang_matrix/lang_capability.json +842 -0
  25. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  26. package/data/regions_cn.json +1 -0
  27. package/docs/ADDING_NEW_ENGINE.md +184 -0
  28. package/docs/ARGO_INTRO.md +124 -0
  29. package/docs/DATA_SOURCE_GAPS.md +81 -0
  30. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  31. package/docs/ENGINE_CATALOG.md +575 -0
  32. package/docs/MCP_SETUP.md +108 -0
  33. package/docs/PLATFORM_PATHS.md +111 -0
  34. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  35. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  36. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  37. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  38. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  39. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  40. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  41. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  42. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  43. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  44. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  45. package/docs/SEARCH_ARCHIVE.md +115 -0
  46. package/docs/assets/architecture.svg +104 -0
  47. package/docs/assets/hero.svg +98 -0
  48. package/docs/engines/bangumi.md +27 -0
  49. package/docs/engines/biorxiv.md +27 -0
  50. package/docs/engines/clawhub.md +27 -0
  51. package/docs/engines/crt_sh.md +27 -0
  52. package/docs/engines/datacite.md +27 -0
  53. package/docs/engines/deps_dev.md +27 -0
  54. package/docs/engines/douban_movie.md +27 -0
  55. package/docs/engines/endoflife.md +27 -0
  56. package/docs/engines/flk_law.md +27 -0
  57. package/docs/engines/fxtwitter.md +27 -0
  58. package/docs/engines/google_news.md +27 -0
  59. package/docs/engines/gov_regulations.md +27 -0
  60. package/docs/engines/guqian.md +27 -0
  61. package/docs/engines/hackernews.md +27 -0
  62. package/docs/engines/iplant.md +27 -0
  63. package/docs/engines/keenable.md +27 -0
  64. package/docs/engines/met_museum.md +27 -0
  65. package/docs/engines/nasa_images.md +27 -0
  66. package/docs/engines/netease_music.md +27 -0
  67. package/docs/engines/npm.md +27 -0
  68. package/docs/engines/nvd.md +27 -0
  69. package/docs/engines/openreview.md +27 -0
  70. package/docs/engines/openstd.md +27 -0
  71. package/docs/engines/parallel_free.md +27 -0
  72. package/docs/engines/people_daily.md +27 -0
  73. package/docs/engines/realtime_index.md +27 -0
  74. package/docs/engines/sec_edgar.md +27 -0
  75. package/docs/engines/seltz.md +27 -0
  76. package/docs/engines/skillsmp.md +27 -0
  77. package/docs/engines/sspai.md +27 -0
  78. package/docs/engines/std_samr.md +27 -0
  79. package/docs/engines/train.md +27 -0
  80. package/docs/engines/un_comtrade.md +27 -0
  81. package/docs/engines/wikisource.md +27 -0
  82. package/docs/engines/zdic.md +27 -0
  83. package/docs/engines/zenodo.md +27 -0
  84. package/docs/engines/zhihu_hot_app.md +27 -0
  85. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  86. package/engines/README.md +10 -0
  87. package/engines/_template_cli.yaml +39 -0
  88. package/engines/_template_http.yaml +37 -0
  89. package/engines/plugins/_template_plugin.py +50 -0
  90. package/engines/specs/_cisa_kev.yaml +54 -0
  91. package/engines/specs/_osv.yaml +63 -0
  92. package/engines/specs/aljazeera.yaml +43 -0
  93. package/engines/specs/antara.yaml +43 -0
  94. package/engines/specs/artic.yaml +20 -0
  95. package/engines/specs/aviation_weather.yaml +39 -0
  96. package/engines/specs/bangumi.yaml +21 -0
  97. package/engines/specs/biorxiv.yaml +24 -0
  98. package/engines/specs/carbon_intensity.yaml +32 -0
  99. package/engines/specs/cfets.yaml +41 -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/defillama.yaml +38 -0
  113. package/engines/specs/deps_dev.yaml +21 -0
  114. package/engines/specs/digup.yaml +36 -0
  115. package/engines/specs/doi.yaml +39 -0
  116. package/engines/specs/douban_movie.yaml +22 -0
  117. package/engines/specs/dw_news.yaml +47 -0
  118. package/engines/specs/egov_law.yaml +21 -0
  119. package/engines/specs/elpais.yaml +43 -0
  120. package/engines/specs/endoflife.yaml +21 -0
  121. package/engines/specs/energy_charts.yaml +21 -0
  122. package/engines/specs/factcheck_org.yaml +52 -0
  123. package/engines/specs/faz.yaml +43 -0
  124. package/engines/specs/federal_register.yaml +57 -0
  125. package/engines/specs/firecrawl.yaml +53 -0
  126. package/engines/specs/flk_law.yaml +24 -0
  127. package/engines/specs/folha.yaml +43 -0
  128. package/engines/specs/france24.yaml +44 -0
  129. package/engines/specs/full_fact.yaml +47 -0
  130. package/engines/specs/fxtwitter.yaml +36 -0
  131. package/engines/specs/gbfs_nyc.yaml +32 -0
  132. package/engines/specs/gdacs.yaml +21 -0
  133. package/engines/specs/gdelt.yaml +64 -0
  134. package/engines/specs/gleif.yaml +55 -0
  135. package/engines/specs/google_news.yaml +22 -0
  136. package/engines/specs/gov_regulations.yaml +38 -0
  137. package/engines/specs/guardian_rss.yaml +59 -0
  138. package/engines/specs/hkex.yaml +40 -0
  139. package/engines/specs/hurriyet.yaml +43 -0
  140. package/engines/specs/iplant.yaml +25 -0
  141. package/engines/specs/jikan.yaml +24 -0
  142. package/engines/specs/jolpica.yaml +20 -0
  143. package/engines/specs/k10plus.yaml +20 -0
  144. package/engines/specs/keenable.yaml +44 -0
  145. package/engines/specs/keenable_free.yaml +36 -0
  146. package/engines/specs/lefigaro.yaml +43 -0
  147. package/engines/specs/listenbrainz.yaml +19 -0
  148. package/engines/specs/matichon.yaml +43 -0
  149. package/engines/specs/met_museum.yaml +21 -0
  150. package/engines/specs/nasa_images.yaml +45 -0
  151. package/engines/specs/netease_music.yaml +38 -0
  152. package/engines/specs/nhk.yaml +43 -0
  153. package/engines/specs/nhtsa_vpic.yaml +20 -0
  154. package/engines/specs/noaa_swpc.yaml +20 -0
  155. package/engines/specs/nvd.yaml +38 -0
  156. package/engines/specs/obis.yaml +20 -0
  157. package/engines/specs/opencitations.yaml +52 -0
  158. package/engines/specs/openf1.yaml +21 -0
  159. package/engines/specs/openfoodfacts.yaml +45 -0
  160. package/engines/specs/openligadb.yaml +20 -0
  161. package/engines/specs/openreview.yaml +37 -0
  162. package/engines/specs/openstd.yaml +21 -0
  163. package/engines/specs/people_daily.yaml +22 -0
  164. package/engines/specs/realtime_index.yaml +34 -0
  165. package/engines/specs/ror.yaml +20 -0
  166. package/engines/specs/satnogs.yaml +20 -0
  167. package/engines/specs/sec_edgar.yaml +46 -0
  168. package/engines/specs/shfe.yaml +39 -0
  169. package/engines/specs/skillsmp.yaml +50 -0
  170. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  171. package/engines/specs/soilgrids.yaml +25 -0
  172. package/engines/specs/sspai.yaml +36 -0
  173. package/engines/specs/stackexchange.yaml +46 -0
  174. package/engines/specs/std_samr.yaml +21 -0
  175. package/engines/specs/tass.yaml +43 -0
  176. package/engines/specs/tavily_free.yaml +56 -0
  177. package/engines/specs/tle_mirror.yaml +21 -0
  178. package/engines/specs/train.yaml +37 -0
  179. package/engines/specs/tvmaze.yaml +20 -0
  180. package/engines/specs/twitter_syndication.yaml +31 -0
  181. package/engines/specs/un_comtrade.yaml +23 -0
  182. package/engines/specs/unpaywall.yaml +54 -0
  183. package/engines/specs/vnpress.yaml +43 -0
  184. package/engines/specs/weather.yaml +32 -0
  185. package/engines/specs/who_don.yaml +21 -0
  186. package/engines/specs/who_gho.yaml +20 -0
  187. package/engines/specs/wikimedia_commons.yaml +34 -0
  188. package/engines/specs/wikisource.yaml +22 -0
  189. package/engines/specs/worms.yaml +19 -0
  190. package/engines/specs/x_trending.yaml +34 -0
  191. package/engines/specs/yna.yaml +43 -0
  192. package/engines/specs/ynet.yaml +43 -0
  193. package/engines/specs/yuanbao.yaml +93 -0
  194. package/engines/specs/zbmath.yaml +40 -0
  195. package/engines/specs/zdic.yaml +22 -0
  196. package/engines/specs/zenodo.yaml +27 -0
  197. package/engines/specs/zhihu_hot_app.yaml +35 -0
  198. package/mcp/clients.yaml +106 -0
  199. package/package.json +55 -12
  200. package/packages/dsh-plugin/README.md +152 -0
  201. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  202. package/packages/dsh-plugin/dsh/client.js +1533 -0
  203. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  204. package/packages/dsh-plugin/dsh/index.js +527 -0
  205. package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
  206. package/packages/dsh-plugin/dsh/runner.js +666 -0
  207. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  208. package/packages/dsh-plugin/package.json +38 -0
  209. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  210. package/packages/dsh-plugin/test/client.test.js +406 -0
  211. package/packages/dsh-plugin/test/config.test.js +30 -0
  212. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  213. package/packages/dsh-plugin/test/index.test.js +1382 -0
  214. package/references/query-templates.md +96 -0
  215. package/references/query-templates.yaml +118 -0
  216. package/scripts/ab_eval.py +219 -0
  217. package/scripts/ab_eval_p0p1.py +266 -0
  218. package/scripts/adaptive.py +294 -70
  219. package/scripts/answer.py +154 -0
  220. package/scripts/archive_run.py +591 -0
  221. package/scripts/argo_engine_registry.py +172 -0
  222. package/scripts/argo_paths.py +728 -0
  223. package/scripts/article.py +136 -0
  224. package/scripts/article_meta.py +131 -0
  225. package/scripts/arxiv_source.py +201 -0
  226. package/scripts/badcase_ledger.py +252 -0
  227. package/scripts/batch_probe.py +241 -0
  228. package/scripts/benchmark.py +19 -0
  229. package/scripts/bounded_run.py +144 -0
  230. package/scripts/browser_auth.py +323 -0
  231. package/scripts/budget.py +247 -0
  232. package/scripts/budget_cli.py +93 -0
  233. package/scripts/build_release_assets.sh +65 -0
  234. package/scripts/burstiness_detector.py +147 -0
  235. package/scripts/cache.py +1066 -67
  236. package/scripts/cache_guard.py +271 -0
  237. package/scripts/cache_key_vdom.py +56 -0
  238. package/scripts/cache_similarity.py +170 -0
  239. package/scripts/caliber_isolation.py +195 -0
  240. package/scripts/calibrate_lowquality.py +210 -0
  241. package/scripts/candidate_envelope.py +366 -0
  242. package/scripts/chrome_cdp.py +871 -0
  243. package/scripts/ci_drift_gate.py +90 -0
  244. package/scripts/circuit_breaker.py +389 -0
  245. package/scripts/citations.py +334 -0
  246. package/scripts/cite_cli.py +59 -0
  247. package/scripts/cjk_tokens.py +95 -0
  248. package/scripts/clarify.py +47 -24
  249. package/scripts/cli_io.py +240 -0
  250. package/scripts/config.py +683 -45
  251. package/scripts/config_cache.py +402 -0
  252. package/scripts/conn_pool.py +220 -0
  253. package/scripts/content_boundary.py +46 -0
  254. package/scripts/content_security.py +685 -0
  255. package/scripts/content_signals.py +751 -0
  256. package/scripts/crawl.py +74 -35
  257. package/scripts/cross_lingual_trace.py +293 -0
  258. package/scripts/daemon.py +866 -0
  259. package/scripts/digup_bridge.py +214 -0
  260. package/scripts/dispatch_result.py +98 -0
  261. package/scripts/engine_admission.py +366 -0
  262. package/scripts/engine_dispatch.py +982 -0
  263. package/scripts/engine_env.py +539 -0
  264. package/scripts/engine_failure.py +411 -0
  265. package/scripts/engine_families.py +738 -0
  266. package/scripts/engine_policy.py +218 -0
  267. package/scripts/engine_requires.py +190 -0
  268. package/scripts/engine_status.py +322 -0
  269. package/scripts/engine_validate.py +638 -0
  270. package/scripts/engines.py +799 -294
  271. package/scripts/engines_base.py +1521 -0
  272. package/scripts/engines_builders.py +310 -0
  273. package/scripts/engines_builders_batch10.py +204 -0
  274. package/scripts/engines_builders_batch11.py +174 -0
  275. package/scripts/engines_builders_batch12.py +103 -0
  276. package/scripts/engines_builders_batch13.py +340 -0
  277. package/scripts/engines_builders_batch14.py +656 -0
  278. package/scripts/engines_builders_batch15.py +249 -0
  279. package/scripts/engines_builders_batch9.py +1468 -0
  280. package/scripts/engines_builders_cn.py +2283 -0
  281. package/scripts/engines_builders_data.py +2923 -0
  282. package/scripts/engines_builders_data_macro.py +775 -0
  283. package/scripts/engines_builders_feeds.py +255 -0
  284. package/scripts/engines_builders_intl.py +1239 -0
  285. package/scripts/engines_builders_keyless.py +111 -0
  286. package/scripts/engines_builders_search.py +440 -0
  287. package/scripts/engines_builders_tech.py +1359 -0
  288. package/scripts/evidence.py +620 -166
  289. package/scripts/evidence_loop.py +624 -0
  290. package/scripts/evidence_tier.py +298 -0
  291. package/scripts/except_sets.py +25 -0
  292. package/scripts/extract.py +55 -16
  293. package/scripts/fact_align.py +207 -0
  294. package/scripts/fact_crosscheck.py +230 -0
  295. package/scripts/fetch.py +25 -12
  296. package/scripts/fetch_browser.py +87 -0
  297. package/scripts/fetch_jsrun.py +122 -0
  298. package/scripts/fetch_quality.py +225 -0
  299. package/scripts/fetch_render_tinyfish.py +117 -0
  300. package/scripts/fetch_v3.py +1804 -0
  301. package/scripts/focus_extract.py +350 -0
  302. package/scripts/fulltext_store.py +169 -0
  303. package/scripts/fxtwitter_api.py +325 -0
  304. package/scripts/gen_engine_catalog.py +364 -0
  305. package/scripts/gen_native_tools.py +91 -0
  306. package/scripts/gen_routing_snapshot.py +195 -0
  307. package/scripts/health_check.py +140 -402
  308. package/scripts/health_probe.py +23 -8
  309. package/scripts/hot_state.py +134 -0
  310. package/scripts/http_client.py +1053 -0
  311. package/scripts/image_ops.py +430 -0
  312. package/scripts/image_vision/vision_probe.swift +126 -0
  313. package/scripts/install.ps1 +166 -0
  314. package/scripts/install.sh +221 -0
  315. package/scripts/job.py +1186 -0
  316. package/scripts/jump_resolver.py +235 -0
  317. package/scripts/lang_capability.py +150 -0
  318. package/scripts/lang_detect.py +289 -0
  319. package/scripts/lang_matrix_probe.py +329 -0
  320. package/scripts/lang_pref.py +311 -0
  321. package/scripts/link_source.py +340 -0
  322. package/scripts/local_image.py +970 -0
  323. package/scripts/local_seek.py +402 -0
  324. package/scripts/macro_countries.py +97 -0
  325. package/scripts/matrix_search_eval.py +1187 -0
  326. package/scripts/mcp_compact.py +234 -0
  327. package/scripts/mcp_diag.py +8 -3
  328. package/scripts/mcp_handlers.py +492 -0
  329. package/scripts/mcp_handlers_surface.py +157 -0
  330. package/scripts/mcp_launch.sh +124 -0
  331. package/scripts/mcp_local.py +241 -0
  332. package/scripts/mcp_server.py +125 -419
  333. package/scripts/mcp_setup.py +431 -0
  334. package/scripts/mcp_tools.py +331 -0
  335. package/scripts/mcp_transport.py +191 -0
  336. package/scripts/mcp_warm.py +127 -0
  337. package/scripts/min_version_scan.py +335 -0
  338. package/scripts/net_proxy.py +258 -0
  339. package/scripts/network_aware.py +152 -0
  340. package/scripts/output_transparency.py +200 -0
  341. package/scripts/paid_policy.py +98 -0
  342. package/scripts/paper.py +274 -0
  343. package/scripts/pdf_extract.py +518 -0
  344. package/scripts/phrase_filter.py +66 -0
  345. package/scripts/plan.py +422 -0
  346. package/scripts/postprocess_signals.py +150 -0
  347. package/scripts/pre-commit +48 -0
  348. package/scripts/query_enhance.py +157 -0
  349. package/scripts/query_operators.py +196 -0
  350. package/scripts/query_rewriter.py +116 -7
  351. package/scripts/query_signals.py +300 -0
  352. package/scripts/query_templates.py +167 -0
  353. package/scripts/query_understanding.py +411 -0
  354. package/scripts/query_variants.py +298 -0
  355. package/scripts/quota.py +633 -76
  356. package/scripts/rank_signals.py +775 -0
  357. package/scripts/ranking_eval.py +319 -0
  358. package/scripts/readability_extract.py +433 -0
  359. package/scripts/recompute.py +415 -0
  360. package/scripts/recovery.py +616 -0
  361. package/scripts/recovery_l0.py +160 -0
  362. package/scripts/recovery_runner.py +150 -0
  363. package/scripts/redskill/redskill_engine.py +332 -0
  364. package/scripts/regression_p0p1.py +318 -0
  365. package/scripts/replay_eval.py +658 -0
  366. package/scripts/rerank_tfidf.py +225 -0
  367. package/scripts/research.py +607 -403
  368. package/scripts/research_cli.py +361 -0
  369. package/scripts/research_dossier.py +492 -0
  370. package/scripts/research_expand.py +169 -0
  371. package/scripts/research_gates.py +272 -0
  372. package/scripts/research_report.py +209 -0
  373. package/scripts/research_strategy.py +37 -0
  374. package/scripts/research_work_packages.py +195 -0
  375. package/scripts/result_lang.py +311 -0
  376. package/scripts/retry_policy.py +76 -0
  377. package/scripts/robots_guard.py +259 -0
  378. package/scripts/route.py +921 -254
  379. package/scripts/route_bangs.py +40 -0
  380. package/scripts/route_cache.py +243 -0
  381. package/scripts/route_combo.py +917 -0
  382. package/scripts/route_domains.py +341 -0
  383. package/scripts/route_lang.py +605 -0
  384. package/scripts/route_log.py +63 -0
  385. package/scripts/route_policy.py +250 -0
  386. package/scripts/rrf_classic.py +66 -0
  387. package/scripts/screenshot.py +73 -0
  388. package/scripts/search.py +886 -406
  389. package/scripts/search_attribution.py +185 -0
  390. package/scripts/search_benchmark.py +407 -0
  391. package/scripts/search_cli.py +640 -0
  392. package/scripts/search_entry.py +395 -0
  393. package/scripts/search_output.py +690 -0
  394. package/scripts/search_pipeline.py +926 -0
  395. package/scripts/search_rank.py +991 -0
  396. package/scripts/search_types.py +129 -16
  397. package/scripts/seek_locator.py +68 -0
  398. package/scripts/semantic_evidence.py +391 -0
  399. package/scripts/serp_guard.py +336 -0
  400. package/scripts/serp_kill_log.py +74 -0
  401. package/scripts/single_flight.py +120 -0
  402. package/scripts/social_engines/__init__.py +10 -0
  403. package/scripts/social_engines/bilibili_engine.py +131 -0
  404. package/scripts/social_engines/hackernews_engine.py +113 -0
  405. package/scripts/social_engines/reddit_engine.py +172 -0
  406. package/scripts/social_engines/twitter_engine.py +397 -0
  407. package/scripts/social_engines/v2ex_engine.py +101 -0
  408. package/scripts/social_engines/weibo_engine.py +128 -0
  409. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  410. package/scripts/social_engines/zhihu_engine.py +139 -0
  411. package/scripts/social_research.py +201 -0
  412. package/scripts/sogou_weixin_browser.py +318 -0
  413. package/scripts/source_ledger.py +249 -0
  414. package/scripts/stage_timing.py +58 -0
  415. package/scripts/stats_cli.py +107 -0
  416. package/scripts/straggler_policy.py +60 -0
  417. package/scripts/stylometry_detector.py +231 -0
  418. package/scripts/sync_backends.py +353 -0
  419. package/scripts/sync_installs.py +29 -0
  420. package/scripts/template_cli.py +127 -0
  421. package/scripts/text_lexicon.py +72 -0
  422. package/scripts/tfidf_router.py +109 -37
  423. package/scripts/time_utils.py +233 -0
  424. package/scripts/topic_research_profiles.py +643 -0
  425. package/scripts/train.py +737 -0
  426. package/scripts/tweet.py +596 -0
  427. package/scripts/url_canon.py +217 -0
  428. package/scripts/url_optimize.py +56 -0
  429. package/scripts/url_safety.py +236 -0
  430. package/scripts/usage_log.py +116 -0
  431. package/scripts/usage_log_clean.py +113 -0
  432. package/scripts/v2ex_nodes.py +311 -0
  433. package/scripts/variant_recall.py +188 -0
  434. package/scripts/watch.py +205 -0
  435. package/scripts/wechat_article.py +258 -0
  436. package/scripts/wide_research.py +447 -0
  437. package/scripts/wx.py +349 -0
  438. package/scripts/x_article_fetch.py +141 -0
  439. package/scripts/x_search.py +234 -0
  440. package/scripts/x_trending.py +189 -0
  441. package/scripts/yaml_load.py +67 -0
  442. package/scripts/zhihu_quota.py +139 -0
  443. package/sub-skills/ego-search/SKILL.md +253 -0
  444. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  445. package/sub-skills/ego-search/scripts/merge.py +226 -0
  446. package/sub-skills/ego-search/scripts/quality.py +64 -0
  447. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  448. package/sub-skills/ego-search/scripts/safety.py +109 -0
  449. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  450. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  451. package/sub-skills/js-run/SKILL.md +67 -0
  452. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  453. package/sub-skills/js-run/scripts/shim.js +188 -0
  454. package/sub-skills/local-search/SKILL.md +50 -53
  455. package/sub-skills/local-search/bm25.py +186 -0
  456. package/sub-skills/local-search/cli_runner.py +331 -0
  457. package/sub-skills/local-search/config.yaml +152 -55
  458. package/sub-skills/local-search/engine_registry.py +91 -26
  459. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  460. package/sub-skills/local-search/local_search_adapter.py +13 -2
  461. package/sub-skills/local-search/parse_maps.yaml +40 -42
  462. package/sub-skills/local-search/parsers.py +328 -0
  463. package/sub-skills/local-search/search_v3.py +716 -242
  464. package/sub-skills/local-search/smart_router.py +47 -7
  465. package/sub-skills/local-seek/SKILL.md +110 -0
  466. package/sub-skills/local-seek/config/domains.yaml +20 -0
  467. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  468. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  469. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  470. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  471. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  472. package/sub-skills/local-seek/scripts/seek_rank.py +197 -0
@@ -0,0 +1,917 @@
1
+ #!/usr/bin/env python3
2
+ """route_combo.py — 引擎组合的装配与裁剪。
3
+
4
+ 一个域命中之后,combo 要经过这些步骤才定稿:展开 local_search 子引擎 →
5
+ 语言相关追加与排序(在 route_lang)→ geo 补充 → 能力族去重 → 自适应学习器
6
+ 重排 → 预算截断(在 route_policy)。本模块承载「装配」这一段。
7
+
8
+ 与 route_policy 的分工:本模块决定**谁该在场、什么顺序**,route_policy 决定
9
+ **预算允许多少、谁必须留下**。两者分开是因为前者的失败形态是「选错源」,
10
+ 后者是「源被裁掉」——症状不同,改动的判据也不同。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any
17
+
18
+ from config import get_engines
19
+ from quota import get_quota_manager
20
+ from route_lang import (_enabled_local_engines, _filter_lang_bound_family,
21
+ _get_registry, _lang_must_keep, _specs_snapshot,
22
+ lang_allows)
23
+
24
+ # 自适应学习器(可选依赖):按历史成败微调同族引擎的次序。
25
+ # 从 route.py 搬来这里:它是 _get_engines_combo 的私有状态,放在 route.py 会让
26
+ # 「import route」白付一次 adaptive 装载(路由热路径,缓存命中也要走)。
27
+ #
28
+ # 惰性化(2026-09-27):上一段注释的意图此前并没有兑现——route.py:125 是
29
+ # **模块级**导入本模块,`get_learner()` 照样在 import route 时构造。而构造
30
+ # 即 `_init_db()`:connect + 3 PRAGMA + CREATE TABLE + 3×PRAGMA table_info +
31
+ # CREATE INDEX + 全表 DELETE 剪枝 + commit,随 engine_perf 行数线性增长
32
+ # (实测 5k 行 1–3 ms、50k 行 10–30 ms),每次进程启动都付。改成首次
33
+ # **使用**时才构造:缓存命中路径(不进 _get_engines_combo)与只读路由路径
34
+ # 都不再付这笔钱。
35
+ #
36
+ # `_UNRESOLVED` 哨兵而非 None:测试用 `patch("route_combo._adaptive_learner",
37
+ # None)` 隔离学习器干扰,None 必须是「已解析且没有」的合法值,不能与
38
+ # 「还没解析」共用同一个值。
39
+ # 垂直保护的分数地板(2026-10-02):自适应分低于此值的引擎
40
+ # 是「已被证明失败」(无数据引擎为中性 0.5),不再受垂直保护、
41
+ # primary 豁免与 must_keep 庇护。取值依据:learner 分数 = 成功率×
42
+ # 延迟因子×质量,0.15 对应长期近乎全败(实测 thesportsdb 0.084、
43
+ # 近期 12/13 次失败)。route.py 的 must_keep 组装也导入此常量。
44
+ _VERTICAL_FLOOR = 0.15
45
+
46
+ _UNRESOLVED: Any = object()
47
+ _adaptive_learner: Any = _UNRESOLVED
48
+
49
+
50
+ def _get_learner() -> Any:
51
+ """首次使用时才构造自适应学习器(失败或无依赖返回 None)。"""
52
+ global _adaptive_learner
53
+ if _adaptive_learner is _UNRESOLVED:
54
+ try:
55
+ from adaptive import get_learner
56
+ _adaptive_learner = get_learner()
57
+ except Exception:
58
+ _adaptive_learner = None
59
+ return _adaptive_learner
60
+
61
+
62
+ def _expand_local_search(engine_list: list[str], features: dict | None = None) -> list[str]:
63
+ """将 local_search 扩展为具体的子引擎(基于查询特征)。"""
64
+ if "local_search" not in engine_list:
65
+ return engine_list
66
+ if _get_registry is None:
67
+ return engine_list
68
+
69
+ sub_engines = _enabled_local_engines() # 内部自取注册表,此处不再重复加载
70
+ if not sub_engines:
71
+ return engine_list
72
+
73
+ selected = _select_sub_engines(sub_engines, features)
74
+ result = [e for e in engine_list if e != "local_search"]
75
+ # 截断只限本地子引擎数量;远端成员总量由 route_query 末尾
76
+ # engine_policy.filter_combo_by_policy 统一管。曾用 result[:4] 整体截断,
77
+ # combo 声明顺序即生死(第 5 位起被无差别砍掉,octen/子引擎全灭)。
78
+ for eng in selected[:4]:
79
+ if eng not in result:
80
+ result.append(eng)
81
+ return result
82
+
83
+
84
+ def _general_fallback(enabled: set[str]) -> list[str]:
85
+ """通用免费源领队 + 本地检索保底(清单唯一来源 engine_policy.GENERAL_FREE_FALLBACK)。
86
+
87
+ 域内引擎全被过滤 / 无匹配域时的回退组合。通用免费源在前、local_search 殿后,
88
+ 清单与 recovery L3 共用同一常量,避免两处清单漂移。
89
+
90
+ local_search 殿后而非领队(2026-10-15 实测定案):日常档 combo_budget=2
91
+ (见 engine_policy.combo_budget,fast/auto+fast 都只取前 2 位),
92
+ `_expand_local_search` 又把展开出的本地子引擎**追加尾部**——它一旦占住前排,
93
+ 尾部子引擎必被预算截断,只剩 local_search 聚合器**独跑**。1059 条真实查询里
94
+ 它独跑 109 次、中位 2.9s、45% 超 3s,占全部查询墙钟 369s,而其中纯英文
95
+ 技术查询("zig comptime explained" 类)占 82%——本地文件检索对这类 query
96
+ 语义上无效,纯亏延迟。改为通用源领队后:常规查询直接落 anysearch/local_bing
97
+ (实测 ~1.1-1.7s),local_search 只在通用源全部不可用(熔断/配额耗尽)时
98
+ 才顶上前预算位,本地检索能力不减,只是不再无条件霸占首轮。
99
+ """
100
+ try:
101
+ from engine_policy import GENERAL_FREE_FALLBACK
102
+ except ImportError:
103
+ GENERAL_FREE_FALLBACK = ("anysearch", "octen", "local_bing")
104
+ order = list(GENERAL_FREE_FALLBACK)
105
+ # 领队覆盖(2026-10-21):A/B 与线上回退都走这里,避免为了复测改常量。
106
+ # 传引擎名即把它提到首位(例:ARGO_GENERAL_FALLBACK_LEAD=anysearch 复现
107
+ # 2026-10-21 之前的顺位);传空/未知值不生效。与下面的 LEGACY 开关正交。
108
+ lead_override = (os.environ.get("ARGO_GENERAL_FALLBACK_LEAD") or "").strip()
109
+ if lead_override and lead_override in order:
110
+ order = [lead_override] + [e for e in order if e != lead_override]
111
+ # 逃生开关:A/B 与线上回退都走这条,避免为了复测改常量。
112
+ # 2026-10-17 对拍(交错、随机序):octen 入第二顺位后,anysearch 抖动
113
+ # 窗口 p90 3273→1614ms、max 6266→1928ms、错误 9→0;健康窗口中位无差。
114
+ try:
115
+ from engine_env import env_flag
116
+ if env_flag("ARGO_GENERAL_FALLBACK_LEGACY", default=False):
117
+ order = [e for e in order if e != "octen"]
118
+ except ImportError:
119
+ pass
120
+ lead = [e for e in order if e in enabled]
121
+ # 保底项同样受 enabled 约束:不在 enabled 说明本机压根没注册/被禁用,
122
+ # 选它只会在执行层撞 "未知引擎" 空跑。
123
+ return lead + (["local_search"] if "local_search" in enabled else [])
124
+
125
+
126
+ def _breaker_blocked(engine_id: str) -> bool:
127
+ """单引擎熔断判定:disabled / open 且冷却未过 → True(剔除)。
128
+
129
+ half_open 保留探测资格(与 _get_engines_combo 内熔断感知过滤
130
+ 同一语义)。状态读取失败 ≠ 熔断:fail-open 返回 False,留痕与
131
+ _filter_breaker_blocked 既有口径一致(017b01a 姊妹形状)。
132
+ """
133
+ try:
134
+ from circuit_breaker import get_breaker
135
+ st = get_breaker().status(engine_id)
136
+ except Exception as _exc:
137
+ import logging
138
+ logging.getLogger("unified_search").debug(
139
+ f"breaker.status 读取失败,按健康处理: {_exc!r}")
140
+ return False
141
+ state = st.get("state")
142
+ if state == "disabled":
143
+ return True
144
+ return state == "open" and int(st.get("cooldown_remain") or 0) > 0
145
+
146
+
147
+ def _filter_breaker_blocked(engine_list: list[str]) -> list[str]:
148
+ """剔除确定熔断态引擎(disabled / open 且冷却未过),与 _get_engines_combo
149
+ 内的熔断感知过滤同一语义(half_open 保留探测资格)。
150
+
151
+ D4:语言引擎追加、通用保底、TF-IDF 注入等路径在 _get_engines_combo 之外,
152
+ 追加的引擎可能处于熔断态仍进 combo,白占并行槽位。统一统一处理到最终组装后。
153
+ """
154
+ if not engine_list:
155
+ return engine_list
156
+ return [e for e in engine_list if not _breaker_blocked(e)]
157
+
158
+
159
+ def engine_is_dead(engine_id: str) -> bool:
160
+ """死源判定:自适应分 <_VERTICAL_FLOOR(已被证明失败)。
161
+
162
+ 学习器未加载或读取失败 → False(fail-open:无数据引擎是
163
+ 中性 0.5,不应被误判为死源)。GEC 过滤、route.py 的
164
+ must_keep 与 TF-IDF 注入三处共用同一判据(2026-10-02
165
+ 抽取,此前三处各写一遍)。
166
+ """
167
+ learner = _get_learner()
168
+ if learner is None:
169
+ return False
170
+ try:
171
+ return learner.get_score(engine_id) < _VERTICAL_FLOOR
172
+ except Exception:
173
+ return False
174
+
175
+
176
+ def build_refill_pool(engines_combo: list[str], mode: str,
177
+ depth: str, context: str) -> list[str]:
178
+ """槽位回填池快照:策略(预算截断 + must_keep 腾位)之前的
179
+ 候选,已过 GEC 的准入/配额/语言过滤。
180
+
181
+ research 语境外的 research_only 源不进池——与
182
+ filter_combo_by_policy 的 tier 过滤同口径,避免回填把
183
+ 策略裁掉的垂直研究源悄悄塞回通用 combo。
184
+ """
185
+ pool = list(engines_combo)
186
+ try:
187
+ from engine_policy import _tier_lookup, is_research_context
188
+ if not is_research_context(mode=mode, depth=depth,
189
+ context=context):
190
+ _tier_of = _tier_lookup()
191
+ pool = [e for e in pool if _tier_of(e) != "research_only"]
192
+ except ImportError:
193
+ pass
194
+ return pool
195
+
196
+
197
+ def _hard_down_blocked(engine_id: str) -> bool:
198
+ """配额硬耗尽判定:已达到硬上限或运行时耗尽标记 → True(剔除)。"""
199
+ try:
200
+ return bool(get_quota_manager().is_hard_down(engine_id))
201
+ except Exception:
202
+ return False
203
+
204
+
205
+ def is_engine_routable(
206
+ engine_id: str,
207
+ *,
208
+ enabled: set[str] | None = None,
209
+ check_breaker: bool = True,
210
+ check_hard_down: bool = True,
211
+ check_dead: bool = False,
212
+ lang: str = "",
213
+ specs: dict | None = None,
214
+ ) -> bool:
215
+ """单一引擎路由准入谓词:统一收口 enabled / 熔断 / 配额硬耗尽 / 死源地板 / 语言可达。"""
216
+ if enabled is not None and engine_id not in enabled:
217
+ return False
218
+ if lang and not lang_allows(engine_id, lang, (specs or {}).get(engine_id)):
219
+ return False
220
+ if check_dead and engine_is_dead(engine_id):
221
+ return False
222
+ if check_breaker and _breaker_blocked(engine_id):
223
+ return False
224
+ if check_hard_down and _hard_down_blocked(engine_id):
225
+ return False
226
+ return True
227
+
228
+
229
+ def backfill_after_breaker(combo: list[str], enabled: set[str],
230
+ features: dict | None = None,
231
+ query: str = "", skip_aux: bool = False,
232
+ refill_pool: list[str] | None = None
233
+ ) -> list[str]:
234
+ """breaker_filter + 熔断槽位回填(combo 定稿的最后一道)。
235
+
236
+ 先走 breaker_filter(熔断摘除 + 语言补充 + 空回退),再把
237
+ 熔断摘除让掉的预算内槽位从 refill_pool 按序补回:候选须在
238
+ enabled、非熔断、语言边界合规(与 _filter_lang_bound_family
239
+ 同口径——池是策略前快照,语言摘除发生在策略之后,回填不得
240
+ 撤销语言摘除)、且不在 combo。补到摘除前长度为止;池耗尽
241
+ 则保持短 combo(fail-open)。
242
+
243
+ 实测根因(2026-10-02):中文地名查询触发 geo must_keep
244
+ 挤掉次引擎(腾位),geo 位引擎随后又因熔断 disabled 被
245
+ 摘除——两头损失叠加后 combo 只剩 anysearch 单引擎,丧失
246
+ 对冲与早停多样性(anysearch 延迟 1.6-6s 且波动大)。
247
+ """
248
+ target_len = len(combo)
249
+ combo = breaker_filter(combo, enabled, features=features,
250
+ query=query, skip_aux=skip_aux)
251
+ if refill_pool and len(combo) < target_len:
252
+ for cand in refill_pool:
253
+ if len(combo) >= target_len:
254
+ break
255
+ if cand in combo or not is_engine_routable(
256
+ cand, enabled=enabled, check_hard_down=not skip_aux):
257
+ continue
258
+ if _filter_lang_bound_family(
259
+ [cand], features, _specs_snapshot(), query) != [cand]:
260
+ continue
261
+ combo.append(cand)
262
+ return combo
263
+
264
+
265
+ # TF-IDF「强证据」线:超过它语义推荐才可领队(catch-all 覆写与 TF-IDF 分支
266
+ # 同一条线)。0.12~0.15 是弱证据——推荐引擎只作辅源跟跑,通用保底领队;
267
+ # 否则营养库接「餐厅推荐」这类低分错配会占 primary 位(实测 usda 0.145)。
268
+ TFIDF_STRONG_SCORE = 0.15
269
+
270
+
271
+ def tfidf_lead_combo(tfidf_best: str | None, tfidf_best_score: float,
272
+ enabled: set[str]) -> list[str]:
273
+ """TF-IDF 分支的初始 combo:强证据推荐领队 + anysearch 对冲。
274
+
275
+ 弱证据(≤TFIDF_STRONG_SCORE)时反过来:通用保底领队,推荐引擎殿后
276
+ 作辅源——仍在 combo 里并行竞速,能力不减,只是不再占 primary 位
277
+ (含 primary 宽限/充分性判定对它的偏袒)。
278
+ """
279
+ strong = bool(tfidf_best and tfidf_best_score > TFIDF_STRONG_SCORE)
280
+ lead: list[str] = []
281
+ if strong:
282
+ lead = [tfidf_best]
283
+ # 垂直领队用 octen 对冲、anysearch 第三;anysearch 领队则反过来。
284
+ hedges = ("octen", "anysearch") if tfidf_best != "anysearch" \
285
+ else ("anysearch", "octen")
286
+ for e in hedges:
287
+ if e in enabled and e not in lead:
288
+ lead.append(e)
289
+ else:
290
+ # 弱证据/无推荐:通用保底领队,弱推荐作为辅源跟在后面(既有契约,
291
+ # tests/test_route_tfidf_weak_score.py 锁死),octen 排第三。日常
292
+ # budget=2 会截掉第三位,所以弱证据不额外占 octen 的位;低分回退
293
+ # 分支(score < TFIDF_MIN_SCORE)走通用兜底链,那里 octen 已在第二。
294
+ if "anysearch" in enabled:
295
+ lead.append("anysearch")
296
+ if tfidf_best and tfidf_best not in lead:
297
+ lead.append(tfidf_best)
298
+ if "octen" in enabled and "octen" not in lead:
299
+ lead.append("octen")
300
+ out: list[str] = []
301
+ for e in lead:
302
+ if e in enabled and e not in out:
303
+ out.append(e)
304
+ return out
305
+
306
+
307
+ def select_tfidf_best(query: str,
308
+ features: dict | None,
309
+ specs: dict | None,
310
+ semantic_route,
311
+ min_score: float = 0.12,
312
+ enabled: set[str] | None = None,
313
+ ) -> tuple[str | None, float, list]:
314
+ """TF-IDF 语义路由选源:返回 (最佳引擎, 分数, 候选表)。
315
+
316
+ 从 route.py 抽取(2026-10-02):候选表按分数降序,
317
+ 首个通过四道门的候选当选——
318
+ ① 社交引擎须查询带社交信号(否则跳过看下一个);
319
+ ② 分数 <min_score → 整条分支作废(后续分更低);
320
+ ③ 语言可达性门:引擎声明的语言能力不含查询语言
321
+ 且非语言中立("*")时不得顶到首位(实测中文
322
+ 法条查询「刑法 判例 司法解释」与 kor_law 共享
323
+ 汉字而命中,被注进 legal 域首位);
324
+ ④ 死源地板(engine_is_dead):语义最相关也不得
325
+ 顶到首位——实测 thesportsdb 0.084 仍是「NBA
326
+ 总决赛」类查询的第一候选,注入后撤销 GEC 地板
327
+ 过滤并占 wave-1 竞速首位,白等它的单引擎超时。
328
+
329
+ semantic_route 由调用方传入:route.py 经模块属性
330
+ 访问解析(测试打桩 patch("route.semantic_route")
331
+ 的契约不变)。
332
+ """
333
+ social_engines = {
334
+ "twitter", "reddit", "xiaohongshu", "bilibili", "weibo",
335
+ "zhihu", "hackernews", "v2ex",
336
+ }
337
+ tfidf_best: str | None = None
338
+ tfidf_best_score = 0.0
339
+ near_miss: tuple[str, float] | None = None
340
+ tfidf_scores: list = semantic_route(query, top_k=3)
341
+ for cand, score, _ in tfidf_scores:
342
+ social_ok = True
343
+ if cand in social_engines:
344
+ ql = query.lower()
345
+ social_signals = (
346
+ "微博", "小红书", "推特", "twitter", "reddit", "舆情",
347
+ "讨论", "网友", "评论", "b站", "bilibili", "抖音",
348
+ )
349
+ social_ok = any(s in ql for s in social_signals)
350
+ if score < min_score:
351
+ # 分数降序:后续候选分更低,整条 TF-IDF 分支作废
352
+ break
353
+ if not social_ok:
354
+ # 社交引擎候选但查询无社交信号:跳过看下一个候选
355
+ continue
356
+ # ja/ko 查询:候选若是中文内容/政策引擎(gov_policy/百科等),
357
+ # 对日/韩用户无关(返回中文站),丢弃让通用 anysearch 主导。
358
+ # 丢弃当前候选后继续看下一个(2026-08 修复:旧逻辑只看 top-1,
359
+ # 丢弃后不检查 top-2/3,可能错失 anysearch 等合格候选)。
360
+ _ql = (features or {}).get("primary_lang") or ""
361
+ if not is_engine_routable(cand, check_breaker=False,
362
+ check_dead=True, lang=_ql, specs=specs):
363
+ continue
364
+ if enabled is not None and cand not in enabled:
365
+ if near_miss is None:
366
+ near_miss = (cand, score)
367
+ continue
368
+ tfidf_best = cand
369
+ tfidf_best_score = score
370
+ break
371
+ if tfidf_best is None and near_miss is not None:
372
+ tfidf_best, tfidf_best_score = near_miss
373
+ return tfidf_best, tfidf_best_score, tfidf_scores
374
+
375
+
376
+ def _maybe_add_geo_engine(engine_list: list[str], features: dict | None,
377
+ enabled: set[str]) -> list[str]:
378
+ """P0-001:geo 查询追加 local_openstreetmap(地理编码/POI)。"""
379
+ if not features or not features.get("has_geo"):
380
+ return engine_list
381
+ if "local_openstreetmap" not in enabled:
382
+ return engine_list
383
+ if "local_openstreetmap" in engine_list:
384
+ return engine_list
385
+ return engine_list + ["local_openstreetmap"]
386
+
387
+
388
+ _INTENT_PARALLELISM: dict[str, tuple[int, bool]] = {
389
+ "definition": (1, False),
390
+ "fact": (1, False),
391
+ "news": (2, True),
392
+ "compare": (3, True),
393
+ "social": (3, True),
394
+ }
395
+
396
+
397
+ _NARROW_ENGINES = frozenset({
398
+ "mdn", "models_dev", "huggingface", "devto",
399
+ "wikipedia", "baidu_baike", "openalex", "europepmc",
400
+ })
401
+
402
+
403
+ def _apply_intent_parallelism(engine_list: list[str], features: dict | None,
404
+ domain: dict | None, mode: str,
405
+ default_parallel: bool) -> tuple[list[str], bool]:
406
+ """P0-005:按意图动态裁剪引擎数与并行度。
407
+
408
+ definition/fact → 1 引擎串行;news → 2 引擎并行;compare/social → 3 引擎并行。
409
+ 深度研究词(has_depth_word)视为 research,保持 3 引擎并行。
410
+ fast 模式强制串行(但仍可裁剪引擎数)。
411
+
412
+ Returns:
413
+ (裁剪后的引擎列表, 是否并行)
414
+ """
415
+ if not engine_list:
416
+ return engine_list, default_parallel
417
+
418
+ intents = (features or {}).get("intents") or []
419
+ is_research = bool((features or {}).get("has_depth_word"))
420
+
421
+ target_n: int | None = None
422
+ want_parallel = default_parallel
423
+
424
+ # research/compare 优先(更需要多源)
425
+ if is_research or "compare" in intents:
426
+ target_n, want_parallel = 3, True
427
+ elif "social" in intents:
428
+ target_n, want_parallel = 3, True
429
+ elif "news" in intents:
430
+ target_n, want_parallel = 2, True
431
+ elif "definition" in intents or "fact" in intents:
432
+ target_n, want_parallel = 1, False
433
+ # 窄域引擎单点保护:primary 是窄域引擎时保留 2 引擎并行
434
+ if engine_list and engine_list[0] in _NARROW_ENGINES and len(engine_list) > 1:
435
+ target_n, want_parallel = 2, True
436
+
437
+ if target_n is None:
438
+ return engine_list, default_parallel
439
+
440
+ trimmed = engine_list[:target_n]
441
+ if mode == "fast":
442
+ want_parallel = False
443
+ if len(trimmed) <= 1:
444
+ want_parallel = False
445
+ return trimmed, want_parallel
446
+
447
+
448
+ def _select_sub_engines(sub_engines: list[str], features: dict | None = None) -> list[str]:
449
+ """根据查询特征选择子引擎。"""
450
+ if not features:
451
+ # 默认保底:快源优先(brave/yahoo 实测 ~1.1s),ddgs 默认后端慢不主动纳入
452
+ return [e for e in ["local_bing", "local_brave", "local_yahoo"]
453
+ if e in sub_engines]
454
+
455
+ primary_lang = features.get("primary_lang", "")
456
+ chinese_ratio = features.get("chinese_ratio", 0)
457
+
458
+ # 多语种(v2.7):日/韩查询优先对应语言的本地引擎
459
+ if primary_lang == "ja":
460
+ return [e for e in ["local_bing"] if e in sub_engines]
461
+ if primary_lang == "ko":
462
+ return [e for e in ["local_bing"] if e in sub_engines]
463
+ if chinese_ratio > 0.1:
464
+ # 百度/搜狗结果质量低(SERP 跳转链为主),仅作印证不主动纳入;
465
+ # 中文补充源只用 local_bing
466
+ return [e for e in ["local_bing"] if e in sub_engines]
467
+ elif features.get("has_technical"):
468
+ return [e for e in ["local_github", "local_stackoverflow", "local_bing"] if e in sub_engines]
469
+ elif features.get("has_depth_word"):
470
+ return [e for e in ["local_arxiv", "local_semantic_scholar", "local_bing"] if e in sub_engines]
471
+ else:
472
+ # 2026-09-16 实测校正:原链条 [local_bing, local_duckduckgo, local_mojeek]
473
+ # 里后两个**都已损坏**——local_duckduckgo 连接失败;local_mojeek 被
474
+ # Mojeek 的 captcha 页拦住(HTTP 200 + <title>Captcha</title>,属静默
475
+ # 失败,靠 anti-bot 检测才判成 blocked)。它们占着 combo 槽位,而真正
476
+ # 能出结果的独立索引反而进不来(marginalia/wiby/searchmysite 实测各 10 条)。
477
+ #
478
+ # 换成可用且**更契合长尾**的独立索引:这三个都不是大厂代理,正是冲
479
+ # 「小网站/独立博客/非商业页面」去的,比再塞一个同类大引擎更有价值。
480
+ # local_bing 仍打头(唯一稳定可用的大厂 SERP)。
481
+ #
482
+ # ⚠️ 它们不是 local_* 子引擎,所以不能用 sub_engines 过滤——那会让这一支
483
+ # 恒等于 [local_bing](实测英文通用查询只剩 1 个源),下面那三个名字
484
+ # 永远等不到。判据改成「本地子引擎 OR 已启用的顶层引擎」。
485
+ try:
486
+ from engines import available_engines
487
+ _available = set(available_engines())
488
+ except Exception:
489
+ _available = set()
490
+ return [e for e in ["local_bing", "marginalia", "wiby", "searchmysite"]
491
+ if e in sub_engines or e in _available]
492
+
493
+
494
+ def _get_engines_combo(domain: dict[str, Any], enabled: set[str], mode: str = "auto",
495
+ features: dict | None = None) -> list[str]:
496
+ """从域配置获取 engines_combo,过滤不可用/付费(budget 模式)。
497
+ 自动将 local_search 扩展为子引擎(消灭黑盒)。
498
+
499
+ 注意:depth/context 的 combo 预算与 research_only 截断在 route_query 末尾
500
+ 统一走 engine_policy.filter_combo_by_policy,本函数只做可用性/成本/健康过滤。
501
+ """
502
+ # 学习器在此处首次解析(惰性,见模块顶部 _get_learner):走到这里说明
503
+ # 真的要装配 combo,这笔钱躲不掉;不进本函数的路径(缓存命中等)不付。
504
+ _adaptive_learner = _get_learner()
505
+ combo = domain.get("engines_combo", [])
506
+ primary = domain.get("primary", "anysearch")
507
+ fallback = domain.get("fallback")
508
+ if combo:
509
+ filtered = [e for e in combo if e in enabled]
510
+ else:
511
+ engines = [primary]
512
+ if fallback and fallback != primary:
513
+ engines.append(fallback)
514
+ filtered = [e for e in engines if e in enabled]
515
+ # P0-1:fallback 语义修复——combo 非空时也并入 fallback 候选。
516
+ # 旧逻辑只在 combo 为空时读 fallback,而 69 个域全部配置了 engines_combo,
517
+ # 导致 22 个真备用 fallback 全部失效(备用源形同虚设)。
518
+ # 追加到尾部 + 串行执行:正常路径 primary 先跑,early-stop 命中即不触碰
519
+ # fallback(零额外开销);仅当 primary 无结果/故障时才轮到 fallback 保底。
520
+ if fallback and fallback != primary and fallback in enabled and fallback not in filtered:
521
+ filtered.append(fallback)
522
+
523
+ # 🔑 关键改动:将 local_search 扩展为子引擎
524
+ if "local_search" in filtered:
525
+ filtered = _expand_local_search(filtered, features)
526
+
527
+ # F7:远端配额耗尽(如 byted 10406 Free quota exhausted)→ 全模式排除,
528
+ # 备用源自然接管,到周期边界惰性自愈(详见 quota.mark_remote_exhausted)。
529
+ # fail-open:全部被排除时保留原 combo,交由执行层把配额错误暴露出来。
530
+ try:
531
+ _qm = get_quota_manager()
532
+ if filtered:
533
+ _alive = [e for e in filtered if not _qm.is_hard_down(e)]
534
+ if _alive:
535
+ filtered = _alive
536
+ except Exception:
537
+ pass
538
+
539
+ # fast/budget 模式过滤付费引擎
540
+ if mode in ("fast", "budget"):
541
+ quota_mgr = get_quota_manager()
542
+ filtered = [e for e in filtered if quota_mgr.is_available(e, mode=mode)]
543
+
544
+ # fast/budget 模式优先前置零成本子引擎
545
+ if mode in ("fast", "budget"):
546
+ free_locals = [e for e in filtered if e.startswith("local_")]
547
+ others = [e for e in filtered if not e.startswith("local_")]
548
+ filtered = free_locals + others
549
+
550
+ # 垂直域主源保护
551
+ # 实测:wikipedia 分数常 <0.3,org_entity 在 wikidata 熔断时会只剩 baidu,英文 HQ 题脏结果。
552
+ # modal_card 的 bocha_ai/bocha 为 cost_tier=low(0.7),fast 的 0.85 阈值会误杀整 combo。
553
+ _VERTICAL_PROTECT = frozenset({
554
+ "film_search", "sports_search", "geo_places", "org_entity", "media_search",
555
+ "modal_card",
556
+ # zhihu_content 的 zhihu_global 曾被 learner 低分过滤饿死(历史用量少
557
+ # →分低→更不被用),37 天仅 53 次;断掉「饿死循环」
558
+ "zhihu_content",
559
+ # soil_agri 的 openfoodfacts(食品成分库)同理:新源无历史分,
560
+ # 实测在 route_query 内被 learner 摘掉(combo 只剩 [usda, anysearch]),
561
+ # 而单独调 _get_engines_combo 时还在——差别就是 learner 是否已加载。
562
+ "soil_agri",
563
+ # world_news 的 14 个本地语言源有同一风险且更严重:它们首次运行时
564
+ # anysearch 先返回并 early-stop,语言源拿不到贡献分 → 分数低于 0.3 →
565
+ # 被自适应过滤摘出 combo → 下次更不可能被选中。实测:接线当天跑过一次
566
+ # 真实搜索后,`오늘 뉴스` 的 combo 就从 [anysearch, yna] 退化成
567
+ # [anysearch, local_bing](源还在 enabled,只是被 learner 摘掉)。
568
+ # 该族每个源服务一种语言、无同族可替代,被摘掉即该语言通道消失。
569
+ "world_news",
570
+ })
571
+ primary = domain.get("primary")
572
+ domain_name = domain.get("name")
573
+ protect: set[str] = set()
574
+ # primary 保护同样受分数地板约束(2026-10-02,与下方学习器
575
+ # 过滤、route.py must_keep 组装同一 _VERTICAL_FLOOR 口径):
576
+ # 已被证明失败的 primary(实测 thesportsdb 0.084、近期
577
+ # 12/13 次失败)不进 protect——此前这行无条件豁免让它躲过
578
+ # 学习器过滤,每次垂直查询白等它的单引擎超时(2.5-3.5s)。
579
+ # 保护的本意是防新源无历史分被饿死(无数据引擎为中性
580
+ # 0.5),不是保死源;分数回升后自动回归。它仍是域声明
581
+ # 成员与 fallback,自动进入恢复链 L3 兜底。
582
+ if primary and (
583
+ _adaptive_learner is None
584
+ or _adaptive_learner.get_score(primary) >= _VERTICAL_FLOOR):
585
+ protect.add(primary)
586
+ # 仅 modal_card 整 combo 免 cost 裁剪(结构化路径不可被 anysearch 顶替)
587
+ if domain_name == "modal_card":
588
+ protect.update(filtered)
589
+ protect.update(domain.get("engines_combo") or [])
590
+
591
+ # fast 模式:只保留免费引擎;modal_card / primary 保护成员例外
592
+ if mode == "fast":
593
+ from config import get_cost_factor
594
+ filtered = [
595
+ e for e in filtered
596
+ if e in protect or get_cost_factor(e) >= 0.85
597
+ ]
598
+
599
+ # 自适应学习过滤(保留主引擎 + 垂直域 combo 成员不被误杀)
600
+ if _adaptive_learner is not None and len(filtered) > 1:
601
+ original = filtered[:]
602
+ # 分数地板:垂直保护与 primary 豁免都不庇护「已被证明失败」
603
+ # 的引擎(自适应分 <_VERTICAL_FLOOR)。无数据引擎是中性
604
+ # 0.5、不会落进这个区间——落到这里的是有失败历史且近乎
605
+ # 全败的源(实测 thesportsdb 0.084、近期 12/13 次失败):
606
+ # 保护的本意是防新源无历史分被饿死,不是保死源;否则每次
607
+ # 垂直查询都白等它的单引擎超时(2.5-3.5s)。分数回升后
608
+ # 自动回归 combo,且它仍是域 fallback(恢复链 L3 候选)。
609
+ if domain_name in _VERTICAL_PROTECT:
610
+ protect = {e for e in original
611
+ if _adaptive_learner.get_score(e) >= _VERTICAL_FLOOR
612
+ } | protect
613
+ filtered = [
614
+ e for e in filtered
615
+ if e in protect
616
+ or (e == primary
617
+ and _adaptive_learner.get_score(e) >= _VERTICAL_FLOOR)
618
+ or _adaptive_learner.get_score(e) >= 0.3
619
+ ]
620
+ if not filtered:
621
+ filtered = original
622
+
623
+ # 网络环境感知排序(独立于过滤,主引擎永远第一)。
624
+ # 只重排「非主引擎」,且只在同能力族内排序(避免跨族调整破坏
625
+ # combo 预算——垂直族必须保持在 web_general 之前)。
626
+ # 有显著分数差(≥0.15)时同族内快源前置;不足则顺序不变(缓存键稳定)。
627
+ if _adaptive_learner is not None and len(filtered) > 1:
628
+ primary = domain.get("primary")
629
+ try:
630
+ from engine_families import family_of
631
+ # 引擎声明必须传下去:config.yaml 的 family 字段才是来源。
632
+ # 不传时 family_of 会退回静态覆盖表、再退到默认值 web_general——
633
+ # 实测 16 个引擎(含本次新接的 osv / cisa_kev / federal_register /
634
+ # unpaywall / opencitations,以及 sports 三个、weather 两个)因此
635
+ # 被当成通用源,在下面「同族按分数排序」里跟 anysearch(0.886) 同族,
636
+ # 于是一个个被挤到后面(us_legal 的 federal_register 就是这么掉到
637
+ # 第三位的,而它的域声明顺序本来是第二位)。
638
+ _specs = get_engines()
639
+ if not isinstance(_specs, dict):
640
+ _specs = {}
641
+ except ImportError:
642
+ family_of = None
643
+ _specs = {}
644
+
645
+ def _fam(e: str) -> str:
646
+ try:
647
+ return family_of(e, _specs.get(e)) if family_of else "?"
648
+ except Exception:
649
+ return "?"
650
+
651
+ if primary and primary in filtered:
652
+ primary_eng, rest = primary, [e for e in filtered if e != primary]
653
+ else:
654
+ primary_eng, rest = None, list(filtered)
655
+ if len(rest) > 1 and family_of is not None:
656
+ # 按原始顺序分组(同族相邻),族内按分数稳定排序
657
+ grouped: list[str] = []
658
+ seen_fam: set[str] = set()
659
+ for e in rest:
660
+ f = _fam(e)
661
+ if f not in seen_fam:
662
+ seen_fam.add(f)
663
+ members = [x for x in rest if _fam(x) == f]
664
+ if len(members) > 1:
665
+ scored = [(x, _adaptive_learner.get_score(x)) for x in members]
666
+ top_score = max(s for _, s in scored)
667
+ laggards = [x for x, s in scored if top_score - s >= 0.15]
668
+ if laggards and len(laggards) < len(scored):
669
+ fast = [x for x, s in scored if top_score - s < 0.15]
670
+ grouped.extend(fast + laggards)
671
+ else:
672
+ grouped.extend(members)
673
+ else:
674
+ grouped.extend(members)
675
+ rest = grouped
676
+ filtered = ([primary_eng] if primary_eng else []) + rest
677
+
678
+ # 健康检查过滤:只对本地子引擎(local_*)做健康判定,非本地引擎
679
+ # 无条件保留。两条路径(scripts health_check / health_probe fallback)
680
+ # 必须保持同一语义,否则被劫持/缺模块时行为会静默漂移(曾导致
681
+ # wikipedia 被 health.db 的旧探测失败记录误过滤)。
682
+ try:
683
+ from health_check import is_available as _hc_available
684
+ healthy = []
685
+ for e in filtered:
686
+ if e.startswith("local_"):
687
+ if _hc_available(e):
688
+ healthy.append(e)
689
+ else:
690
+ healthy.append(e)
691
+ if healthy:
692
+ filtered = healthy
693
+ except ImportError:
694
+ try:
695
+ from health_probe import get_engine_status
696
+ healthy = []
697
+ for e in filtered:
698
+ if e.startswith("local_"):
699
+ if get_engine_status(e).get("available", True):
700
+ healthy.append(e)
701
+ else:
702
+ healthy.append(e)
703
+ if healthy:
704
+ filtered = healthy
705
+ except ImportError:
706
+ pass
707
+
708
+ # ── 配额/熔断感知:确定不可用源剔除 + 候选滚动(无缝切换)──────────
709
+ # 正常路径(全部引擎可用)顺序不变 → 引擎集合不变 → 缓存键不变 → 零速度倒退。
710
+ # P0-3:disabled / open+cooldown 的引擎是「确定不可用」——不再沉底保留
711
+ # (沉底后仍会被执行,白耗一次注定失败的超时),而是直接剔除,让域内
712
+ # 候选(fallback / combo 其他成员,天然同主题)自动顶位;域内无候选时
713
+ # 集合收缩,交由 route_query 尾部通用保底 / recovery 按 family 检查补源。
714
+ # open 但 cooldown 已过 → half-open 探测资格,保留(与 allow() 一致)。
715
+ # 缓存键基于 sorted(engines) 集合:剔除改变集合→键变,但 open+cooldown
716
+ # 时负缓存已生效,键变化无损失;且故障源不再被调用。
717
+ usable, unusable = [], []
718
+ for e in filtered:
719
+ ok = True
720
+ try:
721
+ if not get_quota_manager().is_available(e, mode=mode):
722
+ ok = False
723
+ except Exception:
724
+ pass
725
+ if ok:
726
+ try:
727
+ from circuit_breaker import get_breaker
728
+ # 必须用 allow() 而非 status():allow 内置状态转移(disabled/open
729
+ # 冷却超时 → half_open 探测资格),status 只读。曾导致 disabled
730
+ # 引擎在 route 层被永久剔除、执行层 allow() 永远不被调用、
731
+ # B4 恢复通道成死代码(bocha 卡死 disabled 一天余的根因)。
732
+ allowed, _reason = get_breaker().allow(e)
733
+ if not allowed:
734
+ ok = False
735
+ except ImportError:
736
+ pass
737
+ except Exception:
738
+ pass
739
+ (usable if ok else unusable).append(e)
740
+ if unusable and usable:
741
+ filtered = usable
742
+ elif unusable and not usable:
743
+ # 域内全部不可用:返回空集,由 route_query 尾部保底(通用免费源 /
744
+ # modal_card 保留声明引擎供执行层返回 error item)
745
+ filtered = []
746
+
747
+ # ── 能力族去重 + 互补回填(标准化调用契约)────────────────────────
748
+ # 全网搜索族同质化最高(byted/bocha/octen 都是通用网页检索),
749
+ # 同族堆叠纯属浪费预算位:web_general 至多保留 2 个,垂直族保留多源。
750
+ # config.yaml 引擎声明的 family 字段是来源(spec_lookup 传入 family_of,
751
+ # 不再只看静态覆盖表)。去重腾出的槽位由 complement_refill 用互补能力族
752
+ # 引擎回填(与域主引擎 coverage 重叠的高优先级源),兑现「给其他族腾出
753
+ # 预算位」;已有垂直成员的域不再追加,尊重域作者配置。
754
+ try:
755
+ from engine_families import dedupe_by_family, complement_refill
756
+ specs = get_engines()
757
+ if isinstance(specs, dict):
758
+ # 只收缩 web_general:垂直族(academic/code/finance 等)保留多源
759
+ # 交叉验证,不去重(股票域 sina/eastmoney 双行情源必须共存)。
760
+ deduped = dedupe_by_family(
761
+ filtered, max_per_family=2, spec_lookup=specs,
762
+ limit_families=frozenset({"web_general"}),
763
+ )
764
+ removed = len(filtered) - len(deduped)
765
+ if removed > 0:
766
+ filtered = complement_refill(
767
+ deduped, enabled=enabled, spec_lookup=specs,
768
+ domain_primary=domain.get("primary"),
769
+ max_slots=min(2, removed),
770
+ )
771
+ else:
772
+ filtered = deduped
773
+ except ImportError:
774
+ pass
775
+
776
+ return filtered
777
+
778
+
779
+ # ── combo 定稿的三段共享步骤 ───────────────────────────────────────────────────
780
+ #
781
+ # route_query 的三条分支(域命中 / TF-IDF / 通用保底)各自组装 combo,但有
782
+ # 三段逻辑在每条分支里**逐字重复**。重复的代价不是行数,而是「改一处漏两处」:
783
+ # 代码注释里已经记着两次因此产生的缺陷(TF-IDF 分支漏了语言过滤、语言/次域追加
784
+ # 的引擎绕过了熔断处理)。这三段收在这里,新增一步只需要改一个地方。
785
+ #
786
+ # 刻意**不做**「把三条分支合并成一个带开关的函数」:三条流水线的步骤集合本身
787
+ # 不同(保底分支不走语言摘除与意图收口,域分支多一步 primary 扶正),合并需要
788
+ # 五六个模式开关——那是把显式的分支换成隐式的 flag 矩阵,读者更难判断某条路径
789
+ # 到底跑了哪些步骤。共享的是**真正相同的步骤**,不是分支本身。
790
+
791
+
792
+ def intent_squeeze(combo: list[str], features: dict | None, domain: dict | None,
793
+ mode: str, parallel: bool,
794
+ must_keep: list[str]) -> tuple[list[str], bool]:
795
+ """definition/fact 意图下收紧 combo(幂等二次施加)。
796
+
797
+ policy 层的新源 must_keep 补回会放大 combo:单源即答语义下,扩容槽纯属
798
+ 阶梯等待浪费(实测 academic definition 1 → 3 引擎)。只对 definition/fact
799
+ 收口——social/news/compare 本身要多源,扩容与意图同向。must_keep 成员豁免
800
+ (它们有硬保留理由:geo 的 local_openstreetmap 被裁会退化成 wikidata 单源)。
801
+ """
802
+ intents = (features or {}).get("intents") or []
803
+ if "definition" not in intents and "fact" not in intents:
804
+ return combo, parallel
805
+ keep = set(must_keep)
806
+ rest = [e for e in combo if e not in keep]
807
+ if len(rest) != len(combo):
808
+ rest, parallel = _apply_intent_parallelism(rest, features, domain, mode,
809
+ parallel)
810
+ return rest + [e for e in combo if e in keep and e not in rest], parallel
811
+ return _apply_intent_parallelism(combo, features, domain, mode, parallel)
812
+
813
+
814
+ def geo_lang_must_keep(features: dict | None, enabled: set[str],
815
+ combo: list[str], query: str) -> list[str]:
816
+ """must_keep 的公共部分:geo 主源 + 语言保护(三条分支都要)。"""
817
+ out: list[str] = []
818
+ if (features or {}).get("has_geo") and "local_openstreetmap" in enabled:
819
+ out.append("local_openstreetmap")
820
+ out.extend(_lang_must_keep(features, enabled, combo, query))
821
+ return out
822
+
823
+
824
+ def lang_aux_engines(features: dict | None, enabled: set[str],
825
+ query: str) -> list[str]:
826
+ """语言查询的补充引擎:预算定稿后**追加**到 combo 末尾,不进 must_keep。
827
+
828
+ 与 must_keep 的分工是硬性的,不是风格选择:must_keep 的语义是「别把
829
+ 这个挤掉」,而 policy 层会把 must_keep 的项**提到 combo 前部**。实测把
830
+ hatena_bookmark 塞进 must_keep 后,ja 的 TF-IDF 兜底分支 combo 从
831
+ [anysearch, local_bing] 变成 [local_bing, hatena_bookmark],
832
+ `engine` 也从 anysearch 变成 local_bing——直接违反
833
+ tests/test_multilingual_routing.py::TestJaKoTfidfSkip 钉住的契约
834
+ (「候选全被语言过滤时退到通用保底 anysearch」)。
835
+
836
+ 追加到末尾既保住了通用保底的首位,也让补充源真的参与这次搜索。
837
+ """
838
+ if not features or not enabled:
839
+ return []
840
+ from route_lang import _LANG_AUX_ENGINES, _family_lang
841
+ lang = _family_lang(features, query)
842
+ if not lang:
843
+ return []
844
+ return [e for e in _LANG_AUX_ENGINES.get(lang, []) if e in enabled]
845
+
846
+
847
+ def apply_lang_aux(combo: list[str], features: dict | None,
848
+ enabled: set[str], query: str,
849
+ skip: bool = False) -> list[str]:
850
+ """把语言补充源追加到 combo 末尾(幂等)。
851
+
852
+ `skip` 对应 route.py 的 `_pure_combo`(用户显式指定引擎):那类 combo 是
853
+ 用户意图本身,不该被路由补充。做成参数而不是让两处调用方各自 if,是为了让
854
+ route.py 少一行——它已在 1000 行门禁的临界值上。
855
+ """
856
+ if skip:
857
+ return combo
858
+ for e in lang_aux_engines(features, enabled, query):
859
+ if e not in combo and e in enabled:
860
+ combo.append(e)
861
+ return combo
862
+
863
+
864
+ def breaker_filter(combo: list[str], enabled: set[str],
865
+ empty: tuple[str, ...] = ("anysearch", "local_bing"),
866
+ features: dict | None = None, query: str = "",
867
+ skip_aux: bool = False) -> list[str]:
868
+ """熔断统一处理 + 空回退 + 语言补充源追加(combo 定稿的最后一道)。
869
+
870
+ combo 非空是执行层的前提(空 combo 会让 engines[0] IndexError),且语言/
871
+ geo/次域/TF-IDF 追加的引擎都可能处于熔断态——任何拼装路径的末尾都必须过这
872
+ 一道,所以它是共享步骤而不是各分支自己写。`empty` 允许分支声明自己的兜底
873
+ 次序(通用保底路径只认 anysearch)。
874
+
875
+ 语言补充源(`_LANG_AUX_ENGINES`)也收在这里,而不是让 route.py 的两条
876
+ combo 定稿路径各调一次 `apply_lang_aux`:那是同一个「按语言调整 combo」的
877
+ 动作,放在定稿收口里既保证一定被应用,也省掉 route.py 的行数——它已在
878
+ 1000 行模块体积门禁的临界值上。放在熔断之后是有意的:补充源若恰处熔断
879
+ 态,会被 `_filter_breaker_blocked` 一并摘掉。
880
+ """
881
+ combo = apply_lang_aux(combo, features, enabled, query, skip=skip_aux)
882
+ combo = [e for e in combo if is_engine_routable(
883
+ e, check_hard_down=not skip_aux)]
884
+ if not combo:
885
+ combo = ([e for e in empty if is_engine_routable(e, enabled=enabled, check_breaker=False)]
886
+ or [e for e in empty if e in enabled] or ["anysearch"])
887
+ return combo
888
+
889
+
890
+ # 强语义注入的通用引擎黑名单:这些引擎已被域 combo 覆盖,注入会喧宾夺主。
891
+ # 放在模块级而不是 route_query 体内:它是**常量数据**,藏在热路径函数里每次
892
+ # 调用重建(实测 0.074 µs,性能上无所谓,但读代码的人会以为它随调用变化)。
893
+ _GENERAL_ENGINES = frozenset({
894
+ "anysearch", "byted", "bocha", "octen",
895
+ "local_search", "zhihu", "wechat_sogou", "uapi", "tavily",
896
+ "brave", "bocha_ai", "google_scholar", "arxiv", "wikipedia",
897
+ })
898
+
899
+
900
+ def inject_strong_semantic(combo: list[str], *, tfidf_best: str | None,
901
+ score: float, is_catch_all: bool,
902
+ enabled: set[str]) -> tuple[list[str], bool]:
903
+ """TF-IDF 强匹配时把推荐引擎前置(v2.7.10)。返回 (combo, 是否注入)。
904
+
905
+ 位置由调用方决定:必须在 primary 扶正**之后**——否则被扶正压到第二位,
906
+ 串行 early-stop 下永远不执行。
907
+
908
+ 判据:分数 ≥0.6(远高于最低阈值 0.12)表示查询与引擎文档强匹配,前置注入
909
+ 不锁死(域主源仍在尾部备位,注入引擎失败时自然补位);已在 combo 里、
910
+ 属于通用源(会被域 combo 覆盖)、catch-all 域、或不在 enabled 都不注入。
911
+ """
912
+ if not (tfidf_best and score >= 0.6 and not is_catch_all
913
+ and tfidf_best not in combo
914
+ and tfidf_best not in _GENERAL_ENGINES
915
+ and tfidf_best in enabled):
916
+ return combo, False
917
+ return [tfidf_best] + [e for e in combo if e != tfidf_best], True