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
package/scripts/cache.py CHANGED
@@ -3,7 +3,7 @@
3
3
  cache.py — Unified Search v2 双层缓存引擎
4
4
 
5
5
  功能:
6
- L1: 内存 LRU 热缓存(100 条),避免同进程重复查询
6
+ L1: 内存 LRU 热缓存(1000 条),避免同进程重复查询
7
7
  L2: SQLite 持久化缓存(TTL 可配置),跨进程复用
8
8
  分级 TTL:financial / news / realtime / general / research / evergreen
9
9
  大值 gzip 压缩(> 1KB)
@@ -11,16 +11,27 @@ cache.py — Unified Search v2 双层缓存引擎
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import contextlib
14
15
  import gzip
15
16
  import hashlib
16
17
  import json
17
18
  import os
18
- import sqlite3
19
19
  import threading
20
20
  import time
21
21
  from collections import OrderedDict
22
22
  from typing import Optional
23
23
 
24
+ _sqlite3 = None
25
+
26
+
27
+ def _get_sqlite3():
28
+ """延迟导入 sqlite3,避免重量级 import 链(sqlite3 → _sqlite3 → zlib → bz2 → lzma)。"""
29
+ global _sqlite3
30
+ if _sqlite3 is None:
31
+ import sqlite3
32
+ _sqlite3 = sqlite3
33
+ return _sqlite3
34
+
24
35
  try:
25
36
  from config import get_cache_config
26
37
  except ImportError:
@@ -29,15 +40,29 @@ except ImportError:
29
40
  sys.path.insert(0, str(Path(__file__).parent))
30
41
  from config import get_cache_config
31
42
 
43
+ # 本地状态目录唯一来源(env ARGO_STATE_DIR → config cache.db_path 父目录 → 旧路径)
44
+ import argo_paths
45
+ from cache_guard import (
46
+ DegradedCacheRejected,
47
+ RECOVERED_DEGRADED_TTL,
48
+ assert_not_degraded,
49
+ assert_not_failed_state, # 写入守卫(2026-09-27 拆出;失败态守卫 2026-09-29 增补)
50
+ )
51
+ from cache_key_vdom import cache_key_vdom # 引擎级垂直域维度(--domain/--sub_domain)
52
+ from cli_io import dumps
53
+ from except_sets import IO_BENIGN, SHAPE_BENIGN
54
+
32
55
 
33
56
  # ── 常量 ──────────────────────────────────────────────────────────────────────
34
57
 
35
- DEFAULT_DB_PATH = "~/.cache/unified-search/cache.db"
58
+ # 由唯一来源派生,不再字面量拼 ~/.cache/unified-search。
59
+ # 用 db_path() 而非 state_path():config.yaml 显式改写 db_path 时尊重用户配置。
60
+ DEFAULT_DB_PATH = str(argo_paths.db_path())
36
61
  DEFAULT_TTL = 3600
37
- MAX_MEMORY_ITEMS = 100
38
- MAX_DB_SIZE_MB = 100
39
- COMPRESSION_THRESHOLD = 1024
40
- COMPRESSION_LEVEL = 6
62
+ MAX_MEMORY_ITEMS = 1000
63
+ MAX_DB_SIZE_MB = 200
64
+ COMPRESSION_THRESHOLD = 768
65
+ COMPRESSION_LEVEL = 1
41
66
 
42
67
  # 分级 TTL(秒)
43
68
  CACHE_TIERS = {
@@ -49,6 +74,13 @@ CACHE_TIERS = {
49
74
  "evergreen": 86400, # 24 小时
50
75
  }
51
76
 
77
+ # 当天缓存策略:仅 evergreen/research 可延长至日末。
78
+ # general 不再日末延长,避免「今日热点」等泛中文域被错误拉到超长 TTL。
79
+ SAME_DAY_ELIGIBLE_TIERS = {"research", "evergreen"}
80
+
81
+ # 时效敏感查询硬上限(秒)— 覆盖 domain 误分到 general 的情况
82
+ REALTIME_TTL_CAP = 900
83
+
52
84
  # query domain → TTL tier 映射
53
85
  DOMAIN_TIER_MAP = {
54
86
  "stock_query": "financial",
@@ -56,17 +88,105 @@ DOMAIN_TIER_MAP = {
56
88
  "financial_news": "news",
57
89
  "zhihu_content": "general",
58
90
  "tech_deep": "research",
91
+ "english_tech": "research",
59
92
  "news_realtime": "realtime",
93
+ "hot_trending": "realtime",
94
+ "zhihu_hot_list": "realtime",
60
95
  "general_search": "general",
96
+ "chinese_general": "general",
97
+ "chinese_tech_deep": "research",
98
+ "fact_check": "research",
99
+ "code_search": "research",
100
+ "wechat_search": "news",
101
+ "shopping": "general",
102
+ "reference": "evergreen",
103
+ "social": "general",
104
+ "local_chinese": "general",
105
+ "local_news": "news",
106
+ "local_academic": "research",
107
+ "local_code": "research",
108
+ "local_reference": "evergreen",
109
+ "local_general": "general",
61
110
  "stock": "financial",
62
111
  "fund": "financial",
63
112
  "news": "realtime",
64
113
  "tech": "research",
114
+ "deep": "research", # 别名:深度/研究类
65
115
  "general": "general",
66
116
  "auto": "general",
117
+ "fetch": "general",
118
+ # ── v2.7.3 补全:48 个无映射域按时效归类(此前全走 general 3600s,
119
+ # 实时卡片/快讯/行情被缓存 1 小时后过期)──
120
+ "ths_hot_search": "realtime",
121
+ "cls_telegraph_search": "realtime",
122
+ "em_news_search": "news",
123
+ "jin10_flash": "realtime",
124
+ "modal_card": "realtime", # 油价/金价/车票实时值卡片
125
+ "us_stock": "financial",
126
+ "macro_data": "financial",
127
+ "crypto_search": "financial",
128
+ "weather_query": "realtime",
129
+ "aviation_weather": "realtime",
130
+ "global_event": "news",
131
+ # 稳定型:学术/百科/参考类放宽到 research(提高命中率)
132
+ "hackernews_search": "news",
133
+ "scholar_search": "research",
134
+ "chem_search": "research",
135
+ "protein_search": "research",
136
+ "patent_search": "research",
137
+ "earth_science": "research",
138
+ "academic": "research",
139
+ "cn_encyclopedia": "evergreen",
140
+ "dictionary_search": "evergreen",
141
+ "book_search": "evergreen",
142
+ "film_search": "evergreen",
143
+ "anime_encyclopedia": "evergreen",
144
+ "web_archive": "evergreen",
145
+ # 稳定型:技术/社区/百科类(补全剩余 24 个,避免默认 general 一刀切)
146
+ "stackoverflow_search": "research",
147
+ "v2ex_search": "research",
148
+ "cn_tech_community": "research",
149
+ "package_search": "research",
150
+ "web_docs": "research",
151
+ "ml_models": "research",
152
+ "ai_model": "research",
153
+ "species_search": "evergreen",
154
+ "rfc_search": "evergreen",
155
+ "us_legal": "evergreen",
156
+ "legal": "evergreen",
157
+ "wenshu_query": "evergreen",
158
+ "medical": "research",
159
+ "game_search": "evergreen",
160
+ "prediction_market": "realtime",
161
+ "sports_search": "news",
162
+ "geo_places": "evergreen",
163
+ "org_entity": "evergreen",
164
+ "media_search": "evergreen",
165
+ "image_search": "evergreen",
166
+ "entity_search": "evergreen",
167
+ "semantic_discovery": "research",
168
+ "meme_slang": "evergreen",
169
+ "company_search": "research",
67
170
  }
68
171
 
69
172
 
173
+ # ── 命中策略判据(2026-10-16 拆至 cache_similarity.py)─────────────────────────
174
+ # 归一化 / minhash 相似度 / 限定符拆分 / 时效检测四件事整段搬走(判据层,
175
+ # 零 IO)。此处 re-export:既有 `from cache import query_similarity` 等
176
+ # import 点与 `monkeypatch.setattr(cache, ...)` 的测试面都保持不变——
177
+ # 调用方经本模块全局名查找,patch 照常生效(拆模块老规矩,同 cache_guard)。
178
+ from cache_similarity import ( # noqa: F401
179
+ _hash_token,
180
+ _MINHASH_PERM,
181
+ _ngrams,
182
+ _signature,
183
+ is_freshness_sensitive_query,
184
+ normalize_query,
185
+ query_similarity,
186
+ split_qualifiers,
187
+ )
188
+
189
+
70
190
  # ── LRU 内存缓存 ───────────────────────────────────────────────────────────────
71
191
 
72
192
  class LRUCache:
@@ -98,6 +218,11 @@ class LRUCache:
98
218
  self._store.popitem(last=False)
99
219
  self._store[key] = value
100
220
 
221
+ def remove(self, key: str) -> None:
222
+ """移除指定键(不存在时静默)。"""
223
+ with self._lock:
224
+ self._store.pop(key, None)
225
+
101
226
  def clear(self):
102
227
  with self._lock:
103
228
  self._store.clear()
@@ -122,23 +247,114 @@ class SQLiteCache:
122
247
 
123
248
  SCHEMA_VERSION = 2
124
249
 
125
- def __init__(self, db_path: str = DEFAULT_DB_PATH, ttl: int = DEFAULT_TTL):
250
+ def __init__(self, db_path: str = DEFAULT_DB_PATH, ttl: int = DEFAULT_TTL,
251
+ max_size_mb: float | None = None):
126
252
  self._db_path = os.path.expanduser(db_path)
127
253
  self._ttl = ttl
254
+ # 驱逐阈值:显式参数 > 模块常量。**在 __init__ 取值而不是每次驱逐时读**,
255
+ # 这样测试 monkeypatch 模块常量(既有小库复现手法,见 test_cache_reclaim)
256
+ # 依然生效;生产侧的上限由 SearchCache 从 config.yaml 传下来。
257
+ self._max_size_mb = (float(max_size_mb)
258
+ if isinstance(max_size_mb, (int, float))
259
+ and not isinstance(max_size_mb, bool)
260
+ and max_size_mb > 0
261
+ else float(MAX_DB_SIZE_MB))
128
262
  self._lock = threading.RLock()
129
263
  self._hits = 0
130
264
  self._misses = 0
131
- os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
132
- self._init_db()
265
+ # 缓存是加速层而非功能依赖:目录不可建/不可写(只读挂载、沙箱、
266
+ # 权限受限、磁盘满)时降级为“不可用”而不是抛异常拖垮调用方。
267
+ self._degraded_reason: str | None = None
268
+ # :memory: 必须单连接(每次 connect(":memory:") 都是独立空库)
269
+ self._mem_conn: _get_sqlite3().Connection | None = None
270
+ # :memory: / 空路径 / URI 无需建目录
271
+ if self._db_path not in (":memory:", "") and not self._db_path.startswith("file:"):
272
+ parent = os.path.dirname(self._db_path)
273
+ if parent:
274
+ try:
275
+ os.makedirs(parent, exist_ok=True)
276
+ except OSError as e:
277
+ self._degraded_reason = f"cache dir unavailable: {e}"
278
+ if self._db_path == ":memory:":
279
+ # check_same_thread=False:SQLiteCache 随 SearchCache 常驻,MCP 长驻
280
+ # 进程里第一次碰缓存的线程未必是建连接的那个线程,默认的
281
+ # 「同线程校验」会直接抛 ProgrammingError 让整层内存缓存不可用。
282
+ # 这里放开校验是安全的——所有 _mem_conn 访问都在 self._lock
283
+ # (RLock,见上方 __init__)之内串行化,不存在真正的并发使用。
284
+ self._mem_conn = _get_sqlite3().connect(":memory:", timeout=10,
285
+ check_same_thread=False)
286
+ self._mem_conn.execute("PRAGMA synchronous=NORMAL")
287
+ if self._degraded_reason is None:
288
+ try:
289
+ self._init_db()
290
+ except _get_sqlite3().Error as e:
291
+ # 只读数据库 / 权限不足 / 磁盘满:整层降级,不向上传播
292
+ self._degraded_reason = f"cache db unavailable: {e}"
293
+ self._close_mem_conn()
294
+
295
+ def _close_mem_conn(self) -> None:
296
+ if self._mem_conn is not None:
297
+ try:
298
+ self._mem_conn.close()
299
+ except _get_sqlite3().Error:
300
+ pass
301
+ self._mem_conn = None
302
+
303
+ @property
304
+ def degraded(self) -> bool:
305
+ """缓存层是否已降级(此时读写均为 no-op,调用方应当前作未命中)。"""
306
+ return self._degraded_reason is not None
133
307
 
134
- def _connect(self) -> sqlite3.Connection:
135
- conn = sqlite3.connect(self._db_path, timeout=10)
136
- conn.execute("PRAGMA journal_mode=WAL")
308
+ @property
309
+ def degraded_reason(self) -> str | None:
310
+ return self._degraded_reason
311
+
312
+ def _connect(self) -> _get_sqlite3().Connection:
313
+ if self._mem_conn is not None:
314
+ return self._mem_conn
315
+ conn = _get_sqlite3().connect(self._db_path, timeout=10)
316
+ argo_paths.apply_state_pragmas(conn)
137
317
  conn.execute("PRAGMA synchronous=NORMAL")
138
318
  return conn
139
319
 
320
+ @contextlib.contextmanager
321
+ def _conn(self):
322
+ """借一个连接:出作用域提交/回滚,并把**自建**连接关掉。
323
+
324
+ 为什么不是 `with self._connect() as conn:`:sqlite3 的连接上下文管理器
325
+ 管的是**事务**(正常退出 commit、异常 rollback),它**不关连接**。于是
326
+ 此前 9 处调用点每跑一次就新建并遗弃一条文件连接——进程内跑几千次缓存
327
+ 操作就攒下几千个 fd,长驻 MCP 进程迟早撞上 ulimit(EMFILE),而报错点
328
+ 会落在完全无关的 socket/文件调用上,极难归因。
329
+
330
+ 这里把「作用域」补成真正的作用域语义:commit → close。异常时先
331
+ rollback 再关,事务状态不留半截。
332
+
333
+ `:memory:` 是**共享**连接(每次 connect(":memory:") 都是独立空库),
334
+ 不能关——关掉等于把整层内存缓存作废。锁不在这里取:调用点有的持锁
335
+ (读改写路径)、有的不持(_evict_if_needed / size_mb 只读统计),
336
+ 保持各自原语义。
337
+ """
338
+ conn = self._connect()
339
+ shared = conn is self._mem_conn
340
+ try:
341
+ yield conn
342
+ conn.commit()
343
+ except BaseException:
344
+ try:
345
+ conn.rollback()
346
+ except _get_sqlite3().Error:
347
+ pass
348
+ raise
349
+ finally:
350
+ if not shared:
351
+ try:
352
+ conn.close()
353
+ except _get_sqlite3().Error:
354
+ pass
355
+
140
356
  def _init_db(self):
141
- with self._connect() as conn:
357
+ with self._conn() as conn:
142
358
  conn.executescript("""
143
359
  CREATE TABLE IF NOT EXISTS search_cache (
144
360
  key TEXT PRIMARY KEY,
@@ -160,9 +376,32 @@ class SQLiteCache:
160
376
  conn.execute("ALTER TABLE search_cache ADD COLUMN domain TEXT DEFAULT 'general'")
161
377
  if "ttl" not in cols:
162
378
  conn.execute("ALTER TABLE search_cache ADD COLUMN ttl INTEGER DEFAULT 3600")
379
+ # mode/depth:软命中隔离用(见 find_similar)。
380
+ #
381
+ # 默认空串而不是 'auto'/'fast':这两列要回答的是「这条缓存是按哪个
382
+ # mode/depth 写出来的」,而迁移前的历史行**无从得知**。默认成
383
+ # auto/fast 等于替它们猜一个——deep 请求会软命中一条其实按 fast
384
+ # 写的条目,正是要修的那个 bug。空串与任何真实 mode/depth 都不相等,
385
+ # 于是历史行自然不参与软命中,等 TTL 到期回收即可(现在每小时扫一次)。
386
+ if "mode" not in cols:
387
+ conn.execute("ALTER TABLE search_cache ADD COLUMN mode TEXT DEFAULT ''")
388
+ if "depth" not in cols:
389
+ conn.execute("ALTER TABLE search_cache ADD COLUMN depth TEXT DEFAULT ''")
163
390
  conn.executescript("""
164
391
  CREATE INDEX IF NOT EXISTS idx_search_cache_expires ON search_cache(created_at);
165
392
  CREATE INDEX IF NOT EXISTS idx_search_cache_domain ON search_cache(domain);
393
+ -- accessed_at 是两条热查询的排序列(淘汰取最旧、find_similar 取最新)。
394
+ -- 没有它两者都退化成「全表扫描 + 临时 B 树排序」:EXPLAIN 实测
395
+ -- `SCAN search_cache` + `USE TEMP B-TREE FOR ORDER BY`。
396
+ CREATE INDEX IF NOT EXISTS idx_search_cache_accessed ON search_cache(accessed_at);
397
+ -- find_similar 是「WHERE domain = ? AND mode = ? AND depth = ?
398
+ -- ORDER BY accessed_at DESC」:过滤列与排序列合成一个索引,
399
+ -- 否则 SQLite 仍要建临时 B 树排序(EXPLAIN 实测 `USE TEMP B-TREE
400
+ -- FOR ORDER BY`)。旧的两列版 domain_accessed 由这条覆盖(前缀
401
+ -- 相同),留着只是给每次写入多维护一棵 B 树。
402
+ DROP INDEX IF EXISTS idx_search_cache_domain_accessed;
403
+ CREATE INDEX IF NOT EXISTS idx_search_cache_scope_accessed
404
+ ON search_cache(domain, mode, depth, accessed_at);
166
405
  """)
167
406
  conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
168
407
  ("schema_version", str(self.SCHEMA_VERSION)))
@@ -182,9 +421,29 @@ class SQLiteCache:
182
421
  raw = gzip.decompress(blob) if compressed else blob
183
422
  return json.loads(raw.decode("utf-8"))
184
423
 
424
+ def has_live(self, key: str) -> bool:
425
+ """该键是否已有一条**未过期**条目。
426
+
427
+ 只读探测:不动 accessed_at、不计命中/未命中、不删过期行——它要回答的
428
+ 只是「现在覆盖它会不会毁掉有效数据」,不是「这次查询命中了没有」。
429
+ 用 get() 兼职探测会污染 LRU 的访问时间,也会让命中率统计失真。
430
+ """
431
+ if self._degraded_reason is not None:
432
+ return False
433
+ with self._lock:
434
+ with self._conn() as conn:
435
+ row = conn.execute(
436
+ "SELECT created_at, ttl FROM search_cache WHERE key = ?", (key,)
437
+ ).fetchone()
438
+ return row is not None and not self._is_expired(row[0], row[1])
439
+
185
440
  def get(self, key: str) -> Optional[dict]:
441
+ if self._degraded_reason is not None:
442
+ # 降级:一律视作未命中。缓存是加速层,缺它只该变慢,不该变错。
443
+ self._misses += 1
444
+ return None
186
445
  with self._lock:
187
- with self._connect() as conn:
446
+ with self._conn() as conn:
188
447
  row = conn.execute(
189
448
  "SELECT value_blob, compressed, created_at, ttl FROM search_cache WHERE key = ?",
190
449
  (key,),
@@ -205,45 +464,241 @@ class SQLiteCache:
205
464
  return self._deserialize(value_blob, compressed)
206
465
 
207
466
  def set(self, key: str, query: str, engine: str, max_results: int,
208
- value: dict, domain: str = "general", ttl: int | None = None):
467
+ value: dict, domain: str = "general", ttl: int | None = None,
468
+ mode: str = "", depth: str = ""):
469
+ if self._degraded_reason is not None:
470
+ return
209
471
  with self._lock:
210
472
  blob, compressed = self._serialize(value)
211
473
  now = time.time()
212
474
  effective_ttl = ttl if ttl is not None else self._ttl
213
- with self._connect() as conn:
475
+ with self._conn() as conn:
214
476
  conn.execute(
215
477
  """INSERT OR REPLACE INTO search_cache
216
- (key, query, engine, max_results, domain, value_blob, compressed, ttl, created_at, accessed_at)
217
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
218
- (key, query, engine, max_results, domain, blob, compressed, effective_ttl, now, now),
478
+ (key, query, engine, max_results, domain, mode, depth,
479
+ value_blob, compressed, ttl, created_at, accessed_at)
480
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
481
+ (key, query, engine, max_results, domain, mode, depth,
482
+ blob, compressed, effective_ttl, now, now),
219
483
  )
220
484
  conn.commit()
221
- self._evict_if_needed()
485
+ # 驱逐失败不阻断写入方:缓存是加速层,缺它只该变慢不该变错。
486
+ # 与 self._degraded_reason 的 fail-open 语义同构——DB 损坏/磁盘满/WAL
487
+ # 异常时,宁可让下次搜索多写一条也不让 execute_search 在最后一步 traceback。
488
+ try:
489
+ self._evict_if_needed()
490
+ except _get_sqlite3().Error:
491
+ pass
492
+
493
+ # 过期行回收的节流间隔。扫描是 O(rows) 全表(`created_at + ttl < ?` 不可
494
+ # 走索引),放进每次 set() 的热路径会白付约 6ms/次;稳态下每小时一次足够
495
+ # 把库压在低位。
496
+ _EXPIRY_SWEEP_INTERVAL_S = 3600.0
497
+ # VACUUM 门槛:空闲页占比超过它才付一次整库重写(固定字节门槛两头都不对)。
498
+ _RECLAIM_FREE_RATIO = 0.2
499
+
500
+ def _expiry_sweep_due(self, conn) -> bool:
501
+ try:
502
+ row = conn.execute("SELECT value FROM meta WHERE key = ?",
503
+ ("expiry_swept_at",)).fetchone()
504
+ return (not row
505
+ or (time.time() - float(row[0])) > self._EXPIRY_SWEEP_INTERVAL_S)
506
+ except (_get_sqlite3().Error, TypeError, ValueError):
507
+ return True # 读不出来就扫一次,宁可多扫不积压
508
+
509
+ @staticmethod
510
+ def _mark_expiry_swept(conn) -> None:
511
+ try:
512
+ conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
513
+ ("expiry_swept_at", str(time.time())))
514
+ conn.commit()
515
+ except _get_sqlite3().Error:
516
+ pass
222
517
 
223
518
  def _evict_if_needed(self):
224
- """超过 100MB 时删除最旧的记录"""
225
- if self.size_mb <= MAX_DB_SIZE_MB:
226
- return
227
- with self._connect() as conn:
228
- target_mb = MAX_DB_SIZE_MB * 0.8
229
- while self.size_mb > target_mb:
230
- row = conn.execute("SELECT key FROM search_cache ORDER BY accessed_at ASC LIMIT 1").fetchone()
231
- if not row:
232
- break
233
- conn.execute("DELETE FROM search_cache WHERE key = ?", (row[0],))
519
+ """空间回收三步:过期行 → LRU 淘汰 → VACUUM。
520
+
521
+ **历史 bug(2026-09-19 复现)**:判定读的是文件页数
522
+ (page_count × page_size),扣减的却是 payload 净长。而本库
523
+ `auto_vacuum=0` 且全仓无 VACUUM,页数**永不下降**——库一旦超过
524
+ MAX_DB_SIZE_MB,`while total_bytes > target_bytes` 就永不收敛:
525
+ 每次 set() 把整库(含刚写入的那一行)删空,此后每次写入重复清空,
526
+ 缓存永久 100% miss。修法是让扣减与判定同源——进入淘汰循环后一律
527
+ 用 payload 口径,删完再 VACUUM 把文件真正缩回去(不 VACUUM 的话
528
+ 下一轮 set() 会看到同样的页数并重删一遍,正是这个 bug 的另一半)。
529
+
530
+ 另一半是**过期行**:它们对任何调用方都不可见(get() 命中即删),
531
+ 实测线上 5521 行里 5444 行(98.6%)已过期、占 payload 的 98.3%。
532
+ 不回收不只是占盘——find_similar 与淘汰扫描都要扫过这些永不命中的行。
533
+ 按节流间隔回收,稳态每小时一次。
534
+
535
+ 阈值口径(2026-10-15):limit 取 `self._max_size_mb`(构造时定下),
536
+ 不再直接读模块常量 MAX_DB_SIZE_MB——生产侧的上限现在来自 config.yaml
537
+ 的 `cache.max_size_mb`,改配置真的会改行为。
538
+ """
539
+ with self._conn() as conn:
540
+ limit_bytes = self._max_size_mb * 1024 * 1024
541
+
542
+ # 三条触发各自独立,代价都是 O(1):
543
+ # 过期清扫(节流到每小时一次,因为它要全表扫)
544
+ # LRU 淘汰(文件超限时)
545
+ # 纯回收(空闲页够多时——清扫刚删完一大片就会命中这条,
546
+ # 不把它独立出来,回收就会被节流挡到下一个清扫点,
547
+ # 文件白停在高水位一小时)
548
+ if self._expiry_sweep_due(conn):
549
+ conn.execute(
550
+ "DELETE FROM search_cache WHERE created_at + ttl < ?",
551
+ (time.time(),))
234
552
  conn.commit()
553
+ self._mark_expiry_swept(conn)
554
+
555
+ page_bytes = conn.execute(
556
+ "SELECT (SELECT page_count FROM pragma_page_count)"
557
+ " * (SELECT page_size FROM pragma_page_size)"
558
+ ).fetchone()[0] or 0
559
+ if page_bytes > limit_bytes:
560
+ # 按最近访问时间淘汰最旧行;扣减口径与判定同源,循环必然收敛
561
+ target_bytes = int(limit_bytes * 0.8)
562
+ payload = conn.execute(
563
+ "SELECT COALESCE(SUM(LENGTH(value_blob)), 0) FROM search_cache"
564
+ ).fetchone()[0] or 0
565
+ while payload > target_bytes:
566
+ rows = conn.execute(
567
+ "SELECT key, LENGTH(value_blob) FROM search_cache "
568
+ "ORDER BY accessed_at ASC LIMIT 50"
569
+ ).fetchall()
570
+ if not rows:
571
+ break
572
+ conn.executemany("DELETE FROM search_cache WHERE key = ?",
573
+ [(k,) for k, _ in rows])
574
+ payload -= sum((sz or 0) for _, sz in rows)
575
+ conn.commit()
576
+
577
+ # 删行不会让文件变小:页进 freelist,文件停在高水位。不回收的话
578
+ # 稳态下会留着一整块「只有空闲页」的库——实测线上 10.1MB 文件里
579
+ # 活数据只有 0.12MB。判据用空闲页占比而非固定字节:小库才会回收。
580
+ if self._free_bytes(conn) > self._RECLAIM_FREE_RATIO * (page_bytes or 1):
581
+ conn.execute("VACUUM")
582
+
583
+ @staticmethod
584
+ def _free_bytes(conn) -> int:
585
+ """空闲页占用的字节数(O(1),用于决定是否值得 VACUUM)。"""
586
+ return conn.execute(
587
+ "SELECT (SELECT freelist_count FROM pragma_freelist_count)"
588
+ " * (SELECT page_size FROM pragma_page_size)"
589
+ ).fetchone()[0] or 0
235
590
 
236
591
  def clear(self, older_than_hours: int = 24):
592
+ if self._degraded_reason is not None:
593
+ return
237
594
  with self._lock:
238
595
  cutoff = time.time() - older_than_hours * 3600
239
- with self._connect() as conn:
596
+ with self._conn() as conn:
240
597
  conn.execute("DELETE FROM search_cache WHERE created_at < ?", (cutoff,))
241
598
  conn.commit()
242
599
 
600
+ def find_similar(self, query: str, engine: str = "auto",
601
+ domain: str = "general", limit: int = 50,
602
+ threshold: float = 0.7,
603
+ mode: str = "auto", depth: str = "fast") -> list[dict]:
604
+ """近重复查询软命中:扫描最近缓存,minhash 相似度 ≥ threshold 的条目。
605
+
606
+ 返回 [{key, query, similarity}],按相似度降序。用于语义缓存——
607
+ 「苹果 2025 营收」可软命中「苹果 2025 年营收」的缓存。
608
+ 扫描限制 limit 条最近查询,控制成本。
609
+
610
+ 阈值 0.7:中文字符级 n-gram 下,「营收」vs「年营收」这类
611
+ 单字差异相似度约 0.75,0.7 可捕捉近重复且排除无关查询(≈0)。
612
+
613
+ engine 隔离(v2.4.2 修复):SQL 必须按 engine 列过滤。此前形参收了
614
+ engine 但 WHERE 子句只用 domain,软命中因此跨引擎串味——实测
615
+ `--engine v2ex` 可命中 bilibili 缓存;fetch 与 evidence 同 domain 下
616
+ URL 词面相似(如 x 与 x.md,相似度 0.875)会互相串正文。
617
+ 组合键(多引擎拼接的 `a+b`)不参与软命中:组合结果集是融合产物,
618
+ 与任何单引擎缓存都不可互换。
619
+
620
+ **engine 现在是精确匹配,不再是「auto 通配」**(2026-09-19 随之调整):
621
+ engine 列从「路由出来的引擎组合」改存「请求侧身份」(用户点名的引擎
622
+ 或 auto,见 search.execute_search 的 cache_engine_key 说明)。语义变了,
623
+ 通配的含义也跟着变——以前 `auto` 不是列里会出现的值,通配分支其实
624
+ 从未被走到(组合列要么是 `a+b` 要么是单引擎名);现在 `auto` 是真实的
625
+ 请求身份,再通配就等于让「自动路由」的请求去吃「显式指定 pypi」的
626
+ 缓存,正是 v2.4.2 要挡的跨引擎串味。请求不同 → 结果不可互换。
627
+
628
+ 限定符隔离(2026-09-18 修复):限定符是过滤器而非内容,整串相似度
629
+ 会被它主导——`keywords:pi-package mcp` 与 `keywords:pi-package
630
+ memory` 整串相似度 0.875,判别词只占几个字符,于是软命中把另一条
631
+ 查询的结果交了出去(实测 `--engine npm` 查 mcp 拿到 memory 那批包)。
632
+ 现在先要求限定符集合相同,再比**载荷**的相似度;无限定符的查询载荷
633
+ 即整串,行为不变。
634
+
635
+ mode/depth 隔离(2026-09-19 修复):这是同一类串味的第三处,前两处
636
+ (engine、限定符)修的时候漏了它。`_key` 刻意把 mode/depth 编进键、
637
+ 类 docstring 也承诺「depth / mode 隔离,防 fast/deep、budget 污染」,
638
+ 但软命中的 WHERE 只有 domain+engine:`--depth deep` 请求会软命中一条
639
+ 按 fast 写的条目(或反过来),拿到的结果集与请求档位不匹配,而结果里
640
+ 还报 `cached: true`。现在 WHERE 带上 mode/depth 精确匹配——它们本来
641
+ 就是「结果集全集的组成部分」,与限定符同理:档位不同,结果就不可互换。
642
+ """
643
+ nq = normalize_query(query)
644
+ nq_quals, nq_payload = split_qualifiers(nq)
645
+ base_len = len(nq_payload)
646
+ if self._degraded_reason is not None:
647
+ return []
648
+ # engine 精确匹配(请求身份不同 → 结果不可互换,见上方 docstring)
649
+ with self._lock:
650
+ with self._conn() as conn:
651
+ rows = conn.execute(
652
+ "SELECT key, query, engine, domain, ttl, created_at "
653
+ "FROM search_cache "
654
+ "WHERE domain = ? AND mode = ? AND depth = ? AND engine = ? "
655
+ "ORDER BY accessed_at DESC LIMIT ?",
656
+ (domain, mode, depth, engine, limit),
657
+ ).fetchall()
658
+ candidates = []
659
+ for key, cached_q, cached_engine, cached_dom, ttl, created_at in rows:
660
+ if not cached_q or cached_q == nq:
661
+ continue
662
+ if self._is_expired(created_at, ttl):
663
+ continue
664
+ # 多引擎请求不参与软命中:融合产物 ≠ 任何单引擎结果集。
665
+ # `+` 是历史写法(engine 列曾存路由出来的组合),`,` 是现在
666
+ # 请求身份的形状(`--engine a,b` 的 engine_request 就是原串),
667
+ # 两个都挡——只挡 `+` 会让逗号多引擎请求偷偷进入软命中。
668
+ if "+" in (cached_engine or "") or "," in (cached_engine or ""):
669
+ continue
670
+ cnq = normalize_query(cached_q)
671
+ c_quals, c_payload = split_qualifiers(cnq)
672
+ # 限定符隔离:过滤器不同 → 结果全集不同,任何相似度都不成立
673
+ if c_quals != nq_quals:
674
+ continue
675
+ clen = len(c_payload)
676
+ # 长度约束:差异过大(>50%)不可能是近重复
677
+ if base_len > 0 and abs(clen - base_len) / max(base_len, 1) > 0.5:
678
+ continue
679
+ # 比载荷而非整串:限定符已在上面单独比对,留在串里只会稀释判据
680
+ sim = query_similarity(nq_payload, c_payload)
681
+ if sim >= threshold:
682
+ candidates.append({
683
+ "key": key, "query": cached_q, "similarity": round(sim, 3),
684
+ })
685
+ candidates.sort(key=lambda x: -x["similarity"])
686
+ return candidates
687
+
243
688
  @property
244
689
  def stats(self) -> dict:
690
+ if self._degraded_reason is not None:
691
+ return {
692
+ "hits": self._hits,
693
+ "misses": self._misses,
694
+ "hit_rate": round(self._hits / max(self._hits + self._misses, 1), 3),
695
+ "size_mb": 0.0,
696
+ "entries": 0,
697
+ "degraded": True,
698
+ "degraded_reason": self._degraded_reason,
699
+ }
245
700
  with self._lock:
246
- with self._connect() as conn:
701
+ with self._conn() as conn:
247
702
  row = conn.execute("SELECT COUNT(*), SUM(LENGTH(value_blob)) FROM search_cache").fetchone()
248
703
  return {
249
704
  "hits": self._hits,
@@ -255,77 +710,633 @@ class SQLiteCache:
255
710
 
256
711
  @property
257
712
  def size_mb(self) -> float:
258
- with self._connect() as conn:
259
- row = conn.execute("SELECT SUM(LENGTH(value_blob)) FROM search_cache").fetchone()
713
+ # O(1) 页统计代替 O(rows) 全表 blob 扫描。MAX_DB_SIZE_MB 语义本就是
714
+ # 「磁盘容量阈值」,新口径(文件占用)比旧口径(净 payload 求和)更贴原意;
715
+ # 实测差异 ~50%(7.4MB vs 4.9MB),驱逐会更早触发一点,对 100MB 阈值无实质影响。
716
+ # 旧实现每次 `_evict_if_needed` 冷启动 21.4ms、温 10ms;新实现 <0.1ms。
717
+ if self._degraded_reason is not None:
718
+ return 0.0
719
+ with self._conn() as conn:
720
+ row = conn.execute(
721
+ "SELECT (SELECT page_count FROM pragma_page_count)"
722
+ " * (SELECT page_size FROM pragma_page_size)"
723
+ ).fetchone()
260
724
  return (row[0] or 0) / 1024 / 1024
261
725
 
262
726
 
263
727
  # ── 双层缓存入口 ───────────────────────────────────────────────────────────────
264
728
 
729
+ # 空结果正缓存极短 TTL(秒)— 避免把失败固化成「无结果」
730
+ EMPTY_RESULT_TTL = 45
731
+ # fetch URL 默认 TTL
732
+ FETCH_DEFAULT_TTL = 3600
733
+
734
+ # 证据分在 fetch 条目里的子键名。做成模块级常量而不是类属性:类会被测试
735
+ # 用工厂函数替换(monkeypatch SearchCache),此时 `SearchCache.KEY` 取不到,
736
+ # 写入路径会静默失败——静默正是这类改动最难发现的地方。
737
+ FETCH_EVIDENCE_KEY = "evidence"
738
+
739
+ # 取数内容管线版本——它是 fetch 缓存键的一部分,不是元数据。
740
+ #
741
+ # 为什么必须进键:正文缓存按 URL 复用,但「同一个 URL 抓出来的正文长什么样」
742
+ # 取决于产出方式(提取算法、是否走内容协商、降级链顺序)。这些一改,旧条目
743
+ # 就不再是同一份内容。不进键的后果是——改进上线后,先前缓存过的 URL 继续返回
744
+ # 旧的低质正文,新旧并存且无法从外部诊断(用户只会觉得「时好时坏」)。
745
+ #
746
+ # 因此:凡改动正文产出方式,把这个数字 +1。旧条目自然失联、TTL 到期回收,
747
+ # 无需手工清库,也不会把「缓存没刷新」误判成「优化没生效」。
748
+ FETCH_PIPELINE_VERSION = 2
749
+
750
+
751
+ class LoginCacheRejected(ValueError):
752
+ """登录态 / 不可缓存载荷禁止写入公共 SearchCache。"""
753
+
754
+
755
+ def is_login_partition_payload(payload: object) -> bool:
756
+ """是否为登录态分区载荷(不得进入公共 unified-search 缓存)。
757
+
758
+ 判定(任一命中):
759
+ - login_state_used is True
760
+ - cache_eligible is False
761
+ - auth_partition 以 login 开头(如 login / login:zhihu.com)
762
+ - source / engine 含 ego-browser / ego_browser(浏览器登录态检索)
763
+ """
764
+ if not isinstance(payload, dict):
765
+ return False
766
+ if payload.get("login_state_used") is True:
767
+ return True
768
+ if payload.get("cache_eligible") is False:
769
+ return True
770
+ auth = payload.get("auth_partition")
771
+ if isinstance(auth, str) and auth.lower().startswith("login"):
772
+ return True
773
+ for key in ("source", "engine", "backend"):
774
+ val = payload.get(key)
775
+ if isinstance(val, str):
776
+ low = val.lower()
777
+ if "ego-browser" in low or "ego_browser" in low:
778
+ return True
779
+ return False
780
+
781
+
782
+ def assert_cacheable(payload: object, *, context: str = "cache") -> None:
783
+ """公共 SearchCache 写入守卫:登录态结果硬拒绝。
784
+
785
+ 登录态检索(ego-search 等)必须走独立分区或默认不缓存 body;
786
+ 禁止污染 ~/.cache/unified-search/cache.db。
787
+ """
788
+ if is_login_partition_payload(payload):
789
+ raise LoginCacheRejected(
790
+ f"{context}: login-partition / cache_eligible=false payload "
791
+ "must not enter public SearchCache"
792
+ )
793
+
794
+
795
+ def assert_results_cacheable(results: object, *, context: str) -> None:
796
+ """逐条检查结果列表——**不抽样**。
797
+
798
+ 2026-09-19 修复:守卫此前只覆盖顶层载荷,于是两条真实缺口:
799
+
800
+ - `SearchCache.set`(combo,主写入路径)对 `results[]` **一条都不查**——
801
+ 实测把 `login_state_used: True` 放在结果列表第 1 条,照样写进公共库;
802
+ - `set_engine` 只查 `results[:3]`,第 4 条起不查。
803
+
804
+ 「硬拒绝」被降级成「抽样拒绝」,而登录态标记的生产者是存在的
805
+ (candidate_envelope / plan 会在**逐条**结果上写 login_state_used /
806
+ auth_partition / cache_eligible)。条目数本来就有上限(几十条),
807
+ 逐条检查的代价可忽略,没有理由抽样。
808
+ """
809
+ if not isinstance(results, list):
810
+ return
811
+ for item in results:
812
+ if isinstance(item, dict):
813
+ assert_cacheable(item, context=context)
814
+
815
+
265
816
  class SearchCache:
266
817
  """
267
818
  双层缓存引擎:L1 LRU + L2 SQLite
268
- 缓存键 = SHA256(query + "|" + engine + "|" + str(max_results))[:32]
819
+
820
+ 缓存键(v2.4.1)= SHA256(kind|norm_query|engine|domain|mode|depth)[:32]
821
+ - query 归一化后入 key(空白/大小写)
822
+ - 不含 max_results:支持柔性命中(cached_n >= requested_n 可截断返回)
823
+ - depth / mode 隔离,防 fast/deep、budget 污染
824
+ - 时效敏感 query 强制 TTL ≤ REALTIME_TTL_CAP
825
+ - 登录态载荷(login_state_used / cache_eligible=false / ego-browser)
826
+ 在 set / set_engine / set_fetch 入口硬拒绝,与公共缓存隔离
827
+
828
+ 分层:
829
+ combo 结果 / per-engine 结果 / fetch URL(前缀区分)
269
830
  """
270
831
 
271
832
  def __init__(self, db_path: str = DEFAULT_DB_PATH, ttl: int = DEFAULT_TTL):
272
833
  cfg = get_cache_config()
273
- self._db_path = os.path.expanduser(cfg.get("db_path", db_path))
834
+ # 允许测试传入显式 db_path 覆盖配置
835
+ if db_path != DEFAULT_DB_PATH:
836
+ self._db_path = os.path.expanduser(db_path)
837
+ else:
838
+ # ARGO_STATE_DIR 是硬开关,不能被磁盘 config.yaml 的 db_path 盖掉;
839
+ # 未设置时才尊重 config 的显式配置。
840
+ self._db_path = str(argo_paths.db_path())
274
841
  self._ttl = cfg.get("ttl", ttl)
842
+ # config.yaml 的 `cache.enabled` / `cache.max_size_mb` 此前是**死配置**:
843
+ # 两个键写在配置里、默认值也定义了,代码却从不读——关缓存关不掉、改上限
844
+ # 不生效,只能改源码。现在两处都接线(2026-10-15)。
845
+ self._enabled = bool(cfg.get("enabled", True))
846
+ self._max_size_mb = cfg.get("max_size_mb")
275
847
  self._l1 = LRUCache(max_size=MAX_MEMORY_ITEMS)
276
- self._l2 = SQLiteCache(db_path=self._db_path, ttl=self._ttl)
848
+ self._l2 = SQLiteCache(db_path=self._db_path, ttl=self._ttl,
849
+ max_size_mb=self._max_size_mb)
277
850
 
278
851
  @staticmethod
279
- def _key(query: str, engine: str, max_results: int) -> str:
280
- raw = f"{query}|{engine}|{max_results}"
852
+ def _key(query: str, engine: str, max_results: int = 0, domain: str = "general",
853
+ mode: str = "auto", depth: str = "fast", kind: str = "combo",
854
+ since: str | None = None, until: str | None = None,
855
+ **vdom) -> str:
856
+ """生成缓存键。max_results 不参与 key(柔性命中);kind 区分 combo/engine/fetch。"""
857
+ raw = f"{kind}|{normalize_query(query)}|{engine}|{domain}|{mode}|{depth}"
858
+ for tag, val in (("since", since), ("until", until), *cache_key_vdom(vdom)):
859
+ if val:
860
+ raw += f"|{tag}={val}"
281
861
  return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
282
862
 
283
863
  @staticmethod
284
- def resolve_ttl(domain: str = "general") -> int:
285
- """根据 domain 返回 TTL(秒)。"""
864
+ def resolve_ttl(domain: str = "general", query: str | None = None) -> int:
865
+ """根据 domain(及可选 query 时效信号)返回基础 TTL(秒)。"""
286
866
  tier = DOMAIN_TIER_MAP.get(domain, "general")
287
- return CACHE_TIERS.get(tier, DEFAULT_TTL)
867
+ ttl = CACHE_TIERS.get(tier, DEFAULT_TTL)
868
+ if query and is_freshness_sensitive_query(query):
869
+ ttl = min(ttl, REALTIME_TTL_CAP)
870
+ # 时效域硬上限,防止调用方传入超长 base_ttl 后绕过
871
+ if tier in ("financial", "news", "realtime"):
872
+ cap = {"financial": 300, "news": 600, "realtime": REALTIME_TTL_CAP}[tier]
873
+ ttl = min(ttl, cap)
874
+ return ttl
288
875
 
289
- def get(self, query: str, engine: str, max_results: int,
290
- domain: str = "general") -> Optional[dict]:
291
- """先查 L1,未命中再查 L2。"""
292
- key = self._key(query, engine, max_results)
876
+ @staticmethod
877
+ def _seconds_until_end_of_day() -> int:
878
+ """距本地当天 23:59:59 的剩余秒数(aware 而非 naive 是为 DST 切换日)。"""
879
+ import datetime
880
+ now = datetime.datetime.now().astimezone()
881
+ end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=0)
882
+ return max(int((end_of_day - now).total_seconds()), 60)
883
+
884
+ def _resolve_effective_ttl(self, domain: str, base_ttl: int | None = None,
885
+ query: str | None = None) -> int:
886
+ """解析有效 TTL:仅 research/evergreen 可日末延长;时效 query 强制 cap。"""
887
+ tier = DOMAIN_TIER_MAP.get(domain, "general")
888
+ if base_ttl is not None:
889
+ ttl = base_ttl
890
+ else:
891
+ ttl = self.resolve_ttl(domain, query=query)
892
+
893
+ # 时效敏感查询:禁止日末延长,硬 cap
894
+ if query and is_freshness_sensitive_query(query):
895
+ return min(ttl, REALTIME_TTL_CAP)
896
+
897
+ if tier in ("financial", "news", "realtime"):
898
+ cap = {"financial": 300, "news": 600, "realtime": REALTIME_TTL_CAP}[tier]
899
+ return min(ttl, cap)
900
+
901
+ if tier in SAME_DAY_ELIGIBLE_TIERS and base_ttl is None:
902
+ return max(ttl, self._seconds_until_end_of_day())
903
+ return ttl
904
+
905
+ def _disabled(self) -> bool:
906
+ """`cache.enabled: false` —— 整层缓存停用。
907
+
908
+ 语义是「当没有缓存」而不是「有缓存但不写」:读恒 miss(每次调用都
909
+ 真的去打上游)、写一律丢弃、不落盘。关掉写却不关读最危险——旧条目会
910
+ 在 TTL 内继续被交付,用户以为「缓存已关」但拿到的仍是陈货。
911
+ """
912
+ return not self._enabled
913
+
914
+ @property
915
+ def enabled(self) -> bool:
916
+ return self._enabled
917
+
918
+ def _read(self, key: str) -> Optional[dict]:
919
+ if self._disabled():
920
+ return None
921
+ # 返回浅拷贝 + results 列表拷贝:缓存持有数据的所有权,下游对 results
922
+ # 的原地改写(如 _engine 标记、rerank 字段)不得污染 store。
923
+ # 实测:50KB payload 的 deepcopy 约 5-15ms,浅拷贝 <0.5ms。
293
924
  hit = self._l1.get(key)
294
925
  if hit is not None:
295
- ttl = hit.get("_ttl", self.resolve_ttl(domain))
296
- if time.time() - hit.get("_ts", 0) < ttl:
297
- hit["_cache_level"] = "L1"
298
- return hit
299
- self._l1._store.pop(key, None)
926
+ ttl = hit.get("_ttl", 0)
927
+ if ttl > 0 and time.time() - hit.get("_ts", 0) < ttl:
928
+ out = dict(hit)
929
+ out["results"] = [dict(r) if isinstance(r, dict) else r
930
+ for r in (hit.get("results") or [])]
931
+ out["_cache_level"] = "L1"
932
+ return out
933
+ self._l1.remove(key)
300
934
 
301
935
  hit = self._l2.get(key)
302
936
  if hit is not None:
303
937
  self._l1.set(key, hit)
304
- hit["_cache_level"] = "L2"
305
- return hit
938
+ out = dict(hit)
939
+ out["results"] = [dict(r) if isinstance(r, dict) else r
940
+ for r in (hit.get("results") or [])]
941
+ out["_cache_level"] = "L2"
942
+ return out
306
943
  return None
307
944
 
945
+ def _write(self, key: str, query: str, engine: str, max_results: int,
946
+ value: dict, domain: str, ttl: int,
947
+ mode: str = "", depth: str = "") -> None:
948
+ if self._disabled():
949
+ return
950
+ payload = {**value, "_domain": domain, "_ttl": ttl, "_ts": time.time(),
951
+ "_max_results": max_results}
952
+ self._l1.set(key, payload)
953
+ self._l2.set(key, query, engine, max_results, payload, domain=domain,
954
+ ttl=ttl, mode=mode, depth=depth)
955
+
956
+ @staticmethod
957
+ def _soft_slice(hit: dict, max_results: int) -> Optional[dict]:
958
+ """柔性命中:缓存条数足够则截断返回;不足则 miss 以便升级拉取。"""
959
+ results = hit.get("results")
960
+ if results is None:
961
+ # fetch 等非 results 形态直接返回
962
+ return hit
963
+ cached_n = int(hit.get("_max_results") or len(results) or 0)
964
+ if cached_n >= max_results or len(results) >= max_results:
965
+ out = dict(hit)
966
+ out["results"] = list(results)[:max_results]
967
+ out["count"] = len(out["results"])
968
+ out["_soft_hit"] = True
969
+ return out
970
+ return None # 需要更多条 → 视为 miss
971
+
972
+ def get(self, query: str, engine: str, max_results: int,
973
+ domain: str = "general", mode: str = "auto",
974
+ depth: str = "fast") -> Optional[dict]:
975
+ """先查 L1,未命中再查 L2。支持 depth 隔离 + max_results 柔性命中。"""
976
+ if self._disabled():
977
+ # 提前返回:_read 已被 guard 挡住,但下面的语义软命中会直接问 L2,
978
+ # 不在这里拦就会「读返回 None 却仍交出一条旧缓存」。
979
+ return None
980
+ key = self._key(query, engine, max_results, domain, mode, depth, kind="combo")
981
+ hit = self._read(key)
982
+ if hit is None:
983
+ # 语义软命中:精确 miss 时,minhash 找近重复查询的缓存
984
+ if domain != "general" or mode == "auto":
985
+ try:
986
+ similar = self._l2.find_similar(query, engine, domain,
987
+ mode=mode, depth=depth)
988
+ for cand in similar:
989
+ s_hit = self._l2.get(cand["key"])
990
+ if s_hit is None:
991
+ continue
992
+ sres = s_hit.get("results")
993
+ if isinstance(sres, list) and not sres:
994
+ # 负缓存(空结果条目)不进软命中:那是**别的查询**
995
+ # 的瞬时失败,交出去后上层拿到 cached=true、count=0,
996
+ # 无法区分「本查询没结果」与「别的查询失败了」
997
+ # (2026-10-17 实测:sim=0.75 时「苹果 2025 营收」
998
+ # 会软命中「苹果 2025 年营收」的负缓存)。
999
+ continue
1000
+ sliced = self._soft_slice(s_hit, max_results)
1001
+ if sliced is not None:
1002
+ out = dict(sliced)
1003
+ out["_cache_level"] = "L2"
1004
+ out["_semantic_hit"] = True
1005
+ out["_semantic_similarity"] = cand["similarity"]
1006
+ out["_semantic_query"] = cand["query"]
1007
+ # L1 只回填「本请求 key 视角」的载荷(含来源与软命中标记),
1008
+ # 不回填他人原始 s_hit:后者既无归属标记也无 TTL 约束,
1009
+ # 会在 L2 过期后继续以本 key 的身份存活,把一次软命中
1010
+ # 固化成硬命中(v2.4.2 修复)。
1011
+ self._l1.set(key, out)
1012
+ return out
1013
+ except IO_BENIGN:
1014
+ pass
1015
+ return None
1016
+ sliced = self._soft_slice(hit, max_results)
1017
+ return sliced
1018
+
308
1019
  def set(self, query: str, engine: str, max_results: int, results: dict,
309
- domain: str = "general", ttl: int | None = None):
310
- """写入双层缓存。"""
311
- key = self._key(query, engine, max_results)
312
- effective_ttl = ttl if ttl is not None else self.resolve_ttl(domain)
313
- value = {**results, "_domain": domain, "_ttl": effective_ttl, "_ts": time.time()}
314
- self._l1.set(key, value)
315
- self._l2.set(key, query, engine, max_results, value, domain=domain, ttl=effective_ttl)
1020
+ domain: str = "general", ttl: int | None = None, mode: str = "auto",
1021
+ depth: str = "fast", recovered: bool = False):
1022
+ """写入双层缓存。空结果强制短 TTL;时效 query 强制 cap。
1023
+
1024
+ 自适应 TTL:内容稳定的查询自动延长 TTL(上限为域 TTL),
1025
+ 内容频繁变化则保持短 TTL,兼顾命中率与新鲜度。
1026
+ 登录态 / cache_eligible=false 载荷硬拒绝(LoginCacheRejected)。
1027
+
1028
+ recovered=True(恢复链成功产出的批次):整批退化时不硬拒,降级为
1029
+ 短 TTL 写入(RECOVERED_DEGRADED_TTL)——理由见下方守卫块注释。
1030
+ """
1031
+ assert_cacheable(results, context="SearchCache.set")
1032
+ # engine 名本身也可能标记登录态源
1033
+ assert_cacheable({"engine": engine, "source": engine}, context="SearchCache.set")
1034
+ result_list = results.get("results") if isinstance(results, dict) else None
1035
+ # 逐条查:登录态标记的生产者在**逐条**结果上(见 assert_results_cacheable)
1036
+ assert_results_cacheable(result_list, context="SearchCache.set")
1037
+ # 退化守卫:上游抖动返回的残次品不得固化(见 is_degraded_results)。
1038
+ # 例外(2026-10-17):recovered 批次不整批拒写——硬拒把「难查询的恢复
1039
+ # 成果」与「上游抖动残次品」一同挡在缓存外,重复查询 miss 后每次重付
1040
+ # dispatch+恢复链全程(实测同查询 21s 内 3 次恢复链,越难查越进不去
1041
+ # 缓存的结构性循环)。降级为短 TTL:重复查询有命中,低相关不长期固化。
1042
+ recovered_degraded = False
1043
+ try:
1044
+ assert_not_degraded(result_list, context="SearchCache.set")
1045
+ except DegradedCacheRejected:
1046
+ if not recovered:
1047
+ raise
1048
+ recovered_degraded = True
1049
+ # 失败态守卫(2026-09-29):缺密钥/熔断/鉴权/配额且无有效结果不写负缓存
1050
+ assert_not_failed_state(results, context="SearchCache.set")
1051
+ is_empty = isinstance(result_list, list) and len(result_list) == 0
1052
+ key = self._key(query, engine, max_results, domain, mode, depth, kind="combo")
1053
+ if is_empty:
1054
+ # 瞬时失败不得销毁好数据(2026-09-19 修复)。
1055
+ #
1056
+ # EMPTY_RESULT_TTL 的意图是「别把一次失败固化成『这个查询没结果』」,
1057
+ # 但写入走的是 INSERT OR REPLACE——于是同键上一条还有一小时寿命的
1058
+ # 有效缓存,会被一次网络抖动产生的空结果整条覆盖掉(实测复现)。
1059
+ # 负缓存仍然要留(那正是短 TTL 的用途),只是**不覆盖已有活条目**:
1060
+ # 有活条目说明上一次取到了东西,它比这次的失败更可信。
1061
+ if self._l2.has_live(key):
1062
+ return
1063
+ effective_ttl = EMPTY_RESULT_TTL if ttl is None else min(ttl, EMPTY_RESULT_TTL)
1064
+ else:
1065
+ effective_ttl = self._resolve_effective_ttl(domain, ttl, query=query)
1066
+ effective_ttl = self._adaptive_ttl(
1067
+ query, engine, domain, effective_ttl, result_list,
1068
+ mode=mode, depth=depth,
1069
+ )
1070
+ if recovered_degraded:
1071
+ effective_ttl = min(effective_ttl, RECOVERED_DEGRADED_TTL)
1072
+ self._write(key, query, engine, max_results, results, domain, effective_ttl,
1073
+ mode=mode, depth=depth)
1074
+
1075
+ def _adaptive_ttl(self, query: str, engine: str, domain: str,
1076
+ base_ttl: int, result_list: list,
1077
+ mode: str = "auto", depth: str = "fast") -> int:
1078
+ """自适应 TTL:对比同查询旧缓存内容哈希,稳定则延长 TTL。
1079
+
1080
+ 内容稳定(哈希一致)→ TTL 延长到 base_ttl * 2(上限域 TTL);
1081
+ 内容变化 → 保持 base_ttl。仅对非时效查询生效,避免影响新鲜度。
1082
+
1083
+ 旧键必须与实际写入键同 mode/depth,否则 fast/budget 等模式下
1084
+ 永远查不到上一轮缓存,自适应延长静默失效。
1085
+ """
1086
+ if not query or is_freshness_sensitive_query(query):
1087
+ return base_ttl
1088
+ if not result_list:
1089
+ return base_ttl
1090
+ try:
1091
+ key = self._key(query, engine, 0, domain, mode, depth, kind="combo")
1092
+ old = self._l2.get(key)
1093
+ if old is None:
1094
+ return base_ttl
1095
+ old_results = old.get("results") if isinstance(old, dict) else None
1096
+ if not isinstance(old_results, list) or not old_results:
1097
+ return base_ttl
1098
+ # 内容指纹:title+url 前 N 条
1099
+ def _fp(rs: list) -> tuple:
1100
+ return tuple(
1101
+ (r.get("url", ""), (r.get("title", "") or "")[:50])
1102
+ for r in rs[:5]
1103
+ )
1104
+ if _fp(old_results) == _fp(result_list):
1105
+ cap = {"financial": 300, "news": 600, "realtime": REALTIME_TTL_CAP}.get(
1106
+ DOMAIN_TIER_MAP.get(domain, "general"))
1107
+ # 稳定 → 延长 2×base;有硬帽层不超帽、不反向压缩日末延长;无帽层直接 2×
1108
+ return min(base_ttl * 2, max(base_ttl, cap)) if cap else base_ttl * 2
1109
+ return base_ttl
1110
+ except SHAPE_BENIGN:
1111
+ return base_ttl
1112
+
1113
+ # ── per-engine 结果缓存 ──────────────────────────────────────────────────
1114
+
1115
+ def get_engine(self, query: str, engine: str, max_results: int,
1116
+ domain: str = "general", mode: str = "auto",
1117
+ depth: str = "fast", since: str | None = None,
1118
+ until: str | None = None, **vdom) -> Optional[list]:
1119
+ key = self._key(query, engine, max_results, domain, mode, depth, kind="engine",
1120
+ since=since, until=until, **vdom)
1121
+ hit = self._read(key)
1122
+ if hit is None:
1123
+ return None
1124
+ sliced = self._soft_slice(hit, max_results)
1125
+ if sliced is None:
1126
+ return None
1127
+ return list(sliced.get("results") or [])
1128
+
1129
+ def set_engine(self, query: str, engine: str, max_results: int,
1130
+ results: list, domain: str = "general", mode: str = "auto",
1131
+ depth: str = "fast", ttl: int | None = None,
1132
+ since: str | None = None, until: str | None = None, **vdom):
1133
+ assert_cacheable({"engine": engine, "source": engine}, context="SearchCache.set_engine")
1134
+ assert_results_cacheable(results, context="SearchCache.set_engine")
1135
+ assert_not_degraded(results, context="SearchCache.set_engine")
1136
+ is_empty = not results
1137
+ key = self._key(query, engine, max_results, domain, mode, depth, kind="engine",
1138
+ since=since, until=until, **vdom)
1139
+ if is_empty and self._l2.has_live(key):
1140
+ # 瞬时空结果不得覆盖同键仍有寿命的好条目(同族:combo 层 2026-09-19 修复)
1141
+ return
1142
+ if is_empty:
1143
+ effective_ttl = EMPTY_RESULT_TTL if ttl is None else min(ttl, EMPTY_RESULT_TTL)
1144
+ else:
1145
+ effective_ttl = self._resolve_effective_ttl(domain, ttl, query=query)
1146
+ self._write(key, query, engine, max_results, {"results": results}, domain,
1147
+ effective_ttl, mode=mode, depth=depth)
1148
+
1149
+ # ── fetch URL 缓存 ───────────────────────────────────────────────────────
1150
+
1151
+ def get_fetch(self, url: str) -> Optional[dict]:
1152
+ return self._read(self._fetch_key(url))
1153
+
1154
+ def set_fetch(self, url: str, payload: dict, ttl: int = FETCH_DEFAULT_TTL):
1155
+ """写入 fetch 缓存。登录态正文硬拒绝,防止同 URL 登录页污染公共库。"""
1156
+ assert_cacheable(payload, context="SearchCache.set_fetch")
1157
+ self._write(self._fetch_key(url), url, "fetch", 0, payload, "fetch", ttl)
1158
+
1159
+ # 取数可用性:把「取不到」与「取到了但没用」分开。
1160
+ #
1161
+ # 依据是同一批本地记录——正文字段来自 fetch 条目,反面字段同样来自它。
1162
+ # 分流的理由:调用方对这两种失败要做的事完全不同。
1163
+ # blocked(系统类:robots / 明确拒绝)→ 换源,别在它身上耗
1164
+ # poor (内容类:登录墙 / 付费墙 / JS 壳 / 极短)→ 换源或降置信
1165
+ # 两者都不该与「还没试过」混为一谈——实测 14% 的抓取建议指向 robots
1166
+ # 明令禁止的地址,搜索阶段就能判定,却仍然被当作「值得一抓」。
1167
+ _POOR_PAGE_TYPES = ("auth_wall", "paywall", "js_shell")
1168
+ _POOR_MIN_CHARS = 200
1169
+
1170
+ def local_status(self, urls: list[str]) -> dict[str, dict]:
1171
+ """一次遍历给出每个 URL 的本地正文与取数可用性。零联网。
1172
+
1173
+ 返回 {url: {"body": {...}|None, "retrieval": {...}}},只含本地有记录的 URL。
1174
+ """
1175
+ out: dict[str, dict] = {}
1176
+ if not urls:
1177
+ return out
1178
+ try:
1179
+ from fulltext_store import path_for as _ft_path
1180
+ except ImportError:
1181
+ _ft_path = None
1182
+ try:
1183
+ from robots_guard import known_blocked as _known_blocked
1184
+ except ImportError:
1185
+ _known_blocked = None
1186
+
1187
+ for url in urls:
1188
+ if not url:
1189
+ continue
1190
+ try:
1191
+ hit = self._read(self._fetch_key(url))
1192
+ except IO_BENIGN + SHAPE_BENIGN:
1193
+ hit = None
1194
+ entry: dict = {}
1195
+
1196
+ # ① 反面记录:以前抓过、结果不可用
1197
+ if hit and not hit.get("success"):
1198
+ err = str(hit.get("error") or "")
1199
+ ret = {"status": "blocked" if hit.get("fetch_method") == "robots_blocked"
1200
+ else "poor",
1201
+ "reason": "robots" if hit.get("fetch_method") == "robots_blocked"
1202
+ else (hit.get("fetch_method") or "fetch_failed")}
1203
+ if err:
1204
+ ret["detail"] = err[:120]
1205
+ entry["retrieval"] = ret
1206
+ elif hit and hit.get("success"):
1207
+ pt = str(hit.get("page_type") or "")
1208
+ ln = int(hit.get("length") or 0)
1209
+ if pt in self._POOR_PAGE_TYPES:
1210
+ entry["retrieval"] = {"status": "poor", "reason": pt,
1211
+ "detail": f"length={ln}"}
1212
+ elif ln < self._POOR_MIN_CHARS:
1213
+ entry["retrieval"] = {"status": "poor", "reason": "too_short",
1214
+ "detail": f"length={ln}"}
1215
+
1216
+ # ② 正面记录:本地已有正文
1217
+ cached = bool(hit and hit.get("success"))
1218
+ body: dict = {}
1219
+ if cached:
1220
+ body = {
1221
+ "length": hit.get("length", 0),
1222
+ "truncated": bool(hit.get("truncated")),
1223
+ "full_length": hit.get("full_length", hit.get("length", 0)),
1224
+ }
1225
+ if hit.get("full_text_path"):
1226
+ body["full_text_path"] = hit["full_text_path"]
1227
+ if _ft_path is not None and not body.get("full_text_path"):
1228
+ try:
1229
+ fp = _ft_path(url, "text")
1230
+ if fp.is_file():
1231
+ body["full_text_path"] = str(fp)
1232
+ if not cached:
1233
+ body["truncated"] = False
1234
+ body["full_length"] = fp.stat().st_size
1235
+ except OSError:
1236
+ pass
1237
+ if body:
1238
+ body["source"] = ("cache+archive" if cached
1239
+ and "full_text_path" in body
1240
+ else "archive" if not cached else "cache")
1241
+ entry["body"] = body
1242
+
1243
+ # ③ 只有「取不到」的判定才需要 robots 兜底:正文都不在,谈何可用
1244
+ if "retrieval" not in entry and _known_blocked is not None:
1245
+ try:
1246
+ if _known_blocked(url) is True:
1247
+ entry["retrieval"] = {"status": "blocked", "reason": "robots"}
1248
+ except Exception: # 侧信道:robots 只读判定失败按「不知道」处理(规则见 except_sets)
1249
+ pass
1250
+
1251
+ if entry:
1252
+ out[url] = entry
1253
+ return out
1254
+
1255
+ def _fetch_key(self, url: str) -> str:
1256
+ """正文缓存键。读写必须共用此函数——分头拼 key 会静默读写失联。
1257
+
1258
+ 管线版本经 mode 位并入(见 FETCH_PIPELINE_VERSION 的说明)。
1259
+ """
1260
+ return self._key(url, "fetch", 0, "fetch", "auto",
1261
+ f"pv{FETCH_PIPELINE_VERSION}", kind="fetch")
1262
+
1263
+ # ── URL 证据分:作为 fetch 条目的一个子键存放(2026-09-17 消融)
1264
+ #
1265
+ # 原本是独立的 kind(evidence),实测与 fetch 条目 **6/10 字段重复**、
1266
+ # 同一个 result 字典、同一时刻、同一套 TTL 映射各写一次。拆开的代价:
1267
+ # - 两个存储 + 两条写路径 + 两条读路径 + 两份 TTL 映射
1268
+ # - **可能失配**:fetch 缓存拒绝登录态内容时(assert_cacheable 抛错),
1269
+ # evidence 仍在另一个 try 块里写成功 → 「证据在、正文不在」,
1270
+ # 于是 verify 永久跳过这个 URL,把没核验过的当成已核验过的。
1271
+ # 合并后 1:1 由构造保证。代价实测:冷 L1 下多读 20 条含正文的行
1272
+ # 约 +0.19 ms(1.52 → 1.71 ms),可忽略。
1273
+ EVIDENCE_KEY = FETCH_EVIDENCE_KEY
1274
+
1275
+ def get_evidence(self, url: str) -> Optional[dict]:
1276
+ """读 URL 的正文级证据分(从 fetch 条目投影)。未命中返回 None。"""
1277
+ hit = self._read(self._fetch_key(url))
1278
+ ev = (hit or {}).get(self.EVIDENCE_KEY)
1279
+ if not ev:
1280
+ return None
1281
+ # 兼容旧形状:读侧统一带上 url,调用方无需知道存储位置
1282
+ return {**ev, "url": (hit or {}).get("url") or url}
1283
+
1284
+ def set_evidence(self, url: str, evidence: dict, ttl: int | None = None):
1285
+ """把证据分并入该 URL 的 fetch 条目。
1286
+
1287
+ 没有 fetch 条目时**不写**——证据分描述的是「这份正文有多可信」,
1288
+ 正文都不在,单独留一个分数只会让调用方误判「这条已核验过」。
1289
+ 这正是合并要消除的那个失配。
1290
+ """
1291
+ if not evidence:
1292
+ return
1293
+ with self._l2._lock:
1294
+ hit = self._read(self._fetch_key(url))
1295
+ if not hit:
1296
+ return
1297
+ merged = {k: v for k, v in hit.items() if not str(k).startswith("_")}
1298
+ merged[self.EVIDENCE_KEY] = {k: v for k, v in evidence.items()
1299
+ if k not in ("url",) and not str(k).startswith("_")}
1300
+ merged["_max_chars"] = hit.get("_max_chars", 0)
1301
+ try:
1302
+ self._write(self._fetch_key(url), url, "fetch", 0, merged, "fetch",
1303
+ ttl if ttl is not None else FETCH_DEFAULT_TTL)
1304
+ except IO_BENIGN + SHAPE_BENIGN:
1305
+ pass
316
1306
 
317
1307
  def clear(self, older_than_hours: int = 24):
1308
+ if self._disabled():
1309
+ return
318
1310
  self._l2.clear(older_than_hours=older_than_hours)
319
1311
 
320
1312
  @property
321
1313
  def stats(self) -> dict:
1314
+ if self._disabled():
1315
+ # 关缓存时命中断言恒为 0:报「0 命中率」比报一个没人写过的
1316
+ # l1/l2 计数更解释得通(否则用户会以为缓存坏了)。
1317
+ return {
1318
+ "enabled": False,
1319
+ "hits": 0, "misses": 0, "hit_rate": 0.0,
1320
+ "size_mb": 0.0, "entries": 0,
1321
+ "l1": self._l1.stats, "l2": self._l2.stats,
1322
+ }
322
1323
  l1 = self._l1.stats
323
1324
  l2 = self._l2.stats
324
- total_hits = l1["hits"] + l2["hits"]
1325
+ # 一次 _read 就是一次查找:L1 交付即命中,L1 没交付(未命中或已过期)
1326
+ # 才下探 L2;L2 也拿不到才算真 miss。所以 miss 数恒等于 l2["misses"],
1327
+ # 命中数 = 总查找 − miss。
1328
+ #
1329
+ # 此前 hits 用 l1.hits + l2.hits、分母却用 l1.misses:L1 未命中的每一笔
1330
+ # 都会再记一次 l2 命中,于是分母里既有这一笔的 hit 又有它的 l1.miss,
1331
+ # 命中率被系统性压低——L2 越有效,报出来的数字越差。
1332
+ lookups = l1["hits"] + l1["misses"]
1333
+ misses = l2["misses"]
1334
+ hits = lookups - misses
325
1335
  return {
326
- "hits": total_hits,
327
- "misses": l1["misses"],
328
- "hit_rate": round(total_hits / max(total_hits + l1["misses"], 1), 3),
1336
+ "enabled": True,
1337
+ "hits": hits,
1338
+ "misses": misses,
1339
+ "hit_rate": round(hits / max(lookups, 1), 3),
329
1340
  "size_mb": l2["size_mb"],
330
1341
  "entries": l2["entries"],
331
1342
  "l1": l1,
@@ -357,7 +1368,7 @@ def _cli():
357
1368
  cache = SearchCache()
358
1369
  if args.cmd == "get":
359
1370
  hit = cache.get(args.query, args.engine, args.max_results, domain=args.domain)
360
- print(json.dumps({"hit": hit is not None, "data": hit}, ensure_ascii=False))
1371
+ print(dumps({"hit": hit is not None, "data": hit}))
361
1372
  elif args.cmd == "set":
362
1373
  cache.set(args.query, args.engine, args.max_results, json.loads(args.value_json), domain=args.domain)
363
1374
  print('{"ok": true}')
@@ -365,7 +1376,7 @@ def _cli():
365
1376
  cache.clear(older_than_hours=args.older_than)
366
1377
  print('{"ok": true}')
367
1378
  elif args.cmd == "stats":
368
- print(json.dumps(cache.stats, ensure_ascii=False, indent=2))
1379
+ print(dumps(cache.stats))
369
1380
  else:
370
1381
  parser.print_help()
371
1382