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,640 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""replay_eval.py — 把真实搜索的返回录下来,以后不上网也能重跑整条处理流程。
|
|
3
|
+
|
|
4
|
+
## 解决什么问题
|
|
5
|
+
|
|
6
|
+
原来这套工具能回答「这次改动有没有弄坏东西」,回答不了「这次改动是不是变好了」。
|
|
7
|
+
|
|
8
|
+
已有的几项离线检查各只管一段:matrix_search_eval 只判断路由选得对不对(是或否);
|
|
9
|
+
ranking_golden 能算排序质量分,但它用的引擎返回是手写的假数据;relevance_golden
|
|
10
|
+
存的是真实返回,却只核对相关性判断,不跑后面的流程。结果是:去重、合并、排序、
|
|
11
|
+
输出这几处的改动,效果只能等一周一次的联网检查,或者靠人读代码。
|
|
12
|
+
|
|
13
|
+
这个脚本补上中间那一段——把一次真实搜索里每个引擎返回的内容存下来,以后不上网
|
|
14
|
+
也能用同一批数据重跑整条流程,并给出两次运行之间的差异。
|
|
15
|
+
|
|
16
|
+
## 三个关键决定
|
|
17
|
+
|
|
18
|
+
1. **不改产品代码,只换掉一个函数。** search.py 顶部写着 engine_search 必须是模块级
|
|
19
|
+
函数、网络出口靠替换属性来顶替(search_benchmark 和 test_budget_observability
|
|
20
|
+
都这么做)。本脚本照同样的做法,只替换 search.engine_search 与
|
|
21
|
+
search._missing_env_for,跑完立刻还原。其余三个(get_engines /
|
|
22
|
+
get_execution_config / get_cost_factor)保持原样——它们读的是仓库里的
|
|
23
|
+
config.yaml,本来就是确定的。
|
|
24
|
+
|
|
25
|
+
2. **路由结果沿用录制时的那一份,不重新计算。** 路由会受熔断状态、配额耗尽、
|
|
26
|
+
自适应学习影响,而录制是在真实状态目录下跑的、重跑是在临时空目录下跑的,
|
|
27
|
+
两边选出的引擎组合可能不一样。不沿用就会变成「在测状态差异」而不是「在测代码
|
|
28
|
+
改动」(实测:不沿用的时候,40 次里有 1 次走了完全不同的引擎序列)。
|
|
29
|
+
路由本身对不对,由已有的 matrix_search_eval --offline 和路由金标负责。
|
|
30
|
+
|
|
31
|
+
3. **记下「第几次调用返回了什么」。** 重跑时按顺序发放,超过记录次数就重复最后一次
|
|
32
|
+
并记一笔。重试次数这类变化因此看得见,不会被悄悄吞掉。
|
|
33
|
+
|
|
34
|
+
4. **路由选中的引擎如果在记录里找不到,单独记一笔,不当成空结果。** 「这个源没录到」
|
|
35
|
+
和「这个源返回了空」是两回事,混在一起会让对比工具自己变成新的误报来源。
|
|
36
|
+
|
|
37
|
+
## 使用注意(重要)
|
|
38
|
+
|
|
39
|
+
录下来的引擎返回**只属于录制那一天**。它保证的是「我们的处理逻辑没有退步」,
|
|
40
|
+
不是「现在网上就是这个样子」。网页结构会变、配额会变、不同地区的可达性也不同,
|
|
41
|
+
重跑不会反映这些。
|
|
42
|
+
|
|
43
|
+
## 用法
|
|
44
|
+
|
|
45
|
+
python3 scripts/replay_eval.py # 重跑并打印结果
|
|
46
|
+
python3 scripts/replay_eval.py --json # 输出 JSON
|
|
47
|
+
python3 scripts/replay_eval.py --check # 检查是否低于下限
|
|
48
|
+
python3 scripts/replay_eval.py --case <id> # 看单条明细
|
|
49
|
+
python3 scripts/replay_eval.py --save-baseline P.json # 把当前结果存为对照基准
|
|
50
|
+
python3 scripts/replay_eval.py --compare P.json # 与基准对比,列出差异
|
|
51
|
+
python3 scripts/replay_eval.py --record # 联网录制(唯一联网模式)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
import copy
|
|
58
|
+
import json
|
|
59
|
+
import math
|
|
60
|
+
import os
|
|
61
|
+
import sys
|
|
62
|
+
import tempfile
|
|
63
|
+
from datetime import datetime
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
from typing import Any, Callable
|
|
66
|
+
from cli_io import dumps, dumps_pretty
|
|
67
|
+
|
|
68
|
+
# 状态目录隔离必须放在 import 任何 argo 模块之前:熔断、配额、自适应学习都读本机
|
|
69
|
+
# 状态文件,重跑要跨机器得到同样结果,所以一律落到一个空的临时目录。同 ranking_eval.py。
|
|
70
|
+
os.environ.setdefault("ARGO_STATE_DIR",
|
|
71
|
+
tempfile.mkdtemp(prefix="argo-replay-eval-"))
|
|
72
|
+
|
|
73
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
74
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
75
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
76
|
+
|
|
77
|
+
ROOT = SCRIPT_DIR.parent
|
|
78
|
+
DEFAULT_DATA_PATH = ROOT / "tests" / "golden" / "pipeline_golden.json"
|
|
79
|
+
|
|
80
|
+
# 要替换掉的两个函数。名字的唯一来源是 search.py 顶部注释(那里说明了为什么它们
|
|
81
|
+
# 必须是模块级函数);这里只引用,不另立一套。
|
|
82
|
+
_REPLACED_NAMES = ("engine_search", "_missing_env_for")
|
|
83
|
+
|
|
84
|
+
# 录制时默认用的查询词:跨几个不同的领域,让重跑能碰到不同的引擎组合和判断分支。
|
|
85
|
+
# 这只是「录什么」的建议,实际以数据文件内容为准。
|
|
86
|
+
_DEFAULT_QUERIES = [
|
|
87
|
+
"Python asyncio 事件循环 最佳实践",
|
|
88
|
+
"2026 中国 GDP 增速 目标",
|
|
89
|
+
"OpenAI MCP specification",
|
|
90
|
+
"量子计算 最新进展",
|
|
91
|
+
"React Server Components production",
|
|
92
|
+
"上海天气 未来一周",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
# 下划线开头的字段不进录制文件:它们是流程中途补上的标记,存下来只会让下一次重跑
|
|
96
|
+
# 看到上一次的残留。
|
|
97
|
+
_DROP_PREFIX = "_"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── 小工具 ────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _jsonable(obj: Any) -> Any:
|
|
103
|
+
"""只保留能写成 JSON 的值,保证录制文件存得下、换台机器也读得回来。"""
|
|
104
|
+
if isinstance(obj, dict):
|
|
105
|
+
return {k: _jsonable(v) for k, v in obj.items()
|
|
106
|
+
if not (isinstance(k, str) and k.startswith(_DROP_PREFIX))
|
|
107
|
+
and _writable(v)}
|
|
108
|
+
if isinstance(obj, (list, tuple)):
|
|
109
|
+
return [_jsonable(v) for v in obj if _writable(v)]
|
|
110
|
+
return obj
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _writable(v: Any) -> bool:
|
|
114
|
+
return isinstance(v, (str, int, float, bool, type(None), dict, list, tuple))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_data(path: Path | None = None) -> dict:
|
|
118
|
+
p = Path(path) if path else DEFAULT_DATA_PATH
|
|
119
|
+
if not p.exists():
|
|
120
|
+
return {"_meta": {}, "cases": []}
|
|
121
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _agent_bytes(payload: dict) -> int:
|
|
125
|
+
"""算出这份结果按 agent 档输出后占多少字节——这是 Agent 每次真正付出的上下文代价。
|
|
126
|
+
|
|
127
|
+
直接调用产品代码里的投影函数,不另算一套,免得「这里量出来的数」和
|
|
128
|
+
「用户实际拿到的」对不上。
|
|
129
|
+
"""
|
|
130
|
+
import search
|
|
131
|
+
try:
|
|
132
|
+
view = search._strip_for_agent(payload)
|
|
133
|
+
except Exception:
|
|
134
|
+
view = {"count": payload.get("count", 0)}
|
|
135
|
+
return len(dumps(view).encode("utf-8"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _dcg(grades: list[float]) -> float:
|
|
139
|
+
return sum((2 ** g - 1) / math.log2(i + 2) for i, g in enumerate(grades))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _rank_metrics(urls: list[str], relevant: dict[str, Any] | None) -> dict:
|
|
143
|
+
"""算排序质量分(nDCG@10 / MRR@10)。没有人工标注就不给分,而不是给 0。
|
|
144
|
+
|
|
145
|
+
「得了 0 分」和「没有测」必须分开:前者会让检查在没有标注的数据上误报成退步。
|
|
146
|
+
"""
|
|
147
|
+
if not relevant:
|
|
148
|
+
return {}
|
|
149
|
+
grades = [float(relevant.get(u, 0)) for u in urls[:10]]
|
|
150
|
+
mrr = 0.0
|
|
151
|
+
for i, g in enumerate(grades):
|
|
152
|
+
if g >= 2:
|
|
153
|
+
mrr = 1.0 / (i + 1)
|
|
154
|
+
break
|
|
155
|
+
ideal = sorted(float(v) for v in relevant.values() if v > 0)[::-1][:10]
|
|
156
|
+
idcg = _dcg(ideal)
|
|
157
|
+
return {
|
|
158
|
+
"mrr": round(mrr, 4),
|
|
159
|
+
"ndcg": round(_dcg(grades) / idcg, 4) if idcg > 0 else 0.0,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── 换掉网络出口 ───────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
class _SwappedEngine:
|
|
166
|
+
"""跑的时候把网络出口换成自己的函数,跑完逐个还原。
|
|
167
|
+
|
|
168
|
+
不还原会污染同进程后面跑的代码——这是 search_benchmark 注释里写过的教训,
|
|
169
|
+
照抄一份。
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, replacement: Callable) -> None:
|
|
173
|
+
self._replacement = replacement
|
|
174
|
+
self._originals: dict[str, Any] = {}
|
|
175
|
+
|
|
176
|
+
def __enter__(self):
|
|
177
|
+
import search
|
|
178
|
+
self._originals = {n: getattr(search, n) for n in _REPLACED_NAMES}
|
|
179
|
+
search.engine_search = self._replacement
|
|
180
|
+
search._missing_env_for = lambda _eng: []
|
|
181
|
+
return search
|
|
182
|
+
|
|
183
|
+
def __exit__(self, *exc) -> None:
|
|
184
|
+
import search
|
|
185
|
+
for name, value in self._originals.items():
|
|
186
|
+
setattr(search, name, value)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ── 跑一条数据 ─────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def _route_decision(case: dict) -> dict:
|
|
192
|
+
"""取录制时那一份路由结果。
|
|
193
|
+
|
|
194
|
+
录制跑在真实状态目录下,重跑跑在临时空目录下,两边选出的引擎组合可能不同,
|
|
195
|
+
所以要用录制时存下来的那一份。保存的字段里若缺了后来新增的键,就用当前路由
|
|
196
|
+
补齐,这样老的录制文件不会因为代码加了字段而整条失效。
|
|
197
|
+
|
|
198
|
+
路由本身选得对不对,由已有的 matrix_search_eval --offline 与路由金标负责,
|
|
199
|
+
两边的职责不重叠。
|
|
200
|
+
"""
|
|
201
|
+
from route import route_query
|
|
202
|
+
|
|
203
|
+
query = case["query"]
|
|
204
|
+
mode = case.get("mode", "auto")
|
|
205
|
+
depth = case.get("depth", "fast")
|
|
206
|
+
saved = dict(case.get("decision") or {})
|
|
207
|
+
current = route_query(query, mode=mode, depth=depth)
|
|
208
|
+
for key, value in current.items():
|
|
209
|
+
saved.setdefault(key, value)
|
|
210
|
+
return saved
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _run_pipeline(case: dict, *, engine_call: Callable) -> dict:
|
|
214
|
+
"""用指定的引擎调用方式跑一遍完整流程,返回原始结果。"""
|
|
215
|
+
import search
|
|
216
|
+
from cache import SearchCache
|
|
217
|
+
|
|
218
|
+
query = case["query"]
|
|
219
|
+
mode = case.get("mode", "auto")
|
|
220
|
+
depth = case.get("depth", "fast")
|
|
221
|
+
max_results = int(case.get("max_results", 5))
|
|
222
|
+
timeout = int(case.get("timeout", 10))
|
|
223
|
+
|
|
224
|
+
decision = _route_decision(case)
|
|
225
|
+
payload = search.execute_search(
|
|
226
|
+
query, decision, max_results, timeout, depth,
|
|
227
|
+
SearchCache(), True, mode=mode,
|
|
228
|
+
)
|
|
229
|
+
payload["_domain_decided"] = decision.get("domain")
|
|
230
|
+
payload["_engines_combo"] = decision.get("engines_combo") or decision.get("engines")
|
|
231
|
+
return payload
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _collect_metrics(case: dict, payload: dict, not_recorded: set[str],
|
|
235
|
+
over_limit: set[str]) -> dict:
|
|
236
|
+
urls = [r.get("url", "") for r in (payload.get("results") or [])
|
|
237
|
+
if isinstance(r, dict)]
|
|
238
|
+
out = {
|
|
239
|
+
"id": case["id"],
|
|
240
|
+
"kept": payload.get("count", len(urls)),
|
|
241
|
+
"status": payload.get("status"),
|
|
242
|
+
"domain": payload.get("_domain_decided"),
|
|
243
|
+
"engines": list(payload.get("_engines_combo") or []),
|
|
244
|
+
"funnel": payload.get("funnel"),
|
|
245
|
+
"urls": urls,
|
|
246
|
+
"bytes": _agent_bytes(payload),
|
|
247
|
+
"errors": len(payload.get("errors") or []),
|
|
248
|
+
# 每条结果的**全量字段**:urls 只锁住位次,锁不住「分数/来源/信号怎么变的」。
|
|
249
|
+
# 拆 execute_search 这类加工层重构时,位次不变但分数漂移正是要抓的东西,
|
|
250
|
+
# 因此这里存全量而不是摘要(--compare 会做字段级对比)。
|
|
251
|
+
"results_full": [_jsonable(r) for r in (payload.get("results") or [])
|
|
252
|
+
if isinstance(r, dict)],
|
|
253
|
+
}
|
|
254
|
+
out.update(_rank_metrics(urls, case.get("relevant")))
|
|
255
|
+
if not_recorded:
|
|
256
|
+
out["not_recorded"] = sorted(not_recorded)
|
|
257
|
+
if over_limit:
|
|
258
|
+
out["extra_attempts"] = sorted(over_limit)
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def replay_case(case: dict, recording: dict[str, list[list[dict]]]) -> dict:
|
|
263
|
+
"""按录制的数据重跑一条(全程不联网)。"""
|
|
264
|
+
not_recorded: set[str] = set()
|
|
265
|
+
call_count: dict[str, int] = {}
|
|
266
|
+
over_limit: set[str] = set()
|
|
267
|
+
|
|
268
|
+
def fake_engine_call(query: str, engine: str, n: int = 5,
|
|
269
|
+
timeout: float | None = None, depth: str = "fast",
|
|
270
|
+
mode: str = "auto", **_: Any) -> list[dict]:
|
|
271
|
+
attempts = recording.get(engine)
|
|
272
|
+
if not attempts:
|
|
273
|
+
not_recorded.add(engine)
|
|
274
|
+
return []
|
|
275
|
+
idx = call_count.get(engine, 0)
|
|
276
|
+
call_count[engine] = idx + 1
|
|
277
|
+
if idx >= len(attempts):
|
|
278
|
+
over_limit.add(engine)
|
|
279
|
+
# 深拷贝:流程会就地在结果里补 _engine、score、rerank_dims 这些字段,
|
|
280
|
+
# 不拷贝会把改动写回录制文件,下一次重跑就带着上一次的残留。
|
|
281
|
+
return copy.deepcopy(attempts[min(idx, len(attempts) - 1)])
|
|
282
|
+
|
|
283
|
+
with _SwappedEngine(fake_engine_call):
|
|
284
|
+
payload = _run_pipeline(case, engine_call=fake_engine_call)
|
|
285
|
+
return _collect_metrics(case, payload, not_recorded, over_limit)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def record_case(query: str, *, case_id: str | None = None,
|
|
289
|
+
mode: str = "auto", depth: str = "fast",
|
|
290
|
+
max_results: int = 5, timeout: int = 10) -> dict:
|
|
291
|
+
"""真跑一次,把每个引擎返回的内容录下来(唯一会联网的路径)。"""
|
|
292
|
+
import search
|
|
293
|
+
real = search.engine_search
|
|
294
|
+
recorded: dict[str, list[list[dict]]] = {}
|
|
295
|
+
|
|
296
|
+
def recording_engine_call(query: str, engine: str, n: int = 5,
|
|
297
|
+
timeout: float | None = None, depth: str = "fast",
|
|
298
|
+
mode: str = "auto", **kw: Any) -> list[dict]:
|
|
299
|
+
rows = real(query, engine, n=n, timeout=timeout, depth=depth,
|
|
300
|
+
mode=mode, **kw)
|
|
301
|
+
recorded.setdefault(engine, []).append(_jsonable(rows))
|
|
302
|
+
return rows
|
|
303
|
+
|
|
304
|
+
case = {
|
|
305
|
+
"id": case_id or _slugify(query),
|
|
306
|
+
"query": query, "mode": mode, "depth": depth,
|
|
307
|
+
"max_results": max_results, "timeout": timeout,
|
|
308
|
+
}
|
|
309
|
+
# 路由结果随录制一起存下来,重跑时用它,避免「真实状态与临时空状态不同」
|
|
310
|
+
# 带来的组合漂移(见 _route_decision)。
|
|
311
|
+
from route import route_query
|
|
312
|
+
case["decision"] = _jsonable(route_query(query, mode=mode, depth=depth))
|
|
313
|
+
with _SwappedEngine(recording_engine_call):
|
|
314
|
+
payload = _run_pipeline(case, engine_call=recording_engine_call)
|
|
315
|
+
|
|
316
|
+
case["engines"] = recorded
|
|
317
|
+
case["_recorded"] = {
|
|
318
|
+
"kept": payload.get("count", 0),
|
|
319
|
+
"domain": payload.get("_domain_decided"),
|
|
320
|
+
"engines_combo": payload.get("_engines_combo"),
|
|
321
|
+
}
|
|
322
|
+
return case
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _slugify(query: str) -> str:
|
|
326
|
+
keep = [c if c.isalnum() else "-" for c in query.strip().lower()]
|
|
327
|
+
slug = "".join(keep).strip("-")
|
|
328
|
+
while "--" in slug:
|
|
329
|
+
slug = slug.replace("--", "-")
|
|
330
|
+
return (slug or "case")[:40]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ── 重跑、对比、检查 ───────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
def evaluate_all(data_path: Path | None = None) -> dict:
|
|
336
|
+
doc = _load_data(data_path)
|
|
337
|
+
cases = doc.get("cases") or []
|
|
338
|
+
results = [replay_case(c, c.get("engines") or {}) for c in cases]
|
|
339
|
+
out: dict[str, Any] = {"n_cases": len(cases), "cases": results}
|
|
340
|
+
if results:
|
|
341
|
+
out["mean_kept"] = round(sum(r["kept"] for r in results) / len(results), 3)
|
|
342
|
+
out["mean_bytes"] = round(
|
|
343
|
+
sum(r["bytes"] for r in results) / len(results), 1)
|
|
344
|
+
scored = [r["ndcg"] for r in results if "ndcg" in r]
|
|
345
|
+
if scored:
|
|
346
|
+
out["mean_ndcg"] = round(sum(scored) / len(scored), 4)
|
|
347
|
+
out["cases_with_missing"] = sum(1 for r in results if r.get("not_recorded"))
|
|
348
|
+
out["total_missing"] = sum(len(r.get("not_recorded") or []) for r in results)
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _clip_field(value: Any, limit: int = 90) -> Any:
|
|
353
|
+
"""字段值截断:diff 是给人看的,整段 snippet 会把输出冲掉。"""
|
|
354
|
+
if isinstance(value, str) and len(value) > limit:
|
|
355
|
+
return value[:limit] + "…"
|
|
356
|
+
if isinstance(value, list) and len(value) > 12:
|
|
357
|
+
return value[:12] + ["…"]
|
|
358
|
+
return value
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _field_changes(base_results: list | None,
|
|
362
|
+
new_results: list | None) -> list[dict]:
|
|
363
|
+
"""同一 URL 上的字段级变化(分数、来源、信号、共识引擎…)。
|
|
364
|
+
|
|
365
|
+
位次表(entered/left/moved)只看得到「谁进谁出」;加工层重构最容易造成的是
|
|
366
|
+
「位次没动但分数/字段变了」——那类漂移必须能看见,否则拆排序或信号阶段时
|
|
367
|
+
没有可信的锁。只比两边都有的 URL,进出的 URL 由 entered/left 表达。
|
|
368
|
+
"""
|
|
369
|
+
bmap = {r.get("url"): r for r in (base_results or [])
|
|
370
|
+
if isinstance(r, dict) and r.get("url")}
|
|
371
|
+
rows: list[dict] = []
|
|
372
|
+
for r in new_results or []:
|
|
373
|
+
if not isinstance(r, dict):
|
|
374
|
+
continue
|
|
375
|
+
base = bmap.get(r.get("url"))
|
|
376
|
+
if base is None:
|
|
377
|
+
continue
|
|
378
|
+
diffs = {k: {"from": _clip_field(base.get(k)), "to": _clip_field(r.get(k))}
|
|
379
|
+
for k in sorted(set(base) | set(r))
|
|
380
|
+
if base.get(k) != r.get(k)}
|
|
381
|
+
if diffs:
|
|
382
|
+
rows.append({"url": r.get("url"), "fields": diffs})
|
|
383
|
+
return rows
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def diff_reports(base: dict, new: dict) -> dict:
|
|
387
|
+
"""两次运行之间的差异:指标变化、哪些结果新进来、哪些掉了、位次怎么动的。
|
|
388
|
+
|
|
389
|
+
这是这个脚本存在的理由——分数本身 ranking_eval 已经能给,这里要的是
|
|
390
|
+
「这次相对上次差在哪」。
|
|
391
|
+
"""
|
|
392
|
+
base_by_id = {c["id"]: c for c in base.get("cases", [])}
|
|
393
|
+
rows = []
|
|
394
|
+
for c in new.get("cases", []):
|
|
395
|
+
b = base_by_id.get(c["id"])
|
|
396
|
+
if b is None:
|
|
397
|
+
rows.append({"id": c["id"], "note": "基准里没有这条(新增的)"})
|
|
398
|
+
continue
|
|
399
|
+
b_urls = list(b.get("urls") or [])
|
|
400
|
+
n_urls = list(c.get("urls") or [])
|
|
401
|
+
entered = [u for u in n_urls if u not in b_urls]
|
|
402
|
+
left = [u for u in b_urls if u not in n_urls]
|
|
403
|
+
moved = []
|
|
404
|
+
for u in n_urls:
|
|
405
|
+
if u in b_urls:
|
|
406
|
+
bi, ni = b_urls.index(u) + 1, n_urls.index(u) + 1
|
|
407
|
+
if bi != ni:
|
|
408
|
+
moved.append({"url": u, "from": bi, "to": ni})
|
|
409
|
+
row = {
|
|
410
|
+
"id": c["id"],
|
|
411
|
+
"d_kept": c["kept"] - b["kept"],
|
|
412
|
+
"d_bytes": c["bytes"] - b["bytes"],
|
|
413
|
+
"entered": entered,
|
|
414
|
+
"left": left,
|
|
415
|
+
"moved": moved,
|
|
416
|
+
}
|
|
417
|
+
fields = _field_changes(b.get("results_full"), c.get("results_full"))
|
|
418
|
+
if fields:
|
|
419
|
+
row["field_changes"] = fields
|
|
420
|
+
if "ndcg" in c and "ndcg" in b:
|
|
421
|
+
row["d_ndcg"] = round(c["ndcg"] - b["ndcg"], 4)
|
|
422
|
+
rows.append(row)
|
|
423
|
+
changed = [r for r in rows
|
|
424
|
+
if r.get("d_kept") or r.get("d_bytes") or r.get("entered")
|
|
425
|
+
or r.get("left") or r.get("d_ndcg") or r.get("field_changes")]
|
|
426
|
+
out: dict[str, Any] = {
|
|
427
|
+
"n_cases": len(rows),
|
|
428
|
+
"n_changed": len(changed),
|
|
429
|
+
"cases": rows,
|
|
430
|
+
"entered_total": sum(len(r.get("entered") or []) for r in rows),
|
|
431
|
+
"left_total": sum(len(r.get("left") or []) for r in rows),
|
|
432
|
+
}
|
|
433
|
+
for key in ("kept", "bytes", "ndcg"):
|
|
434
|
+
if f"mean_{key}" in base and f"mean_{key}" in new:
|
|
435
|
+
out[f"d_mean_{key}"] = round(new[f"mean_{key}"] - base[f"mean_{key}"],
|
|
436
|
+
4 if key == "ndcg" else 1)
|
|
437
|
+
return out
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def check_limits(report: dict, doc: dict) -> list[str]:
|
|
441
|
+
"""逐条核对下限,返回不合格的说明(空表示全部通过)。
|
|
442
|
+
|
|
443
|
+
下限是**可选的**:没设下限的那条不参与条数与体积的判断,而不是默认算过。
|
|
444
|
+
但「录制的数据里没有这个引擎」跟下限无关——那是「这次重跑没能还原录下来的
|
|
445
|
+
那份场景」,任何一条出现都要报出来,否则对比工具自己会变成新的误报来源。
|
|
446
|
+
"""
|
|
447
|
+
bad: list[str] = []
|
|
448
|
+
by_id = {c["id"]: c for c in doc.get("cases", [])}
|
|
449
|
+
for r in report.get("cases", []):
|
|
450
|
+
for eng in r.get("not_recorded") or []:
|
|
451
|
+
bad.append(f"{r['id']}: 录制的数据里没有 {eng}"
|
|
452
|
+
f"(路由变了?还是录制时漏了?)")
|
|
453
|
+
limit = (by_id.get(r["id"]) or {}).get("limit") or {}
|
|
454
|
+
if not limit:
|
|
455
|
+
continue
|
|
456
|
+
if "min_kept" in limit and r["kept"] < limit["min_kept"]:
|
|
457
|
+
bad.append(f"{r['id']}: 结果 {r['kept']} 条,少于下限 "
|
|
458
|
+
f"{limit['min_kept']} 条")
|
|
459
|
+
if "max_bytes" in limit and r["bytes"] > limit["max_bytes"]:
|
|
460
|
+
bad.append(f"{r['id']}: 输出 {r['bytes']} 字节,超过上限 "
|
|
461
|
+
f"{limit['max_bytes']} 字节")
|
|
462
|
+
if "min_ndcg" in limit and "ndcg" in r and r["ndcg"] < limit["min_ndcg"]:
|
|
463
|
+
bad.append(f"{r['id']}: 排序质量分 {r['ndcg']} 低于下限 "
|
|
464
|
+
f"{limit['min_ndcg']}")
|
|
465
|
+
return bad
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def save_baseline(report: dict, path: Path) -> None:
|
|
469
|
+
path.write_text(dumps_pretty({
|
|
470
|
+
"_meta": {"saved_at": datetime.now().isoformat(timespec="seconds"),
|
|
471
|
+
"note": "对照基准:--compare 用它算差异"},
|
|
472
|
+
"report": report,
|
|
473
|
+
}), encoding="utf-8")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def load_baseline(path: Path) -> dict:
|
|
477
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
478
|
+
return doc.get("report", doc)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ── 打印 ──────────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
def _fmt_case(r: dict) -> str:
|
|
484
|
+
line = (f" {r['id']:<28} 结果 {r['kept']:<3} 条 输出 {r['bytes']:>5} 字节"
|
|
485
|
+
f" 领域={r.get('domain')}")
|
|
486
|
+
if "ndcg" in r:
|
|
487
|
+
line += f" 质量分={r['ndcg']:.3f}"
|
|
488
|
+
if r.get("not_recorded"):
|
|
489
|
+
line += f" 没录到[{','.join(r['not_recorded'])}]"
|
|
490
|
+
if r.get("extra_attempts"):
|
|
491
|
+
line += f" 多调了[{','.join(r['extra_attempts'])}]"
|
|
492
|
+
return line
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _fmt_diff(d: dict) -> str:
|
|
496
|
+
lines = [f"对比:{d['n_cases']} 条里有 {d['n_changed']} 条出现变化 | "
|
|
497
|
+
f"新进来 {d['entered_total']} 条 / 掉了 {d['left_total']} 条"]
|
|
498
|
+
for key, label, unit in (("d_mean_kept", "平均结果数", " 条"),
|
|
499
|
+
("d_mean_bytes", "平均输出", " 字节"),
|
|
500
|
+
("d_mean_ndcg", "平均质量分", "")):
|
|
501
|
+
if key in d:
|
|
502
|
+
lines.append(f" {label} {d[key]:+}{unit}")
|
|
503
|
+
for r in d["cases"]:
|
|
504
|
+
flags = []
|
|
505
|
+
if r.get("note"):
|
|
506
|
+
flags.append(r["note"])
|
|
507
|
+
if r.get("d_kept"):
|
|
508
|
+
flags.append(f"结果{r['d_kept']:+d}条")
|
|
509
|
+
if r.get("d_bytes"):
|
|
510
|
+
flags.append(f"输出{r['d_bytes']:+d}字节")
|
|
511
|
+
if r.get("d_ndcg"):
|
|
512
|
+
flags.append(f"质量分{r['d_ndcg']:+.4f}")
|
|
513
|
+
if r.get("entered"):
|
|
514
|
+
flags.append(f"新进来{len(r['entered'])}条")
|
|
515
|
+
if r.get("left"):
|
|
516
|
+
flags.append(f"掉了{len(r['left'])}条")
|
|
517
|
+
if r.get("moved"):
|
|
518
|
+
flags.append(f"位次变了{len(r['moved'])}条")
|
|
519
|
+
if r.get("field_changes"):
|
|
520
|
+
flags.append(f"字段变了{len(r['field_changes'])}条")
|
|
521
|
+
if flags:
|
|
522
|
+
lines.append(f" {r['id']:<28} " + " ".join(flags))
|
|
523
|
+
for fc in r.get("field_changes") or []:
|
|
524
|
+
keys = ", ".join(sorted(fc["fields"]))
|
|
525
|
+
lines.append(f" {fc['url'][:60]} → 字段变化:{keys}")
|
|
526
|
+
return "\n".join(lines)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _fmt_header(doc: dict) -> str:
|
|
530
|
+
meta = doc.get("_meta") or {}
|
|
531
|
+
return (f"录制数据:{len(doc.get('cases') or [])} 条 | "
|
|
532
|
+
f"录制于 {meta.get('recorded_at', '未知')}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ── 命令行 ────────────────────────────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
def _cmd_record(args) -> int:
|
|
538
|
+
queries = args.queries or _DEFAULT_QUERIES
|
|
539
|
+
out_path = Path(args.out) if args.out else DEFAULT_DATA_PATH
|
|
540
|
+
cases: list[dict] = []
|
|
541
|
+
for q in queries:
|
|
542
|
+
print(f"正在录制:{q}", file=sys.stderr)
|
|
543
|
+
try:
|
|
544
|
+
cases.append(record_case(q, mode=args.mode, depth=args.depth,
|
|
545
|
+
max_results=args.n, timeout=args.timeout))
|
|
546
|
+
except Exception as e: # 单条失败不该让整批录制作废
|
|
547
|
+
print(f" 这条失败了,跳过:{type(e).__name__}: {e}", file=sys.stderr)
|
|
548
|
+
if not cases:
|
|
549
|
+
print("一条都没录到", file=sys.stderr)
|
|
550
|
+
return 1
|
|
551
|
+
doc = {
|
|
552
|
+
"_meta": {
|
|
553
|
+
"recorded_at": datetime.now().isoformat(timespec="seconds"),
|
|
554
|
+
"note": "真实引擎返回的录制数据。改动之后如果确定变好,"
|
|
555
|
+
"请更新各条的下限并在这里记一笔。",
|
|
556
|
+
"usage": "录下来的引擎返回只属于录制那一天。重跑保证的是"
|
|
557
|
+
"「我们的处理逻辑没有退步」,不是「现在网上就是这个样子」"
|
|
558
|
+
"——网页结构会变、配额会变、不同地区的可达性也不同。",
|
|
559
|
+
},
|
|
560
|
+
"cases": cases,
|
|
561
|
+
}
|
|
562
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
563
|
+
out_path.write_text(dumps_pretty(doc), encoding="utf-8")
|
|
564
|
+
print(f"已写入 {out_path}({len(cases)} 条)", file=sys.stderr)
|
|
565
|
+
return 0
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def main() -> int:
|
|
569
|
+
ap = argparse.ArgumentParser(description="离线重跑与对比")
|
|
570
|
+
ap.add_argument("--data", help="录制数据文件(默认 tests/golden/pipeline_golden.json)")
|
|
571
|
+
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
|
572
|
+
ap.add_argument("--check", action="store_true",
|
|
573
|
+
help="检查是否低于下限(低于则退出码为 1)")
|
|
574
|
+
ap.add_argument("--case", help="只看某一条的明细")
|
|
575
|
+
ap.add_argument("--compare", help="与基准文件对比,列出差异")
|
|
576
|
+
ap.add_argument("--save-baseline", help="把当前结果存为对照基准")
|
|
577
|
+
ap.add_argument("--record", action="store_true", help="联网录制(唯一会联网的模式)")
|
|
578
|
+
ap.add_argument("--queries", nargs="*", help="录制用的查询词")
|
|
579
|
+
ap.add_argument("--out", help="录制结果写到哪里")
|
|
580
|
+
ap.add_argument("--mode", default="auto")
|
|
581
|
+
ap.add_argument("--depth", default="fast")
|
|
582
|
+
ap.add_argument("-n", type=int, default=5)
|
|
583
|
+
ap.add_argument("--timeout", type=int, default=10)
|
|
584
|
+
args = ap.parse_args()
|
|
585
|
+
|
|
586
|
+
if args.record:
|
|
587
|
+
return _cmd_record(args)
|
|
588
|
+
|
|
589
|
+
data_path = Path(args.data) if args.data else None
|
|
590
|
+
doc = _load_data(data_path)
|
|
591
|
+
if not doc.get("cases"):
|
|
592
|
+
print(f"录制数据是空的:{data_path or DEFAULT_DATA_PATH}\n"
|
|
593
|
+
f"先用 `--record` 录一批真实返回。", file=sys.stderr)
|
|
594
|
+
return 2
|
|
595
|
+
|
|
596
|
+
report = evaluate_all(data_path)
|
|
597
|
+
|
|
598
|
+
if args.case:
|
|
599
|
+
case = next((c for c in doc["cases"] if c["id"] == args.case), None)
|
|
600
|
+
if case is None:
|
|
601
|
+
print(f"没有这条:{args.case}", file=sys.stderr)
|
|
602
|
+
return 2
|
|
603
|
+
r = replay_case(case, case.get("engines") or {})
|
|
604
|
+
print(_fmt_case(r))
|
|
605
|
+
for pos, u in enumerate(r["urls"], 1):
|
|
606
|
+
print(f" #{pos} {u[:78]}")
|
|
607
|
+
return 0
|
|
608
|
+
|
|
609
|
+
if args.save_baseline:
|
|
610
|
+
save_baseline(report, Path(args.save_baseline))
|
|
611
|
+
print(f"基准已存到:{args.save_baseline}", file=sys.stderr)
|
|
612
|
+
|
|
613
|
+
if args.compare:
|
|
614
|
+
diff = diff_reports(load_baseline(Path(args.compare)), report)
|
|
615
|
+
if args.json:
|
|
616
|
+
print(dumps(diff))
|
|
617
|
+
else:
|
|
618
|
+
print(_fmt_header(doc))
|
|
619
|
+
print(_fmt_diff(diff))
|
|
620
|
+
return 0
|
|
621
|
+
|
|
622
|
+
if args.json:
|
|
623
|
+
print(dumps(report))
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
print(_fmt_header(doc))
|
|
627
|
+
for r in report["cases"]:
|
|
628
|
+
print(_fmt_case(r))
|
|
629
|
+
|
|
630
|
+
if args.check:
|
|
631
|
+
bad = check_limits(report, doc)
|
|
632
|
+
if bad:
|
|
633
|
+
print("\n".join(f"不合格 {b}" for b in bad), file=sys.stderr)
|
|
634
|
+
return 1
|
|
635
|
+
print("全部通过:结果条数与输出体积都在下限之内,也没有漏录的引擎")
|
|
636
|
+
return 0
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
if __name__ == "__main__":
|
|
640
|
+
sys.exit(main())
|