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,926 @@
1
+ #!/usr/bin/env python3
2
+ """search_pipeline.py — 融合后加工:把「多引擎结果」变成「可交付结果」。
3
+
4
+ ## 为什么单独成模块
5
+
6
+ execute_search 里,dispatch(打网络)与「融合后加工」(纯本地,13 个阶段)是两种
7
+ 性质完全不同的工作:前者关心并发/超时/熔断,后者关心过滤、去重、排序、标注。
8
+ 后者有 354 行、9 个计时段、6 个漏斗计数,混在网络编排里时,读一次「为什么这条
9
+ 结果被剔掉了」要穿过整条调度链。
10
+
11
+ ## 两概念的状态模型(这是本模块存在的理由)
12
+
13
+ 加工阶段的输入输出曾经散成 28 个外层局部量 + 21 个写回量。现在只有两个概念:
14
+
15
+ - `_SearchRequest`:**请求侧**,进入加工后不再改变(查询、档位、时间窗、
16
+ 钩子、计时句柄);
17
+ - `_SearchRun`:**运行侧**,被阶段逐步改写的累加量(结果集、各类剔除计数、
18
+ 精排状态、漏斗、缓存载荷)。
19
+
20
+ 阶段函数签名统一为 `(req, run) -> run`。要加一个阶段时,只需在 run 上加一个
21
+ 字段,不必再往 execute_search 的局部量里塞东西——那条路径正是当初把
22
+ execute_search 撑到 724 行的原因。
23
+
24
+ ## 边界
25
+
26
+ 本模块**不打网络**(唯一例外是 D6 macro 证据下限与空结果恢复树,它们通过
27
+ `req.run_one` / `req.ingest` 钩子回到调度层执行)。判据:本文件不 import
28
+ http_client / engines_base / engine_dispatch。
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from search_types import slot_record as dataclass, slot_field as field, slot_replace as replace
34
+ from typing import Any
35
+
36
+ # 模块级曾有一行 `import logging`,而本模块 7 处 `logging.getLogger(...)`
37
+ # 全部是 debug 级别、且全仓从未把 unified_search 的 level 调离默认 WARNING
38
+ # ——也就是说它们默认永不产生输出,代价却是把 traceback → dataclasses →
39
+ # inspect → _colorize(CPython 3.13+ 实测 14.6 ms / 87 个模块)拖进
40
+ # `import search` 的必经之路。search.py 在模块级导入本模块,所以每次 argo
41
+ # 调用(含纯缓存命中的那一档)都要付这笔钱去买一个从不打字的 logger。
42
+ #
43
+ # 修法沿用 search_output.py / argo_engine_registry.py 的既定模式:一处按需
44
+ # 取 logger 的小函数,模块级不留任何绑定。绝不能改成就地 `import logging`
45
+ # ——那会与下面 `except` 块内的局部导入形成遮蔽,重演 search_output 里那次
46
+ # UnboundLocalError。
47
+ def _log(message: str) -> None:
48
+ """默认静默的调试出口;仅在真正需要记录时才引入 logging。"""
49
+ import logging
50
+ logging.getLogger("unified_search").debug(message)
51
+
52
+ from bounded_run import run_bounded
53
+ from cache_guard import attempt_cache_write, should_record_adaptive
54
+ from engine_env import env_flag
55
+ from variant_recall import augment_with_variants
56
+ from phrase_filter import apply_phrase_filter, extract_phrases
57
+ from except_sets import OPT_IMPORT, SHAPE_BENIGN
58
+ from postprocess_signals import apply_tfidf_polish, detect_number_conflict
59
+ from search_attribution import usage_log_attribution, is_synthetic_run
60
+ from search_output import _collect_errors, _slow_query_ttl, build_funnel
61
+ from serp_kill_log import clean_engine_lists
62
+ from search_rank import (
63
+ _LANG_SCRIPT,
64
+ _RERANK_DEGRADED_STATUSES,
65
+ _align_facts_safe,
66
+ _apply_consensus_and_sort,
67
+ _attach_selection_signals,
68
+ _lang_prefer_rerank,
69
+ _rerank_pool_limit,
70
+ deduplicate_by_url,
71
+ local_five_dim_rerank,
72
+ minhash_dedupe,
73
+ rerank_results,
74
+ rrf_merge,
75
+ )
76
+ from search_types import Stage
77
+ from stage_timing import tick as _tick, tock as _tock
78
+ from time_utils import apply_time_window as _apply_time_window
79
+ from time_utils import sort_results_by_time as _sort_results
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class _SearchRequest:
84
+ """请求侧输入:进入加工后不再改变(请求参数、派生查询、钩子、计时句柄)。"""
85
+
86
+ query: str
87
+ decision: dict[str, Any]
88
+ engines: list[str]
89
+ engines_combo: list[str]
90
+ domain: str
91
+ mode: str
92
+ depth: str
93
+ timeout: int
94
+ max_results: int
95
+ retrieval_query: str
96
+ parallel: bool
97
+ eff_timeout: float
98
+ exclude_terms: list[str]
99
+ qu: Any
100
+ since_iso: str | None
101
+ until_iso: str | None
102
+ since_ts: float | None
103
+ until_ts: float | None
104
+ time_aware: bool
105
+ skip_cache: bool
106
+ timing: Any
107
+ on_progress: Any
108
+ sort: str
109
+ cache: Any
110
+ engine_label: str
111
+ cache_engine_key: Any
112
+ emit_usage_log: Any
113
+ breaker: Any
114
+ # 引擎级垂直域/子域(CLI --domain/--sub_domain)。此前这两个开关被
115
+ # argparse 接下、SKILL.md 与 usage.md 都写进了用法,却没有任何一层读取
116
+ # 它们——请求照发、结果照回,只是 `domain` 参数根本没到引擎,于是
117
+ # 「限定金融域」静默退化成通用搜索。`domain` 字段(上方)是**路由域**,
118
+ # 与这里的引擎入参同名不同义,刻意不合并。
119
+ engine_domain: str | None = None
120
+ engine_sub_domain: str | None = None
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class _SearchRun:
125
+ """运行侧状态:被阶段逐步改写(`replace` 返回新实例)。
126
+
127
+ 字段分三组:结果集(raw_results / engine_outcomes / merged)、加工累加量
128
+ (各类剔除计数、精排状态、漏斗、缓存载荷)、调度产出(engine_latency /
129
+ 预算与墙钟账 / 计时句柄与钩子)。
130
+ """
131
+
132
+ raw_results: dict[str, list[dict[str, Any]]]
133
+ engine_outcomes: list[dict[str, Any]]
134
+ merged: list[dict[str, Any]]
135
+ minhash_removed: int = 0
136
+ image_dup_removed: int = 0
137
+ image_dropped: dict[str, int] = field(default_factory=dict)
138
+ excluded_count: int = 0
139
+ time_filtered: int = 0
140
+ time_filter_warning: str | None = None
141
+ recovery_info: dict[str, Any] | None = None
142
+ rank_method: str = "none"
143
+ reranker_status: str = "skipped_short"
144
+ local_rerank_on: bool = False
145
+ fact_alignment: dict[str, Any] | None = None
146
+ number_conflict: dict[str, Any] | None = None
147
+ noise_dropped: list[dict[str, Any]] = field(default_factory=list)
148
+ funnel: dict[str, Any] | None = None
149
+ result_payload: dict[str, Any] | None = None
150
+ quota_batch: Any = None
151
+ tk_fusion: float = 0.0
152
+ run_one: Any = None
153
+ ingest: Any = None
154
+ engine_latency: dict[str, float] = field(default_factory=dict)
155
+ wasted_ms: int = 0
156
+ useful_ms: int = 0
157
+ dispatch_tail: dict[str, int] = field(default_factory=dict)
158
+ early_stopped: bool = False
159
+ budget_used_ms: int | None = None
160
+ budget_total_ms: int | None = None
161
+ elapsed: int = 0
162
+
163
+
164
+ # 兜底补搜的候选顺序。anysearch 在前是因为它对日/韩这类通用查询的召回
165
+ # 实测最好;local_bing 次之(带 setlang 的本地 SERP);再往后是各语言的
166
+ # 通用源,都不行时至少还有 wikipedia 这类必定有结果的百科。
167
+ #
168
+ # 2026-09-27:删去 duckduckgo——engine_policy 已记录「2026-09-07:duckduckgo
169
+ # 移出」(本机出口网络层阻断,无客户端解法)。留着它不会真调到死源
170
+ # (_fallback_candidates 用 available 过滤),但 ja/ko 零结果兜底会白白
171
+ # 少一个候选位,而 tests/test_noise_gate_fallback.py 的 available 集合手写
172
+ # 包含它,死引用没有任何测试能发现。
173
+ _FALLBACK_ENGINES: tuple[str, ...] = (
174
+ "anysearch", "local_bing", "wikipedia", "octen",
175
+ )
176
+
177
+
178
+ def _fallback_candidates(already: set[str], available: set[str],
179
+ breaker: Any = None) -> list[str]:
180
+ """噪声门清空结果后的兜底引擎:跳过已试过的、不可用的、被熔断的。
181
+
182
+ 与 D6(macro_data 域)的候选筛选同构,抽成函数是为了让「兜底只补搜、
183
+ 不复活被丢弃结果」这条契约有地方可测——两处各写一份筛选逻辑时,
184
+ 其中一处漏掉 breaker 判断就会变成绕过熔断。
185
+ """
186
+ out: list[str] = []
187
+ for name in _FALLBACK_ENGINES:
188
+ if name in already or name not in available:
189
+ continue
190
+ if breaker is not None:
191
+ try:
192
+ allowed, _ = breaker.allow(name)
193
+ except Exception:
194
+ allowed = True # 熔断器自身故障不该阻断兜底
195
+ if not allowed:
196
+ continue
197
+ out.append(name)
198
+ if len(out) >= 2: # 上界:一次搜索最多补两个,避免拖成长任务
199
+ break
200
+ return out
201
+
202
+
203
+ def postprocess(req: _SearchRequest, run: _SearchRun, hooks: Any) -> _SearchRun:
204
+ """融合后加工:13 个阶段,顺序即契约(过滤 → 去重 → 软排 → 精排 → 标注)。
205
+
206
+ hooks 只用于两处需要回到调度层的动作(D6 macro 补搜、空结果恢复树)——
207
+ 入口按值传入,见 search_entry 的 _SearchHooks 说明。
208
+ """
209
+ engine_search = hooks.engine_search
210
+ available_engines = hooks.available_engines
211
+ query = req.query
212
+ decision = req.decision
213
+ engines = req.engines
214
+ engines_combo = req.engines_combo
215
+ domain = req.domain
216
+ mode = req.mode
217
+ depth = req.depth
218
+ timeout = req.timeout
219
+ max_results = req.max_results
220
+ exclude_terms = req.exclude_terms
221
+ qu = req.qu
222
+ since_iso = req.since_iso
223
+ until_iso = req.until_iso
224
+ since_ts = req.since_ts
225
+ until_ts = req.until_ts
226
+ time_aware = req.time_aware
227
+ skip_cache = req.skip_cache
228
+ timing = req.timing
229
+ on_progress = req.on_progress
230
+ _tk_fusion = run.tk_fusion
231
+ _run_one = run.run_one
232
+ _ingest = run.ingest
233
+ _emit_usage = req.emit_usage_log
234
+ quota_batch = run.quota_batch
235
+ breaker = req.breaker
236
+ raw_results = run.raw_results
237
+ engine_outcomes = run.engine_outcomes
238
+ merged = run.merged
239
+
240
+ # 融合输入派生(原先在 execute_search 里,属于加工的第一步)
241
+ # 去 error 与 SERP 守卫同源:缓存命中的结果也要过同一道门(issue #18)。
242
+ clean_lists = clean_engine_lists(raw_results, query)
243
+
244
+ # 查询主语言:噪声门与语言能力加权共用同一个判定(唯一来源,
245
+ # 避免两处各自算导致行为漂移)。
246
+ _q_lang_for_fusion = (
247
+ ((decision or {}).get("features") or {}).get("primary_lang") or None
248
+ )
249
+
250
+
251
+
252
+ # ── 多语言噪声门(result_lang)─────────────────────────────────────
253
+ # 实测问题:引擎在非支持语言下会返回「成功但不相关」的结果——
254
+ # juejin 在阿拉伯语查询下返回 10 条、相关度 0.00(全是通用热帖),
255
+ # 却报告 status=ok。这类噪声混进 RRF 融合会污染最终结果。
256
+ #
257
+ # 判定不依赖「猜测查询语言」:用结果自身的书写系统(Unicode 码位,
258
+ # 确定性)+ 查询词元命中率。语言不符且相关度低 → 判为噪声并剔除。
259
+ #
260
+ # 只在「查询语言可判定且非中英」时启用:中英是 argo 的主战场,
261
+ # 现有引擎面足够宽,过早过滤会误伤(如英文查询命中中文优质内容)。
262
+ _noise_dropped: list[dict[str, Any]] = []
263
+ try:
264
+ from result_lang import assess_results as _assess_lang
265
+ # 「非中英才启用噪声门」的白名单从 lang_pref 单一真源派生
266
+ # (BASELINE_LANGS + WEAK_QUERY_LANGS):此前这里是第四处硬编码
267
+ # ("zh", "en", "mixed", "other", "")——新增受追踪语言要改三处的
268
+ # bug 类别,门禁只锁住了两处,这条一直在暗处。
269
+ from lang_pref import BASELINE_LANGS, WEAK_QUERY_LANGS
270
+ _baseline_or_weak = frozenset(BASELINE_LANGS) | WEAK_QUERY_LANGS
271
+ _q_lang = _q_lang_for_fusion
272
+ if _q_lang and _q_lang not in _baseline_or_weak:
273
+ _kept = []
274
+ for _lst in clean_lists:
275
+ if not _lst:
276
+ continue
277
+ _eng = _lst[0].get("_engine") or _lst[0].get("source") or "?"
278
+ _a = _assess_lang(query, _lst, expected_lang=_q_lang)
279
+ if _a["verdict"] == "noise":
280
+ _noise_dropped.append({
281
+ "engine": _eng, "lang": _a["lang"],
282
+ "relevance": _a["relevance"],
283
+ "reason": (_a["reasons"][0] if _a.get("reasons")
284
+ else "low relevance"),
285
+ })
286
+ continue
287
+ _kept.append(_lst)
288
+ clean_lists = _kept
289
+ except Exception as _e: # 噪声门是质量增强,失败不得拖垮主流程
290
+ import logging as _lg
291
+ _lg.getLogger("unified_search.search").debug(f"噪声门跳过: {_e}")
292
+
293
+ # ── 噪声门兜底:非中英查询下门清空了全部结果时补搜 ────────────────
294
+ # 实测 bug:`argo search "プロンプト エンジニアリング"` 返回 count=0
295
+ # 而 errors=[]。链路是——路由派给 qiita(TF-IDF 0.683)→ qiita 的
296
+ # `?query=` 对日语召回极差、静默降级 → 噪声门**正确**判定 noise 并丢弃
297
+ # → combo 里的 anysearch 因早停被 cancelled → merged 空、errors 空。
298
+ #
299
+ # 噪声门没错(qiita 确实在降级),错的是清空之后没有兜底:同文件 D6
300
+ # 分支早就为 macro_data 域做了「结构化源覆盖不足就追加通用引擎」,噪声
301
+ # 门这条路却没有对应机制,于是一次静默降级被放大成零结果且无任何线索。
302
+ #
303
+ # 只补搜、不复活:被噪声门丢掉的列表绝不回填(否则噪声门形同虚设),
304
+ # 兜底的结果来自**另外的引擎**,仍要过 RRF 与后续的精排/去重。
305
+ if not clean_lists and _noise_dropped:
306
+ try:
307
+ _fb = _fallback_candidates(
308
+ already=set(raw_results.keys()),
309
+ available=set(available_engines()),
310
+ breaker=breaker,
311
+ )
312
+ for _eng in _fb:
313
+ _e2, _res2, _out2, _lat2 = _run_one(_eng)
314
+ _ingest(_e2, _res2, _out2, _lat2)
315
+ _goods2 = [r for r in _res2
316
+ if isinstance(r, dict) and "error" not in r]
317
+ if _goods2:
318
+ clean_lists.append(_goods2)
319
+ except Exception as _e2: # 兜底是最后一道保底,失败不得反噬主流程
320
+ import logging as _lg2
321
+ _lg2.getLogger("unified_search.search").debug(f"噪声门兜底跳过: {_e2}")
322
+
323
+ # ── multi-query 变体召回波(Step 1,本体在 variant_recall.py)───────────
324
+ # 主 query 召回不足时用变体补召回,rrf_merge 按多 query 共识加权;隔离 + 失败安全。
325
+ clean_lists, _variant_recalled = augment_with_variants(req, engine_search, clean_lists, max_results)
326
+
327
+ if len(clean_lists) > 1:
328
+ merged = rrf_merge(clean_lists, lang=_q_lang_for_fusion)
329
+ elif clean_lists:
330
+ merged = deduplicate_by_url(clean_lists[0])
331
+ # 单引擎也补 consensus
332
+ for r in merged:
333
+ eng = r.get("_engine") or r.get("source") or ""
334
+ if eng:
335
+ r.setdefault("consensus_engines", [eng])
336
+ else:
337
+ merged = []
338
+
339
+ merged, _phrase_dropped = apply_phrase_filter(merged, extract_phrases(query)) # Step 3 短语精确过滤(仅查询含引号短语时触发)
340
+ # ── D6:macro_data 域证据下限(事实核查防单源)─────────────────────
341
+ # deep 研究场景下结果 <2 条说明结构化源未覆盖该查询:追加通用保底引擎
342
+ # 补证据,避免「单引擎单结果」被事实核查 / 融合阶段当作答案;补搜结果
343
+ # 一并进 RRF,consensus 维度天然加权。
344
+ #
345
+ # depth 只判 "deep":--depth 的 choices 是 fast|balanced|deep,"research"
346
+ # 永远不会出现(2026-09-27 清掉的半死条件)。候选表里的 duckduckgo
347
+ # 同样清掉——engine_policy 已记录它 2026-09-07 移出,留着只会让
348
+ # 「这个引擎还在不在」没有单一答案。
349
+ if (domain == "macro_data" and merged and len(merged) < 2
350
+ and depth == "deep"):
351
+ _done = set(raw_results.keys())
352
+ _cands = [
353
+ e for e in ("anysearch", "local_bing")
354
+ if e not in _done
355
+ and e in set(available_engines())
356
+ and (breaker is None or breaker.allow(e)[0])
357
+ ]
358
+ _extra_lists = []
359
+ for _eng in _cands[:2]:
360
+ _e, _res, _out, _lat = _run_one(_eng)
361
+ _ingest(_e, _res, _out, _lat)
362
+ _goods = [r for r in _res if isinstance(r, dict) and "error" not in r]
363
+ if _goods:
364
+ _extra_lists.append(_goods)
365
+ if _extra_lists:
366
+ merged = rrf_merge([merged] + _extra_lists)
367
+
368
+ # 配额记账:整批一次写入文件(同一次搜索的 N 个引擎合并为一次写)。
369
+ # 必须放在 D6 补搜之后:那是最后一个 _ingest 调用点,flush 提前会让
370
+ # 补搜引擎的记账永远落不了盘(2026-09-13 审查实锤)。
371
+ _tock(timing, "fusion", _tk_fusion)
372
+ _tk_dedupe = _tick(timing)
373
+ # ── P0:过滤 SERP/跳转 URL(搜索结果页、baidu.com/link 等不可当信源正文)──
374
+ # 漏斗第 4 格必须**在 SERP 过滤之前**取值。此前取值点在下面 minhash 之后
375
+ # (:366),于是 SERP 丢弃的条目被算进「跨引擎去重」那格的损耗里:
376
+ # 实测 `local_bing` 返回 10 条全是裸 bing.com/search、全部被 SERP 过滤掉,
377
+ # 漏斗报的是 `returned 11 → deduped 0`,把人指向「去重削没了」——而
378
+ # 去重根本无辜(ARGO_MINHASH_DEDUPE=0 时照样归零)。funnel_collapse 会
379
+ # 回答「结果在 deduped 归零 = 被当重复削掉」,那个归因在这里是错的,
380
+ # 而它恰是 0 结果的首要诊断入口。
381
+ _funnel_deduped = len(merged)
382
+ if merged:
383
+ try:
384
+ from serp_guard import is_unusable_url as _is_unusable # SERP + 内容农场,标定见 test_content_farm_guard.py
385
+ merged = [r for r in merged if not _is_unusable(r.get("url", ""), r.get("title", ""), r.get("snippet", ""))]
386
+ except ImportError:
387
+ pass # serp_guard 不可用时跳过(本地五维 rerank 已对 SERP 降权)
388
+
389
+ # ── minhash 近重复去重(结果级,RRF 后 / SERP 后)─────────────────────
390
+ # max_keep 与下方放宽截断同源:去重只需要保证「前 N 条非重复」正确,
391
+ # 因为多出来的条目下一行就被截掉了。不设上限时是 O(结果数²) 的两两比较。
392
+ _pool_limit = _rerank_pool_limit(max_results)
393
+ minhash_removed = 0
394
+ if merged and len(merged) > 1:
395
+ try:
396
+ deduped, minhash_removed = minhash_dedupe(merged, max_keep=_pool_limit)
397
+ merged = deduped
398
+ except Exception as _e:
399
+ _log(f"minhash 去重跳过: {type(_e).__name__}")
400
+ # ── 图片级去重(文本去重之后,图片粒度)─────────────────────────────
401
+ # 上面两道去重都只看页面(URL/标题/正文),对图本身无感知:同一素材在
402
+ # 不同 CDN 上的 URL 不同、标题也可能不同,两道都判不出来——实测
403
+ # 「雪景 壁纸」的结果里存在同一张图占两位的情况。这里按图片直链归一键
404
+ # 补一刀。放在文本去重之后:文本重复量大且比较便宜,先削一批再比图片。
405
+ image_dup_removed = 0
406
+ if merged and len(merged) > 1:
407
+ try:
408
+ from image_ops import deduplicate_images as _dedupe_images
409
+ merged, image_dup_removed = _dedupe_images(merged)
410
+ except Exception as _e:
411
+ _log(f"图片去重跳过: {type(_e).__name__}")
412
+ _tock(timing, "dedupe", _tk_dedupe)
413
+ # 三个子段各自计时,**不能共用一个 tick**:
414
+ # `filter`(否定词 + 时间窗过滤)是纯本地遍历;`recovery` 是**网络调用**
415
+ # (域路由零结果时的救援链,见下);`rerank` 是纯本地重排。此前三者共用
416
+ # 一个 tick 且整个挂在 "rerank" 名下,实测「python 怎么读csv」的 rerank
417
+ # 阶段报 706ms(占全查询 40%),而真正的重排只用 3.6ms——那 700ms 是恢复
418
+ # 链打的一次 anysearch。`--explain-timing` 的全部价值就是回答「瓶颈在哪」,
419
+ # 报错桶等于把人指向错误的优化对象。
420
+ _tk_filter = _tick(timing)
421
+
422
+ # ── P2:多语言语言偏好软排序(属目标语言书写系统的结果前置,软排不删除)──
423
+ # 判据在 search_rank._LANG_SCRIPT 这张表里;此处只问「该语言被追踪吗」。
424
+ # 原先这里是 `_p_lang in ("ja", "ko")`,与 rerank 入口、噪声门构成三处
425
+ # 各自独立的硬编码闸门,于是 zh 被三处同时排除(详见 search_rank 注释)。
426
+ try:
427
+ _p_lang = ((decision or {}).get("features") or {}).get("primary_lang")
428
+ if _p_lang in _LANG_SCRIPT:
429
+ merged = _lang_prefer_rerank(merged, _p_lang)
430
+ except OPT_IMPORT + SHAPE_BENIGN: # 软排序增强,任何失败按「不排序」处理
431
+ pass
432
+
433
+ # 放宽截断:rerank 阶段看到 _pool_limit 条,最终输出再截断 max_results
434
+ merged = merged[:_pool_limit]
435
+
436
+ # ── P0-001:按 exclude_terms 过滤(否定约束)──
437
+ excluded_count = 0
438
+ if merged and exclude_terms:
439
+ kept = []
440
+ low_terms = [t.lower() for t in exclude_terms if t]
441
+ for r in merged:
442
+ hay = f"{r.get('title', '')} {r.get('snippet', '')} {r.get('url', '')}".lower()
443
+ if any(t in hay for t in low_terms):
444
+ excluded_count += 1
445
+ continue
446
+ kept.append(r)
447
+ merged = kept
448
+
449
+ # ── 时间窗结果后过滤保底 ──
450
+ # 仅当组合内含带时间能力引擎时执行:无时间字段引擎的结果没有可滤对象,
451
+ # 跳过遍历省开销;语义上与缓存键隔离保持一致(7d/30d 共享同一缓存内容)。
452
+ time_filtered = 0
453
+ if time_aware and (since_ts is not None or until_ts is not None) and merged:
454
+ merged, time_filtered = _apply_time_window(merged, since_ts, until_ts)
455
+ if time_filtered:
456
+ _log(
457
+ f"时间窗后过滤剔除 {time_filtered} 条(since={since_iso}, until={until_iso})")
458
+ # 漏斗第 5 格:否定词过滤 + 时间窗过滤之后(见 build_funnel)
459
+ _funnel_filtered = len(merged)
460
+ _tock(timing, "filter", _tk_filter)
461
+
462
+ # ── 图片可用性过滤(仅图片检索域)─────────────────────────────────
463
+ # 只在 image_search 这种「用户要的就是图」的查询上启用:普通网页搜索的
464
+ # 结果里也可能带 image_url(正文配图),那里图小不构成丢弃整篇文章的理由。
465
+ # 判据与阈值见 image_ops(分辨率下限、极端长宽比、明确非商用)。
466
+ # 默认只剔「字段层面即可确定不可用」的(无图/过小/极端比例);非商用图
467
+ # 不剔——「搜图看看」和「找能发布的素材」是两个需求,后者由调用方显式
468
+ # 开启。剔除明细进 image_dropped,供诊断「为什么少了 N 条」。
469
+ image_dropped: dict[str, int] = {}
470
+ if merged and domain == "image_search":
471
+ try:
472
+ from image_ops import filter_usable_images as _filter_imgs
473
+ merged, image_dropped = _filter_imgs(merged)
474
+ except Exception as _e:
475
+ _log(f"图片可用性过滤跳过: {type(_e).__name__}")
476
+
477
+ # D5:时间窗空操作告警——用户指定了时间窗,组合内含时间能力引擎,
478
+ # 但结果没有任何 published_at(下推缺失/源端未返回):
479
+ # `--since/--until` 实际未生效(宽松策略保留无时间字段条目,
480
+ # time_filtered 恒 0)。透传 warning 而非静默降级。
481
+ # 组合内不含时间能力引擎时是已知常态,不重复告警。
482
+ time_filter_warning: str | None = None
483
+ if time_aware and (since_ts is not None or until_ts is not None) and merged:
484
+ if not any(r.get("published_at") for r in merged):
485
+ time_filter_warning = (
486
+ f"引擎未返回 published_at,时间窗 {since_iso or '任意'} ~ "
487
+ f"{until_iso or '任意'} 未实际过滤"
488
+ )
489
+
490
+ # ── P0-002:空结果错误恢复决策树 ──
491
+ recovery_info: dict[str, Any] | None = None
492
+ # 复杂度门控:低复杂度查询只允许低成本放宽(L1/L2),
493
+ # 禁用高价多源/跨语言(L3/L4)——简单问题不搞多轮,省 token。
494
+ _max_rec_level: str | None = None
495
+ try:
496
+ from query_enhance import complexity_gate
497
+ if qu is not None and complexity_gate(query, qu) == "low":
498
+ _max_rec_level = "L2"
499
+ except OPT_IMPORT + SHAPE_BENIGN: # 门控判定失败按「不限制放宽档位」处理
500
+ pass
501
+ _tk_recovery = _tick(timing)
502
+ _recovery_engines: set[str] = set()
503
+ # 查询词回声(2026-10-17 实锤):有结果但整批退化时同样要恢复——
504
+ # `if not merged` 只看「有没有结果」,不看「是不是垃圾」。判据与理由见
505
+ # query_signals.is_query_echo(rerank 尚未执行,只能用零网络成本的词面)。
506
+ try:
507
+ from postprocess_signals import echo_recovery_needed as _echo_needed
508
+ _echo_triggered = _echo_needed(merged, query)
509
+ except OPT_IMPORT + SHAPE_BENIGN: # 守卫不可用不设卡(fail-open)
510
+ _echo_triggered = False
511
+ if not merged or _echo_triggered:
512
+ from recovery_runner import run_pipeline_recovery
513
+ _dk = {k: v for k in ("domain", "sub_domain") if (v := getattr(req, "engine_" + k, None))}
514
+ merged, _rec_info = run_pipeline_recovery(
515
+ query=query,
516
+ merged=merged,
517
+ raw_results=raw_results,
518
+ decision=decision,
519
+ engines=engines,
520
+ available_engines_fn=available_engines,
521
+ max_results=max_results,
522
+ max_rec_level=_max_rec_level,
523
+ echo_triggered=_echo_triggered,
524
+ primary_grace_s=float(hooks.primary_grace_s or 0.0),
525
+ timeout=timeout,
526
+ depth=depth,
527
+ mode=mode,
528
+ since_iso=since_iso,
529
+ until_iso=until_iso,
530
+ skip_cache=skip_cache,
531
+ quota_batch=quota_batch,
532
+ engine_search_fn=engine_search,
533
+ emit_usage_fn=_emit_usage,
534
+ recovery_engines_set=_recovery_engines,
535
+ engine_domain_extra=_dk,
536
+ )
537
+ if _rec_info:
538
+ recovery_info = _rec_info
539
+
540
+ # 配额记账落盘:整批一次写入文件(同一次搜索的 N 个引擎合并为一次写)。
541
+ # 位置是契约:必须在**最后一个 quota add 点**之后。D6 补搜走后,
542
+ # 恢复链(_recovery_executor 里同样 quota_batch.add)是最后一个——
543
+ # 它走的不是 dispatch 的 _run_one(全仓唯一的记账入口),不单独补一行
544
+ # add,恢复段消耗的远端额度就不进本地账本:get_remaining_ratio 高估
545
+ # 剩余 → 路由继续派发 → 上游 429/10406,而配额状态机看不到任何迹象
546
+ # (2026-09-27 审查实锤,与「配额自愈闭环」的设计目标直接矛盾)。
547
+ quota_batch.flush()
548
+
549
+ # Reranker:ARGO_LOCAL_RERANK 开关(0 关闭本地五维保底;默认 1 开启)
550
+ _tock(timing, "recovery", _tk_recovery)
551
+ _tk_rerank = _tick(timing)
552
+ local_rerank_on = env_flag("ARGO_LOCAL_RERANK")
553
+ reranker_status = "skipped_short"
554
+ rank_method = "none"
555
+ if mode == "fast" or depth == "fast":
556
+ reranker_status = "skipped_fast"
557
+ elif merged and len(merged) > 1:
558
+ # 全量重排(top_n=len),由最终输出统一截断 max_results
559
+ merged, reranker_status = rerank_results(query, merged, top_n=len(merged))
560
+ if reranker_status == "ok":
561
+ rank_method = "bocha"
562
+
563
+ # 本地五维 rerank 保底:受 ARGO_LOCAL_RERANK 开关控制(可观测 rank_method)。
564
+ # 触发计算方式走 _RERANK_DEGRADED_STATUSES 唯一来源:新增「bocha 未出排序」的
565
+ # 状态时只改那一处,不会漏掉这里的保底(漏了就是既不精排也不保底)。
566
+ if local_rerank_on and merged and len(merged) > 1 and \
567
+ reranker_status in _RERANK_DEGRADED_STATUSES:
568
+ try:
569
+ # top_n=max_results(而非 len(merged)):下游 _apply_consensus_and_sort
570
+ # 紧接着就按 max_results 截断,尾部不会有人用。传真实 K 才能启用
571
+ # 函数内的 K 相关剪枝(可证明前 K 个选序不变),n=200 时把 O(n²)
572
+ # 贪心从 ~33ms 压到毫秒级。
573
+ merged = local_five_dim_rerank(query, merged, domain=domain,
574
+ top_n=max_results)
575
+ rank_method = "local_five_dim"
576
+ except Exception as e:
577
+ _log(
578
+ f"本地五维 rerank 跳过: {type(e).__name__}")
579
+ # TF-IDF 语义抛光 + none 兜底(方案 A;实现与开关在 postprocess_signals)。
580
+ # 2026-10-16 提取:特性钩子各带 try/except 与开关,留在主管线会把它顶破
581
+ # 模块体积门禁(1070 > 上限 1033),且放大主管线的异常面。ran=False
582
+ # 覆盖「开关关 / fast 档 / 结果不足 / 执行失败」——无论哪种本轮排序都
583
+ # 没有 TF-IDF 贡献,none 兜底都应生效(含 rerank_tfidf 抛异常的边缘态,
584
+ # 此前该态会被误留 stale rank_method)。
585
+ merged, rank_method, _tfidf_ran = apply_tfidf_polish(
586
+ query, merged, max_results, mode, reranker_status, rank_method, _log)
587
+ if not local_rerank_on and reranker_status in _RERANK_DEGRADED_STATUSES \
588
+ and not _tfidf_ran:
589
+ rank_method = "none"
590
+
591
+ _tock(timing, "rerank", _tk_rerank)
592
+ _tk_signals = _tick(timing)
593
+
594
+ # ── site:/-site: 后置过滤(query_operators 契约)──
595
+ # 放在恢复链与精排之后、截断之前:既不会把「站点化结果为空」误当
596
+ # 0 结果触发一轮白跑的恢复链,也保证截断后仍有 max_results 条。
597
+ site_filtered = 0
598
+ _site_ops = (decision or {}).get("operators") or {}
599
+ _site_include = list(_site_ops.get("sites") or [])
600
+ _site_exclude = list(_site_ops.get("exclude_sites") or [])
601
+ if merged and (_site_include or _site_exclude):
602
+ try:
603
+ from query_operators import filter_results_by_sites
604
+ merged, site_filtered = filter_results_by_sites(
605
+ merged, _site_include, _site_exclude)
606
+ except OPT_IMPORT + SHAPE_BENIGN:
607
+ # 过滤器不可用不拦:保持原始结果,比静默清空安全。
608
+ site_filtered = 0
609
+
610
+ if merged:
611
+ merged = _apply_consensus_and_sort(merged, max_results)
612
+
613
+ _attach_selection_signals(merged, mode, depth)
614
+
615
+ # ── P0-004:关键事实交叉标记(仅 deep/auto 且结果 ≥3;fast 跳过)──
616
+ fact_alignment: dict[str, Any] | None = _align_facts_safe(merged, mode, depth)
617
+
618
+ # 数字交叉验证信号(方案 A;实现与开关在 postprocess_signals)。
619
+ # 只 deep/auto 且结果 ≥3 时启用(函数内部门控),前 10 条做 O(n) 正则
620
+ # 对比(<1ms);有分歧时 values[i] 与 sources[i] 同序配对,进 payload
621
+ # 供调用方决定是否 fetch 原文核对。
622
+ number_conflict = detect_number_conflict(query, merged, mode, depth, _log)
623
+
624
+ _tock(timing, "signals", _tk_signals)
625
+ _tk_cache_write = _tick(timing)
626
+
627
+ if on_progress:
628
+ on_progress(Stage.MERGING, {"count": len(merged)})
629
+
630
+ # 阶段漏斗账(见 build_funnel)。在写缓存之前算一次、两处消费:
631
+ # 既进 result_payload(缓存命中时它随存档一起返回,否则命中路径的漏斗会
632
+ # 缺席——而「缺席」比数字更容易被误读成「没数据」),也进本次输出。
633
+ # 此处 len(merged) 已等于最终条数:_apply_consensus_and_sort 在上一步已按
634
+ # max_results 截断,而 _sort_results 只重排、不改长度。
635
+ funnel = build_funnel(
636
+ len(engines),
637
+ # 「实际发起调用」= 编排层跑过的引擎 ∪ 恢复链补调的引擎。
638
+ # 此前只数 engine_outcomes/raw_results,恢复段走的不是 dispatch、
639
+ # 两条都不进 → 实测「python 怎么读csv」报出 routed 2 → called 2 →
640
+ # returned 5:两个引擎交出了 5 条结果,而真凶(恢复链打的 anysearch)
641
+ # 在漏斗里根本不存在。漏斗的全部用途就是定位塌陷点,一个会漏掉真凶的
642
+ # 漏斗比没有更危险。
643
+ len({o.get("engine") for o in engine_outcomes if o.get("engine")}
644
+ | set(raw_results) | _recovery_engines),
645
+ sum(1 for items in raw_results.values() if isinstance(items, list)
646
+ for it in items
647
+ if isinstance(it, dict) and "error" not in it),
648
+ _funnel_deduped, _funnel_filtered, len(merged),
649
+ )
650
+
651
+ result_payload = {
652
+ "results": merged,
653
+ "engines_used": list(raw_results.keys()),
654
+ # 产出这批结果的**那次运行**的组合与理由,随载荷一起存。
655
+ #
656
+ # 缓存键改成请求身份后,同一查询的两次运行可以路由到不同组合,而命中
657
+ # 时本次路由根本没执行。不存这两个字段的话,命中响应只能报本次路由,
658
+ # 那是一个没跑过的计划,还会与同样来自缓存的 engines_used /
659
+ # engine_outcomes 自相矛盾(2026-09-19)。约 100 字节/条,换来缓存
660
+ # 条目自描述。旧条目没有这两个键,命中路径回退到本次路由(原行为)。
661
+ "engines": list(engines),
662
+ "engines_combo": list(engines_combo),
663
+ "route_reason": decision.get("reason"),
664
+ "domain": domain,
665
+ "engine_outcomes": engine_outcomes,
666
+ "time_filtered": time_filtered,
667
+ "time_filter_warning": time_filter_warning,
668
+ "noise_dropped": _noise_dropped,
669
+ "funnel": funnel,
670
+ }
671
+ if _site_include or _site_exclude:
672
+ result_payload["site_filter"] = {
673
+ "sites": _site_include, "exclude_sites": _site_exclude,
674
+ }
675
+ result_payload["site_filtered"] = site_filtered
676
+ result_payload.update(variant_recalled=_variant_recalled, phrase_dropped=_phrase_dropped) # 观测补账:均不在漏斗 returned 口径内
677
+
678
+ return replace(
679
+ run,
680
+ merged=merged,
681
+ minhash_removed=minhash_removed,
682
+ image_dup_removed=image_dup_removed,
683
+ image_dropped=image_dropped,
684
+ excluded_count=excluded_count,
685
+ time_filtered=time_filtered,
686
+ time_filter_warning=time_filter_warning,
687
+ recovery_info=recovery_info,
688
+ rank_method=rank_method,
689
+ reranker_status=reranker_status,
690
+ local_rerank_on=local_rerank_on,
691
+ fact_alignment=fact_alignment,
692
+ number_conflict=number_conflict,
693
+ noise_dropped=_noise_dropped,
694
+ funnel=funnel,
695
+ result_payload=result_payload,
696
+ )
697
+
698
+ def finalize(req: _SearchRequest, run: _SearchRun, hooks: Any) -> dict[str, Any]:
699
+ """收尾:写缓存 → 自适应记账 → 语言偏好 → 装配对外响应。
700
+
701
+ 与 postprocess 的分工:那里决定「结果是什么」,这里决定「怎么对外说」
702
+ (缓存写入、学习器记账、漏斗/计时/信源的字段形态)。签名同样只吃两个
703
+ 状态对象——execute_search 里不再有一串需要手工搬运的局部量。
704
+
705
+ hooks 只用于 `get_cost_factor`:测试靠 patch 它把成本系数钉成常数,
706
+ 按值传入才能让补丁生效(见 search_entry._SearchHooks)。
707
+ """
708
+ get_cost_factor = hooks.get_cost_factor
709
+ query = req.query
710
+ decision = req.decision
711
+ engines = req.engines
712
+ engines_combo = req.engines_combo
713
+ domain = req.domain
714
+ mode, depth = req.mode, req.depth
715
+ max_results = req.max_results
716
+ timing, on_progress = req.timing, req.on_progress
717
+ sort, cache = req.sort, req.cache
718
+ engine_label, cache_engine_key = req.engine_label, req.cache_engine_key
719
+ skip_cache = req.skip_cache
720
+ merged = run.merged
721
+ raw_results = run.raw_results
722
+ engine_outcomes = run.engine_outcomes
723
+ engine_latency = run.engine_latency
724
+ minhash_removed = run.minhash_removed
725
+ image_dup_removed = run.image_dup_removed
726
+ image_dropped = run.image_dropped
727
+ excluded_count = run.excluded_count
728
+ time_filtered = run.time_filtered
729
+ time_filter_warning = run.time_filter_warning
730
+ recovery_info = run.recovery_info
731
+ rank_method = run.rank_method
732
+ reranker_status = run.reranker_status
733
+ local_rerank_on = run.local_rerank_on
734
+ fact_alignment = run.fact_alignment
735
+ number_conflict = run.number_conflict
736
+ _noise_dropped = run.noise_dropped
737
+ funnel = run.funnel
738
+ result_payload = run.result_payload
739
+ wasted_ms, useful_ms = run.wasted_ms, run.useful_ms
740
+ dispatch_tail = run.dispatch_tail
741
+ early_stopped = run.early_stopped
742
+ budget_used_ms, budget_total_ms = run.budget_used_ms, run.budget_total_ms
743
+ elapsed = run.elapsed
744
+ exclude_terms = req.exclude_terms
745
+ _tk_cache_write = _tick(timing)
746
+ # 写 combo 缓存:空结果短 TTL / 时效 cap 由 cache.set 处理。
747
+ #
748
+ # 守卫拒绝(登录态 / 上游退化)只该取消**这次写入**,不该取消这次查询。
749
+ # 2026-09-27 实锤:`Apple Inc 10-K annual report 2025` 命中 sec_edgar
750
+ # 拿到 Apple 的 CIK 归档页(1 条相关 + 4 条全文检索噪音),批内多数
751
+ # relevance=0 触发退化守卫,异常穿透到 CLI 顶层——traceback 退出,已检索
752
+ # 到的结果连同响应一起丢弃。守卫的意图是「别固化」而不是「别返回」。
753
+ cache_write_skip: str | None = None
754
+ if not skip_cache:
755
+ effective_ttl = None
756
+ if merged and elapsed > 2000:
757
+ # 慢查询略延长缓存:省的是「同一查询再付一次慢网」的钱。
758
+ effective_ttl = _slow_query_ttl(cache.resolve_ttl(domain, query=query),
759
+ elapsed)
760
+ cache_write_skip = attempt_cache_write(
761
+ lambda: cache.set(
762
+ query, cache_engine_key, max_results, result_payload,
763
+ domain=domain, ttl=effective_ttl, mode=mode, depth=depth,
764
+ # 恢复成功的批次标记给写侧:整批退化时降级短 TTL 而非硬拒
765
+ # (硬拒会让重复查询重付 dispatch+恢复链全程,cache.set 注释)。
766
+ recovered=bool((recovery_info or {}).get("recovered")),
767
+ ),
768
+ context="finalize.cache_set",
769
+ )
770
+ _tock(timing, "cache_write", _tk_cache_write)
771
+
772
+ # 自适应学习(单事务批量写入 + cache_guard 统一状态态过滤)
773
+ try:
774
+ import contextlib
775
+ from adaptive import get_learner
776
+ learner = get_learner()
777
+ _outcome_of = {o.get("engine"): o
778
+ for o in engine_outcomes if isinstance(o, dict)}
779
+ _batch_ctx = learner.batch() if hasattr(learner, "batch") else contextlib.nullcontext()
780
+ with _batch_ctx:
781
+ for eng, res in raw_results.items():
782
+ outcome = _outcome_of.get(eng)
783
+ if not should_record_adaptive(outcome, res):
784
+ continue
785
+ st = (outcome or {}).get("status")
786
+ success = bool(res and any(isinstance(r, dict) and "error" not in r for r in res))
787
+ # 空结果说明查询与源不匹配而非源故障,单独统计不拉低成功率(见 adaptive.get_score)。
788
+ empty = (not success) and st in ("no-results", "no-results-cached")
789
+ latency = engine_latency.get(eng, elapsed / max(len(raw_results), 1))
790
+ cost = get_cost_factor(eng)
791
+ quality = None
792
+ if success:
793
+ try:
794
+ from query_signals import result_relevance
795
+ quality = result_relevance(query, res)
796
+ except Exception:
797
+ quality = None
798
+ learner.record(eng, success=success, latency_ms=latency,
799
+ cost=0.0 if cost >= 0.85 else 0.001, empty=empty,
800
+ quality=quality)
801
+ except ImportError:
802
+ pass
803
+ except Exception as e:
804
+ _log(f"自适应学习记录跳过: {type(e).__name__}")
805
+
806
+ # 语言偏好:记录本轮查询语 + 输出观测快照(默认中英 + 系统 + 习惯)
807
+ lang_pref_info: dict[str, Any] | None = None
808
+ try:
809
+ from lang_pref import record_query_lang, lang_pref_snapshot
810
+ feats = decision.get("features") or {}
811
+ q_lang = feats.get("primary_lang") or ""
812
+ if not q_lang:
813
+ try:
814
+ from lang_detect import detect_language
815
+ q_lang = detect_language(query)
816
+ except ImportError:
817
+ q_lang = ""
818
+ if q_lang:
819
+ record_query_lang(q_lang)
820
+ lang_pref_info = lang_pref_snapshot(query_lang=q_lang)
821
+ except ImportError:
822
+ pass
823
+ except Exception as e:
824
+ _log(
825
+ f"语言偏好记录跳过: {type(e).__name__}")
826
+
827
+ if on_progress:
828
+ on_progress(Stage.DONE, {"count": len(merged), "elapsed_ms": elapsed})
829
+
830
+ # 使用日志:每次非缓存搜索一条总账(stats 命令的数据源)。query 截断 60
831
+ # 字符(与 recovery 流同一脱敏纪律);记录失败静默,绝不拖累主路径。
832
+ # 取自 req 而非局部别名:_emit_usage 是 postprocess 的局部变量,
833
+ # 本函数(finalize)不在其作用域内,直接引用会 NameError。
834
+ if req.emit_usage_log is not None and not is_synthetic_run(raw_results):
835
+ try:
836
+ # 归因走 search_attribution;合成引擎(基准/混沌)整条不入日志。
837
+ req.emit_usage_log("query", {
838
+ "query": (query[:60] if query else query),
839
+ "count": len(merged),
840
+ "elapsed_ms": round(elapsed),
841
+ "engines_used": list(raw_results.keys())[:8],
842
+ **usage_log_attribution(engine_latency, engine_outcomes=engine_outcomes),
843
+ "errors": len(_collect_errors(raw_results, engine_outcomes)),
844
+ "recovered": bool(recovery_info.get("recovered")) if recovery_info else False,
845
+ "early_stopped": bool(early_stopped),
846
+ "depth": depth, "mode": mode, "domain": domain,
847
+ })
848
+ except SHAPE_BENIGN:
849
+ pass
850
+
851
+ tfidf_scores = decision.get("tfidf_scores", [])
852
+ if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
853
+ tfidf_scores = []
854
+
855
+ # 排序在返回前、写缓存后:缓存内容保持 score 序(缓存键/内容不受 sort 影响),
856
+ # sort 只改变本次展示顺序;缓存命中路径在 return 前同样处理,两路径行为一致。
857
+ out_results = _sort_results(merged, sort)
858
+
859
+ out: dict[str, Any] = {
860
+ "query": query, "engine": engine_label, "engines": engines,
861
+ "engines_combo": engines_combo, "cached": False,
862
+ "domain": domain, "elapsed_ms": elapsed,
863
+ "tfidf_scores": tfidf_scores,
864
+ "route_reason": decision.get("reason"),
865
+ "results": out_results,
866
+ "count": len(out_results), "engines_used": list(raw_results.keys()),
867
+ "errors": _collect_errors(raw_results, engine_outcomes),
868
+ "engine_outcomes": engine_outcomes,
869
+ # 阶段漏斗账:0 结果时用它定位塌在哪一层(见 build_funnel)
870
+ "funnel": funnel,
871
+ # 多语言噪声门:被剔除的引擎及原因(可观测,便于定位「为什么少了几个源」)
872
+ "noise_dropped": _noise_dropped,
873
+ "wasted_engine_ms": wasted_ms,
874
+ "early_stopped": early_stopped,
875
+ "reranker": reranker_status,
876
+ "rank_method": rank_method,
877
+ "minhash_removed": minhash_removed,
878
+ "image_dup_removed": image_dup_removed,
879
+ "image_dropped": image_dropped or None,
880
+ "local_rerank_on": local_rerank_on,
881
+ "recovery": recovery_info,
882
+ "fact_alignment": fact_alignment,
883
+ "number_conflict": number_conflict,
884
+ "exclude_terms": exclude_terms,
885
+ "excluded_count": excluded_count,
886
+ "time_filtered": time_filtered,
887
+ "time_filter_warning": time_filter_warning,
888
+ # 缓存被写入守卫拒绝时记下类别名(如 DegradedCacheRejected)。
889
+ # 这次结果照常返回,但下次不会命中缓存——没有这个字段,使用者只会
890
+ # 看到「同一个查询每次都很慢」,且无从知道原因是守卫在拒写。
891
+ "cache_write_skip": cache_write_skip,
892
+ "mode": mode, "depth": depth,
893
+ "login_hint": decision.get("login_hint"),
894
+ }
895
+ # site:/-site: 过滤账(随缓存一起回放,命中时也能说明「为什么少了 N 条」)。
896
+ if result_payload.get("site_filter") is not None:
897
+ out["site_filter"] = result_payload["site_filter"]
898
+ out["site_filtered"] = result_payload.get("site_filtered", 0)
899
+ if lang_pref_info is not None:
900
+ out["lang_pref"] = lang_pref_info
901
+ if timing is not None:
902
+ # 并发效率:dispatch 是墙钟,engine_latency 之和是各引擎各自耗时。
903
+ # 比值远小于引擎数说明并发没排满(或某个慢源独占尾部),是判断
904
+ # 「该加并发还是该摘慢源」的直接依据。
905
+ eng_sum = sum(engine_latency.values())
906
+ out["timing"] = timing.summary()
907
+ out["timing"]["elapsed_ms"] = elapsed
908
+ out["timing"]["dispatch"] = {
909
+ # 墙钟/尾等待/弃置的口径真源见 dispatch_result.py;useful + wasted 与
910
+ # wall 没有恒等式(2026-10-10 起),不要再按恒等式读仪表。
911
+ "wall_ms": budget_used_ms,
912
+ "engines_run": len(engine_latency),
913
+ "engine_sum_ms": eng_sum,
914
+ "parallel_efficiency": (round(eng_sum / budget_used_ms, 2)
915
+ if budget_used_ms else None),
916
+ "useful_ms": useful_ms,
917
+ "wasted_ms": wasted_ms,
918
+ **dispatch_tail,
919
+ "early_stopped": early_stopped,
920
+ }
921
+ if budget_total_ms is not None:
922
+ # 预算消耗额:fast/auto 有总预算,deep 无(键缺席即「无预算」)。
923
+ # timing 在 agent 档保留,预算可见性随答案一起到达。
924
+ out["timing"]["budget"] = {"used_ms": budget_used_ms,
925
+ "total_ms": budget_total_ms}
926
+ return out