argo-search 2.9.7 → 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.
Files changed (140) hide show
  1. package/README.en.md +58 -145
  2. package/README.es.md +58 -145
  3. package/README.ja.md +64 -151
  4. package/README.ko.md +63 -150
  5. package/README.md +65 -154
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +74 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +219 -60
  15. package/docs/ENGINE_CATALOG.md +50 -39
  16. package/docs/MCP_SETUP.md +3 -1
  17. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  18. package/engines/specs/cfets.yaml +41 -0
  19. package/engines/specs/defillama.yaml +38 -0
  20. package/engines/specs/firecrawl.yaml +1 -1
  21. package/engines/specs/fxtwitter.yaml +5 -0
  22. package/engines/specs/gleif.yaml +55 -0
  23. package/engines/specs/hkex.yaml +40 -0
  24. package/engines/specs/keenable_free.yaml +36 -0
  25. package/engines/specs/shfe.yaml +39 -0
  26. package/engines/specs/stackexchange.yaml +2 -2
  27. package/engines/specs/tavily_free.yaml +56 -0
  28. package/engines/specs/yuanbao.yaml +93 -0
  29. package/mcp/clients.yaml +32 -0
  30. package/package.json +6 -2
  31. package/packages/dsh-plugin/README.md +23 -5
  32. package/packages/dsh-plugin/dsh/client.js +304 -15
  33. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  34. package/packages/dsh-plugin/dsh/index.js +112 -1875
  35. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  36. package/packages/dsh-plugin/dsh/runner.js +666 -0
  37. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  38. package/packages/dsh-plugin/package.json +2 -2
  39. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  40. package/packages/dsh-plugin/test/client.test.js +175 -1
  41. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  42. package/packages/dsh-plugin/test/index.test.js +44 -0
  43. package/scripts/adaptive.py +24 -1
  44. package/scripts/article_meta.py +131 -0
  45. package/scripts/budget.py +247 -0
  46. package/scripts/budget_cli.py +93 -0
  47. package/scripts/build_release_assets.sh +65 -0
  48. package/scripts/cache.py +60 -72
  49. package/scripts/ci_drift_gate.py +90 -0
  50. package/scripts/content_boundary.py +46 -0
  51. package/scripts/content_signals.py +26 -5
  52. package/scripts/cross_lingual_trace.py +293 -0
  53. package/scripts/dispatch_result.py +98 -0
  54. package/scripts/engine_dispatch.py +49 -86
  55. package/scripts/engine_env.py +5 -0
  56. package/scripts/engine_families.py +6 -0
  57. package/scripts/engine_policy.py +7 -7
  58. package/scripts/engines.py +21 -0
  59. package/scripts/engines_base.py +3 -3
  60. package/scripts/engines_builders.py +12 -0
  61. package/scripts/engines_builders_batch10.py +48 -3
  62. package/scripts/engines_builders_batch14.py +656 -0
  63. package/scripts/engines_builders_batch15.py +249 -0
  64. package/scripts/engines_builders_keyless.py +111 -0
  65. package/scripts/engines_builders_tech.py +0 -1
  66. package/scripts/evidence.py +31 -3
  67. package/scripts/evidence_loop.py +54 -22
  68. package/scripts/fetch_v3.py +25 -0
  69. package/scripts/gen_routing_snapshot.py +11 -0
  70. package/scripts/install.ps1 +18 -0
  71. package/scripts/install.sh +16 -1
  72. package/scripts/job.py +3 -3
  73. package/scripts/local_seek.py +41 -5
  74. package/scripts/macro_countries.py +15 -0
  75. package/scripts/matrix_search_eval.py +82 -0
  76. package/scripts/mcp_compact.py +234 -0
  77. package/scripts/mcp_handlers.py +60 -642
  78. package/scripts/mcp_launch.sh +10 -8
  79. package/scripts/mcp_local.py +241 -0
  80. package/scripts/mcp_server.py +19 -14
  81. package/scripts/mcp_setup.py +33 -9
  82. package/scripts/mcp_tools.py +1 -0
  83. package/scripts/mcp_transport.py +1 -1
  84. package/scripts/mcp_warm.py +127 -0
  85. package/scripts/output_transparency.py +200 -0
  86. package/scripts/paid_policy.py +98 -0
  87. package/scripts/quota.py +158 -13
  88. package/scripts/recovery_runner.py +150 -0
  89. package/scripts/replay_eval.py +18 -0
  90. package/scripts/research.py +37 -21
  91. package/scripts/research_expand.py +24 -1
  92. package/scripts/retry_policy.py +76 -0
  93. package/scripts/route.py +6 -6
  94. package/scripts/route_cache.py +18 -0
  95. package/scripts/route_combo.py +7 -0
  96. package/scripts/route_domains.py +32 -19
  97. package/scripts/search.py +15 -27
  98. package/scripts/search_attribution.py +100 -3
  99. package/scripts/search_cli.py +24 -11
  100. package/scripts/search_entry.py +7 -1
  101. package/scripts/search_output.py +99 -15
  102. package/scripts/search_pipeline.py +42 -193
  103. package/scripts/search_types.py +107 -9
  104. package/scripts/semantic_evidence.py +179 -3
  105. package/scripts/serp_guard.py +115 -7
  106. package/scripts/serp_kill_log.py +25 -2
  107. package/scripts/stats_cli.py +13 -1
  108. package/scripts/wide_research.py +45 -0
  109. package/scripts/zhihu_quota.py +139 -0
  110. package/sub-skills/local-search/bm25.py +27 -3
  111. package/sub-skills/local-search/cli_runner.py +331 -0
  112. package/sub-skills/local-search/parsers.py +328 -0
  113. package/sub-skills/local-search/search_v3.py +31 -674
  114. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  115. package/assets/readme/settings-keys.png +0 -0
  116. package/assets/readme/settings-tools.png +0 -0
  117. package/scripts/image_vision/vision_probe +0 -0
  118. package/scripts/local_engines.py +0 -321
  119. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  120. package/sub-skills/ego-search/references/install.md +0 -67
  121. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  122. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  123. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  124. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  125. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  126. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  127. package/sub-skills/local-seek/references/git-integration.md +0 -54
  128. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  129. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  130. package/sub-skills/local-seek/references/strategies.md +0 -52
  131. package/sub-skills/local-seek/references/structural-search.md +0 -59
  132. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  133. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  134. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  135. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  136. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  137. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  138. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  139. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  140. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """recovery_runner.py — 搜索主管线错误恢复执行器与 L3 策略。
3
+
4
+ 拆分自 search_pipeline.py:将零结果/异常退化恢复链与有界并发对冲执行器从主管线剥离。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import Any
11
+
12
+ from bounded_run import run_bounded
13
+ from except_sets import OPT_IMPORT, SHAPE_BENIGN
14
+ from search_rank import deduplicate_by_url
15
+
16
+
17
+ def run_pipeline_recovery(
18
+ *,
19
+ query: str,
20
+ merged: list[dict[str, Any]],
21
+ raw_results: dict[str, list],
22
+ decision: dict[str, Any],
23
+ engines: list[str],
24
+ available_engines_fn: Any,
25
+ max_results: int,
26
+ max_rec_level: str | None,
27
+ echo_triggered: bool,
28
+ primary_grace_s: float,
29
+ timeout: float,
30
+ depth: str,
31
+ mode: str,
32
+ since_iso: str | None,
33
+ until_iso: str | None,
34
+ skip_cache: bool,
35
+ quota_batch: Any,
36
+ engine_search_fn: Any,
37
+ emit_usage_fn: Any = None,
38
+ recovery_engines_set: set[str] | None = None,
39
+ engine_domain_extra: dict[str, Any] | None = None,
40
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
41
+ """执行恢复链(L1 放宽 / L2 变体 / L3 换引擎):有界并发对冲跑候选引擎并复检壳。"""
42
+ recovery_info = None
43
+ try:
44
+ from recovery import run_recovery
45
+ tried = list(raw_results.keys()) or list(engines)
46
+ fallback_engines = decision.get("engines_fallback") or []
47
+
48
+ rec_level = max_rec_level
49
+ if echo_triggered:
50
+ rec_level = "L3"
51
+ elif fallback_engines and decision.get("domain") not in (
52
+ None, "", "general", "general_search") \
53
+ and (rec_level is None or rec_level < "L3"):
54
+ rec_level = "L3"
55
+
56
+ try:
57
+ enabled_set = set(available_engines_fn())
58
+ except Exception:
59
+ enabled_set = None
60
+
61
+ dk = engine_domain_extra or {}
62
+
63
+ def _fire(eng: str) -> list[dict[str, Any]]:
64
+ if recovery_engines_set is not None:
65
+ recovery_engines_set.add(eng)
66
+ try:
67
+ res = engine_search_fn(
68
+ query, eng, n=max_results,
69
+ timeout=timeout, depth=depth, mode=mode,
70
+ since=since_iso, until=until_iso,
71
+ skip_cache=skip_cache, **dk,
72
+ )
73
+ except Exception as e:
74
+ sys.stderr.write(f"[recovery] 恢复链异常({type(e).__name__}): {e}\n")
75
+ res = []
76
+ goods = [r for r in (res or []) if isinstance(r, dict) and "error" not in r]
77
+ quota_batch.add(eng, bool(goods))
78
+ if goods:
79
+ for r in goods:
80
+ r.setdefault("_engine", eng)
81
+ r.setdefault("_recovered", True)
82
+ try:
83
+ from postprocess_signals import drop_echo_items as _drop_echo
84
+ goods = _drop_echo(goods, query)
85
+ except OPT_IMPORT + SHAPE_BENIGN:
86
+ pass
87
+ return goods
88
+
89
+ def _recovery_executor(rq: str, rengines: list[str]) -> list[dict[str, Any]]:
90
+ if len(rengines) <= 1:
91
+ return _fire(rengines[0]) if rengines else []
92
+ grace = max(0.0, float(primary_grace_s or 0.0))
93
+ wait_s = grace * (len(rengines) - 1) + max(1.0, float(timeout)) + 1.0
94
+
95
+ def _enough(finished: list[tuple[str, Any]]) -> bool:
96
+ return any(isinstance(v, list) and v for _i, v in finished)
97
+
98
+ finished, _unfinished = run_bounded(
99
+ rengines, _fire, wait_s, max_workers=3,
100
+ enough=_enough, stagger_s=grace,
101
+ )
102
+ for _item, value in finished:
103
+ if isinstance(value, list) and value:
104
+ return value
105
+ return []
106
+
107
+ rec_results, rec_result = run_recovery(
108
+ query, tried, _recovery_executor,
109
+ engines_fallback=fallback_engines, enabled=enabled_set, mode=mode,
110
+ max_level=rec_level,
111
+ )
112
+ recovery_info = rec_result.to_dict()
113
+
114
+ if emit_usage_fn is not None:
115
+ try:
116
+ emit_usage_fn("recovery", {
117
+ "query": (query[:60] if query else query),
118
+ "triggered": recovery_info.get("triggered"),
119
+ "recovered": recovery_info.get("recovered"),
120
+ "level_used": recovery_info.get("level_used"),
121
+ "strategy_used": recovery_info.get("strategy_used"),
122
+ "steps_tried": len(recovery_info.get("steps_tried") or []),
123
+ "final_query": (recovery_info.get("final_query") or "")[:60],
124
+ "note": recovery_info.get("note", ""),
125
+ })
126
+ except SHAPE_BENIGN:
127
+ pass
128
+
129
+ if rec_results:
130
+ try:
131
+ from recovery_l0 import finalize_l3_results
132
+ merged = finalize_l3_results(rec_results, max_results)
133
+ except ImportError:
134
+ merged = deduplicate_by_url(rec_results)[:max_results]
135
+
136
+ rec_by_eng: dict[str, list] = {}
137
+ for r in merged:
138
+ eng = r.get("_engine") or r.get("source") or ""
139
+ if eng:
140
+ r.setdefault("consensus_engines", [eng])
141
+ rec_by_eng.setdefault(eng, []).append(r)
142
+ for eng_name, lst in rec_by_eng.items():
143
+ raw_results.setdefault(eng_name, lst)
144
+
145
+ except ImportError:
146
+ pass
147
+ except Exception as e:
148
+ sys.stderr.write(f"[recovery] 错误恢复跳过: {type(e).__name__}\n")
149
+
150
+ return merged, recovery_info
@@ -534,8 +534,25 @@ def _fmt_header(doc: dict) -> str:
534
534
 
535
535
  # ── 命令行 ────────────────────────────────────────────────────────────────────
536
536
 
537
+ def _read_queries_file(path: str) -> list[str]:
538
+ """从文件读查询词:每行一个,忽略空行与行首注释。
539
+
540
+ 为什么要有这个入口:原来靠 shell 的 `mapfile` 拼参数,在 zsh / 空文件 /
541
+ 只有空行三种情况下会静默退化成「录一条空查询」——2026-10-22 本地实跑
542
+ 就录出了一条 query="" 的 case,而阈值判定只看 id 交集,于是「一条也算通过」。
543
+ """
544
+ qs = [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines()
545
+ if line.strip() and not line.lstrip().startswith("#")]
546
+ return qs
547
+
548
+
537
549
  def _cmd_record(args) -> int:
538
550
  queries = args.queries or _DEFAULT_QUERIES
551
+ if getattr(args, "queries_file", None):
552
+ queries = _read_queries_file(args.queries_file)
553
+ if not queries:
554
+ print(f"查询集是空的:{args.queries_file}", file=sys.stderr)
555
+ return 1
539
556
  out_path = Path(args.out) if args.out else DEFAULT_DATA_PATH
540
557
  cases: list[dict] = []
541
558
  for q in queries:
@@ -576,6 +593,7 @@ def main() -> int:
576
593
  ap.add_argument("--save-baseline", help="把当前结果存为对照基准")
577
594
  ap.add_argument("--record", action="store_true", help="联网录制(唯一会联网的模式)")
578
595
  ap.add_argument("--queries", nargs="*", help="录制用的查询词")
596
+ ap.add_argument("--queries-file", help="从文件读查询词(每行一个,空行与 # 注释忽略)")
579
597
  ap.add_argument("--out", help="录制结果写到哪里")
580
598
  ap.add_argument("--mode", default="auto")
581
599
  ap.add_argument("--depth", default="fast")
@@ -518,7 +518,8 @@ def deep_research(query: str, num_sub_queries: int = 4, max_results: int = 5,
518
518
  route_strategy: str | None = None,
519
519
  work_packages: Any = None,
520
520
  allow_recompute: bool = False,
521
- broaden: bool = False) -> dict[str, Any]:
521
+ broaden: bool = False,
522
+ cross_lingual_trace: bool = False) -> dict[str, Any]:
522
523
  """执行取证。有工作包则按依赖分阶段;否则扩词检索。产出 dossier。"""
523
524
  import time as _time
524
525
  _deadline = _time.time() + timeout * 3
@@ -569,36 +570,49 @@ def deep_research(query: str, num_sub_queries: int = 4, max_results: int = 5,
569
570
  route_strategy=route_strategy,
570
571
  )
571
572
 
572
- # --broaden: 来源不足时自动追加英文/学术方向补充搜索
573
- if broaden and not packages:
574
- all_urls: set[str] = set()
575
- for sr in collection["sub_results"]:
576
- for r in sr.get("results", []):
577
- url = r.get("url") or r.get("canonical_url")
578
- if url:
579
- all_urls.add(url)
580
- if len(all_urls) < 15:
581
- broaden_subs = [
582
- {"query": f"{query} 论文 学术", "intent": "学术补充", "strategy": "academic"},
583
- {"query": f"{query} research paper", "intent": "英文学术补充", "strategy": "english_focused"},
584
- ]
573
+ # ── 跨语言长程搜索与溯源链路(Cross-Lingual Long-Range Trace & Pivot)──
574
+ trace_report: dict[str, Any] | None = None
575
+ try:
576
+ from cross_lingual_trace import (
577
+ assess_cross_lingual_need,
578
+ build_cross_lingual_trace_queries,
579
+ synthesize_cross_lingual_evidence,
580
+ )
581
+ assess = assess_cross_lingual_need(
582
+ original_query,
583
+ collection.get("merged_results", []),
584
+ domain=(profile or {}).get("key") or "",
585
+ force_trace=bool(cross_lingual_trace or broaden),
586
+ )
587
+ should_trace = cross_lingual_trace or (not packages and (broaden or assess["needed"]))
588
+ if should_trace and _time.time() < _deadline:
589
+ trace_subs = []
590
+ for t_lang in assess.get("target_langs", ["en"])[:2]:
591
+ trace_subs.extend(build_cross_lingual_trace_queries(original_query, target_lang=t_lang))
585
592
  existing_queries = {sq["query"] for sq in sub_queries}
586
- broaden_subs = [sq for sq in broaden_subs if sq["query"] not in existing_queries]
587
- if broaden_subs:
588
- broaden_collection = collect_sources(
589
- broaden_subs, max_results, timeout, depth, mode,
593
+ trace_subs = [sq for sq in trace_subs if sq["query"] not in existing_queries]
594
+ if trace_subs:
595
+ trace_collection = collect_sources(
596
+ trace_subs, max_results, timeout, depth, mode,
590
597
  engines_priority=engines_priority or None,
591
598
  profile=profile, budget=budget, route_strategy=route_strategy,
592
599
  )
593
- collection["sub_results"].extend(broaden_collection["sub_results"])
594
- sub_queries.extend(broaden_subs)
595
- # 重新融合:把所有 sub_results 的 results 合并进 merged_results
600
+ collection["sub_results"].extend(trace_collection["sub_results"])
601
+ sub_queries.extend(trace_subs)
602
+ trace_report = synthesize_cross_lingual_evidence(
603
+ collection.get("merged_results", []),
604
+ trace_collection.get("merged_results", []),
605
+ original_query,
606
+ )
596
607
  result_lists = [sr["results"] for sr in collection["sub_results"] if sr.get("results")]
597
608
  if len(result_lists) > 1:
598
609
  from search_rank import rrf_merge
599
610
  collection["merged_results"] = rrf_merge(result_lists)[:max_results * 3]
600
611
  elif result_lists:
601
612
  collection["merged_results"] = result_lists[0]
613
+ except Exception as _trace_err:
614
+ import logging
615
+ logging.getLogger("unified_search").warning("跨语言长程溯源跳过: %s", _trace_err)
602
616
 
603
617
  gaps = identify_gaps(collection["sub_results"], original_query)
604
618
  source_grades = (profile or {}).get("source_grades") if profile else None
@@ -669,6 +683,8 @@ def deep_research(query: str, num_sub_queries: int = 4, max_results: int = 5,
669
683
  plan_info, rewrite_result,
670
684
  )
671
685
  _attach_evidence_loop(report, collection)
686
+ if trace_report is not None:
687
+ report["cross_lingual_trace"] = trace_report
672
688
  report["quality_gate_results"] = evaluate_dossier_gates(report)
673
689
  report["conclusion_cap"] = report["quality_gate_results"]["conclusion_cap"]
674
690
  return report
@@ -8,6 +8,29 @@ Agent 的 MECE 问题树走 research_work_packages,不要把扩词当研究拆
8
8
  from __future__ import annotations
9
9
 
10
10
  import re
11
+
12
+
13
+ def _english_anchor(query: str) -> str:
14
+ """中英混合查询里的英文锚点;单 token 不足以独立成查询时返回空串。
15
+
16
+ why(2026-10-22 实测):「Rust 异步运行时怎么选」被扩成一条独立的
17
+ `Rust` 子查询,抓回 Rust 游戏官网、语言首页、算法库,15 条来源里 3 条
18
+ 与问题无关;「AI 监管 新规」同理会扩成 `AI`。单 token 丢了中文里真正的
19
+ 限定语(异步运行时/监管),既浪费一次检索又污染引用。
20
+ 判据用「≥2 个词 或 单 token ≥5 字符」:`kubernetes`、`Python`、`Claude Code`
21
+ 这类本身可独立检索的保留;`Rust`/`AI`/`DSH`/纯数字年份与股票代码不单飞。
22
+ """
23
+ tokens = [w for w in query.split()
24
+ if w.isascii() and len(w) > 1 and any(c.isalnum() for c in w)
25
+ and not w.isdigit()]
26
+ text = " ".join(tokens)
27
+ if not text:
28
+ return ""
29
+ if len(tokens) >= 2:
30
+ return text
31
+ return text if len(text) >= 5 else ""
32
+
33
+
11
34
  def expand_query(query: str, num_sub: int = 4) -> list[dict[str, str]]:
12
35
  """按关键词特征扩检索词。产出的是搜索变体,不是可验证子问题。"""
13
36
  sub_queries: list[dict[str, str]] = []
@@ -16,7 +39,7 @@ def expand_query(query: str, num_sub: int = 4) -> list[dict[str, str]]:
16
39
  has_english = any(c.isascii() and c.isalpha() for c in query)
17
40
 
18
41
  if has_chinese and has_english:
19
- eng_words = " ".join(w for w in query.split() if w.isascii() and len(w) > 2)
42
+ eng_words = _english_anchor(query)
20
43
  if eng_words:
21
44
  sub_queries.append({
22
45
  "query": eng_words,
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """retry_policy.py — 失败类别与重试判据(唯一来源)。
3
+
4
+ 拆出 engine_dispatch 的理由有两条:
5
+
6
+ 1. **单一真源**:重试判据(该不该再发一次)与归因判据(这次算什么失败)
7
+ 必须用同一套词表与同一个优先级。两处各写一套的下场是重试按一套词表
8
+ 跳过、熔断按另一套记账,对同一次失败给出两个不同判断。
9
+ 2. **行数门禁**:engine_dispatch 已接近祖父上限(tests/test_module_size_gate.
10
+ py),而本模块是纯策略、无 IO、无闭包状态——正好是「拆出去两边都轻」
11
+ 的那一类。
12
+
13
+ 本模块不做决定、不碰状态:只把「错误文本 → 类别 → 是否值得重试」算出来。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from cache_guard import _QUOTA_ERROR_KEYWORDS
21
+
22
+ # 拦截页特征词(唯一来源):error 文本里出现即判 blocked。HTML 引擎的反爬
23
+ # 命中没有 error 文本(静默空结果),走 engines_base 的归因寄存器;
24
+ # 这张表兜住「error 结果里带拦截页字样」的可见路径。
25
+ _BLOCKED_ERROR_KEYWORDS = (
26
+ "just a moment", "checking your browser", "cf-browser-verification",
27
+ "challenge", "ddos-guard", "perimeterx", "access denied",
28
+ "handshake failure", "unable to handshake", "安全验证", "滑动验证",
29
+ )
30
+
31
+ # 确定性失败:源端状态,不是网络抖动。重试只是多烧一次调用并推迟切备选
32
+ #——配额耗尽重试仍是耗尽,鉴权重试仍是 401,反爬重试仍撞同一堵墙。
33
+ # 与 dsh-free-search 的 failure-aware fallback 同构(quota/auth 冷却到会话
34
+ # 结束、bot-wall 短退避,只有 transient 才重试一次)。
35
+ NON_RETRYABLE_STATUS = frozenset({"quota-exhausted", "auth-failed", "blocked"})
36
+
37
+
38
+ def classify_error_status(message: Any) -> str:
39
+ """错误文本 → 失败类别。**重试判据与归因判据的共同定义点**。
40
+
41
+ 优先级顺序即语义强弱:超时最具体(描述本次调用的形态),配额/限流/
42
+ 拦截是源端状态,鉴权最后(401/403 也可能来自拦截页)。
43
+ """
44
+ m = str(message or "").lower()
45
+ if "timeout" in m or "timed out" in m:
46
+ return "timeout"
47
+ if any(k in m for k in _QUOTA_ERROR_KEYWORDS):
48
+ return "quota-exhausted"
49
+ if "rate" in m or "429" in m:
50
+ return "rate-limited"
51
+ if any(k in m for k in _BLOCKED_ERROR_KEYWORDS):
52
+ return "blocked"
53
+ if "auth" in m or "401" in m or "403" in m:
54
+ return "auth-failed"
55
+ return "error"
56
+
57
+
58
+ def is_retryable_failure(res: list[Any] | None) -> bool:
59
+ """这组结果值不值得再试一次(纯函数,门禁可直接打到)。
60
+
61
+ 判据取自 `classify_error_status`,与归因同源。空结果与无错误条目返回
62
+ True——沿用既有「重试一次」语义,本次只收紧确定性失败这一类。
63
+ """
64
+ for r in res or []:
65
+ if isinstance(r, dict) and "error" in r:
66
+ return classify_error_status(r.get("error")) not in NON_RETRYABLE_STATUS
67
+ return True
68
+
69
+
70
+ def record_attempts(outcome: dict[str, Any], attempts: dict[str, int]) -> None:
71
+ """把「实际发了几次网络请求」如实写进 outcome。
72
+
73
+ 单独成函数是为了让 engine_dispatch 不必为 3 行记账逻辑长到行数门禁
74
+ 外面去;记账口径(至少 1 次)也只在这里定义一次。
75
+ """
76
+ outcome["attempts"] = max(1, int((attempts or {}).get("n", 1)))
package/scripts/route.py CHANGED
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import sys
24
24
  import time
25
- from dataclasses import dataclass
25
+ from search_types import slot_record as dataclass
26
26
  from typing import Any, Callable
27
27
  from cli_io import dumps
28
28
 
@@ -128,6 +128,7 @@ from route_lang import ( # noqa: E402
128
128
  _SOCIAL_ZH_GENERAL,
129
129
  _get_registry,
130
130
  )
131
+ from paid_policy import insert_hedge
131
132
  from route_combo import ( # noqa: E402
132
133
  _expand_local_search,
133
134
  _general_fallback,
@@ -585,14 +586,11 @@ def _route_by_domain(ctx: _RouteCtx, domain: dict[str, Any], secondary: list[dic
585
586
  is_catch_all=is_catch_all, enabled=enabled)
586
587
  if _strong:
587
588
  confidence = 0.9
588
- # 熔断统一处理 + 槽位回填(2026-10-02,回填逻辑与
589
- # 判据见 backfill_after_breaker docstring)
589
+ # 熔断统一处理 + 槽位回填(判据见 backfill_after_breaker)
590
590
  engines_combo = backfill_after_breaker(
591
591
  engines_combo, enabled, features=features, query=query,
592
592
  skip_aux=_pure_combo, refill_pool=pre_policy_pool)
593
- # budget 截断后保持一致 parallel,避免短 combo 仍开多余并行
594
- # research 语境例外:子查询跑满 combo(no_early_stop),串行会拖垮
595
- # 整条研究管线,强制并行
593
+ # parallel 与预算后 combo 长度对齐;research 语境强制并行
596
594
  if (mode == "fast" and context != "research") or len(engines_combo) <= 1:
597
595
  parallel = False
598
596
  elif context == "research":
@@ -601,6 +599,8 @@ def _route_by_domain(ctx: _RouteCtx, domain: dict[str, Any], secondary: list[dic
601
599
  # 双引擎默认串行,利于 early-stop(答案域)
602
600
  parallel = parallel and len(engines_combo) > 2
603
601
 
602
+ engines_combo = insert_hedge(engines_combo, domain.get("name", ""), mode, enabled)
603
+
604
604
  return _done(
605
605
  engine=engines_combo[0],
606
606
  engines=engines_combo,
@@ -19,6 +19,7 @@ route_cache → route → route_cache 的循环导入。
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
+ import os
22
23
  import time
23
24
  from typing import Any
24
25
 
@@ -100,6 +101,23 @@ def _route_state_fingerprint() -> str:
100
101
  parts.append("cb=" + ",".join(sorted(get_breaker().auto_disabled())))
101
102
  except Exception:
102
103
  return ""
104
+ # 路由旋钮的 env 覆盖必须参与指纹:否则「设了 ARGO_GENERAL_FALLBACK_LEAD
105
+ # 却因缓存命中不生效」,逃生开关与 A/B 臂都变成摆设(2026-10-21 实测踩中)。
106
+ # LEGACY 是同一类开关(改通用兜底链的成员),一并入指纹,避免只修一个入口。
107
+ # 统一经 engine_env.get_env 取值(兼顾 ~/.config/argo/env 文件与进程环境)。
108
+ try:
109
+ from engine_env import get_env as _get_env
110
+ except Exception:
111
+ _get_env = lambda k, d="": os.environ.get(k, d) # noqa: E731
112
+ parts.append("lead=" + (_get_env("ARGO_GENERAL_FALLBACK_LEAD") or ""))
113
+ parts.append("legacy=" + (_get_env("ARGO_GENERAL_FALLBACK_LEGACY") or ""))
114
+ # 付费对冲开关与月度预算同样改变 combo;不纳入指纹会让开关或预算变更在缓存命中时静默不生效。
115
+ parts.append("paid=" + (_get_env("ARGO_PAID_SEARCH") or ""))
116
+ try:
117
+ from budget import budget_signature
118
+ parts.append(f"bgt={budget_signature()!r}")
119
+ except Exception:
120
+ pass
103
121
  return "|".join(parts)
104
122
 
105
123
 
@@ -12,6 +12,7 @@
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import os
15
16
  from typing import Any
16
17
 
17
18
  from config import get_engines
@@ -101,6 +102,12 @@ def _general_fallback(enabled: set[str]) -> list[str]:
101
102
  except ImportError:
102
103
  GENERAL_FREE_FALLBACK = ("anysearch", "octen", "local_bing")
103
104
  order = list(GENERAL_FREE_FALLBACK)
105
+ # 领队覆盖(2026-10-21):A/B 与线上回退都走这里,避免为了复测改常量。
106
+ # 传引擎名即把它提到首位(例:ARGO_GENERAL_FALLBACK_LEAD=anysearch 复现
107
+ # 2026-10-21 之前的顺位);传空/未知值不生效。与下面的 LEGACY 开关正交。
108
+ lead_override = (os.environ.get("ARGO_GENERAL_FALLBACK_LEAD") or "").strip()
109
+ if lead_override and lead_override in order:
110
+ order = [lead_override] + [e for e in order if e != lead_override]
104
111
  # 逃生开关:A/B 与线上回退都走这条,避免为了复测改常量。
105
112
  # 2026-10-17 对拍(交错、随机序):octen 入第二顺位后,anysearch 抖动
106
113
  # 窗口 p90 3273→1614ms、max 6266→1928ms、错误 9→0;健康窗口中位无差。
@@ -76,6 +76,26 @@ _compiled_domains: list[dict[str, Any]] | None = None
76
76
  _compiled_domains_id: tuple | None = None # (name, patterns/exclude_if 内容指纹)
77
77
 
78
78
 
79
+ def _ensure_domain_compiled(domain: dict[str, Any]) -> list[tuple[re.Pattern, re.Pattern | None]]:
80
+ """按需编译单个域的触发词与意图正则(扫到哪个域才编译哪个域)。"""
81
+ rules = domain.get("_rules")
82
+ if rules is not None:
83
+ return rules
84
+ rules = []
85
+ for p in _pattern_entries(domain):
86
+ match_src, unless_src = _split_pattern_entry(p)
87
+ match_re = _try_compile(match_src)
88
+ if match_re is None:
89
+ continue # 触发词写坏 → 丢这一条(与旧行为一致)
90
+ rules.append((match_re, _try_compile(unless_src)))
91
+ intent_src = domain.get("intent_required")
92
+ domain["_intent"] = (
93
+ _try_compile(intent_src) if isinstance(intent_src, str) and intent_src else None
94
+ )
95
+ domain["_rules"] = rules
96
+ return rules
97
+
98
+
79
99
  def _compile_domain_patterns(domains: list[dict[str, Any]]) -> list[dict[str, Any]]:
80
100
  """把域声明编译成可执行形态(`_rules` = (触发词, 该词的否决词|None) 列表)。
81
101
 
@@ -85,22 +105,9 @@ def _compile_domain_patterns(domains: list[dict[str, Any]]) -> list[dict[str, An
85
105
  """
86
106
  compiled = []
87
107
  for idx, domain in enumerate(domains):
88
- rules = []
89
- for p in _pattern_entries(domain):
90
- match_src, unless_src = _split_pattern_entry(p)
91
- match_re = _try_compile(match_src)
92
- if match_re is None:
93
- continue # 触发词写坏 → 丢这一条(与旧行为一致)
94
- rules.append((match_re, _try_compile(unless_src)))
95
- intent_re = None
96
- intent_src = domain.get("intent_required")
97
- if isinstance(intent_src, str) and intent_src:
98
- try:
99
- intent_re = re.compile(intent_src)
100
- except re.error:
101
- intent_re = None
102
- compiled.append({**domain, "_idx": idx, "_rules": rules,
103
- "_intent": intent_re})
108
+ entry = {**domain, "_idx": idx, "_rules": None, "_intent": None}
109
+ _ensure_domain_compiled(entry)
110
+ compiled.append(entry)
104
111
  return compiled
105
112
 
106
113
 
@@ -129,7 +136,7 @@ def _split_pattern_entry(entry: Any) -> tuple[Any, Any]:
129
136
 
130
137
  def _domain_fires(domain: dict[str, Any], query: str) -> bool:
131
138
  """一个域是否成立:任一触发词命中,且它自己的否决词没命中。"""
132
- for match_re, unless_re in domain["_rules"]:
139
+ for match_re, unless_re in _ensure_domain_compiled(domain):
133
140
  if unless_re is not None and unless_re.search(query):
134
141
  continue
135
142
  if match_re.search(query):
@@ -155,7 +162,12 @@ def _get_compiled_domains(domains: list[dict[str, Any]]) -> list[dict[str, Any]]
155
162
  )
156
163
  if _compiled_domains is not None and _compiled_domains_id == dom_fp:
157
164
  return _compiled_domains
158
- _compiled_domains = _compile_domain_patterns(domains)
165
+ # 槽位懒初始化:实际正则在 _domain_fires 扫到该域时才按需编译,
166
+ # 冷启动路由省去未扫到域(及被语言门跳过的域)的正则编译税。
167
+ _compiled_domains = [
168
+ {**domain, "_idx": idx, "_rules": None, "_intent": None}
169
+ for idx, domain in enumerate(domains)
170
+ ]
159
171
  _compiled_domains_id = dom_fp
160
172
  return _compiled_domains
161
173
 
@@ -168,7 +180,8 @@ def warm_compiled_domains() -> None:
168
180
  懒编译与全量回退都会自愈,预热是纯优化。
169
181
  """
170
182
  try:
171
- _get_compiled_domains(get_domains())
183
+ for d in _get_compiled_domains(get_domains()):
184
+ _ensure_domain_compiled(d)
172
185
  except Exception:
173
186
  pass
174
187
 
package/scripts/search.py CHANGED
@@ -214,7 +214,6 @@ def _missing_env_for(eng: str) -> list[str]:
214
214
  # 学习跳过判定。
215
215
 
216
216
  from engine_dispatch import ( # noqa: E402
217
- _QUOTA_ERROR_KEYWORDS,
218
217
  classify_engine_outcome as _classify_engine_outcome,
219
218
  run_dispatch,
220
219
  )
@@ -547,18 +546,7 @@ def execute_search(query: str, decision: dict[str, Any], max_results: int,
547
546
 
548
547
  # ── 统一入口 ──────────────────────────────────────────────────────────────────
549
548
 
550
- def _resolve_include_local(include_local: bool | None, mode: str) -> bool:
551
- """include-local 三态解析:True/False 显式,None=自动(fast/budget 开)。
552
-
553
- 本地文件命中此前默认全关,模型与用户在正常使用中永远看不到本机内容
554
- (2026-09-29 审查:「本地内容搜索被忽略」)。自动开的安全垫已齐备:
555
- 宽根守卫(home/根目录不扫)+ 3s 子进程超时 + L1 并行提交(不占墙钟,
556
- 常规项目目录 rg 实测 33ms)。auto/deep 保持显式开启——深度模式由
557
- 调用方自主决定证据面。
558
- """
559
- if include_local is None:
560
- return mode in ("fast", "budget")
561
- return bool(include_local)
549
+ from local_seek import resolve_include_local as _resolve_include_local
562
550
 
563
551
 
564
552
  def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = False,
@@ -785,13 +773,13 @@ def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = F
785
773
  file=sys.stderr)
786
774
  if search_query != original_query:
787
775
  print(f"[改写] {original_query} → {search_query}", file=sys.stderr)
788
- # L1 并行化:include_local 时把 _run_local_seek 提前提交到线程池,
789
- # 与主搜索并行执行。主搜索完成时本地搜索通常已就绪,零额外墙钟。
790
- include_local = _resolve_include_local(include_local, mode)
776
+ # L1 并行化:本地搜索与主搜索并行提交(仅显式开启时在宽根下提示)。
777
+ _local_seek_explicit = include_local is not None
778
+ include_local = _resolve_include_local(include_local, mode, query)
791
779
  _local_seek_future = None
792
780
  if include_local:
793
781
  _local_seek_future = _get_local_seek_executor().submit(
794
- _run_local_seek, query, n)
782
+ _run_local_seek, query, n, warn=_local_seek_explicit)
795
783
 
796
784
  result = execute_search(
797
785
  query=search_query, decision=decision, max_results=n,
@@ -863,15 +851,15 @@ def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = F
863
851
  "url-seed: seed URL was not fetched; results are related discovery only"
864
852
  )
865
853
  # 恢复链两种结局分开说(2026-10-10 方案甲):「恢复成功换了引擎」与
866
- # 「触发了但无可用策略」对消费者的含义完全不同——后者意味着空结果是
867
- # 查询本身无召回(该改写查询),不是引擎故障(该重试)。
854
+ # 「触发了但无可用策略」对消费者的含义完全不同。失败那一半再细分:
855
+ # 源被跳过/鉴权失败时不要写成「查询没有召回」(2026-10-22 修正),
856
+ # 具体判据与措辞在 search_attribution.zero_result_recovery_note。
868
857
  _rec_info = result.get("recovery") or {}
869
- if isinstance(_rec_info, dict) and _rec_info.get("triggered") \
870
- and not _rec_info.get("recovered"):
871
- extra_lim.append(
872
- "recovery triggered but no usable strategy ("
873
- f"{_rec_info.get('note') or 'no note'}); empty results likely mean "
874
- "the query had no recall — try rephrasing")
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)
875
863
  elif result.get("recovery"):
876
864
  extra_lim.append("recovery used; engine fallback may differ from primary route")
877
865
  # 执行归因:把「为什么只有这么几条 / 为什么只跑了一个源」写进随答案一起
@@ -942,8 +930,8 @@ def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = F
942
930
  from serp_kill_log import drain_serp_guard_kills
943
931
  for _kill in drain_serp_guard_kills():
944
932
  result.setdefault("errors", []).append(
945
- f"serp_guard: {_kill['engine']} 整页判垃圾被拦下"
946
- f"({_kill['results']} 条,疑似 SERP/跳转壳占比高)——"
933
+ f"serp_guard: {_kill['engine']} 头部结果均未覆盖 20% 查询词,"
934
+ f"整页按答非所问丢弃({_kill['results']} 条)——"
947
935
  "如需复核可设 ARGO_SERP_GUARD=0 重跑")
948
936
  except ImportError:
949
937
  pass