argo-search 1.0.1 → 2.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (463) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +822 -0
  3. package/README.es.md +864 -0
  4. package/README.ja.md +864 -0
  5. package/README.ko.md +864 -0
  6. package/README.md +867 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/settings-keys.png +0 -0
  12. package/assets/readme/settings-tools.png +0 -0
  13. package/assets/readme/why-better.svg +104 -0
  14. package/assets/readme/workflow.svg +83 -0
  15. package/backends/domain_profiles.json +2308 -96
  16. package/backends/engine_registry.yaml +3314 -503
  17. package/backends/query_synonyms_cn.json +35 -0
  18. package/backends/quota_profiles.json +2409 -161
  19. package/backends/source_types_cn.json +83 -0
  20. package/backends/text_lexicon.json +44 -0
  21. package/bin/argo +703 -0
  22. package/bin/argo.js +71 -39
  23. package/config.yaml +4976 -363
  24. package/cordis.patch.yml +21 -0
  25. package/data/countries.json +40 -0
  26. package/data/lang_matrix/lang_capability.json +842 -0
  27. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  28. package/data/regions_cn.json +1 -0
  29. package/docs/ADDING_NEW_ENGINE.md +184 -0
  30. package/docs/ARGO_INTRO.md +124 -0
  31. package/docs/DATA_SOURCE_GAPS.md +81 -0
  32. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  33. package/docs/ENGINE_CATALOG.md +564 -0
  34. package/docs/MCP_SETUP.md +106 -0
  35. package/docs/PLATFORM_PATHS.md +111 -0
  36. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  37. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  38. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  39. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  40. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  41. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  42. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  43. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  44. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  45. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  46. package/docs/SEARCH_ARCHIVE.md +115 -0
  47. package/docs/assets/architecture.svg +104 -0
  48. package/docs/assets/hero.svg +98 -0
  49. package/docs/engines/bangumi.md +27 -0
  50. package/docs/engines/biorxiv.md +27 -0
  51. package/docs/engines/clawhub.md +27 -0
  52. package/docs/engines/crt_sh.md +27 -0
  53. package/docs/engines/datacite.md +27 -0
  54. package/docs/engines/deps_dev.md +27 -0
  55. package/docs/engines/douban_movie.md +27 -0
  56. package/docs/engines/endoflife.md +27 -0
  57. package/docs/engines/flk_law.md +27 -0
  58. package/docs/engines/fxtwitter.md +27 -0
  59. package/docs/engines/google_news.md +27 -0
  60. package/docs/engines/gov_regulations.md +27 -0
  61. package/docs/engines/guqian.md +27 -0
  62. package/docs/engines/hackernews.md +27 -0
  63. package/docs/engines/iplant.md +27 -0
  64. package/docs/engines/keenable.md +27 -0
  65. package/docs/engines/met_museum.md +27 -0
  66. package/docs/engines/nasa_images.md +27 -0
  67. package/docs/engines/netease_music.md +27 -0
  68. package/docs/engines/npm.md +27 -0
  69. package/docs/engines/nvd.md +27 -0
  70. package/docs/engines/openreview.md +27 -0
  71. package/docs/engines/openstd.md +27 -0
  72. package/docs/engines/parallel_free.md +27 -0
  73. package/docs/engines/people_daily.md +27 -0
  74. package/docs/engines/realtime_index.md +27 -0
  75. package/docs/engines/sec_edgar.md +27 -0
  76. package/docs/engines/seltz.md +27 -0
  77. package/docs/engines/skillsmp.md +27 -0
  78. package/docs/engines/sspai.md +27 -0
  79. package/docs/engines/std_samr.md +27 -0
  80. package/docs/engines/train.md +27 -0
  81. package/docs/engines/un_comtrade.md +27 -0
  82. package/docs/engines/wikisource.md +27 -0
  83. package/docs/engines/zdic.md +27 -0
  84. package/docs/engines/zenodo.md +27 -0
  85. package/docs/engines/zhihu_hot_app.md +27 -0
  86. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  87. package/engines/README.md +10 -0
  88. package/engines/_template_cli.yaml +39 -0
  89. package/engines/_template_http.yaml +37 -0
  90. package/engines/plugins/_template_plugin.py +50 -0
  91. package/engines/specs/_cisa_kev.yaml +54 -0
  92. package/engines/specs/_osv.yaml +63 -0
  93. package/engines/specs/aljazeera.yaml +43 -0
  94. package/engines/specs/antara.yaml +43 -0
  95. package/engines/specs/artic.yaml +20 -0
  96. package/engines/specs/aviation_weather.yaml +39 -0
  97. package/engines/specs/bangumi.yaml +21 -0
  98. package/engines/specs/biorxiv.yaml +24 -0
  99. package/engines/specs/carbon_intensity.yaml +32 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/deps_dev.yaml +21 -0
  113. package/engines/specs/digup.yaml +36 -0
  114. package/engines/specs/doi.yaml +39 -0
  115. package/engines/specs/douban_movie.yaml +22 -0
  116. package/engines/specs/dw_news.yaml +47 -0
  117. package/engines/specs/egov_law.yaml +21 -0
  118. package/engines/specs/elpais.yaml +43 -0
  119. package/engines/specs/endoflife.yaml +21 -0
  120. package/engines/specs/energy_charts.yaml +21 -0
  121. package/engines/specs/factcheck_org.yaml +52 -0
  122. package/engines/specs/faz.yaml +43 -0
  123. package/engines/specs/federal_register.yaml +57 -0
  124. package/engines/specs/firecrawl.yaml +53 -0
  125. package/engines/specs/flk_law.yaml +24 -0
  126. package/engines/specs/folha.yaml +43 -0
  127. package/engines/specs/france24.yaml +44 -0
  128. package/engines/specs/full_fact.yaml +47 -0
  129. package/engines/specs/fxtwitter.yaml +31 -0
  130. package/engines/specs/gbfs_nyc.yaml +32 -0
  131. package/engines/specs/gdacs.yaml +21 -0
  132. package/engines/specs/gdelt.yaml +64 -0
  133. package/engines/specs/google_news.yaml +22 -0
  134. package/engines/specs/gov_regulations.yaml +38 -0
  135. package/engines/specs/guardian_rss.yaml +59 -0
  136. package/engines/specs/hurriyet.yaml +43 -0
  137. package/engines/specs/iplant.yaml +25 -0
  138. package/engines/specs/jikan.yaml +24 -0
  139. package/engines/specs/jolpica.yaml +20 -0
  140. package/engines/specs/k10plus.yaml +20 -0
  141. package/engines/specs/keenable.yaml +44 -0
  142. package/engines/specs/lefigaro.yaml +43 -0
  143. package/engines/specs/listenbrainz.yaml +19 -0
  144. package/engines/specs/matichon.yaml +43 -0
  145. package/engines/specs/met_museum.yaml +21 -0
  146. package/engines/specs/nasa_images.yaml +45 -0
  147. package/engines/specs/netease_music.yaml +38 -0
  148. package/engines/specs/nhk.yaml +43 -0
  149. package/engines/specs/nhtsa_vpic.yaml +20 -0
  150. package/engines/specs/noaa_swpc.yaml +20 -0
  151. package/engines/specs/nvd.yaml +38 -0
  152. package/engines/specs/obis.yaml +20 -0
  153. package/engines/specs/opencitations.yaml +52 -0
  154. package/engines/specs/openf1.yaml +21 -0
  155. package/engines/specs/openfoodfacts.yaml +45 -0
  156. package/engines/specs/openligadb.yaml +20 -0
  157. package/engines/specs/openreview.yaml +37 -0
  158. package/engines/specs/openstd.yaml +21 -0
  159. package/engines/specs/people_daily.yaml +22 -0
  160. package/engines/specs/realtime_index.yaml +34 -0
  161. package/engines/specs/ror.yaml +20 -0
  162. package/engines/specs/satnogs.yaml +20 -0
  163. package/engines/specs/sec_edgar.yaml +46 -0
  164. package/engines/specs/skillsmp.yaml +50 -0
  165. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  166. package/engines/specs/soilgrids.yaml +25 -0
  167. package/engines/specs/sspai.yaml +36 -0
  168. package/engines/specs/stackexchange.yaml +46 -0
  169. package/engines/specs/std_samr.yaml +21 -0
  170. package/engines/specs/tass.yaml +43 -0
  171. package/engines/specs/tle_mirror.yaml +21 -0
  172. package/engines/specs/train.yaml +37 -0
  173. package/engines/specs/tvmaze.yaml +20 -0
  174. package/engines/specs/twitter_syndication.yaml +31 -0
  175. package/engines/specs/un_comtrade.yaml +23 -0
  176. package/engines/specs/unpaywall.yaml +54 -0
  177. package/engines/specs/vnpress.yaml +43 -0
  178. package/engines/specs/weather.yaml +32 -0
  179. package/engines/specs/who_don.yaml +21 -0
  180. package/engines/specs/who_gho.yaml +20 -0
  181. package/engines/specs/wikimedia_commons.yaml +34 -0
  182. package/engines/specs/wikisource.yaml +22 -0
  183. package/engines/specs/worms.yaml +19 -0
  184. package/engines/specs/x_trending.yaml +34 -0
  185. package/engines/specs/yna.yaml +43 -0
  186. package/engines/specs/ynet.yaml +43 -0
  187. package/engines/specs/zbmath.yaml +40 -0
  188. package/engines/specs/zdic.yaml +22 -0
  189. package/engines/specs/zenodo.yaml +27 -0
  190. package/engines/specs/zhihu_hot_app.yaml +35 -0
  191. package/mcp/clients.yaml +74 -0
  192. package/package.json +51 -12
  193. package/packages/dsh-plugin/README.md +134 -0
  194. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  195. package/packages/dsh-plugin/dsh/client.js +1244 -0
  196. package/packages/dsh-plugin/dsh/index.js +2290 -0
  197. package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
  198. package/packages/dsh-plugin/package.json +38 -0
  199. package/packages/dsh-plugin/test/client.test.js +232 -0
  200. package/packages/dsh-plugin/test/config.test.js +30 -0
  201. package/packages/dsh-plugin/test/index.test.js +1338 -0
  202. package/references/query-templates.md +96 -0
  203. package/references/query-templates.yaml +118 -0
  204. package/scripts/ab_eval.py +219 -0
  205. package/scripts/ab_eval_p0p1.py +266 -0
  206. package/scripts/adaptive.py +271 -70
  207. package/scripts/answer.py +154 -0
  208. package/scripts/archive_run.py +591 -0
  209. package/scripts/argo_engine_registry.py +172 -0
  210. package/scripts/argo_paths.py +728 -0
  211. package/scripts/article.py +136 -0
  212. package/scripts/arxiv_source.py +201 -0
  213. package/scripts/badcase_ledger.py +252 -0
  214. package/scripts/batch_probe.py +241 -0
  215. package/scripts/benchmark.py +19 -0
  216. package/scripts/bounded_run.py +144 -0
  217. package/scripts/browser_auth.py +323 -0
  218. package/scripts/burstiness_detector.py +147 -0
  219. package/scripts/cache.py +1078 -67
  220. package/scripts/cache_guard.py +271 -0
  221. package/scripts/cache_key_vdom.py +56 -0
  222. package/scripts/cache_similarity.py +170 -0
  223. package/scripts/caliber_isolation.py +195 -0
  224. package/scripts/calibrate_lowquality.py +210 -0
  225. package/scripts/candidate_envelope.py +366 -0
  226. package/scripts/chrome_cdp.py +871 -0
  227. package/scripts/circuit_breaker.py +389 -0
  228. package/scripts/citations.py +334 -0
  229. package/scripts/cite_cli.py +59 -0
  230. package/scripts/cjk_tokens.py +95 -0
  231. package/scripts/clarify.py +47 -24
  232. package/scripts/cli_io.py +240 -0
  233. package/scripts/config.py +683 -45
  234. package/scripts/config_cache.py +402 -0
  235. package/scripts/conn_pool.py +220 -0
  236. package/scripts/content_security.py +685 -0
  237. package/scripts/content_signals.py +730 -0
  238. package/scripts/crawl.py +74 -35
  239. package/scripts/daemon.py +866 -0
  240. package/scripts/digup_bridge.py +214 -0
  241. package/scripts/engine_admission.py +366 -0
  242. package/scripts/engine_dispatch.py +1019 -0
  243. package/scripts/engine_env.py +534 -0
  244. package/scripts/engine_failure.py +411 -0
  245. package/scripts/engine_families.py +732 -0
  246. package/scripts/engine_policy.py +218 -0
  247. package/scripts/engine_requires.py +190 -0
  248. package/scripts/engine_status.py +322 -0
  249. package/scripts/engine_validate.py +638 -0
  250. package/scripts/engines.py +778 -294
  251. package/scripts/engines_base.py +1521 -0
  252. package/scripts/engines_builders.py +298 -0
  253. package/scripts/engines_builders_batch10.py +159 -0
  254. package/scripts/engines_builders_batch11.py +174 -0
  255. package/scripts/engines_builders_batch12.py +103 -0
  256. package/scripts/engines_builders_batch13.py +340 -0
  257. package/scripts/engines_builders_batch9.py +1468 -0
  258. package/scripts/engines_builders_cn.py +2283 -0
  259. package/scripts/engines_builders_data.py +2923 -0
  260. package/scripts/engines_builders_data_macro.py +775 -0
  261. package/scripts/engines_builders_feeds.py +255 -0
  262. package/scripts/engines_builders_intl.py +1239 -0
  263. package/scripts/engines_builders_search.py +440 -0
  264. package/scripts/engines_builders_tech.py +1360 -0
  265. package/scripts/evidence.py +588 -162
  266. package/scripts/evidence_loop.py +592 -0
  267. package/scripts/evidence_tier.py +298 -0
  268. package/scripts/except_sets.py +25 -0
  269. package/scripts/extract.py +55 -16
  270. package/scripts/fact_align.py +207 -0
  271. package/scripts/fact_crosscheck.py +230 -0
  272. package/scripts/fetch.py +25 -12
  273. package/scripts/fetch_browser.py +87 -0
  274. package/scripts/fetch_jsrun.py +122 -0
  275. package/scripts/fetch_quality.py +225 -0
  276. package/scripts/fetch_render_tinyfish.py +117 -0
  277. package/scripts/fetch_v3.py +1779 -0
  278. package/scripts/focus_extract.py +350 -0
  279. package/scripts/fulltext_store.py +169 -0
  280. package/scripts/fxtwitter_api.py +325 -0
  281. package/scripts/gen_engine_catalog.py +364 -0
  282. package/scripts/gen_native_tools.py +91 -0
  283. package/scripts/gen_routing_snapshot.py +184 -0
  284. package/scripts/health_check.py +140 -402
  285. package/scripts/health_probe.py +23 -8
  286. package/scripts/hot_state.py +134 -0
  287. package/scripts/http_client.py +1053 -0
  288. package/scripts/image_ops.py +430 -0
  289. package/scripts/image_vision/vision_probe +0 -0
  290. package/scripts/image_vision/vision_probe.swift +126 -0
  291. package/scripts/install.ps1 +148 -0
  292. package/scripts/install.sh +206 -0
  293. package/scripts/job.py +1186 -0
  294. package/scripts/jump_resolver.py +235 -0
  295. package/scripts/lang_capability.py +150 -0
  296. package/scripts/lang_detect.py +289 -0
  297. package/scripts/lang_matrix_probe.py +329 -0
  298. package/scripts/lang_pref.py +311 -0
  299. package/scripts/link_source.py +340 -0
  300. package/scripts/local_engines.py +321 -0
  301. package/scripts/local_image.py +970 -0
  302. package/scripts/local_seek.py +366 -0
  303. package/scripts/macro_countries.py +82 -0
  304. package/scripts/matrix_search_eval.py +1105 -0
  305. package/scripts/mcp_diag.py +8 -3
  306. package/scripts/mcp_handlers.py +1074 -0
  307. package/scripts/mcp_handlers_surface.py +157 -0
  308. package/scripts/mcp_launch.sh +122 -0
  309. package/scripts/mcp_server.py +118 -417
  310. package/scripts/mcp_setup.py +407 -0
  311. package/scripts/mcp_tools.py +330 -0
  312. package/scripts/mcp_transport.py +191 -0
  313. package/scripts/min_version_scan.py +335 -0
  314. package/scripts/net_proxy.py +258 -0
  315. package/scripts/network_aware.py +152 -0
  316. package/scripts/paper.py +274 -0
  317. package/scripts/pdf_extract.py +518 -0
  318. package/scripts/phrase_filter.py +66 -0
  319. package/scripts/plan.py +422 -0
  320. package/scripts/postprocess_signals.py +150 -0
  321. package/scripts/pre-commit +48 -0
  322. package/scripts/query_enhance.py +157 -0
  323. package/scripts/query_operators.py +196 -0
  324. package/scripts/query_rewriter.py +116 -7
  325. package/scripts/query_signals.py +300 -0
  326. package/scripts/query_templates.py +167 -0
  327. package/scripts/query_understanding.py +411 -0
  328. package/scripts/query_variants.py +298 -0
  329. package/scripts/quota.py +488 -76
  330. package/scripts/rank_signals.py +775 -0
  331. package/scripts/ranking_eval.py +319 -0
  332. package/scripts/readability_extract.py +433 -0
  333. package/scripts/recompute.py +415 -0
  334. package/scripts/recovery.py +616 -0
  335. package/scripts/recovery_l0.py +160 -0
  336. package/scripts/redskill/redskill_engine.py +332 -0
  337. package/scripts/regression_p0p1.py +318 -0
  338. package/scripts/replay_eval.py +640 -0
  339. package/scripts/rerank_tfidf.py +225 -0
  340. package/scripts/research.py +591 -403
  341. package/scripts/research_cli.py +361 -0
  342. package/scripts/research_dossier.py +492 -0
  343. package/scripts/research_expand.py +146 -0
  344. package/scripts/research_gates.py +272 -0
  345. package/scripts/research_report.py +209 -0
  346. package/scripts/research_strategy.py +37 -0
  347. package/scripts/research_work_packages.py +195 -0
  348. package/scripts/result_lang.py +311 -0
  349. package/scripts/robots_guard.py +259 -0
  350. package/scripts/route.py +921 -254
  351. package/scripts/route_bangs.py +40 -0
  352. package/scripts/route_cache.py +225 -0
  353. package/scripts/route_combo.py +910 -0
  354. package/scripts/route_domains.py +328 -0
  355. package/scripts/route_lang.py +605 -0
  356. package/scripts/route_log.py +63 -0
  357. package/scripts/route_policy.py +250 -0
  358. package/scripts/rrf_classic.py +66 -0
  359. package/scripts/screenshot.py +73 -0
  360. package/scripts/search.py +897 -405
  361. package/scripts/search_attribution.py +88 -0
  362. package/scripts/search_benchmark.py +407 -0
  363. package/scripts/search_cli.py +627 -0
  364. package/scripts/search_entry.py +389 -0
  365. package/scripts/search_output.py +606 -0
  366. package/scripts/search_pipeline.py +1077 -0
  367. package/scripts/search_rank.py +991 -0
  368. package/scripts/search_types.py +27 -12
  369. package/scripts/seek_locator.py +68 -0
  370. package/scripts/semantic_evidence.py +215 -0
  371. package/scripts/serp_guard.py +228 -0
  372. package/scripts/serp_kill_log.py +51 -0
  373. package/scripts/single_flight.py +120 -0
  374. package/scripts/social_engines/__init__.py +10 -0
  375. package/scripts/social_engines/bilibili_engine.py +131 -0
  376. package/scripts/social_engines/hackernews_engine.py +113 -0
  377. package/scripts/social_engines/reddit_engine.py +172 -0
  378. package/scripts/social_engines/twitter_engine.py +397 -0
  379. package/scripts/social_engines/v2ex_engine.py +101 -0
  380. package/scripts/social_engines/weibo_engine.py +128 -0
  381. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  382. package/scripts/social_engines/zhihu_engine.py +139 -0
  383. package/scripts/social_research.py +201 -0
  384. package/scripts/sogou_weixin_browser.py +318 -0
  385. package/scripts/source_ledger.py +249 -0
  386. package/scripts/stage_timing.py +58 -0
  387. package/scripts/stats_cli.py +95 -0
  388. package/scripts/straggler_policy.py +60 -0
  389. package/scripts/stylometry_detector.py +231 -0
  390. package/scripts/sync_backends.py +353 -0
  391. package/scripts/sync_installs.py +29 -0
  392. package/scripts/template_cli.py +127 -0
  393. package/scripts/text_lexicon.py +72 -0
  394. package/scripts/tfidf_router.py +109 -37
  395. package/scripts/time_utils.py +233 -0
  396. package/scripts/topic_research_profiles.py +643 -0
  397. package/scripts/train.py +737 -0
  398. package/scripts/tweet.py +596 -0
  399. package/scripts/url_canon.py +217 -0
  400. package/scripts/url_optimize.py +56 -0
  401. package/scripts/url_safety.py +236 -0
  402. package/scripts/usage_log.py +116 -0
  403. package/scripts/usage_log_clean.py +113 -0
  404. package/scripts/v2ex_nodes.py +311 -0
  405. package/scripts/variant_recall.py +188 -0
  406. package/scripts/watch.py +205 -0
  407. package/scripts/wechat_article.py +258 -0
  408. package/scripts/wide_research.py +402 -0
  409. package/scripts/wx.py +349 -0
  410. package/scripts/x_article_fetch.py +141 -0
  411. package/scripts/x_search.py +234 -0
  412. package/scripts/x_trending.py +189 -0
  413. package/scripts/yaml_load.py +67 -0
  414. package/sub-skills/ego-search/SKILL.md +253 -0
  415. package/sub-skills/ego-search/references/browser-runtime.md +43 -0
  416. package/sub-skills/ego-search/references/install.md +67 -0
  417. package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
  418. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  419. package/sub-skills/ego-search/scripts/merge.py +226 -0
  420. package/sub-skills/ego-search/scripts/quality.py +64 -0
  421. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  422. package/sub-skills/ego-search/scripts/safety.py +109 -0
  423. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  424. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  425. package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
  426. package/sub-skills/js-run/SKILL.md +67 -0
  427. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  428. package/sub-skills/js-run/scripts/shim.js +188 -0
  429. package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
  430. package/sub-skills/js-run/tests/test_jsrun.py +165 -0
  431. package/sub-skills/local-search/SKILL.md +50 -53
  432. package/sub-skills/local-search/bm25.py +162 -0
  433. package/sub-skills/local-search/config.yaml +152 -55
  434. package/sub-skills/local-search/engine_registry.py +91 -26
  435. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  436. package/sub-skills/local-search/local_search_adapter.py +13 -2
  437. package/sub-skills/local-search/parse_maps.yaml +40 -42
  438. package/sub-skills/local-search/search_v3.py +1182 -65
  439. package/sub-skills/local-search/smart_router.py +47 -7
  440. package/sub-skills/local-seek/SKILL.md +110 -0
  441. package/sub-skills/local-seek/config/domains.yaml +20 -0
  442. package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
  443. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
  444. package/sub-skills/local-seek/references/git-integration.md +54 -0
  445. package/sub-skills/local-seek/references/remote-handoff.md +46 -0
  446. package/sub-skills/local-seek/references/rg-recipes.md +96 -0
  447. package/sub-skills/local-seek/references/strategies.md +52 -0
  448. package/sub-skills/local-seek/references/structural-search.md +59 -0
  449. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  450. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  451. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  452. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  453. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  454. package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
  455. package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
  456. package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
  457. package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
  458. package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
  459. package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
  460. package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
  461. package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
  462. package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
  463. package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env python3
2
+ """lang_matrix_probe.py — 多语言 × 引擎 能力矩阵实测(C 阶段量化基线)。
3
+
4
+ ## 为什么需要它
5
+
6
+ argo 的对外宣称是「多语言检测与跨语言回退」,但实测发现两个未见底的问题:
7
+ 1. 语言检测只有「语系级」精度:日语「人工知能」被判成 zh
8
+ 2. 引擎在多语言下的**噪声率未知**:juejin 在阿/俄/德查询下返回 10 条、
9
+ 相关度 0.00(纯噪声),但引擎报告 status=ok
10
+
11
+ 没有完整矩阵,任何「该补哪门语言」的决策都是拍脑袋。本脚本产出
12
+ 「语言 × 引擎」的返回量 + 相关度,作为 B 阶段的决策依据。
13
+
14
+ ## 度量计算方式
15
+
16
+ 对每个 (引擎, 语言) 组合:
17
+ - count :返回条数(0 = 无能力)
18
+ - rel :相关度均值 0~1(查询词元出现在标题/摘要的比例)
19
+ - verdict :相关(>=0.5) / 部分(>=0.2) / 噪声(<0.2) / 空
20
+
21
+ 相关度用「查询词元命中率」而非语义相似度:廉价、可解释、无需模型,
22
+ 足以区分「真结果」与「热帖噪声」——实测 juejin 噪声案例得 0.00,
23
+ 真结果得 0.5~1.0,判别力充分。
24
+
25
+ 用法:
26
+ python3 scripts/lang_matrix_probe.py --json /tmp/matrix.json
27
+ python3 scripts/lang_matrix_probe.py --langs zh,ja,de --engines wikipedia,qiita
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ # 状态隔离(2026-10-10 方案甲):走真实搜索路径的脚本会写生产状态(实测夹具
33
+ # 引擎已混入 circuit_breaker/usage_log)。须先于 argo 状态模块导入重定向
34
+ # (机制见 argo_paths.isolate_state_dir docstring);被测试导入时不生效。
35
+ if __name__ == "__main__":
36
+ from argo_paths import isolate_state_dir
37
+ isolate_state_dir("argo-bench")
38
+
39
+ import argparse
40
+ import json
41
+ import logging
42
+ import re
43
+ import sys
44
+ import time
45
+ from pathlib import Path
46
+ from cli_io import dumps_pretty
47
+
48
+ SCRIPT_DIR = Path(__file__).resolve().parent
49
+ if str(SCRIPT_DIR) not in sys.path:
50
+ sys.path.insert(0, str(SCRIPT_DIR))
51
+
52
+ logging.disable(logging.CRITICAL)
53
+
54
+ # ── 必须最先执行:把 ~/.config/argo/env 的密钥同步进 os.environ ───────────
55
+ # 实测教训:首版探针直接 `import engines` 调用,绕过了 bin/argo 入口的
56
+ # sync_envfile_to_environ(),导致所有靠文件密钥的引擎(bocha/exa/octen/
57
+ # anysearch…)全部误报失败——因为 builder 用 os.environ 直读,
58
+ # 而密钥只在文件里。修法不是改探针调用方式,而是显式补上同步这一步:
59
+ # 凡是「绕过 CLI 入口直接调库」的场景都必须自己做这件事。
60
+ try:
61
+ from engine_env import sync_envfile_to_environ
62
+ _SYNCED = sync_envfile_to_environ()
63
+ except Exception:
64
+ _SYNCED = []
65
+
66
+ # ── 语料:每种语言一个「语义等价」的查询(同一个主题:AI 进展)─────────────
67
+ # 语义等价是关键:这样不同语言的横向对比才有意义(比较的是「引擎能力」
68
+ # 而不是「主题差异」)。部分语言另配一个本地化主题以覆盖文化类引擎。
69
+ QUERIES: dict[str, list[str]] = {
70
+ "zh": ["人工智能 最新进展", "北京 旅游 攻略"],
71
+ "en": ["artificial intelligence progress", "new york travel guide"],
72
+ "ja": ["人工知能 最新 動向", "東京 観光 おすすめ"],
73
+ "ko": ["인공지능 최신 동향", "서울 여행 추천"],
74
+ "ru": ["искусственный интеллект", "москва путешествие"],
75
+ "ar": ["الذكاء الاصطناعي", "دبي سياحة"],
76
+ "de": ["künstliche Intelligenz", "berlin reise"],
77
+ "fr": ["intelligence artificielle", "paris voyage"],
78
+ "es": ["inteligencia artificial", "madrid viaje"],
79
+ "pt": ["inteligência artificial", "lisboa viagem"],
80
+ "it": ["intelligenza artificiale", "roma viaggio"],
81
+ "nl": ["kunstmatige intelligentie", "amsterdam reis"],
82
+ "pl": ["sztuczna inteligencja", "warszawa podróż"],
83
+ "tr": ["yapay zeka", "istanbul seyahat"],
84
+ "vi": ["trí tuệ nhân tạo", "hà nội du lịch"],
85
+ "th": ["ปัญญาประดิษฐ์", "ท่องเที่ยว กรุงเทพ"],
86
+ "hi": ["कृत्रिम बुद्धिमत्ता", "दिल्ली यात्रा"],
87
+ "he": ["בינה מלאכותית", "תל אביב טיול"],
88
+ }
89
+
90
+ # ── 待测引擎:语言相关的(内容型),语言中立的(科学数据库)不测 ──────────
91
+ ENGINES = [
92
+ # 通用搜索
93
+ "wikipedia", "zh_wikipedia", "wikidata", "anysearch",
94
+ # 社区
95
+ "juejin", "qiita", "v2ex", "hackernews", "devto", "stackoverflow",
96
+ "zhihu_global", "hatena_bookmark",
97
+ # 新闻/媒体
98
+ "gdelt", "byted", "you", "em_global_news",
99
+ # 文化/书籍
100
+ "moegirl", "ndl", "gutenberg", "dnb", "open_library", "know_your_meme",
101
+ # 独立索引
102
+ "marginalia", "wiby", "lieu", "searchmysite",
103
+ # 中国源(对照组)
104
+ "bocha", "tavily", "exa",
105
+ ]
106
+
107
+
108
+ def relevance(query: str, item: dict) -> float:
109
+ """查询词元在结果中的命中率(0~1)。计算方式见模块 docstring。"""
110
+ blob = ((item.get("title") or "") + " " + (item.get("snippet") or "")).lower()
111
+ if not blob.strip():
112
+ return 0.0
113
+ # 拉丁词元
114
+ toks = [t for t in re.findall(r"[a-z]{3,}", query.lower())]
115
+ if toks:
116
+ return sum(1 for t in toks if t in blob) / len(toks)
117
+ # CJK / 日文假名
118
+ cjk = re.findall(r"[\u4e00-\u9fff\u3040-\u30ff]{2,}", query)
119
+ if cjk:
120
+ return sum(1 for c in cjk if c in blob) / len(cjk)
121
+ # 韩文
122
+ han = re.findall(r"[\uac00-\ud7af]{2,}", query)
123
+ if han:
124
+ return sum(1 for c in han if c in blob) / len(han)
125
+ # 西里尔 / 阿拉伯 / 天城文 / 希伯来:整词
126
+ words = [w for w in re.split(r"\s+", query) if len(w) >= 3]
127
+ if words:
128
+ return sum(1 for w in words if w in blob) / len(words)
129
+ return 0.0
130
+
131
+
132
+ def _one_call(eng: str, q: str, n: int, timeout: float) -> dict:
133
+ """单次 (引擎, 查询) 调用 → cell 结果。任何异常都收紧为 error 字段。"""
134
+ import engines as eng_mod
135
+ t0 = time.time()
136
+ try:
137
+ raw = eng_mod.search(q, eng, n=n, timeout=timeout) or []
138
+ err = None
139
+ except Exception as e:
140
+ raw, err = [], f"{type(e).__name__}"
141
+ # 过滤 error 占位条目:引擎失败时会返回 [{"error": "...", "source": ...}],
142
+ # 若不剔除,它会被当成「1 条结果、相关度 0」→ 误判为 noise。
143
+ # 实测影响:you/parallel 缺密钥时正是这种形态,早期版本把它们记成噪声而非故障。
144
+ results = [r for r in raw
145
+ if isinstance(r, dict) and not r.get("error")]
146
+ if raw and not results and not err:
147
+ err = str(raw[0].get("error"))[:60] if isinstance(raw[0], dict) else "error-only"
148
+ lat = round((time.time() - t0) * 1000)
149
+ if results:
150
+ sc = [relevance(q, x) for x in results]
151
+ rel = round(sum(sc) / len(sc), 3)
152
+ else:
153
+ rel = 0.0
154
+ return {"query": q, "count": len(results), "rel": rel,
155
+ "latency_ms": lat, "error": err}
156
+
157
+
158
+ def _verdict(total: int, avg_rel: float) -> str:
159
+ if total == 0:
160
+ return "empty"
161
+ if avg_rel >= 0.5:
162
+ return "good"
163
+ if avg_rel >= 0.2:
164
+ return "partial"
165
+ return "noise"
166
+
167
+
168
+ def probe(engines: list[str], langs: list[str], n: int = 5,
169
+ timeout: float = 12.0, workers: int = 8,
170
+ out_path: str | None = None, quiet: bool = False) -> dict:
171
+ """并发探测「语言 × 引擎」矩阵。
172
+
173
+ ## 为什么必须并发(实测教训)
174
+
175
+ 首版是三重串行循环:18 语言 × 28 引擎 × 2 查询 = 1008 次网络调用。
176
+ 实测跑 35 分钟仅消耗 12 秒 CPU —— 即 99% 时间在等网络。
177
+ 改为 8 并发后同样的工作量约 2-3 分钟。
178
+
179
+ ## 增量写入文件
180
+
181
+ 每完成一批(batch)就把当前矩阵写入 out_path。这样即使中途被中断,
182
+ 已得数据仍可用——长任务不该「要么全有要么全无」。
183
+ """
184
+ from concurrent.futures import ThreadPoolExecutor, as_completed
185
+
186
+ tasks: list[tuple[str, str, str]] = []
187
+ for lang in langs:
188
+ for q in (QUERIES.get(lang) or []):
189
+ for eng in engines:
190
+ tasks.append((lang, eng, q))
191
+
192
+ total_tasks = len(tasks)
193
+ if not quiet:
194
+ print(f"计划 {total_tasks} 次调用({len(langs)} 语言 × {len(engines)} 引擎 "
195
+ f"× 若干查询),并发 {workers}", flush=True)
196
+
197
+ # cells[(lang, eng)] = [cell, ...]
198
+ cells: dict[tuple[str, str], list[dict]] = {}
199
+ done = 0
200
+ with ThreadPoolExecutor(max_workers=workers) as ex:
201
+ futs = {ex.submit(_one_call, eng, q, n, timeout): (lang, eng, q)
202
+ for lang, eng, q in tasks}
203
+ for fut in as_completed(futs):
204
+ lang, eng, q = futs[fut]
205
+ try:
206
+ cell = fut.result()
207
+ except Exception as e: # 理论上 _one_call 不抛,双保险
208
+ cell = {"query": q, "count": 0, "rel": 0.0,
209
+ "latency_ms": 0, "error": f"future:{type(e).__name__}"}
210
+ cells.setdefault((lang, eng), []).append(cell)
211
+ done += 1
212
+ # 增量写入文件:每 50 次完成写一次
213
+ if out_path and done % 50 == 0:
214
+ _flush_matrix(cells, langs, engines, out_path,
215
+ {"langs": langs, "engines": engines, "n": n,
216
+ "progress": f"{done}/{total_tasks}",
217
+ "partial": True})
218
+ if not quiet and done % 100 == 0:
219
+ print(f" 进度 {done}/{total_tasks}", flush=True)
220
+
221
+ # 组装矩阵
222
+ matrix: dict[str, dict] = {}
223
+ for lang in langs:
224
+ if not (QUERIES.get(lang) or []):
225
+ continue
226
+ matrix[lang] = {}
227
+ for eng in engines:
228
+ cl = cells.get((lang, eng)) or []
229
+ if not cl:
230
+ continue
231
+ total = sum(c["count"] for c in cl)
232
+ avg_rel = (sum(c["rel"] * c["count"] for c in cl) / total
233
+ if total else 0.0)
234
+ matrix[lang][eng] = {
235
+ "total": total, "avg_rel": round(avg_rel, 3),
236
+ "verdict": _verdict(total, avg_rel), "cells": cl,
237
+ }
238
+ return matrix
239
+
240
+
241
+ def _flush_matrix(cells: dict, langs: list[str], engines: list[str],
242
+ out_path: str, meta: dict) -> None:
243
+ """把当前 cells 组装成矩阵并写入文件(增量与最终共用)。"""
244
+ matrix: dict[str, dict] = {}
245
+ for lang in langs:
246
+ row: dict[str, dict] = {}
247
+ for eng in engines:
248
+ cl = cells.get((lang, eng)) or []
249
+ if not cl:
250
+ continue
251
+ total = sum(c["count"] for c in cl)
252
+ avg_rel = (sum(c["rel"] * c["count"] for c in cl) / total
253
+ if total else 0.0)
254
+ row[eng] = {"total": total, "avg_rel": round(avg_rel, 3),
255
+ "verdict": _verdict(total, avg_rel), "cells": cl}
256
+ if row:
257
+ matrix[lang] = row
258
+ out = {"matrix": matrix, "summary": summarize(matrix), "meta": meta}
259
+ Path(out_path).write_text(dumps_pretty(out), encoding="utf-8")
260
+
261
+
262
+ def summarize(matrix: dict) -> dict:
263
+ """汇总:每语言可用引擎数 / 每引擎的语言覆盖。"""
264
+ per_lang, per_eng = {}, {}
265
+ for lang, row in matrix.items():
266
+ good = [e for e, m in row.items() if m["verdict"] == "good"]
267
+ partial = [e for e, m in row.items() if m["verdict"] == "partial"]
268
+ noise = [e for e, m in row.items() if m["verdict"] == "noise"]
269
+ per_lang[lang] = {"good": len(good), "partial": len(partial),
270
+ "noise": len(noise), "good_engines": good,
271
+ "noise_engines": noise}
272
+ for e, m in row.items():
273
+ per_eng.setdefault(e, {})[lang] = m["verdict"]
274
+ eng_summary = {}
275
+ for e, langs in per_eng.items():
276
+ eng_summary[e] = {
277
+ "good_langs": [l for l, v in langs.items() if v == "good"],
278
+ "noise_langs": [l for l, v in langs.items() if v == "noise"],
279
+ "good_count": sum(1 for v in langs.values() if v == "good"),
280
+ }
281
+ return {"per_lang": per_lang, "per_engine": eng_summary}
282
+
283
+
284
+ def main() -> None:
285
+ ap = argparse.ArgumentParser(description="多语言 × 引擎能力矩阵实测")
286
+ ap.add_argument("--langs", default=",".join(QUERIES.keys()))
287
+ ap.add_argument("--engines", default=",".join(ENGINES))
288
+ ap.add_argument("-n", type=int, default=5)
289
+ ap.add_argument("--timeout", type=float, default=12.0)
290
+ ap.add_argument("--workers", type=int, default=8,
291
+ help="并发数(串行版实测 35 分钟只花 12s CPU,必须并发)")
292
+ ap.add_argument("--json", help="结果落盘路径(每 50 次调用增量写一次)")
293
+ args = ap.parse_args()
294
+
295
+ langs = [x.strip() for x in args.langs.split(",") if x.strip()]
296
+ engines = [x.strip() for x in args.engines.split(",") if x.strip()]
297
+
298
+ t0 = time.time()
299
+ matrix = probe(engines, langs, n=args.n, timeout=args.timeout,
300
+ workers=args.workers, out_path=args.json)
301
+ elapsed = round(time.time() - t0, 1)
302
+ summary = summarize(matrix)
303
+
304
+ if args.json:
305
+ _flush_matrix(
306
+ {(l, e): m["cells"] for l, row in matrix.items() for e, m in row.items()},
307
+ langs, engines, args.json,
308
+ {"langs": langs, "engines": engines, "n": args.n,
309
+ "elapsed_s": elapsed, "partial": False},
310
+ )
311
+
312
+ # 控制台摘要
313
+ print(f"\n耗时 {elapsed}s · {len(langs)} 语言 × {len(engines)} 引擎\n")
314
+ print(f"{'语言':<6}{'可用':>6}{'部分':>6}{'噪声':>6} 可用引擎")
315
+ print("-" * 72)
316
+ for lang, s in summary["per_lang"].items():
317
+ print(f"{lang:<6}{s['good']:>6}{s['partial']:>6}{s['noise']:>6} "
318
+ f"{', '.join(s['good_engines'][:5])}")
319
+ print()
320
+ print(f"{'引擎':<18}{'良好语言数':>10} 良好语言")
321
+ print("-" * 72)
322
+ ranked = sorted(summary["per_engine"].items(),
323
+ key=lambda x: -x[1]["good_count"])
324
+ for e, s in ranked:
325
+ print(f"{e:<18}{s['good_count']:>10} {', '.join(s['good_langs'][:8])}")
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env python3
2
+ """lang_pref.py — 搜索语言偏好:默认中英 + 系统语言 + 使用习惯
3
+
4
+ 像系统软件的「界面语言」与「内容语言」分层一样,搜索侧拆成:
5
+
6
+ 1. 本轮查询语言(强信号)—— 永远驱动 setlang / 路由 / 引擎选择
7
+ 2. 用户使用习惯(滑动窗口)—— 持续使用某语种后提升该语种偏好
8
+ 3. 系统 locale —— 冷启动种子(macOS/Linux LANG、LC_ALL)
9
+ 4. 内置基线 zh + en —— 永不剔除,中英双轨覆盖全球主流量
10
+
11
+ 原则:
12
+ - 强查询信号(zh/ja/ko/非拉丁等)以查询为准,习惯/系统只作补充与观测
13
+ - 弱信号(mixed / other / 空)才用 prefer_langs[0] 驱动引擎语言参数
14
+ - 中英基线始终在 prefer 列表中,习惯学到 ja 也不会丢掉 zh/en
15
+ - 纯本地、仅 stdlib;状态写 <状态目录>/lang_habit.json(由 argo_paths 派生)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import threading
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any, Optional
27
+
28
+ # ── 路径 / 常量 ──────────────────────────────────────────────────────────────
29
+
30
+ def _state_dir() -> Path:
31
+ """状态目录(惰性派生,支持 ARGO_STATE_DIR 覆盖)。"""
32
+ import argo_paths
33
+ return argo_paths.ensure_state_dir()
34
+
35
+
36
+ STATE_DIR = _state_dir() # 兼容旧引用
37
+ STATE_PATH = STATE_DIR / "lang_habit.json"
38
+
39
+ # 全球默认:中英双语基线(永不剔除)
40
+ BASELINE_LANGS: tuple[str, ...] = ("zh", "en")
41
+
42
+ # 习惯生效阈值:至少 N 条样本且主导语种占比 ≥ 阈值
43
+ HABIT_MIN_SAMPLES = 5
44
+ HABIT_MIN_RATIO = 0.55
45
+ HABIT_WINDOW = 80 # 滑动窗口最大事件数
46
+
47
+ # 与 lang_detect 保持一致的可学习标签(排除 mixed/other 噪声)
48
+ _TRACKABLE = frozenset({
49
+ "zh", "en", "ja", "ko", "latin", "cyrillic", "thai",
50
+ "arabic", "hebrew", "greek", "devanagari",
51
+ })
52
+
53
+ # 弱信号:引擎语言参数可回落到偏好语
54
+ WEAK_QUERY_LANGS = frozenset({"mixed", "other", ""})
55
+
56
+ # locale 前缀 → argo 语言标签
57
+ _LOCALE_PREFIX: list[tuple[str, str]] = [
58
+ ("zh", "zh"),
59
+ ("ja", "ja"),
60
+ ("ko", "ko"),
61
+ ("en", "en"),
62
+ ("ru", "cyrillic"),
63
+ ("uk", "cyrillic"),
64
+ ("bg", "cyrillic"),
65
+ ("th", "thai"),
66
+ ("ar", "arabic"),
67
+ ("he", "hebrew"),
68
+ ("iw", "hebrew"),
69
+ ("el", "greek"),
70
+ ("hi", "devanagari"),
71
+ ("mr", "devanagari"),
72
+ ("ne", "devanagari"),
73
+ # 西欧带变音:归 latin(与 detect_language 一致)
74
+ ("fr", "latin"),
75
+ ("de", "latin"),
76
+ ("es", "latin"),
77
+ ("it", "latin"),
78
+ ("pt", "latin"),
79
+ ("nl", "latin"),
80
+ ("sv", "latin"),
81
+ ("pl", "latin"),
82
+ ("tr", "latin"),
83
+ ("vi", "latin"),
84
+ ("id", "latin"),
85
+ ("ms", "latin"),
86
+ ]
87
+
88
+ _lock = threading.Lock()
89
+ _cache: dict[str, Any] | None = None
90
+ _cache_mtime: float = 0.0
91
+
92
+
93
+ # ── 系统 locale ──────────────────────────────────────────────────────────────
94
+
95
+ def _raw_locale() -> str:
96
+ """读取进程环境语言标签(不调用 setlocale,避免改全局状态)。"""
97
+ for key in ("LC_ALL", "LC_MESSAGES", "LANG"):
98
+ val = (os.environ.get(key) or "").strip()
99
+ if val and val not in ("C", "POSIX"):
100
+ return val
101
+ try:
102
+ import locale
103
+ loc = locale.getlocale()
104
+ if loc and loc[0]:
105
+ return loc[0]
106
+ except Exception:
107
+ pass
108
+ return ""
109
+
110
+
111
+ def system_lang(raw: str | None = None) -> str:
112
+ """系统语言 → argo 标签;无法判定时返回空串(不冒充 en)。"""
113
+ s = (raw if raw is not None else _raw_locale()).strip()
114
+ if not s:
115
+ return ""
116
+ # zh_CN.UTF-8 / ja-JP / en_US → 取前缀
117
+ s = s.replace("-", "_")
118
+ m = re.match(r"^([A-Za-z]{2,3})", s)
119
+ if not m:
120
+ return ""
121
+ prefix = m.group(1).lower()
122
+ for p, tag in _LOCALE_PREFIX:
123
+ if prefix == p:
124
+ return tag
125
+ return ""
126
+
127
+
128
+ # ── 习惯持久化 ───────────────────────────────────────────────────────────────
129
+
130
+ def _empty_state() -> dict[str, Any]:
131
+ return {"version": 1, "events": [], "updated_at": 0.0}
132
+
133
+
134
+ def _load_state() -> dict[str, Any]:
135
+ global _cache, _cache_mtime
136
+ with _lock:
137
+ try:
138
+ if STATE_PATH.exists():
139
+ mtime = STATE_PATH.stat().st_mtime
140
+ if _cache is not None and mtime == _cache_mtime:
141
+ return _cache
142
+ data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
143
+ if not isinstance(data, dict):
144
+ data = _empty_state()
145
+ data.setdefault("events", [])
146
+ data.setdefault("version", 1)
147
+ _cache = data
148
+ _cache_mtime = mtime
149
+ return data
150
+ except (OSError, json.JSONDecodeError, TypeError):
151
+ pass
152
+ _cache = _empty_state()
153
+ _cache_mtime = 0.0
154
+ return _cache
155
+
156
+
157
+ def _save_state(state: dict[str, Any]) -> None:
158
+ global _cache, _cache_mtime
159
+ with _lock:
160
+ try:
161
+ state["updated_at"] = time.time()
162
+ # 原子写走唯一来源(唯一 tmp 名)——旧实现固定 `.tmp` 名,
163
+ # 并发进程会互相搬走临时文件导致写失败。
164
+ import argo_paths as _paths
165
+ _paths.atomic_write_json(STATE_PATH, state, indent=None)
166
+ _cache = state
167
+ _cache_mtime = STATE_PATH.stat().st_mtime
168
+ except OSError:
169
+ pass # 偏好写失败不阻断搜索
170
+
171
+
172
+ def record_query_lang(lang: str) -> None:
173
+ """记录一次查询主语言(仅可追踪标签;mixed/other 忽略)。"""
174
+ if not lang or lang not in _TRACKABLE:
175
+ return
176
+ state = _load_state()
177
+ events = list(state.get("events") or [])
178
+ events.append({"lang": lang, "ts": time.time()})
179
+ if len(events) > HABIT_WINDOW:
180
+ events = events[-HABIT_WINDOW:]
181
+ state["events"] = events
182
+ _save_state(state)
183
+
184
+
185
+ def dominant_habit(
186
+ min_samples: int = HABIT_MIN_SAMPLES,
187
+ min_ratio: float = HABIT_MIN_RATIO,
188
+ events: list[dict[str, Any]] | None = None,
189
+ ) -> Optional[str]:
190
+ """滑动窗口主导语种;样本不足或不够集中时返回 None。"""
191
+ if events is None:
192
+ events = list((_load_state().get("events") or []))
193
+ if len(events) < min_samples:
194
+ return None
195
+ counts: dict[str, int] = {}
196
+ for e in events:
197
+ lang = e.get("lang") if isinstance(e, dict) else None
198
+ if lang in _TRACKABLE:
199
+ counts[lang] = counts.get(lang, 0) + 1
200
+ if not counts:
201
+ return None
202
+ top_lang, top_n = max(counts.items(), key=lambda x: x[1])
203
+ total = sum(counts.values())
204
+ if total < min_samples or top_n / total < min_ratio:
205
+ return None
206
+ return top_lang
207
+
208
+
209
+ def habit_counts() -> dict[str, int]:
210
+ """调试/观测:各语种计数。"""
211
+ counts: dict[str, int] = {}
212
+ for e in (_load_state().get("events") or []):
213
+ lang = e.get("lang") if isinstance(e, dict) else None
214
+ if lang in _TRACKABLE:
215
+ counts[lang] = counts.get(lang, 0) + 1
216
+ return counts
217
+
218
+
219
+ # ── 偏好解析 ─────────────────────────────────────────────────────────────────
220
+
221
+ def prefer_langs(
222
+ query_lang: str | None = None,
223
+ *,
224
+ system: str | None = None,
225
+ habit: str | None = None,
226
+ use_live_habit: bool = True,
227
+ ) -> list[str]:
228
+ """有序语言偏好列表(去重,基线 zh+en 垫底)。
229
+
230
+ 顺序:本轮强查询语 → 习惯主导语 → 系统语言 → 中英基线。
231
+
232
+ Args:
233
+ query_lang: 本轮 detect_language 结果;弱信号不前置。
234
+ system: 注入系统语(测试用);None 则现场读 locale。
235
+ habit: 注入习惯语(测试用);None 且 use_live_habit 时读持久化。
236
+ use_live_habit: 是否读取磁盘习惯(单测可关)。
237
+ """
238
+ ordered: list[str] = []
239
+
240
+ def _push(tag: str | None) -> None:
241
+ if tag and tag not in ordered and tag in _TRACKABLE:
242
+ ordered.append(tag)
243
+
244
+ ql = (query_lang or "").strip()
245
+ # 强查询信号前置;弱信号留给习惯/系统
246
+ if ql and ql not in WEAK_QUERY_LANGS:
247
+ _push(ql)
248
+
249
+ if habit is not None:
250
+ _push(habit)
251
+ elif use_live_habit:
252
+ _push(dominant_habit())
253
+
254
+ sys_tag = system if system is not None else system_lang()
255
+ _push(sys_tag)
256
+
257
+ for b in BASELINE_LANGS:
258
+ _push(b)
259
+
260
+ # 极端保底:若全部过滤空,仍返回中英
261
+ return ordered or list(BASELINE_LANGS)
262
+
263
+
264
+ def effective_engine_lang(query_lang: str, preferred: list[str] | None = None) -> str:
265
+ """决定引擎 setlang/hl 用的语言标签。
266
+
267
+ 强查询语 → 原样;弱信号 → prefer_langs 首项(默认 en,因基线含 en)。
268
+ """
269
+ ql = (query_lang or "").strip()
270
+ if ql and ql not in WEAK_QUERY_LANGS:
271
+ return ql
272
+ prefs = preferred if preferred is not None else prefer_langs(query_lang=ql)
273
+ for p in prefs:
274
+ if p in _TRACKABLE:
275
+ return p
276
+ return "en"
277
+
278
+
279
+ def is_weak_query_lang(lang: str) -> bool:
280
+ return (lang or "") in WEAK_QUERY_LANGS
281
+
282
+
283
+ def lang_pref_snapshot(query_lang: str | None = None) -> dict[str, Any]:
284
+ """供 search 结果顶层观测字段。"""
285
+ sys_tag = system_lang()
286
+ habit = dominant_habit()
287
+ prefs = prefer_langs(query_lang=query_lang, system=sys_tag, habit=habit,
288
+ use_live_habit=False)
289
+ return {
290
+ "query_lang": query_lang or "",
291
+ "system_lang": sys_tag,
292
+ "habit_lang": habit or "",
293
+ "prefer_langs": prefs,
294
+ "baseline": list(BASELINE_LANGS),
295
+ "engine_lang": effective_engine_lang(query_lang or "", prefs),
296
+ "habit_counts": habit_counts(),
297
+ "habit_samples": sum(habit_counts().values()),
298
+ }
299
+
300
+
301
+ def reset_habit_for_tests() -> None:
302
+ """测试用:清空内存与磁盘习惯。"""
303
+ global _cache, _cache_mtime
304
+ with _lock:
305
+ _cache = _empty_state()
306
+ _cache_mtime = 0.0
307
+ try:
308
+ if STATE_PATH.exists():
309
+ STATE_PATH.unlink()
310
+ except OSError:
311
+ pass