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,991 @@
1
+ #!/usr/bin/env python3
2
+ """search_rank.py — 排序、去重与融合后处理(纯函数层)。
3
+
4
+ 承载「拿到各引擎结果之后、交付之前」的全部加工:URL 规范化与去重、RRF 加权
5
+ 融合、minhash 近重复合并、语言偏好软排序、Bocha 精排与本地五维保底精排、
6
+ 共识/事实对齐信号、域过滤。
7
+
8
+ 为什么单独成模块:这一层是本仓**唯一**能靠离线金标验证的部分
9
+ (tests/golden/pipeline_golden.json + replay_eval.py),把它与网络调度、
10
+ CLI、输出格式分开后,「排序改动」可以只跑秒级的排序金标,不必拖上整条链路。
11
+ 函数一律吃列表、吐列表/标量,不碰全局状态(缓存除外,见下)。
12
+
13
+ 2026-09-27 拆分:单条结果的**打分算子**(相关性/完整性/各项惩罚)搬去
14
+ rank_signals.py,本文件保留融合与调度。
15
+
16
+ 缓存说明:`_rel_factor_cache` / `_weight_cache` 是进程内 TTL 记忆化,
17
+ `invalidate_engine_weight_cache()` 是它的显式失效口(配置变更后调用)。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import time
26
+ from typing import Any
27
+
28
+ from engine_env import env_flag, get_env
29
+ from url_canon import canonical_url as _canonical_url_impl
30
+
31
+ # cache 延迟到首次真正需要相似度时再导入(2026-09-27,方案 B3):顶层 import
32
+ # 会把 cache.py 连带 sqlite3/shutil/tempfile 整条拉进每次 import search 的
33
+ # 路径(实测 16-25ms,而 import 本身只要 30ms)。调用点 _content_similarity
34
+ # 自带 Jaccard 兜底,故延迟导入不改变行为。
35
+ # 名字**不能**叫 `_query_similarity`:那会把包装对象遮蔽成自身,递归到
36
+ # RecursionError 后被 except 吞掉、静默退化成 Jaccard——去重阈值 0.85 之下
37
+ # Jaccard 0.88 会把 20 条不同结果并成 1 条(minhash 只有 0.375)。
38
+ _QUERY_SIMILARITY = None
39
+
40
+
41
+ def _resolve_query_similarity():
42
+ """取 cache.query_similarity(首次调用导入,之后走模块级记忆)。"""
43
+ global _QUERY_SIMILARITY
44
+ if _QUERY_SIMILARITY is None:
45
+ try:
46
+ from cache import query_similarity as _fn
47
+ except ImportError:
48
+ return None
49
+ _QUERY_SIMILARITY = _fn
50
+ return _QUERY_SIMILARITY
51
+
52
+ # 打分算子已拆分到 rank_signals(2026-09-27,本文件触及 1000 行硬上限时拆出)。
53
+ # 分工:rank_signals 只回答「这条结果多匹配、这份内容多可信」,本文件负责
54
+ # 融合/去重/语言偏好/截断等调度。开关与常量仍走 ARGO_*,行为不变。
55
+ from rank_signals import ( # noqa: E402,F401
56
+ CJK_STOPCHARS as _CJK_STOPCHARS,
57
+ combined_source_penalties as _combined_source_penalties,
58
+ completeness_v2_enabled as _completeness_v2_enabled,
59
+ # 兼容转出:搜索路径走 combined_source_penalties,不再直接调这三个,
60
+ # 但 tests 直接 from search_rank import 它们,删掉会 ImportError。
61
+ cross_domain_homogeneity_enabled as _cross_domain_homogeneity_enabled,
62
+ cross_domain_homogeneity_penalty as _cross_domain_homogeneity_penalty,
63
+ domain_concentration_penalty as _domain_concentration_penalty,
64
+ domain_penalty_enabled as _domain_penalty_enabled,
65
+ authority_level,
66
+ domain_score_floors as _domain_score_floors,
67
+ fuzzy_near_dup,
68
+ normalize_title_suffix,
69
+ host_of as _host_of,
70
+ relevance_units as _relevance_units,
71
+ relevance_v2_enabled as _relevance_v2_enabled,
72
+ score_completeness as _score_completeness,
73
+ score_relevance as _score_relevance,
74
+ title_stuffing_penalty as _title_stuffing_penalty,
75
+ )
76
+
77
+
78
+ # 中文/英文混合分词用的正则,延迟编译(首次 _tokens 时建)
79
+ _CJK_OR_WORD = None
80
+
81
+ # 域相关性地板表(按域/源),延迟加载一次
82
+
83
+
84
+ def _canonical_url(url: str) -> str:
85
+ """URL 归一化(薄转发到 url_canon 唯一来源)。"""
86
+ return _canonical_url_impl(url)
87
+
88
+
89
+ _ENGINE_FUSION_WEIGHTS: dict[str, float] = {
90
+ # 权威百科/学术/官方
91
+ "wikipedia": 1.4, "wikidata": 1.4, "zh_wikipedia": 1.4, "baidu_baike": 1.3,
92
+ "arxiv": 1.3, "openalex": 1.3, "crossref": 1.3, "semantic_scholar": 1.3,
93
+ "dblp": 1.3, "europepmc": 1.3, "pubmed": 1.3, "google_scholar": 1.3,
94
+ "pubchem": 1.3, "uniprot": 1.3, "rcsb_pdb": 1.3,
95
+ "github": 1.2, "pypi": 1.2, "npm": 1.2, "crates": 1.2, "mdn": 1.2,
96
+ "stackoverflow": 1.1, "imdb": 1.2, "thesportsdb": 1.2, "itunes": 1.2,
97
+ "finviz": 1.2, "sina_quote": 1.2, "tencent_quote": 1.2, "eastmoney": 1.2,
98
+ "fred": 1.3, "worldbank": 1.3, "nbs_stats": 1.3, "eurostat": 1.3, "fx_rate": 1.2, "frankfurter": 1.2,
99
+ # 通用引擎(基线)
100
+ "duckduckgo": 1.0, "local_bing": 1.0, "local_duckduckgo": 1.0,
101
+ "local_google": 1.0, "anysearch": 1.05, "byted": 1.1, "bocha": 1.0,
102
+ "bocha_ai": 1.3, # 垂直结构化模态卡(实时值)
103
+ "brave": 1.0, "uapi": 1.0, "local_search": 1.0, "octen": 1.0,
104
+ "gdelt": 1.0, "opencorporates": 1.2, "google_patents": 1.2,
105
+ # 社交/低质(降权)
106
+ "twitter": 0.7, "reddit": 0.7, "xiaohongshu": 0.7, "bilibili": 0.7,
107
+ "weibo": 0.7, "v2ex": 0.8, "zhihu": 0.8, "hackernews": 0.8, "zhihu_hot": 0.8,
108
+ "baidu_hot": 0.8, "toutiao_hot": 0.8, "bilibili_hot": 0.8,
109
+ }
110
+
111
+
112
+ _rel_factor_cache: dict[str, tuple[float, float]] = {}
113
+
114
+
115
+ _REL_FACTOR_TTL = 30.0
116
+ _MAX_CACHE_SIZE = 256
117
+
118
+
119
+ def _evict_cache(cache: dict, now: float) -> None:
120
+ """缓存淘汰:先清除过期条目,如果仍超上限则清除最老的条目。"""
121
+ if len(cache) < _MAX_CACHE_SIZE:
122
+ return
123
+ # 第一轮:清除过期条目
124
+ expired = [k for k, v in cache.items() if v[1] <= now]
125
+ for k in expired:
126
+ del cache[k]
127
+ # 第二轮:如果仍超上限,清除最老的条目(按过期时间排序)
128
+ if len(cache) >= _MAX_CACHE_SIZE:
129
+ sorted_keys = sorted(cache.keys(), key=lambda k: cache[k][1])
130
+ for k in sorted_keys[:len(cache) - _MAX_CACHE_SIZE + 1]:
131
+ del cache[k]
132
+
133
+
134
+ _weight_cache: dict[tuple[str, str], tuple[float, float]] = {}
135
+
136
+
137
+ def _single_reliability(engine: str) -> float:
138
+ now = time.time()
139
+ cached = _rel_factor_cache.get(engine)
140
+ if cached and cached[1] > now:
141
+ return cached[0]
142
+ factor = 1.0
143
+ try:
144
+ from circuit_breaker import get_breaker
145
+ st = get_breaker().status(engine)
146
+ state = st.get("state")
147
+ if state == "disabled":
148
+ factor = 0.5
149
+ elif state == "open":
150
+ factor = 0.7
151
+ elif state == "half_open":
152
+ factor = 0.85
153
+ failures = int(st.get("failures") or 0)
154
+ if failures >= 5:
155
+ factor = min(factor, 0.8)
156
+ except Exception:
157
+ factor = 1.0
158
+ _evict_cache(_rel_factor_cache, now)
159
+ _rel_factor_cache[engine] = (factor, now + _REL_FACTOR_TTL)
160
+ return factor
161
+
162
+
163
+ def _engine_weight(source: str, lang: str | None = None) -> float:
164
+ """按引擎来源返回融合权重(source 可能含 'local_bing/sina_quote' 合并形式)。
165
+
166
+ 静态基础权重(权威/学术提权、社交降权)× 动态可靠性因子(weakest-link):
167
+ 熔断/高错误源降权,健康权威源维持提权。论文 2508.01405 的路径质量评估落地。
168
+
169
+ lang(可选)启用**语言能力加权**:由 18语言×29引擎 矩阵实测得到的
170
+ 能力画像(data/lang_matrix/lang_capability.json)决定——该语言下实测
171
+ 良好的引擎提权、实测噪声的降权、无数据的保持中性。画像缺失/过期时
172
+ 完全退化为原行为(见 lang_capability 的安全降级契约)。
173
+
174
+ 结果缓存(_weight_cache,TTL = _REL_FACTOR_TTL):
175
+ `rrf_merge` 对**每条结果**调用一次本函数,而同一次融合里 (source, lang)
176
+ 的取值空间只有「参与引擎数 × 1」,300 条结果实测 0.54ms 全花在重复的
177
+ `split`/`max`/`min` 上。TTL 对齐底层可靠性因子的 30s 窗口,因此本缓存
178
+ **不会把熔断状态变化多冻结哪怕一秒**(旧实现靠 _rel_factor_cache 记忆,
179
+ 同一个 TTL)。语言画像更新走 invalidate_engine_weight_cache()。
180
+ """
181
+ if not source:
182
+ return 1.0
183
+ ck = (str(source), lang or "")
184
+ now = time.time()
185
+ ent = _weight_cache.get(ck)
186
+ if ent is not None and ent[1] > now:
187
+ return ent[0]
188
+ # 合并来源:静态权重取最高源,可靠性取最低源(weakest-link:任一路径弱即降权)
189
+ parts = [p.strip() for p in str(source).split("/") if p.strip()]
190
+ if not parts:
191
+ return 1.0
192
+ static = max([_ENGINE_FUSION_WEIGHTS.get(p, 1.0) for p in parts])
193
+ rel = min([_single_reliability(p) for p in parts])
194
+ out = static * rel
195
+ adjusted = True
196
+ if lang:
197
+ try:
198
+ from lang_capability import score_adjust
199
+ # 多来源取最高:某个来源在该语言下有能力即可(不因合并源里
200
+ # 混入一个未知引擎而失去提权)
201
+ adj = max([score_adjust(p, lang) for p in parts] or [1.0])
202
+ out *= adj
203
+ except Exception:
204
+ # 调整失败:本次按未调整值使用,但**不写缓存**——缓存键含 lang,
205
+ # 固化 30s 会让该语言持续拿到降级权重(与「退化结果不写缓存」同源)
206
+ adjusted = False
207
+ out = round(out, 3)
208
+ if adjusted:
209
+ _evict_cache(_weight_cache, now)
210
+ _weight_cache[ck] = (out, now + _REL_FACTOR_TTL)
211
+ return out
212
+
213
+
214
+ def invalidate_engine_weight_cache() -> None:
215
+ """清空 _engine_weight 结果缓存。
216
+
217
+ 与 lang_capability.reload() 配对使用:语言画像更新后必须调用本函数,
218
+ 否则本缓存会在 TTL 窗口内继续返回旧画像算出的权重。熔断状态无需调用
219
+ ——两者 TTL 相同(_REL_FACTOR_TTL),不会互相冻结。
220
+ """
221
+ _weight_cache.clear()
222
+
223
+
224
+ def _rrf_weighted_default() -> bool:
225
+ """RRF 是否默认按引擎加权(WG-RRF)。
226
+
227
+ 逃生开关 `ARGO_RRF_WEIGHTED=0`(或 off/no/false/n/disable/disabled/
228
+ none)退回**经典 RRF**:Claude Shannon 原文那版,各引擎同位次等权。
229
+
230
+ 为什么需要它:加权版把「权威源提权、社交源降权」的领域先验编进了融合层,
231
+ 这在多数查询上是净收益,但它**改变了跨引擎的相对次序**——实测同一组
232
+ 三引擎结果,加权版把「权威源第 1 条」排在首位,经典版则把「被两引擎
233
+ 共同命中的共识条目」提到第 2。两者是**可辩驳的排序哲学差异**,不是
234
+ 对错之分。留一个开关的意义在于:出现「本次结果不对劲」时能把融合层
235
+ 单独摘出去定位(是融合的锅还是引擎的锅),以及为回归对比提供基线。
236
+
237
+ 读环境变量而非写死常量:与仓库既有 ARGO_* 开关同一约定(如
238
+ ARGO_MINHASH_DEDUPE / ARGO_FETCH_JINA),且 CLI 与 MCP 两种宿主都能
239
+ 在不改代码的前提下切换。
240
+ """
241
+ # 收编到 env_flag(2026-09-29):此前这里是全仓第五套真值表(多认
242
+ # disable/disabled、少认 n/on/y/t),且 get_env 失败时回落裸
243
+ # os.environ 的分支两条路径两份语义。统一后全仓一处判据。
244
+ from engine_env import env_flag
245
+ return env_flag("ARGO_RRF_WEIGHTED", default=True)
246
+
247
+
248
+ def rrf_merge(ranked_lists: list[list[dict[str, Any]]], k: int = 60,
249
+ weighted: bool | None = None,
250
+ lang: str | None = None) -> list[dict[str, Any]]:
251
+ """Reciprocal Rank Fusion 合并多引擎结果,保留 consensus_engines。
252
+
253
+ 键用归一化 URL;RRF 分单独存 _rrf_score,首次遇到的结果保留完整字段,
254
+ 后续同 URL 只累加共识、择优补充 snippet。
255
+
256
+ 口径隔离:不同口径的引擎结果不直接 merge,而是作为独立证据通道并列
257
+ 呈现。同口径内做 RRF 融合,不同口径按优先级排序后并列输出。
258
+ 口径分类表与分组逻辑在 caliber_isolation.py。
259
+ """
260
+ if weighted is None:
261
+ weighted = _rrf_weighted_default()
262
+
263
+ # 经典 RRF(weighted=False):保留旧的全局列表级 RRF 语义,每个引擎列表
264
+ # 独立贡献排名。口径隔离只用于加权分支(weighted=True)。
265
+ if not weighted:
266
+ from rrf_classic import rrf_classic
267
+ return rrf_classic(ranked_lists, k=k, lang=lang,
268
+ _canonical_url=_canonical_url_impl)
269
+
270
+ from caliber_isolation import merge_by_caliber
271
+ return merge_by_caliber(
272
+ ranked_lists, k=k, weighted=weighted, lang=lang,
273
+ _canonical_url=_canonical_url_impl,
274
+ _engine_weight=_engine_weight,
275
+ )
276
+
277
+
278
+ def _content_similarity(a: str, b: str) -> float:
279
+ """标题+片段的 minhash 相似度(复用 cache.query_similarity,失败回退 Jaccard)。"""
280
+ if not a or not b:
281
+ return 0.0
282
+ _sim_fn = _resolve_query_similarity() # cache 不可用时返回 None
283
+ if _sim_fn is not None:
284
+ try:
285
+ return float(_sim_fn(a, b))
286
+ except Exception:
287
+ pass # 单条相似度计算失败按「无相似度」处理,回退 Jaccard
288
+ import re as _re
289
+ sa, sb = set(_re.findall(r"[\u4e00-\u9fff]|[a-zA-Z0-9]+", a.lower())), set(_re.findall(r"[\u4e00-\u9fff]|[a-zA-Z0-9]+", b.lower()))
290
+ if not sa or not sb:
291
+ return 0.0
292
+ return len(sa & sb) / len(sa | sb) if sa | sb else 0.0
293
+
294
+
295
+ def _content_sig(r: dict[str, Any]) -> str:
296
+ # 站后缀先剥(normalize_title_suffix):跨站同文对差异几乎全在结尾后缀,不剥 minhash 过不了阈值。
297
+ title = normalize_title_suffix(r.get("title", "") or "")
298
+ return f"{title} {r.get('snippet', '') or ''}".strip()
299
+
300
+
301
+ def _distinct_data_rows(ka: str, kb: str) -> bool:
302
+ """两条结果的规范 URL 是否「同文档、不同查询」——即同一资源的不同数据行。
303
+
304
+ 时序/截面类数据引擎(fred/worldbank/eurostat/comtrade…)按观测期逐行发
305
+ 条目,URL 以查询参数承载内容身份(?obs=…&PartnerAreas=…),文本彼此仅
306
+ 差日期与数值,minhash 相似度恒过阈值。查询参数不同即内容不同,不做
307
+ 近重复折叠;跨站同质网页(不同 host/path)不受影响,仍按原文折叠。
308
+ """
309
+ from urllib.parse import urlparse
310
+ pa, pb = urlparse(ka), urlparse(kb)
311
+ if (pa.netloc, pa.path) != (pb.netloc, pb.path):
312
+ return False
313
+ return pa.query != pb.query
314
+
315
+
316
+ _RERANK_POOL_FACTOR = 3
317
+
318
+ _RERANK_POOL_MIN = 15
319
+
320
+
321
+ def _rerank_pool_limit(max_results: int) -> int:
322
+ """精排池容量(去重提前停与放宽截断的唯一来源)。"""
323
+ return max(max_results * _RERANK_POOL_FACTOR, _RERANK_POOL_MIN)
324
+
325
+
326
+ def minhash_dedupe(
327
+ results: list[dict[str, Any]], threshold: float = 0.85,
328
+ enabled: bool | None = None, max_keep: int | None = None,
329
+ ) -> tuple[list[dict[str, Any]], int]:
330
+ """结果级近重复去重(MMR 前置):同一事件多引擎同质网页堆叠时去重。
331
+
332
+ 流程:URL 归一键已去重 → 剩余按(score, selection)降序贪心,content_similarity ≥ threshold 视为近重复,仅留首条;minhash 落在 [0.55, 0.85) 近似命中带内时再由 rapidfuzz token_set_ratio 复核(可选依赖,ARGO_DEDUP_FUZZY=0 关,实现见 rank_signals.fuzzy_near_dup)。
333
+ 开关:ARGO_MINHASH_DEDUPE=0 时关闭;默认开启(阈值可由 ARGO_MINHASH_THRESHOLD 覆盖,默认 0.85)。
334
+ 返回 (deduped, removed_count),每条被移除的结果记 `_near_dup=True`。
335
+
336
+ max_keep:只保证「前 max_keep 条非重复结果」与不设上限时逐位一致,到达
337
+ 上限即停。调用侧拿到结果后紧接着就截断到同一个上限,所以这不改变任何
338
+ 输出,却把 O(n²) 的两两比较降成 O(n · max_keep)——实测 800 条结果时快
339
+ 677 倍(输出前 max_keep 条完全相同)。`removed` 相应变为下界:只统计到
340
+ 提前停为止,被截掉的尾部本来也不参与输出。
341
+ """
342
+ if enabled is None:
343
+ enabled = env_flag("ARGO_MINHASH_DEDUPE")
344
+ if not enabled or not results or len(results) <= 1:
345
+ return results, 0
346
+ try:
347
+ thr = float(os.environ.get("ARGO_MINHASH_THRESHOLD", str(threshold)))
348
+ threshold = max(0.5, min(0.98, thr))
349
+ except Exception:
350
+ pass
351
+ pool = sorted(
352
+ results,
353
+ key=lambda r: (float(r.get("score", 0) or 0), float(r.get("selection", 0) or 0)),
354
+ reverse=True,
355
+ )
356
+ kept: list[dict[str, Any]] = []
357
+ # 已保留项的 (内容签名, 归一 URL)。此前是两条并行数组再 zip——两者必须
358
+ # 同步推进才有意义,拆散了就是一个静默的错位陷阱。
359
+ kept_keys: list[tuple[str, str]] = []
360
+ removed = 0
361
+ for r in pool:
362
+ if max_keep is not None and len(kept) >= max_keep:
363
+ break
364
+ sig = _content_sig(r)
365
+ rkey = _canonical_url(r.get("url", ""))
366
+ # 数据行豁免先于模糊判定:fred/worldbank 观测期行只差日期数值,不排除会被 token_set_ratio 并成一条。
367
+ is_dup = any(
368
+ not _distinct_data_rows(rkey, ku)
369
+ and ((_sim := _content_similarity(sig, ks)) >= threshold
370
+ or fuzzy_near_dup(sig, ks, _sim))
371
+ for ks, ku in kept_keys
372
+ )
373
+ if is_dup:
374
+ removed += 1
375
+ r["_near_dup"] = True
376
+ else:
377
+ kept.append(r)
378
+ kept_keys.append((sig, rkey))
379
+ return kept, removed
380
+
381
+
382
+ def deduplicate_by_url(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
383
+ """URL 去重(归一化键)。"""
384
+ seen: set[str] = set()
385
+ out = []
386
+ for r in results:
387
+ key = (
388
+ _canonical_url(r.get("url", ""))
389
+ or (f"title:{r.get('title', '')}" if r.get("title") else "")
390
+ or (f"card:{r.get('card_type', '')}" if r.get("card_type") else "")
391
+ or f"anon:{len(out)}"
392
+ )
393
+ if key not in seen:
394
+ seen.add(key)
395
+ out.append(r)
396
+ return out
397
+
398
+
399
+ # 「结果是否以目标语言书写」判定表:lang -> (命中, 排除)。
400
+ # 命中 = 目标语言的特征码位;排除 = 与之共享码位、必须让位的语言特征。
401
+ #
402
+ # 这张表取代了原先三处并存的硬编码闸门(rerank 入口 `not in ("ja","ko")`、
403
+ # search_pipeline 调用点 `in ("ja","ko")`、噪声门 `not in ("zh","en",...)`)。
404
+ # 原 bug:`argo search "记忆宫殿"` 的 5 条结果里有 3 条是日文/捷克文/英文,
405
+ # 而 zh 被三道闸门同时排除在语言处理之外——中文路径零语言感知。
406
+ #
407
+ # 为什么是数据驱动而不是再加一个 if:任何新增的受追踪语言只需要往这张表
408
+ # 加一行,而不是去三处各改一次字面量;漏改的后果是「静默失效」,肉眼只能
409
+ # 从结果语种不对上看出来。tests/test_lang_prefer_scope.py 的
410
+ # TestNoHardcodedLangGateRemains 把这条纪律钉成断言。
411
+ #
412
+ # 为什么需要「排除」侧:中日共享汉字表。日文标题「記憶の宮殿」里的「記憶」
413
+ # 落在 zh 的 CJK 区间内,只判命中就会把日文当成中文顶到第一位——比原 bug
414
+ # 更糟(用户看到的第一条是日文,而语种过滤「本来是管这个的」)。故 zh 的
415
+ # 判定额外要求「不含假名」:现代日文文本几乎总带假名,而纯中文文本不会。
416
+ _LANG_SCRIPT: dict[str, tuple[str, str | None]] = {
417
+ "ja": (r"[\u3040-\u30ff]", None), # 平假名 / 片假名
418
+ "ko": (r"[\uac00-\ud7af]", None), # 谚文音节
419
+ "zh": (r"[\u4e00-\u9fff]", r"[\u3040-\u30ff\uac00-\ud7af]"), # CJK,且非日/韩
420
+ "ru": (r"[\u0400-\u04ff]", None), # 西里尔
421
+ "el": (r"[\u0370-\u03ff]", None), # 希腊
422
+ "ar": (r"[\u0600-\u06ff]", None), # 阿拉伯
423
+ "he": (r"[\u0590-\u05ff]", None), # 希伯来
424
+ "th": (r"[\u0e00-\u0e7f]", None), # 泰
425
+ "hi": (r"[\u0900-\u097f]", None), # 天城文
426
+ }
427
+
428
+
429
+ def _lang_prefer_rerank(results: list[dict[str, Any]],
430
+ primary_lang: str | None) -> list[dict[str, Any]]:
431
+ """按目标语言的书写系统把属于该语言的结果前置(软排序,不删除)。
432
+
433
+ `en` 刻意不在表里:拉丁字母是 web 的通用语种,「含拉丁字母」对几乎
434
+ 所有结果都命中,该信号无区分度。对 en 而言 RRF 融合的相关度排序已经
435
+ 够用,故保持恒等(返回原对象,不引入无谓重排)。
436
+ """
437
+ if not results:
438
+ return results
439
+ spec = _LANG_SCRIPT.get(primary_lang or "")
440
+ if not spec:
441
+ return results
442
+ hit_src, excl_src = spec
443
+ pat = re.compile(hit_src)
444
+ excl = re.compile(excl_src) if excl_src else None
445
+
446
+ def _key(r: dict[str, Any]) -> int:
447
+ hay = f"{r.get('title', '')} {r.get('snippet', '')}"
448
+ if not pat.search(hay):
449
+ return 1
450
+ if excl is not None and excl.search(hay):
451
+ return 1
452
+ return 0
453
+
454
+ keys = [_key(r) for r in results]
455
+ # 没有任何结果属于目标语言时原样返回:既省掉一次无意义的 list 拷贝,
456
+ # 也让「软排序不改变输入」这条性质对调用方恒真(否则 identity 断言会
457
+ # 在「无命中」这个最常见的场景下假性失败)。
458
+ if not any(k == 0 for k in keys):
459
+ return results
460
+ # stable sort:属于目标语言的结果在前,其余保持原 RRF 顺序
461
+ return sorted(results, key=_key)
462
+
463
+
464
+ _RERANK_BREAKER_KEY = "rerank:bocha"
465
+
466
+
467
+ def _rerank_breaker():
468
+ """精排端点的熔断器;不可用时返回 None(精排降级,不阻断搜索)。"""
469
+ try:
470
+ from circuit_breaker import get_breaker
471
+ return get_breaker()
472
+ except Exception:
473
+ return None
474
+
475
+
476
+ def _note_rerank_failure(breaker, category: str, detail: str) -> None:
477
+ """把精排端点的失败写进熔断器(含归因)。"""
478
+ if breaker is None:
479
+ return
480
+ try:
481
+ breaker.record_failure(
482
+ _RERANK_BREAKER_KEY, kind="error",
483
+ attribution={"category": category,
484
+ "reason": "语义精排端点不可用", "detail": detail},
485
+ )
486
+ except Exception:
487
+ pass
488
+
489
+
490
+ def rerank_results(query: str, results: list[dict[str, Any]],
491
+ top_n: int = 10, timeout: float = 5
492
+ ) -> tuple[list[dict[str, Any]], str]:
493
+ """使用博查语义排序模型对搜索结果二次精排。
494
+
495
+ 返回 (results, status):status ∈ ok | skipped_no_key | skipped_short |
496
+ skipped_fast | skipped_circuit_open | fallback
497
+ """
498
+ if not results or len(results) <= 1:
499
+ return results, "skipped_short"
500
+
501
+ api_key = get_env(["ARGO_BOCHA_API_KEY", "BOCHA_API_KEY"])
502
+ if not api_key:
503
+ return results, "skipped_no_key"
504
+
505
+ # 端点熔断:先问「还该不该打这一枪」。
506
+ #
507
+ # 为什么必须记住失败:这是一次**同步阻塞**的网络调用,压在 CPU 后处理链上。
508
+ # 实测账户余额不足时(403 `{"code":"403","message":"You do not have enough
509
+ # money"}`)每次搜索都真发一次请求、真等一次 RTT——223 ms,占 balanced
510
+ # 档墙钟的 63%、占全部后处理耗时的 89%——而旧实现把 HTTPError 一律吞成
511
+ # "fallback",既不计数也不冷却,于是每次搜索都重犯同一笔开销。
512
+ # 参数类失败(凭证/额度)不会因为再试一次自愈,只有周期性探测才有意义,
513
+ # 这正是熔断器「冷却 + 半开探测」的语义;状态写入文件,所以后续 CLI 单发进程
514
+ # 也直接跳过(进程内记忆对一次性 CLI 没有意义)。
515
+ breaker = _rerank_breaker()
516
+ if breaker is not None:
517
+ try:
518
+ allowed, _reason = breaker.allow(_RERANK_BREAKER_KEY)
519
+ except Exception:
520
+ allowed = True
521
+ if not allowed:
522
+ return results, "skipped_circuit_open"
523
+
524
+ documents = []
525
+ for r in results:
526
+ doc_text = f"{r.get('title', '')} {r.get('snippet', '')}".strip()
527
+ documents.append(doc_text or "empty")
528
+
529
+ import urllib.request
530
+ from net_proxy import open_url # 出口调度唯一入口(issue #13 同类修复)
531
+ payload = json.dumps({
532
+ "model": "gte-rerank", "query": query,
533
+ "documents": documents[:50],
534
+ "top_n": min(top_n, len(documents)),
535
+ "return_documents": False,
536
+ }).encode("utf-8")
537
+
538
+ req = urllib.request.Request(
539
+ "https://api.bocha.cn/v1/rerank", data=payload,
540
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
541
+ )
542
+ try:
543
+ with open_url(req, timeout=timeout) as resp:
544
+ data = json.loads(resp.read().decode("utf-8"))
545
+ # 拿到可解析响应即视为端点健康(含「有响应但无排序结果」),
546
+ # 闭合熔断状态——否则一次历史失败会让人工恢复后仍被拦。
547
+ if breaker is not None:
548
+ try:
549
+ breaker.record_success(_RERANK_BREAKER_KEY)
550
+ except Exception:
551
+ pass
552
+ rerank_results_list = data.get("data", {}).get("results", [])
553
+ if not rerank_results_list:
554
+ return results, "fallback"
555
+ scored = []
556
+ for rr in rerank_results_list:
557
+ idx = rr.get("index", -1)
558
+ score = rr.get("relevance_score", 0)
559
+ if 0 <= idx < len(results):
560
+ item = dict(results[idx])
561
+ orig_score = item.get("score", 0) or 0
562
+ item["score"] = round(score * 0.7 + orig_score * 0.3, 4)
563
+ scored.append(item)
564
+ if scored:
565
+ scored.sort(key=lambda x: x.get("score", 0), reverse=True)
566
+ return scored[:top_n], "ok"
567
+ except urllib.error.HTTPError as e:
568
+ # 401/403(凭证失效 / 额度耗尽)与 429(限流)都不是瞬时抖动:
569
+ # 立刻重试不会自愈,只会把同一笔 RTT 再付一次。分类只影响归因展示,
570
+ # 熔断策略一视同仁(都是 kind="error")。
571
+ cat = "rate_limited" if e.code == 429 else "auth"
572
+ _note_rerank_failure(breaker, cat, f"HTTP {e.code}")
573
+ return results, "fallback"
574
+ except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
575
+ _note_rerank_failure(breaker, "network", f"{type(e).__name__}: {e}")
576
+ return results, "fallback"
577
+ return results, "fallback"
578
+
579
+
580
+ def _tokens(text: str) -> list[str]:
581
+ """轻量分词:中文单字 + 英文单词,统一小写(复用 tfidf 风格)。
582
+
583
+ 保留单字切分供 `_bigrams`(新颖性去冗余)使用——那里比的是「两段文本
584
+ 的用字是否雷同」,单字粒度足够且更宽容。**相关性**不能用它,见
585
+ `_relevance_units` 的模块注释。
586
+ """
587
+ global _CJK_OR_WORD
588
+ if _CJK_OR_WORD is None:
589
+ import re as _re
590
+ _CJK_OR_WORD = _re.compile(r"[\u4e00-\u9fff]|[a-zA-Z0-9]+")
591
+ return [t for t in _CJK_OR_WORD.findall((text or "").lower())]
592
+
593
+
594
+ def _bigrams(tokens: list[str]) -> set[str]:
595
+ return {f"{tokens[i]}_{tokens[i+1]}" for i in range(len(tokens) - 1)}
596
+
597
+
598
+ def _jaccard(a: set[str], b: set[str]) -> float:
599
+ if not a or not b:
600
+ return 0.0
601
+ inter = len(a & b)
602
+ union = len(a | b)
603
+ return inter / union if union else 0.0
604
+
605
+
606
+
607
+
608
+ def _consensus_prior(results: list[dict[str, Any]]) -> list[float]:
609
+ """融合先验:把 RRF 分与跨引擎共识数归一化成 [0,1](逐条保持一致 results)。
610
+
611
+ 为什么需要它(这是本函数存在的唯一理由):
612
+ `local_five_dim_rerank` 用 relevance(token 覆盖率)/completeness(文本长度)
613
+ 等**文本自身**的维度重新打分,这在结构上偏爱「啰嗦的长网页」而压制
614
+ 「多个引擎都认同但摘要简短」的结果。实测:一条 3 引擎共识条目
615
+ (RRF 0.0418) 会被单源长文本条目 (RRF 0.0164) 反超——融合层的核心产出
616
+ 在最终排序中丢失。
617
+
618
+ 这里**不改五维公式**,只把融合信号作为一个独立先验维度加进来,避免与
619
+ `score` 字段竞争(`score` 已被本函数覆写,拿它当输入是循环依赖)。
620
+
621
+ 归一化计算方式:RRF 分取最大值归一到 1(保序,不放大);共识数按
622
+ `min(n-1, 3)/3` 计(封顶 3,避免 5 源共识把量纲压过其他维度)。
623
+ 共识在排序路径**只有这一个入口**:旧版此处之外还有一次乘法共识
624
+ boost(×(1+0.05·min(n-1,3)),2026-09-13 移除)——同一信号被重复
625
+ 计分,3 引擎共识合计被放大约 19%。evidence selection 阶段的
626
+ `selection` 乘法是「先核验哪条」的独立信号,不影响本排序。
627
+ """
628
+ priors: list[float] = []
629
+ raw = [float(r.get("_rrf_score", 0.0) or 0.0) for r in results]
630
+ peak = max(raw) if raw else 0.0
631
+ for r, rrf in zip(results, raw):
632
+ rrf_norm = (rrf / peak) if peak > 0 else 0.0
633
+ cons = len(r.get("consensus_engines") or [])
634
+ cons_norm = min(max(cons - 1, 0), 3) / 3.0
635
+ # 两个子信号取均值:单纯多引擎重复 != 更可信,RRF 分还含引擎权重
636
+ priors.append(0.5 * rrf_norm + 0.5 * cons_norm)
637
+ return priors
638
+
639
+
640
+
641
+
642
+
643
+ def local_five_dim_rerank(query: str, results: list[dict[str, Any]],
644
+ domain: str = "general", top_n: int = 10
645
+ ) -> list[dict[str, Any]]:
646
+ """本地五维精排(无 Bocha Key / fallback 时保底)。
647
+
648
+ 维度权重(通用,前五维和为 0.88,余下 0.12 给融合先验):
649
+ 相关性 0.26 + 权威性 0.26 + 时效性 0.18 + 完整性 0.13 + 新颖性 0.05
650
+ + **融合先验 0.12**(RRF 分 + 跨引擎共识,见 `_consensus_prior`)
651
+ tech/code 域:权威 0.18、相关 0.35(技术查询更看内容匹配)。
652
+
653
+ 无融合信息时(单引擎路径)先验恒为 0,与旧行为等价:此时五维按 0.88
654
+ 整体折算,是原权重比例的等比缩放,排序结果不变。
655
+
656
+ 新颖性:标题 bigram 与「已排更高结果」的 Jaccard 互补(1 − overlap),
657
+ 奖励信息增量,抑制近重复堆叠。
658
+
659
+ 每个结果写入 rerank_dims 明细(含 prior),供可观测。
660
+ """
661
+ if not results:
662
+ return results
663
+
664
+ def _src_has(source: str, name: str) -> bool:
665
+ # rrf_merge 会把同 URL 结果的 source 合并成 "local_bing/sina_quote",
666
+ # 精确匹配会漏掉合并后的结果,这里按「/」切分做成员判断。
667
+ return name in str(source).split("/")
668
+
669
+ # 权重表(MECE)。前五维整体缩放 (1 - W_PRIOR),把余量留给融合先验。
670
+ # 关键性质:prior 缺席时(单引擎路径)五维被同一常数缩放,是原权重比例的
671
+ # 等比变换 —— 排序结果与改造前逐位一致。该等价性由测试锁定。
672
+ W_PRIOR = 0.12
673
+ _BASE = 1.0 - W_PRIOR
674
+ is_tech = domain in ("tech_deep", "code_search", "local_code", "academic")
675
+ if is_tech:
676
+ # 原 tech 权重:rel .40 / auth .20 / fresh .20 / comp .15 / nov .05
677
+ w = {"relevance": 0.40 * _BASE, "authority": 0.20 * _BASE,
678
+ "freshness": 0.20 * _BASE, "completeness": 0.15 * _BASE,
679
+ "novelty": 0.05 * _BASE}
680
+ else:
681
+ # 原通用权重:rel .30 / auth .30 / fresh .20 / comp .15 / nov .05
682
+ w = {"relevance": 0.30 * _BASE, "authority": 0.30 * _BASE,
683
+ "freshness": 0.20 * _BASE, "completeness": 0.15 * _BASE,
684
+ "novelty": 0.05 * _BASE}
685
+ _priors = _consensus_prior(results)
686
+
687
+ # 复用 evidence 的权威/时效评分(若可用)
688
+ try:
689
+ from evidence import score_authority, score_freshness
690
+ _has_evidence = True
691
+ except ImportError:
692
+ _has_evidence = False
693
+
694
+ # 相关性比对单元(2026-09-27 换算子):CJK 二元组 + 拉丁词,不再是中文
695
+ # 单字。ARGO_RELEVANCE_V2=0 时 _score_relevance 退回旧口径,而那时它需要
696
+ # 单字集合——故按开关形态分别构造,保证逃生门「逐位回到旧行为」成立。
697
+ query_tokens = (set(_tokens(query)) if not _relevance_v2_enabled()
698
+ else set(_relevance_units(query)))
699
+ floors = _domain_score_floors().get(domain, {})
700
+
701
+ # 来源侧惩罚(域级集中 + 跨域同质),均为 _static4 的乘子(≤1),在
702
+ # 贪心之前应用——故 K 剪枝上界 U_i = 静态分 + w_novelty + W_PRIOR·prior
703
+ # 仍保守成立(系数 ≤1 只会让上界更小,不会失效)。
704
+ domain_penalty, _xh = _combined_source_penalties(results)
705
+
706
+ # 先计算前四维静态分
707
+ enriched = []
708
+ for _i, r in enumerate(results):
709
+ title = r.get("title", "") or ""
710
+ snippet = r.get("snippet", "") or ""
711
+ url = r.get("url", "") or ""
712
+ source = r.get("source", "") or ""
713
+ relevance = _score_relevance(query_tokens, title, snippet)
714
+ # 域级源保底分(答案型源:书目/行情/汇率/官方公告),声明在
715
+ # config.yaml 各域 score_floors——排序代码对源类型无知
716
+ for _src, fl in floors.items():
717
+ if "relevance" in fl and _src_has(source, _src):
718
+ relevance = max(relevance, fl["relevance"])
719
+ if _has_evidence:
720
+ try:
721
+ authority = float(score_authority(url, source).get("score", 0.5))
722
+ except Exception:
723
+ authority = 0.5
724
+ try:
725
+ freshness = float(score_freshness(r).get("score", 0.5))
726
+ except Exception:
727
+ freshness = 0.5
728
+ # 权威/时效保底(行情快照/官方公告源在 evidence 域名表里
729
+ # 偏低,实为高可信答案源),声明同上
730
+ for _src, fl in floors.items():
731
+ if not _src_has(source, _src):
732
+ continue
733
+ if "authority" in fl:
734
+ authority = max(authority, fl["authority"])
735
+ if "freshness" in fl:
736
+ freshness = max(freshness, fl["freshness"])
737
+ else:
738
+ authority, freshness = 0.5, 0.5
739
+ completeness = _score_completeness(title, snippet)
740
+ enriched.append({
741
+ "r": r, "title": title,
742
+ "relevance": relevance, "authority": authority,
743
+ "freshness": freshness, "completeness": completeness,
744
+ # bigrams 一次性预算:贪心选序会反复查阅同一标题,把分词+哈希
745
+ # 摊到外层避免 O(n²) 重复计算(n 为待排结果数)。
746
+ "bg": _bigrams(_tokens(title)),
747
+ "prior": _priors[_i],
748
+ })
749
+
750
+ # K 相关剪枝(可证明无损,2026-09-19):
751
+ # 边际分 = 静态四维 + w_novelty·novelty + W_PRIOR·prior。novelty∈[0,1] 且
752
+ # prior 已知,所以任一条目在任一轮的边际分都 ≤ U_i = 静态分 + w_novelty
753
+ # + W_PRIOR·prior_i。记 L 为静态分的第 K 大值(K = top_n):前 K 轮里最多
754
+ # 选走 K−1 条,池中必然还剩至少一条静态分 ≥ L 的条目,它的边际分 ≥ L。
755
+ # 因此 U_i < L 的条目在前 K 轮里不可能被选中——剪掉它,前 K 个选序与逐条
756
+ # 真算逐位一致(平局规则「首个索引胜出」也不受影响,被剪条目本来就赢不了)。
757
+ #
758
+ # 为什么不用「上一轮分数作上界」的增量剪枝:selected_bigrams 是并集,加入
759
+ # bigram 不重叠的条目会让 jaccard 下降、novelty 回升,边际分不是单调不增的,
760
+ # 那条界不成立(2026-09-19 对拍出 8 处差异后废弃,勿再尝试)。
761
+ #
762
+ # 静态分按原加法顺序(左结合到 completeness)预算,再逐项加 novelty 与
763
+ # prior——浮点加法顺序与改造前一致,金标对拍才可能零差异。
764
+ pool = enriched[:]
765
+ for e in pool:
766
+ e["_static4"] = (w["relevance"] * e["relevance"]
767
+ + w["authority"] * e["authority"]
768
+ + w["freshness"] * e["freshness"]
769
+ + w["completeness"] * e["completeness"])
770
+ # 来源侧惩罚(域级集中 / 跨域同质)作为静态分乘子(≤1)在**贪心之前**
771
+ # 应用:① 无需事后重排,且惩罚表为空时本行是 ×1.0、浮点上逐位无操作,
772
+ # 「无惩罚时与旧实现逐位一致」的不变式不被破坏;② K 剪枝上界
773
+ # penalty×_static4 + w_novelty + W_PRIOR×prior ≤ 旧上界,剪枝仍安全。
774
+ _pf = domain_penalty.get(_host_of(e["r"].get("url") or ""))
775
+ if _pf is not None and _pf < 1.0:
776
+ e["_static4"] *= _pf
777
+ e["_domain_factor"] = _pf
778
+ _xf = _xh.get(e["r"].get("url") or "")
779
+ if _xf is not None and _xf < 1.0:
780
+ e["_static4"] *= _xf
781
+ e["_cross_domain_factor"] = _xf
782
+ e["_ub"] = e["_static4"] + w["novelty"] + W_PRIOR * e["prior"]
783
+ if 0 < top_n < len(pool):
784
+ l_k = sorted((e["_static4"] for e in pool), reverse=True)[top_n - 1]
785
+ kept = [e for e in pool if e["_ub"] >= l_k]
786
+ if len(kept) < len(pool):
787
+ pool = kept
788
+
789
+ # 贪心排序:每步选边际得分最高者,novelty 相对已选集合动态计算。
790
+ # 域级惩罚在选序后按最终分乘算(见下),保证 K 剪枝上界不受影响。
791
+ ranked: list[dict[str, Any]] = []
792
+ selected_bigrams: set[str] = set()
793
+ while pool:
794
+ best_idx, best_score, best_novelty = 0, -1.0, 1.0
795
+ for i, e in enumerate(pool):
796
+ novelty = 1.0 - _jaccard(e["bg"], selected_bigrams)
797
+ score = e["_static4"] + w["novelty"] * novelty + W_PRIOR * e["prior"]
798
+ if score > best_score:
799
+ best_idx, best_score, best_novelty = i, score, novelty
800
+ chosen = pool.pop(best_idx)
801
+ r = chosen["r"]
802
+ r["score"] = round(best_score, 4)
803
+ r["rerank_dims"] = {
804
+ "relevance": chosen["relevance"],
805
+ "authority": round(chosen["authority"], 4),
806
+ "freshness": round(chosen["freshness"], 4),
807
+ "completeness": chosen["completeness"],
808
+ "novelty": round(best_novelty, 4),
809
+ "prior": round(chosen["prior"], 4),
810
+ }
811
+ # 降权项留可观测项:线上出现「为什么这条掉了」时才查得到原因
812
+ _df = chosen.get("_domain_factor")
813
+ if _df is not None:
814
+ r["rerank_dims"]["domain_concentration"] = _df
815
+ _xdf = chosen.get("_cross_domain_factor")
816
+ if _xdf is not None:
817
+ r["rerank_dims"]["cross_domain_homogeneity"] = _xdf
818
+ selected_bigrams |= chosen["bg"]
819
+ ranked.append(r)
820
+
821
+ return ranked[:top_n]
822
+
823
+
824
+ def _apply_consensus_and_sort(merged: list[dict[str, Any]],
825
+ max_results: int) -> list[dict[str, Any]]:
826
+ """融合层最终排序:按五维 rerank 的 score 降序并截断。
827
+
828
+ 排序只认 rerank 写入的 score(含 ① 融合先验维度)。此处曾有第二道乘法
829
+ 共识 boost(×(1+0.05·min(n-1,3))),与 ① 对同一信号重复计分:3 引擎
830
+ 共识合计被放大 ~19%(1.08×1.10),2026-09-13 移除(金标 18 条对拍
831
+ 无序位回归)。共识的排序影响由 ① 表达,可观测面由 `consensus_engines`
832
+ 与 evidence selection 的 `selection` 字段表达。
833
+
834
+ max_results 在**这里**夹到非负:负数切片 `[: -3]` 从尾部砍 3 条(10 条给
835
+ 7 条,比不传的默认 5 条还多)。CLI 层给可读报错,但 MCP/库调用绕开
836
+ argparse,兜底必须落在这个切片点。
837
+ """
838
+ merged.sort(key=lambda r: abs(r.get("score", 0) or 0), reverse=True)
839
+ return merged[:max(0, int(max_results or 0))]
840
+
841
+
842
+ def _attach_selection_signals(merged: list[dict[str, Any]], mode: str,
843
+ depth: str) -> None:
844
+ """两阶段 selection 信号(authority/freshness/selection/absorption/…)。
845
+
846
+ 这是 evidence「先核验哪条」的依据,独立于排序 score——共识在此阶段
847
+ 合法地参与(提高待核验优先级),不属于排序重复计分。
848
+ fast 模式跳过(MCP 默认紧凑也不返回这些字段)。失败静默:观测层
849
+ 不得拖累搜索主路径。
850
+ """
851
+ if not merged or mode == "fast" or depth == "fast":
852
+ return
853
+ try:
854
+ from evidence import score_authority, score_freshness, score_traceability
855
+ from content_signals import score_evidence_density
856
+ for r in merged:
857
+ url = r.get("url", "")
858
+ source = r.get("source", "")
859
+ title = r.get("title", "") or ""
860
+ snippet = r.get("snippet", "") or ""
861
+ auth = score_authority(url, source)
862
+ fresh = score_freshness(r)
863
+ dens = score_evidence_density(snippet, title)
864
+ selection = auth["score"]
865
+ if auth.get("is_serp"):
866
+ selection = min(selection, 0.15)
867
+ cons = r.get("consensus_engines") or []
868
+ if len(cons) >= 2 and not auth.get("is_serp"):
869
+ selection = min(1.0, selection * (1.0 + 0.1 * min(len(cons) - 1, 2)))
870
+ absorption = dens["absorption_score"]
871
+ orig = float(next((v for v in (r.get("score"),) if v not in (None, "")), 0.5)) # 哨兵判空:显式 0 分不得膨胀成 0.5
872
+ r["authority"] = auth["score"]
873
+ r["authority_tier"] = auth["tier"]
874
+ # 离散权威等级 0-4(≥3 极权威):上游原生 authority 优先,否则连续分映射
875
+ r["authority_level"] = authority_level(
876
+ auth["score"], r.get("upstream_authority"))
877
+ r["freshness"] = fresh["score"]
878
+ r["selection"] = round(selection, 3)
879
+ r["absorption"] = round(absorption, 3)
880
+ r["evidence_flags"] = {
881
+ "has_numbers": dens["has_numbers"],
882
+ "has_comparison": dens["has_comparison"],
883
+ "has_definition": dens["has_definition"],
884
+ "is_serp": bool(auth.get("is_serp")),
885
+ "consensus": len(cons),
886
+ }
887
+ trace = score_traceability(r)
888
+ r["traceability"] = trace["score"]
889
+ r["credibility_fast"] = round(
890
+ selection * 0.35 + absorption * 0.30 + fresh["score"] * 0.15 + orig * 0.10 + trace["score"] * 0.10,
891
+ 3,
892
+ )
893
+ except ImportError:
894
+ pass
895
+ except Exception as e:
896
+ import logging
897
+ logging.getLogger("unified_search").debug(f"可信度评分跳过: {type(e).__name__}")
898
+
899
+
900
+ def _align_facts_safe(merged: list[dict[str, Any]], mode: str,
901
+ depth: str) -> dict[str, Any] | None:
902
+ """关键事实交叉标记(P0-004)。仅 deep/auto 且结果 ≥3;fast 跳过。
903
+
904
+ 输出体积限制:corroborated/conflicts 各最多保留 10 条,避免大结果集
905
+ 下 fact_alignment 膨胀(实测极端案例单条冲突含 50+ domains,输出 >5KB)。
906
+ """
907
+ if not merged:
908
+ return None
909
+ try:
910
+ from fact_align import align_facts
911
+ raw = align_facts(merged, min_results=3, mode=mode, depth=depth)
912
+ if raw is None:
913
+ return None
914
+ # 体积截断:保留 stats 完整性,截断明细数组
915
+ corroborated = raw.get("fact_corroborated", [])[:10]
916
+ conflicts = raw.get("fact_conflicts", [])[:10]
917
+ # 单条冲突的 domains 也限制(保留前 5 个域名)
918
+ for c in conflicts:
919
+ for v in c.get("values", []):
920
+ if len(v.get("domains", [])) > 5:
921
+ v["domains"] = v["domains"][:5]
922
+ return {
923
+ "enabled": raw.get("enabled", True),
924
+ "fact_conflicts": conflicts,
925
+ "fact_corroborated": corroborated,
926
+ "stats": raw.get("stats", {}),
927
+ "truncated": bool(
928
+ len(raw.get("fact_corroborated", [])) > 10
929
+ or len(raw.get("fact_conflicts", [])) > 10
930
+ ),
931
+ }
932
+ except ImportError:
933
+ return None # fact_align 模块不可用
934
+ except Exception as e:
935
+ import logging
936
+ logging.getLogger("unified_search").debug(
937
+ f"事实交叉标记跳过: {type(e).__name__}")
938
+ return None
939
+
940
+
941
+ def _domain_matches(host: str, domain: str) -> bool:
942
+ """host 等于域或是其子域(github.com 命中 api.github.com)。"""
943
+ return host == domain or host.endswith("." + domain)
944
+
945
+
946
+ def filter_results_by_domains(
947
+ results: list[Any] | None,
948
+ include_domains: list[str] | None = None,
949
+ exclude_domains: list[str] | None = None,
950
+ ) -> tuple[list[Any], str | None]:
951
+ """域名后置过滤(引擎无关,融合排序之后执行)。
952
+
953
+ include:仅保留命中域名(含子域)的结果;exclude:剔除命中域名的结果。
954
+ 返回 (保留列表, 说明文本);两组过滤都为空时原样返回。
955
+ """
956
+ inc = [str(d).strip().lower() for d in (include_domains or []) if str(d).strip()]
957
+ exc = [str(d).strip().lower() for d in (exclude_domains or []) if str(d).strip()]
958
+ if not inc and not exc:
959
+ return results or [], None
960
+ kept: list[Any] = []
961
+ dropped = 0
962
+ for r in results or []:
963
+ host = ""
964
+ if isinstance(r, dict):
965
+ try:
966
+ from urllib.parse import urlparse as _up
967
+ host = (_up(r.get("url", "") or "").hostname or "").lower()
968
+ except Exception:
969
+ host = ""
970
+ if inc and not any(_domain_matches(host, d) for d in inc):
971
+ dropped += 1
972
+ continue
973
+ if any(_domain_matches(host, d) for d in exc):
974
+ dropped += 1
975
+ continue
976
+ kept.append(r)
977
+ note = f"domain filter: kept {len(kept)}, dropped {dropped}"
978
+ return kept, note
979
+
980
+ # 「bocha 没有产出排序」的唯一来源:落到本地五维保底的状态全集。
981
+ #
982
+ # 此前是内联在调用点的四元素元组,新增状态极易漏改,而漏改的后果是静默的——
983
+ # 既不精排也不保底,最终顺序退化成 RRF 原始序,没有任何信号。它属于精排层,
984
+ # 因此住在精排函数旁边(search.py 只是同名转出)。
985
+ _RERANK_DEGRADED_STATUSES = frozenset({
986
+ "skipped_no_key", # 未配置密钥
987
+ "skipped_short", # 结果太少,不值得精排
988
+ "skipped_fast", # fast 档不付远程精排
989
+ "skipped_circuit_open", # 端点熔断中(见 _RERANK_BREAKER_KEY)
990
+ "fallback", # 端点报错或返回不可用数据
991
+ })