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
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""search_pipeline.py — 融合后加工:把「多引擎结果」变成「可交付结果」。
|
|
3
|
+
|
|
4
|
+
## 为什么单独成模块
|
|
5
|
+
|
|
6
|
+
execute_search 里,dispatch(打网络)与「融合后加工」(纯本地,13 个阶段)是两种
|
|
7
|
+
性质完全不同的工作:前者关心并发/超时/熔断,后者关心过滤、去重、排序、标注。
|
|
8
|
+
后者有 354 行、9 个计时段、6 个漏斗计数,混在网络编排里时,读一次「为什么这条
|
|
9
|
+
结果被剔掉了」要穿过整条调度链。
|
|
10
|
+
|
|
11
|
+
## 两概念的状态模型(这是本模块存在的理由)
|
|
12
|
+
|
|
13
|
+
加工阶段的输入输出曾经散成 28 个外层局部量 + 21 个写回量。现在只有两个概念:
|
|
14
|
+
|
|
15
|
+
- `_SearchRequest`:**请求侧**,进入加工后不再改变(查询、档位、时间窗、
|
|
16
|
+
钩子、计时句柄);
|
|
17
|
+
- `_SearchRun`:**运行侧**,被阶段逐步改写的累加量(结果集、各类剔除计数、
|
|
18
|
+
精排状态、漏斗、缓存载荷)。
|
|
19
|
+
|
|
20
|
+
阶段函数签名统一为 `(req, run) -> run`。要加一个阶段时,只需在 run 上加一个
|
|
21
|
+
字段,不必再往 execute_search 的局部量里塞东西——那条路径正是当初把
|
|
22
|
+
execute_search 撑到 724 行的原因。
|
|
23
|
+
|
|
24
|
+
## 边界
|
|
25
|
+
|
|
26
|
+
本模块**不打网络**(唯一例外是 D6 macro 证据下限与空结果恢复树,它们通过
|
|
27
|
+
`req.run_one` / `req.ingest` 钩子回到调度层执行)。判据:本文件不 import
|
|
28
|
+
http_client / engines_base / engine_dispatch。
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from search_types import slot_record as dataclass, slot_field as field, slot_replace as replace
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
# 模块级曾有一行 `import logging`,而本模块 7 处 `logging.getLogger(...)`
|
|
37
|
+
# 全部是 debug 级别、且全仓从未把 unified_search 的 level 调离默认 WARNING
|
|
38
|
+
# ——也就是说它们默认永不产生输出,代价却是把 traceback → dataclasses →
|
|
39
|
+
# inspect → _colorize(CPython 3.13+ 实测 14.6 ms / 87 个模块)拖进
|
|
40
|
+
# `import search` 的必经之路。search.py 在模块级导入本模块,所以每次 argo
|
|
41
|
+
# 调用(含纯缓存命中的那一档)都要付这笔钱去买一个从不打字的 logger。
|
|
42
|
+
#
|
|
43
|
+
# 修法沿用 search_output.py / argo_engine_registry.py 的既定模式:一处按需
|
|
44
|
+
# 取 logger 的小函数,模块级不留任何绑定。绝不能改成就地 `import logging`
|
|
45
|
+
# ——那会与下面 `except` 块内的局部导入形成遮蔽,重演 search_output 里那次
|
|
46
|
+
# UnboundLocalError。
|
|
47
|
+
def _log(message: str) -> None:
|
|
48
|
+
"""默认静默的调试出口;仅在真正需要记录时才引入 logging。"""
|
|
49
|
+
import logging
|
|
50
|
+
logging.getLogger("unified_search").debug(message)
|
|
51
|
+
|
|
52
|
+
from bounded_run import run_bounded
|
|
53
|
+
from cache_guard import attempt_cache_write, should_record_adaptive
|
|
54
|
+
from engine_env import env_flag
|
|
55
|
+
from variant_recall import augment_with_variants
|
|
56
|
+
from phrase_filter import apply_phrase_filter, extract_phrases
|
|
57
|
+
from except_sets import OPT_IMPORT, SHAPE_BENIGN
|
|
58
|
+
from postprocess_signals import apply_tfidf_polish, detect_number_conflict
|
|
59
|
+
from search_attribution import usage_log_attribution, is_synthetic_run
|
|
60
|
+
from search_output import _collect_errors, _slow_query_ttl, build_funnel
|
|
61
|
+
from serp_kill_log import clean_engine_lists
|
|
62
|
+
from search_rank import (
|
|
63
|
+
_LANG_SCRIPT,
|
|
64
|
+
_RERANK_DEGRADED_STATUSES,
|
|
65
|
+
_align_facts_safe,
|
|
66
|
+
_apply_consensus_and_sort,
|
|
67
|
+
_attach_selection_signals,
|
|
68
|
+
_lang_prefer_rerank,
|
|
69
|
+
_rerank_pool_limit,
|
|
70
|
+
deduplicate_by_url,
|
|
71
|
+
local_five_dim_rerank,
|
|
72
|
+
minhash_dedupe,
|
|
73
|
+
rerank_results,
|
|
74
|
+
rrf_merge,
|
|
75
|
+
)
|
|
76
|
+
from search_types import Stage
|
|
77
|
+
from stage_timing import tick as _tick, tock as _tock
|
|
78
|
+
from time_utils import apply_time_window as _apply_time_window
|
|
79
|
+
from time_utils import sort_results_by_time as _sort_results
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class _SearchRequest:
|
|
84
|
+
"""请求侧输入:进入加工后不再改变(请求参数、派生查询、钩子、计时句柄)。"""
|
|
85
|
+
|
|
86
|
+
query: str
|
|
87
|
+
decision: dict[str, Any]
|
|
88
|
+
engines: list[str]
|
|
89
|
+
engines_combo: list[str]
|
|
90
|
+
domain: str
|
|
91
|
+
mode: str
|
|
92
|
+
depth: str
|
|
93
|
+
timeout: int
|
|
94
|
+
max_results: int
|
|
95
|
+
retrieval_query: str
|
|
96
|
+
parallel: bool
|
|
97
|
+
eff_timeout: float
|
|
98
|
+
exclude_terms: list[str]
|
|
99
|
+
qu: Any
|
|
100
|
+
since_iso: str | None
|
|
101
|
+
until_iso: str | None
|
|
102
|
+
since_ts: float | None
|
|
103
|
+
until_ts: float | None
|
|
104
|
+
time_aware: bool
|
|
105
|
+
skip_cache: bool
|
|
106
|
+
timing: Any
|
|
107
|
+
on_progress: Any
|
|
108
|
+
sort: str
|
|
109
|
+
cache: Any
|
|
110
|
+
engine_label: str
|
|
111
|
+
cache_engine_key: Any
|
|
112
|
+
emit_usage_log: Any
|
|
113
|
+
breaker: Any
|
|
114
|
+
# 引擎级垂直域/子域(CLI --domain/--sub_domain)。此前这两个开关被
|
|
115
|
+
# argparse 接下、SKILL.md 与 usage.md 都写进了用法,却没有任何一层读取
|
|
116
|
+
# 它们——请求照发、结果照回,只是 `domain` 参数根本没到引擎,于是
|
|
117
|
+
# 「限定金融域」静默退化成通用搜索。`domain` 字段(上方)是**路由域**,
|
|
118
|
+
# 与这里的引擎入参同名不同义,刻意不合并。
|
|
119
|
+
engine_domain: str | None = None
|
|
120
|
+
engine_sub_domain: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class _SearchRun:
|
|
125
|
+
"""运行侧状态:被阶段逐步改写(`replace` 返回新实例)。
|
|
126
|
+
|
|
127
|
+
字段分三组:结果集(raw_results / engine_outcomes / merged)、加工累加量
|
|
128
|
+
(各类剔除计数、精排状态、漏斗、缓存载荷)、调度产出(engine_latency /
|
|
129
|
+
预算与墙钟账 / 计时句柄与钩子)。
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
raw_results: dict[str, list[dict[str, Any]]]
|
|
133
|
+
engine_outcomes: list[dict[str, Any]]
|
|
134
|
+
merged: list[dict[str, Any]]
|
|
135
|
+
minhash_removed: int = 0
|
|
136
|
+
image_dup_removed: int = 0
|
|
137
|
+
image_dropped: dict[str, int] = field(default_factory=dict)
|
|
138
|
+
excluded_count: int = 0
|
|
139
|
+
time_filtered: int = 0
|
|
140
|
+
time_filter_warning: str | None = None
|
|
141
|
+
recovery_info: dict[str, Any] | None = None
|
|
142
|
+
rank_method: str = "none"
|
|
143
|
+
reranker_status: str = "skipped_short"
|
|
144
|
+
local_rerank_on: bool = False
|
|
145
|
+
fact_alignment: dict[str, Any] | None = None
|
|
146
|
+
number_conflict: dict[str, Any] | None = None
|
|
147
|
+
noise_dropped: list[dict[str, Any]] = field(default_factory=list)
|
|
148
|
+
funnel: dict[str, Any] | None = None
|
|
149
|
+
result_payload: dict[str, Any] | None = None
|
|
150
|
+
quota_batch: Any = None
|
|
151
|
+
tk_fusion: float = 0.0
|
|
152
|
+
run_one: Any = None
|
|
153
|
+
ingest: Any = None
|
|
154
|
+
engine_latency: dict[str, float] = field(default_factory=dict)
|
|
155
|
+
wasted_ms: int = 0
|
|
156
|
+
useful_ms: int = 0
|
|
157
|
+
dispatch_tail: dict[str, int] = field(default_factory=dict)
|
|
158
|
+
early_stopped: bool = False
|
|
159
|
+
budget_used_ms: int | None = None
|
|
160
|
+
budget_total_ms: int | None = None
|
|
161
|
+
elapsed: int = 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# 兜底补搜的候选顺序。anysearch 在前是因为它对日/韩这类通用查询的召回
|
|
165
|
+
# 实测最好;local_bing 次之(带 setlang 的本地 SERP);再往后是各语言的
|
|
166
|
+
# 通用源,都不行时至少还有 wikipedia 这类必定有结果的百科。
|
|
167
|
+
#
|
|
168
|
+
# 2026-09-27:删去 duckduckgo——engine_policy 已记录「2026-09-07:duckduckgo
|
|
169
|
+
# 移出」(本机出口网络层阻断,无客户端解法)。留着它不会真调到死源
|
|
170
|
+
# (_fallback_candidates 用 available 过滤),但 ja/ko 零结果兜底会白白
|
|
171
|
+
# 少一个候选位,而 tests/test_noise_gate_fallback.py 的 available 集合手写
|
|
172
|
+
# 包含它,死引用没有任何测试能发现。
|
|
173
|
+
_FALLBACK_ENGINES: tuple[str, ...] = (
|
|
174
|
+
"anysearch", "local_bing", "wikipedia", "octen",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _fallback_candidates(already: set[str], available: set[str],
|
|
179
|
+
breaker: Any = None) -> list[str]:
|
|
180
|
+
"""噪声门清空结果后的兜底引擎:跳过已试过的、不可用的、被熔断的。
|
|
181
|
+
|
|
182
|
+
与 D6(macro_data 域)的候选筛选同构,抽成函数是为了让「兜底只补搜、
|
|
183
|
+
不复活被丢弃结果」这条契约有地方可测——两处各写一份筛选逻辑时,
|
|
184
|
+
其中一处漏掉 breaker 判断就会变成绕过熔断。
|
|
185
|
+
"""
|
|
186
|
+
out: list[str] = []
|
|
187
|
+
for name in _FALLBACK_ENGINES:
|
|
188
|
+
if name in already or name not in available:
|
|
189
|
+
continue
|
|
190
|
+
if breaker is not None:
|
|
191
|
+
try:
|
|
192
|
+
allowed, _ = breaker.allow(name)
|
|
193
|
+
except Exception:
|
|
194
|
+
allowed = True # 熔断器自身故障不该阻断兜底
|
|
195
|
+
if not allowed:
|
|
196
|
+
continue
|
|
197
|
+
out.append(name)
|
|
198
|
+
if len(out) >= 2: # 上界:一次搜索最多补两个,避免拖成长任务
|
|
199
|
+
break
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def postprocess(req: _SearchRequest, run: _SearchRun, hooks: Any) -> _SearchRun:
|
|
204
|
+
"""融合后加工:13 个阶段,顺序即契约(过滤 → 去重 → 软排 → 精排 → 标注)。
|
|
205
|
+
|
|
206
|
+
hooks 只用于两处需要回到调度层的动作(D6 macro 补搜、空结果恢复树)——
|
|
207
|
+
入口按值传入,见 search_entry 的 _SearchHooks 说明。
|
|
208
|
+
"""
|
|
209
|
+
engine_search = hooks.engine_search
|
|
210
|
+
available_engines = hooks.available_engines
|
|
211
|
+
query = req.query
|
|
212
|
+
decision = req.decision
|
|
213
|
+
engines = req.engines
|
|
214
|
+
engines_combo = req.engines_combo
|
|
215
|
+
domain = req.domain
|
|
216
|
+
mode = req.mode
|
|
217
|
+
depth = req.depth
|
|
218
|
+
timeout = req.timeout
|
|
219
|
+
max_results = req.max_results
|
|
220
|
+
exclude_terms = req.exclude_terms
|
|
221
|
+
qu = req.qu
|
|
222
|
+
since_iso = req.since_iso
|
|
223
|
+
until_iso = req.until_iso
|
|
224
|
+
since_ts = req.since_ts
|
|
225
|
+
until_ts = req.until_ts
|
|
226
|
+
time_aware = req.time_aware
|
|
227
|
+
skip_cache = req.skip_cache
|
|
228
|
+
timing = req.timing
|
|
229
|
+
on_progress = req.on_progress
|
|
230
|
+
_tk_fusion = run.tk_fusion
|
|
231
|
+
_run_one = run.run_one
|
|
232
|
+
_ingest = run.ingest
|
|
233
|
+
_emit_usage = req.emit_usage_log
|
|
234
|
+
quota_batch = run.quota_batch
|
|
235
|
+
breaker = req.breaker
|
|
236
|
+
raw_results = run.raw_results
|
|
237
|
+
engine_outcomes = run.engine_outcomes
|
|
238
|
+
merged = run.merged
|
|
239
|
+
|
|
240
|
+
# 融合输入派生(原先在 execute_search 里,属于加工的第一步)
|
|
241
|
+
# 去 error 与 SERP 守卫同源:缓存命中的结果也要过同一道门(issue #18)。
|
|
242
|
+
clean_lists = clean_engine_lists(raw_results, query)
|
|
243
|
+
|
|
244
|
+
# 查询主语言:噪声门与语言能力加权共用同一个判定(唯一来源,
|
|
245
|
+
# 避免两处各自算导致行为漂移)。
|
|
246
|
+
_q_lang_for_fusion = (
|
|
247
|
+
((decision or {}).get("features") or {}).get("primary_lang") or None
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── 多语言噪声门(result_lang)─────────────────────────────────────
|
|
253
|
+
# 实测问题:引擎在非支持语言下会返回「成功但不相关」的结果——
|
|
254
|
+
# juejin 在阿拉伯语查询下返回 10 条、相关度 0.00(全是通用热帖),
|
|
255
|
+
# 却报告 status=ok。这类噪声混进 RRF 融合会污染最终结果。
|
|
256
|
+
#
|
|
257
|
+
# 判定不依赖「猜测查询语言」:用结果自身的书写系统(Unicode 码位,
|
|
258
|
+
# 确定性)+ 查询词元命中率。语言不符且相关度低 → 判为噪声并剔除。
|
|
259
|
+
#
|
|
260
|
+
# 只在「查询语言可判定且非中英」时启用:中英是 argo 的主战场,
|
|
261
|
+
# 现有引擎面足够宽,过早过滤会误伤(如英文查询命中中文优质内容)。
|
|
262
|
+
_noise_dropped: list[dict[str, Any]] = []
|
|
263
|
+
try:
|
|
264
|
+
from result_lang import assess_results as _assess_lang
|
|
265
|
+
# 「非中英才启用噪声门」的白名单从 lang_pref 单一真源派生
|
|
266
|
+
# (BASELINE_LANGS + WEAK_QUERY_LANGS):此前这里是第四处硬编码
|
|
267
|
+
# ("zh", "en", "mixed", "other", "")——新增受追踪语言要改三处的
|
|
268
|
+
# bug 类别,门禁只锁住了两处,这条一直在暗处。
|
|
269
|
+
from lang_pref import BASELINE_LANGS, WEAK_QUERY_LANGS
|
|
270
|
+
_baseline_or_weak = frozenset(BASELINE_LANGS) | WEAK_QUERY_LANGS
|
|
271
|
+
_q_lang = _q_lang_for_fusion
|
|
272
|
+
if _q_lang and _q_lang not in _baseline_or_weak:
|
|
273
|
+
_kept = []
|
|
274
|
+
for _lst in clean_lists:
|
|
275
|
+
if not _lst:
|
|
276
|
+
continue
|
|
277
|
+
_eng = _lst[0].get("_engine") or _lst[0].get("source") or "?"
|
|
278
|
+
_a = _assess_lang(query, _lst, expected_lang=_q_lang)
|
|
279
|
+
if _a["verdict"] == "noise":
|
|
280
|
+
_noise_dropped.append({
|
|
281
|
+
"engine": _eng, "lang": _a["lang"],
|
|
282
|
+
"relevance": _a["relevance"],
|
|
283
|
+
"reason": (_a["reasons"][0] if _a.get("reasons")
|
|
284
|
+
else "low relevance"),
|
|
285
|
+
})
|
|
286
|
+
continue
|
|
287
|
+
_kept.append(_lst)
|
|
288
|
+
clean_lists = _kept
|
|
289
|
+
except Exception as _e: # 噪声门是质量增强,失败不得拖垮主流程
|
|
290
|
+
import logging as _lg
|
|
291
|
+
_lg.getLogger("unified_search.search").debug(f"噪声门跳过: {_e}")
|
|
292
|
+
|
|
293
|
+
# ── 噪声门兜底:非中英查询下门清空了全部结果时补搜 ────────────────
|
|
294
|
+
# 实测 bug:`argo search "プロンプト エンジニアリング"` 返回 count=0
|
|
295
|
+
# 而 errors=[]。链路是——路由派给 qiita(TF-IDF 0.683)→ qiita 的
|
|
296
|
+
# `?query=` 对日语召回极差、静默降级 → 噪声门**正确**判定 noise 并丢弃
|
|
297
|
+
# → combo 里的 anysearch 因早停被 cancelled → merged 空、errors 空。
|
|
298
|
+
#
|
|
299
|
+
# 噪声门没错(qiita 确实在降级),错的是清空之后没有兜底:同文件 D6
|
|
300
|
+
# 分支早就为 macro_data 域做了「结构化源覆盖不足就追加通用引擎」,噪声
|
|
301
|
+
# 门这条路却没有对应机制,于是一次静默降级被放大成零结果且无任何线索。
|
|
302
|
+
#
|
|
303
|
+
# 只补搜、不复活:被噪声门丢掉的列表绝不回填(否则噪声门形同虚设),
|
|
304
|
+
# 兜底的结果来自**另外的引擎**,仍要过 RRF 与后续的精排/去重。
|
|
305
|
+
if not clean_lists and _noise_dropped:
|
|
306
|
+
try:
|
|
307
|
+
_fb = _fallback_candidates(
|
|
308
|
+
already=set(raw_results.keys()),
|
|
309
|
+
available=set(available_engines()),
|
|
310
|
+
breaker=breaker,
|
|
311
|
+
)
|
|
312
|
+
for _eng in _fb:
|
|
313
|
+
_e2, _res2, _out2, _lat2 = _run_one(_eng)
|
|
314
|
+
_ingest(_e2, _res2, _out2, _lat2)
|
|
315
|
+
_goods2 = [r for r in _res2
|
|
316
|
+
if isinstance(r, dict) and "error" not in r]
|
|
317
|
+
if _goods2:
|
|
318
|
+
clean_lists.append(_goods2)
|
|
319
|
+
except Exception as _e2: # 兜底是最后一道保底,失败不得反噬主流程
|
|
320
|
+
import logging as _lg2
|
|
321
|
+
_lg2.getLogger("unified_search.search").debug(f"噪声门兜底跳过: {_e2}")
|
|
322
|
+
|
|
323
|
+
# ── multi-query 变体召回波(Step 1,本体在 variant_recall.py)───────────
|
|
324
|
+
# 主 query 召回不足时用变体补召回,rrf_merge 按多 query 共识加权;隔离 + 失败安全。
|
|
325
|
+
clean_lists, _variant_recalled = augment_with_variants(req, engine_search, clean_lists, max_results)
|
|
326
|
+
|
|
327
|
+
if len(clean_lists) > 1:
|
|
328
|
+
merged = rrf_merge(clean_lists, lang=_q_lang_for_fusion)
|
|
329
|
+
elif clean_lists:
|
|
330
|
+
merged = deduplicate_by_url(clean_lists[0])
|
|
331
|
+
# 单引擎也补 consensus
|
|
332
|
+
for r in merged:
|
|
333
|
+
eng = r.get("_engine") or r.get("source") or ""
|
|
334
|
+
if eng:
|
|
335
|
+
r.setdefault("consensus_engines", [eng])
|
|
336
|
+
else:
|
|
337
|
+
merged = []
|
|
338
|
+
|
|
339
|
+
merged, _phrase_dropped = apply_phrase_filter(merged, extract_phrases(query)) # Step 3 短语精确过滤(仅查询含引号短语时触发)
|
|
340
|
+
# ── D6:macro_data 域证据下限(事实核查防单源)─────────────────────
|
|
341
|
+
# deep 研究场景下结果 <2 条说明结构化源未覆盖该查询:追加通用保底引擎
|
|
342
|
+
# 补证据,避免「单引擎单结果」被事实核查 / 融合阶段当作答案;补搜结果
|
|
343
|
+
# 一并进 RRF,consensus 维度天然加权。
|
|
344
|
+
#
|
|
345
|
+
# depth 只判 "deep":--depth 的 choices 是 fast|balanced|deep,"research"
|
|
346
|
+
# 永远不会出现(2026-09-27 清掉的半死条件)。候选表里的 duckduckgo
|
|
347
|
+
# 同样清掉——engine_policy 已记录它 2026-09-07 移出,留着只会让
|
|
348
|
+
# 「这个引擎还在不在」没有单一答案。
|
|
349
|
+
if (domain == "macro_data" and merged and len(merged) < 2
|
|
350
|
+
and depth == "deep"):
|
|
351
|
+
_done = set(raw_results.keys())
|
|
352
|
+
_cands = [
|
|
353
|
+
e for e in ("anysearch", "local_bing")
|
|
354
|
+
if e not in _done
|
|
355
|
+
and e in set(available_engines())
|
|
356
|
+
and (breaker is None or breaker.allow(e)[0])
|
|
357
|
+
]
|
|
358
|
+
_extra_lists = []
|
|
359
|
+
for _eng in _cands[:2]:
|
|
360
|
+
_e, _res, _out, _lat = _run_one(_eng)
|
|
361
|
+
_ingest(_e, _res, _out, _lat)
|
|
362
|
+
_goods = [r for r in _res if isinstance(r, dict) and "error" not in r]
|
|
363
|
+
if _goods:
|
|
364
|
+
_extra_lists.append(_goods)
|
|
365
|
+
if _extra_lists:
|
|
366
|
+
merged = rrf_merge([merged] + _extra_lists)
|
|
367
|
+
|
|
368
|
+
# 配额记账:整批一次写入文件(同一次搜索的 N 个引擎合并为一次写)。
|
|
369
|
+
# 必须放在 D6 补搜之后:那是最后一个 _ingest 调用点,flush 提前会让
|
|
370
|
+
# 补搜引擎的记账永远落不了盘(2026-09-13 审查实锤)。
|
|
371
|
+
_tock(timing, "fusion", _tk_fusion)
|
|
372
|
+
_tk_dedupe = _tick(timing)
|
|
373
|
+
# ── P0:过滤 SERP/跳转 URL(搜索结果页、baidu.com/link 等不可当信源正文)──
|
|
374
|
+
# 漏斗第 4 格必须**在 SERP 过滤之前**取值。此前取值点在下面 minhash 之后
|
|
375
|
+
# (:366),于是 SERP 丢弃的条目被算进「跨引擎去重」那格的损耗里:
|
|
376
|
+
# 实测 `local_bing` 返回 10 条全是裸 bing.com/search、全部被 SERP 过滤掉,
|
|
377
|
+
# 漏斗报的是 `returned 11 → deduped 0`,把人指向「去重削没了」——而
|
|
378
|
+
# 去重根本无辜(ARGO_MINHASH_DEDUPE=0 时照样归零)。funnel_collapse 会
|
|
379
|
+
# 回答「结果在 deduped 归零 = 被当重复削掉」,那个归因在这里是错的,
|
|
380
|
+
# 而它恰是 0 结果的首要诊断入口。
|
|
381
|
+
_funnel_deduped = len(merged)
|
|
382
|
+
if merged:
|
|
383
|
+
try:
|
|
384
|
+
from serp_guard import is_unusable_url as _is_unusable # SERP + 内容农场,标定见 test_content_farm_guard.py
|
|
385
|
+
merged = [r for r in merged if not _is_unusable(r.get("url", ""), r.get("title", ""), r.get("snippet", ""))]
|
|
386
|
+
except ImportError:
|
|
387
|
+
pass # serp_guard 不可用时跳过(本地五维 rerank 已对 SERP 降权)
|
|
388
|
+
|
|
389
|
+
# ── minhash 近重复去重(结果级,RRF 后 / SERP 后)─────────────────────
|
|
390
|
+
# max_keep 与下方放宽截断同源:去重只需要保证「前 N 条非重复」正确,
|
|
391
|
+
# 因为多出来的条目下一行就被截掉了。不设上限时是 O(结果数²) 的两两比较。
|
|
392
|
+
_pool_limit = _rerank_pool_limit(max_results)
|
|
393
|
+
minhash_removed = 0
|
|
394
|
+
if merged and len(merged) > 1:
|
|
395
|
+
try:
|
|
396
|
+
deduped, minhash_removed = minhash_dedupe(merged, max_keep=_pool_limit)
|
|
397
|
+
merged = deduped
|
|
398
|
+
except Exception as _e:
|
|
399
|
+
_log(f"minhash 去重跳过: {type(_e).__name__}")
|
|
400
|
+
# ── 图片级去重(文本去重之后,图片粒度)─────────────────────────────
|
|
401
|
+
# 上面两道去重都只看页面(URL/标题/正文),对图本身无感知:同一素材在
|
|
402
|
+
# 不同 CDN 上的 URL 不同、标题也可能不同,两道都判不出来——实测
|
|
403
|
+
# 「雪景 壁纸」的结果里存在同一张图占两位的情况。这里按图片直链归一键
|
|
404
|
+
# 补一刀。放在文本去重之后:文本重复量大且比较便宜,先削一批再比图片。
|
|
405
|
+
image_dup_removed = 0
|
|
406
|
+
if merged and len(merged) > 1:
|
|
407
|
+
try:
|
|
408
|
+
from image_ops import deduplicate_images as _dedupe_images
|
|
409
|
+
merged, image_dup_removed = _dedupe_images(merged)
|
|
410
|
+
except Exception as _e:
|
|
411
|
+
_log(f"图片去重跳过: {type(_e).__name__}")
|
|
412
|
+
_tock(timing, "dedupe", _tk_dedupe)
|
|
413
|
+
# 三个子段各自计时,**不能共用一个 tick**:
|
|
414
|
+
# `filter`(否定词 + 时间窗过滤)是纯本地遍历;`recovery` 是**网络调用**
|
|
415
|
+
# (域路由零结果时的救援链,见下);`rerank` 是纯本地重排。此前三者共用
|
|
416
|
+
# 一个 tick 且整个挂在 "rerank" 名下,实测「python 怎么读csv」的 rerank
|
|
417
|
+
# 阶段报 706ms(占全查询 40%),而真正的重排只用 3.6ms——那 700ms 是恢复
|
|
418
|
+
# 链打的一次 anysearch。`--explain-timing` 的全部价值就是回答「瓶颈在哪」,
|
|
419
|
+
# 报错桶等于把人指向错误的优化对象。
|
|
420
|
+
_tk_filter = _tick(timing)
|
|
421
|
+
|
|
422
|
+
# ── P2:多语言语言偏好软排序(属目标语言书写系统的结果前置,软排不删除)──
|
|
423
|
+
# 判据在 search_rank._LANG_SCRIPT 这张表里;此处只问「该语言被追踪吗」。
|
|
424
|
+
# 原先这里是 `_p_lang in ("ja", "ko")`,与 rerank 入口、噪声门构成三处
|
|
425
|
+
# 各自独立的硬编码闸门,于是 zh 被三处同时排除(详见 search_rank 注释)。
|
|
426
|
+
try:
|
|
427
|
+
_p_lang = ((decision or {}).get("features") or {}).get("primary_lang")
|
|
428
|
+
if _p_lang in _LANG_SCRIPT:
|
|
429
|
+
merged = _lang_prefer_rerank(merged, _p_lang)
|
|
430
|
+
except OPT_IMPORT + SHAPE_BENIGN: # 软排序增强,任何失败按「不排序」处理
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
# 放宽截断:rerank 阶段看到 _pool_limit 条,最终输出再截断 max_results
|
|
434
|
+
merged = merged[:_pool_limit]
|
|
435
|
+
|
|
436
|
+
# ── P0-001:按 exclude_terms 过滤(否定约束)──
|
|
437
|
+
excluded_count = 0
|
|
438
|
+
if merged and exclude_terms:
|
|
439
|
+
kept = []
|
|
440
|
+
low_terms = [t.lower() for t in exclude_terms if t]
|
|
441
|
+
for r in merged:
|
|
442
|
+
hay = f"{r.get('title', '')} {r.get('snippet', '')} {r.get('url', '')}".lower()
|
|
443
|
+
if any(t in hay for t in low_terms):
|
|
444
|
+
excluded_count += 1
|
|
445
|
+
continue
|
|
446
|
+
kept.append(r)
|
|
447
|
+
merged = kept
|
|
448
|
+
|
|
449
|
+
# ── 时间窗结果后过滤保底 ──
|
|
450
|
+
# 仅当组合内含带时间能力引擎时执行:无时间字段引擎的结果没有可滤对象,
|
|
451
|
+
# 跳过遍历省开销;语义上与缓存键隔离保持一致(7d/30d 共享同一缓存内容)。
|
|
452
|
+
time_filtered = 0
|
|
453
|
+
if time_aware and (since_ts is not None or until_ts is not None) and merged:
|
|
454
|
+
merged, time_filtered = _apply_time_window(merged, since_ts, until_ts)
|
|
455
|
+
if time_filtered:
|
|
456
|
+
_log(
|
|
457
|
+
f"时间窗后过滤剔除 {time_filtered} 条(since={since_iso}, until={until_iso})")
|
|
458
|
+
# 漏斗第 5 格:否定词过滤 + 时间窗过滤之后(见 build_funnel)
|
|
459
|
+
_funnel_filtered = len(merged)
|
|
460
|
+
_tock(timing, "filter", _tk_filter)
|
|
461
|
+
|
|
462
|
+
# ── 图片可用性过滤(仅图片检索域)─────────────────────────────────
|
|
463
|
+
# 只在 image_search 这种「用户要的就是图」的查询上启用:普通网页搜索的
|
|
464
|
+
# 结果里也可能带 image_url(正文配图),那里图小不构成丢弃整篇文章的理由。
|
|
465
|
+
# 判据与阈值见 image_ops(分辨率下限、极端长宽比、明确非商用)。
|
|
466
|
+
# 默认只剔「字段层面即可确定不可用」的(无图/过小/极端比例);非商用图
|
|
467
|
+
# 不剔——「搜图看看」和「找能发布的素材」是两个需求,后者由调用方显式
|
|
468
|
+
# 开启。剔除明细进 image_dropped,供诊断「为什么少了 N 条」。
|
|
469
|
+
image_dropped: dict[str, int] = {}
|
|
470
|
+
if merged and domain == "image_search":
|
|
471
|
+
try:
|
|
472
|
+
from image_ops import filter_usable_images as _filter_imgs
|
|
473
|
+
merged, image_dropped = _filter_imgs(merged)
|
|
474
|
+
except Exception as _e:
|
|
475
|
+
_log(f"图片可用性过滤跳过: {type(_e).__name__}")
|
|
476
|
+
|
|
477
|
+
# D5:时间窗空操作告警——用户指定了时间窗,组合内含时间能力引擎,
|
|
478
|
+
# 但结果没有任何 published_at(下推缺失/源端未返回):
|
|
479
|
+
# `--since/--until` 实际未生效(宽松策略保留无时间字段条目,
|
|
480
|
+
# time_filtered 恒 0)。透传 warning 而非静默降级。
|
|
481
|
+
# 组合内不含时间能力引擎时是已知常态,不重复告警。
|
|
482
|
+
time_filter_warning: str | None = None
|
|
483
|
+
if time_aware and (since_ts is not None or until_ts is not None) and merged:
|
|
484
|
+
if not any(r.get("published_at") for r in merged):
|
|
485
|
+
time_filter_warning = (
|
|
486
|
+
f"引擎未返回 published_at,时间窗 {since_iso or '任意'} ~ "
|
|
487
|
+
f"{until_iso or '任意'} 未实际过滤"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# ── P0-002:空结果错误恢复决策树 ──
|
|
491
|
+
recovery_info: dict[str, Any] | None = None
|
|
492
|
+
# 复杂度门控:低复杂度查询只允许低成本放宽(L1/L2),
|
|
493
|
+
# 禁用高价多源/跨语言(L3/L4)——简单问题不搞多轮,省 token。
|
|
494
|
+
_max_rec_level: str | None = None
|
|
495
|
+
try:
|
|
496
|
+
from query_enhance import complexity_gate
|
|
497
|
+
if qu is not None and complexity_gate(query, qu) == "low":
|
|
498
|
+
_max_rec_level = "L2"
|
|
499
|
+
except OPT_IMPORT + SHAPE_BENIGN: # 门控判定失败按「不限制放宽档位」处理
|
|
500
|
+
pass
|
|
501
|
+
_tk_recovery = _tick(timing)
|
|
502
|
+
_recovery_engines: set[str] = set()
|
|
503
|
+
# 查询词回声(2026-10-17 实锤):有结果但整批退化时同样要恢复——
|
|
504
|
+
# `if not merged` 只看「有没有结果」,不看「是不是垃圾」。判据与理由见
|
|
505
|
+
# query_signals.is_query_echo(rerank 尚未执行,只能用零网络成本的词面)。
|
|
506
|
+
try:
|
|
507
|
+
from postprocess_signals import echo_recovery_needed as _echo_needed
|
|
508
|
+
_echo_triggered = _echo_needed(merged, query)
|
|
509
|
+
except OPT_IMPORT + SHAPE_BENIGN: # 守卫不可用不设卡(fail-open)
|
|
510
|
+
_echo_triggered = False
|
|
511
|
+
if not merged or _echo_triggered:
|
|
512
|
+
from recovery_runner import run_pipeline_recovery
|
|
513
|
+
_dk = {k: v for k in ("domain", "sub_domain") if (v := getattr(req, "engine_" + k, None))}
|
|
514
|
+
merged, _rec_info = run_pipeline_recovery(
|
|
515
|
+
query=query,
|
|
516
|
+
merged=merged,
|
|
517
|
+
raw_results=raw_results,
|
|
518
|
+
decision=decision,
|
|
519
|
+
engines=engines,
|
|
520
|
+
available_engines_fn=available_engines,
|
|
521
|
+
max_results=max_results,
|
|
522
|
+
max_rec_level=_max_rec_level,
|
|
523
|
+
echo_triggered=_echo_triggered,
|
|
524
|
+
primary_grace_s=float(hooks.primary_grace_s or 0.0),
|
|
525
|
+
timeout=timeout,
|
|
526
|
+
depth=depth,
|
|
527
|
+
mode=mode,
|
|
528
|
+
since_iso=since_iso,
|
|
529
|
+
until_iso=until_iso,
|
|
530
|
+
skip_cache=skip_cache,
|
|
531
|
+
quota_batch=quota_batch,
|
|
532
|
+
engine_search_fn=engine_search,
|
|
533
|
+
emit_usage_fn=_emit_usage,
|
|
534
|
+
recovery_engines_set=_recovery_engines,
|
|
535
|
+
engine_domain_extra=_dk,
|
|
536
|
+
)
|
|
537
|
+
if _rec_info:
|
|
538
|
+
recovery_info = _rec_info
|
|
539
|
+
|
|
540
|
+
# 配额记账落盘:整批一次写入文件(同一次搜索的 N 个引擎合并为一次写)。
|
|
541
|
+
# 位置是契约:必须在**最后一个 quota add 点**之后。D6 补搜走后,
|
|
542
|
+
# 恢复链(_recovery_executor 里同样 quota_batch.add)是最后一个——
|
|
543
|
+
# 它走的不是 dispatch 的 _run_one(全仓唯一的记账入口),不单独补一行
|
|
544
|
+
# add,恢复段消耗的远端额度就不进本地账本:get_remaining_ratio 高估
|
|
545
|
+
# 剩余 → 路由继续派发 → 上游 429/10406,而配额状态机看不到任何迹象
|
|
546
|
+
# (2026-09-27 审查实锤,与「配额自愈闭环」的设计目标直接矛盾)。
|
|
547
|
+
quota_batch.flush()
|
|
548
|
+
|
|
549
|
+
# Reranker:ARGO_LOCAL_RERANK 开关(0 关闭本地五维保底;默认 1 开启)
|
|
550
|
+
_tock(timing, "recovery", _tk_recovery)
|
|
551
|
+
_tk_rerank = _tick(timing)
|
|
552
|
+
local_rerank_on = env_flag("ARGO_LOCAL_RERANK")
|
|
553
|
+
reranker_status = "skipped_short"
|
|
554
|
+
rank_method = "none"
|
|
555
|
+
if mode == "fast" or depth == "fast":
|
|
556
|
+
reranker_status = "skipped_fast"
|
|
557
|
+
elif merged and len(merged) > 1:
|
|
558
|
+
# 全量重排(top_n=len),由最终输出统一截断 max_results
|
|
559
|
+
merged, reranker_status = rerank_results(query, merged, top_n=len(merged))
|
|
560
|
+
if reranker_status == "ok":
|
|
561
|
+
rank_method = "bocha"
|
|
562
|
+
|
|
563
|
+
# 本地五维 rerank 保底:受 ARGO_LOCAL_RERANK 开关控制(可观测 rank_method)。
|
|
564
|
+
# 触发计算方式走 _RERANK_DEGRADED_STATUSES 唯一来源:新增「bocha 未出排序」的
|
|
565
|
+
# 状态时只改那一处,不会漏掉这里的保底(漏了就是既不精排也不保底)。
|
|
566
|
+
if local_rerank_on and merged and len(merged) > 1 and \
|
|
567
|
+
reranker_status in _RERANK_DEGRADED_STATUSES:
|
|
568
|
+
try:
|
|
569
|
+
# top_n=max_results(而非 len(merged)):下游 _apply_consensus_and_sort
|
|
570
|
+
# 紧接着就按 max_results 截断,尾部不会有人用。传真实 K 才能启用
|
|
571
|
+
# 函数内的 K 相关剪枝(可证明前 K 个选序不变),n=200 时把 O(n²)
|
|
572
|
+
# 贪心从 ~33ms 压到毫秒级。
|
|
573
|
+
merged = local_five_dim_rerank(query, merged, domain=domain,
|
|
574
|
+
top_n=max_results)
|
|
575
|
+
rank_method = "local_five_dim"
|
|
576
|
+
except Exception as e:
|
|
577
|
+
_log(
|
|
578
|
+
f"本地五维 rerank 跳过: {type(e).__name__}")
|
|
579
|
+
# TF-IDF 语义抛光 + none 兜底(方案 A;实现与开关在 postprocess_signals)。
|
|
580
|
+
# 2026-10-16 提取:特性钩子各带 try/except 与开关,留在主管线会把它顶破
|
|
581
|
+
# 模块体积门禁(1070 > 上限 1033),且放大主管线的异常面。ran=False
|
|
582
|
+
# 覆盖「开关关 / fast 档 / 结果不足 / 执行失败」——无论哪种本轮排序都
|
|
583
|
+
# 没有 TF-IDF 贡献,none 兜底都应生效(含 rerank_tfidf 抛异常的边缘态,
|
|
584
|
+
# 此前该态会被误留 stale rank_method)。
|
|
585
|
+
merged, rank_method, _tfidf_ran = apply_tfidf_polish(
|
|
586
|
+
query, merged, max_results, mode, reranker_status, rank_method, _log)
|
|
587
|
+
if not local_rerank_on and reranker_status in _RERANK_DEGRADED_STATUSES \
|
|
588
|
+
and not _tfidf_ran:
|
|
589
|
+
rank_method = "none"
|
|
590
|
+
|
|
591
|
+
_tock(timing, "rerank", _tk_rerank)
|
|
592
|
+
_tk_signals = _tick(timing)
|
|
593
|
+
|
|
594
|
+
# ── site:/-site: 后置过滤(query_operators 契约)──
|
|
595
|
+
# 放在恢复链与精排之后、截断之前:既不会把「站点化结果为空」误当
|
|
596
|
+
# 0 结果触发一轮白跑的恢复链,也保证截断后仍有 max_results 条。
|
|
597
|
+
site_filtered = 0
|
|
598
|
+
_site_ops = (decision or {}).get("operators") or {}
|
|
599
|
+
_site_include = list(_site_ops.get("sites") or [])
|
|
600
|
+
_site_exclude = list(_site_ops.get("exclude_sites") or [])
|
|
601
|
+
if merged and (_site_include or _site_exclude):
|
|
602
|
+
try:
|
|
603
|
+
from query_operators import filter_results_by_sites
|
|
604
|
+
merged, site_filtered = filter_results_by_sites(
|
|
605
|
+
merged, _site_include, _site_exclude)
|
|
606
|
+
except OPT_IMPORT + SHAPE_BENIGN:
|
|
607
|
+
# 过滤器不可用不拦:保持原始结果,比静默清空安全。
|
|
608
|
+
site_filtered = 0
|
|
609
|
+
|
|
610
|
+
if merged:
|
|
611
|
+
merged = _apply_consensus_and_sort(merged, max_results)
|
|
612
|
+
|
|
613
|
+
_attach_selection_signals(merged, mode, depth)
|
|
614
|
+
|
|
615
|
+
# ── P0-004:关键事实交叉标记(仅 deep/auto 且结果 ≥3;fast 跳过)──
|
|
616
|
+
fact_alignment: dict[str, Any] | None = _align_facts_safe(merged, mode, depth)
|
|
617
|
+
|
|
618
|
+
# 数字交叉验证信号(方案 A;实现与开关在 postprocess_signals)。
|
|
619
|
+
# 只 deep/auto 且结果 ≥3 时启用(函数内部门控),前 10 条做 O(n) 正则
|
|
620
|
+
# 对比(<1ms);有分歧时 values[i] 与 sources[i] 同序配对,进 payload
|
|
621
|
+
# 供调用方决定是否 fetch 原文核对。
|
|
622
|
+
number_conflict = detect_number_conflict(query, merged, mode, depth, _log)
|
|
623
|
+
|
|
624
|
+
_tock(timing, "signals", _tk_signals)
|
|
625
|
+
_tk_cache_write = _tick(timing)
|
|
626
|
+
|
|
627
|
+
if on_progress:
|
|
628
|
+
on_progress(Stage.MERGING, {"count": len(merged)})
|
|
629
|
+
|
|
630
|
+
# 阶段漏斗账(见 build_funnel)。在写缓存之前算一次、两处消费:
|
|
631
|
+
# 既进 result_payload(缓存命中时它随存档一起返回,否则命中路径的漏斗会
|
|
632
|
+
# 缺席——而「缺席」比数字更容易被误读成「没数据」),也进本次输出。
|
|
633
|
+
# 此处 len(merged) 已等于最终条数:_apply_consensus_and_sort 在上一步已按
|
|
634
|
+
# max_results 截断,而 _sort_results 只重排、不改长度。
|
|
635
|
+
funnel = build_funnel(
|
|
636
|
+
len(engines),
|
|
637
|
+
# 「实际发起调用」= 编排层跑过的引擎 ∪ 恢复链补调的引擎。
|
|
638
|
+
# 此前只数 engine_outcomes/raw_results,恢复段走的不是 dispatch、
|
|
639
|
+
# 两条都不进 → 实测「python 怎么读csv」报出 routed 2 → called 2 →
|
|
640
|
+
# returned 5:两个引擎交出了 5 条结果,而真凶(恢复链打的 anysearch)
|
|
641
|
+
# 在漏斗里根本不存在。漏斗的全部用途就是定位塌陷点,一个会漏掉真凶的
|
|
642
|
+
# 漏斗比没有更危险。
|
|
643
|
+
len({o.get("engine") for o in engine_outcomes if o.get("engine")}
|
|
644
|
+
| set(raw_results) | _recovery_engines),
|
|
645
|
+
sum(1 for items in raw_results.values() if isinstance(items, list)
|
|
646
|
+
for it in items
|
|
647
|
+
if isinstance(it, dict) and "error" not in it),
|
|
648
|
+
_funnel_deduped, _funnel_filtered, len(merged),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
result_payload = {
|
|
652
|
+
"results": merged,
|
|
653
|
+
"engines_used": list(raw_results.keys()),
|
|
654
|
+
# 产出这批结果的**那次运行**的组合与理由,随载荷一起存。
|
|
655
|
+
#
|
|
656
|
+
# 缓存键改成请求身份后,同一查询的两次运行可以路由到不同组合,而命中
|
|
657
|
+
# 时本次路由根本没执行。不存这两个字段的话,命中响应只能报本次路由,
|
|
658
|
+
# 那是一个没跑过的计划,还会与同样来自缓存的 engines_used /
|
|
659
|
+
# engine_outcomes 自相矛盾(2026-09-19)。约 100 字节/条,换来缓存
|
|
660
|
+
# 条目自描述。旧条目没有这两个键,命中路径回退到本次路由(原行为)。
|
|
661
|
+
"engines": list(engines),
|
|
662
|
+
"engines_combo": list(engines_combo),
|
|
663
|
+
"route_reason": decision.get("reason"),
|
|
664
|
+
"domain": domain,
|
|
665
|
+
"engine_outcomes": engine_outcomes,
|
|
666
|
+
"time_filtered": time_filtered,
|
|
667
|
+
"time_filter_warning": time_filter_warning,
|
|
668
|
+
"noise_dropped": _noise_dropped,
|
|
669
|
+
"funnel": funnel,
|
|
670
|
+
}
|
|
671
|
+
if _site_include or _site_exclude:
|
|
672
|
+
result_payload["site_filter"] = {
|
|
673
|
+
"sites": _site_include, "exclude_sites": _site_exclude,
|
|
674
|
+
}
|
|
675
|
+
result_payload["site_filtered"] = site_filtered
|
|
676
|
+
result_payload.update(variant_recalled=_variant_recalled, phrase_dropped=_phrase_dropped) # 观测补账:均不在漏斗 returned 口径内
|
|
677
|
+
|
|
678
|
+
return replace(
|
|
679
|
+
run,
|
|
680
|
+
merged=merged,
|
|
681
|
+
minhash_removed=minhash_removed,
|
|
682
|
+
image_dup_removed=image_dup_removed,
|
|
683
|
+
image_dropped=image_dropped,
|
|
684
|
+
excluded_count=excluded_count,
|
|
685
|
+
time_filtered=time_filtered,
|
|
686
|
+
time_filter_warning=time_filter_warning,
|
|
687
|
+
recovery_info=recovery_info,
|
|
688
|
+
rank_method=rank_method,
|
|
689
|
+
reranker_status=reranker_status,
|
|
690
|
+
local_rerank_on=local_rerank_on,
|
|
691
|
+
fact_alignment=fact_alignment,
|
|
692
|
+
number_conflict=number_conflict,
|
|
693
|
+
noise_dropped=_noise_dropped,
|
|
694
|
+
funnel=funnel,
|
|
695
|
+
result_payload=result_payload,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
def finalize(req: _SearchRequest, run: _SearchRun, hooks: Any) -> dict[str, Any]:
|
|
699
|
+
"""收尾:写缓存 → 自适应记账 → 语言偏好 → 装配对外响应。
|
|
700
|
+
|
|
701
|
+
与 postprocess 的分工:那里决定「结果是什么」,这里决定「怎么对外说」
|
|
702
|
+
(缓存写入、学习器记账、漏斗/计时/信源的字段形态)。签名同样只吃两个
|
|
703
|
+
状态对象——execute_search 里不再有一串需要手工搬运的局部量。
|
|
704
|
+
|
|
705
|
+
hooks 只用于 `get_cost_factor`:测试靠 patch 它把成本系数钉成常数,
|
|
706
|
+
按值传入才能让补丁生效(见 search_entry._SearchHooks)。
|
|
707
|
+
"""
|
|
708
|
+
get_cost_factor = hooks.get_cost_factor
|
|
709
|
+
query = req.query
|
|
710
|
+
decision = req.decision
|
|
711
|
+
engines = req.engines
|
|
712
|
+
engines_combo = req.engines_combo
|
|
713
|
+
domain = req.domain
|
|
714
|
+
mode, depth = req.mode, req.depth
|
|
715
|
+
max_results = req.max_results
|
|
716
|
+
timing, on_progress = req.timing, req.on_progress
|
|
717
|
+
sort, cache = req.sort, req.cache
|
|
718
|
+
engine_label, cache_engine_key = req.engine_label, req.cache_engine_key
|
|
719
|
+
skip_cache = req.skip_cache
|
|
720
|
+
merged = run.merged
|
|
721
|
+
raw_results = run.raw_results
|
|
722
|
+
engine_outcomes = run.engine_outcomes
|
|
723
|
+
engine_latency = run.engine_latency
|
|
724
|
+
minhash_removed = run.minhash_removed
|
|
725
|
+
image_dup_removed = run.image_dup_removed
|
|
726
|
+
image_dropped = run.image_dropped
|
|
727
|
+
excluded_count = run.excluded_count
|
|
728
|
+
time_filtered = run.time_filtered
|
|
729
|
+
time_filter_warning = run.time_filter_warning
|
|
730
|
+
recovery_info = run.recovery_info
|
|
731
|
+
rank_method = run.rank_method
|
|
732
|
+
reranker_status = run.reranker_status
|
|
733
|
+
local_rerank_on = run.local_rerank_on
|
|
734
|
+
fact_alignment = run.fact_alignment
|
|
735
|
+
number_conflict = run.number_conflict
|
|
736
|
+
_noise_dropped = run.noise_dropped
|
|
737
|
+
funnel = run.funnel
|
|
738
|
+
result_payload = run.result_payload
|
|
739
|
+
wasted_ms, useful_ms = run.wasted_ms, run.useful_ms
|
|
740
|
+
dispatch_tail = run.dispatch_tail
|
|
741
|
+
early_stopped = run.early_stopped
|
|
742
|
+
budget_used_ms, budget_total_ms = run.budget_used_ms, run.budget_total_ms
|
|
743
|
+
elapsed = run.elapsed
|
|
744
|
+
exclude_terms = req.exclude_terms
|
|
745
|
+
_tk_cache_write = _tick(timing)
|
|
746
|
+
# 写 combo 缓存:空结果短 TTL / 时效 cap 由 cache.set 处理。
|
|
747
|
+
#
|
|
748
|
+
# 守卫拒绝(登录态 / 上游退化)只该取消**这次写入**,不该取消这次查询。
|
|
749
|
+
# 2026-09-27 实锤:`Apple Inc 10-K annual report 2025` 命中 sec_edgar
|
|
750
|
+
# 拿到 Apple 的 CIK 归档页(1 条相关 + 4 条全文检索噪音),批内多数
|
|
751
|
+
# relevance=0 触发退化守卫,异常穿透到 CLI 顶层——traceback 退出,已检索
|
|
752
|
+
# 到的结果连同响应一起丢弃。守卫的意图是「别固化」而不是「别返回」。
|
|
753
|
+
cache_write_skip: str | None = None
|
|
754
|
+
if not skip_cache:
|
|
755
|
+
effective_ttl = None
|
|
756
|
+
if merged and elapsed > 2000:
|
|
757
|
+
# 慢查询略延长缓存:省的是「同一查询再付一次慢网」的钱。
|
|
758
|
+
effective_ttl = _slow_query_ttl(cache.resolve_ttl(domain, query=query),
|
|
759
|
+
elapsed)
|
|
760
|
+
cache_write_skip = attempt_cache_write(
|
|
761
|
+
lambda: cache.set(
|
|
762
|
+
query, cache_engine_key, max_results, result_payload,
|
|
763
|
+
domain=domain, ttl=effective_ttl, mode=mode, depth=depth,
|
|
764
|
+
# 恢复成功的批次标记给写侧:整批退化时降级短 TTL 而非硬拒
|
|
765
|
+
# (硬拒会让重复查询重付 dispatch+恢复链全程,cache.set 注释)。
|
|
766
|
+
recovered=bool((recovery_info or {}).get("recovered")),
|
|
767
|
+
),
|
|
768
|
+
context="finalize.cache_set",
|
|
769
|
+
)
|
|
770
|
+
_tock(timing, "cache_write", _tk_cache_write)
|
|
771
|
+
|
|
772
|
+
# 自适应学习(单事务批量写入 + cache_guard 统一状态态过滤)
|
|
773
|
+
try:
|
|
774
|
+
import contextlib
|
|
775
|
+
from adaptive import get_learner
|
|
776
|
+
learner = get_learner()
|
|
777
|
+
_outcome_of = {o.get("engine"): o
|
|
778
|
+
for o in engine_outcomes if isinstance(o, dict)}
|
|
779
|
+
_batch_ctx = learner.batch() if hasattr(learner, "batch") else contextlib.nullcontext()
|
|
780
|
+
with _batch_ctx:
|
|
781
|
+
for eng, res in raw_results.items():
|
|
782
|
+
outcome = _outcome_of.get(eng)
|
|
783
|
+
if not should_record_adaptive(outcome, res):
|
|
784
|
+
continue
|
|
785
|
+
st = (outcome or {}).get("status")
|
|
786
|
+
success = bool(res and any(isinstance(r, dict) and "error" not in r for r in res))
|
|
787
|
+
# 空结果说明查询与源不匹配而非源故障,单独统计不拉低成功率(见 adaptive.get_score)。
|
|
788
|
+
empty = (not success) and st in ("no-results", "no-results-cached")
|
|
789
|
+
latency = engine_latency.get(eng, elapsed / max(len(raw_results), 1))
|
|
790
|
+
cost = get_cost_factor(eng)
|
|
791
|
+
quality = None
|
|
792
|
+
if success:
|
|
793
|
+
try:
|
|
794
|
+
from query_signals import result_relevance
|
|
795
|
+
quality = result_relevance(query, res)
|
|
796
|
+
except Exception:
|
|
797
|
+
quality = None
|
|
798
|
+
learner.record(eng, success=success, latency_ms=latency,
|
|
799
|
+
cost=0.0 if cost >= 0.85 else 0.001, empty=empty,
|
|
800
|
+
quality=quality)
|
|
801
|
+
except ImportError:
|
|
802
|
+
pass
|
|
803
|
+
except Exception as e:
|
|
804
|
+
_log(f"自适应学习记录跳过: {type(e).__name__}")
|
|
805
|
+
|
|
806
|
+
# 语言偏好:记录本轮查询语 + 输出观测快照(默认中英 + 系统 + 习惯)
|
|
807
|
+
lang_pref_info: dict[str, Any] | None = None
|
|
808
|
+
try:
|
|
809
|
+
from lang_pref import record_query_lang, lang_pref_snapshot
|
|
810
|
+
feats = decision.get("features") or {}
|
|
811
|
+
q_lang = feats.get("primary_lang") or ""
|
|
812
|
+
if not q_lang:
|
|
813
|
+
try:
|
|
814
|
+
from lang_detect import detect_language
|
|
815
|
+
q_lang = detect_language(query)
|
|
816
|
+
except ImportError:
|
|
817
|
+
q_lang = ""
|
|
818
|
+
if q_lang:
|
|
819
|
+
record_query_lang(q_lang)
|
|
820
|
+
lang_pref_info = lang_pref_snapshot(query_lang=q_lang)
|
|
821
|
+
except ImportError:
|
|
822
|
+
pass
|
|
823
|
+
except Exception as e:
|
|
824
|
+
_log(
|
|
825
|
+
f"语言偏好记录跳过: {type(e).__name__}")
|
|
826
|
+
|
|
827
|
+
if on_progress:
|
|
828
|
+
on_progress(Stage.DONE, {"count": len(merged), "elapsed_ms": elapsed})
|
|
829
|
+
|
|
830
|
+
# 使用日志:每次非缓存搜索一条总账(stats 命令的数据源)。query 截断 60
|
|
831
|
+
# 字符(与 recovery 流同一脱敏纪律);记录失败静默,绝不拖累主路径。
|
|
832
|
+
# 取自 req 而非局部别名:_emit_usage 是 postprocess 的局部变量,
|
|
833
|
+
# 本函数(finalize)不在其作用域内,直接引用会 NameError。
|
|
834
|
+
if req.emit_usage_log is not None and not is_synthetic_run(raw_results):
|
|
835
|
+
try:
|
|
836
|
+
# 归因走 search_attribution;合成引擎(基准/混沌)整条不入日志。
|
|
837
|
+
req.emit_usage_log("query", {
|
|
838
|
+
"query": (query[:60] if query else query),
|
|
839
|
+
"count": len(merged),
|
|
840
|
+
"elapsed_ms": round(elapsed),
|
|
841
|
+
"engines_used": list(raw_results.keys())[:8],
|
|
842
|
+
**usage_log_attribution(engine_latency, engine_outcomes=engine_outcomes),
|
|
843
|
+
"errors": len(_collect_errors(raw_results, engine_outcomes)),
|
|
844
|
+
"recovered": bool(recovery_info.get("recovered")) if recovery_info else False,
|
|
845
|
+
"early_stopped": bool(early_stopped),
|
|
846
|
+
"depth": depth, "mode": mode, "domain": domain,
|
|
847
|
+
})
|
|
848
|
+
except SHAPE_BENIGN:
|
|
849
|
+
pass
|
|
850
|
+
|
|
851
|
+
tfidf_scores = decision.get("tfidf_scores", [])
|
|
852
|
+
if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
|
|
853
|
+
tfidf_scores = []
|
|
854
|
+
|
|
855
|
+
# 排序在返回前、写缓存后:缓存内容保持 score 序(缓存键/内容不受 sort 影响),
|
|
856
|
+
# sort 只改变本次展示顺序;缓存命中路径在 return 前同样处理,两路径行为一致。
|
|
857
|
+
out_results = _sort_results(merged, sort)
|
|
858
|
+
|
|
859
|
+
out: dict[str, Any] = {
|
|
860
|
+
"query": query, "engine": engine_label, "engines": engines,
|
|
861
|
+
"engines_combo": engines_combo, "cached": False,
|
|
862
|
+
"domain": domain, "elapsed_ms": elapsed,
|
|
863
|
+
"tfidf_scores": tfidf_scores,
|
|
864
|
+
"route_reason": decision.get("reason"),
|
|
865
|
+
"results": out_results,
|
|
866
|
+
"count": len(out_results), "engines_used": list(raw_results.keys()),
|
|
867
|
+
"errors": _collect_errors(raw_results, engine_outcomes),
|
|
868
|
+
"engine_outcomes": engine_outcomes,
|
|
869
|
+
# 阶段漏斗账:0 结果时用它定位塌在哪一层(见 build_funnel)
|
|
870
|
+
"funnel": funnel,
|
|
871
|
+
# 多语言噪声门:被剔除的引擎及原因(可观测,便于定位「为什么少了几个源」)
|
|
872
|
+
"noise_dropped": _noise_dropped,
|
|
873
|
+
"wasted_engine_ms": wasted_ms,
|
|
874
|
+
"early_stopped": early_stopped,
|
|
875
|
+
"reranker": reranker_status,
|
|
876
|
+
"rank_method": rank_method,
|
|
877
|
+
"minhash_removed": minhash_removed,
|
|
878
|
+
"image_dup_removed": image_dup_removed,
|
|
879
|
+
"image_dropped": image_dropped or None,
|
|
880
|
+
"local_rerank_on": local_rerank_on,
|
|
881
|
+
"recovery": recovery_info,
|
|
882
|
+
"fact_alignment": fact_alignment,
|
|
883
|
+
"number_conflict": number_conflict,
|
|
884
|
+
"exclude_terms": exclude_terms,
|
|
885
|
+
"excluded_count": excluded_count,
|
|
886
|
+
"time_filtered": time_filtered,
|
|
887
|
+
"time_filter_warning": time_filter_warning,
|
|
888
|
+
# 缓存被写入守卫拒绝时记下类别名(如 DegradedCacheRejected)。
|
|
889
|
+
# 这次结果照常返回,但下次不会命中缓存——没有这个字段,使用者只会
|
|
890
|
+
# 看到「同一个查询每次都很慢」,且无从知道原因是守卫在拒写。
|
|
891
|
+
"cache_write_skip": cache_write_skip,
|
|
892
|
+
"mode": mode, "depth": depth,
|
|
893
|
+
"login_hint": decision.get("login_hint"),
|
|
894
|
+
}
|
|
895
|
+
# site:/-site: 过滤账(随缓存一起回放,命中时也能说明「为什么少了 N 条」)。
|
|
896
|
+
if result_payload.get("site_filter") is not None:
|
|
897
|
+
out["site_filter"] = result_payload["site_filter"]
|
|
898
|
+
out["site_filtered"] = result_payload.get("site_filtered", 0)
|
|
899
|
+
if lang_pref_info is not None:
|
|
900
|
+
out["lang_pref"] = lang_pref_info
|
|
901
|
+
if timing is not None:
|
|
902
|
+
# 并发效率:dispatch 是墙钟,engine_latency 之和是各引擎各自耗时。
|
|
903
|
+
# 比值远小于引擎数说明并发没排满(或某个慢源独占尾部),是判断
|
|
904
|
+
# 「该加并发还是该摘慢源」的直接依据。
|
|
905
|
+
eng_sum = sum(engine_latency.values())
|
|
906
|
+
out["timing"] = timing.summary()
|
|
907
|
+
out["timing"]["elapsed_ms"] = elapsed
|
|
908
|
+
out["timing"]["dispatch"] = {
|
|
909
|
+
# 墙钟/尾等待/弃置的口径真源见 dispatch_result.py;useful + wasted 与
|
|
910
|
+
# wall 没有恒等式(2026-10-10 起),不要再按恒等式读仪表。
|
|
911
|
+
"wall_ms": budget_used_ms,
|
|
912
|
+
"engines_run": len(engine_latency),
|
|
913
|
+
"engine_sum_ms": eng_sum,
|
|
914
|
+
"parallel_efficiency": (round(eng_sum / budget_used_ms, 2)
|
|
915
|
+
if budget_used_ms else None),
|
|
916
|
+
"useful_ms": useful_ms,
|
|
917
|
+
"wasted_ms": wasted_ms,
|
|
918
|
+
**dispatch_tail,
|
|
919
|
+
"early_stopped": early_stopped,
|
|
920
|
+
}
|
|
921
|
+
if budget_total_ms is not None:
|
|
922
|
+
# 预算消耗额:fast/auto 有总预算,deep 无(键缺席即「无预算」)。
|
|
923
|
+
# timing 在 agent 档保留,预算可见性随答案一起到达。
|
|
924
|
+
out["timing"]["budget"] = {"used_ms": budget_used_ms,
|
|
925
|
+
"total_ms": budget_total_ms}
|
|
926
|
+
return out
|