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,136 @@
1
+ #!/usr/bin/env python3
2
+ """article.py — 微信公众号文章全文抓取。
3
+
4
+ 用法: argo article <url> [--json]
5
+ argo article <url> --json > article.json
6
+
7
+ 绕过 robots.txt 限制:公众号 robots 禁爬,但文章页对真实浏览器开放,
8
+ 用手机 UA 直连(curl/urllib 不遵守 robots),解析 js_content 提取正文。
9
+
10
+ 输出: 标题 / 作者 / 发布时间 / 正文纯文本 / 图片 URL 列表
11
+ """
12
+ import argparse, json, re, sys, html as htmllib, urllib.request
13
+ from cli_io import dumps
14
+
15
+ UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
16
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
17
+
18
+ HEADERS = {
19
+ "User-Agent": UA,
20
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
21
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
22
+ "Referer": "https://mp.weixin.qq.com/",
23
+ }
24
+
25
+
26
+ def fetch(url: str, timeout: int = 30) -> str:
27
+ req = urllib.request.Request(url, headers=HEADERS)
28
+ # 起点域名白名单挡不住服务端 302:重定向的每一跳都要过 SSRF 校验
29
+ # (http/https scheme + 非本机/内网目标),被拒即抛错而非跟随
30
+ opener = urllib.request.build_opener(_SafeRedirectHandler)
31
+ with opener.open(req, timeout=timeout) as r:
32
+ return r.read().decode("utf-8", errors="ignore")
33
+
34
+
35
+ class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
36
+ """每跳重定向目标过 url_safety.check_url,拦截内网/非 http(s) 目标。"""
37
+
38
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
39
+ from url_safety import check_url
40
+ ok, reason = check_url(newurl)
41
+ if not ok:
42
+ raise ValueError(f"重定向目标被 SSRF 防护拦截({reason}): {newurl}")
43
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
44
+
45
+
46
+ def strip_tags(s: str) -> str:
47
+ s = re.sub(r"<script[\s\S]*?</script>|<style[\s\S]*?</style>", "", s)
48
+ s = re.sub(r"<br\s*/?>", "\n", s)
49
+ s = re.sub(r"</(p|div|section|h\d|li|tr|blockquote)>", "\n", s)
50
+ s = re.sub(r"<[^>]+>", "", s)
51
+ s = htmllib.unescape(s)
52
+ lines = [l.strip() for l in s.split("\n")]
53
+ return "\n".join(l for l in lines if l)
54
+
55
+
56
+ def fetch_article(url: str, timeout: int = 30) -> dict:
57
+ """抓取并解析公众号文章,返回结构化 dict(CLI 与 MCP 共用主链)。
58
+
59
+ URL 非 mp.weixin.qq.com 时抛 ValueError(CLI 走 stderr 退出,
60
+ MCP 转 isError);抓取失败/反爬拦截返回 {"ok": False, "error": ...}。
61
+ """
62
+ if not re.match(r"https?://mp\.weixin\.qq\.com/", url):
63
+ raise ValueError("仅支持 mp.weixin.qq.com 链接")
64
+
65
+ try:
66
+ raw = fetch(url, timeout)
67
+ except Exception as e:
68
+ return {"ok": False, "error": f"抓取失败: {e}"}
69
+
70
+ if "js_content" not in raw and "环境异常" in raw:
71
+ return {"ok": False, "error": "微信反爬拦截(环境异常),请稍后重试"}
72
+
73
+ def grab(pattern, default=""):
74
+ m = re.search(pattern, raw)
75
+ return m.group(1).strip() if m else default
76
+
77
+ title = grab(r'<meta property="og:title" content="([^"]+)"') or \
78
+ grab(r'<h1[^>]*class="rich_media_title"[^>]*>([\s\S]*?)</h1>')
79
+ author = grab(r'<meta property="og:article:author" content="([^"]+)"') or \
80
+ grab(r'id="js_name"[^>]*>([^<]+)<')
81
+ pubtime = grab(r'id="publish_time"[^>]*>([^<]+)<')
82
+
83
+ m = re.search(r'id="js_content"[^>]*>([\s\S]*?)</div>\s*<script', raw)
84
+ if not m:
85
+ m = re.search(r'id="js_content"[^>]*>([\s\S]*?)</div>', raw)
86
+ body_html = m.group(1) if m else ""
87
+
88
+ text = strip_tags(body_html)
89
+ imgs = re.findall(r'data-src="(https://mmbiz\.qpic\.cn/[^"]+)"', body_html)
90
+ if not imgs:
91
+ imgs = re.findall(r'src="(https://mmbiz\.qpic\.cn/[^"]+)"', body_html)
92
+
93
+ return {
94
+ "ok": True,
95
+ "url": url,
96
+ "title": title,
97
+ "author": author,
98
+ "publish_time": pubtime,
99
+ "char_count": len(text),
100
+ "image_count": len(imgs),
101
+ "images": imgs,
102
+ "content": text,
103
+ }
104
+
105
+
106
+ def main():
107
+ ap = argparse.ArgumentParser(description="微信公众号文章全文抓取")
108
+ ap.add_argument("url", help="mp.weixin.qq.com 文章链接")
109
+ ap.add_argument("--json", action="store_true", help="输出 JSON")
110
+ args = ap.parse_args()
111
+
112
+ try:
113
+ out = fetch_article(args.url)
114
+ except ValueError as e:
115
+ print(str(e), file=sys.stderr)
116
+ sys.exit(1)
117
+ if not out["ok"]:
118
+ print(dumps(out))
119
+ sys.exit(1)
120
+
121
+ if args.json:
122
+ print(dumps(out))
123
+ else:
124
+ print(f"标题: {out['title']}")
125
+ print(f"作者: {out['author']} 时间: {out['publish_time']} "
126
+ f"正文 {out['char_count']} 字 / {out['image_count']} 图")
127
+ print("---")
128
+ print(out["content"])
129
+ if out["images"]:
130
+ print("---")
131
+ for i, u in enumerate(out["images"], 1):
132
+ print(f"[图{i}] {u}")
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
@@ -0,0 +1,201 @@
1
+ """arXiv 源码包(e-print)获取与安全解包 — 论文 LaTeX 精读的取数层。
2
+
3
+ P1 论文深读三能力之一(2026-09-30 P0 实测后立项):外部论文工具的
4
+ get_paper_latex 实测 3.4s 拿到 main tex + 源文件清单,argo 此前无此能力。
5
+
6
+ 安全边界(全部内存态,不落盘解包):
7
+ 1. 只认 arxiv.org 的 https URL(scheme + 域名白名单,拒环回/私有地址)
8
+ 2. 下载总量封顶(默认 64MB),成员数封顶(500),单文件解压封顶(2MB)
9
+ 3. tar 成员名拒绝绝对路径 / 路径穿越 / 盘符——本模块只读内存,永不写文件
10
+ 4. 只收 .tex/.bbl 文本(其余成员只列名不收内容)
11
+
12
+ 缓存:走 fetch 缓存(同 id 1h 内二读零成本),与 pdf 提取缓存同一纪律。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import gzip
17
+ import io
18
+ import tarfile
19
+ from typing import Any
20
+
21
+ from net_proxy import open_url
22
+ from pdf_extract import CONTENT_WARNING, chunk_text
23
+
24
+ # 域名白名单(e-print 只在 arxiv.org 主站)
25
+ _ALLOWED_HOSTS = {"arxiv.org", "www.arxiv.org", "export.arxiv.org"}
26
+ _MAX_DOWNLOAD = 64 * 1024 * 1024 # 压缩包下载上限
27
+ _MAX_MEMBERS = 500 # 成员数上限
28
+ _MAX_TOTAL_UNCOMPRESSED = 64 * 1024 * 1024 # 解压总量上限
29
+ _MAX_FILE_UNCOMPRESSED = 2 * 1024 * 1024 # 单文件解压上限(.tex/.bbl)
30
+ _MAX_CACHE_TEXTS = 2 * 1024 * 1024 # 进缓存的文本总量上限(超出只存清单)
31
+ _TEX_EXTS = (".tex", ".bbl")
32
+ _DOCUMENTCLASS_RE = None # 惰性编译(见 _is_main_candidate)
33
+
34
+
35
+ class EprintError(Exception):
36
+ """e-print 取数/解包失败(消息可直接进用户输出)。"""
37
+
38
+
39
+ def _is_main_candidate(name: str, text: str) -> bool:
40
+ global _DOCUMENTCLASS_RE
41
+ if _DOCUMENTCLASS_RE is None:
42
+ import re
43
+ _DOCUMENTCLASS_RE = re.compile(r"\\documentclass")
44
+ return _DOCUMENTCLASS_RE.search(text) is not None
45
+
46
+
47
+ def _validate_url(url: str) -> None:
48
+ """仅允许白名单域名的 http(s) URL(拒环回/私有/保留地址)。"""
49
+ from urllib.parse import urlparse
50
+ p = urlparse(url)
51
+ if p.scheme not in ("http", "https"):
52
+ raise EprintError(f"仅允许 http(s) URL:{url[:80]}")
53
+ host = (p.hostname or "").lower()
54
+ if host not in _ALLOWED_HOSTS:
55
+ raise EprintError(f"域名不在白名单({'/'.join(sorted(_ALLOWED_HOSTS))}):{host}")
56
+
57
+
58
+ def _safe_name(name: str) -> bool:
59
+ """tar 成员名安全判定:拒绝对路径/盘符/穿越(只读内存态,防御性双保险)。"""
60
+ if not name or name.startswith(("/", "\\")):
61
+ return False
62
+ if ":" in name.split("/")[0]: # C:\ 盘符形态
63
+ return False
64
+ parts = name.replace("\\", "/").split("/")
65
+ return ".." not in parts
66
+
67
+
68
+ def _pick_main(texts: dict[str, str]) -> str | None:
69
+ """主 tex:含 \\documentclass 的最大文件;没有则最大的 .tex。"""
70
+ candidates = [n for n in texts if n.endswith(".tex")]
71
+ if not candidates:
72
+ return None
73
+ doc_class = [n for n in candidates if _is_main_candidate(n, texts[n])]
74
+ pool = doc_class or candidates
75
+ return max(pool, key=lambda n: len(texts[n]))
76
+
77
+
78
+ def parse_eprint(body: bytes, *, max_members: int = _MAX_MEMBERS,
79
+ max_total: int = _MAX_TOTAL_UNCOMPRESSED,
80
+ max_file: int = _MAX_FILE_UNCOMPRESSED) -> dict[str, Any]:
81
+ """解析 e-print 字节流(tar.gz / 单文件 gzip / 纯 tex),全内存态。
82
+
83
+ 返回 {format, files:[{name,size}], texts:{name:content}, rejected:[...]},
84
+ 非 LaTeX 源包(如误传 PDF)抛 EprintError。
85
+ """
86
+ if body[:5] == b"%PDF-":
87
+ raise EprintError("这是 PDF 不是 LaTeX 源包(e-print 端点才返回源码)")
88
+ tf = None
89
+ try:
90
+ try:
91
+ tf = tarfile.open(fileobj=io.BytesIO(body), mode="r:*")
92
+ members = [(m.name, m.size, tf.extractfile(m)) for m in tf.getmembers()]
93
+ fmt = "tar"
94
+ except tarfile.ReadError:
95
+ if body[:2] == b"\x1f\x8b": # 单文件 gzip(老论文常见:单个 .tex.gz)
96
+ text = gzip.decompress(body).decode("utf-8", errors="replace")
97
+ name = "main.tex"
98
+ return {"format": "gz", "main_file": name,
99
+ "files": [{"name": name, "size": len(text)}],
100
+ "texts": {name: text}, "rejected": []}
101
+ text = body.decode("utf-8", errors="replace")
102
+ return {"format": "plain", "main_file": "main.tex",
103
+ "files": [{"name": "main.tex", "size": len(text)}],
104
+ "texts": {"main.tex": text}, "rejected": []}
105
+ except EprintError:
106
+ raise
107
+ except Exception as e:
108
+ raise EprintError(f"源码包解析失败: {str(e)[:120]}")
109
+
110
+ files: list[dict[str, Any]] = []
111
+ texts: dict[str, str] = {}
112
+ rejected: list[str] = []
113
+ total = 0
114
+ if len(members) > max_members:
115
+ raise EprintError(f"成员数 {len(members)} 超上限 {max_members},疑似恶意包")
116
+ for raw_name, size, fh in members:
117
+ if not _safe_name(raw_name):
118
+ rejected.append(raw_name)
119
+ continue
120
+ if size > max_file:
121
+ rejected.append(f"{raw_name}(单文件 {size} 超上限)")
122
+ continue
123
+ total += size
124
+ if total > max_total:
125
+ raise EprintError(f"解压总量超上限 {max_total // (1024 * 1024)}MB")
126
+ if not raw_name.lower().endswith(_TEX_EXTS) or fh is None:
127
+ files.append({"name": raw_name, "size": size})
128
+ continue
129
+ data = fh.read(max_file + 1)
130
+ if len(data) > max_file:
131
+ rejected.append(f"{raw_name}(解压超限)")
132
+ continue
133
+ text = data.decode("utf-8", errors="replace")
134
+ files.append({"name": raw_name, "size": size})
135
+ texts[raw_name] = text
136
+ main = _pick_main(texts)
137
+ return {"format": fmt, "files": files, "texts": texts,
138
+ "main_file": main, "rejected": rejected}
139
+
140
+
141
+ def fetch_eprint(arxiv_id: str, use_cache: bool = True) -> dict[str, Any]:
142
+ """拉取并解析 arXiv 源码包;带 fetch 缓存(1h 内二读零成本)。
143
+
144
+ 返回 parse_eprint 结果 + {"content_warning": ...};texts 总量超缓存上限时
145
+ 只缓存清单并置 texts_omitted(本次调用仍返回全文)。
146
+ """
147
+ aid = arxiv_id.strip()
148
+ if not aid or any(c.isspace() for c in aid):
149
+ raise EprintError(f"arXiv id 形态不对:{aid[:40]}")
150
+ url = f"https://arxiv.org/e-print/{aid}"
151
+ _validate_url(url)
152
+
153
+ cache = None
154
+ cache_key = f"arxiv-eprint:{aid}"
155
+ if use_cache:
156
+ try:
157
+ from cache import SearchCache
158
+ cache = SearchCache()
159
+ hit = cache.get_fetch(cache_key)
160
+ if isinstance(hit, dict) and hit.get("files"):
161
+ hit["_cached"] = True
162
+ return hit
163
+ except Exception:
164
+ cache = None
165
+
166
+ with open_url(url, timeout=60) as resp:
167
+ body = resp.read(_MAX_DOWNLOAD + 1)
168
+ if len(body) > _MAX_DOWNLOAD:
169
+ raise EprintError(f"源码包超过下载上限 {_MAX_DOWNLOAD // (1024 * 1024)}MB")
170
+
171
+ result = parse_eprint(body)
172
+ result["arxiv_id"] = aid
173
+ result["content_warning"] = CONTENT_WARNING
174
+ if cache is not None:
175
+ try:
176
+ payload = {k: v for k, v in result.items() if k != "_cached"}
177
+ if sum(len(t) for t in payload.get("texts", {}).values()) > _MAX_CACHE_TEXTS:
178
+ payload["texts"] = {}
179
+ payload["texts_omitted"] = True
180
+ cache.set_fetch(cache_key, payload)
181
+ except Exception:
182
+ pass # 缓存写失败不影响本次结果
183
+ return result
184
+
185
+
186
+ def latex_read(source: dict[str, Any], file: str | None = None,
187
+ start: int = 0, max_chars: int | None = None) -> dict[str, Any]:
188
+ """从已解析的源码包按文件分页读取(默认主 tex);chunk 契约与 pdf 相同。"""
189
+ texts = source.get("texts") or {}
190
+ name = file or source.get("main_file")
191
+ if not name:
192
+ raise EprintError("源码包内没有可读的 .tex 文件")
193
+ if name not in texts:
194
+ if source.get("texts_omitted"):
195
+ raise EprintError("该源码包文本未入缓存(体积超限),请重新拉取")
196
+ available = ", ".join(sorted(texts))[:200] or "(无 .tex 成员)"
197
+ raise EprintError(f"文件 {name} 不在源码包内。可用:{available}")
198
+ seg = chunk_text(texts[name], start, max_chars or 12000)
199
+ return {"content_warning": CONTENT_WARNING, "file": name,
200
+ "main_file": source.get("main_file"), "source_format": source.get("format"),
201
+ **seg}
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """badcase_ledger — 坏例账本:把分散三处的失败统一入账、可查、可做发布门禁。
3
+
4
+ 定位(2026-10-11 方案丙):argo 的失败证据分散在三处、形态各异——
5
+ 1. usage_log/query.jsonl:errors[] 非空或 count=0 的真实查询(含 serp_guard
6
+ 整页误杀外露,见 search.py drain 处);
7
+ 2. usage_log/recovery.jsonl:触发过恢复链的查询(triggered=true);
8
+ 3. circuit_breaker.json:state != closed 的引擎(点状快照,引擎级坏例)。
9
+ 本工具把它们归一进 SQLite 账本(<state>/badcase.db),支持 ingest(幂等去重)、
10
+ list、stats 与 check(发布前门禁:近 N 天新增坏例超阈值即非零退出)。
11
+
12
+ 与 replay_eval.py 的分工:replay_eval 用录制重放整条管线评打分质量(离线
13
+ 仲裁);本账本只收纳「真实发生的失败」并让它们可归因、可回归——对应评测
14
+ 体系的 badcase 管理环节:发现 → 入账 → 归因 → 修复后 check 不复发。
15
+
16
+ 数据卫生:engines_used 含表外引擎名(夹具/拼写错)的记录不入账——账本只收
17
+ 真实坏例,与 usage_log_clean 的隔离口径同源(engines.available_engines)。
18
+
19
+ 用法:
20
+ python3 scripts/badcase_ledger.py ingest # 三源归一入账(幂等)
21
+ python3 scripts/badcase_ledger.py list --kind query_error --limit 10
22
+ python3 scripts/badcase_ledger.py stats # 按 kind/引擎聚合
23
+ python3 scripts/badcase_ledger.py check --since 7d --max 50 # 发布门禁
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import hashlib
29
+ import json
30
+ import sqlite3
31
+ import sys
32
+ from collections import Counter
33
+ from datetime import datetime, timedelta
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
38
+
39
+ import argo_paths # noqa: E402
40
+ from engines import available_engines # noqa: E402
41
+ from usage_log import usage_log_dir # noqa: E402
42
+
43
+ _CANON = frozenset(available_engines())
44
+
45
+ _SCHEMA = (
46
+ "CREATE TABLE IF NOT EXISTS badcases ("
47
+ " id INTEGER PRIMARY KEY,"
48
+ " kind TEXT NOT NULL," # query_error | empty_result | recovery | breaker
49
+ " ts TEXT NOT NULL," # 原始 ISO 时间串;breaker 快照用入账时刻
50
+ " query TEXT NOT NULL DEFAULT '',"
51
+ " engine TEXT NOT NULL DEFAULT '',"
52
+ " detail TEXT NOT NULL DEFAULT '',"
53
+ " fingerprint TEXT NOT NULL UNIQUE)"
54
+ )
55
+
56
+
57
+ def db_path() -> Path:
58
+ return argo_paths.state_path("badcase.db")
59
+
60
+
61
+ def _connect() -> sqlite3.Connection:
62
+ con = sqlite3.connect(str(db_path()), timeout=8)
63
+ con.execute(_SCHEMA)
64
+ return con
65
+
66
+
67
+ def _rows_from_query_log(path: Path) -> list[dict[str, Any]]:
68
+ if not path.exists():
69
+ return []
70
+ out = []
71
+ for line in path.read_text(encoding="utf-8").splitlines():
72
+ if not line.strip():
73
+ continue
74
+ try:
75
+ r = json.loads(line)
76
+ except json.JSONDecodeError:
77
+ continue
78
+ raw_used = r.get("engines_used") or []
79
+ used = [e for e in raw_used if e in _CANON]
80
+ if not used and raw_used:
81
+ continue # 表外名占满 → 夹具行,不入账(口径同 usage_log_clean)
82
+ raw_errors = r.get("errors")
83
+ # 生产日志里 errors 有两种形态:字符串列表(主流)与 int 计数(部分
84
+ # 旧条目)——统一成字符串列表再取前几条
85
+ errors = (raw_errors if isinstance(raw_errors, list)
86
+ else ([str(raw_errors)] if raw_errors else []))
87
+ if not errors and r.get("count"):
88
+ continue # 正常条目不是坏例
89
+ if errors:
90
+ out.append({"kind": "query_error", "ts": r.get("ts", ""),
91
+ "query": r.get("query", ""),
92
+ "engine": ",".join(used[:3]),
93
+ "detail": " | ".join(str(e)[:160] for e in errors[:3])})
94
+ else:
95
+ out.append({"kind": "empty_result", "ts": r.get("ts", ""),
96
+ "query": r.get("query", ""),
97
+ "engine": ",".join(used[:3]),
98
+ "detail": f"count=0, mode={r.get('mode')}"})
99
+ return out
100
+
101
+
102
+ def _rows_from_recovery_log(path: Path) -> list[dict[str, Any]]:
103
+ if not path.exists():
104
+ return []
105
+ out = []
106
+ for line in path.read_text(encoding="utf-8").splitlines():
107
+ if not line.strip():
108
+ continue
109
+ try:
110
+ r = json.loads(line)
111
+ except json.JSONDecodeError:
112
+ continue
113
+ if not r.get("triggered"):
114
+ continue
115
+ out.append({"kind": "recovery", "ts": r.get("ts", ""),
116
+ "query": r.get("query", ""),
117
+ "engine": "",
118
+ "detail": (f"level={r.get('level_used')} "
119
+ f"strategy={r.get('strategy_used')} "
120
+ f"recovered={r.get('recovered')}")})
121
+ return out
122
+
123
+
124
+ def _rows_from_breaker(path: Path) -> list[dict[str, Any]]:
125
+ if not path.exists():
126
+ return []
127
+ try:
128
+ engines = json.loads(path.read_text(encoding="utf-8")).get("engines", {})
129
+ except (json.JSONDecodeError, OSError):
130
+ return []
131
+ out = []
132
+ now = datetime.now().astimezone().isoformat(timespec="seconds")
133
+ for name, st in sorted(engines.items()):
134
+ if st.get("state") == "closed":
135
+ continue
136
+ out.append({"kind": "breaker", "ts": now, "query": "", "engine": name,
137
+ "detail": (f"state={st.get('state')} "
138
+ f"failures={st.get('failures')} "
139
+ f"opens={st.get('opens')}")})
140
+ return out
141
+
142
+
143
+ def ingest() -> dict:
144
+ log_dir = usage_log_dir()
145
+ rows = (_rows_from_query_log(log_dir / "query.jsonl")
146
+ + _rows_from_recovery_log(log_dir / "recovery.jsonl")
147
+ + _rows_from_breaker(argo_paths.state_path("circuit_breaker.json")))
148
+ con = _connect()
149
+ added = 0
150
+ try:
151
+ for r in rows:
152
+ fp = hashlib.sha1("|".join(
153
+ [str(r["kind"]), str(r["ts"]), str(r["query"]),
154
+ str(r["engine"]), str(r["detail"])[:120]])
155
+ .encode("utf-8")).hexdigest()
156
+ cur = con.execute(
157
+ "INSERT OR IGNORE INTO badcases(ts, query, engine, detail,"
158
+ " kind, fingerprint) VALUES (?,?,?,?,?,?)",
159
+ (r["ts"], r["query"], r["engine"], r["detail"],
160
+ r["kind"], fp))
161
+ added += cur.rowcount
162
+ total = con.execute("SELECT COUNT(*) FROM badcases").fetchone()[0]
163
+ con.commit()
164
+ finally:
165
+ con.close()
166
+ return {"sources": len(rows), "added": added, "total": total,
167
+ "db": str(db_path())}
168
+
169
+
170
+ def _parse_since(since: str) -> datetime:
171
+ """7d / 24h / 2026-10-01 → datetime(本地时区)。"""
172
+ since = since.strip().lower()
173
+ now = datetime.now().astimezone()
174
+ if since.endswith("d") and since[:-1].isdigit():
175
+ return now - timedelta(days=int(since[:-1]))
176
+ if since.endswith("h") and since[:-1].isdigit():
177
+ return now - timedelta(hours=int(since[:-1]))
178
+ return datetime.fromisoformat(since).astimezone()
179
+
180
+
181
+ def _fetch(kind: str | None, since: datetime | None,
182
+ limit: int) -> list[dict[str, Any]]:
183
+ con = _connect()
184
+ try:
185
+ sql = ("SELECT ts, kind, query, engine, detail FROM badcases"
186
+ " WHERE 1=1")
187
+ params: list = []
188
+ if kind:
189
+ sql += " AND kind = ?"
190
+ params.append(kind)
191
+ if since is not None:
192
+ sql += " AND ts >= ?"
193
+ params.append(since.isoformat(timespec="seconds"))
194
+ sql += " ORDER BY ts DESC LIMIT ?"
195
+ params.append(limit)
196
+ keys = ["ts", "kind", "query", "engine", "detail"]
197
+ return [dict(zip(keys, row)) for row in con.execute(sql, params)]
198
+ finally:
199
+ con.close()
200
+
201
+
202
+ def check(since: str, max_new: int) -> dict:
203
+ rows = _fetch(None, _parse_since(since), limit=10_000)
204
+ ok = len(rows) <= max_new
205
+ return {"since": since, "new_badcases": len(rows), "max": max_new,
206
+ "ok": ok}
207
+
208
+
209
+ def stats() -> dict:
210
+ con = _connect()
211
+ try:
212
+ by_kind = dict(con.execute(
213
+ "SELECT kind, COUNT(*) FROM badcases GROUP BY kind").fetchall())
214
+ by_engine = dict(con.execute(
215
+ "SELECT engine, COUNT(*) FROM badcases WHERE engine != ''"
216
+ " GROUP BY engine ORDER BY COUNT(*) DESC LIMIT 8").fetchall())
217
+ total = con.execute("SELECT COUNT(*) FROM badcases").fetchone()[0]
218
+ finally:
219
+ con.close()
220
+ return {"total": total, "by_kind": by_kind, "top_engines": by_engine}
221
+
222
+
223
+ def main(argv: list[str] | None = None) -> int:
224
+ ap = argparse.ArgumentParser(description="坏例账本(badcase 管理)")
225
+ sub = ap.add_subparsers(dest="cmd", required=True)
226
+ sub.add_parser("ingest")
227
+ p_list = sub.add_parser("list")
228
+ p_list.add_argument("--kind", default=None,
229
+ choices=["query_error", "empty_result",
230
+ "recovery", "breaker"])
231
+ p_list.add_argument("--limit", type=int, default=10)
232
+ sub.add_parser("stats")
233
+ p_check = sub.add_parser("check")
234
+ p_check.add_argument("--since", default="7d")
235
+ p_check.add_argument("--max", type=int, default=50)
236
+ args = ap.parse_args(argv)
237
+ if args.cmd == "ingest":
238
+ print(json.dumps(ingest(), ensure_ascii=False))
239
+ elif args.cmd == "list":
240
+ for row in _fetch(args.kind, None, args.limit):
241
+ print(json.dumps(row, ensure_ascii=False))
242
+ elif args.cmd == "stats":
243
+ print(json.dumps(stats(), ensure_ascii=False))
244
+ elif args.cmd == "check":
245
+ report = check(args.since, args.max)
246
+ print(json.dumps(report, ensure_ascii=False))
247
+ return 0 if report["ok"] else 1
248
+ return 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())