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,90 @@
1
+ #!/usr/bin/env python3
2
+ """漂移阈值判定:把夜间「重录 vs 基线」的比较结果变成退出码。
3
+
4
+ ## 为什么单独成文件
5
+
6
+ 判定逻辑原来写在 nightly.yml 的 heredoc 里:语法对错只能在推上去之后知道,
7
+ 阈值改松改紧也没人测。抽出来后有两个好处——本地能跑同一套判定,用例能锁住
8
+ 「跌多少算跌」这条契约(tests/test_ci_workflows.py)。
9
+
10
+ ## 判什么、为什么只判这两样
11
+
12
+ - **结果条数**:少 1 条允许(工具源单条抖动很常见),少 2 条以上算漂移;
13
+ - **输出体积**:超过基线 20% 算漂移(体积是 Agent 真金白银付的上下文)。
14
+
15
+ `not_recorded`(重放时要了一个当时没调过的引擎)**不计入**:录制只保留真正
16
+ 被调到的引擎,而重放是瞬时完成,早停时机与真跑不同,这是录制机制的正常边界,
17
+ 当成回归报会制造长期噪音。它仍然会打印出来,供人看。
18
+
19
+ 用法:
20
+ python3 scripts/ci_drift_gate.py --baseline 基线报告.json --current 本次报告.json
21
+ 退出码:0 通过;1 有漂移;2 输入缺失/格式不对(这两件事必须分开,混在一起
22
+ 会让人以为「红了=质量掉了」)。
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ KEPT_SLACK = 1 # 允许少 1 条
34
+ BYTES_GROWTH = 1.2 # 体积上限 = 基线 × 1.2
35
+
36
+
37
+ def _load(path: Path) -> dict[str, Any]:
38
+ doc = json.loads(path.read_text(encoding="utf-8"))
39
+ # save_baseline 存的是 {"_meta":…, "report":{…}};evaluate_all 直接给报告本体
40
+ return doc.get("report", doc)
41
+
42
+
43
+ def violations(base: dict[str, Any], cur: dict[str, Any]) -> list[str]:
44
+ by_id = {c.get("id"): c for c in (base.get("cases") or [])}
45
+ cur_ids = {c.get("id") for c in (cur.get("cases") or [])}
46
+ out: list[str] = []
47
+ for case in cur.get("cases") or []:
48
+ old = by_id.get(case.get("id"))
49
+ if not old:
50
+ continue # 新增的用例没有基线,不算漂移
51
+ cid = case.get("id")
52
+ kept, old_kept = case.get("kept", 0), old.get("kept", 0)
53
+ if kept < old_kept - KEPT_SLACK:
54
+ out.append(f"{cid}: 结果 {kept} 条,基线 {old_kept} 条")
55
+ old_bytes = old.get("bytes", 0)
56
+ if old_bytes and case.get("bytes", 0) > old_bytes * BYTES_GROWTH:
57
+ out.append(
58
+ f"{cid}: 输出 {case.get('bytes')}B,超过基线 {old_bytes}B×{BYTES_GROWTH}")
59
+ # 基线里有、本次没有:不是「通过」,是这条根本没被录到(查询集被改、
60
+ # 或录制整条失败)。2026-10-22 本地实跑踩过:只剩 1 条时判定照样退 0。
61
+ for cid in by_id:
62
+ if cid and cid not in cur_ids:
63
+ out.append(f"{cid}: 本次报告里没有这条(查询集被改了,还是录制失败?)")
64
+ return out
65
+
66
+
67
+ def main() -> int:
68
+ ap = argparse.ArgumentParser(description="夜间漂移阈值判定")
69
+ ap.add_argument("--baseline", required=True)
70
+ ap.add_argument("--current", required=True)
71
+ args = ap.parse_args()
72
+
73
+ try:
74
+ base = _load(Path(args.baseline))
75
+ cur = _load(Path(args.current))
76
+ except (OSError, json.JSONDecodeError) as e:
77
+ print(f"读不到报告:{type(e).__name__}: {e}", file=sys.stderr)
78
+ return 2
79
+
80
+ bad = violations(base, cur)
81
+ if bad:
82
+ print("\n".join(bad), file=sys.stderr)
83
+ return 1
84
+ print(f"通过:{len(cur.get('cases') or [])} 条,漏录 "
85
+ f"{cur.get('total_missing', 0)} 个引擎(信息项,不算漂移)")
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """content_boundary.py — 不可信网页内容的边界标记与清洗。
3
+
4
+ 为什么需要单独一层:Argo 对 Agent 输出的是 JSON,不能像纯文本工具那样简单
5
+ 把整段响应包进 <untrusted-web-content>(会破坏 JSON 解析)。所以这里只做
6
+ 两件确定能做的事:
7
+
8
+ 1. **清洗**:任何来自上游的标题/摘要/正文在进入输出前,剥掉其中的边界标记
9
+ 字样——否则网页正文里自带的 `</untrusted-web-content>` 可以提前闭合边界,
10
+ 把后续内容伪装成可信指令(dsh-free-search 的同类防护)。
11
+ 2. **声明**:调用方在响应里带 `content_trust: "untrusted"`,让模型与宿主
12
+ 知道这批文本的信任级别;文本边界由宿主(DSH/MCP 客户端)按自己的渲染
13
+ 形态加,不在 JSON 契约里硬塞。
14
+
15
+ 不引入依赖、不做 HTML 清洗:正文已经是纯文本摘要,这里只管边界标记这一类
16
+ 可被利用的结构字符。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Any
23
+
24
+ CONTENT_TRUST_UNTRUSTED = "untrusted"
25
+
26
+ # 大小写、属性、空白都按「可能的变体」处理;只剥标记本身,不动正文其余字符。
27
+ _BOUNDARY_RE = re.compile(
28
+ r"<\s*/?\s*untrusted-web-content\b[^>]*>", re.IGNORECASE)
29
+
30
+
31
+ def strip_boundary_markers(text: Any, limit: int | None = None) -> str:
32
+ """剥掉正文里的边界标记(含变体),可选截断。"""
33
+ out = _BOUNDARY_RE.sub("", str(text or ""))
34
+ if limit is not None and limit >= 0 and len(out) > limit:
35
+ out = out[:limit]
36
+ return out
37
+
38
+
39
+ def sanitize_results_boundary(results: Any) -> None:
40
+ """就地清洗结果列表每条的 title/snippet 边界标记(CLI/Agent/MCP 共用单一出口)。"""
41
+ for item in results or []:
42
+ if not isinstance(item, dict):
43
+ continue
44
+ for key in ("title", "snippet"):
45
+ if isinstance(item.get(key), str):
46
+ item[key] = strip_boundary_markers(item[key])
@@ -492,9 +492,16 @@ def score_clickbait(title: str) -> dict:
492
492
  score += 0.08
493
493
  hits.append("year_prefix")
494
494
  # 利益承诺:「看完…你就会」「建议收藏」「一篇搞懂」
495
- if re.search(r"(看完|建议收藏|建议收藏|一篇(?:讲|说|搞懂)|全解析|必看|建议收藏)", t):
495
+ if re.search(r"(看完|建议收藏|一篇(?:讲|说|搞懂)|全解析|必看)", t):
496
496
  score += 0.10
497
497
  hits.append("benefit_promise")
498
+ # 虚假实测 / 承诺尾钩
499
+ if re.search(r"自费\s*\d+\s*元?实测", t):
500
+ score += 0.25
501
+ hits.append("fake_testimony")
502
+ if re.search(r"看完.*(?:你就懂了|你就会)", t):
503
+ score += 0.15
504
+ hits.append("clickbait_promise")
498
505
 
499
506
  # 标点:仅在已有命中时作**放大器**(PACLIC 的误杀教训)
500
507
  if score > 0:
@@ -595,13 +602,27 @@ def score_template_repetition(content: str) -> dict:
595
602
  这里是接入点,且标定集能立刻给出 before/after。
596
603
  """
597
604
  text = (content or "").strip()
598
- if len(text) < 120:
605
+ # ── 序数连接词与流水线套话检测(对抗现代批量模板/AI农场)──
606
+ connectors = ("首先", "其次", "然后", "接下来", "最后", "综上所述")
607
+ hit_conn = sum(1 for w in connectors if w in text)
608
+ ai_phrases = ("业内人士表示", "专家分析", "消费者认为", "数据显示", "有望成为")
609
+ hit_ai = sum(1 for w in ai_phrases if w in text)
610
+ conn_boost = 0.0
611
+ if hit_conn >= 4:
612
+ conn_boost = 0.85
613
+ elif hit_conn >= 3 and hit_ai >= 1:
614
+ conn_boost = 0.80
615
+ elif hit_ai >= 3:
616
+ conn_boost = 0.78
617
+
618
+ if len(text) < 120 and conn_boost == 0.0:
599
619
  return {"score": 0.0, "templates": 0, "sentences": 0, "repetition": 0.0}
600
620
  # 分句:中英标点 + 换行
601
621
  sents = [s.strip() for s in re.split(r"[。!?!?;;\n]+", text) if len(s.strip()) >= 6]
602
622
  if len(sents) < 4:
603
- return {"score": 0.0, "templates": len(sents), "sentences": len(sents),
604
- "repetition": 0.0}
623
+ base_rep = conn_boost
624
+ return {"score": round(base_rep, 3), "templates": len(sents), "sentences": len(sents),
625
+ "repetition": round(base_rep, 3)}
605
626
  sigs: list[str] = []
606
627
  for s in sents:
607
628
  head = s[:2]
@@ -610,7 +631,7 @@ def score_template_repetition(content: str) -> dict:
610
631
  bucket = "S" if len(s) < 25 else ("M" if len(s) < 60 else "L")
611
632
  sigs.append(f"{head}|{tail}|{bucket}")
612
633
  uniq = len(set(sigs))
613
- repetition = 1.0 - uniq / len(sigs)
634
+ repetition = max(1.0 - uniq / len(sigs), conn_boost)
614
635
  return {
615
636
  "score": round(min(repetition, 1.0), 3),
616
637
  "templates": uniq,
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """cross_lingual_trace.py — 跨语言长程搜索与多源任务溯源引擎。
3
+
4
+ 架构定位:
5
+ 解耦自 research.py 与 wide_research.py。当单语言(如中文或特定小语种)
6
+ 信息贫瘠、被低质农场包围,或任务明确要求事实溯源(源头论文、首发漏洞、
7
+ 规范草案、最初作者、原始出处)时,自动触发跨语言长程追踪(Pivot & Trace),
8
+ 并进行跨语言证据交叉比对(Reconciliation & Disagreement Check)。
9
+
10
+ 核心阶段:
11
+ 1. 需求与充分性判定(assess_cross_lingual_need):
12
+ 检测结果稀疏度、权威源覆盖率以及溯源关键词(起源/出处/最早/first proposed 等)。
13
+ 2. 跨语言实体锚点与溯源子查询生成(build_cross_lingual_trace_queries):
14
+ 将查询投影至目标语种(以英文为主,或日/德/法垂直语境),生成多阶段溯源子查询。
15
+ 3. 跨语言多源证据对账与综合(synthesize_cross_lingual_evidence):
16
+ 将跨语言采集回来的结果与原语言结果按一手源等级、时序和互补关系对齐,
17
+ 沉淀出 primary_origins(一手源)、complements(互补证据)与 disagreements(口径差异)。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import Any
24
+
25
+
26
+ # 溯源与跨语言强触发词
27
+ _TRACING_KEYWORDS = (
28
+ "溯源", "起源", "出处", "最初", "源头", "提出者", "发明", "草案",
29
+ "原型", "首发", "首个", "第一篇", "历史沿革", "来龙去脉",
30
+ "origin", "provenance", "root cause", "first proposed", "author of",
31
+ "initial draft", "whitepaper", "specification", "cve-", "rfc "
32
+ )
33
+
34
+ _SOURCE_TYPE_RANKS = {
35
+ "official": 4,
36
+ "academic": 4,
37
+ "standard": 4,
38
+ "repository": 3,
39
+ "reference": 3,
40
+ "news": 2,
41
+ "community": 2,
42
+ "general": 1,
43
+ }
44
+
45
+
46
+ def assess_cross_lingual_need(
47
+ query: str,
48
+ results: list[dict[str, Any]] | None,
49
+ domain: str = "",
50
+ min_sufficient_results: int = 3,
51
+ force_trace: bool = False,
52
+ ) -> dict[str, Any]:
53
+ """评估是否需要启动跨语言长程搜索与任务溯源。
54
+
55
+ 触发条件(满足任意一条):
56
+ 1. 显式开启 force_trace 或查询包含溯源关键词;
57
+ 2. 结果稀疏(有效结果 < min_sufficient_results);
58
+ 3. 高置信度/权威一手信源缺失(authority_level >= 3 的结果为 0);
59
+ 4. 强跨语言领域(security_search, academic, package_intel, rfc)。
60
+ """
61
+ q_low = (query or "").lower()
62
+ has_tracing_keyword = any(kw in q_low for kw in _TRACING_KEYWORDS)
63
+
64
+ valid_results = [r for r in (results or []) if isinstance(r, dict)]
65
+ res_count = len(valid_results)
66
+
67
+ high_auth_count = sum(
68
+ 1 for r in valid_results
69
+ if float(r.get("authority") or r.get("score") or 0.0) >= 0.7
70
+ or int(r.get("authority_level") or 0) >= 3
71
+ )
72
+
73
+ domain_low = (domain or "").lower()
74
+ is_cross_lingual_domain = any(
75
+ d in domain_low for d in ("security", "academic", "package", "rfc", "macro", "us_stock")
76
+ )
77
+
78
+ is_sparse = res_count < min_sufficient_results
79
+ no_authoritative = (res_count > 0 and high_auth_count == 0)
80
+
81
+ needed = force_trace or has_tracing_keyword or is_sparse or no_authoritative or (
82
+ is_cross_lingual_domain and high_auth_count < 2
83
+ )
84
+
85
+ reasons: list[str] = []
86
+ if force_trace:
87
+ reasons.append("显式指定跨语言长程溯源")
88
+ if has_tracing_keyword:
89
+ reasons.append("命中溯源意图关键词")
90
+ if is_sparse:
91
+ reasons.append(f"原语言结果不足(仅 {res_count} 条,门槛 {min_sufficient_results})")
92
+ if no_authoritative:
93
+ reasons.append("原语言缺乏高置信度一手权威信源")
94
+ if is_cross_lingual_domain:
95
+ reasons.append(f"领域「{domain}」天然具备跨语言溯源需求")
96
+
97
+ target_langs = ["en"]
98
+ if re.search(r"[\u3040-\u309f\u30a0-\u30ff]", query):
99
+ target_langs = ["en", "zh"]
100
+ elif re.search(r"(日企|日本|动漫|acg|丰田|索尼|任天堂)", query, re.I):
101
+ target_langs = ["en", "ja"]
102
+
103
+ return {
104
+ "needed": bool(needed),
105
+ "target_langs": target_langs,
106
+ "is_tracing": bool(has_tracing_keyword or force_trace),
107
+ "is_sparse": bool(is_sparse),
108
+ "reasons": reasons,
109
+ "high_auth_count": high_auth_count,
110
+ "result_count": res_count,
111
+ }
112
+
113
+
114
+ def _extract_core_entity(query: str) -> str:
115
+ """从复杂查询中提取核心溯源实体(英文锚点或关键名词)。"""
116
+ q = (query or "").strip()
117
+ # 优先抽取其中的西文标识(如 CVE-2024-1234, React, Transformer, SQLite 等)
118
+ ascii_matches = re.findall(r"[A-Za-z0-9_\-\.\:\/]{3,}", q)
119
+ if ascii_matches:
120
+ # 过滤掉通用词
121
+ clean_terms = [m for m in ascii_matches if m.lower() not in ("what", "how", "why", "when", "with")]
122
+ if clean_terms:
123
+ return " ".join(clean_terms[:3])
124
+
125
+ # 如果纯 CJK,剥离常见的动词与提问词
126
+ cleaned = re.sub(
127
+ r"(如何|怎么|为什么|溯源|起源|出处|最初|源头|提出者|发明|的来历|是谁|什么时候|最新|评价)",
128
+ " ",
129
+ q,
130
+ ).strip()
131
+ words = [w for w in re.split(r"\s+", cleaned) if len(w) >= 2]
132
+ return words[0] if words else q
133
+
134
+
135
+ def build_cross_lingual_trace_queries(
136
+ query: str,
137
+ target_lang: str = "en",
138
+ focus: str = "comprehensive",
139
+ ) -> list[dict[str, str]]:
140
+ """生成分阶段跨语言长程溯源子查询。
141
+
142
+ 返回结构化的子查询列表,标注意图(intent)、策略(strategy)与语言标记。
143
+ """
144
+ entity = _extract_core_entity(query)
145
+ subs: list[dict[str, str]] = []
146
+
147
+ if target_lang == "en":
148
+ # 阶段 1:一手原始出处与概念提出(Origin & Inception)
149
+ subs.append({
150
+ "query": f"{entity} original paper first proposed inception origin",
151
+ "intent": "原始论文/源头提出者与发布背景溯源",
152
+ "strategy": "origin_trace",
153
+ "lang": "en",
154
+ })
155
+ # 阶段 2:规范、代码仓与官方权威文档(Specification & Implementation)
156
+ subs.append({
157
+ "query": f"{entity} official repository documentation specification RFC",
158
+ "intent": "官方规范、权威仓库与技术标准追踪",
159
+ "strategy": "primary_spec",
160
+ "lang": "en",
161
+ })
162
+ # 阶段 3:演进考证与关键争议(Evolution & Historical Milestone)
163
+ subs.append({
164
+ "query": f"{entity} milestone evolution history benchmark analysis",
165
+ "intent": "历史演进里程碑与事实交叉考证",
166
+ "strategy": "cross_check",
167
+ "lang": "en",
168
+ })
169
+ elif target_lang == "ja":
170
+ subs.append({
171
+ "query": f"{entity} 開発経緯 歴史 初出 仕様",
172
+ "intent": "日文原始出处与开发历史溯源",
173
+ "strategy": "origin_trace",
174
+ "lang": "ja",
175
+ })
176
+ subs.append({
177
+ "query": f"{entity} 公式ドキュメント 仕様書 論文",
178
+ "intent": "日文官方规范与文档追踪",
179
+ "strategy": "primary_spec",
180
+ "lang": "ja",
181
+ })
182
+ else:
183
+ subs.append({
184
+ "query": f"{entity} origin official source history",
185
+ "intent": f"{target_lang} 语种源头与官方文档追踪",
186
+ "strategy": "origin_trace",
187
+ "lang": target_lang,
188
+ })
189
+
190
+ return subs
191
+
192
+
193
+ def _classify_source_nature(url: str, title: str) -> str:
194
+ """根据 URL 和标题对信源进行性质分类。"""
195
+ u_low = (url or "").lower()
196
+ t_low = (title or "").lower()
197
+
198
+ if any(h in u_low for h in ("arxiv.org", "doi.org", "acm.org", "ieee.org", "biorxiv.org", "semanticscholar.org")):
199
+ return "academic"
200
+ if any(h in u_low for h in ("github.com", "gitlab.com", "bitbucket.org", "git.kernel.org")):
201
+ return "repository"
202
+ if any(h in u_low for h in ("rfc-editor.org", "w3.org", "iso.org", "ecma-international.org", "cve.mitre.org", "nvd.nist.gov")):
203
+ return "standard"
204
+ if any(h in u_low for h in (".gov", ".edu", "europa.eu", "who.int", "un.org")):
205
+ return "official"
206
+ if any(h in u_low for h in ("wikipedia.org", "wikidata.org", "baike.baidu.com")):
207
+ return "reference"
208
+ if any(k in t_low for k in ("official", "announcement", "release note", "white paper", "specification")):
209
+ return "official"
210
+ return "general"
211
+
212
+
213
+ def synthesize_cross_lingual_evidence(
214
+ primary_results: list[dict[str, Any]],
215
+ cross_results: list[dict[str, Any]],
216
+ query: str,
217
+ ) -> dict[str, Any]:
218
+ """将原语言与跨语言长程检索证据进行交叉对账。
219
+
220
+ 产出:
221
+ - primary_origins: 权威一手信源(论文、代码仓、标准机构)
222
+ - complements: 跨语言带来的增量补充事实
223
+ - disagreements: 跨语言比对中发现的时间/口径分歧
224
+ - provenance_chain: 结构化溯源脉络
225
+ """
226
+ seen_urls: set[str] = set()
227
+ primary_origins: list[dict[str, Any]] = []
228
+ complements: list[dict[str, Any]] = []
229
+ disagreements: list[str] = []
230
+
231
+ all_cross = [r for r in cross_results if isinstance(r, dict)]
232
+
233
+ # 1. 甄选跨语言一手源
234
+ for r in all_cross:
235
+ u = r.get("url") or ""
236
+ if not u or u in seen_urls:
237
+ continue
238
+ seen_urls.add(u)
239
+ t = r.get("title") or ""
240
+ nature = _classify_source_nature(u, t)
241
+ entry = {
242
+ "title": t,
243
+ "url": u,
244
+ "snippet": r.get("snippet") or "",
245
+ "nature": nature,
246
+ "source": r.get("source") or "",
247
+ "authority_rank": _SOURCE_TYPE_RANKS.get(nature, 1),
248
+ }
249
+ if nature in ("academic", "standard", "official", "repository"):
250
+ primary_origins.append(entry)
251
+ else:
252
+ complements.append(entry)
253
+
254
+ # 排序:权威一手源排在最前
255
+ primary_origins.sort(key=lambda x: x["authority_rank"], reverse=True)
256
+
257
+ # 2. 检查年份/时间戳分歧
258
+ year_re = re.compile(r"\b(19\d{2}|20\d{2})\b")
259
+ prim_years = set()
260
+ for r in primary_results:
261
+ text = f"{r.get('title', '')} {r.get('snippet', '')}"
262
+ prim_years.update(year_re.findall(text))
263
+
264
+ cross_years = set()
265
+ for r in all_cross:
266
+ text = f"{r.get('title', '')} {r.get('snippet', '')}"
267
+ cross_years.update(year_re.findall(text))
268
+
269
+ if prim_years and cross_years and not (prim_years & cross_years):
270
+ disagreements.append(
271
+ f"跨语言时间线存在差异:原语言提及时段 {sorted(prim_years)},而外文一手源提及时段 {sorted(cross_years)}"
272
+ )
273
+
274
+ # 3. 构造溯源链条
275
+ provenance_chain: list[dict[str, str]] = []
276
+ for origin in primary_origins[:4]:
277
+ provenance_chain.append({
278
+ "stage": "一手信源定位",
279
+ "source_type": origin["nature"],
280
+ "title": origin["title"][:80],
281
+ "url": origin["url"],
282
+ "evidence": origin["snippet"][:140],
283
+ })
284
+
285
+ return {
286
+ "status": "completed" if (primary_origins or complements) else "no_incremental_evidence",
287
+ "primary_origins": primary_origins[:6],
288
+ "complements_count": len(complements),
289
+ "complements": complements[:6],
290
+ "disagreements": disagreements,
291
+ "provenance_chain": provenance_chain,
292
+ "total_cross_sources": len(all_cross),
293
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """dispatch_result.py — 引擎编排的产出对象与墙钟记账(2026-10-21 从 engine_dispatch 拆出)。
3
+
4
+ 拆出原因:module size 门禁要求 engine_dispatch 只能减不能增,而 2026-10-21 的
5
+ 尾等待记账需要同时扩数据类与汇总逻辑。两者都是**纯数据**(无 IO、无调度依赖),
6
+ 放在调度器里只会让「调度策略」和「记账口径」继续互相遮挡。
7
+
8
+ 记账口径(为什么不是一个数):
9
+ - `useful_ms`:最后一个**贡献结果**的引擎完成时刻(答案从这一刻起已在手里)。
10
+ - `first_sufficient_ms`:早停判据首次成立(结果够用)的时刻;no_early 域没有
11
+ 这个时刻,保持 0。
12
+ - `tail_wait_ms`:结果已可交付后仍在等的时间。有早停时刻用它算,没有就用
13
+ 「首条结果时刻」算——后者正是 no_early 域白等的来源。
14
+ - `abandoned_engine_ms`:被早停弃置的引擎已花的墙钟**之和**。它是并行资源
15
+ 浪费,不等于用户多等的时间(两个引擎并行跑到最后,赢家交结果、输家被弃置,
16
+ 用户没有任何额外等待)。`wasted_ms` 保留为它的别名,兼容旧读法。
17
+
18
+ 历史教训:旧 `wasted_ms` 口径换过三次,前两版分别是「非 ok 引擎 latency 之和」
19
+ (并行时必然大于墙钟)与 `wall - useful`(有界并发下恒为 0)。两版都让仪表看
20
+ 起来正常、却无法指向任何动作。这次拆成两个互不重叠的量,各自对应一个旋钮:
21
+ tail 指向收工时机,abandoned 指向并发/对冲预算。
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ __all__ = ["DispatchResult", "summarize_dispatch"]
29
+
30
+
31
+ class DispatchResult:
32
+ """编排产出 + 两个钩子(融合后仍要补搜的路径用)。
33
+
34
+ `run_one` / `ingest` 必须外露:macro_data 域的 D6 证据下限补搜在融合之后,
35
+ 它要跑单个引擎并把结果并进同一份账(raw_results / engine_outcomes /
36
+ engine_latency / abandoned 计数),另起一份会让 engines_used 与配额记账对不上。
37
+
38
+ `budget_used_ms` / `budget_total_ms`:本次编排的实际墙钟消耗与总预算
39
+ (deep 等无预算模式 total 为 None)。搜索输出把它挂进 timing.budget,
40
+ 让「这次离预算上限还有多远」可观测,而不必反推 process_ms。
41
+
42
+ 记账字段的语义与历史见模块 docstring;`wasted_ms` 仅为兼容别名。
43
+ """
44
+
45
+ __slots__ = ("raw_results", "engine_outcomes", "engine_latency",
46
+ "wasted_ms", "early_stopped", "run_one", "ingest",
47
+ "budget_used_ms", "budget_total_ms", "useful_ms",
48
+ "first_sufficient_ms", "tail_wait_ms", "abandoned_engine_ms")
49
+
50
+ def __init__(self, raw_results: Any, engine_outcomes: Any,
51
+ engine_latency: Any, wasted_ms: int, early_stopped: bool,
52
+ run_one: Any, ingest: Any, budget_used_ms: int | None = None,
53
+ budget_total_ms: int | None = None, useful_ms: int = 0,
54
+ first_sufficient_ms: int = 0, tail_wait_ms: int = 0,
55
+ abandoned_engine_ms: int | None = None) -> None:
56
+ self.raw_results = raw_results
57
+ self.engine_outcomes = engine_outcomes
58
+ self.engine_latency = engine_latency
59
+ self.wasted_ms = wasted_ms
60
+ self.early_stopped = early_stopped
61
+ self.run_one = run_one
62
+ self.ingest = ingest
63
+ self.budget_used_ms = budget_used_ms
64
+ self.budget_total_ms = budget_total_ms
65
+ self.useful_ms = useful_ms
66
+ self.first_sufficient_ms = first_sufficient_ms
67
+ self.tail_wait_ms = tail_wait_ms
68
+ # 旧调用方只传 wasted_ms:别名取同一值,不引入第二个真源。
69
+ self.abandoned_engine_ms = (wasted_ms if abandoned_engine_ms is None
70
+ else abandoned_engine_ms)
71
+
72
+
73
+ def summarize_dispatch(*, wall_ms: int, contributed_ms: list[float],
74
+ first_result_at: float | None,
75
+ first_sufficient_at: float | None,
76
+ base: float, abandoned_ms: list[float]) -> dict[str, int]:
77
+ """把调度器的事件时刻折算成对外记账字段(纯函数,便于单测)。
78
+
79
+ `base` 是本次编排的单调钟起点;两个 `*_at` 都是绝对时刻。没有可交付结果
80
+ (全失败)时尾等待记 0:没有东西可交付,那段等待不是「可省的」。
81
+ """
82
+ useful_ms = int(min(max(contributed_ms), wall_ms)) if contributed_ms else 0
83
+
84
+ def _rel(at: float | None) -> int:
85
+ if at is None:
86
+ return 0
87
+ return int(min(max(at - base, 0.0), wall_ms / 1000.0) * 1000)
88
+
89
+ first_result_ms = _rel(first_result_at)
90
+ first_sufficient_ms = _rel(first_sufficient_at)
91
+ anchor = first_sufficient_ms or first_result_ms
92
+ return {
93
+ "useful_ms": useful_ms,
94
+ "first_result_ms": first_result_ms,
95
+ "first_sufficient_ms": first_sufficient_ms,
96
+ "tail_wait_ms": max(0, wall_ms - anchor) if anchor else 0,
97
+ "abandoned_engine_ms": int(sum(abandoned_ms)),
98
+ }