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
@@ -44,15 +44,112 @@ def top_engine_latency(engine_latency: dict[str, Any] | None,
44
44
 
45
45
 
46
46
  def usage_log_attribution(engine_latency: dict[str, Any] | None,
47
- top_n: int = DEFAULT_TOP_N) -> dict[str, Any]:
48
- """usage_log query 流的归因字段(当前只有引擎耗时 Top-N)。
47
+ top_n: int = DEFAULT_TOP_N,
48
+ engine_outcomes: Iterable[dict[str, Any]] | None = None
49
+ ) -> dict[str, Any]:
50
+ """usage_log query 流的归因字段(慢源 Top-N + 失败类别)。
49
51
 
50
52
  返回 dict 以便调用方 **展开进日志记录**,未来新增归因维度(如
51
53
  「等待预算被谁耗掉」「缓存未命中的原因分布」)时只扩这里,
52
54
  主管线那一行 `**usage_log_attribution(...)` 不动——归因是会长大的,
53
55
  写入点不该跟着长(主管线有模块大小门禁,见模块 docstring)。
56
+
57
+ 失败类别只记「哪类」不记条数:errors 已经记了条数,两者互补。
58
+ """
59
+ out: dict[str, Any] = {
60
+ "engine_latency_top": top_engine_latency(engine_latency, top_n),
61
+ }
62
+ kinds = failure_kinds(engine_outcomes)
63
+ if kinds:
64
+ out["failure_kinds"] = kinds
65
+ return out
66
+
67
+
68
+ # 合成引擎名(只存在于基准/混沌测试的 fake combo)。它们出现在 engines_used
69
+ # 里就说明这一跑不是真实检索;写进生产 usage_log 会污染慢源排行与总量统计
70
+ # (2026-10-21 实测 "hedge bench" 32 条,dead_primary/fast_backup 进了慢源榜)。
71
+ _SYNTHETIC_ENGINES: frozenset[str] = frozenset({
72
+ "dead_primary", "dead_backup", "fast_backup", "slow_backup",
73
+ "slow_primary", "quick_primary", "fast_primary",
74
+ })
75
+
76
+
77
+ def is_synthetic_run(engines: Iterable[str] | None) -> bool:
78
+ """这组引擎是否来自基准/测试的合成组合(有一个即判合成)。"""
79
+ return any(str(e) in _SYNTHETIC_ENGINES for e in (engines or []))
80
+
81
+
82
+ # 「源被跳过/没资格调用」的状态:空结果时要把它们与「查询没有召回」分开。
83
+ # 只收持续性状态(配额/缺 env/鉴权),不含 timeout/error——后者是本次
84
+ # 真的失败了,原表述已够用。
85
+ SKIPPED_SOURCE_STATUSES: frozenset[str] = frozenset({
86
+ "skipped-quota-exhausted", "skipped-missing-env",
87
+ "quota-exhausted", "auth-failed",
88
+ })
89
+
90
+
91
+ def skipped_sources(engine_outcomes: Iterable[dict[str, Any]] | None,
92
+ limit: int = 4) -> list[str]:
93
+ """空结果时被跳过/鉴权失败的引擎名(去重、保序、限量)。"""
94
+ out: list[str] = []
95
+ seen: set[str] = set()
96
+ for o in (engine_outcomes or []):
97
+ if not isinstance(o, dict):
98
+ continue
99
+ if str(o.get("status") or "") not in SKIPPED_SOURCE_STATUSES:
100
+ continue
101
+ name = str(o.get("engine") or "?")
102
+ if name not in seen:
103
+ seen.add(name)
104
+ out.append(name)
105
+ if len(out) >= max(0, int(limit)):
106
+ break
107
+ return out
108
+
109
+
110
+ def zero_result_recovery_note(recovery: Any,
111
+ engine_outcomes: Iterable[dict[str, Any]] | None
112
+ ) -> str | None:
113
+ """恢复链触发但没救回来时的局限声明;不需要声明时返回 None。
114
+
115
+ 「没有召回」与「源被跳过/鉴权失败」处置完全不同:前者改写查询,
116
+ 后者看 failures 或补凭证。此前一律写成 no recall,实测 bocha 配额
117
+ 耗尽(403 not enough money)被报成「查询无召回」,调用方白跑一轮。
54
118
  """
55
- return {"engine_latency_top": top_engine_latency(engine_latency, top_n)}
119
+ if not isinstance(recovery, dict) or not recovery.get("triggered") \
120
+ or recovery.get("recovered"):
121
+ return None
122
+ note = recovery.get("note") or "no note"
123
+ skipped = skipped_sources(engine_outcomes)
124
+ if skipped:
125
+ return (
126
+ "recovery triggered but no usable strategy ("
127
+ f"{note}); empty results came from skipped/failed sources "
128
+ f"({', '.join(skipped)}) — see failures; this is not necessarily "
129
+ "a no-recall query"
130
+ )
131
+ return (
132
+ "recovery triggered but no usable strategy ("
133
+ f"{note}); empty results likely mean the query had no recall — "
134
+ "try rephrasing"
135
+ )
136
+
137
+
138
+ def failure_kinds(engine_outcomes: Iterable[dict[str, Any]] | None,
139
+ limit: int = 6) -> list[str]:
140
+ """本次调用的失败类别(去重、稳定排序),写进 usage_log 供长期归因。
141
+
142
+ errors 只记「几条」,回答不了「哪类失败在反复出现」;类别来自
143
+ output_transparency.build_failures(与 agent 档 failures 同一口径),
144
+ 不在日志侧另写一份分类。
145
+ """
146
+ try:
147
+ from output_transparency import build_failures
148
+ except ImportError: # pragma: no cover - 单文件分发时的降级
149
+ return []
150
+ rows = build_failures({"engine_outcomes": list(engine_outcomes or [])},
151
+ limit=limit)
152
+ return sorted({str(r.get("category") or "unknown") for r in rows})
56
153
 
57
154
 
58
155
  def aggregate_slow_engines(rows: Iterable[dict[str, Any]],
@@ -328,6 +328,27 @@ def _format_engine_summary(summary: dict[str, Any]) -> str:
328
328
  return "\n".join(lines)
329
329
 
330
330
 
331
+ def _merge_verify_timing(timing: dict[str, Any], verify_ms: float) -> None:
332
+ """把 verify 的墙钟并回 timing 的三个总数,保持互相自洽。
333
+
334
+ why:verify 发生在主搜索快照之后。此前只并回 stages_ms/process_ms,
335
+ elapsed_ms 仍是主搜索的数(实测 1173 vs 10967),调用方最先读的总账
336
+ 比它的分项之和还小 8 倍。三个数必须一起走,否则「这次为什么慢」会被
337
+ 带偏到主搜索上。
338
+ """
339
+ total = float(timing.get("stages_ms") or 0) + verify_ms
340
+ timing.setdefault("stages", []).append({
341
+ "stage": "verify",
342
+ "ms": round(verify_ms, 1),
343
+ "pct": round(verify_ms * 100.0 / total, 1) if total > 0 else 0.0,
344
+ })
345
+ timing["stages_ms"] = round(total, 1)
346
+ timing["process_ms"] = round(
347
+ float(timing.get("process_ms") or 0) + verify_ms, 1)
348
+ timing["elapsed_ms"] = round(
349
+ max(float(timing.get("elapsed_ms") or 0), total), 1)
350
+
351
+
331
352
  def main():
332
353
  # 入口第一件事:钉住 stdout/stderr 编码(实现唯一,见 cli_io)。
333
354
  # 放在这里而不是 import 期:本模块被 search.py 与 bin/argo 两条路调用,
@@ -579,19 +600,11 @@ def main():
579
600
  # (process_ms 定格处)之后,净增 280–1160ms 完全不进任何账——实测
580
601
  # wall 776–1746ms vs process_ms 494–701ms,「这次为什么慢」归因指错
581
602
  # 方向。快照后移做不到(verify 是装配后的可选后处理),补一个 stage
582
- # 并把耗时并回 stages_ms / process_ms,两个总数保持自洽。
603
+ # 并把耗时并回三个总数(stages_ms / process_ms / elapsed_ms),
604
+ # 三者保持自洽(2026-10-22 补 elapsed_ms)。
583
605
  if _timing is not None and "timing" in results and args.verify:
584
606
  _verify_ms = (time.perf_counter() - _t_verify0) * 1000.0
585
- _t = results["timing"]
586
- _total = float(_t.get("stages_ms") or 0) + _verify_ms
587
- _t.setdefault("stages", []).append({
588
- "stage": "verify",
589
- "ms": round(_verify_ms, 1),
590
- "pct": round(_verify_ms * 100.0 / _total, 1) if _total > 0 else 0.0,
591
- })
592
- _t["stages_ms"] = round(_total, 1)
593
- _t["process_ms"] = round(
594
- float(_t.get("process_ms") or 0) + _verify_ms, 1)
607
+ _merge_verify_timing(results["timing"], _verify_ms)
595
608
 
596
609
  if args.json_output:
597
610
  public = {k: v for k, v in results.items() if not k.startswith("_")}
@@ -28,7 +28,7 @@ engine_dispatch。
28
28
  from __future__ import annotations
29
29
 
30
30
  import time
31
- from dataclasses import dataclass, field, replace
31
+ from search_types import slot_record as dataclass, slot_field as field, slot_replace as replace
32
32
  from typing import Any, Callable, Optional
33
33
 
34
34
  from engine_dispatch import run_dispatch
@@ -361,6 +361,11 @@ def dispatch(req: _SearchRequest, run: _SearchRun, hooks: _SearchHooks) -> _Sear
361
361
  engine_latency = _dispatch.engine_latency
362
362
  wasted_ms = _dispatch.wasted_ms
363
363
  useful_ms = _dispatch.useful_ms
364
+ dispatch_tail = {
365
+ "first_sufficient_ms": _dispatch.first_sufficient_ms,
366
+ "tail_wait_ms": _dispatch.tail_wait_ms,
367
+ "abandoned_engine_ms": _dispatch.abandoned_engine_ms,
368
+ }
364
369
  early_stopped = _dispatch.early_stopped
365
370
  budget_used_ms = _dispatch.budget_used_ms
366
371
  budget_total_ms = _dispatch.budget_total_ms
@@ -378,6 +383,7 @@ def dispatch(req: _SearchRequest, run: _SearchRun, hooks: _SearchHooks) -> _Sear
378
383
  engine_latency=engine_latency,
379
384
  wasted_ms=wasted_ms,
380
385
  useful_ms=useful_ms,
386
+ dispatch_tail=dispatch_tail,
381
387
  early_stopped=early_stopped,
382
388
  budget_used_ms=budget_used_ms,
383
389
  budget_total_ms=budget_total_ms,
@@ -8,10 +8,23 @@
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from dataclasses import dataclass
12
-
13
11
  from search_rank import filter_results_by_domains
12
+ from search_types import slot_record as dataclass
14
13
  from typing import Any
14
+ from output_transparency import ( # noqa: F401
15
+ _AGGREGATOR_HOSTS,
16
+ _FAILURE_META,
17
+ _FAILURE_NON_ERROR,
18
+ _NON_ERROR_OUTCOME,
19
+ _SEVERITY,
20
+ _UNCERTAINTY_HIGH,
21
+ _UNCERTAINTY_LOW,
22
+ _UNCERTAINTY_MEDIUM,
23
+ _hostname,
24
+ build_failures,
25
+ build_uncertainty,
26
+ decorate_compact_payload,
27
+ )
15
28
 
16
29
 
17
30
  # 本模块的 6 处日志都写在 **except 处理器**里,且分散在 shape_response 的
@@ -72,6 +85,39 @@ _AGENT_RESULT_FIELDS = (
72
85
  )
73
86
 
74
87
 
88
+ def _slim_verify(v: Any) -> dict[str, Any] | None:
89
+ """verify 块的 agent 精简版:保留「核验后改了什么」,丢掉逐条细节。
90
+
91
+ why:`--fields agent` 此前把整个 verify 块剥掉,而这是**唯一**能说明
92
+ 「花几秒核验换来了什么」的字段。保留 revision_summary 与每条 delta,
93
+ 体积约 0.2–0.8 KB;pre/post 绝对分与 title 已在 results[] 可查,不重复。
94
+ """
95
+ if not isinstance(v, dict):
96
+ return None
97
+ out: dict[str, Any] = {}
98
+ for key in ("revision_summary", "pending", "skipped_cached", "semantic"):
99
+ if v.get(key) is not None:
100
+ out[key] = v[key]
101
+ rows = []
102
+ for r in v.get("verified") or []:
103
+ if not isinstance(r, dict):
104
+ continue
105
+ rows.append({k: r[k] for k in
106
+ ("url", "delta", "post_absorption", "fetch_method")
107
+ if r.get(k) is not None})
108
+ if rows:
109
+ out["verified"] = rows
110
+ return out or None
111
+
112
+
113
+ def _slim_reorder(r: Any) -> dict[str, Any] | None:
114
+ """证据回写排序的 agent 精简版:只留结论(是否重排、动了几处)。"""
115
+ if not isinstance(r, dict):
116
+ return None
117
+ out = {k: r[k] for k in ("reordered", "moved") if r.get(k) is not None}
118
+ return out or None
119
+
120
+
75
121
  def _strip_for_agent(payload: dict[str, Any]) -> dict[str, Any]:
76
122
  """--fields agent:输出只留答案内容(P2-2,2026-09-13)。
77
123
 
@@ -93,9 +139,33 @@ def _strip_for_agent(payload: dict[str, Any]) -> dict[str, Any]:
93
139
  # 阶段耗时:默认就带(--no-timing 才没有)。剥掉会让使用者看不到
94
140
  # 「这次慢在哪」,也拿不到自己动手优化所需的依据。
95
141
  "timing",
142
+ # 综合可信度:一个 level 就能决定「直接引用」还是「先核验」。
143
+ # 剥掉它等于让 agent 自己交叉比对来源数/同站/聚合站/引擎失败——
144
+ # 而那正是它最容易漏掉的一步。
145
+ "meta",
146
+ # 结构化失败清单:类别 + 是否可重试 + 建议动作(≤6 条短字段)。
147
+ # 只有 errors 的整行文本时,agent 知道「坏了」但不知道「该重试、
148
+ # 换源还是去配 key」;这两件事的处置完全不同。
149
+ "failures",
150
+ # 信任级别:网页文本一律是不可信外部数据,随答案一起到达,
151
+ # 避免调用方把摘要里的指令当任务执行(输出层声明,非提示词)。
152
+ "content_trust",
153
+ # 核验产出:verify/evidence_reorder 是**花了墙钟换来的**,且是
154
+ # 「该不该引用这批结果」的直接依据。此前被整块剥掉,调用方付了
155
+ # 几秒核验却只看到 verified_count 一个数(2026-10-22 实测)。
156
+ # 值由下方 _slim_verify/_slim_reorder 压缩,不整块照搬。
157
+ "verify", "evidence_reorder",
96
158
  )
97
159
  out: dict[str, Any] = {k: payload[k] for k in keep_top
98
160
  if payload.get(k) is not None}
161
+ for _key, _slim in (("verify", _slim_verify),
162
+ ("evidence_reorder", _slim_reorder)):
163
+ if payload.get(_key) is not None:
164
+ _value = _slim(payload[_key])
165
+ if _value is not None:
166
+ out[_key] = _value
167
+ else:
168
+ out.pop(_key, None)
99
169
  slim_results = []
100
170
  for r in payload.get("results") or []:
101
171
  if not isinstance(r, dict):
@@ -219,7 +289,8 @@ def format_timing(t: dict[str, Any]) -> str:
219
289
  f" 引擎并发 墙钟 {d.get('wall_ms')} ms / 引擎合计 "
220
290
  f"{d.get('engine_sum_ms')} ms → 并发效率 "
221
291
  f"{d.get('parallel_efficiency')}(跑了 {d.get('engines_run')} 个,"
222
- f"早停={d.get('early_stopped')},浪费 {d.get('wasted_ms')} ms)")
292
+ f"早停={d.get('early_stopped')},尾等待 {d.get('tail_wait_ms')} ms,"
293
+ f"弃置 {d.get('abandoned_engine_ms')} ms)")
223
294
  if "overhead_ms" in t:
224
295
  lines.append(
225
296
  f" 固定开销 {t['overhead_ms']} ms"
@@ -331,18 +402,6 @@ def format_text_output(results: dict[str, Any]) -> str:
331
402
  return "\n".join(lines)
332
403
 
333
404
 
334
- # 不算失败的 outcome 状态:这些情况「引擎跑了、没问题」,不该出现在 errors[]
335
- # cancelled = 早停收工时被主动弃置(结果已够,引擎没失败也没超窗)
336
- _NON_ERROR_OUTCOME = frozenset({
337
- "ok", "ok-cached", "partial", "no-results", "no-results-cached",
338
- "cancelled",
339
- # 配额耗尽的跳过是**持续状态**,不是本次查询的失败:标记默认持续到周期
340
- # 边界(一天),若每个查询都把它塞进 errors,调用方会看到一条持续一天的
341
- # 「错误」并误判本次检索降级。状态本身在 --list-engines 可见。
342
- "skipped-quota-exhausted",
343
- })
344
-
345
-
346
405
  def _collect_errors(raw_results: dict[str, list[dict[str, Any]]],
347
406
  engine_outcomes: list[dict[str, Any]] | None = None
348
407
  ) -> list[str]:
@@ -379,6 +438,7 @@ def _collect_errors(raw_results: dict[str, list[dict[str, Any]]],
379
438
  _add(f"{o.get('engine')}: {detail}")
380
439
  return errors
381
440
 
441
+
382
442
  @dataclass(frozen=True)
383
443
  class _ShapeContext:
384
444
  """输出成形的全部选项(请求侧只读 + 一个本地检索钩子)。"""
@@ -603,4 +663,28 @@ def shape_response(ctx: _ShapeContext, result: dict[str, Any]) -> dict[str, Any]
603
663
  # 待核验 0 条」这类自相矛盾的口径。
604
664
  _el["suggested"] = _sug
605
665
  _el["pending_count"] = len(_sug)
666
+ # 综合可信度:读的 results 必须与最终输出同源,所以放在收口(与 count
667
+ # 重算同处)——域过滤与本地命中并入都发生在这之前。只读不写结果,
668
+ # 失败时静默跳过(增强失败不得让一次搜索失败)。
669
+ try:
670
+ result["meta"] = {"uncertainty": build_uncertainty(result)}
671
+ except Exception as e:
672
+ _log(f"可信度评估跳过: {type(e).__name__}")
673
+ # 结构化失败清单:与 meta 同一收口,读的 outcome 与漏斗/可信度同源。
674
+ # 失败时静默跳过(增强失败不得让一次搜索失败)。
675
+ try:
676
+ failures = build_failures(result)
677
+ if failures:
678
+ result["failures"] = failures
679
+ except Exception as e:
680
+ _log(f"失败清单跳过: {type(e).__name__}")
681
+ # 信任级别与边界清洗:所有入口(full/agent/MCP)共用同一个收口,
682
+ # 避免 CLI/agent 档带 content_trust 却未剥离网页自带的 </untrusted-web-content>。
683
+ try:
684
+ from content_boundary import (CONTENT_TRUST_UNTRUSTED,
685
+ sanitize_results_boundary)
686
+ sanitize_results_boundary(result.get("results"))
687
+ result["content_trust"] = CONTENT_TRUST_UNTRUSTED
688
+ except Exception:
689
+ pass
606
690
  return result