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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""BM25 聚焦提取 — 从长文本中提取与查询最相关的段落。
|
|
2
|
+
|
|
3
|
+
移植自 Hound 的 focus.py,简化为独立函数。
|
|
4
|
+
纯 Python 实现(re + math),无外部依赖。
|
|
5
|
+
|
|
6
|
+
BM25 参数:k1=1.5, b=0.75,使用 BM25+ 风格的正 IDF,
|
|
7
|
+
确保单个匹配词也能获得正分数。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# 版本号优先:`6.2.2` 这类章节号必须整体成词。
|
|
16
|
+
# 原式 `[a-z0-9一-鿿]+` 会把 "6.2.2" 切成 "6"/"2"/"2",再被长度下限逐个滤掉,
|
|
17
|
+
# 于是查询里的章节号**完全没参与打分**——实测对 RFC 查「6.2.2 Syntax-Based
|
|
18
|
+
# Normalization」时,最有区分度的那个词等于没写。可选版本号放在首分支,
|
|
19
|
+
# 交替匹配按分支顺序取,因此 "6.2.2" 会整体命中而不是退化成数字。
|
|
20
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:\.[0-9]+)+|[a-z0-9一-鿿]+")
|
|
21
|
+
# 单个 CJK 字符是词(汉语词多为 1–2 字),单个拉丁字母/数字才是噪声
|
|
22
|
+
_CJK_CHAR_RE = re.compile(r"^[一-鿿]$")
|
|
23
|
+
|
|
24
|
+
# BM25 参数
|
|
25
|
+
_K1 = 1.5
|
|
26
|
+
_B = 0.75
|
|
27
|
+
# 默认评分阈值
|
|
28
|
+
_THRESHOLD = 1.0
|
|
29
|
+
# 当没有任何段落超过阈值时,回退保留的段落数
|
|
30
|
+
_FALLBACK_TOP = 5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tokens(text: str) -> list[str]:
|
|
34
|
+
"""分词:版本号整体成词、CJK 按字、拉丁按空白词,滤掉单字母噪声。
|
|
35
|
+
|
|
36
|
+
长度下限只针对拉丁/数字:单个汉字本身就是一个词,用 `len >= 2` 一刀切
|
|
37
|
+
会把「茶」「书」这类查询词整个丢掉——与质量评分里中文被系统性低评
|
|
38
|
+
是同一类语种偏差。
|
|
39
|
+
"""
|
|
40
|
+
out: list[str] = []
|
|
41
|
+
for t in _TOKEN_RE.findall((text or "").lower()):
|
|
42
|
+
if len(t) >= 2 or _CJK_CHAR_RE.match(t):
|
|
43
|
+
out.append(t)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_heading(block: str) -> bool:
|
|
48
|
+
"""判断块是否为 Markdown 标题。"""
|
|
49
|
+
for line in block.splitlines():
|
|
50
|
+
if line.strip():
|
|
51
|
+
return line.lstrip().startswith("#")
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# 目录块识别。目录把一个文档里所有章节标题聚在一个块里,查询命中章节标题时
|
|
56
|
+
# 它的词密度天然碾压正文段落——实测对 RFC 3986 查「6.2.2 Syntax-Based
|
|
57
|
+
# Normalization」,返回的是整篇目录,目标小节一个字没有。
|
|
58
|
+
#
|
|
59
|
+
# 两种 TOC 方言各给一个信号:
|
|
60
|
+
# 纯文本(RFC/论文/书籍):引导点,如 "6.2.2. Syntax-Based Normalization . . . 40"
|
|
61
|
+
# Markdown:整行是一条指向本文锚点的列表链接,如 "- [Getting Started](#getting-started)"
|
|
62
|
+
_TOC_LEADER = re.compile(r"\.\s*\.\s*\.") # ". . ." / "..." 引导点
|
|
63
|
+
_TOC_MD_LINK = re.compile(r"^\s*[-*+]\s*\[[^\]]+\]\(#") # "- [标题](#锚)"
|
|
64
|
+
_TOC_MIN_LINES = 4 # 少于 4 行不成目录
|
|
65
|
+
_TOC_LINE_RATIO = 0.6 # 索引行占比达到此值即判为目录
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_toc_block(block: str) -> bool:
|
|
69
|
+
"""块是否为目录/索引(导航性质,不参与 focus 选块)。"""
|
|
70
|
+
lines = [ln for ln in block.splitlines() if ln.strip()]
|
|
71
|
+
if len(lines) < _TOC_MIN_LINES:
|
|
72
|
+
return False
|
|
73
|
+
hits = sum(1 for ln in lines
|
|
74
|
+
if _TOC_LEADER.search(ln) or _TOC_MD_LINK.match(ln))
|
|
75
|
+
return hits / len(lines) >= _TOC_LINE_RATIO
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# 重复块识别——页面样板(运行页眉/页脚/页码栏)。
|
|
79
|
+
#
|
|
80
|
+
# 这类块文本几乎逐字重复,且往往很短;BM25 的长度归一化偏爱短块,于是
|
|
81
|
+
# 「RFC 3986 URI Generic Syntax January 2005」这种每页顶头的运行页眉,
|
|
82
|
+
# 会因为含有查询词 "syntax" 而集体胜出,把真正的正文段落挤出去——实测对
|
|
83
|
+
# RFC 3986 查「6.2.2 Syntax-Based Normalization」,输出几乎全是页眉副本。
|
|
84
|
+
# 一个文本重复出现这么多次,它承载的信息量必然接近于零。
|
|
85
|
+
_DUP_MIN_REPEAT = 3 # 同一文本出现次数达到此值即判为样板
|
|
86
|
+
_DUP_MAX_CHARS = 300 # 只对短文本判重(长段落重复更可能是合法引用)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _boilerplate_blocks(blocks: list[str]) -> set[int]:
|
|
90
|
+
"""返回样板块索引集合(文本重复出现、且长度较短的块)。"""
|
|
91
|
+
norm: dict[str, list[int]] = {}
|
|
92
|
+
for i, b in enumerate(blocks):
|
|
93
|
+
if len(b) > _DUP_MAX_CHARS:
|
|
94
|
+
continue
|
|
95
|
+
key = " ".join(b.split()).lower()
|
|
96
|
+
if key:
|
|
97
|
+
norm.setdefault(key, []).append(i)
|
|
98
|
+
return {i for idxs in norm.values() if len(idxs) >= _DUP_MIN_REPEAT
|
|
99
|
+
for i in idxs}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _split_blocks(text: str) -> list[str]:
|
|
103
|
+
"""按空行切分文本为段落块(标题、段落、表格、列表)。"""
|
|
104
|
+
blocks: list[str] = []
|
|
105
|
+
current: list[str] = []
|
|
106
|
+
for line in text.splitlines():
|
|
107
|
+
if line.strip() == "":
|
|
108
|
+
if current:
|
|
109
|
+
blocks.append("\n".join(current))
|
|
110
|
+
current = []
|
|
111
|
+
else:
|
|
112
|
+
current.append(line)
|
|
113
|
+
if current:
|
|
114
|
+
blocks.append("\n".join(current))
|
|
115
|
+
return blocks
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fit_to_budget(keep: list[int], blocks: list[str],
|
|
119
|
+
scores: list[float], budget: int) -> list[int]:
|
|
120
|
+
"""按相关度优先裁剪保留集,直到正文总长落入预算。
|
|
121
|
+
|
|
122
|
+
为什么需要:结果最终按**文档顺序**输出、再由调用方按 max_chars 截断。
|
|
123
|
+
只按阈值选块时,靠后的高相关块会被整段截掉——实测对 RFC 3986 查
|
|
124
|
+
「6.2.2 Syntax-Based Normalization」,选中的目标小节排在文档十万字符处,
|
|
125
|
+
截断后用户手上只剩题头与目录,看上去像 BM25 偏袒目录。
|
|
126
|
+
|
|
127
|
+
按分数降序装填(装不下的跳过而不是中断,好让短块也能补上),
|
|
128
|
+
输出顺序仍由调用方还原为文档序。
|
|
129
|
+
"""
|
|
130
|
+
if budget <= 0:
|
|
131
|
+
return keep
|
|
132
|
+
total = sum(len(blocks[i]) for i in keep) + 2 * max(len(keep) - 1, 0)
|
|
133
|
+
if total <= budget:
|
|
134
|
+
return keep
|
|
135
|
+
kept: list[int] = []
|
|
136
|
+
acc = 0
|
|
137
|
+
for i in sorted(keep, key=lambda i: scores[i], reverse=True):
|
|
138
|
+
seg = len(blocks[i]) + 2
|
|
139
|
+
if acc + seg > budget:
|
|
140
|
+
continue
|
|
141
|
+
kept.append(i)
|
|
142
|
+
acc += seg
|
|
143
|
+
if not kept:
|
|
144
|
+
# 一个完整块都装不下时,至少留下最高分的那个(交由调用方截断)。
|
|
145
|
+
# 否则输出只剩头部提示行,而调用方还会以为「聚焦成功」——
|
|
146
|
+
# 交出空正文比不聚焦更糟:实测长小节 + 默认 max_chars=8000 正是这条路径。
|
|
147
|
+
kept = [max(keep, key=lambda i: scores[i])]
|
|
148
|
+
return kept
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def focus_extract(content: str, query: str, top_k: int = 5,
|
|
152
|
+
budget: int = 0) -> str:
|
|
153
|
+
"""BM25 聚焦提取:从长文本提取与查询最相关的段落。纯 Python,无依赖。
|
|
154
|
+
|
|
155
|
+
budget > 0 时在选块阶段即按相关度装入预算,避免高相关块在后续按
|
|
156
|
+
max_chars 截断时被砍掉(见 _fit_to_budget)。
|
|
157
|
+
"""
|
|
158
|
+
# 空查询或短文本直接返回原文
|
|
159
|
+
if not query or not content or not content.strip():
|
|
160
|
+
return content
|
|
161
|
+
if len(content) < 2000:
|
|
162
|
+
return content
|
|
163
|
+
blocks = _split_blocks(content)
|
|
164
|
+
if len(blocks) <= 1:
|
|
165
|
+
return content
|
|
166
|
+
qterms = set(_tokens(query))
|
|
167
|
+
if not qterms:
|
|
168
|
+
return content
|
|
169
|
+
|
|
170
|
+
block_tokens = [_tokens(bl) for bl in blocks]
|
|
171
|
+
n = len(blocks)
|
|
172
|
+
# `else 1.0`:没有段落时避免除零。此前写作 `else 0.0 or 1.0`(等价但读起来
|
|
173
|
+
# 像是「0 也当 1 用」),且让紧随其后的 `if avgdl == 0` 看着像死代码——
|
|
174
|
+
# 那条其实是活的:n>0 但所有段落都切不出词元时 avgdl 仍是 0。
|
|
175
|
+
avgdl = (sum(len(t) for t in block_tokens) / n) if n else 1.0
|
|
176
|
+
if avgdl == 0:
|
|
177
|
+
avgdl = 1.0
|
|
178
|
+
|
|
179
|
+
# 文档频率(每个词出现在多少个段落中)
|
|
180
|
+
df: dict[str, int] = {}
|
|
181
|
+
for toks in block_tokens:
|
|
182
|
+
for t in set(toks):
|
|
183
|
+
df[t] = df.get(t, 0) + 1
|
|
184
|
+
|
|
185
|
+
def idf(term: str) -> float:
|
|
186
|
+
d = df.get(term, 0)
|
|
187
|
+
# +1 保证 IDF 为正(BM25+ 风格)
|
|
188
|
+
return math.log((n - d + 0.5) / (d + 0.5) + 1)
|
|
189
|
+
|
|
190
|
+
def score(i: int) -> float:
|
|
191
|
+
toks = block_tokens[i]
|
|
192
|
+
if not toks:
|
|
193
|
+
return 0.0
|
|
194
|
+
tf: dict[str, int] = {}
|
|
195
|
+
for t in toks:
|
|
196
|
+
tf[t] = tf.get(t, 0) + 1
|
|
197
|
+
dl = len(toks)
|
|
198
|
+
s = 0.0
|
|
199
|
+
denom_len = _K1 * (1 - _B + _B * dl / avgdl)
|
|
200
|
+
for term in qterms:
|
|
201
|
+
f = tf.get(term)
|
|
202
|
+
if f:
|
|
203
|
+
s += idf(term) * (f * (_K1 + 1)) / (f + denom_len)
|
|
204
|
+
return s
|
|
205
|
+
|
|
206
|
+
scores = [score(i) for i in range(n)]
|
|
207
|
+
# 目录与样板块不参与选块——阈值档与兜底档都要排除,否则兜底会把它们
|
|
208
|
+
# 重新捞回来(这两类都不是「内容」,而是导航与重复装饰)
|
|
209
|
+
excluded = _boilerplate_blocks(blocks)
|
|
210
|
+
candidates = [i for i in range(n)
|
|
211
|
+
if i not in excluded and not _is_toc_block(blocks[i])]
|
|
212
|
+
keep = [i for i in candidates if scores[i] >= _THRESHOLD]
|
|
213
|
+
|
|
214
|
+
# 如果没有段落超过阈值,回靠取得分最高的 top_k 个
|
|
215
|
+
if not keep:
|
|
216
|
+
keep = sorted(candidates, key=lambda i: scores[i], reverse=True)[:top_k]
|
|
217
|
+
|
|
218
|
+
# 预算内按相关度择优(不改变输出顺序,见 _fit_to_budget)
|
|
219
|
+
if budget:
|
|
220
|
+
keep = _fit_to_budget(keep, blocks, scores, budget)
|
|
221
|
+
|
|
222
|
+
keep_set = set(keep)
|
|
223
|
+
# 保留被保留段落前的标题(提供上下文)
|
|
224
|
+
for i in keep:
|
|
225
|
+
if i > 0 and _is_heading(blocks[i - 1]) and not _is_heading(blocks[i]):
|
|
226
|
+
keep_set.add(i - 1)
|
|
227
|
+
|
|
228
|
+
# 标题回填可能把总量顶回预算之上,再按分数裁一次(标题不参与裁剪,
|
|
229
|
+
# 它是幸存块的语义锚点)
|
|
230
|
+
if budget:
|
|
231
|
+
total = sum(len(blocks[i]) for i in keep_set) + 2 * max(len(keep_set) - 1, 0)
|
|
232
|
+
if total > budget:
|
|
233
|
+
headings = {i for i in keep_set if _is_heading(blocks[i])}
|
|
234
|
+
body = [i for i in keep_set if i not in headings]
|
|
235
|
+
room = budget - sum(len(blocks[i]) + 2 for i in headings)
|
|
236
|
+
keep_set = headings | set(
|
|
237
|
+
_fit_to_budget(body, blocks, scores, max(room, 0)))
|
|
238
|
+
|
|
239
|
+
# 按原始顺序输出
|
|
240
|
+
kept = "\n\n".join(blocks[i] for i in range(n) if i in keep_set)
|
|
241
|
+
header = (
|
|
242
|
+
f"[Focus: {query!r}; showing {len(keep_set)} of {n} blocks "
|
|
243
|
+
f"by BM25 relevance. Pass focus='' for the full page.]"
|
|
244
|
+
)
|
|
245
|
+
return header + "\n\n" + kept
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# 聚焦场景的抓取额度放大系数——聚焦必须能看到整篇。
|
|
249
|
+
#
|
|
250
|
+
# 若 max_chars 在聚焦前生效,BM25 就只能在文档开头那一段里挑段落。实测对
|
|
251
|
+
# RFC 3986 查「6.2.2 Syntax-Based Normalization」:目标小节在十万字符处,
|
|
252
|
+
# 抓取时早被截掉,聚焦结果退化成题头与目录,看起来像「BM25 偏袒目录」,
|
|
253
|
+
# 真正的原因是截断早于选块。
|
|
254
|
+
#
|
|
255
|
+
# 所以聚焦时先按放大额度取回全文,选块后再裁回用户额度:
|
|
256
|
+
# 内存与缓存条目的代价只在显式使用 --focus 时付出,而它换来的是「能选中」。
|
|
257
|
+
FOCUS_FETCH_MULTIPLIER = 8
|
|
258
|
+
FOCUS_MIN_FETCH_CHARS = 100_000
|
|
259
|
+
# 上限:额度会随抓取结果一起进正文缓存(键含 _max_chars),无上限时
|
|
260
|
+
# 调用方传 max_chars=50000 就能把 400KB 正文写进共享缓存,内存与磁盘都失控。
|
|
261
|
+
# 20 万字符已覆盖绝大多数长文档(RFC 全文约 14 万)。
|
|
262
|
+
FOCUS_MAX_FETCH_CHARS = 200_000
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def focus_fetch_chars(max_chars: int, query: str | None) -> int:
|
|
266
|
+
"""聚焦场景实际使用的抓取额度;无 query 时原样返回 max_chars。"""
|
|
267
|
+
if not query:
|
|
268
|
+
return max_chars
|
|
269
|
+
return min(max(max_chars * FOCUS_FETCH_MULTIPLIER, FOCUS_MIN_FETCH_CHARS),
|
|
270
|
+
FOCUS_MAX_FETCH_CHARS)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def apply_focus(result: dict, query: str, top_k: int = 5,
|
|
274
|
+
max_chars: int | None = None) -> dict:
|
|
275
|
+
"""把 BM25 聚焦提取应用到抓取结果——CLI 与 MCP 共用的单一入口。
|
|
276
|
+
|
|
277
|
+
背景:`--focus` / `focus` 这个能力同时暴露在 CLI(`argo fetch URL --focus`)
|
|
278
|
+
与 MCP(`argo_fetch` 的 `focus` 参数)两处。两处各写一套裁剪逻辑时,
|
|
279
|
+
文档描述的语义与实际行为容易分叉(历史 bug:CLI 侧压根没有该参数)。
|
|
280
|
+
统一到这里,任何一侧新增调用方都自动继承同一语义。
|
|
281
|
+
|
|
282
|
+
契约:
|
|
283
|
+
- query 为空串或正文为空 → 原样返回,不写任何字段
|
|
284
|
+
- 正文过短(focus_extract 内部按 2000 字符阈值判定)→ 不裁剪,
|
|
285
|
+
focus_applied=False,调用方能看出「省 token 没生效」而不是静默当成功
|
|
286
|
+
- 发生裁剪 → content/length 更新,focus_applied=True
|
|
287
|
+
"""
|
|
288
|
+
if not query or not (result.get("content") or "").strip():
|
|
289
|
+
return result
|
|
290
|
+
original = result["content"]
|
|
291
|
+
# 告知选块阶段的可用额度:留出头部提示行的位置,其余全部给正文,
|
|
292
|
+
# 这样选出并输出的块恰好落在 max_chars 内,不会在最后一步被截掉。
|
|
293
|
+
budget = 0
|
|
294
|
+
if max_chars:
|
|
295
|
+
header_len = len(
|
|
296
|
+
f"[Focus: {query!r}; showing 000 of 000 blocks by BM25 relevance. "
|
|
297
|
+
f"Pass focus='' for the full page.]"
|
|
298
|
+
) + 2
|
|
299
|
+
budget = max(max_chars - header_len, 0)
|
|
300
|
+
focused = focus_extract(original, query, top_k=top_k, budget=budget)
|
|
301
|
+
result["focus_query"] = query
|
|
302
|
+
# 正文为空(只剩头部提示行)时退回全文并如实标注未生效——交出空正文
|
|
303
|
+
# 比不聚焦更糟,且不能让调用方以为省到了 token。
|
|
304
|
+
body = focused.split("\n\n", 1)[1] if "\n\n" in focused else ""
|
|
305
|
+
if focused == original or not body.strip():
|
|
306
|
+
result["focus_applied"] = False
|
|
307
|
+
return result
|
|
308
|
+
result["content"] = focused
|
|
309
|
+
result["length"] = len(focused)
|
|
310
|
+
result["focus_applied"] = True
|
|
311
|
+
# 聚焦前用的是放大额度,此处裁回用户额度(见 focus_fetch_chars)
|
|
312
|
+
if max_chars and len(result["content"]) > max_chars:
|
|
313
|
+
result["content"] = result["content"][:max_chars]
|
|
314
|
+
result["length"] = len(result["content"])
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
# 简单测试:构造超过 2000 字符的文本
|
|
320
|
+
block = """
|
|
321
|
+
## 异步编程
|
|
322
|
+
|
|
323
|
+
Python asyncio 提供 async/await 语法,事件循环调度协程。
|
|
324
|
+
适合 IO 密集型任务,比多线程更轻量。
|
|
325
|
+
""".strip()
|
|
326
|
+
long_text = ("# 编程指南\n\n" + block * 30) # >2000 字符
|
|
327
|
+
|
|
328
|
+
# 正常过滤
|
|
329
|
+
result = focus_extract(long_text, "异步编程 事件循环", top_k=2)
|
|
330
|
+
print(result[:600])
|
|
331
|
+
|
|
332
|
+
# 空查询
|
|
333
|
+
assert focus_extract(long_text, "") == long_text
|
|
334
|
+
|
|
335
|
+
# 短文本直接返回
|
|
336
|
+
assert focus_extract("短文本", "查询") == "短文本"
|
|
337
|
+
|
|
338
|
+
# apply_focus:发生裁剪 → 记账为已应用
|
|
339
|
+
res = {"content": long_text, "length": len(long_text)}
|
|
340
|
+
out = apply_focus(res, "异步编程 事件循环")
|
|
341
|
+
assert out["focus_applied"] is True
|
|
342
|
+
assert out["length"] == len(out["content"])
|
|
343
|
+
|
|
344
|
+
# apply_focus:短文本不裁剪 → 诚实记账为未应用(而非假装成功)
|
|
345
|
+
short = {"content": "短文本", "length": 3}
|
|
346
|
+
short_out = apply_focus(short, "查询")
|
|
347
|
+
assert short_out["focus_applied"] is False
|
|
348
|
+
assert short_out["content"] == "短文本" and short_out["length"] == 3
|
|
349
|
+
|
|
350
|
+
print("\n测试通过。")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""fulltext_store.py — 抓取正文的全文存档。
|
|
3
|
+
|
|
4
|
+
问题:`max_chars` 同时管两件事——「交给 Agent 多少」(上下文预算)和
|
|
5
|
+
「取回物留多少」(证据完整性)。前者必须有界,后者不该有损。合成一个机制
|
|
6
|
+
的后果是:默认档 8,000 字抓一个 118,789 字的页面,剩下 93% 当场销毁,
|
|
7
|
+
既没有存档、也没有信号、更没法回读。而 argo 的定位是「搜索与证据核验」,
|
|
8
|
+
证据被截断销毁与定位直接冲突。
|
|
9
|
+
|
|
10
|
+
原则:**交付视图与存档副本分离**。
|
|
11
|
+
- `content`(交付给 Agent)仍然受 max_chars 约束,token 预算不变
|
|
12
|
+
- 完整正文另存一份到状态目录,结果里给 `full_text_path` 与 `truncated` 标记
|
|
13
|
+
- 回读走本地文件,不重新联网
|
|
14
|
+
|
|
15
|
+
只在**确实发生截断**时写档:没截断就没有副本被销毁,写档纯属占盘。
|
|
16
|
+
|
|
17
|
+
存储形态:
|
|
18
|
+
<state_root>/fulltext/<sha1(url|kind)[:16]>.<ext> 正文,纯文本可直接读
|
|
19
|
+
<state_root>/fulltext/index.jsonl 追加式索引,仅用于列举
|
|
20
|
+
|
|
21
|
+
读取只按 URL 哈希定位,不依赖索引——索引坏了、被删了都不影响回读,
|
|
22
|
+
它只是给人看的一份台账。
|
|
23
|
+
|
|
24
|
+
开关:ARGO_FULLTEXT=0 关闭存档;目录可用 ARGO_FULLTEXT_DIR 覆盖。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from engine_env import env_flag
|
|
36
|
+
|
|
37
|
+
# 单个归档上限:超过它的页面本身就不适合整份留存(实测最大的正常页面在
|
|
38
|
+
# 2 MB 量级;出现 8 MB 以上的多半是误抓到的数据文件而不是文章)。
|
|
39
|
+
MAX_FILE_BYTES = 8 * 1024 * 1024
|
|
40
|
+
# 归档总量上限,超出按最久未访问(mtime)淘汰
|
|
41
|
+
MAX_FILES = 300
|
|
42
|
+
MAX_TOTAL_BYTES = 256 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
_EXT = {"md": "md", "text": "txt", "html": "html"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def enabled() -> bool:
|
|
48
|
+
"""全文存档开关:ARGO_FULLTEXT=0 关闭,默认开启。"""
|
|
49
|
+
return env_flag("ARGO_FULLTEXT")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def store_dir() -> Path:
|
|
53
|
+
override = (os.environ.get("ARGO_FULLTEXT_DIR") or "").strip()
|
|
54
|
+
if override:
|
|
55
|
+
return Path(override).expanduser()
|
|
56
|
+
try:
|
|
57
|
+
import argo_paths
|
|
58
|
+
return argo_paths.ensure_state_dir("fulltext")
|
|
59
|
+
except Exception:
|
|
60
|
+
return Path.home() / ".cache" / "unified-search" / "fulltext"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def key_for(url: str, kind: str = "md") -> str:
|
|
64
|
+
"""归档文件名主体。kind 参与哈希:同一 URL 的正文与 HTML 是两份东西。"""
|
|
65
|
+
raw = f"{kind}|{url}".encode("utf-8")
|
|
66
|
+
return hashlib.sha1(raw).hexdigest()[:16]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def path_for(url: str, kind: str = "md") -> Path:
|
|
70
|
+
return store_dir() / f"{key_for(url, kind)}.{_EXT.get(kind, 'txt')}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def save(url: str, text: str, kind: str = "md") -> str | None:
|
|
74
|
+
"""存档完整正文,返回路径;不满足条件或失败时返回 None。
|
|
75
|
+
|
|
76
|
+
**永不上抛**:存档是增值能力,不是抓取的前置条件。写不进去(只读挂载、
|
|
77
|
+
磁盘满、权限不足)只是少一份副本,不该让一次成功的抓取变成失败。
|
|
78
|
+
"""
|
|
79
|
+
if not enabled() or not text:
|
|
80
|
+
return None
|
|
81
|
+
size = len(text.encode("utf-8", errors="replace"))
|
|
82
|
+
if size > MAX_FILE_BYTES:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
import argo_paths
|
|
86
|
+
path = path_for(url, kind)
|
|
87
|
+
argo_paths.atomic_write_text(path, text)
|
|
88
|
+
_index(url, kind, path, size)
|
|
89
|
+
_evict()
|
|
90
|
+
return str(path)
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load(url: str, kind: str = "md") -> str | None:
|
|
96
|
+
"""按 URL 回读归档正文;没有则 None。"""
|
|
97
|
+
try:
|
|
98
|
+
path = path_for(url, kind)
|
|
99
|
+
if path.is_file():
|
|
100
|
+
os.utime(path, None) # 回读即续命,参与 LRU
|
|
101
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_path(path: str) -> str | None:
|
|
108
|
+
"""按显式路径回读(结果里给的 full_text_path 可能来自旧状态目录)。"""
|
|
109
|
+
try:
|
|
110
|
+
p = Path(path).expanduser()
|
|
111
|
+
if p.is_file():
|
|
112
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def entries() -> list[dict]:
|
|
119
|
+
"""列举归档(按 mtime 新→旧)。用于自省与排障。"""
|
|
120
|
+
out: list[dict] = []
|
|
121
|
+
try:
|
|
122
|
+
d = store_dir()
|
|
123
|
+
for p in d.iterdir():
|
|
124
|
+
if p.suffix == ".jsonl" or not p.is_file():
|
|
125
|
+
continue
|
|
126
|
+
st = p.stat()
|
|
127
|
+
out.append({"path": str(p), "bytes": st.st_size,
|
|
128
|
+
"mtime": st.st_mtime})
|
|
129
|
+
except Exception:
|
|
130
|
+
return []
|
|
131
|
+
out.sort(key=lambda e: e["mtime"], reverse=True)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _index(url: str, kind: str, path: Path, size: int) -> None:
|
|
136
|
+
"""追加一行台账。写入失败不影响主流程(回读不依赖它)。"""
|
|
137
|
+
try:
|
|
138
|
+
line = json.dumps({"url": url, "kind": kind, "path": str(path),
|
|
139
|
+
"bytes": size, "ts": int(time.time())},
|
|
140
|
+
ensure_ascii=False) + "\n"
|
|
141
|
+
with open(store_dir() / "index.jsonl", "a", encoding="utf-8") as f:
|
|
142
|
+
f.write(line)
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _evict() -> None:
|
|
148
|
+
"""按数量与总量淘汰最旧的归档。
|
|
149
|
+
|
|
150
|
+
entries() 是 mtime 新→旧的。两个上限分开处理:先按数量砍到 MAX_FILES,
|
|
151
|
+
再按总量继续砍——写成单个循环配一个 break 很容易在 i=0 处就退出
|
|
152
|
+
(此处连踩两次),分开写反而没有出错空间。
|
|
153
|
+
"""
|
|
154
|
+
items = entries()
|
|
155
|
+
total = sum(e["bytes"] for e in items)
|
|
156
|
+
|
|
157
|
+
def _drop(seq: list) -> int:
|
|
158
|
+
removed = 0
|
|
159
|
+
try:
|
|
160
|
+
os.unlink(seq["path"])
|
|
161
|
+
except Exception:
|
|
162
|
+
return 0
|
|
163
|
+
return seq["bytes"]
|
|
164
|
+
|
|
165
|
+
while len(items) > MAX_FILES:
|
|
166
|
+
total -= _drop(items.pop())
|
|
167
|
+
while items and total > MAX_TOTAL_BYTES:
|
|
168
|
+
total -= _drop(items.pop())
|
|
169
|
+
|