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.
- package/LICENSE +201 -21
- package/README.en.md +822 -0
- package/README.es.md +864 -0
- package/README.ja.md +864 -0
- package/README.ko.md +864 -0
- package/README.md +867 -320
- package/SKILL.md +135 -0
- package/assets/readme/hero.svg +121 -0
- package/assets/readme/made-with-beautify.svg +10 -0
- package/assets/readme/proof-routes.svg +73 -0
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/assets/readme/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2308 -96
- package/backends/engine_registry.yaml +3314 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2409 -161
- package/backends/source_types_cn.json +83 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +703 -0
- package/bin/argo.js +71 -39
- package/config.yaml +4976 -363
- package/cordis.patch.yml +21 -0
- package/data/countries.json +40 -0
- package/data/lang_matrix/lang_capability.json +842 -0
- package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
- package/data/regions_cn.json +1 -0
- package/docs/ADDING_NEW_ENGINE.md +184 -0
- package/docs/ARGO_INTRO.md +124 -0
- package/docs/DATA_SOURCE_GAPS.md +81 -0
- package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
- package/docs/ENGINE_CATALOG.md +564 -0
- package/docs/MCP_SETUP.md +106 -0
- package/docs/PLATFORM_PATHS.md +111 -0
- package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
- package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
- package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
- package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
- package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
- package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
- package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
- package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
- package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
- package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
- package/docs/SEARCH_ARCHIVE.md +115 -0
- package/docs/assets/architecture.svg +104 -0
- package/docs/assets/hero.svg +98 -0
- package/docs/engines/bangumi.md +27 -0
- package/docs/engines/biorxiv.md +27 -0
- package/docs/engines/clawhub.md +27 -0
- package/docs/engines/crt_sh.md +27 -0
- package/docs/engines/datacite.md +27 -0
- package/docs/engines/deps_dev.md +27 -0
- package/docs/engines/douban_movie.md +27 -0
- package/docs/engines/endoflife.md +27 -0
- package/docs/engines/flk_law.md +27 -0
- package/docs/engines/fxtwitter.md +27 -0
- package/docs/engines/google_news.md +27 -0
- package/docs/engines/gov_regulations.md +27 -0
- package/docs/engines/guqian.md +27 -0
- package/docs/engines/hackernews.md +27 -0
- package/docs/engines/iplant.md +27 -0
- package/docs/engines/keenable.md +27 -0
- package/docs/engines/met_museum.md +27 -0
- package/docs/engines/nasa_images.md +27 -0
- package/docs/engines/netease_music.md +27 -0
- package/docs/engines/npm.md +27 -0
- package/docs/engines/nvd.md +27 -0
- package/docs/engines/openreview.md +27 -0
- package/docs/engines/openstd.md +27 -0
- package/docs/engines/parallel_free.md +27 -0
- package/docs/engines/people_daily.md +27 -0
- package/docs/engines/realtime_index.md +27 -0
- package/docs/engines/sec_edgar.md +27 -0
- package/docs/engines/seltz.md +27 -0
- package/docs/engines/skillsmp.md +27 -0
- package/docs/engines/sspai.md +27 -0
- package/docs/engines/std_samr.md +27 -0
- package/docs/engines/train.md +27 -0
- package/docs/engines/un_comtrade.md +27 -0
- package/docs/engines/wikisource.md +27 -0
- package/docs/engines/zdic.md +27 -0
- package/docs/engines/zenodo.md +27 -0
- package/docs/engines/zhihu_hot_app.md +27 -0
- package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
- package/engines/README.md +10 -0
- package/engines/_template_cli.yaml +39 -0
- package/engines/_template_http.yaml +37 -0
- package/engines/plugins/_template_plugin.py +50 -0
- package/engines/specs/_cisa_kev.yaml +54 -0
- package/engines/specs/_osv.yaml +63 -0
- package/engines/specs/aljazeera.yaml +43 -0
- package/engines/specs/antara.yaml +43 -0
- package/engines/specs/artic.yaml +20 -0
- package/engines/specs/aviation_weather.yaml +39 -0
- package/engines/specs/bangumi.yaml +21 -0
- package/engines/specs/biorxiv.yaml +24 -0
- package/engines/specs/carbon_intensity.yaml +32 -0
- package/engines/specs/cinii.yaml +44 -0
- package/engines/specs/clawhub.yaml +49 -0
- package/engines/specs/cleveland.yaml +19 -0
- package/engines/specs/cn_ai_news.yaml +43 -0
- package/engines/specs/core.yaml +35 -0
- package/engines/specs/crt_sh.yaml +38 -0
- package/engines/specs/ct24.yaml +43 -0
- package/engines/specs/datacite.yaml +27 -0
- package/engines/specs/datagov_uk.yaml +41 -0
- package/engines/specs/ddgs_images.yaml +45 -0
- package/engines/specs/deepseek_search.yaml +74 -0
- package/engines/specs/deezer.yaml +20 -0
- package/engines/specs/deps_dev.yaml +21 -0
- package/engines/specs/digup.yaml +36 -0
- package/engines/specs/doi.yaml +39 -0
- package/engines/specs/douban_movie.yaml +22 -0
- package/engines/specs/dw_news.yaml +47 -0
- package/engines/specs/egov_law.yaml +21 -0
- package/engines/specs/elpais.yaml +43 -0
- package/engines/specs/endoflife.yaml +21 -0
- package/engines/specs/energy_charts.yaml +21 -0
- package/engines/specs/factcheck_org.yaml +52 -0
- package/engines/specs/faz.yaml +43 -0
- package/engines/specs/federal_register.yaml +57 -0
- package/engines/specs/firecrawl.yaml +53 -0
- package/engines/specs/flk_law.yaml +24 -0
- package/engines/specs/folha.yaml +43 -0
- package/engines/specs/france24.yaml +44 -0
- package/engines/specs/full_fact.yaml +47 -0
- package/engines/specs/fxtwitter.yaml +31 -0
- package/engines/specs/gbfs_nyc.yaml +32 -0
- package/engines/specs/gdacs.yaml +21 -0
- package/engines/specs/gdelt.yaml +64 -0
- package/engines/specs/google_news.yaml +22 -0
- package/engines/specs/gov_regulations.yaml +38 -0
- package/engines/specs/guardian_rss.yaml +59 -0
- package/engines/specs/hurriyet.yaml +43 -0
- package/engines/specs/iplant.yaml +25 -0
- package/engines/specs/jikan.yaml +24 -0
- package/engines/specs/jolpica.yaml +20 -0
- package/engines/specs/k10plus.yaml +20 -0
- package/engines/specs/keenable.yaml +44 -0
- package/engines/specs/lefigaro.yaml +43 -0
- package/engines/specs/listenbrainz.yaml +19 -0
- package/engines/specs/matichon.yaml +43 -0
- package/engines/specs/met_museum.yaml +21 -0
- package/engines/specs/nasa_images.yaml +45 -0
- package/engines/specs/netease_music.yaml +38 -0
- package/engines/specs/nhk.yaml +43 -0
- package/engines/specs/nhtsa_vpic.yaml +20 -0
- package/engines/specs/noaa_swpc.yaml +20 -0
- package/engines/specs/nvd.yaml +38 -0
- package/engines/specs/obis.yaml +20 -0
- package/engines/specs/opencitations.yaml +52 -0
- package/engines/specs/openf1.yaml +21 -0
- package/engines/specs/openfoodfacts.yaml +45 -0
- package/engines/specs/openligadb.yaml +20 -0
- package/engines/specs/openreview.yaml +37 -0
- package/engines/specs/openstd.yaml +21 -0
- package/engines/specs/people_daily.yaml +22 -0
- package/engines/specs/realtime_index.yaml +34 -0
- package/engines/specs/ror.yaml +20 -0
- package/engines/specs/satnogs.yaml +20 -0
- package/engines/specs/sec_edgar.yaml +46 -0
- package/engines/specs/skillsmp.yaml +50 -0
- package/engines/specs/sogou_weixin_browser.yaml +30 -0
- package/engines/specs/soilgrids.yaml +25 -0
- package/engines/specs/sspai.yaml +36 -0
- package/engines/specs/stackexchange.yaml +46 -0
- package/engines/specs/std_samr.yaml +21 -0
- package/engines/specs/tass.yaml +43 -0
- package/engines/specs/tle_mirror.yaml +21 -0
- package/engines/specs/train.yaml +37 -0
- package/engines/specs/tvmaze.yaml +20 -0
- package/engines/specs/twitter_syndication.yaml +31 -0
- package/engines/specs/un_comtrade.yaml +23 -0
- package/engines/specs/unpaywall.yaml +54 -0
- package/engines/specs/vnpress.yaml +43 -0
- package/engines/specs/weather.yaml +32 -0
- package/engines/specs/who_don.yaml +21 -0
- package/engines/specs/who_gho.yaml +20 -0
- package/engines/specs/wikimedia_commons.yaml +34 -0
- package/engines/specs/wikisource.yaml +22 -0
- package/engines/specs/worms.yaml +19 -0
- package/engines/specs/x_trending.yaml +34 -0
- package/engines/specs/yna.yaml +43 -0
- package/engines/specs/ynet.yaml +43 -0
- package/engines/specs/zbmath.yaml +40 -0
- package/engines/specs/zdic.yaml +22 -0
- package/engines/specs/zenodo.yaml +27 -0
- package/engines/specs/zhihu_hot_app.yaml +35 -0
- package/mcp/clients.yaml +74 -0
- package/package.json +51 -12
- package/packages/dsh-plugin/README.md +134 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1244 -0
- package/packages/dsh-plugin/dsh/index.js +2290 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/test/client.test.js +232 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/index.test.js +1338 -0
- package/references/query-templates.md +96 -0
- package/references/query-templates.yaml +118 -0
- package/scripts/ab_eval.py +219 -0
- package/scripts/ab_eval_p0p1.py +266 -0
- package/scripts/adaptive.py +271 -70
- package/scripts/answer.py +154 -0
- package/scripts/archive_run.py +591 -0
- package/scripts/argo_engine_registry.py +172 -0
- package/scripts/argo_paths.py +728 -0
- package/scripts/article.py +136 -0
- package/scripts/arxiv_source.py +201 -0
- package/scripts/badcase_ledger.py +252 -0
- package/scripts/batch_probe.py +241 -0
- package/scripts/benchmark.py +19 -0
- package/scripts/bounded_run.py +144 -0
- package/scripts/browser_auth.py +323 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1078 -67
- package/scripts/cache_guard.py +271 -0
- package/scripts/cache_key_vdom.py +56 -0
- package/scripts/cache_similarity.py +170 -0
- package/scripts/caliber_isolation.py +195 -0
- package/scripts/calibrate_lowquality.py +210 -0
- package/scripts/candidate_envelope.py +366 -0
- package/scripts/chrome_cdp.py +871 -0
- package/scripts/circuit_breaker.py +389 -0
- package/scripts/citations.py +334 -0
- package/scripts/cite_cli.py +59 -0
- package/scripts/cjk_tokens.py +95 -0
- package/scripts/clarify.py +47 -24
- package/scripts/cli_io.py +240 -0
- package/scripts/config.py +683 -45
- package/scripts/config_cache.py +402 -0
- package/scripts/conn_pool.py +220 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +730 -0
- package/scripts/crawl.py +74 -35
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +1019 -0
- package/scripts/engine_env.py +534 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +732 -0
- package/scripts/engine_policy.py +218 -0
- package/scripts/engine_requires.py +190 -0
- package/scripts/engine_status.py +322 -0
- package/scripts/engine_validate.py +638 -0
- package/scripts/engines.py +778 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +298 -0
- package/scripts/engines_builders_batch10.py +159 -0
- package/scripts/engines_builders_batch11.py +174 -0
- package/scripts/engines_builders_batch12.py +103 -0
- package/scripts/engines_builders_batch13.py +340 -0
- package/scripts/engines_builders_batch9.py +1468 -0
- package/scripts/engines_builders_cn.py +2283 -0
- package/scripts/engines_builders_data.py +2923 -0
- package/scripts/engines_builders_data_macro.py +775 -0
- package/scripts/engines_builders_feeds.py +255 -0
- package/scripts/engines_builders_intl.py +1239 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1360 -0
- package/scripts/evidence.py +588 -162
- package/scripts/evidence_loop.py +592 -0
- package/scripts/evidence_tier.py +298 -0
- package/scripts/except_sets.py +25 -0
- package/scripts/extract.py +55 -16
- package/scripts/fact_align.py +207 -0
- package/scripts/fact_crosscheck.py +230 -0
- package/scripts/fetch.py +25 -12
- package/scripts/fetch_browser.py +87 -0
- package/scripts/fetch_jsrun.py +122 -0
- package/scripts/fetch_quality.py +225 -0
- package/scripts/fetch_render_tinyfish.py +117 -0
- package/scripts/fetch_v3.py +1779 -0
- package/scripts/focus_extract.py +350 -0
- package/scripts/fulltext_store.py +169 -0
- package/scripts/fxtwitter_api.py +325 -0
- package/scripts/gen_engine_catalog.py +364 -0
- package/scripts/gen_native_tools.py +91 -0
- package/scripts/gen_routing_snapshot.py +184 -0
- package/scripts/health_check.py +140 -402
- package/scripts/health_probe.py +23 -8
- package/scripts/hot_state.py +134 -0
- package/scripts/http_client.py +1053 -0
- package/scripts/image_ops.py +430 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/image_vision/vision_probe.swift +126 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +206 -0
- package/scripts/job.py +1186 -0
- package/scripts/jump_resolver.py +235 -0
- package/scripts/lang_capability.py +150 -0
- package/scripts/lang_detect.py +289 -0
- package/scripts/lang_matrix_probe.py +329 -0
- package/scripts/lang_pref.py +311 -0
- package/scripts/link_source.py +340 -0
- package/scripts/local_engines.py +321 -0
- package/scripts/local_image.py +970 -0
- package/scripts/local_seek.py +366 -0
- package/scripts/macro_countries.py +82 -0
- package/scripts/matrix_search_eval.py +1105 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +1074 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +122 -0
- package/scripts/mcp_server.py +118 -417
- package/scripts/mcp_setup.py +407 -0
- package/scripts/mcp_tools.py +330 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/min_version_scan.py +335 -0
- package/scripts/net_proxy.py +258 -0
- package/scripts/network_aware.py +152 -0
- package/scripts/paper.py +274 -0
- package/scripts/pdf_extract.py +518 -0
- package/scripts/phrase_filter.py +66 -0
- package/scripts/plan.py +422 -0
- package/scripts/postprocess_signals.py +150 -0
- package/scripts/pre-commit +48 -0
- package/scripts/query_enhance.py +157 -0
- package/scripts/query_operators.py +196 -0
- package/scripts/query_rewriter.py +116 -7
- package/scripts/query_signals.py +300 -0
- package/scripts/query_templates.py +167 -0
- package/scripts/query_understanding.py +411 -0
- package/scripts/query_variants.py +298 -0
- package/scripts/quota.py +488 -76
- package/scripts/rank_signals.py +775 -0
- package/scripts/ranking_eval.py +319 -0
- package/scripts/readability_extract.py +433 -0
- package/scripts/recompute.py +415 -0
- package/scripts/recovery.py +616 -0
- package/scripts/recovery_l0.py +160 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +640 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +591 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +146 -0
- package/scripts/research_gates.py +272 -0
- package/scripts/research_report.py +209 -0
- package/scripts/research_strategy.py +37 -0
- package/scripts/research_work_packages.py +195 -0
- package/scripts/result_lang.py +311 -0
- package/scripts/robots_guard.py +259 -0
- package/scripts/route.py +921 -254
- package/scripts/route_bangs.py +40 -0
- package/scripts/route_cache.py +225 -0
- package/scripts/route_combo.py +910 -0
- package/scripts/route_domains.py +328 -0
- package/scripts/route_lang.py +605 -0
- package/scripts/route_log.py +63 -0
- package/scripts/route_policy.py +250 -0
- package/scripts/rrf_classic.py +66 -0
- package/scripts/screenshot.py +73 -0
- package/scripts/search.py +897 -405
- package/scripts/search_attribution.py +88 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +627 -0
- package/scripts/search_entry.py +389 -0
- package/scripts/search_output.py +606 -0
- package/scripts/search_pipeline.py +1077 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +27 -12
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +215 -0
- package/scripts/serp_guard.py +228 -0
- package/scripts/serp_kill_log.py +51 -0
- package/scripts/single_flight.py +120 -0
- package/scripts/social_engines/__init__.py +10 -0
- package/scripts/social_engines/bilibili_engine.py +131 -0
- package/scripts/social_engines/hackernews_engine.py +113 -0
- package/scripts/social_engines/reddit_engine.py +172 -0
- package/scripts/social_engines/twitter_engine.py +397 -0
- package/scripts/social_engines/v2ex_engine.py +101 -0
- package/scripts/social_engines/weibo_engine.py +128 -0
- package/scripts/social_engines/xiaohongshu_engine.py +137 -0
- package/scripts/social_engines/zhihu_engine.py +139 -0
- package/scripts/social_research.py +201 -0
- package/scripts/sogou_weixin_browser.py +318 -0
- package/scripts/source_ledger.py +249 -0
- package/scripts/stage_timing.py +58 -0
- package/scripts/stats_cli.py +95 -0
- package/scripts/straggler_policy.py +60 -0
- package/scripts/stylometry_detector.py +231 -0
- package/scripts/sync_backends.py +353 -0
- package/scripts/sync_installs.py +29 -0
- package/scripts/template_cli.py +127 -0
- package/scripts/text_lexicon.py +72 -0
- package/scripts/tfidf_router.py +109 -37
- package/scripts/time_utils.py +233 -0
- package/scripts/topic_research_profiles.py +643 -0
- package/scripts/train.py +737 -0
- package/scripts/tweet.py +596 -0
- package/scripts/url_canon.py +217 -0
- package/scripts/url_optimize.py +56 -0
- package/scripts/url_safety.py +236 -0
- package/scripts/usage_log.py +116 -0
- package/scripts/usage_log_clean.py +113 -0
- package/scripts/v2ex_nodes.py +311 -0
- package/scripts/variant_recall.py +188 -0
- package/scripts/watch.py +205 -0
- package/scripts/wechat_article.py +258 -0
- package/scripts/wide_research.py +402 -0
- package/scripts/wx.py +349 -0
- package/scripts/x_article_fetch.py +141 -0
- package/scripts/x_search.py +234 -0
- package/scripts/x_trending.py +189 -0
- package/scripts/yaml_load.py +67 -0
- package/sub-skills/ego-search/SKILL.md +253 -0
- package/sub-skills/ego-search/references/browser-runtime.md +43 -0
- package/sub-skills/ego-search/references/install.md +67 -0
- package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
- package/sub-skills/ego-search/scripts/ego_search.py +800 -0
- package/sub-skills/ego-search/scripts/merge.py +226 -0
- package/sub-skills/ego-search/scripts/quality.py +64 -0
- package/sub-skills/ego-search/scripts/runtime.py +172 -0
- package/sub-skills/ego-search/scripts/safety.py +109 -0
- package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
- package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
- package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
- package/sub-skills/js-run/SKILL.md +67 -0
- package/sub-skills/js-run/scripts/jsrun.py +112 -0
- package/sub-skills/js-run/scripts/shim.js +188 -0
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
- package/sub-skills/js-run/tests/test_jsrun.py +165 -0
- package/sub-skills/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +162 -0
- package/sub-skills/local-search/config.yaml +152 -55
- package/sub-skills/local-search/engine_registry.py +91 -26
- package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
- package/sub-skills/local-search/local_search_adapter.py +13 -2
- package/sub-skills/local-search/parse_maps.yaml +40 -42
- package/sub-skills/local-search/search_v3.py +1182 -65
- package/sub-skills/local-search/smart_router.py +47 -7
- package/sub-skills/local-seek/SKILL.md +110 -0
- package/sub-skills/local-seek/config/domains.yaml +20 -0
- package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
- package/sub-skills/local-seek/references/git-integration.md +54 -0
- package/sub-skills/local-seek/references/remote-handoff.md +46 -0
- package/sub-skills/local-seek/references/rg-recipes.md +96 -0
- package/sub-skills/local-seek/references/strategies.md +52 -0
- package/sub-skills/local-seek/references/structural-search.md +59 -0
- package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
- package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
- package/sub-skills/local-seek/scripts/seek.py +1465 -0
- package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
- package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
- package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
- package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
- package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
- package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
- package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
- package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
- 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 热缓存(
|
|
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
|
-
|
|
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 =
|
|
38
|
-
MAX_DB_SIZE_MB =
|
|
39
|
-
COMPRESSION_THRESHOLD =
|
|
40
|
-
COMPRESSION_LEVEL =
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
"""
|
|
292
|
-
|
|
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",
|
|
296
|
-
if time.time() - hit.get("_ts", 0) < ttl:
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
327
|
-
"
|
|
328
|
-
"
|
|
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(
|
|
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(
|
|
1379
|
+
print(dumps(cache.stats))
|
|
369
1380
|
else:
|
|
370
1381
|
parser.print_help()
|
|
371
1382
|
|