argo-search 1.0.1 → 2.9.8
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 +735 -0
- package/README.es.md +777 -0
- package/README.ja.md +777 -0
- package/README.ko.md +777 -0
- package/README.md +778 -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/why-better.svg +104 -0
- package/assets/readme/workflow.svg +83 -0
- package/backends/domain_profiles.json +2356 -96
- package/backends/engine_registry.yaml +3428 -503
- package/backends/query_synonyms_cn.json +35 -0
- package/backends/quota_profiles.json +2488 -160
- package/backends/source_types_cn.json +109 -0
- package/backends/text_lexicon.json +44 -0
- package/bin/argo +772 -0
- package/bin/argo.js +102 -39
- package/config.yaml +5139 -367
- 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 +575 -0
- package/docs/MCP_SETUP.md +108 -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/RELEASE_NOTES_v2.9.8.md +73 -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/cfets.yaml +41 -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/defillama.yaml +38 -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 +36 -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/gleif.yaml +55 -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/hkex.yaml +40 -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/keenable_free.yaml +36 -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/shfe.yaml +39 -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/tavily_free.yaml +56 -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/yuanbao.yaml +93 -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 +106 -0
- package/package.json +55 -12
- package/packages/dsh-plugin/README.md +152 -0
- package/packages/dsh-plugin/cordis.patch.yml +48 -0
- package/packages/dsh-plugin/dsh/client.js +1533 -0
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +527 -0
- package/packages/dsh-plugin/dsh/native-tools.mjs +749 -0
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +38 -0
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +406 -0
- package/packages/dsh-plugin/test/config.test.js +30 -0
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +1382 -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 +294 -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/article_meta.py +131 -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/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/burstiness_detector.py +147 -0
- package/scripts/cache.py +1066 -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/ci_drift_gate.py +90 -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_boundary.py +46 -0
- package/scripts/content_security.py +685 -0
- package/scripts/content_signals.py +751 -0
- package/scripts/crawl.py +74 -35
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/daemon.py +866 -0
- package/scripts/digup_bridge.py +214 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_admission.py +366 -0
- package/scripts/engine_dispatch.py +982 -0
- package/scripts/engine_env.py +539 -0
- package/scripts/engine_failure.py +411 -0
- package/scripts/engine_families.py +738 -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 +799 -294
- package/scripts/engines_base.py +1521 -0
- package/scripts/engines_builders.py +310 -0
- package/scripts/engines_builders_batch10.py +204 -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_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -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_keyless.py +111 -0
- package/scripts/engines_builders_search.py +440 -0
- package/scripts/engines_builders_tech.py +1359 -0
- package/scripts/evidence.py +620 -166
- package/scripts/evidence_loop.py +624 -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 +1804 -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 +195 -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.swift +126 -0
- package/scripts/install.ps1 +166 -0
- package/scripts/install.sh +221 -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_image.py +970 -0
- package/scripts/local_seek.py +402 -0
- package/scripts/macro_countries.py +97 -0
- package/scripts/matrix_search_eval.py +1187 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_diag.py +8 -3
- package/scripts/mcp_handlers.py +492 -0
- package/scripts/mcp_handlers_surface.py +157 -0
- package/scripts/mcp_launch.sh +124 -0
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +125 -419
- package/scripts/mcp_setup.py +431 -0
- package/scripts/mcp_tools.py +331 -0
- package/scripts/mcp_transport.py +191 -0
- package/scripts/mcp_warm.py +127 -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/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -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 +633 -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/recovery_runner.py +150 -0
- package/scripts/redskill/redskill_engine.py +332 -0
- package/scripts/regression_p0p1.py +318 -0
- package/scripts/replay_eval.py +658 -0
- package/scripts/rerank_tfidf.py +225 -0
- package/scripts/research.py +607 -403
- package/scripts/research_cli.py +361 -0
- package/scripts/research_dossier.py +492 -0
- package/scripts/research_expand.py +169 -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/retry_policy.py +76 -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 +243 -0
- package/scripts/route_combo.py +917 -0
- package/scripts/route_domains.py +341 -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 +886 -406
- package/scripts/search_attribution.py +185 -0
- package/scripts/search_benchmark.py +407 -0
- package/scripts/search_cli.py +640 -0
- package/scripts/search_entry.py +395 -0
- package/scripts/search_output.py +690 -0
- package/scripts/search_pipeline.py +926 -0
- package/scripts/search_rank.py +991 -0
- package/scripts/search_types.py +129 -16
- package/scripts/seek_locator.py +68 -0
- package/scripts/semantic_evidence.py +391 -0
- package/scripts/serp_guard.py +336 -0
- package/scripts/serp_kill_log.py +74 -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 +107 -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 +447 -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/scripts/zhihu_quota.py +139 -0
- package/sub-skills/ego-search/SKILL.md +253 -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/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/local-search/SKILL.md +50 -53
- package/sub-skills/local-search/bm25.py +186 -0
- package/sub-skills/local-search/cli_runner.py +331 -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/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +716 -242
- 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/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 +197 -0
package/scripts/search.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
search.py — Unified Search v2 CLI 主入口 &
|
|
3
|
+
search.py — Unified Search v2 CLI 主入口 & 执行调度
|
|
4
4
|
|
|
5
5
|
职责:
|
|
6
6
|
- 解析命令行参数
|
|
@@ -14,306 +14,569 @@ search.py — Unified Search v2 CLI 主入口 & 执行编排
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
import argparse
|
|
18
|
-
import json
|
|
19
|
-
import os
|
|
20
|
-
import sys
|
|
21
17
|
import time
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
|
|
19
|
+
# 尽早取时点:**放在重导入之前**,整条 import 链才算得进「固定开销」。
|
|
20
|
+
# (放在导入之后测出来的是 0——模块体跑完时导入早已结束。)
|
|
21
|
+
# 解释器自身的启动(本机 ~15 ms)到这里仍然测不到;那部分只能由外部
|
|
22
|
+
# `time` 命令给,所以不在这儿谎报成已测。
|
|
23
|
+
_MODULE_T0 = time.perf_counter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
from engine_env import env_flag # noqa: E402
|
|
27
|
+
import json # noqa: E402
|
|
28
|
+
import os # noqa: E402
|
|
29
|
+
import re # noqa: E402
|
|
30
|
+
import sys # noqa: E402
|
|
31
|
+
from typing import Any, Callable, Optional, TYPE_CHECKING # noqa: E402
|
|
25
32
|
|
|
26
33
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
27
34
|
sys.path.insert(0, SCRIPT_DIR)
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
# cache 的导入**推迟到真正要用时**(2026-09-27,方案 B3)。
|
|
37
|
+
# cache.py 的 import 链(sqlite3 / shutil / tempfile / gzip / hashlib)实测
|
|
38
|
+
# 16-25ms,而 import search 本身只要 ~30ms;它只在「真的要读/写缓存」时
|
|
39
|
+
# 才需要——`--list-engines`、纯路由决策、plan_only 路径都用不上。
|
|
40
|
+
#
|
|
41
|
+
# SearchCache 仍是本模块的公开面(tests/test_search_timing.py 写
|
|
42
|
+
# `S.SearchCache()`),故用显式的 `__getattr__` 转发保持可访问。
|
|
43
|
+
# 踩过的坑:**不要**在模块内写裸名 `SearchCache()` 指望 __getattr__ 兜底——
|
|
44
|
+
# PEP 562 只管 `search.SearchCache` 这种属性访问,模块内全局名查找直接
|
|
45
|
+
# NameError(实测 33 条测试集体红)。所以构造点是**函数内局部 import**,
|
|
46
|
+
# 公开面是**属性访问**,两条路各走各的。
|
|
47
|
+
if TYPE_CHECKING: # pragma: no cover - 仅类型检查期
|
|
48
|
+
from cache import SearchCache # noqa: F401
|
|
49
|
+
|
|
50
|
+
# 被移除的 `query_similarity` 是**从未被调用**的死导入(全仓无引用)。
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __getattr__(name: str):
|
|
54
|
+
"""按需转发 cache.SearchCache(模块级惰性属性,仅服务 `search.SearchCache`)。"""
|
|
55
|
+
if name == "SearchCache":
|
|
56
|
+
from cache import SearchCache
|
|
57
|
+
globals()["SearchCache"] = SearchCache
|
|
58
|
+
return SearchCache
|
|
59
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
from config import get_cost_factor, get_execution_config, get_engines # noqa: E402
|
|
63
|
+
|
|
64
|
+
# ── 排序/融合层(search_rank)与输出层(search_output)按职责拆出,这里同名转出,
|
|
65
|
+
# 调用方与既有测试无需改。打桩点若落在这些符号上,必须打在**读取处**(实现模块)。
|
|
66
|
+
# **别按「谁引用了它」裁剪这份清单**:测试用字符串引用模块属性(patch.object
|
|
67
|
+
# (search, "_tokens")),AST 统计会漏——照它裁 24 个名字,全量测试红 83 条。
|
|
68
|
+
from search_rank import ( # noqa: E402
|
|
69
|
+
_RERANK_DEGRADED_STATUSES,
|
|
70
|
+
_CJK_OR_WORD,
|
|
71
|
+
_ENGINE_FUSION_WEIGHTS,
|
|
72
|
+
_REL_FACTOR_TTL,
|
|
73
|
+
_RERANK_BREAKER_KEY,
|
|
74
|
+
_RERANK_POOL_FACTOR,
|
|
75
|
+
_RERANK_POOL_MIN,
|
|
76
|
+
_align_facts_safe,
|
|
77
|
+
_apply_consensus_and_sort,
|
|
78
|
+
_attach_selection_signals,
|
|
79
|
+
_bigrams,
|
|
80
|
+
_canonical_url,
|
|
81
|
+
_content_sig,
|
|
82
|
+
_content_similarity,
|
|
83
|
+
_distinct_data_rows,
|
|
84
|
+
_domain_matches,
|
|
85
|
+
_domain_score_floors,
|
|
86
|
+
_engine_weight,
|
|
87
|
+
_jaccard,
|
|
88
|
+
_lang_prefer_rerank,
|
|
89
|
+
_note_rerank_failure,
|
|
90
|
+
_rel_factor_cache,
|
|
91
|
+
_rerank_breaker,
|
|
92
|
+
_rerank_pool_limit,
|
|
93
|
+
_rrf_weighted_default,
|
|
94
|
+
_score_completeness,
|
|
95
|
+
_score_relevance,
|
|
96
|
+
_single_reliability,
|
|
97
|
+
_tokens,
|
|
98
|
+
_weight_cache,
|
|
99
|
+
deduplicate_by_url,
|
|
100
|
+
filter_results_by_domains,
|
|
101
|
+
invalidate_engine_weight_cache,
|
|
102
|
+
local_five_dim_rerank,
|
|
103
|
+
minhash_dedupe,
|
|
104
|
+
rerank_results,
|
|
105
|
+
rrf_merge,
|
|
106
|
+
)
|
|
107
|
+
from search_entry import _SearchHooks, dispatch, prepare # noqa: E402
|
|
108
|
+
from search_pipeline import ( # noqa: E402
|
|
109
|
+
_SearchRequest,
|
|
110
|
+
_SearchRun,
|
|
111
|
+
finalize,
|
|
112
|
+
postprocess,
|
|
113
|
+
)
|
|
114
|
+
from search_output import ( # noqa: E402
|
|
115
|
+
_ShapeContext,
|
|
116
|
+
shape_response,
|
|
117
|
+
_collect_errors,
|
|
118
|
+
FUNNEL_STAGES,
|
|
119
|
+
_AGENT_RESULT_FIELDS,
|
|
120
|
+
_slow_query_ttl,
|
|
121
|
+
_strip_for_agent,
|
|
122
|
+
build_funnel,
|
|
123
|
+
build_sources,
|
|
124
|
+
describe_funnel,
|
|
125
|
+
format_text_output,
|
|
126
|
+
format_timing,
|
|
127
|
+
funnel_collapse,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# import 链结束的时点。固定开销 = 导入 + argparse + 收尾;这里把导入那段
|
|
131
|
+
# 单独报出来,因为它的可控性最好(延迟导入 / 拆模块就是冲它去的)。
|
|
132
|
+
_IMPORTS_DONE = time.perf_counter()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── engines 惰性代理 ──────────────────────────────────────────────────────────
|
|
136
|
+
# engines 是 import 链里最重的一块:它连带 engines_builders 的 7 个模块与
|
|
137
|
+
# urllib.request 的 http/ssl/email 链,实测占 `import search` 的 39%(交错
|
|
138
|
+
# A/B 中位 47.5ms → 29.2ms,即 18.3ms)。而**缓存命中**这一跳对它零依赖——
|
|
139
|
+
# 实测缓存命中时 engine_search 与 available_engines 调用次数都是 0,两者只在
|
|
140
|
+
# 派发、清单与错误路径上用到。改成惰性代理后:日常热路径省下整棵子树,冷路径
|
|
141
|
+
# 在首次派发时照常导入(总量不变,而冷路径本来就被网络耗时主导)。
|
|
142
|
+
#
|
|
143
|
+
# 必须是**模块级函数**而不是 import 内联:网络出口靠属性替换被顶替——
|
|
144
|
+
# search_benchmark 直接 `search.engine_search = fake`,
|
|
145
|
+
# tests/test_budget_observability 用 `patch("search.engine_search")`。属性被换掉
|
|
146
|
+
# 后代理自然让位,测试与基准的既有语义逐位不变。
|
|
147
|
+
|
|
148
|
+
def engine_search(*args, **kwargs):
|
|
149
|
+
"""惰性包装 engines.search(见上方说明)。"""
|
|
150
|
+
from engines import search as _engine_search
|
|
151
|
+
return _engine_search(*args, **kwargs)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def available_engines(*args, **kwargs):
|
|
155
|
+
"""惰性包装 engines.available_engines(见上方说明)。"""
|
|
156
|
+
from engines import available_engines as _available_engines
|
|
157
|
+
return _available_engines(*args, **kwargs)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── 时间辅助(时间窗归一化 / published_at 解析 / 后过滤 / 排序)──────────────
|
|
161
|
+
#
|
|
162
|
+
# 时间窗三层语义:
|
|
163
|
+
# 1. 下推(since/until → 引擎):入口统一归一化为绝对 ISO,引擎收到确定值
|
|
164
|
+
# 2. 后过滤(结果层保底):引擎不带时间窗能力时,按 published_at 剔除超窗
|
|
165
|
+
# 3. 排序(--sort):仅展示顺序,不影响召回
|
|
166
|
+
#
|
|
167
|
+
# 归一化规则:相对量(Nd/Nh/Nw)→ 绝对日期;绝对时间无时区按本地时区解释
|
|
168
|
+
# (与 published_ts 一致);非法输入保持原样下推、不参与后过滤,不阻断搜索。
|
|
169
|
+
|
|
170
|
+
from time_utils import ( # noqa: E402
|
|
171
|
+
published_ts as _published_ts,
|
|
172
|
+
normalize_time_window as _normalize_time_window,
|
|
173
|
+
apply_time_window as _apply_time_window,
|
|
174
|
+
sort_results_by_time as _sort_results,
|
|
175
|
+
is_time_capable as _is_time_capable,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── 查询改写辅助 ────────────────────────────────────────────────────────────────
|
|
180
|
+
# 实现在 query_signals(纯本地启发式,无网络、无编排状态),此处只做别名导入,
|
|
181
|
+
# 保持既有调用点与测试的 `search._query_coverage_ok` 等名字不变。
|
|
182
|
+
|
|
183
|
+
from query_signals import ( # noqa: E402
|
|
184
|
+
apply_query_rewrite as _apply_query_rewrite,
|
|
185
|
+
results_sufficient as _results_sufficient,
|
|
186
|
+
cumulative_sufficient as _cumulative_sufficient,
|
|
187
|
+
query_coverage_ok as _query_coverage_ok,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _missing_env_for(eng: str) -> list[str]:
|
|
192
|
+
"""返回引擎缺失的环境变量名列表;检测不可用时返回空(不阻断搜索)。
|
|
193
|
+
|
|
194
|
+
与路由层 env_ready(spec) 同计算方式:查当前注册表拿 spec,否则
|
|
195
|
+
声明里自定义 required_env 的引擎在此拦截不到(仅 KNOWN_ENV_ALIASES
|
|
196
|
+
成员能命中)。注册表值是 callable(spec 在闭包里)时退化为原名检测。
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
from engine_env import missing_env_for as _missing
|
|
200
|
+
spec = None
|
|
201
|
+
try:
|
|
202
|
+
from engines import get_engine_spec
|
|
203
|
+
spec = get_engine_spec(eng)
|
|
204
|
+
except Exception:
|
|
205
|
+
spec = None
|
|
206
|
+
return _missing(eng, spec)
|
|
207
|
+
except Exception:
|
|
208
|
+
return []
|
|
33
209
|
|
|
34
210
|
|
|
35
|
-
#
|
|
211
|
+
# 结局分类(状态码表 / 配额与拦截关键词 / classify_engine_outcome)已随编排段
|
|
212
|
+
# 搬到 engine_dispatch;这里只导入仍需在 search 内部用到的两个名字:
|
|
213
|
+
# classify_engine_outcome 供下面的 run_dispatch 注入,配额关键词供自适应
|
|
214
|
+
# 学习跳过判定。
|
|
36
215
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
MERGING = "merging"
|
|
43
|
-
DONE = "done"
|
|
216
|
+
from engine_dispatch import ( # noqa: E402
|
|
217
|
+
classify_engine_outcome as _classify_engine_outcome,
|
|
218
|
+
run_dispatch,
|
|
219
|
+
)
|
|
220
|
+
from local_seek import _run_local_seek, _LOCAL_SEEK_TIMEOUT_S # noqa: E402
|
|
44
221
|
|
|
45
222
|
|
|
46
|
-
#
|
|
223
|
+
# 缓存命中路径的 include-local 宽限窗(秒):主结果命中缓存时墙钟已极小
|
|
224
|
+
# (~25ms),为本地命中等满 _LOCAL_SEEK_TIMEOUT_S 会把快路径拖成慢路径
|
|
225
|
+
# (实测 cached 查询 111~138ms vs --no-local 55ms)。宽限覆盖 seek 的常规
|
|
226
|
+
# 60~140ms;超窗的慢 seek 本次不并入——本地命中缺席总好过拖垮快路径。
|
|
227
|
+
def _local_seek_grace_s() -> float:
|
|
228
|
+
"""本地搜索宽限窗(秒),允许环境变量覆盖。"""
|
|
229
|
+
try:
|
|
230
|
+
raw = os.environ.get("ARGO_LOCAL_SEEK_GRACE_S")
|
|
231
|
+
if raw is not None and raw.strip() != "":
|
|
232
|
+
return max(0.0, float(raw))
|
|
233
|
+
except (TypeError, ValueError):
|
|
234
|
+
pass
|
|
235
|
+
return 0.25
|
|
47
236
|
|
|
48
|
-
def rrf_merge(ranked_lists: list[list[dict[str, Any]]], k: int = 60) -> list[dict[str, Any]]:
|
|
49
|
-
"""Reciprocal Rank Fusion 合并多引擎结果。"""
|
|
50
|
-
scores: dict[str, float] = {}
|
|
51
|
-
items: dict[str, dict[str, Any]] = {}
|
|
52
|
-
|
|
53
|
-
for results in ranked_lists:
|
|
54
|
-
for i, r in enumerate(results):
|
|
55
|
-
url = r.get("url", "") or f"__title__:{r.get('title', '')}"
|
|
56
|
-
scores[url] = scores.get(url, 0.0) + 1.0 / (k + i + 1)
|
|
57
|
-
if url not in items:
|
|
58
|
-
items[url] = dict(r)
|
|
59
|
-
else:
|
|
60
|
-
if r.get("score", 0) > items[url].get("score", 0):
|
|
61
|
-
items[url].update(r)
|
|
62
|
-
sources = {items[url].get("source", ""), r.get("source", "")}
|
|
63
|
-
items[url]["source"] = "/".join(s for s in sources if s)
|
|
64
|
-
|
|
65
|
-
ranked = sorted(scores.items(), key=lambda x: -x[1])
|
|
66
|
-
return [items[url] for url, _ in ranked]
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def deduplicate_by_url(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
70
|
-
"""URL 去重。"""
|
|
71
|
-
seen: set[str] = set()
|
|
72
|
-
out = []
|
|
73
|
-
for r in results:
|
|
74
|
-
key = r.get("url", "") or f"title:{r.get('title', '')}"
|
|
75
|
-
if key not in seen:
|
|
76
|
-
seen.add(key)
|
|
77
|
-
out.append(r)
|
|
78
|
-
return out
|
|
79
237
|
|
|
238
|
+
_LOCAL_SEEK_GRACE_S = _local_seek_grace_s()
|
|
80
239
|
|
|
81
|
-
#
|
|
240
|
+
# include-local 的本地搜索 worker:单例下沉到 local_seek 模块,避免 search.py
|
|
241
|
+
# 在 daemon.py 的 runpy.run_module("search", run_name="__main__") 下重跑顶层时
|
|
242
|
+
# 把 _LOCAL_SEEK_EXECUTOR 覆写为 None、导致每次调用泄漏一个 ThreadPoolExecutor。
|
|
243
|
+
def _get_local_seek_executor():
|
|
244
|
+
from local_seek import get_local_seek_executor
|
|
245
|
+
return get_local_seek_executor()
|
|
82
246
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
api_key = os.environ.get("BOCHA_API_KEY", "")
|
|
90
|
-
if not api_key:
|
|
91
|
-
return results
|
|
92
|
-
|
|
93
|
-
documents = []
|
|
94
|
-
for r in results:
|
|
95
|
-
doc_text = f"{r.get('title', '')} {r.get('snippet', '')}".strip()
|
|
96
|
-
documents.append(doc_text or "empty")
|
|
97
|
-
|
|
98
|
-
import urllib.request
|
|
99
|
-
payload = json.dumps({
|
|
100
|
-
"model": "gte-rerank", "query": query,
|
|
101
|
-
"documents": documents[:50],
|
|
102
|
-
"top_n": min(top_n, len(documents)),
|
|
103
|
-
"return_documents": False,
|
|
104
|
-
}).encode("utf-8")
|
|
105
|
-
|
|
106
|
-
req = urllib.request.Request(
|
|
107
|
-
"https://api.bocha.cn/v1/rerank", data=payload,
|
|
108
|
-
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
|
109
|
-
)
|
|
247
|
+
|
|
248
|
+
def _note_remote_quota_exhausted(engine: str, detail: str) -> None:
|
|
249
|
+
"""远端明示配额耗尽(如 byted 10406)→ 标记到周期边界自动恢复。
|
|
250
|
+
|
|
251
|
+
标记后路由组合层全模式排除该引擎、备用源自然接管;恢复无需人工干预。
|
|
252
|
+
"""
|
|
110
253
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if not rerank_results_list:
|
|
115
|
-
return results
|
|
116
|
-
scored = []
|
|
117
|
-
for rr in rerank_results_list:
|
|
118
|
-
idx = rr.get("index", -1)
|
|
119
|
-
score = rr.get("relevance_score", 0)
|
|
120
|
-
if 0 <= idx < len(results):
|
|
121
|
-
item = dict(results[idx])
|
|
122
|
-
orig_score = item.get("score", 0) or 0
|
|
123
|
-
item["score"] = round(score * 0.7 + orig_score * 0.3, 4)
|
|
124
|
-
scored.append(item)
|
|
125
|
-
if scored:
|
|
126
|
-
scored.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
127
|
-
return scored[:top_n]
|
|
128
|
-
except Exception:
|
|
254
|
+
from quota import get_quota_manager
|
|
255
|
+
get_quota_manager().mark_remote_exhausted(engine, reason=detail)
|
|
256
|
+
except Exception: # 侧信道:标记失败不得影响搜索主流程(规则见 except_sets)
|
|
129
257
|
pass
|
|
130
|
-
return results
|
|
131
258
|
|
|
132
259
|
|
|
133
|
-
#
|
|
260
|
+
# 进度阶段枚举住在 search_types(执行层与加工层都要发进度事件,见那里的注释)。
|
|
261
|
+
from search_types import Stage # noqa: E402
|
|
134
262
|
|
|
135
|
-
def execute_search(query: str, decision: dict[str, Any], max_results: int,
|
|
136
|
-
timeout: int, depth: str, cache: SearchCache, skip_cache: bool,
|
|
137
|
-
mode: str = "auto",
|
|
138
|
-
on_progress: Optional[Callable[[Stage, dict[str, Any]], None]] = None) -> dict[str, Any]:
|
|
139
|
-
"""执行搜索:缓存 → 引擎 → 融合 → 精排 → 写缓存。"""
|
|
140
|
-
domain = decision.get("domain") or "general"
|
|
141
|
-
engine_label = decision.get("engine", "auto")
|
|
142
|
-
engines_combo = decision.get("engines_combo", decision.get("engines", [engine_label]))
|
|
143
|
-
engines = engines_combo
|
|
144
|
-
parallel = decision.get("parallel", False) and len(engines) > 1
|
|
145
|
-
|
|
146
|
-
if on_progress:
|
|
147
|
-
on_progress(Stage.START, {"query": query})
|
|
148
|
-
|
|
149
|
-
cache_engine_key = "+".join(sorted(engines)) if len(engines) > 1 else engines[0]
|
|
150
|
-
|
|
151
|
-
if on_progress:
|
|
152
|
-
on_progress(Stage.ROUTING, {"domain": domain, "engine": engine_label, "engines": engines})
|
|
153
|
-
|
|
154
|
-
# 缓存命中
|
|
155
|
-
if not skip_cache:
|
|
156
|
-
hit = cache.get(query, cache_engine_key, max_results, domain=domain)
|
|
157
|
-
if hit:
|
|
158
|
-
if on_progress:
|
|
159
|
-
on_progress(Stage.CACHE_HIT, {"cache_level": hit.get("_cache_level", "L?")})
|
|
160
|
-
tfidf_scores = decision.get("tfidf_scores", [])
|
|
161
|
-
if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
|
|
162
|
-
tfidf_scores = []
|
|
163
|
-
return {
|
|
164
|
-
"query": query, "engine": engine_label, "engines": engines,
|
|
165
|
-
"engines_combo": engines_combo, "cached": True,
|
|
166
|
-
"cache_level": hit.get("_cache_level", "L?"),
|
|
167
|
-
"domain": domain, "elapsed_ms": 0,
|
|
168
|
-
"tfidf_scores": tfidf_scores,
|
|
169
|
-
"results": hit.get("results", []),
|
|
170
|
-
"count": len(hit.get("results", [])),
|
|
171
|
-
"mode": mode,
|
|
172
|
-
}
|
|
173
263
|
|
|
174
|
-
|
|
175
|
-
on_progress(Stage.SEARCHING, {"engines": engines})
|
|
264
|
+
# ── RRF 融合 ───────────────────────────────────────────────────────────────────
|
|
176
265
|
|
|
177
|
-
|
|
178
|
-
|
|
266
|
+
# URL 归一化的唯一来源在 url_canon:本仓曾有四份各自实现的「URL 归一」
|
|
267
|
+
# (search / plan / candidate_envelope / research_dossier,追踪参数表与
|
|
268
|
+
# 大小写规则各不相同),导致同一链接在融合层与 dossier 层归一成不同键。
|
|
269
|
+
# 此处只做薄转发,规则改动一律进 url_canon。
|
|
270
|
+
from url_canon import canonical_url as _canonical_url_impl # noqa: E402
|
|
179
271
|
|
|
180
|
-
exec_cfg = get_execution_config()
|
|
181
|
-
retry_count = exec_cfg.get("retry_count", 0)
|
|
182
272
|
|
|
183
|
-
def _exec_engine(eng: str, retries: int = retry_count) -> list[dict[str, Any]]:
|
|
184
|
-
for attempt in range(retries + 1):
|
|
185
|
-
res = engine_search(query, eng, n=max_results, timeout=timeout, depth=depth, mode=mode)
|
|
186
|
-
if res and any("error" not in r for r in res):
|
|
187
|
-
return res
|
|
188
|
-
if depth != "balanced":
|
|
189
|
-
return engine_search(query, eng, n=max_results, timeout=timeout, depth="balanced", mode=mode)
|
|
190
|
-
return res if res else []
|
|
191
273
|
|
|
192
|
-
if parallel:
|
|
193
|
-
with ThreadPoolExecutor(max_workers=min(len(engines), 3)) as ex:
|
|
194
|
-
futures = {ex.submit(_exec_engine, eng): eng for eng in engines}
|
|
195
|
-
try:
|
|
196
|
-
for fut in as_completed(futures, timeout=timeout + 2):
|
|
197
|
-
eng = futures[fut]
|
|
198
|
-
try:
|
|
199
|
-
raw_results[eng] = fut.result()
|
|
200
|
-
except Exception as e:
|
|
201
|
-
raw_results[eng] = [{"error": str(e), "source": eng}]
|
|
202
|
-
except TimeoutError:
|
|
203
|
-
for fut in futures:
|
|
204
|
-
if not fut.done():
|
|
205
|
-
fut.cancel()
|
|
206
|
-
eng = futures[fut]
|
|
207
|
-
raw_results[eng] = [{"error": "timeout", "source": eng}]
|
|
208
|
-
for fut in futures:
|
|
209
|
-
if not fut.done():
|
|
210
|
-
fut.cancel()
|
|
211
|
-
else:
|
|
212
|
-
for eng in engines:
|
|
213
|
-
res = _exec_engine(eng)
|
|
214
|
-
raw_results[eng] = res
|
|
215
|
-
if res and any("error" not in r for r in res):
|
|
216
|
-
break
|
|
217
|
-
|
|
218
|
-
elapsed = int((time.time() - t0) * 1000)
|
|
219
|
-
|
|
220
|
-
# 融合
|
|
221
|
-
valid_lists = [res for res in raw_results.values()
|
|
222
|
-
if res and not any("error" in r for r in res)]
|
|
223
|
-
if len(valid_lists) > 1:
|
|
224
|
-
merged = rrf_merge(valid_lists)[:max_results]
|
|
225
|
-
elif valid_lists:
|
|
226
|
-
merged = deduplicate_by_url(valid_lists[0])[:max_results]
|
|
227
|
-
else:
|
|
228
|
-
merged = []
|
|
229
274
|
|
|
230
|
-
|
|
231
|
-
if merged and len(merged) > 1:
|
|
232
|
-
merged = rerank_results(query, merged, top_n=max_results)
|
|
275
|
+
# 引擎融合权重(WG-RRF:按来源质量加权,权威源提权、社交/低质源降权)
|
|
233
276
|
|
|
234
|
-
# 按 score 排序
|
|
235
|
-
if merged:
|
|
236
|
-
merged.sort(key=lambda r: abs(r.get("score", 0) or 0), reverse=True)
|
|
237
|
-
merged = merged[:max_results]
|
|
238
277
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
278
|
+
# 动态可靠性因子(weakest-link,论文 arxiv 2508.01405):熔断/高错误引擎降权,
|
|
279
|
+
# 避免「弱检索路径」在融合时拖垮整体精度。带 30s TTL 缓存,避免热路径重复查询。
|
|
280
|
+
|
|
281
|
+
# _engine_weight 的结果缓存:{(source, lang): (weight, expires_at)}。
|
|
282
|
+
# 见 _engine_weight 文档串——rrf_merge 逐条调用而取值空间极小,缓存后 300 条
|
|
283
|
+
# 结果由 0.54ms 降到常数级;TTL 与上面的可靠性窗口对齐,不额外冻结熔断状态。
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# 精排池容量:放宽截断让 rerank 看到 max_results 的 3 倍(下限 15 条),
|
|
303
|
+
# 最终输出再截断到 max_results。去重提前停与放宽截断共用这一个计算方式——
|
|
304
|
+
# 此前它是散在截断点上的字面量,而「去重该停在哪」需要知道同一个数。
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ── 多语言结果语言偏好软排序(P2-覆盖,2026-08 新增)────────────────────────
|
|
314
|
+
# ja/ko 明确主语言查询:把含目标语言字符(假名/谚文)的结果前移,纯相反语言
|
|
315
|
+
# 结果后移。**软排序不删除**(避免误删混合/技术结果),其余语言零开销返回。
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ── Bocha Reranker ──────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
# 精排端点在熔断器里的键。用 `rerank:` 前缀与可路由引擎区分——它不是一个能
|
|
321
|
+
# 出现在 combo 里的引擎,但**复用同一套熔断语义**(失败计数 → 冷却 → 半开探测
|
|
322
|
+
# → 自动禁用后周期复探),这样就不必另造一套「端点退避」机制。
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ── P0-003:本地五维 Rerank 保底 ──────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ── 融合后段(execute_search 的可独立测试单元)────────────────────────────────
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ── 执行层 ─────────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
# fast 单发总墙钟预算(秒)。引擎级超时收紧(≥8s 源 cap 6s、half_open 2s)之外
|
|
365
|
+
# 的整条路径保底:实测引擎 P95 跨度 1.5-8.5s,6s 预算覆盖绝大多数快引擎,
|
|
366
|
+
# 只砍 github 类拖尾——「等待剩余时间」与「是否再起新引擎」都受它约束。
|
|
367
|
+
_FAST_TOTAL_BUDGET_S = 6.0
|
|
368
|
+
|
|
369
|
+
# auto/budget 的总墙钟预算(秒)。auto 是默认模式,此前**没有**预算
|
|
370
|
+
# (deadline=inf),于是 race 窗口退化成 `_eff_timeout + 2`:timeout 默认 10s
|
|
371
|
+
# → 单查询最坏 grace + 12s(race) = 14s。2026-09-15 实测 "claude 5 release
|
|
372
|
+
# date" 冷查询 13.4s,期间无任何信号说明在等什么,用户体感就是「卡住了」。
|
|
373
|
+
#
|
|
374
|
+
# 取 10.0 = 用户明确的体感阈值,也 ≥ execution.default_timeout(8s),不截断
|
|
375
|
+
# 正常查询(实测中位 4s、p75 8s,绝大多数早已 early-stop 完成),只砍极端尾部。
|
|
376
|
+
_AUTO_TOTAL_BUDGET_S = 10.0
|
|
377
|
+
|
|
378
|
+
# deep 的总墙钟预算(秒)。此前 deep 不设预算——「研究场景宁可等待」;但
|
|
379
|
+
# 2026-09-30 实测这句被一个慢源架空:mode=deep → budget_s=None → deadline=inf,
|
|
380
|
+
# firecrawl(声明 timeout=20)单源拖尾 6.5s,dispatch 7338ms 全在等它,期间
|
|
381
|
+
# 其他 8 个引擎早已交付。15.0 = 覆盖 deep 正常 p50(实测 ~7s)与 p90,只截
|
|
382
|
+
# 20s 级的病态拖尾;已完成引擎的证据一条不丢(budget 只管「还等不等」)。
|
|
383
|
+
# execution.deep_budget_s 可覆盖(0 = 恢复无界,回滚无侵入)。
|
|
384
|
+
_DEEP_TOTAL_BUDGET_S = 15.0
|
|
385
|
+
|
|
386
|
+
# 首选引擎的独占宽限窗(秒):在这个时间内完成且合格就免掉 hedge、只付 1 次
|
|
387
|
+
# 调用;窗口内没完成就补发下一个引擎并行赛跑。
|
|
388
|
+
#
|
|
389
|
+
# **两条路径共用同一个值**,因为它们是同一个语义:「我们最信任的那个引擎,
|
|
390
|
+
# 在放弃它之前先给它多久」。串行垂直域(`parallel=False`)此前根本没有这道窗,
|
|
391
|
+
# 一个死源要耗满自己的超时上限(5-8s)才轮到备选;对冲改造后它与 wave-1 用
|
|
392
|
+
# 同一个数。两个名字、两个值会让「到底等多久」没有单一答案。
|
|
393
|
+
#
|
|
394
|
+
# 2026-10-11 起实际窗 = min(本值, 主引擎历史 P75)(只缩不放:快引擎收窄、
|
|
395
|
+
# 慢引擎不动),见 engine_dispatch._adaptive_grace;`ARGO_ADAPTIVE_GRACE=0`
|
|
396
|
+
# 关闭收窄,回到纯本值(消融对照 / A/B 基线臂)。
|
|
397
|
+
#
|
|
398
|
+
# 取值 0.8s(2026-09-17 实测,本机真实网络,8 条查询 ×5 次、逐条交替执行):
|
|
399
|
+
#
|
|
400
|
+
# - 主引擎在 0.8s 内交付时(网络正常时的大多数),两者完全一致——都是 1 次
|
|
401
|
+
# 调用、墙钟无差别(合计 7388ms → 6085ms 那一轮里 7/8 条查询就是这种情况)。
|
|
402
|
+
# - 主引擎慢于 2.0s 时,旧值要多等 1.2s 才肯补发备选,这段直接进墙钟:
|
|
403
|
+
# 实测慢网那一轮 8 条查询合计 20988ms → 13426ms(**−36%**),其中
|
|
404
|
+
# 「2026年 AI Agent 进展」2856→1228ms、「量子计算 突破 2026」2846→1310ms。
|
|
405
|
+
# - 代价是中间地带(主引擎落在 0.8~2.0s)偶尔多发一次调用:实测 8 条查询
|
|
406
|
+
# 合计调用 8→9 条(+12%),而备选源都是免密钥源。
|
|
407
|
+
#
|
|
408
|
+
# 关键判断:旧值 2.0s 恰好在最差的位置——主引擎(anysearch)实测均值 1.9s,
|
|
409
|
+
# 落在窗前窗后都说不准,于是「省下一次调用」这个目的基本没兑现,却把上界
|
|
410
|
+
# 1.2s 的等待稳定地写进了每一次慢查询。**用一个免费备用源的调用换掉 1.2s
|
|
411
|
+
# 用户可见延迟,是一笔划算的交换。**
|
|
412
|
+
_PRIMARY_GRACE_S = 0.8
|
|
413
|
+
|
|
414
|
+
# 质量守卫拒绝早停后的有界宽限窗(秒)。对冲的备份引擎先回来、但结果被判不充分
|
|
415
|
+
# (计数不足,或与查询零词面交集)时,主引擎还能再跑这么久,之后不再等它。
|
|
416
|
+
# 实测「上海 地铁 线路图」:备份给出 10 条纽约共享单车结果(零词面交集,守卫
|
|
417
|
+
# 正确地拒绝早停),主引擎 Nominatim 对这类查询本就答不了、1-3s 后返回 0 条,
|
|
418
|
+
# 却把墙钟拖到 4.4s。只影响「还等多久」,不改变任何结果的取舍。
|
|
419
|
+
# 0 关闭(回到「等到 race 窗口耗尽」的旧行为,供消融对照)。
|
|
420
|
+
_STRAGGLER_GRACE_S = 1.5
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _straggler_grace() -> float:
|
|
424
|
+
"""宽限窗取值(ARGO_STRAGGLER_GRACE_S 覆盖,供消融对照)。"""
|
|
272
425
|
try:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
426
|
+
raw = os.environ.get("ARGO_STRAGGLER_GRACE_S")
|
|
427
|
+
if raw is not None and raw.strip() != "":
|
|
428
|
+
return max(0.0, float(raw))
|
|
429
|
+
except (TypeError, ValueError):
|
|
430
|
+
pass
|
|
431
|
+
return _STRAGGLER_GRACE_S
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _serial_stagger() -> float:
|
|
435
|
+
"""串行路径的起步间隔:`ARGO_SERIAL_STAGGER_S` > `_PRIMARY_GRACE_S`。
|
|
436
|
+
|
|
437
|
+
值与 wave-1 的宽限窗同源(同一语义,见 `_PRIMARY_GRACE_S` 注释):首个
|
|
438
|
+
引擎跑过这么久还没回来,就补发备选源并行跑,而不是等它耗满自己的超时上限。
|
|
439
|
+
|
|
440
|
+
单调语义:0 = 不节流(备选源与首引擎同时起跑,最激进);值越大越接近严格
|
|
441
|
+
串行(取一个大于引擎超时上限的值即可完全退回改造前的行为)。
|
|
442
|
+
|
|
443
|
+
环境变量这一层是给消融对照用的(取大于超时上限的值 = 退回严格串行);
|
|
444
|
+
与隔壁 `_STRAGGLER_GRACE_S` 同一种取法:常量默认 + 环境变量覆盖、**按值
|
|
445
|
+
传进 engine_dispatch**,不在调度模块里重新解析配置。三种取法(常量 /
|
|
446
|
+
config / env)混用会让「这个值到底是多少」没有单一答案,也容易让测试里
|
|
447
|
+
patch 的常量被 config 静默顶掉。
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
raw = os.environ.get("ARGO_SERIAL_STAGGER_S")
|
|
451
|
+
if raw is not None and raw.strip() != "":
|
|
452
|
+
return max(0.0, float(raw))
|
|
453
|
+
except (TypeError, ValueError):
|
|
281
454
|
pass
|
|
455
|
+
return _PRIMARY_GRACE_S
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _deep_budget_s() -> float | None:
|
|
459
|
+
"""deep 总墙钟预算:常量默认 + execution.deep_budget_s 覆盖。
|
|
460
|
+
|
|
461
|
+
0 / 负数 = 恢复无界(回滚无侵入的逃生门);非法值回落常量。
|
|
462
|
+
与 fast/auto 同一种取法:常量默认、**按值传进 engine_dispatch**,
|
|
463
|
+
不在调度模块里重新解析配置(原因见 _serial_stagger 注释)。
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
raw = float(get_execution_config().get("deep_budget_s", _DEEP_TOTAL_BUDGET_S))
|
|
467
|
+
return raw if raw > 0 else None
|
|
468
|
+
except (TypeError, ValueError):
|
|
469
|
+
return _DEEP_TOTAL_BUDGET_S
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# 单引擎墙钟硬预算(秒):含该引擎的**全部**重试尝试,超预算即停、不再发起
|
|
473
|
+
# 新尝试,由调度层切备选源。
|
|
474
|
+
#
|
|
475
|
+
# 为什么要这个上界(2026-09-10 实测):重试会叠乘。anysearch 曾同时具备
|
|
476
|
+
# 引擎级 retry_count=1(2 次)× HTTP 级 max_retries=1(2 次)× 8s
|
|
477
|
+
# = 最坏 32s(实测 31.3s)
|
|
478
|
+
# 用户侧表现是「一个查询卡半分钟」,而这期间既没切备选源、也没有任何
|
|
479
|
+
# 信号说明在等什么。逐处调小超时不是好解法——那会误杀慢网下正常的源;
|
|
480
|
+
# 给**单引擎总墙钟**设上界才是根本的,且对未来新增的重试层同样生效。
|
|
481
|
+
#
|
|
482
|
+
# 取值 10s:用户明确的体感阈值(「10 秒以内也应该能够解决」),
|
|
483
|
+
# 也 ≥ execution.default_timeout(8s),保证单次正常尝试不被截断。
|
|
484
|
+
_PER_ENGINE_BUDGET_S = 10.0
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ── 阶段耗时(--explain-timing)─────────────────────────────────────────────
|
|
488
|
+
# 实现在 stage_timing(纯数据结构),此处只做别名导入,保持既有调用点与
|
|
489
|
+
# 测试的 `search.StageTiming` 名字不变。
|
|
490
|
+
|
|
491
|
+
from stage_timing import ( # noqa: E402
|
|
492
|
+
StageTiming,
|
|
493
|
+
tick as _tick,
|
|
494
|
+
tock as _tock,
|
|
495
|
+
)
|
|
496
|
+
# 尾宽分层策略(纯函数,拆出理由见 straggler_policy 模块头)
|
|
497
|
+
from straggler_policy import straggler_tail # noqa: E402
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def execute_search(query: str, decision: dict[str, Any], max_results: int,
|
|
501
|
+
timeout: int, depth: str, cache: SearchCache, skip_cache: bool,
|
|
502
|
+
mode: str = "auto",
|
|
503
|
+
since: str | None = None, until: str | None = None,
|
|
504
|
+
sort: str = "relevance",
|
|
505
|
+
on_progress: Optional[Callable[[Stage, dict[str, Any]], None]] = None,
|
|
506
|
+
engine_domain: str | None = None,
|
|
507
|
+
engine_sub_domain: str | None = None,
|
|
508
|
+
timing: StageTiming | None = None) -> dict[str, Any]:
|
|
509
|
+
hooks = _SearchHooks(
|
|
510
|
+
engine_search=engine_search, available_engines=available_engines,
|
|
511
|
+
get_cost_factor=get_cost_factor,
|
|
512
|
+
get_engines=get_engines, get_execution_config=get_execution_config,
|
|
513
|
+
missing_env_for=_missing_env_for,
|
|
514
|
+
classify_outcome=_classify_engine_outcome,
|
|
515
|
+
note_quota_exhausted=_note_remote_quota_exhausted,
|
|
516
|
+
per_engine_budget_s=_PER_ENGINE_BUDGET_S,
|
|
517
|
+
fast_budget_s=_FAST_TOTAL_BUDGET_S,
|
|
518
|
+
auto_budget_s=_AUTO_TOTAL_BUDGET_S,
|
|
519
|
+
deep_budget_s=_deep_budget_s(),
|
|
520
|
+
primary_grace_s=_PRIMARY_GRACE_S,
|
|
521
|
+
straggler_grace_s=_straggler_grace(),
|
|
522
|
+
straggler_tail_fn=straggler_tail,
|
|
523
|
+
serial_stagger_s=_serial_stagger(),
|
|
524
|
+
)
|
|
525
|
+
prepared = prepare(query, decision, max_results, timeout, depth, cache,
|
|
526
|
+
skip_cache, mode=mode, since=since, until=until,
|
|
527
|
+
sort=sort, on_progress=on_progress, timing=timing,
|
|
528
|
+
engine_domain=engine_domain,
|
|
529
|
+
engine_sub_domain=engine_sub_domain,
|
|
530
|
+
hooks=hooks)
|
|
531
|
+
if prepared.cached is not None:
|
|
532
|
+
return prepared.cached
|
|
533
|
+
run = dispatch(prepared.req, prepared.run, hooks)
|
|
534
|
+
run = postprocess(prepared.req, run, hooks)
|
|
535
|
+
return finalize(prepared.req, run, hooks)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
|
|
282
540
|
|
|
283
|
-
if on_progress:
|
|
284
|
-
on_progress(Stage.DONE, {"count": len(merged), "elapsed_ms": elapsed})
|
|
285
541
|
|
|
286
|
-
tfidf_scores = decision.get("tfidf_scores", [])
|
|
287
|
-
if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
|
|
288
|
-
tfidf_scores = []
|
|
289
542
|
|
|
290
|
-
return {
|
|
291
|
-
"query": query, "engine": engine_label, "engines": engines,
|
|
292
|
-
"engines_combo": engines_combo, "cached": False,
|
|
293
|
-
"domain": domain, "elapsed_ms": elapsed,
|
|
294
|
-
"tfidf_scores": tfidf_scores, "results": merged,
|
|
295
|
-
"count": len(merged), "engines_used": list(raw_results.keys()),
|
|
296
|
-
"errors": _collect_errors(raw_results), "mode": mode,
|
|
297
|
-
}
|
|
298
543
|
|
|
299
544
|
|
|
300
|
-
def _collect_errors(raw_results: dict[str, list[dict[str, Any]]]) -> list[str]:
|
|
301
|
-
errors = []
|
|
302
|
-
for eng, res in raw_results.items():
|
|
303
|
-
for r in res:
|
|
304
|
-
if isinstance(r, dict) and "error" in r:
|
|
305
|
-
errors.append(f"{eng}: {r['error']}")
|
|
306
|
-
return errors
|
|
307
545
|
|
|
308
546
|
|
|
309
547
|
# ── 统一入口 ──────────────────────────────────────────────────────────────────
|
|
310
548
|
|
|
549
|
+
from local_seek import resolve_include_local as _resolve_include_local
|
|
550
|
+
|
|
551
|
+
|
|
311
552
|
def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = False,
|
|
312
553
|
skip_cache: bool = False, timeout: int = 10,
|
|
313
554
|
depth: str = "fast", mode: str = "auto", local_first: bool = False,
|
|
314
|
-
rewrite: bool = True, cache: Any = None
|
|
555
|
+
rewrite: bool = True, cache: Any = None,
|
|
556
|
+
on_progress: Optional[Callable[[Stage, dict[str, Any]], None]] = None,
|
|
557
|
+
input_kind: str = "auto",
|
|
558
|
+
plan_only: bool = False,
|
|
559
|
+
force_search: bool = False,
|
|
560
|
+
envelope: bool = True,
|
|
561
|
+
context: str = "search",
|
|
562
|
+
engines_boost: list[str] | None = None,
|
|
563
|
+
since: str | None = None,
|
|
564
|
+
until: str | None = None,
|
|
565
|
+
sort: str = "relevance",
|
|
566
|
+
include_local: bool | None = None,
|
|
567
|
+
include_domains: list[str] | None = None,
|
|
568
|
+
exclude_domains: list[str] | None = None,
|
|
569
|
+
engine_domain: str | None = None,
|
|
570
|
+
engine_sub_domain: str | None = None,
|
|
571
|
+
timing: StageTiming | None = None) -> dict[str, Any]:
|
|
315
572
|
"""统一搜索便捷入口。
|
|
316
573
|
|
|
574
|
+
执行分层(不阻塞日常):
|
|
575
|
+
- daily(默认 auto/fast):直搜,不挂 plan,不要求用户确认
|
|
576
|
+
- professional(mode=deep 或 depth=deep):直搜 + 附加 plan 元数据
|
|
577
|
+
- plan_only:仅离线计划(显式开关,不进热路径默认)
|
|
578
|
+
- known-url:工具分流 handoff(不是「请确认后再搜」)
|
|
579
|
+
|
|
317
580
|
Args:
|
|
318
581
|
query: 搜索查询词
|
|
319
582
|
engine: 指定引擎(默认 auto)
|
|
@@ -325,184 +588,401 @@ def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = F
|
|
|
325
588
|
mode: 预算模式
|
|
326
589
|
local_first: 强制本地优先
|
|
327
590
|
rewrite: 是否自动改写查询(默认 True)
|
|
591
|
+
on_progress: 可选进度回调 (stage, data)
|
|
592
|
+
input_kind: auto|keyword|url-seed|known-url
|
|
593
|
+
plan_only: 仅离线计划,不联网
|
|
594
|
+
force_search: 即使判定 known-url 也强制多引擎搜索
|
|
595
|
+
envelope: 附加 candidates/coverage/limitations
|
|
596
|
+
context: search | research
|
|
597
|
+
engines_boost: 垂直引擎前置列表(研究路径 boost,不锁死单引擎)
|
|
598
|
+
since/until: 发布时间时间窗(如 7d / 2026-08-01),下推到支持时间窗的引擎
|
|
599
|
+
|
|
600
|
+
注意:路由永远基于原始 query。改写词只用于引擎检索,避免
|
|
601
|
+
「Python → 追加 pip/库」之类改写污染 package_search 等域规则。
|
|
328
602
|
"""
|
|
329
|
-
|
|
603
|
+
if cache is None:
|
|
604
|
+
from cache import SearchCache # 局部 import;见模块头注释(不能用裸名)
|
|
605
|
+
cache = SearchCache()
|
|
606
|
+
original_query = query
|
|
607
|
+
|
|
608
|
+
# 查询操作符契约层(2026-10-17 query_operators):指令从路由与检索两侧
|
|
609
|
+
# 剥掉(今天以前 site: 会把路由锁死进错误域且引擎拿到脏查询)。
|
|
610
|
+
try:
|
|
611
|
+
from query_operators import parse_query_operators
|
|
612
|
+
_op_clean, _operators = parse_query_operators(original_query)
|
|
613
|
+
if _op_clean and _op_clean != original_query:
|
|
614
|
+
original_query = _op_clean
|
|
615
|
+
decision_operators = _operators
|
|
616
|
+
except ImportError:
|
|
617
|
+
decision_operators = None
|
|
618
|
+
|
|
619
|
+
# 查询改写:仅影响检索串,不影响路由(在执行引擎前应用)
|
|
330
620
|
rewrite_result = None
|
|
621
|
+
search_query = original_query
|
|
331
622
|
|
|
332
|
-
#
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
623
|
+
# ── 离线计划 / URL 分流(离线计划 / 输入分流)──
|
|
624
|
+
# 纪律:build_plan 无网络、不回调本函数 → 无 plan↔search 死循环
|
|
625
|
+
kind = "keyword"
|
|
626
|
+
tier = "daily"
|
|
627
|
+
plan_info: dict[str, Any] | None = None
|
|
628
|
+
try:
|
|
629
|
+
from plan import (
|
|
630
|
+
build_plan, classify_input_kind, execution_tier, should_attach_plan,
|
|
631
|
+
)
|
|
632
|
+
kind = classify_input_kind(query, input_kind)
|
|
633
|
+
tier = execution_tier(mode, depth, context)
|
|
634
|
+
if plan_only:
|
|
635
|
+
return build_plan(
|
|
636
|
+
query, mode=mode, depth=depth, max_results=n,
|
|
637
|
+
engine=engine if not local_first else "local_search",
|
|
638
|
+
input_kind=input_kind,
|
|
639
|
+
context=context,
|
|
640
|
+
)
|
|
641
|
+
if kind == "known-url" and not force_search:
|
|
642
|
+
plan_info = build_plan(
|
|
643
|
+
query, mode=mode, depth=depth, max_results=n,
|
|
644
|
+
engine=engine, input_kind="known-url", context=context,
|
|
645
|
+
)
|
|
646
|
+
# 不发起多引擎搜索;返回 handoff 形态,避免把读链接当热搜
|
|
647
|
+
out = {
|
|
648
|
+
"query": query,
|
|
649
|
+
"engine": None,
|
|
650
|
+
"engines": [],
|
|
651
|
+
"engines_combo": [],
|
|
652
|
+
"cached": False,
|
|
653
|
+
"domain": None,
|
|
654
|
+
"elapsed_ms": 0,
|
|
655
|
+
"results": [],
|
|
656
|
+
"count": 0,
|
|
657
|
+
"errors": [],
|
|
658
|
+
"engine_outcomes": [],
|
|
659
|
+
"wasted_engine_ms": 0,
|
|
660
|
+
"early_stopped": False,
|
|
661
|
+
"mode": mode,
|
|
662
|
+
"depth": depth,
|
|
663
|
+
"status": "handoff_required",
|
|
664
|
+
"input_kind": "known-url",
|
|
665
|
+
"execution_tier": tier,
|
|
666
|
+
"requires_confirmation": False,
|
|
667
|
+
"plan": plan_info,
|
|
668
|
+
"handoff": plan_info.get("handoff"),
|
|
669
|
+
"limitations": plan_info.get("limitations") or [],
|
|
670
|
+
"schema_version": "1.0",
|
|
671
|
+
"candidates": [],
|
|
672
|
+
"coverage": [],
|
|
673
|
+
}
|
|
674
|
+
return out
|
|
675
|
+
except ImportError:
|
|
676
|
+
kind = "keyword"
|
|
677
|
+
tier = "daily"
|
|
678
|
+
except Exception as e:
|
|
679
|
+
import logging
|
|
680
|
+
logging.getLogger("unified_search").debug(f"plan 分流跳过: {type(e).__name__}")
|
|
681
|
+
|
|
682
|
+
# 无词元查询短路:整条查询连一个字母/数字/汉字都没有(纯符号、纯标点、
|
|
683
|
+
# 纯 emoji),任何文本引擎都不可能召回——实测放行只会白烧引擎调用与
|
|
684
|
+
# 3 秒级时延("!!!" 实测 dispatch 3.1 s 且 anysearch 超时、缓存里多一条
|
|
685
|
+
# 垃圾空结果)。只拦 auto 档:显式指定引擎或 local_first 是用户在点名
|
|
686
|
+
# 「就要拿这个串去搜」,计划分流的 plan_only 也已在上方返回,均不受影响。
|
|
687
|
+
if (engine == "auto" and not local_first
|
|
688
|
+
and not re.search(r"\w", original_query)):
|
|
689
|
+
out = {
|
|
690
|
+
"query": original_query,
|
|
691
|
+
"engine": "none", "engines": [], "engines_used": [],
|
|
692
|
+
"domain": "general", "count": 0, "mode": mode, "depth": depth,
|
|
693
|
+
"status": "completed", "fetch_required": False,
|
|
694
|
+
"evidence_loop": {"high_consequence_domain": None,
|
|
695
|
+
"suggested": [], "verified_count": 0,
|
|
696
|
+
"pending_count": 0},
|
|
697
|
+
"errors": [], "login_hint": {"needs_login": False, "reason": ""},
|
|
698
|
+
"funnel": {"routed": 0, "called": 0, "returned": 0,
|
|
699
|
+
"deduped": 0, "filtered": 0, "kept": 0},
|
|
700
|
+
"limitations": ["query has no word tokens (letters/digits/CJK); "
|
|
701
|
+
"no engine can recall it, network dispatch skipped"],
|
|
702
|
+
"results": [],
|
|
703
|
+
"schema_version": "1.0",
|
|
704
|
+
"input_kind": kind, "execution_tier": tier,
|
|
705
|
+
}
|
|
706
|
+
if timing is not None:
|
|
707
|
+
out["timing"] = timing.summary()
|
|
708
|
+
return out
|
|
341
709
|
|
|
710
|
+
# 查询改写:追加领域关键词提升搜索质量
|
|
711
|
+
# local_first 路径跳过改写:改写词面向 web 引擎召回设计,套到本地
|
|
712
|
+
# 聚合(search_v3 智能路由)上会稀释查询、收窄引擎选择、扩大失败面
|
|
713
|
+
#(实测改写词把「Python 异步编程」扩为 5 词长句后本地聚合返回空)。
|
|
714
|
+
# original_query 此时可能是操作符清洗后的 clean_query(见上方
|
|
715
|
+
# query_operators 解析)——不重新赋值回原值,否则 site: 等指令会重新
|
|
716
|
+
# 污染路由(2026-10-17 实测踩中:此处覆盖后 `DeepSeek site:github.com`
|
|
717
|
+
# 又锁回 code_search)。
|
|
718
|
+
rewrite_result = None
|
|
719
|
+
original_query = search_query if decision_operators else query
|
|
720
|
+
if rewrite and not local_first:
|
|
721
|
+
rewritten, rewrite_result = _apply_query_rewrite(original_query)
|
|
722
|
+
if rewrite_result and rewrite_result.get("rewritten"):
|
|
723
|
+
search_query = rewritten
|
|
724
|
+
|
|
725
|
+
# 时间窗:查询里的 after:/before: 兜底(CLI --since/--until 显式值优先)
|
|
726
|
+
if decision_operators:
|
|
727
|
+
since = since or (str(decision_operators["after"])
|
|
728
|
+
if decision_operators.get("after") else since)
|
|
729
|
+
until = until or (str(decision_operators["before"])
|
|
730
|
+
if decision_operators.get("before") else until)
|
|
731
|
+
|
|
732
|
+
# route 延迟导入:route.py 的 import 链(config → logging/shutil/dataclasses)
|
|
733
|
+
# 实测 31ms,而 --list-engines / --help / plan_only 路径对它零依赖。
|
|
734
|
+
# 搜索路径在首次调用时付一次(sys.modules 缓存后免费),非搜索路径省整条链。
|
|
735
|
+
from route import route_query_cached
|
|
736
|
+
|
|
737
|
+
_op_langs = (decision_operators or {}).get("langs") or None
|
|
342
738
|
if local_first:
|
|
343
|
-
|
|
739
|
+
_tk_route = _tick(timing)
|
|
740
|
+
decision = route_query_cached(
|
|
741
|
+
original_query, engine_override="local_search", mode=mode,
|
|
742
|
+
depth=depth, context=context, engines_boost=engines_boost,
|
|
743
|
+
lang_override=_op_langs,
|
|
744
|
+
)
|
|
745
|
+
_tock(timing, "route", _tk_route)
|
|
344
746
|
else:
|
|
345
|
-
|
|
747
|
+
_tk_route = _tick(timing)
|
|
748
|
+
decision = route_query_cached(
|
|
749
|
+
original_query, engine_override=engine, mode=mode,
|
|
750
|
+
depth=depth, context=context, engines_boost=engines_boost,
|
|
751
|
+
lang_override=_op_langs,
|
|
752
|
+
)
|
|
753
|
+
_tock(timing, "route", _tk_route)
|
|
754
|
+
if decision_operators:
|
|
755
|
+
decision["operators"] = decision_operators
|
|
756
|
+
if context == "research":
|
|
757
|
+
# 研究子查询禁早停:第一个「有结果」的垂直目录(如 models_dev 的
|
|
758
|
+
# 模型规格页)不等于研究证据齐了,跑满 combo 再 RRF 融合。
|
|
759
|
+
# 复用 no_early_stop 通道,串行/并行两条执行路径均已消费该标志。
|
|
760
|
+
decision["no_early_stop"] = True
|
|
346
761
|
if explain:
|
|
347
762
|
combo = decision.get('engines_combo', decision.get('engines', []))
|
|
348
763
|
print(
|
|
349
764
|
f"[路由] {decision['reason']} → engine={decision['engine']} "
|
|
350
765
|
f"combo={combo} domain={decision.get('domain')} "
|
|
351
|
-
f"tfidf={decision.get('tfidf_scores', [])} mode={mode}",
|
|
766
|
+
f"tfidf={decision.get('tfidf_scores', [])} mode={mode} kind={kind} tier={tier}",
|
|
352
767
|
file=sys.stderr,
|
|
353
768
|
)
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
769
|
+
if decision_operators and decision_operators.get("dropped"):
|
|
770
|
+
_o = decision_operators
|
|
771
|
+
print(f"[操作符] 剥离 {_o['dropped']} 排除词 {_o['exclude_terms']} "
|
|
772
|
+
f"语言 {_o['langs']} 时间窗 {_o['after']}~{_o['before']}",
|
|
773
|
+
file=sys.stderr)
|
|
774
|
+
if search_query != original_query:
|
|
775
|
+
print(f"[改写] {original_query} → {search_query}", file=sys.stderr)
|
|
776
|
+
# L1 并行化:本地搜索与主搜索并行提交(仅显式开启时在宽根下提示)。
|
|
777
|
+
_local_seek_explicit = include_local is not None
|
|
778
|
+
include_local = _resolve_include_local(include_local, mode, query)
|
|
779
|
+
_local_seek_future = None
|
|
780
|
+
if include_local:
|
|
781
|
+
_local_seek_future = _get_local_seek_executor().submit(
|
|
782
|
+
_run_local_seek, query, n, warn=_local_seek_explicit)
|
|
783
|
+
|
|
784
|
+
result = execute_search(
|
|
785
|
+
query=search_query, decision=decision, max_results=n,
|
|
786
|
+
timeout=timeout, depth=depth, cache=cache,
|
|
787
|
+
skip_cache=skip_cache, mode=mode, on_progress=on_progress,
|
|
788
|
+
since=since, until=until, sort=sort, timing=timing,
|
|
789
|
+
engine_domain=engine_domain, engine_sub_domain=engine_sub_domain,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# include-local 等待预算(H1,2026-09-29):deadline 跨两个等待点共享
|
|
793
|
+
#(此处的预等待 + 下方 shape_response 的合并等待),总预算不叠加。
|
|
794
|
+
# 主结果命中缓存时收窄到宽限窗(_local_seek_grace_s() 调用时取值:
|
|
795
|
+
# 模块级常量只在 import 时读 env,MCP 常驻进程里改 ARGO_LOCAL_SEEK_GRACE_S
|
|
796
|
+
# 不生效——与 _straggler_grace()/_serial_stagger() 同一原则)。
|
|
797
|
+
_local_wait_cached = isinstance(result, dict) and bool(result.get("cached"))
|
|
798
|
+
_local_deadline = time.monotonic() + (_local_seek_grace_s() if _local_wait_cached
|
|
799
|
+
else _LOCAL_SEEK_TIMEOUT_S)
|
|
800
|
+
# 主搜索完成后,本地搜索应已就绪;若未完成则等待剩余预算
|
|
801
|
+
if _local_seek_future is not None:
|
|
802
|
+
try:
|
|
803
|
+
_local_seek_future.result(timeout=max(_local_deadline - time.monotonic(), 0))
|
|
804
|
+
except Exception:
|
|
805
|
+
pass # 本地搜索失败不影响主结果
|
|
806
|
+
# 对外仍报告用户原始 query
|
|
807
|
+
result["query"] = original_query
|
|
808
|
+
if since:
|
|
809
|
+
result["since"] = since
|
|
810
|
+
if until:
|
|
811
|
+
result["until"] = until
|
|
812
|
+
if sort and sort != "relevance":
|
|
813
|
+
result["sort"] = sort
|
|
814
|
+
if rewrite_result and rewrite_result.get("rewritten"):
|
|
358
815
|
result["rewritten_query"] = {
|
|
359
816
|
"original": rewrite_result["original"],
|
|
360
817
|
"rewritten": rewrite_result["rewritten"],
|
|
361
818
|
"confidence": rewrite_result["confidence"],
|
|
362
819
|
"reason": rewrite_result["reason"],
|
|
363
820
|
}
|
|
821
|
+
result["input_kind"] = kind
|
|
822
|
+
result["status"] = "completed"
|
|
823
|
+
result["execution_tier"] = tier
|
|
824
|
+
result["requires_confirmation"] = False # 日常/专业热路径永不阻塞等确认
|
|
825
|
+
# query_original 仅当改写改变检索词时才携带原始词,供存档/MCP 回退。
|
|
826
|
+
# 此前条件 `original_query != query` 恒为 False(原查询词从未被重赋),
|
|
827
|
+
# 导致该字段永远未写出——任何依赖它的下游都拿不到「是否被改写」的信号。
|
|
828
|
+
if search_query != original_query:
|
|
829
|
+
result["query_original"] = original_query
|
|
830
|
+
|
|
831
|
+
# professional:附加离线 plan 元数据(不阻断、不二次搜索)
|
|
832
|
+
try:
|
|
833
|
+
from plan import build_plan, should_attach_plan
|
|
834
|
+
if should_attach_plan(mode, depth, context, plan_only=False):
|
|
835
|
+
result["plan"] = build_plan(
|
|
836
|
+
original_query, mode=mode, depth=depth, max_results=n,
|
|
837
|
+
engine=engine if not local_first else "local_search",
|
|
838
|
+
input_kind=kind if kind != "auto" else "auto",
|
|
839
|
+
context=context,
|
|
840
|
+
)
|
|
841
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
842
|
+
pass
|
|
843
|
+
|
|
844
|
+
# 结果局限声明:**质量信号,与归档开关无关**,两条路径共用同一计算方式。
|
|
845
|
+
# 此前只在 envelope 分支内计算,于是 --no-envelope(文档推荐给 agent 的
|
|
846
|
+
# 档位)整块拿不到它——agent 无从知晓拿到的是「相关发现而非正文」
|
|
847
|
+
# 「降级路由结果」「未预确认的 daily 档」(2026-09-15 输出契约审查)。
|
|
848
|
+
extra_lim: list[str] = []
|
|
849
|
+
if kind == "url-seed":
|
|
850
|
+
extra_lim.append(
|
|
851
|
+
"url-seed: seed URL was not fetched; results are related discovery only"
|
|
852
|
+
)
|
|
853
|
+
# 恢复链两种结局分开说(2026-10-10 方案甲):「恢复成功换了引擎」与
|
|
854
|
+
# 「触发了但无可用策略」对消费者的含义完全不同。失败那一半再细分:
|
|
855
|
+
# 源被跳过/鉴权失败时不要写成「查询没有召回」(2026-10-22 修正),
|
|
856
|
+
# 具体判据与措辞在 search_attribution.zero_result_recovery_note。
|
|
857
|
+
_rec_info = result.get("recovery") or {}
|
|
858
|
+
from search_attribution import zero_result_recovery_note
|
|
859
|
+
_rec_note = zero_result_recovery_note(
|
|
860
|
+
_rec_info, result.get("engine_outcomes"))
|
|
861
|
+
if _rec_note is not None:
|
|
862
|
+
extra_lim.append(_rec_note)
|
|
863
|
+
elif result.get("recovery"):
|
|
864
|
+
extra_lim.append("recovery used; engine fallback may differ from primary route")
|
|
865
|
+
# 执行归因:把「为什么只有这么几条 / 为什么只跑了一个源」写进随答案一起
|
|
866
|
+
# 到达的局限声明。此前这类问题只能靠翻 engine_outcomes 与 early_stopped
|
|
867
|
+
# 反推,而局限声明本身就是给消费者看的「这批结果能用到什么程度」,
|
|
868
|
+
# 成因属于同一类信息(GLM 密集反馈:反馈要能追到具体原因)。
|
|
869
|
+
_fn = result.get("funnel")
|
|
870
|
+
if isinstance(_fn, dict):
|
|
871
|
+
_routed, _called = _fn.get("routed"), _fn.get("called")
|
|
872
|
+
if isinstance(_routed, int) and isinstance(_called, int) \
|
|
873
|
+
and _called < _routed:
|
|
874
|
+
# 有漏斗时由这里给带数字的版本;candidate_envelope.build_limitations
|
|
875
|
+
# 见到 funnel 就不再出泛化表述——同一件事只留一句。
|
|
876
|
+
extra_lim.append(
|
|
877
|
+
f"early_stopped: only {_called} of {_routed} routed engines "
|
|
878
|
+
"were queried; coverage may be narrower than the route implies"
|
|
879
|
+
)
|
|
880
|
+
if result.get("count") == 0:
|
|
881
|
+
_stage = funnel_collapse(_fn)
|
|
882
|
+
if _stage:
|
|
883
|
+
extra_lim.append(
|
|
884
|
+
f"no results: pipeline emptied at '{_stage}' "
|
|
885
|
+
f"[{describe_funnel(_fn)}]"
|
|
886
|
+
)
|
|
887
|
+
# 输出成形(七个阶段:档位局限 → envelope/局限 → 本地正文索引 → 证据门控
|
|
888
|
+
# → 域过滤 → 信源标准化 → 本地命中并入)住在 search_output.shape_response:
|
|
889
|
+
# 那是**响应契约**,与执行/调度无关。
|
|
890
|
+
# 显式点名的引擎里查无此名者:随结果一起报出来(判据见
|
|
891
|
+
# unknown_requested_engines)。局限声明走既有通道 extra_lim,错误行在
|
|
892
|
+
# 成形之后追加——errors 是执行层的账,这里只做「请求级事实」的补充,
|
|
893
|
+
# 不去改执行层的账(两份账混写会互相覆盖)。
|
|
894
|
+
# import 收进真会用到判定的分支:engine 为 auto(默认)时判据函数恒返
|
|
895
|
+
# [],而 import engines 会把整只引擎构建栈(连带 urllib/http/xml/email,
|
|
896
|
+
# 2026-10-01 实测 ~115ms)拖进每次搜索——含缓存命中。红绿锁:
|
|
897
|
+
# tests/test_hotpath_import_lint.py
|
|
898
|
+
_unknown_engines: list[str] = []
|
|
899
|
+
if not local_first and engine and engine != "auto":
|
|
900
|
+
from engines import unknown_requested_engines as _unknown_requested
|
|
901
|
+
_unknown_engines = _unknown_requested(engine)
|
|
902
|
+
if _unknown_engines:
|
|
903
|
+
extra_lim.append(
|
|
904
|
+
"unknown engine name(s) requested: "
|
|
905
|
+
+ ", ".join(_unknown_engines)
|
|
906
|
+
+ "; results may come only from the remaining engines")
|
|
907
|
+
# 包装 _run_local_seek:若 Future 已完成则直接返回结果,否则等待
|
|
908
|
+
# (共享上方的 _local_deadline:缓存命中路径的合并等待同样受宽限窗约束)
|
|
909
|
+
def _local_seek_wrapper(q, max_n, search_dir=None):
|
|
910
|
+
if _local_seek_future is not None:
|
|
911
|
+
try:
|
|
912
|
+
return _local_seek_future.result(
|
|
913
|
+
timeout=max(_local_deadline - time.monotonic(), 0))
|
|
914
|
+
except Exception:
|
|
915
|
+
return []
|
|
916
|
+
return _run_local_seek(q, max_n, search_dir)
|
|
917
|
+
|
|
918
|
+
result = shape_response(_ShapeContext(
|
|
919
|
+
query=query, kind=kind, tier=tier, envelope=envelope, decision=decision,
|
|
920
|
+
extra_lim=extra_lim, cache=cache, include_domains=include_domains,
|
|
921
|
+
exclude_domains=exclude_domains, include_local=include_local, n=n,
|
|
922
|
+
run_local_seek=_local_seek_wrapper,
|
|
923
|
+
), result)
|
|
924
|
+
|
|
925
|
+
# serp_guard 整页误杀外露(2026-10-10 方案甲):_serp_guard_apply 返回 []
|
|
926
|
+
# 是「诚实空」,但对调用方等价于「没搜到」——误杀类缺陷(搜狗跳转壳
|
|
927
|
+
# 10-06、百家号 10-08)都在这里静默过。引擎执行完统一 drain 进 errors,
|
|
928
|
+
# 让误杀可见、可归因(ARGO_SERP_GUARD=0 可复核);空账不产生任何输出。
|
|
929
|
+
try:
|
|
930
|
+
from serp_kill_log import drain_serp_guard_kills
|
|
931
|
+
for _kill in drain_serp_guard_kills():
|
|
932
|
+
result.setdefault("errors", []).append(
|
|
933
|
+
f"serp_guard: {_kill['engine']} 头部结果均未覆盖 20% 查询词,"
|
|
934
|
+
f"整页按答非所问丢弃({_kill['results']} 条)——"
|
|
935
|
+
"如需复核可设 ARGO_SERP_GUARD=0 重跑")
|
|
936
|
+
except ImportError:
|
|
937
|
+
pass
|
|
938
|
+
|
|
939
|
+
for _name in _unknown_engines:
|
|
940
|
+
result.setdefault("errors", []).append(
|
|
941
|
+
f"{_name}: registry 查无此引擎(拼写错误,或该源未随本版发布)")
|
|
942
|
+
|
|
364
943
|
return result
|
|
365
944
|
|
|
366
945
|
|
|
367
|
-
# ──
|
|
946
|
+
# ── 信源标准化 ─────────────────────────────────────────────────────────────────
|
|
368
947
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
engine = results.get("engine", "?")
|
|
374
|
-
cached = results.get("cached", False)
|
|
375
|
-
cache_level = results.get("cache_level", "")
|
|
376
|
-
domain = results.get("domain", "")
|
|
377
|
-
mode = results.get("mode", "auto")
|
|
378
|
-
|
|
379
|
-
header = f"=== {count} results ({elapsed}ms via {engine})"
|
|
380
|
-
if cached:
|
|
381
|
-
header += f" [CACHE {cache_level}]"
|
|
382
|
-
elif domain:
|
|
383
|
-
header += f" [domain:{domain}]"
|
|
384
|
-
if mode != "auto":
|
|
385
|
-
header += f" [mode:{mode}]"
|
|
386
|
-
lines.append(header)
|
|
387
|
-
|
|
388
|
-
for err in results.get("errors", [])[:3]:
|
|
389
|
-
lines.append(f" [ERROR] {err}")
|
|
390
|
-
|
|
391
|
-
for r in results.get("results", []):
|
|
392
|
-
score = r.get("score", 0)
|
|
393
|
-
title = r.get("title", "?")[:80]
|
|
394
|
-
url = r.get("url", "")
|
|
395
|
-
prefix = f"[{score:.2f}]" if score else "[?]"
|
|
396
|
-
lines.append(f" {prefix} {title}")
|
|
397
|
-
if url:
|
|
398
|
-
lines.append(f" {url}")
|
|
399
|
-
snippet = r.get("snippet", "")
|
|
400
|
-
if snippet:
|
|
401
|
-
lines.append(f" {snippet[:120]}")
|
|
402
|
-
|
|
403
|
-
return "\n".join(lines)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
# ── CLI 主入口 ─────────────────────────────────────────────────────────────────
|
|
407
|
-
|
|
408
|
-
def main():
|
|
409
|
-
parser = argparse.ArgumentParser(
|
|
410
|
-
description="Unified Search v2 — 统一搜索 CLI",
|
|
411
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
412
|
-
epilog="""
|
|
413
|
-
示例:
|
|
414
|
-
python3 search.py "python async"
|
|
415
|
-
python3 search.py "英伟达财报" --explain --json
|
|
416
|
-
python3 search.py "基金推荐" --mode fast
|
|
417
|
-
python3 search.py "AAPL" --engine anysearch --domain finance --sub_domain finance.us_stock
|
|
418
|
-
""",
|
|
419
|
-
)
|
|
420
|
-
parser.add_argument("query", nargs="?")
|
|
421
|
-
parser.add_argument("--engine", "-e", default="auto")
|
|
422
|
-
parser.add_argument("--max-results", "-n", type=int, default=5)
|
|
423
|
-
parser.add_argument("--depth", "-d", default="fast",
|
|
424
|
-
choices=["fast", "balanced", "deep"])
|
|
425
|
-
parser.add_argument("--no-cache", action="store_true")
|
|
426
|
-
parser.add_argument("--explain", action="store_true")
|
|
427
|
-
parser.add_argument("--json", action="store_true", dest="json_output")
|
|
428
|
-
parser.add_argument("--timeout", "-t", type=int, default=10)
|
|
429
|
-
parser.add_argument("--list-engines", action="store_true")
|
|
430
|
-
parser.add_argument("--mode", default="auto",
|
|
431
|
-
choices=["fast", "auto", "deep", "budget"],
|
|
432
|
-
help="预算模式: fast=免费优先, auto=成本感知, deep=质量优先, budget=配额控制")
|
|
433
|
-
parser.add_argument("--local-first", action="store_true",
|
|
434
|
-
help="强制优先使用 local_search 零成本聚合引擎")
|
|
435
|
-
parser.add_argument("--domain", default="", help="AnySearch 垂直域")
|
|
436
|
-
parser.add_argument("--sub_domain", default="", help="AnySearch 子域")
|
|
437
|
-
parser.add_argument("--progress", action="store_true")
|
|
438
|
-
|
|
439
|
-
args = parser.parse_args()
|
|
440
|
-
|
|
441
|
-
if args.list_engines:
|
|
442
|
-
print(json.dumps(available_engines(), ensure_ascii=False, indent=2))
|
|
443
|
-
return
|
|
444
|
-
|
|
445
|
-
if not args.query:
|
|
446
|
-
parser.error("必须提供搜索关键词")
|
|
447
|
-
|
|
448
|
-
cache = SearchCache()
|
|
449
|
-
if args.local_first:
|
|
450
|
-
decision = route_query(args.query, engine_override="local_search", mode=args.mode)
|
|
451
|
-
else:
|
|
452
|
-
decision = route_query(args.query, engine_override=args.engine, mode=args.mode)
|
|
948
|
+
# --fields agent:每条 result 保留的答案字段(P2-2)。
|
|
949
|
+
# 媒体专属字段按需登记——非该媒体的结果此键为 None,_strip_for_agent 会自动
|
|
950
|
+
# 丢弃,其他查询不付代价:image_* 来自图源,episode_count/duration_minutes
|
|
951
|
+
# 来自 itunes 的播客结果。
|
|
453
952
|
|
|
454
|
-
# 查询改写
|
|
455
|
-
rewrite_result = None
|
|
456
|
-
try:
|
|
457
|
-
from query_rewriter import rewrite_query as do_rewrite
|
|
458
|
-
rewrite_result = do_rewrite(args.query)
|
|
459
|
-
if rewrite_result["rewritten"] and rewrite_result["confidence"] >= 0.7:
|
|
460
|
-
search_query = rewrite_result["rewritten"]
|
|
461
|
-
else:
|
|
462
|
-
search_query = args.query
|
|
463
|
-
except Exception:
|
|
464
|
-
search_query = args.query
|
|
465
953
|
|
|
466
|
-
if args.explain:
|
|
467
|
-
combo = decision.get('engines_combo', decision.get('engines', []))
|
|
468
|
-
print(
|
|
469
|
-
f"[路由] {decision['reason']} → engine={decision['engine']} "
|
|
470
|
-
f"combo={combo} domain={decision.get('domain')} "
|
|
471
|
-
f"tfidf={decision.get('tfidf_scores', [])} mode={args.mode}",
|
|
472
|
-
file=sys.stderr,
|
|
473
|
-
)
|
|
474
|
-
if rewrite_result and rewrite_result["rewritten"]:
|
|
475
|
-
print(
|
|
476
|
-
f"[改写] {rewrite_result['original']} → {rewrite_result['rewritten']} "
|
|
477
|
-
f"({rewrite_result['confidence']:.0%})",
|
|
478
|
-
file=sys.stderr,
|
|
479
|
-
)
|
|
480
954
|
|
|
481
|
-
on_progress = None
|
|
482
|
-
if args.progress:
|
|
483
|
-
def on_progress(stage: Stage, data: dict[str, Any]):
|
|
484
|
-
print(f"[progress] {stage.value} {data}", file=sys.stderr)
|
|
485
955
|
|
|
486
|
-
results = execute_search(
|
|
487
|
-
query=search_query, decision=decision, max_results=args.max_results,
|
|
488
|
-
timeout=args.timeout, depth=args.depth, cache=cache,
|
|
489
|
-
skip_cache=args.no_cache, mode=args.mode, on_progress=on_progress,
|
|
490
|
-
)
|
|
491
956
|
|
|
492
|
-
if rewrite_result and rewrite_result["rewritten"]:
|
|
493
|
-
results["rewritten_query"] = {
|
|
494
|
-
"original": rewrite_result["original"],
|
|
495
|
-
"rewritten": rewrite_result["rewritten"],
|
|
496
|
-
"confidence": rewrite_result["confidence"],
|
|
497
|
-
"reason": rewrite_result["reason"],
|
|
498
|
-
}
|
|
499
957
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
# ── 输出格式化 ─────────────────────────────────────────────────────────────────
|
|
505
969
|
|
|
506
970
|
|
|
507
971
|
if __name__ == "__main__":
|
|
972
|
+
# 把本模块(此刻身份是 __main__)的 import 时点交给 search_cli。
|
|
973
|
+
#
|
|
974
|
+
# 为什么需要这一行:入口是 bin/argo → runpy.run_module("search",
|
|
975
|
+
# run_name="__main__"),本模块体以 __main__ 身份跑完整条 import 链,
|
|
976
|
+
# 置的 _MODULE_T0 / _IMPORTS_DONE 是真值(约 30 ms)。紧接着下面这行
|
|
977
|
+
# `from search_cli import main` 会让 search_cli 执行 `from search import
|
|
978
|
+
# _MODULE_T0` —— 那是**第二次**导入 search(真模块),此刻依赖全在
|
|
979
|
+
# sys.modules 里,差值 ≈ 0。于是 --explain-timing 的 import_ms 恒为 0.0,
|
|
980
|
+
# 而它这一栏恰恰是给「优化固定开销」用的,报 0 等于把人指向错的数字。
|
|
981
|
+
#
|
|
982
|
+
# runpy 不会把 __main__ 留在 sys.modules['__main__'](它会恢复原模块),
|
|
983
|
+
# 所以靠 __main__ 取不到;写进 search_cli 的模块属性是唯一稳定的交接点。
|
|
984
|
+
from search_cli import main # 反向导入放这里:CLI 依赖本模块,模块级会成环
|
|
985
|
+
import search_cli as _cli_mod
|
|
986
|
+
_cli_mod._TRUE_IMPORT_T0 = _MODULE_T0
|
|
987
|
+
_cli_mod._TRUE_IMPORTS_DONE = _IMPORTS_DONE
|
|
508
988
|
main()
|