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
@@ -25,6 +25,8 @@ from collections import Counter
25
25
  from pathlib import Path
26
26
  from typing import Optional
27
27
 
28
+ import argo_paths as _paths
29
+
28
30
  # ── 路径 ──────────────────────────────────────────────────────────────────────
29
31
 
30
32
  SKILL_DIR = Path(__file__).parent.parent
@@ -33,18 +35,25 @@ DOMAIN_PROFILES_PATH = BACKENDS_DIR / "domain_profiles.json"
33
35
 
34
36
  # ── 分词 ──────────────────────────────────────────────────────────────────────
35
37
 
36
- _TOKEN_PATTERN = re.compile(r"[一-鿿]|[a-zA-Z0-9]+")
38
+ _TOKEN_PATTERN = re.compile(r"[一-鿿\u3040-\u30ff\uac00-\ud7af]|[a-zA-Z0-9]+")
37
39
 
38
40
 
39
41
  def tokenize(text: str) -> list[str]:
40
- """简单分词:中文单字 + 英文单词,统一小写。"""
42
+ """简单分词:中文/日文/韩文单字 + 英文单词,统一小写。
43
+
44
+ 扩展 v2.8:并入假名(\\u3040-\\u30ff)与谚文(\\uac00-\\ud7af),否则韩/日
45
+ 查询 tokenize 恒空、TF-IDF 语义路由对多语言整体失明。单字拉丁仍丢弃(词袋无关)。
46
+ """
41
47
  tokens = []
42
48
  for match in _TOKEN_PATTERN.finditer(text.lower()):
43
49
  token = match.group()
44
- if len(token) == 1 and '一' <= token <= '鿿':
45
- tokens.append(token)
46
- elif len(token) > 1:
50
+ if len(token) > 1:
47
51
  tokens.append(token)
52
+ elif len(token) == 1:
53
+ ch = token[0]
54
+ if ('一' <= ch <= '鿿' or '\u3040' <= ch <= '\u30ff'
55
+ or '\uac00' <= ch <= '\ud7af'):
56
+ tokens.append(ch)
48
57
  return tokens
49
58
 
50
59
 
@@ -100,43 +109,87 @@ def cosine_similarity(vec_a: dict[str, float], vec_b: dict[str, float]) -> float
100
109
  return dot / (norm_a * norm_b)
101
110
 
102
111
 
103
- # ── 配额感知 ──────────────────────────────────────────────────────────────────
112
+ # ── 配额 / 成本(mtime 缓存,避免 route 每次为每个引擎读盘) ──────────────────
113
+
114
+ _quota_state_cache: dict | None = None
115
+ _quota_state_mtime: float = -1.0
116
+ _cost_profiles_cache: dict | None = None
117
+ _cost_profiles_mtime: float = -1.0
118
+
104
119
 
105
120
  def _load_quota_state() -> dict:
106
- """加载配额状态文件。"""
107
- quota_path = Path.home() / ".cache" / "unified-search" / "quota.json"
108
- if not quota_path.exists():
121
+ """加载配额状态文件(mtime 缓存)。"""
122
+ global _quota_state_cache, _quota_state_mtime
123
+ quota_path = _paths.state_path("quota.json")
124
+ try:
125
+ mtime = quota_path.stat().st_mtime
126
+ except OSError:
109
127
  return {}
128
+ if _quota_state_cache is not None and mtime == _quota_state_mtime:
129
+ return _quota_state_cache
110
130
  try:
111
- return json.loads(quota_path.read_text())
131
+ data = json.loads(quota_path.read_bytes())
112
132
  except (json.JSONDecodeError, OSError):
113
- return {}
114
-
115
-
116
- def _quota_ratio(engine: str, quota_state: dict) -> float:
117
- """计算配额剩余比例。返回 0.0-1.0,无记录时默认 1.0。"""
118
- info = quota_state.get(engine)
119
- if not info:
133
+ data = {}
134
+ _quota_state_cache = data if isinstance(data, dict) else {}
135
+ _quota_state_mtime = mtime
136
+ return _quota_state_cache
137
+
138
+
139
+ def _quota_ratio(engine: str, quota_state: dict,
140
+ profiles: dict | None = None) -> float:
141
+ """计算配额剩余比例。返回 0.0-1.0,无记录时默认 1.0。
142
+
143
+ limit 的唯一来源是 quota_profiles.json(声明文件);配额状态文件里只有
144
+ 用量计数。此前本函数只读 state["limit"],而写入方(quota.record)恒填 0,
145
+ 于是 limit<=0 走「无限配额」分支恒返回 1.0——配额感知惩罚(<0.2 砍到
146
+ 2×qr、<0.5 乘 0.5+qr)从来没生效过。与 quota.get_remaining_ratio 的
147
+ profile.get("limit") 计算方式保持一致,缺省才回落到 state 里的值。
148
+ """
149
+ info = quota_state.get(engine) or {}
150
+ profile = (profiles if profiles is not None else _load_cost_profiles()).get(engine) or {}
151
+ limit = profile.get("limit")
152
+ if not isinstance(limit, (int, float)) or limit <= 0:
153
+ limit = info.get("limit")
154
+ if limit is None or limit <= 0:
155
+ # 无限配额 / 脏数据(limit=0)不当作耗尽
120
156
  return 1.0
121
- limit = info.get("limit", 100)
122
- used = info.get("used", 0)
123
- return max(0, limit - used) / limit
157
+ used = info.get("used", 0) or 0
158
+ return max(0.0, float(limit - used) / float(limit))
124
159
 
125
160
 
126
- # ── 成本感知 ──────────────────────────────────────────────────────────────────
127
-
128
- def _cost_factor(engine: str) -> float:
129
- """获取引擎成本因子:free=1.0, low=0.85, paid=0.6。"""
161
+ def _load_cost_profiles() -> dict:
162
+ global _cost_profiles_cache, _cost_profiles_mtime
130
163
  tiers_path = BACKENDS_DIR / "quota_profiles.json"
131
- if not tiers_path.exists():
132
- return 1.0
133
164
  try:
134
- profiles = json.loads(tiers_path.read_text())
165
+ mtime = tiers_path.stat().st_mtime
166
+ except OSError:
167
+ return {}
168
+ if _cost_profiles_cache is not None and mtime == _cost_profiles_mtime:
169
+ return _cost_profiles_cache
170
+ try:
171
+ data = json.loads(tiers_path.read_bytes())
135
172
  except (json.JSONDecodeError, OSError):
136
- return 1.0
173
+ data = {}
174
+ _cost_profiles_cache = data if isinstance(data, dict) else {}
175
+ _cost_profiles_mtime = mtime
176
+ return _cost_profiles_cache
177
+
178
+
179
+ def _cost_factor(engine: str, profiles: dict | None = None) -> float:
180
+ """获取引擎成本因子:free=1.0, low=0.85, paid=0.6。"""
181
+ if profiles is None:
182
+ profiles = _load_cost_profiles()
137
183
  profile = profiles.get(engine, {})
184
+ if not isinstance(profile, dict):
185
+ return 1.0
138
186
  tier = profile.get("cost_tier", "free")
139
- return {"free": 1.0, "low": 0.85, "paid": 0.6}.get(tier, 1.0)
187
+ # 成本因子查 config 的唯一来源(此前本模块自带一张表,与 config 的值不一致)
188
+ try:
189
+ from config import _COST_FACTOR_BY_TIER
190
+ return _COST_FACTOR_BY_TIER.get(tier, 1.0)
191
+ except Exception:
192
+ return 1.0
140
193
 
141
194
 
142
195
  # ── 主路由引擎 ─────────────────────────────────────────────────────────────────
@@ -151,18 +204,34 @@ class SemanticRouter:
151
204
  self.boost_keywords: dict[str, dict[str, float]] = {}
152
205
  self.boost_combos: dict[str, dict[str, float]] = {}
153
206
  self._loaded = False
207
+ self._profiles_mtime: float = 0.0
154
208
 
155
209
  def _ensure_loaded(self) -> None:
156
- """懒加载领域文档,只加载一次。"""
210
+ """懒加载领域文档;文件变化时自动重载。"""
157
211
  if self._loaded:
158
- return
212
+ try:
213
+ mtime = DOMAIN_PROFILES_PATH.stat().st_mtime
214
+ if mtime == self._profiles_mtime:
215
+ return
216
+ except OSError:
217
+ return
218
+ self._loaded = False
219
+ self.engine_names.clear()
220
+ self.engine_vectors.clear()
221
+ self.boost_keywords.clear()
222
+ self.boost_combos.clear()
159
223
  if not DOMAIN_PROFILES_PATH.exists():
160
224
  self._loaded = True
225
+ # mtime 一并复位:留着旧值的话,文件以原 mtime 还原(回收站恢复/
226
+ # 原子写回滚)时会被上面判成「未变化」,路由带着已清空的 vectors
227
+ # 永久空转——静默,无任何报错。
228
+ self._profiles_mtime = 0.0
161
229
  return
162
230
  try:
163
- profiles = json.loads(DOMAIN_PROFILES_PATH.read_text())
231
+ profiles = json.loads(DOMAIN_PROFILES_PATH.read_bytes())
164
232
  except (json.JSONDecodeError, OSError):
165
233
  self._loaded = True
234
+ self._profiles_mtime = 0.0 # 同上:坏文件不占住旧 mtime
166
235
  return
167
236
 
168
237
  corpus = []
@@ -178,6 +247,7 @@ class SemanticRouter:
178
247
  self.vectorizer.fit(corpus)
179
248
  for i, name in enumerate(self.engine_names):
180
249
  self.engine_vectors[name] = self.vectorizer.transform(corpus[i])
250
+ self._profiles_mtime = DOMAIN_PROFILES_PATH.stat().st_mtime
181
251
  self._loaded = True
182
252
 
183
253
  def route(self, query: str, top_k: int = 3,
@@ -194,6 +264,8 @@ class SemanticRouter:
194
264
  query_vec = self.vectorizer.transform(query)
195
265
  scores = []
196
266
  quota_state = _load_quota_state() if quota_aware else {}
267
+ cost_profiles = _load_cost_profiles()
268
+ q_lower = query.lower() # 一次即可,避免每个引擎重复 lower
197
269
 
198
270
  for name in self.engine_names:
199
271
  sim = cosine_similarity(query_vec, self.engine_vectors[name])
@@ -203,7 +275,7 @@ class SemanticRouter:
203
275
  boost = 1.0
204
276
  hit_kws = []
205
277
  for kw, weight in self.boost_keywords.get(name, {}).items():
206
- if kw.lower() in query.lower():
278
+ if kw.lower() in q_lower:
207
279
  boost += weight - 1.0
208
280
  hit_kws.append(kw)
209
281
  if hit_kws:
@@ -212,7 +284,7 @@ class SemanticRouter:
212
284
  # 组合关键词加成
213
285
  hit_combos = []
214
286
  for combo, bonus in self.boost_combos.get(name, {}).items():
215
- if all(w.lower() in query.lower() for w in combo.split()):
287
+ if all(w.lower() in q_lower for w in combo.split()):
216
288
  boost += bonus
217
289
  hit_combos.append(combo)
218
290
  if hit_combos:
@@ -220,15 +292,15 @@ class SemanticRouter:
220
292
 
221
293
  final_score = sim * boost
222
294
 
223
- # 成本感知
224
- cf = _cost_factor(name)
295
+ # 成本感知(profiles 已缓存,不再每引擎读盘)
296
+ cf = _cost_factor(name, cost_profiles)
225
297
  if cf < 1.0:
226
298
  final_score *= cf
227
299
  parts.append(f"cost={cf}")
228
300
 
229
301
  # 配额感知惩罚
230
302
  if quota_aware and quota_state:
231
- qr = _quota_ratio(name, quota_state)
303
+ qr = _quota_ratio(name, quota_state, cost_profiles)
232
304
  if qr < 0.2:
233
305
  final_score *= qr * 2
234
306
  parts.append(f"配额紧张({qr:.0%})")
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python3
2
+ """time_utils.py — 时间窗归一化与 published_at 解析的纯函数工具箱。
3
+
4
+ 从 search.py 中外提,解决 3351 行问题。本模块只含纯函数与编译好的正则,
5
+ 零重依赖(仅 datetime + re),可被路由/搜索/抓取/研究等层安全引用而不必
6
+ 担心循环导入或副作用。
7
+
8
+ 本模块是「时间语义」的唯一来源:
9
+ - published_at 解析 → published_ts
10
+ - 时间窗归一化 → parse_time_value / normalize_time_window
11
+ - 结果后过滤 → apply_time_window
12
+ - 排序 → sort_results_by_time
13
+ - 哪些引擎带时间能力 → TIME_CAPABLE_ENGINES / is_time_capable
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from datetime import datetime, timedelta, timezone
20
+ from typing import Any
21
+
22
+ # published_at 常见形态:YYYY-MM-DD、YYYY-MM-DD HH:MM[:SS]、ISO(YYYY-MM-DDTHH:MM:SS)
23
+ _DATE_RE = re.compile(
24
+ r"^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?"
25
+ )
26
+ # 相对时间:Nd / Nh / Nw(不区分大小写)
27
+ _REL_TIME_RE = re.compile(r"^(\d+)\s*([dhw])$", re.IGNORECASE)
28
+ # 纯日期 YYYY-MM-DD(until 边界含当天;下推保留日期形态)
29
+ _DATE_ONLY_RE = re.compile(r"^\d{4}-\d{1,2}-\d{1,2}$")
30
+
31
+
32
+ # 带发布时间能力(published_at)的引擎集合。时间窗的缓存键隔离与后过滤
33
+ # 只对含这些引擎的组合生效:不带时间字段的引擎会忽略时间窗、结果相同,
34
+ # 隔离缓存键只会白白降低命中率(7d/30d 查同一引擎本可共享缓存)。
35
+ # local_search 聚合内部可能选中 news 类子引擎(带日期),保守纳入。
36
+ TIME_CAPABLE_ENGINES: frozenset[str] = frozenset({
37
+ "realtime_index", "wayback_cdx", "local_search",
38
+ "local_bing_news", "local_google_news", "local_duckduckgo_news",
39
+ "local_ddgs_news",
40
+ # parallel(after_date 下推)/ you(freshness 动态化 + page_age)
41
+ "parallel", "you",
42
+ # deepseek_search:上游逐条返回 RFC 3339 date(output_map 映射
43
+ # published_at),日期覆盖率实测 3-5/5;缺了这一条时 --since/--until
44
+ # 对它静默无效(time_aware=False,后过滤整段跳过),日期最全的引擎
45
+ # 反而用不上时间窗。
46
+ "deepseek_search",
47
+ })
48
+
49
+
50
+ def is_time_capable(eng: str) -> bool:
51
+ """引擎是否可能返回 published_at(决定时间窗是否参与缓存键/后过滤)。"""
52
+ return eng in TIME_CAPABLE_ENGINES
53
+
54
+
55
+ def published_ts(r: dict[str, Any]) -> float | None:
56
+ """解析结果的 published_at → epoch 秒;无法解析返回 None(恒排最后)。
57
+
58
+ ISO 优先(fromisoformat 支持 T/空格分隔、Z、±HH:MM 时区),
59
+ 带时区正确换算 epoch,无时区按本地时区解释;回退 YYYY-MM-DD 手工解析。
60
+ """
61
+ raw = r.get("published_at")
62
+ if not raw:
63
+ return None
64
+ text = str(raw).strip()
65
+ if text.isdigit(): # 部分引擎给 epoch 秒时间戳
66
+ try:
67
+ return float(text)
68
+ except ValueError:
69
+ return None
70
+ try:
71
+ dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
72
+ # aware datetime 正确换算 UTC epoch;naive 按本地时区解释
73
+ return dt.timestamp()
74
+ except ValueError:
75
+ pass
76
+ # basic ISO(YYYYMMDDTHHMMSSZ,如 GDELT seendate):Python 3.10 的
77
+ # fromisoformat 不认,插入分隔符归一化成扩展格式再走同一解析
78
+ if len(text) >= 15 and text[:8].isdigit() and text[8] == "T" and text[9:15].isdigit():
79
+ try:
80
+ dt = datetime.fromisoformat(
81
+ f"{text[:4]}-{text[4:6]}-{text[6:8]}"
82
+ f"T{text[9:11]}:{text[11:13]}:{text[13:15]}+00:00")
83
+ return dt.timestamp()
84
+ except ValueError:
85
+ pass
86
+ m = _DATE_RE.match(text)
87
+ if not m:
88
+ return None
89
+ try:
90
+ return datetime(
91
+ int(m.group(1)), int(m.group(2)), int(m.group(3)),
92
+ int(m.group(4) or 0), int(m.group(5) or 0), int(m.group(6) or 0),
93
+ ).timestamp()
94
+ except ValueError:
95
+ return None
96
+
97
+
98
+ def parse_time_value(value: Any) -> tuple[str | None, float | None]:
99
+ """解析单边时间窗 → (归一化 ISO 字符串, epoch 秒)。
100
+
101
+ 支持:相对量(Nd/Nh/Nw)、YYYY-MM-DD、YYYY-MM-DD HH:MM[:SS]、
102
+ ISO 8601(含 Z / ±HH:MM)、纯数字 epoch 秒。
103
+ 相对量归一化为绝对日期(YYYY-MM-DD),语义确定、可入缓存键;
104
+ 无法解析返回 (None, None),调用方保持原样下推、不参与后过滤。
105
+ """
106
+ if value in (None, ""):
107
+ return None, None
108
+ text = str(value).strip()
109
+ # epoch 秒
110
+ if text.isdigit():
111
+ try:
112
+ ts = float(text)
113
+ dt = datetime.fromtimestamp(ts, tz=timezone.utc)
114
+ return dt.isoformat(timespec="seconds"), ts
115
+ except (ValueError, OSError):
116
+ return None, None
117
+ # 相对量:Nd / Nh / Nw → 绝对日期(本地时区零点)
118
+ m = _REL_TIME_RE.match(text)
119
+ if m:
120
+ amount = int(m.group(1))
121
+ unit = m.group(2).lower()
122
+ now = datetime.now()
123
+ if unit == "h":
124
+ dt = now - timedelta(hours=amount)
125
+ elif unit == "w":
126
+ dt = now - timedelta(weeks=amount)
127
+ else:
128
+ dt = now - timedelta(days=amount)
129
+ d = dt.date()
130
+ return d.isoformat(), datetime(d.year, d.month, d.day).timestamp()
131
+ # 绝对时间:fromisoformat 优先(T/空格分隔、Z、±HH:MM)
132
+ try:
133
+ dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
134
+ except ValueError:
135
+ return None, None
136
+ if dt.tzinfo is not None:
137
+ return dt.isoformat(timespec="seconds"), dt.timestamp()
138
+ # 无时区:按本地时区(与 published_ts 无时区行为一致);
139
+ # 纯日期(时间为零点)保留 YYYY-MM-DD 形态下推,兼容引擎既有解析
140
+ if (dt.hour, dt.minute, dt.second, dt.microsecond) == (0, 0, 0, 0):
141
+ return dt.date().isoformat(), dt.timestamp()
142
+ return dt.isoformat(timespec="seconds"), dt.timestamp()
143
+
144
+
145
+ #: 时间窗接受的写法(给用户看的唯一一份说明;CLI 的 argparse 与 MCP 的错误行
146
+ #: 都从这里取,避免「文档说支持 2w、代码其实只认 d」这类漂移)。
147
+ WINDOW_FORMATS_HINT = (
148
+ "支持 7d/24h/2w、2026-09-01、2026-09-01 12:00、"
149
+ "ISO 8601(含 Z / ±HH:MM)或 epoch 秒")
150
+
151
+
152
+ def is_valid_time_window(value: Any) -> bool:
153
+ """入参能否解析成时间窗(判据与 `parse_time_value` 同源,不另写一套正则)。
154
+
155
+ why:此前非法值被**静默接受**——`--since garbage` 原样回显在响应里、
156
+ `time_filtered=0`、没有任何说明,调用方以为自己拿到了筛过的结果。
157
+ 这是「静默不生效」里最坏的一种:不是少了一层增强,而是**答案被当成
158
+ 已经筛过的**(同一条查询用 `--since 2026-09-01` 与 `--since garbage`
159
+ 拿到同一批结果,且无从分辨)。
160
+ """
161
+ if value in (None, ""):
162
+ return True # 没给就是没给,不是坏值
163
+ _iso, ts = parse_time_value(value)
164
+ return ts is not None
165
+
166
+
167
+ def normalize_time_window(
168
+ since: str | None, until: str | None
169
+ ) -> tuple[str | None, str | None, float | None, float | None]:
170
+ """归一化时间窗 → (since_iso, until_iso, since_ts, until_ts)。
171
+
172
+ 下推与缓存键使用归一化 ISO(相对值转绝对日期,消除 7d 与绝对日期的
173
+ 缓存碎片);后过滤使用 epoch 秒。非法输入 iso 保留原始字符串、
174
+ ts 为 None:仍会下推原值、缓存键仍区分,但不参与后过滤,不阻断搜索。
175
+ """
176
+ s_iso, s_ts = parse_time_value(since)
177
+ u_iso, u_ts = parse_time_value(until)
178
+ # 纯日期 until 语义为「含当天」:边界取当天最后一刻,
179
+ # 使次日零点及之后的结果被剔除、当天 23:59:59 保留
180
+ if u_iso and _DATE_ONLY_RE.fullmatch(u_iso):
181
+ try:
182
+ d = datetime.fromisoformat(u_iso).date()
183
+ u_ts = datetime(d.year, d.month, d.day, 23, 59, 59, 999999).timestamp()
184
+ except ValueError:
185
+ pass
186
+ s_raw = str(since).strip() if since not in (None, "") else None
187
+ u_raw = str(until).strip() if until not in (None, "") else None
188
+ return (s_iso or s_raw, u_iso or u_raw, s_ts, u_ts)
189
+
190
+
191
+ def apply_time_window(
192
+ results: list[dict[str, Any]], since_ts: float | None, until_ts: float | None
193
+ ) -> tuple[list[dict[str, Any]], int]:
194
+ """结果后过滤保底:剔除「有 published_at 且明确超窗」的条目。
195
+
196
+ 宽松策略:无时间字段的结果无法判断、予以保留(避免大多数引擎清空);
197
+ 只有时间明确落在窗口外的才剔除。返回 (保留列表, 剔除数) 供 envelope 上报。
198
+ """
199
+ if since_ts is None and until_ts is None:
200
+ return results, 0
201
+ kept: list[dict[str, Any]] = []
202
+ dropped = 0
203
+ for r in results:
204
+ ts = published_ts(r)
205
+ if ts is not None:
206
+ if since_ts is not None and ts < since_ts:
207
+ dropped += 1
208
+ continue
209
+ if until_ts is not None and ts > until_ts:
210
+ dropped += 1
211
+ continue
212
+ kept.append(r)
213
+ return kept, dropped
214
+
215
+
216
+ def sort_results_by_time(results: list[dict[str, Any]], sort: str) -> list[dict[str, Any]]:
217
+ """按时间重排结果集:oldest 升序 / newest 降序 / relevance 原序。
218
+
219
+ 排序是纯本地展示顺序:不改变结果集、不进入缓存键、不影响缓存内容;
220
+ 无日期条目恒排最后;同时间保持原相对顺序(稳定排序,结果可复现)。
221
+ """
222
+ if sort not in ("oldest", "newest"):
223
+ return results
224
+ if len(results) <= 1:
225
+ return results
226
+
227
+ def _key(r: dict[str, Any]) -> tuple[int, float]:
228
+ ts = published_ts(r)
229
+ if ts is None:
230
+ return (1, 0.0) # 无日期恒排最后
231
+ return (0, ts if sort == "oldest" else -ts)
232
+
233
+ return sorted(results, key=_key)