opencode-skills-collection 4.0.69 → 4.0.70

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 (185) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +39 -1
  2. package/bundled-skills/api-integration-architect/SKILL.md +241 -0
  3. package/bundled-skills/apify-generate-output-schema/SKILL.md +438 -0
  4. package/bundled-skills/apify-integration-development/SKILL.md +168 -0
  5. package/bundled-skills/apify-integration-development/references/ai-framework-package.md +158 -0
  6. package/bundled-skills/apify-integration-development/references/ai-harness-plugin.md +192 -0
  7. package/bundled-skills/apify-integration-development/references/sdk-integration.md +236 -0
  8. package/bundled-skills/apify-integration-development/references/workflow-automation.md +163 -0
  9. package/bundled-skills/architecture-review/README.md +42 -0
  10. package/bundled-skills/architecture-review/SKILL.md +77 -0
  11. package/bundled-skills/architecture-review/examples.md +11 -0
  12. package/bundled-skills/architecture-review/reference/best-practices.md +7 -0
  13. package/bundled-skills/architecture-review/reference/capabilities.md +20 -0
  14. package/bundled-skills/architecture-review/reference/fallbacks.md +11 -0
  15. package/bundled-skills/architecture-review/reference/graph.md +15 -0
  16. package/bundled-skills/architecture-review/reference/mcp.md +14 -0
  17. package/bundled-skills/architecture-review/reference/workflow.md +15 -0
  18. package/bundled-skills/architecture-review/templates/architecture-review.md +21 -0
  19. package/bundled-skills/code-review-sensei/SKILL.md +177 -0
  20. package/bundled-skills/codebase-onboarding/README.md +42 -0
  21. package/bundled-skills/codebase-onboarding/SKILL.md +77 -0
  22. package/bundled-skills/codebase-onboarding/examples.md +11 -0
  23. package/bundled-skills/codebase-onboarding/reference/best-practices.md +7 -0
  24. package/bundled-skills/codebase-onboarding/reference/capabilities.md +20 -0
  25. package/bundled-skills/codebase-onboarding/reference/fallbacks.md +11 -0
  26. package/bundled-skills/codebase-onboarding/reference/graph.md +15 -0
  27. package/bundled-skills/codebase-onboarding/reference/mcp.md +14 -0
  28. package/bundled-skills/codebase-onboarding/reference/workflow.md +15 -0
  29. package/bundled-skills/codebase-onboarding/templates/repository-onboarding.md +21 -0
  30. package/bundled-skills/connection-auth-rules/SKILL.md +199 -0
  31. package/bundled-skills/connection-auth-rules/fetch_schema.py +320 -0
  32. package/bundled-skills/dependency-analysis/README.md +42 -0
  33. package/bundled-skills/dependency-analysis/SKILL.md +76 -0
  34. package/bundled-skills/dependency-analysis/examples.md +11 -0
  35. package/bundled-skills/dependency-analysis/reference/best-practices.md +7 -0
  36. package/bundled-skills/dependency-analysis/reference/capabilities.md +20 -0
  37. package/bundled-skills/dependency-analysis/reference/fallbacks.md +11 -0
  38. package/bundled-skills/dependency-analysis/reference/graph.md +15 -0
  39. package/bundled-skills/dependency-analysis/reference/mcp.md +14 -0
  40. package/bundled-skills/dependency-analysis/reference/workflow.md +15 -0
  41. package/bundled-skills/dependency-analysis/templates/dependency-review.md +21 -0
  42. package/bundled-skills/devops-pipeline-builder/SKILL.md +200 -0
  43. package/bundled-skills/eas-app-stores/SKILL.md +197 -0
  44. package/bundled-skills/eas-app-stores/agents/openai.yaml +4 -0
  45. package/bundled-skills/eas-app-stores/references/app-store-metadata.md +497 -0
  46. package/bundled-skills/eas-app-stores/references/ios-app-store.md +376 -0
  47. package/bundled-skills/eas-app-stores/references/native-ios.md +167 -0
  48. package/bundled-skills/eas-app-stores/references/play-store.md +244 -0
  49. package/bundled-skills/eas-app-stores/references/testflight.md +62 -0
  50. package/bundled-skills/eas-app-stores/references/workflows.md +120 -0
  51. package/bundled-skills/eas-hosting/SKILL.md +448 -0
  52. package/bundled-skills/eas-hosting/agents/openai.yaml +4 -0
  53. package/bundled-skills/eas-observe/SKILL.md +75 -0
  54. package/bundled-skills/eas-observe/agents/openai.yaml +4 -0
  55. package/bundled-skills/eas-observe/references/metrics.md +98 -0
  56. package/bundled-skills/eas-observe/references/queries.md +403 -0
  57. package/bundled-skills/eas-observe/references/setup.md +476 -0
  58. package/bundled-skills/eas-observe/references/third-party.md +136 -0
  59. package/bundled-skills/eas-simulator/SKILL.md +251 -0
  60. package/bundled-skills/eas-simulator/agents/openai.yaml +4 -0
  61. package/bundled-skills/eas-simulator/references/controllers.md +135 -0
  62. package/bundled-skills/eas-simulator/references/run-your-app.md +240 -0
  63. package/bundled-skills/eas-simulator/references/troubleshooting.md +47 -0
  64. package/bundled-skills/eas-workflows/SKILL.md +119 -0
  65. package/bundled-skills/eas-workflows/agents/openai.yaml +4 -0
  66. package/bundled-skills/eas-workflows/scripts/fetch.js +109 -0
  67. package/bundled-skills/expo-animation/LICENSE +21 -0
  68. package/bundled-skills/expo-animation/RECIPES.md +385 -0
  69. package/bundled-skills/expo-animation/SKILL.md +295 -0
  70. package/bundled-skills/expo-animation/agents/openai.yaml +4 -0
  71. package/bundled-skills/fact-check-x-unified/SKILL.md +178 -0
  72. package/bundled-skills/fact-check-x-unified/agents/openai.yaml +4 -0
  73. package/bundled-skills/fact-check-x-unified/references/acceptance-criteria.md +44 -0
  74. package/bundled-skills/fact-check-x-unified/references/contracts.md +39 -0
  75. package/bundled-skills/fact-check-x-unified/scripts/common.py +31 -0
  76. package/bundled-skills/fact-check-x-unified/scripts/fact_check_x.py +1832 -0
  77. package/bundled-skills/fact-check-x-unified/scripts/trusted_search_config.py +324 -0
  78. package/bundled-skills/fact-check-x-unified/tests/anchor_downgrade_test.py +90 -0
  79. package/bundled-skills/fact-check-x-unified/tests/multi_platform_test.py +369 -0
  80. package/bundled-skills/fact-check-x-unified/tests/smoke_test.py +740 -0
  81. package/bundled-skills/fact-check-x-unified/tests/stage_checkpoint_test.py +103 -0
  82. package/bundled-skills/fact-check-x-unified/tests/trusted_search_config_test.py +156 -0
  83. package/bundled-skills/gpt-taste/SKILL.md +8 -1
  84. package/bundled-skills/hf-cli/SKILL.md +263 -0
  85. package/bundled-skills/huggingface-community-evals/SKILL.md +228 -0
  86. package/bundled-skills/huggingface-community-evals/examples/.env.example +3 -0
  87. package/bundled-skills/huggingface-community-evals/examples/USAGE_EXAMPLES.md +101 -0
  88. package/bundled-skills/huggingface-community-evals/scripts/inspect_eval_uv.py +104 -0
  89. package/bundled-skills/huggingface-community-evals/scripts/inspect_vllm_uv.py +306 -0
  90. package/bundled-skills/huggingface-community-evals/scripts/lighteval_vllm_uv.py +297 -0
  91. package/bundled-skills/huggingface-datasets/SKILL.md +130 -0
  92. package/bundled-skills/jev-social/SKILL.md +182 -0
  93. package/bundled-skills/longbridge-derivatives/SKILL.md +117 -0
  94. package/bundled-skills/longbridge-derivatives/references/option.md +36 -0
  95. package/bundled-skills/longbridge-derivatives/references/options-advanced.md +101 -0
  96. package/bundled-skills/longbridge-derivatives/references/options-pnl.md +74 -0
  97. package/bundled-skills/longbridge-derivatives/references/options-strategy.md +82 -0
  98. package/bundled-skills/longbridge-derivatives/references/options-volatility.md +70 -0
  99. package/bundled-skills/longbridge-derivatives/references/warrant.md +12 -0
  100. package/bundled-skills/longbridge-quant/SKILL.md +151 -0
  101. package/bundled-skills/longbridge-quant/references/correlation.md +51 -0
  102. package/bundled-skills/longbridge-quant/references/execution-model.md +68 -0
  103. package/bundled-skills/longbridge-quant/references/factor-research.md +95 -0
  104. package/bundled-skills/longbridge-quant/references/factor-screen.md +101 -0
  105. package/bundled-skills/longbridge-quant/references/hedging.md +136 -0
  106. package/bundled-skills/longbridge-quant/references/ml-strategy.md +77 -0
  107. package/bundled-skills/longbridge-quant/references/multifactor.md +68 -0
  108. package/bundled-skills/longbridge-quant/references/pairs-trading.md +61 -0
  109. package/bundled-skills/longbridge-quant/references/quant-cli.md +133 -0
  110. package/bundled-skills/longbridge-quant/references/quant-stats.md +150 -0
  111. package/bundled-skills/longbridge-quant/references/seasonality.md +50 -0
  112. package/bundled-skills/longbridge-quant/references/strategy-optimizer.md +68 -0
  113. package/bundled-skills/longbridge-quant/references/volatility-strategy.md +52 -0
  114. package/bundled-skills/longbridge-research/SKILL.md +187 -0
  115. package/bundled-skills/longbridge-research/references/company-profile.md +96 -0
  116. package/bundled-skills/longbridge-research/references/company-tearsheet.md +82 -0
  117. package/bundled-skills/longbridge-research/references/competitive-analysis.md +81 -0
  118. package/bundled-skills/longbridge-research/references/consensus.md +92 -0
  119. package/bundled-skills/longbridge-research/references/coverage-initiation.md +76 -0
  120. package/bundled-skills/longbridge-research/references/defi-yield.md +60 -0
  121. package/bundled-skills/longbridge-research/references/finance-calendar.md +165 -0
  122. package/bundled-skills/longbridge-research/references/financial-planning.md +77 -0
  123. package/bundled-skills/longbridge-research/references/forecast-eps.md +39 -0
  124. package/bundled-skills/longbridge-research/references/fund-holder.md +44 -0
  125. package/bundled-skills/longbridge-research/references/hkipo-analysis.md +101 -0
  126. package/bundled-skills/longbridge-research/references/industry-peers.md +46 -0
  127. package/bundled-skills/longbridge-research/references/industry-rank.md +62 -0
  128. package/bundled-skills/longbridge-research/references/insider-trades.md +48 -0
  129. package/bundled-skills/longbridge-research/references/institution-rating.md +62 -0
  130. package/bundled-skills/longbridge-research/references/investment-ideas.md +69 -0
  131. package/bundled-skills/longbridge-research/references/investment-proposal.md +95 -0
  132. package/bundled-skills/longbridge-research/references/investors.md +87 -0
  133. package/bundled-skills/longbridge-research/references/onchain.md +70 -0
  134. package/bundled-skills/longbridge-research/references/post-investment.md +76 -0
  135. package/bundled-skills/longbridge-research/references/shareholder.md +72 -0
  136. package/bundled-skills/longbridge-research/references/short-positions.md +50 -0
  137. package/bundled-skills/longbridge-research/references/short-trades.md +50 -0
  138. package/bundled-skills/longbridge-research/references/stock-research.md +61 -0
  139. package/bundled-skills/longbridge-research/references/thesis-tracker.md +64 -0
  140. package/bundled-skills/makepad-2-0-animation/SKILL.md +318 -0
  141. package/bundled-skills/makepad-2-0-animation/references/animator-reference.md +433 -0
  142. package/bundled-skills/makepad-2-0-dsl/SKILL.md +492 -0
  143. package/bundled-skills/makepad-2-0-dsl/references/dsl-syntax-reference.md +511 -0
  144. package/bundled-skills/makepad-2-0-dsl/references/extended-guide.md +56 -0
  145. package/bundled-skills/makepad-2-0-dsl/references/property-system.md +757 -0
  146. package/bundled-skills/makepad-2-0-events/SKILL.md +497 -0
  147. package/bundled-skills/makepad-2-0-events/references/event-patterns.md +802 -0
  148. package/bundled-skills/makepad-2-0-events/references/extended-guide.md +590 -0
  149. package/bundled-skills/makepad-2-0-layout/SKILL.md +499 -0
  150. package/bundled-skills/makepad-2-0-layout/references/extended-guide.md +243 -0
  151. package/bundled-skills/makepad-2-0-layout/references/layout-patterns.md +881 -0
  152. package/bundled-skills/makepad-2-0-widgets/SKILL.md +261 -0
  153. package/bundled-skills/makepad-2-0-widgets/references/widget-advanced.md +648 -0
  154. package/bundled-skills/makepad-2-0-widgets/references/widget-catalog.md +547 -0
  155. package/bundled-skills/meeting-distiller-pro/SKILL.md +120 -0
  156. package/bundled-skills/monte-carlo-analyze-root-cause/SKILL.md +12 -1
  157. package/bundled-skills/monte-carlo-asset-health/SKILL.md +12 -1
  158. package/bundled-skills/monte-carlo-context-detection/SKILL.md +170 -0
  159. package/bundled-skills/monte-carlo-context-detection/references/signal-definitions.md +46 -0
  160. package/bundled-skills/remotion-captions/SKILL.md +57 -0
  161. package/bundled-skills/remotion-captions/agents/openai.yaml +7 -0
  162. package/bundled-skills/remotion-captions/assets/remotion-icon.svg +4 -0
  163. package/bundled-skills/remotion-captions/display-captions.md +190 -0
  164. package/bundled-skills/remotion-captions/import-srt-captions.md +73 -0
  165. package/bundled-skills/remotion-captions/transcribe-captions.md +70 -0
  166. package/bundled-skills/remotion-create/SKILL.md +106 -0
  167. package/bundled-skills/remotion-create/agents/openai.yaml +7 -0
  168. package/bundled-skills/remotion-create/assets/remotion-icon.svg +4 -0
  169. package/bundled-skills/remotion-create/tailwind.md +11 -0
  170. package/bundled-skills/remotion-create/video-layout.md +9 -0
  171. package/bundled-skills/remotion-docs/SKILL.md +67 -0
  172. package/bundled-skills/remotion-docs/agents/openai.yaml +7 -0
  173. package/bundled-skills/remotion-docs/assets/remotion-icon.svg +4 -0
  174. package/bundled-skills/remotion-interactivity/SKILL.md +270 -0
  175. package/bundled-skills/remotion-interactivity/agents/openai.yaml +7 -0
  176. package/bundled-skills/remotion-interactivity/assets/remotion-icon.svg +4 -0
  177. package/bundled-skills/remotion-render/SKILL.md +48 -0
  178. package/bundled-skills/remotion-render/agents/openai.yaml +7 -0
  179. package/bundled-skills/remotion-render/assets/remotion-icon.svg +4 -0
  180. package/bundled-skills/remotion-render/transparent-videos.md +106 -0
  181. package/bundled-skills/saas-pricing-strategist/SKILL.md +169 -0
  182. package/bundled-skills/score-eval/SKILL.md +35 -0
  183. package/bundled-skills/writing-guidelines/SKILL.md +60 -0
  184. package/package.json +1 -1
  185. package/skills_index.json +980 -3
@@ -0,0 +1,1832 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import hashlib
6
+ import html
7
+ import json
8
+ import os
9
+ import re
10
+ import secrets
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import zipfile
15
+ from pathlib import Path, PureWindowsPath
16
+ from urllib.parse import quote
17
+
18
+ from common import PipelineError, dump_json, load_json, now_iso
19
+ from trusted_search_config import (
20
+ PROVIDER_URL,
21
+ ConfigurationError,
22
+ load_trusted_search_key,
23
+ trusted_search_key_for_execution,
24
+ )
25
+
26
+
27
+ DEPENDENCIES = {
28
+ "collector": "llm-answer-reference-compare",
29
+ "comparison": "fact-check-x-knowledge-compare",
30
+ "authority": "fact-check-x-authoritative-verify",
31
+ }
32
+
33
+ DEPENDENCY_ENTRYPOINTS = {
34
+ "collector": Path("assets/tool/dist/cli.js"),
35
+ "comparison": Path("scripts/knowledge_compare.py"),
36
+ "authority": Path("scripts/authority_verify.py"),
37
+ }
38
+
39
+ COLLECTOR_RUNTIME_PACKAGES = (
40
+ Path("node_modules/@playwright/test/package.json"),
41
+ Path("node_modules/commander/package.json"),
42
+ Path("node_modules/zod/package.json"),
43
+ )
44
+
45
+ TRUSTED_SEARCH_CONFIGURATION_PROMPT = (
46
+ "部分知识点还需要调用可信搜索。首次使用时,我会打开深知智能 MaaS 平台"
47
+ f"({PROVIDER_URL})。您只需完成登录;技能会自动读取已有的可用 Key,"
48
+ "没有时创建 Fact-Check-X 专用 Key,验证后保存到本机共享配置,并自动继续。"
49
+ "后续在 Codex、Claude Code、WorkBuddy 等载体中都会直接复用,已有配置时"
50
+ "不会再次打开登录页。Key 不会进入对话、命令输出或核验报告。"
51
+ )
52
+
53
+ PORTABLE_REPORT_PACKAGE = "05-complete-report-package.zip"
54
+ PORTABLE_REPORT_ROOT = Path("fact-check-x-report")
55
+ PORTABLE_REPORT_README = """Fact-Check-X 完整事实核验报告包
56
+
57
+ 建议按以下顺序打开:
58
+ 1. 01-capture-report.html:各方答案汇总
59
+ 2. 02-comparison-report.html:各方答案聚合(未核验)
60
+ 3. 03-authority-report.html:权威核验后的最终答案
61
+ 4. 04-final-report.html:各方答案测评报告
62
+
63
+ 请先解压整个压缩包,再用浏览器打开 HTML 文件,以保留截图、页面存证和报告导航。
64
+ """
65
+
66
+ REPORT_NAV_ITEMS = (
67
+ ("01-capture-report.html", "各方答案汇总"),
68
+ ("02-comparison-report.html", "各方答案聚合"),
69
+ ("03-authority-report.html", "权威核验答案"),
70
+ ("04-final-report.html", "各方答案测评报告"),
71
+ )
72
+ REPORT_NAV_STYLE = """
73
+ .fcx-report-nav{display:flex;flex-wrap:wrap;gap:8px;margin:14px auto 18px;max-width:1440px;padding:10px 24px;border-top:1px solid #d7dde7;border-bottom:1px solid #d7dde7;background:#fff}
74
+ .fcx-report-nav a,.fcx-report-nav span{display:inline-flex;align-items:center;min-height:32px;padding:5px 10px;border-radius:6px;font-size:13px;text-decoration:none}
75
+ .fcx-report-nav a{border:1px solid #c8d1dc;background:#fff;color:#285a9f}
76
+ .fcx-report-nav a:hover{border-color:#285a9f;color:#173f74}
77
+ .fcx-report-nav span{border:1px solid #187454;background:#e8f5f0;color:#125b42;font-weight:700}
78
+ @media(max-width:760px){.fcx-report-nav{padding:9px 12px;overflow-x:auto;flex-wrap:nowrap}.fcx-report-nav a,.fcx-report-nav span{white-space:nowrap}}
79
+ .fcx-run-notice{max-width:1440px;margin:0 auto 18px;padding:12px 24px;border:1px solid #f0b429;border-left:5px solid #f0b429;border-radius:6px;background:#fffaf0;color:#6b4e07;font-size:13px;line-height:1.6}
80
+ .fcx-run-notice strong{display:block;margin-bottom:6px;font-size:14px;color:#8a5a00}
81
+ .fcx-run-notice ul{margin:0 0 6px;padding-left:20px}
82
+ .fcx-run-notice li{margin:2px 0}
83
+ .fcx-run-notice .fcx-run-notice-tail{color:#7a6320}
84
+ @media(max-width:760px){.fcx-run-notice{padding:11px 12px}}
85
+ """.strip()
86
+ INTERACTION_GATE_FILE = "stage-checkpoints.json"
87
+ INTERACTION_STAGE_ORDER = ("capture", "comparison", "authority", "evaluation")
88
+
89
+
90
+ def interaction_gate_path(run_dir: Path) -> Path:
91
+ return run_dir / INTERACTION_GATE_FILE
92
+
93
+
94
+ def initialize_interaction_gate(run_dir: Path, execution_mode: str) -> None:
95
+ dump_json(interaction_gate_path(run_dir), {
96
+ "schemaVersion": "fact-check-x/stage-checkpoints@1",
97
+ "executionMode": execution_mode,
98
+ "createdAt": now_iso(),
99
+ "stages": {},
100
+ })
101
+
102
+
103
+ def load_interaction_gate(run_dir: Path) -> dict:
104
+ path = interaction_gate_path(run_dir)
105
+ if not path.exists():
106
+ raise PipelineError(
107
+ "缺少阶段交付门禁;请从 prepare-comparison 重新开始"
108
+ )
109
+ gate = load_json(path)
110
+ if gate.get("schemaVersion") != "fact-check-x/stage-checkpoints@1":
111
+ raise PipelineError("阶段交付门禁版本不正确")
112
+ return gate
113
+
114
+
115
+ def record_stage_checkpoint(
116
+ run_dir: Path,
117
+ stage: str,
118
+ label: str,
119
+ path: Path,
120
+ next_stage: str | None,
121
+ ) -> dict:
122
+ if stage not in INTERACTION_STAGE_ORDER:
123
+ raise PipelineError(f"未知阶段:{stage}")
124
+ gate = load_interaction_gate(run_dir)
125
+ resolved = path.resolve()
126
+ if not resolved.is_file():
127
+ raise PipelineError(f"阶段产物不存在:{resolved}")
128
+ artifact = deliverable_descriptor(label, resolved)
129
+ status = "completed" if not next_stage else "awaiting_user"
130
+ token = f"fcx_{secrets.token_urlsafe(18)}" if status == "awaiting_user" else None
131
+ entry = {
132
+ "stage": stage,
133
+ "label": label,
134
+ "path": str(resolved),
135
+ "fileUri": artifact["fileUri"],
136
+ "sha256": capture_digest(resolved),
137
+ "status": status,
138
+ "nextStage": next_stage,
139
+ "token": token,
140
+ "createdAt": now_iso(),
141
+ }
142
+ gate.setdefault("stages", {})[stage] = entry
143
+ dump_json(interaction_gate_path(run_dir), gate)
144
+ checkpoint = {
145
+ "required": True,
146
+ "mustPresentBeforeNextStage": True,
147
+ "stage": stage,
148
+ "status": status,
149
+ "label": label,
150
+ "path": str(resolved),
151
+ "fileUri": artifact["fileUri"],
152
+ "markdownLink": artifact["markdownLink"],
153
+ "message": artifact["markdownLink"],
154
+ "gate": str(interaction_gate_path(run_dir).resolve()),
155
+ }
156
+ if next_stage:
157
+ checkpoint.update({
158
+ "nextStage": next_stage,
159
+ "choices": ["继续下一步", "修正当前结果", "到此结束并保留产物"],
160
+ })
161
+ if token:
162
+ checkpoint["acknowledgement"] = {
163
+ "required": True,
164
+ "token": token,
165
+ "command": (
166
+ "acknowledge-stage --run-dir "
167
+ f"{resolved.parent} --stage {stage} --token {token} --decision continue"
168
+ ),
169
+ }
170
+ return checkpoint
171
+
172
+
173
+ def require_stage_acknowledged(run_dir: Path, stage: str) -> dict:
174
+ gate = load_interaction_gate(run_dir)
175
+ entry = (gate.get("stages") or {}).get(stage)
176
+ if not entry:
177
+ raise PipelineError(f"缺少 {stage} 阶段产物交付记录,禁止继续")
178
+ path = Path(str(entry.get("path") or ""))
179
+ if not path.is_file() or capture_digest(path) != entry.get("sha256"):
180
+ raise PipelineError(f"{stage} 阶段产物已缺失或被修改,禁止继续")
181
+ if entry.get("status") != "acknowledged":
182
+ raise PipelineError(
183
+ f"{stage} 阶段产物尚未得到‘继续下一步’确认,禁止进入后续流程"
184
+ )
185
+ return entry
186
+
187
+
188
+ def acknowledge_stage(args: argparse.Namespace) -> dict:
189
+ run_dir = require_run(args.run_dir)
190
+ gate = load_interaction_gate(run_dir)
191
+ entry = (gate.get("stages") or {}).get(args.stage)
192
+ if not entry:
193
+ raise PipelineError(f"未找到可确认的 {args.stage} 阶段")
194
+ if gate.get("executionMode") != "interactive":
195
+ raise PipelineError("阶段交付门禁配置无效;请重新执行 prepare-comparison")
196
+ if entry.get("status") != "awaiting_user" or not secrets.compare_digest(
197
+ str(entry.get("token") or ""), str(args.token or "")
198
+ ):
199
+ raise PipelineError("阶段确认令牌无效或已使用")
200
+ path = Path(str(entry.get("path") or ""))
201
+ if not path.is_file() or capture_digest(path) != entry.get("sha256"):
202
+ raise PipelineError("待确认的阶段产物已缺失或被修改")
203
+ entry.update({
204
+ "status": "acknowledged" if args.decision == "continue" else args.decision,
205
+ "decision": args.decision,
206
+ "acknowledgedAt": now_iso(),
207
+ "token": None,
208
+ })
209
+ dump_json(interaction_gate_path(run_dir), gate)
210
+ return {
211
+ "status": "completed",
212
+ "stage": args.stage,
213
+ "decision": args.decision,
214
+ "nextStageAllowed": args.decision == "continue",
215
+ "artifact": str(path.resolve()),
216
+ "fileUri": file_uri_for_path(path.resolve()),
217
+ }
218
+
219
+
220
+ def skill_candidates() -> list[Path]:
221
+ values = []
222
+ if os.getenv("FACTCHECK_SKILLS_DIR"):
223
+ values.append(Path(os.environ["FACTCHECK_SKILLS_DIR"]).expanduser())
224
+ # The WorkBuddy single-upload package carries the three business layers here.
225
+ values.append(Path(__file__).resolve().parents[1] / "modules")
226
+ values.append(Path(__file__).resolve().parents[2])
227
+ codex_home = Path(os.getenv("CODEX_HOME", Path.home() / ".codex")).expanduser()
228
+ values.append(codex_home / "skills")
229
+ output = []
230
+ for value in values:
231
+ resolved = value.resolve()
232
+ if resolved not in output:
233
+ output.append(resolved)
234
+ return output
235
+
236
+
237
+ def locate_skills() -> dict[str, Path]:
238
+ found = {}
239
+ for key, directory in DEPENDENCIES.items():
240
+ for parent in skill_candidates():
241
+ candidate = parent / directory
242
+ if (candidate / "SKILL.md").exists() or (candidate / DEPENDENCY_ENTRYPOINTS[key]).exists():
243
+ found[key] = candidate
244
+ break
245
+ if key not in found:
246
+ searched = ", ".join(str(path / directory) for path in skill_candidates())
247
+ raise PipelineError(f"未找到依赖技能 {directory};已检查:{searched}")
248
+ return found
249
+
250
+
251
+ def installed_version() -> dict:
252
+ """读取当前实际运行的技能包版本。
253
+
254
+ 载体界面显示的可能是应用市场上的可用版本而不是本机已安装的版本,两者不一致时
255
+ 很难判断问题出在版本还是代码,所以每次准备运行时都把真实版本连同来源一并回传。
256
+ 版本取自正在执行的这份脚本所在的技能目录,不依赖载体的安装记录。
257
+ """
258
+ root = Path(__file__).resolve().parent.parent
259
+ manifest = root / "package-manifest.json"
260
+ if manifest.is_file():
261
+ try:
262
+ return {
263
+ "version": str(json.loads(manifest.read_text(encoding="utf-8")).get("version") or "unknown"),
264
+ "source": str(manifest.resolve()),
265
+ }
266
+ except (json.JSONDecodeError, OSError):
267
+ pass
268
+ skill_md = root / "SKILL.md"
269
+ if skill_md.is_file():
270
+ for line in skill_md.read_text(encoding="utf-8").splitlines():
271
+ stripped = line.strip()
272
+ if stripped.startswith("version:"):
273
+ return {
274
+ "version": stripped.split(":", 1)[1].strip().strip('"').strip("'"),
275
+ "source": str(skill_md.resolve()),
276
+ }
277
+ return {"version": "unknown", "source": str(root.resolve())}
278
+
279
+
280
+ def prepare_runtime(skills: dict[str, Path]) -> dict:
281
+ tool_root = skills["collector"] / "assets" / "tool"
282
+ package_json = tool_root / "package.json"
283
+ package_lock = tool_root / "package-lock.json"
284
+ if not package_json.is_file() or not package_lock.is_file():
285
+ raise PipelineError("采集运行时缺少 package.json 或 package-lock.json")
286
+
287
+ node = shutil.which("node")
288
+ if not node:
289
+ raise PipelineError("未找到 Node.js;请安装 Node.js 20 或更高版本后重试")
290
+
291
+ def runtime_ready() -> bool:
292
+ if not all((tool_root / relative).is_file() for relative in COLLECTOR_RUNTIME_PACKAGES):
293
+ return False
294
+ probe = subprocess.run(
295
+ [node, "dist/cli.js", "platforms"],
296
+ cwd=tool_root,
297
+ text=True,
298
+ capture_output=True,
299
+ check=False,
300
+ timeout=30,
301
+ )
302
+ return probe.returncode == 0
303
+
304
+ if runtime_ready():
305
+ return {
306
+ "status": "completed",
307
+ "runtime": "ready",
308
+ "installAction": "skipped",
309
+ "toolRoot": str(tool_root.resolve()),
310
+ "installedVersion": installed_version(),
311
+ }
312
+
313
+ npm = shutil.which("npm")
314
+ if not npm:
315
+ raise PipelineError("未找到 npm;请安装 Node.js 20 或更高版本后重试")
316
+ environment = os.environ.copy()
317
+ environment["PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"] = "1"
318
+ try:
319
+ install = subprocess.run(
320
+ [npm, "ci", "--omit=dev", "--no-audit", "--no-fund"],
321
+ cwd=tool_root,
322
+ text=True,
323
+ capture_output=True,
324
+ check=False,
325
+ env=environment,
326
+ timeout=600,
327
+ )
328
+ except subprocess.TimeoutExpired as exc:
329
+ raise PipelineError("采集运行依赖安装超时,请检查 npm 网络后重试") from exc
330
+ if install.returncode:
331
+ detail = install.stderr.strip() or install.stdout.strip() or "npm ci 执行失败"
332
+ raise PipelineError(f"采集运行依赖安装失败:{detail}")
333
+ if not runtime_ready():
334
+ raise PipelineError("采集运行依赖安装完成,但运行时自检未通过")
335
+ return {
336
+ "status": "completed",
337
+ "runtime": "ready",
338
+ "installAction": "installed",
339
+ "toolRoot": str(tool_root.resolve()),
340
+ "installedVersion": installed_version(),
341
+ }
342
+
343
+
344
+ def run(command: list[str], environment: dict[str, str] | None = None) -> dict:
345
+ process = subprocess.run(
346
+ command,
347
+ text=True,
348
+ capture_output=True,
349
+ check=False,
350
+ env=environment,
351
+ )
352
+ if process.returncode:
353
+ raise PipelineError(process.stdout.strip() or process.stderr.strip() or "子技能执行失败")
354
+ lines = [line for line in process.stdout.splitlines() if line.strip()]
355
+ try:
356
+ return json.loads(lines[-1]) if lines else {"status": "completed"}
357
+ except json.JSONDecodeError:
358
+ return {"status": "completed", "output": process.stdout.strip()}
359
+
360
+
361
+ def trusted_search_key() -> str:
362
+ try:
363
+ return load_trusted_search_key()
364
+ except ConfigurationError as exc:
365
+ raise PipelineError(str(exc)) from exc
366
+
367
+
368
+ def validated_authority_key() -> str:
369
+ try:
370
+ key, _, _ = trusted_search_key_for_execution()
371
+ return key
372
+ except ConfigurationError as exc:
373
+ raise PipelineError(str(exc)) from exc
374
+
375
+
376
+ def trusted_search_configuration() -> dict:
377
+ helper = Path(__file__).with_name("trusted_search_config.py").resolve()
378
+ return {
379
+ "providerUrl": PROVIDER_URL,
380
+ "command": [sys.executable, str(helper), "configure"],
381
+ "interaction": "browser_login_only",
382
+ "requiresChatSecret": False,
383
+ "autoResume": True,
384
+ "sharedAcrossCarriers": True,
385
+ }
386
+
387
+
388
+ def require_run(run_dir: str) -> Path:
389
+ path = Path(run_dir).resolve()
390
+ path.mkdir(parents=True, exist_ok=True)
391
+ return path
392
+
393
+
394
+ def capture_digest(path: Path) -> str:
395
+ digest = hashlib.sha256()
396
+ with path.open("rb") as handle:
397
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
398
+ digest.update(block)
399
+ return digest.hexdigest()
400
+
401
+
402
+ def normalize_platform_path_text(path: Path | str, platform: str | None = None) -> str:
403
+ raw = str(path)
404
+ if (platform or os.name) == "nt":
405
+ match = re.match(r"^/([A-Za-z])(?:/(.*))?$", raw)
406
+ if match:
407
+ suffix = match.group(2) or ""
408
+ return f"{match.group(1).upper()}:/{suffix}"
409
+ return raw
410
+
411
+
412
+ def file_uri_for_path(path: Path | str, platform: str | None = None) -> str:
413
+ """Return a standards-compliant file URI without guessing path separators."""
414
+ raw = normalize_platform_path_text(path, platform)
415
+ if re.match(r"^[A-Za-z]:[\\/]", raw) or raw.startswith("\\\\"):
416
+ posix_path = PureWindowsPath(raw).as_posix()
417
+ encoded = quote(posix_path, safe="/:")
418
+ return f"file:{encoded}" if posix_path.startswith("//") else f"file:///{encoded}"
419
+ return Path(raw).resolve().as_uri()
420
+
421
+
422
+ def deliverable_descriptor(label: str, path: Path | str, **extra) -> dict:
423
+ resolved = Path(normalize_platform_path_text(path)).resolve()
424
+ if not resolved.is_file():
425
+ raise PipelineError(f"阶段产物不存在:{resolved}")
426
+ file_uri = file_uri_for_path(resolved)
427
+ return {
428
+ "label": label,
429
+ "path": str(resolved),
430
+ "fileUri": file_uri,
431
+ "markdownLink": f"[打开{label}](<{file_uri}>)",
432
+ **extra,
433
+ }
434
+
435
+
436
+ def referenced_capture_artifacts(results_path: Path, results: dict) -> dict[str, Path]:
437
+ source_root = results_path.parent.resolve()
438
+ referenced: dict[str, Path] = {}
439
+ for platform in results.get("platforms") or []:
440
+ platform_id = str(platform.get("platform") or "unknown")
441
+ artifacts = platform.get("artifacts") or {}
442
+ if not isinstance(artifacts, dict):
443
+ raise PipelineError(f"{platform_id} 的采集存证格式不正确")
444
+ for artifact_type, value in artifacts.items():
445
+ raw_path = str(value or "").strip()
446
+ if not raw_path:
447
+ continue
448
+ relative = Path(raw_path)
449
+ if (
450
+ relative.is_absolute()
451
+ or ".." in relative.parts
452
+ or not relative.parts
453
+ or relative.parts[0] != "artifacts"
454
+ ):
455
+ raise PipelineError(
456
+ f"{platform_id} 的 {artifact_type} 存证路径必须是 artifacts/ 下的相对路径:{raw_path}"
457
+ )
458
+ source = (source_root / relative).resolve()
459
+ try:
460
+ source.relative_to(source_root)
461
+ except ValueError as exc:
462
+ raise PipelineError(
463
+ f"{platform_id} 的 {artifact_type} 存证路径越出采集目录:{raw_path}"
464
+ ) from exc
465
+ if not source.is_file():
466
+ raise PipelineError(
467
+ f"{platform_id} 声明的 {artifact_type} 存证文件不存在:{raw_path}"
468
+ )
469
+ normalized = relative.as_posix()
470
+ previous = referenced.get(normalized)
471
+ if previous and previous != source:
472
+ raise PipelineError(f"采集存证路径冲突:{normalized}")
473
+ referenced[normalized] = source
474
+ return referenced
475
+
476
+
477
+ def capture_artifact_hashes(results_path: Path, results: dict) -> dict[str, str]:
478
+ return {
479
+ relative: capture_digest(source)
480
+ for relative, source in sorted(
481
+ referenced_capture_artifacts(results_path, results).items()
482
+ )
483
+ }
484
+
485
+
486
+ def sync_capture_evidence(run_dir: Path, results_path: Path, results: dict) -> None:
487
+ capture_dir = (run_dir / "capture").resolve()
488
+ source_dir = results_path.parent.resolve()
489
+ artifacts = referenced_capture_artifacts(results_path, results)
490
+ recovery_source = source_dir / "capture-recovery.json"
491
+ recovery_target = capture_dir / "capture-recovery.json"
492
+ artifact_bytes = {
493
+ relative: source.read_bytes() for relative, source in artifacts.items()
494
+ }
495
+ recovery_bytes = recovery_source.read_bytes() if recovery_source.is_file() else None
496
+ for target_root in (capture_dir, run_dir):
497
+ shutil.rmtree(target_root / "artifacts", ignore_errors=True)
498
+ for relative, content in artifact_bytes.items():
499
+ target = target_root / relative
500
+ target.parent.mkdir(parents=True, exist_ok=True)
501
+ target.write_bytes(content)
502
+ if recovery_bytes is not None:
503
+ recovery_target.write_bytes(recovery_bytes)
504
+ else:
505
+ recovery_target.unlink(missing_ok=True)
506
+
507
+
508
+ def json_file_manifest(directory: Path, expected_names: set[str] | None = None) -> dict[str, str]:
509
+ actual = {path.name for path in directory.glob("*.json")} if directory.exists() else set()
510
+ if expected_names is not None and actual != expected_names:
511
+ missing = sorted(expected_names - actual)
512
+ extra = sorted(actual - expected_names)
513
+ raise PipelineError(f"{directory.name} 文件集合不匹配;缺失={missing},额外/陈旧={extra}")
514
+ return {name: capture_digest(directory / name) for name in sorted(actual)}
515
+
516
+
517
+ def require_digest(path: Path, expected: str, label: str) -> None:
518
+ if not path.exists() or capture_digest(path) != expected:
519
+ raise PipelineError(f"{label} 在门禁后缺失或被修改,禁止继续")
520
+
521
+
522
+ def write_comparison_provenance(run_dir: Path, results_path: Path, analysis_path: Path, comparison_path: Path) -> Path:
523
+ gate = run_dir / "comparison-gate.json"
524
+ dump_json(gate, {
525
+ "schemaVersion": "fact-check-x/comparison-gate@1",
526
+ "status": "completed",
527
+ "createdAt": now_iso(),
528
+ "files": {
529
+ "results": {"path": str(results_path), "sha256": capture_digest(results_path)},
530
+ "analysis": {"path": str(analysis_path), "sha256": capture_digest(analysis_path)},
531
+ "comparison": {"path": str(comparison_path), "sha256": capture_digest(comparison_path)},
532
+ },
533
+ })
534
+ return gate
535
+
536
+
537
+ def require_comparison_provenance(run_dir: Path) -> dict:
538
+ gate_path = run_dir / "comparison-gate.json"
539
+ if not gate_path.exists():
540
+ raise PipelineError("缺少 1.1 provenance 门禁;必须重新执行 complete-comparison")
541
+ gate = load_json(gate_path)
542
+ if gate.get("schemaVersion") != "fact-check-x/comparison-gate@1" or gate.get("status") != "completed":
543
+ raise PipelineError("1.1 provenance 门禁状态不正确")
544
+ for label, item in (gate.get("files") or {}).items():
545
+ require_digest(Path(str(item.get("path") or "")).resolve(), str(item.get("sha256") or ""), label)
546
+ return gate
547
+
548
+
549
+ def expected_request_ids(manifest: dict) -> list[str]:
550
+ ids = [str(entry.get("requestId") or "") for entry in manifest.get("requests") or []]
551
+ if not ids or any(not value for value in ids) or len(ids) != len(set(ids)):
552
+ raise PipelineError("权威核验请求清单包含空 ID 或重复 ID")
553
+ return ids
554
+
555
+
556
+ def scrub_portable_paths(value, run_dir: Path):
557
+ if isinstance(value, dict):
558
+ return {key: scrub_portable_paths(item, run_dir) for key, item in value.items()}
559
+ if isinstance(value, list):
560
+ return [scrub_portable_paths(item, run_dir) for item in value]
561
+ if isinstance(value, str):
562
+ run_uri = file_uri_for_path(run_dir.resolve()).rstrip("/")
563
+ if value == run_uri:
564
+ return "."
565
+ if value.startswith(run_uri + "/"):
566
+ return "../" + value[len(run_uri) + 1 :]
567
+ if run_uri + "/" in value:
568
+ return value.replace(run_uri + "/", "../")
569
+ path = Path(value)
570
+ if path.is_absolute():
571
+ try:
572
+ return path.relative_to(run_dir).as_posix()
573
+ except ValueError:
574
+ return path.name
575
+ return value
576
+
577
+
578
+ def portable_file_bytes(path: Path, run_dir: Path) -> bytes:
579
+ if path.suffix.lower() == ".json":
580
+ payload = scrub_portable_paths(load_json(path), run_dir)
581
+ if path.name == "pipeline.json":
582
+ artifacts = payload.get("artifacts") or {}
583
+ artifacts.pop("reportPackageSha256", None)
584
+ artifacts.pop("reportPackageBytes", None)
585
+ return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
586
+ return path.read_bytes()
587
+
588
+
589
+ TECHNICAL_ACQUISITION_STATES = {"blocked", "failed"}
590
+
591
+
592
+ def collect_technical_notices(run_dir: Path) -> list[str]:
593
+ """收集「本该执行但因技术原因没跑成、流程仍继续」的动作。
594
+
595
+ 这些不是内容层面的证据不足,而是采集或调用本身没完成;判定口径不因此改变,
596
+ 但必须在每一步报告的显著位置统一提示,供阅读者判断证据完整性。
597
+ """
598
+ notices: list[str] = []
599
+ results_path = run_dir / "capture" / "results.json"
600
+ if results_path.is_file():
601
+ incomplete: list[str] = []
602
+ for platform in load_json(results_path).get("platforms") or []:
603
+ hits = 0
604
+ for reference in platform.get("references") or []:
605
+ content_state = str(reference.get("contentAcquisition") or "")
606
+ source_state = str(reference.get("sourceAcquisitionStatus") or "")
607
+ if (
608
+ content_state in TECHNICAL_ACQUISITION_STATES
609
+ or source_state in TECHNICAL_ACQUISITION_STATES
610
+ ):
611
+ hits += 1
612
+ if hits:
613
+ label = str(platform.get("label") or platform.get("platform") or "")
614
+ incomplete.append(f"{label} {hits} 条")
615
+ if incomplete:
616
+ notices.append(
617
+ "来源正文因访问受阻或采集失败未取得:" + "、".join(incomplete)
618
+ + "。相关主张只依据平台原文和已取得的证据判定。"
619
+ )
620
+ recovery_path = run_dir / "capture" / "capture-recovery.json"
621
+ if recovery_path.is_file():
622
+ recovery = load_json(recovery_path)
623
+ failed_platforms = [str(item) for item in (recovery.get("failedPlatforms") or [])]
624
+ status = str(recovery.get("status") or "")
625
+ if failed_platforms:
626
+ notices.append(
627
+ "采集恢复流程被触发,涉及:" + "、".join(failed_platforms)
628
+ + f"(状态 {status or '未知'})。"
629
+ )
630
+ return notices
631
+
632
+
633
+ def render_run_notices(notices) -> str:
634
+ items = "".join(f"<li>{html.escape(str(item))}</li>" for item in notices)
635
+ if not items:
636
+ return ""
637
+ return (
638
+ '<div class="fcx-run-notice" data-fcx-run-notice="1" role="note">'
639
+ "<strong>本次运行存在未完成的技术动作</strong>"
640
+ f"<ul>{items}</ul>"
641
+ '<span class="fcx-run-notice-tail">判定口径不因此改变;'
642
+ "以上项目可能影响相关结论的证据完整性,受影响且无法核验的主张按“疑似误导”呈现。</span></div>"
643
+ )
644
+
645
+
646
+ def normalize_report_navigation(content: bytes, current_report: str, notices=()) -> bytes:
647
+ html = content.decode("utf-8")
648
+ html = html.replace('href="capture/report.html"', 'href="01-capture-report.html"')
649
+ html = html.replace('href="report.html"', 'href="04-final-report.html"')
650
+ html = re.sub(
651
+ r'<nav\s+class="(?:report-nav|nav|fcx-report-nav)"[^>]*>.*?</nav>',
652
+ "",
653
+ html,
654
+ flags=re.DOTALL,
655
+ )
656
+ items = []
657
+ for filename, label in REPORT_NAV_ITEMS:
658
+ if filename == current_report:
659
+ items.append(f'<span aria-current="page">{label}</span>')
660
+ else:
661
+ items.append(f'<a href="{filename}">{label}</a>')
662
+ navigation = (
663
+ '<nav class="fcx-report-nav" data-fcx-report-nav="1" '
664
+ 'aria-label="事实核验四阶段报告导航">'
665
+ + "".join(items)
666
+ + "</nav>"
667
+ + render_run_notices(notices)
668
+ )
669
+ if REPORT_NAV_STYLE not in html:
670
+ if "</style>" in html:
671
+ html = html.replace("</style>", f"\n{REPORT_NAV_STYLE}\n</style>", 1)
672
+ else:
673
+ html = html.replace("</head>", f"<style>{REPORT_NAV_STYLE}</style></head>", 1)
674
+ if "<main" in html:
675
+ html = re.sub(r"(<main(?:\s[^>]*)?>)", navigation + r"\1", html, count=1)
676
+ else:
677
+ html = re.sub(r"(<body(?:\s[^>]*)?>)", r"\1" + navigation, html, count=1)
678
+ return html.encode("utf-8")
679
+
680
+
681
+ def normalize_report_file(path: Path, notices=()) -> None:
682
+ path.write_bytes(
683
+ normalize_report_navigation(path.read_bytes(), path.name, notices)
684
+ )
685
+
686
+
687
+ def build_portable_report_package(run_dir: Path) -> dict:
688
+ package_path = run_dir / PORTABLE_REPORT_PACKAGE
689
+ package_path.unlink(missing_ok=True)
690
+ reports = {
691
+ Path("01-capture-report.html"): run_dir / "01-capture-report.html",
692
+ Path("02-comparison-report.html"): run_dir / "02-comparison-report.html",
693
+ Path("03-authority-report.html"): run_dir / "03-authority-report.html",
694
+ Path("04-final-report.html"): run_dir / "04-final-report.html",
695
+ }
696
+ data_files = [
697
+ Path("capture/results.json"),
698
+ Path("capture/report.md"),
699
+ Path("capture/capture-recovery.json"),
700
+ Path("capture-gate.json"),
701
+ Path("comparison-task.json"),
702
+ Path("comparison-analysis.json"),
703
+ Path("comparison.json"),
704
+ Path("comparison-gate.json"),
705
+ Path("authority-gate.json"),
706
+ Path(INTERACTION_GATE_FILE),
707
+ Path("verification.json"),
708
+ Path("pipeline.json"),
709
+ ]
710
+ with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
711
+ archive.writestr(str(PORTABLE_REPORT_ROOT / "README.txt"), PORTABLE_REPORT_README.encode("utf-8"))
712
+ for relative, source in reports.items():
713
+ if not source.exists():
714
+ raise PipelineError(f"缺少对外交付报告:{source.name}")
715
+ content = normalize_report_navigation(source.read_bytes(), relative.name)
716
+ archive.writestr(str(PORTABLE_REPORT_ROOT / relative), content)
717
+ artifacts_dir = run_dir / "capture" / "artifacts"
718
+ if artifacts_dir.exists():
719
+ for source in sorted(path for path in artifacts_dir.rglob("*") if path.is_file()):
720
+ relative = Path("artifacts") / source.relative_to(artifacts_dir)
721
+ archive.writestr(str(PORTABLE_REPORT_ROOT / relative), source.read_bytes())
722
+ for relative in data_files:
723
+ source = run_dir / relative
724
+ if source.exists():
725
+ archive.writestr(
726
+ str(PORTABLE_REPORT_ROOT / "data" / relative),
727
+ portable_file_bytes(source, run_dir),
728
+ )
729
+ for directory in (run_dir / "authority", run_dir / "report-input"):
730
+ if not directory.exists():
731
+ continue
732
+ for source in sorted(path for path in directory.rglob("*") if path.is_file()):
733
+ relative = Path("data") / source.relative_to(run_dir)
734
+ archive.writestr(
735
+ str(PORTABLE_REPORT_ROOT / relative),
736
+ portable_file_bytes(source, run_dir),
737
+ )
738
+ with zipfile.ZipFile(package_path) as archive:
739
+ broken = archive.testzip()
740
+ if broken:
741
+ raise PipelineError(f"对外报告包校验失败:{broken}")
742
+ return {
743
+ "path": str(package_path.resolve()),
744
+ "sha256": capture_digest(package_path),
745
+ "bytes": package_path.stat().st_size,
746
+ }
747
+
748
+
749
+ def validate_capture_results(results_path: Path, minimum_platforms: int = 1) -> dict:
750
+ results = load_json(results_path)
751
+ if results.get("schemaVersion") != "1":
752
+ raise PipelineError("采集结果版本不正确,禁止进入知识点对比")
753
+ platforms = results.get("platforms") or []
754
+ if len(platforms) < minimum_platforms:
755
+ raise PipelineError(f"完整事实核验至少需要 {minimum_platforms} 个采集平台,当前只有 {len(platforms)} 个")
756
+ names = [str(item.get("platform") or "").strip() for item in platforms]
757
+ if len(set(names)) != len(names) or any(not name for name in names):
758
+ raise PipelineError("采集结果包含空平台或重复平台,禁止进入知识点对比")
759
+ incomplete = []
760
+ prompt_markers = (
761
+ "为您智能匹配到当前所在区域为",
762
+ "如想咨询其他区域可点击修改",
763
+ "请选择您想咨询的地区",
764
+ "请先完成登录",
765
+ "登录后即可提问",
766
+ )
767
+ def is_prompt_only(answer: str) -> bool:
768
+ compact = "".join(answer.split())
769
+ matches = ["".join(marker.split()) for marker in prompt_markers if "".join(marker.split()) in compact]
770
+ if not matches:
771
+ return False
772
+ return len(compact) <= max(240, sum(len(marker) for marker in matches) + 120)
773
+
774
+ for item in platforms:
775
+ answer = str(item.get("answerMarkdown") or "").strip()
776
+ if item.get("status") != "success" or not answer:
777
+ incomplete.append(
778
+ f"{item.get('label') or item.get('platform')}: "
779
+ f"{item.get('status')} ({item.get('error') or '无完整回答'})"
780
+ )
781
+ elif is_prompt_only(answer):
782
+ incomplete.append(f"{item.get('label') or item.get('platform')}: 捕获内容是登录、地区选择或初始化提示")
783
+ if incomplete:
784
+ raise PipelineError("采集未完成,已禁止进入知识点对比;请先重新采集失败平台:" + ";".join(incomplete))
785
+ recovery_path = results_path.parent / "capture-recovery.json"
786
+ if recovery_path.exists():
787
+ recovery = load_json(recovery_path)
788
+ if recovery.get("status") == "required":
789
+ raise PipelineError("采集恢复状态仍为 required,必须先由 Computer Use 完成浏览器恢复并重新采集")
790
+ return results
791
+
792
+
793
+ def write_capture_gate(run_dir: Path, results_path: Path, results: dict) -> Path:
794
+ gate = run_dir / "capture-gate.json"
795
+ recovery_path = results_path.parent / "capture-recovery.json"
796
+ dump_json(gate, {
797
+ "schemaVersion": "fact-check-x/capture-gate@1",
798
+ "status": "completed",
799
+ "createdAt": now_iso(),
800
+ "results": str(results_path.resolve()),
801
+ "sha256": capture_digest(results_path),
802
+ "artifactSha256": capture_artifact_hashes(results_path, results),
803
+ "recoverySha256": capture_digest(recovery_path) if recovery_path.is_file() else None,
804
+ "question": results.get("question"),
805
+ "platforms": [item.get("platform") for item in results.get("platforms") or []],
806
+ })
807
+ return gate
808
+
809
+
810
+ def validate_and_gate_capture(run_dir: Path, results_path: Path) -> dict:
811
+ results = validate_capture_results(results_path)
812
+ write_capture_gate(run_dir, results_path, results)
813
+ return results
814
+
815
+
816
+ def require_capture_gate(run_dir: Path, results_path: Path | None = None) -> dict:
817
+ gate_path = run_dir / "capture-gate.json"
818
+ if not gate_path.exists():
819
+ raise PipelineError("缺少采集完整性门禁,禁止执行下游步骤;请先完成 1.0 采集并运行 prepare-comparison")
820
+ gate = load_json(gate_path)
821
+ if gate.get("schemaVersion") != "fact-check-x/capture-gate@1" or gate.get("status") != "completed":
822
+ raise PipelineError("采集完整性门禁状态不正确,禁止执行下游步骤")
823
+ checked_path = results_path or Path(str(gate.get("results") or "")).resolve()
824
+ results = validate_capture_results(checked_path)
825
+ if gate.get("sha256") != capture_digest(checked_path):
826
+ raise PipelineError("results.json 在采集门禁后发生变化,禁止继续;请重新执行 prepare-comparison")
827
+ if gate.get("artifactSha256") != capture_artifact_hashes(checked_path, results):
828
+ raise PipelineError("采集存证文件在门禁后缺失或被修改,禁止继续;请重新执行 prepare-comparison")
829
+ recovery_path = checked_path.parent / "capture-recovery.json"
830
+ recovery_sha256 = capture_digest(recovery_path) if recovery_path.is_file() else None
831
+ if gate.get("recoverySha256") != recovery_sha256:
832
+ raise PipelineError("采集恢复记录在门禁后缺失或被修改,禁止继续;请重新执行 prepare-comparison")
833
+ return gate
834
+
835
+
836
+ def prepare_comparison(args: argparse.Namespace, skills: dict[str, Path]) -> dict:
837
+ run_dir = require_run(args.run_dir)
838
+ results_path = Path(args.results).resolve()
839
+ results = validate_and_gate_capture(run_dir, results_path)
840
+ capture_report = run([
841
+ "node",
842
+ str(skills["collector"] / "assets" / "tool" / "dist" / "report-cli.js"),
843
+ "--input",
844
+ str(results_path),
845
+ "--out",
846
+ str(run_dir / "capture"),
847
+ ])
848
+ sync_capture_evidence(run_dir, results_path, results)
849
+ capture_deliverable = run_dir / "01-capture-report.html"
850
+ shutil.copyfile(capture_report["report"], capture_deliverable)
851
+ normalize_report_file(capture_deliverable, collect_technical_notices(run_dir))
852
+ task = run_dir / "comparison-task.json"
853
+ result = run([sys.executable, str(skills["comparison"] / "scripts" / "knowledge_compare.py"), "--input", str(results_path), "--task-output", str(task)])
854
+ initialize_interaction_gate(run_dir, args.execution_mode)
855
+ result.update({
856
+ "stage": "capture_completed",
857
+ "analysisExecution": {
858
+ "mode": "single_pass",
859
+ "maxToolCallsAfterStageAcknowledgement": 3,
860
+ "instructions": [
861
+ "读取 comparison-task.json 一次,不读取技能源码、契约或完整 results.json 探路",
862
+ "直接写入 comparison-analysis.json 一次",
863
+ "执行 complete-comparison 一次,由程序使用完整采集正文校验",
864
+ ],
865
+ },
866
+ "platforms": [
867
+ {
868
+ "platform": item.get("platform"),
869
+ "label": item.get("label") or item.get("platform"),
870
+ "status": item.get("status"),
871
+ "referenceCount": len(item.get("references") or []),
872
+ "sourceMentionCount": len(item.get("sourceMentions") or []),
873
+ "sourceCountAudit": item.get("sourceCountAudit"),
874
+ "durationMs": item.get("durationMs"),
875
+ }
876
+ for item in results.get("platforms") or []
877
+ ],
878
+ "artifacts": {
879
+ "results": capture_report["results"],
880
+ "answerReferenceReport": capture_report["report"],
881
+ "answerReferenceMarkdown": capture_report["markdown"],
882
+ "captureDeliverable": str(capture_deliverable.resolve()),
883
+ "captureGate": str((run_dir / "capture-gate.json").resolve()),
884
+ "comparisonTask": str(task.resolve()),
885
+ },
886
+ "deliverables": [deliverable_descriptor("各方答案汇总", capture_deliverable)],
887
+ "checkpoint": record_stage_checkpoint(
888
+ run_dir, "capture", "各方答案汇总", capture_deliverable, "comparison"
889
+ ),
890
+ })
891
+ return result
892
+
893
+
894
+ def complete_comparison(args: argparse.Namespace, skills: dict[str, Path]) -> dict:
895
+ run_dir = require_run(args.run_dir)
896
+ require_stage_acknowledged(run_dir, "capture")
897
+ results_path = Path(args.results).resolve()
898
+ results = validate_and_gate_capture(run_dir, results_path)
899
+ sync_capture_evidence(run_dir, results_path, results)
900
+ analysis_source = (
901
+ Path(args.analysis).resolve()
902
+ if args.analysis
903
+ else run_dir / "comparison-analysis.json"
904
+ )
905
+ analysis = run_dir / "comparison-analysis.json"
906
+ comparison = run_dir / "comparison.json"
907
+ result = run([
908
+ sys.executable,
909
+ str(skills["comparison"] / "scripts" / "knowledge_compare.py"),
910
+ "--input",
911
+ str(results_path),
912
+ "--analysis",
913
+ str(analysis_source),
914
+ "--canonical-analysis-output",
915
+ str(analysis),
916
+ "--output",
917
+ str(comparison),
918
+ ])
919
+ comparison_report = run_dir / "comparison.html"
920
+ run([sys.executable, str(skills["comparison"] / "scripts" / "render_comparison.py"), "--results", str(results_path), "--comparison", str(comparison), "--output", str(comparison_report)])
921
+ comparison_deliverable = run_dir / "02-comparison-report.html"
922
+ shutil.copyfile(comparison_report, comparison_deliverable)
923
+ normalize_report_file(comparison_deliverable, collect_technical_notices(run_dir))
924
+ comparison_data = load_json(comparison)
925
+ blocking_analysis_gaps = [
926
+ item
927
+ for item in comparison_data.get("analysisGaps") or []
928
+ if isinstance(item, dict) and item.get("blocking") is True
929
+ ]
930
+ if blocking_analysis_gaps:
931
+ details = "; ".join(
932
+ f"{item.get('knowledgePointId') or '?'}·{item.get('platform') or '?'}:{item.get('reason') or '比较分析结构错误'}"
933
+ for item in blocking_analysis_gaps[:5]
934
+ )
935
+ raise PipelineError(
936
+ "比较分析未通过原子性门禁,禁止进入权威核验。"
937
+ f"请补全 answerExcerpt 或拆分过宽主张,并重新执行 complete-comparison:{details}"
938
+ )
939
+ comparison_gate = write_comparison_provenance(run_dir, results_path, analysis, comparison)
940
+ result.update({
941
+ "stage": "comparison_completed",
942
+ "knowledgePointCount": len(comparison_data.get("knowledgePoints") or []),
943
+ "analysisGapCount": len(comparison_data.get("analysisGaps") or []),
944
+ "artifacts": {
945
+ "comparisonAnalysis": str(analysis.resolve()),
946
+ "comparison": str(comparison.resolve()),
947
+ "comparisonReport": str(comparison_report.resolve()),
948
+ "comparisonDeliverable": str(comparison_deliverable.resolve()),
949
+ "comparisonGate": str(comparison_gate.resolve()),
950
+ },
951
+ "deliverables": [deliverable_descriptor("各方答案聚合(未核验)", comparison_deliverable)],
952
+ "checkpoint": record_stage_checkpoint(
953
+ run_dir,
954
+ "comparison",
955
+ "各方答案聚合(未核验)",
956
+ comparison_deliverable,
957
+ "authority",
958
+ ),
959
+ })
960
+ return result
961
+
962
+
963
+ def differing_claims(point: dict) -> list[dict]:
964
+ claims = []
965
+ for platform, claim in (point.get("claims") or {}).items():
966
+ value = str(claim.get("claim") or "").strip()
967
+ if claim.get("covered") and value:
968
+ claims.append({"platform": platform, "claim": value})
969
+ unique = {item["claim"] for item in claims}
970
+ if (point.get("comparison") or {}).get("status") in ("conflict", "partial", "mostly_consensus") and len(unique) >= 2:
971
+ return claims
972
+ return []
973
+
974
+
975
+ def official_exempt_policy(point: dict) -> str:
976
+ anchor = point.get("trustedAnchor") or {}
977
+ policy = str(anchor.get("sourcePolicy") or "")
978
+ if (
979
+ not policy
980
+ and anchor.get("eligible")
981
+ and anchor.get("platform") in {"dknowc-chat", "dknowc-deep-research"}
982
+ and anchor.get("trustedSearchUsed") is True
983
+ ):
984
+ policy = "dknow_official_reference"
985
+ if anchor.get("eligible") and policy in {
986
+ "dknow_official_reference",
987
+ "gov_cn_reference",
988
+ }:
989
+ return policy
990
+ return ""
991
+
992
+
993
+ def build_requests(comparison: dict, requests_dir: Path) -> dict:
994
+ if comparison.get("schemaVersion") != "fact-check-x/comparison@1":
995
+ raise PipelineError("comparison.json 版本不正确")
996
+ points = comparison.get("knowledgePoints") or []
997
+ if not points:
998
+ raise PipelineError("comparison.json 没有知识点")
999
+ requests_dir.mkdir(parents=True, exist_ok=True)
1000
+ expected = {f"{point['id']}.json" for point in points}
1001
+ stale = [path.name for path in requests_dir.glob("*.json") if path.name not in expected and path.name != "manifest.json"]
1002
+ if stale:
1003
+ raise PipelineError(f"请求目录存在不属于本次任务的旧文件:{stale}")
1004
+ entries = []
1005
+ for point in points:
1006
+ knowledge_point = {
1007
+ key: point.get(key)
1008
+ for key in ("id", "description", "role", "claimType", "core")
1009
+ }
1010
+ payload = {"title": comparison.get("question"), "knowledgePoint": knowledge_point}
1011
+ differences = differing_claims(point)
1012
+ if differences:
1013
+ payload["differingClaims"] = differences
1014
+ request = {
1015
+ "schemaVersion": "fact-check-x/authority-request@1",
1016
+ "requestId": point["id"],
1017
+ "title": comparison.get("question"),
1018
+ "comparisonStatus": (point.get("comparison") or {}).get("status"),
1019
+ "knowledgePoint": knowledge_point,
1020
+ "claims": point.get("claims") or {},
1021
+ "cloudPayload": payload,
1022
+ "trustedAnchor": point.get("trustedAnchor") or {"eligible": False},
1023
+ }
1024
+ path = requests_dir / f"{point['id']}.json"
1025
+ dump_json(path, request)
1026
+ exempt_policy = official_exempt_policy(point)
1027
+ entries.append({
1028
+ "requestId": point["id"],
1029
+ "file": str(path.resolve()),
1030
+ "hasDifferingClaims": bool(differences),
1031
+ "officialExempt": bool(exempt_policy),
1032
+ "dknowExempt": exempt_policy == "dknow_official_reference",
1033
+ "govExempt": exempt_policy == "gov_cn_reference",
1034
+ })
1035
+ manifest = {"schemaVersion": "fact-check-x/authority-requests@1", "createdAt": now_iso(), "question": comparison.get("question"), "taskCount": len(entries), "requests": entries}
1036
+ dump_json(requests_dir / "manifest.json", manifest)
1037
+ return manifest
1038
+
1039
+
1040
+ def prepare_authority(args: argparse.Namespace) -> dict:
1041
+ run_dir = require_run(args.run_dir)
1042
+ require_stage_acknowledged(run_dir, "comparison")
1043
+ require_capture_gate(run_dir)
1044
+ comparison_provenance = require_comparison_provenance(run_dir)
1045
+ comparison_path = Path(args.comparison).resolve() if args.comparison else run_dir / "comparison.json"
1046
+ recorded_comparison = Path(comparison_provenance["files"]["comparison"]["path"]).resolve()
1047
+ if comparison_path != recorded_comparison:
1048
+ raise PipelineError("prepare-authority 只能使用 complete-comparison 已锁定的 comparison.json")
1049
+ comparison = load_json(comparison_path)
1050
+ manifest = build_requests(comparison, run_dir / "authority" / "requests")
1051
+ request_ids = expected_request_ids(manifest)
1052
+ request_names = {f"{request_id}.json" for request_id in request_ids} | {"manifest.json"}
1053
+ request_hashes = json_file_manifest(run_dir / "authority" / "requests", request_names)
1054
+ required_count = sum(not entry.get("officialExempt") for entry in manifest.get("requests") or [])
1055
+ configured = bool(trusted_search_key())
1056
+ result = {
1057
+ "status": "configuration_required" if required_count and not configured else "prepared",
1058
+ "taskCount": manifest["taskCount"],
1059
+ "trustedSearchRequiredCount": required_count,
1060
+ "trustedSearchConfigured": configured,
1061
+ "requests": str((run_dir / "authority" / "requests").resolve()),
1062
+ }
1063
+ if result["status"] == "configuration_required":
1064
+ result.update({
1065
+ "action": "configure_trusted_search",
1066
+ "userPrompt": TRUSTED_SEARCH_CONFIGURATION_PROMPT,
1067
+ "configuration": trusted_search_configuration(),
1068
+ "blockedReason": "未配置 TRUSTED_SEARCH_KEY,禁止发起可信搜索或使用其他来源绕过",
1069
+ })
1070
+ dump_json(run_dir / "authority-gate.json", {
1071
+ "schemaVersion": "fact-check-x/authority-gate@1",
1072
+ "createdAt": now_iso(),
1073
+ "status": result["status"],
1074
+ "trustedSearchRequiredCount": required_count,
1075
+ "trustedSearchConfigured": configured,
1076
+ "requests": result["requests"],
1077
+ "comparisonSha256": comparison_provenance["files"]["comparison"]["sha256"],
1078
+ "requestHashes": request_hashes,
1079
+ })
1080
+ return result
1081
+
1082
+
1083
+ def search_authority(args: argparse.Namespace, skills: dict[str, Path]) -> dict:
1084
+ run_dir = require_run(args.run_dir)
1085
+ require_capture_gate(run_dir)
1086
+ authority_gate_path = run_dir / "authority-gate.json"
1087
+ if not authority_gate_path.exists():
1088
+ raise PipelineError("缺少权威核验门禁;必须先执行 prepare-authority")
1089
+ authority_gate = load_json(authority_gate_path)
1090
+ if authority_gate.get("schemaVersion") != "fact-check-x/authority-gate@1":
1091
+ raise PipelineError("权威核验门禁版本不正确")
1092
+ requests_manifest = load_json(run_dir / "authority" / "requests" / "manifest.json")
1093
+ request_ids = expected_request_ids(requests_manifest)
1094
+ current_request_hashes = json_file_manifest(
1095
+ run_dir / "authority" / "requests",
1096
+ {f"{request_id}.json" for request_id in request_ids} | {"manifest.json"},
1097
+ )
1098
+ if current_request_hashes != authority_gate.get("requestHashes"):
1099
+ raise PipelineError("权威核验请求在 prepare-authority 后被修改,禁止搜索")
1100
+ require_comparison_provenance(run_dir)
1101
+ required_count = sum(not entry.get("officialExempt") for entry in requests_manifest.get("requests") or [])
1102
+ key = trusted_search_key()
1103
+ if required_count and not args.fixtures:
1104
+ key = validated_authority_key()
1105
+ configured = bool(key)
1106
+ if required_count and not configured and not args.fixtures:
1107
+ return {
1108
+ "status": "configuration_required",
1109
+ "action": "configure_trusted_search",
1110
+ "trustedSearchRequiredCount": required_count,
1111
+ "trustedSearchConfigured": False,
1112
+ "userPrompt": TRUSTED_SEARCH_CONFIGURATION_PROMPT,
1113
+ "configuration": trusted_search_configuration(),
1114
+ "blockedReason": "未配置 TRUSTED_SEARCH_KEY,禁止发起可信搜索或使用其他来源绕过",
1115
+ }
1116
+ if authority_gate.get("status") == "configuration_required" and not args.fixtures:
1117
+ return {
1118
+ "status": "configuration_required",
1119
+ "action": "configure_trusted_search",
1120
+ "trustedSearchRequiredCount": required_count,
1121
+ "trustedSearchConfigured": configured,
1122
+ "userPrompt": TRUSTED_SEARCH_CONFIGURATION_PROMPT,
1123
+ "configuration": trusted_search_configuration(),
1124
+ "blockedReason": "配置完成后必须重新执行 prepare-authority,禁止绕过权威核验门禁",
1125
+ }
1126
+ if authority_gate.get("status") != "prepared":
1127
+ raise PipelineError("权威核验门禁状态不允许搜索;请重新执行 prepare-authority")
1128
+ command = [
1129
+ sys.executable,
1130
+ str(skills["authority"] / "scripts" / "batch_search.py"),
1131
+ "--requests-dir",
1132
+ str(run_dir / "authority" / "requests"),
1133
+ "--output-dir",
1134
+ str(run_dir / "authority" / "evidence"),
1135
+ "--max-workers",
1136
+ str(args.max_workers),
1137
+ ]
1138
+ if args.service_area:
1139
+ command.extend(["--service-area", args.service_area])
1140
+ if args.fixtures:
1141
+ command.extend(["--fixtures", str(Path(args.fixtures).resolve())])
1142
+ environment = None
1143
+ if key:
1144
+ environment = {**os.environ, "TRUSTED_SEARCH_KEY": key}
1145
+ result = run(command, environment)
1146
+ if result.get("status") != "completed":
1147
+ raise PipelineError("可信搜索批次未完成,禁止进入裁决")
1148
+ evidence_hashes = json_file_manifest(
1149
+ run_dir / "authority" / "evidence",
1150
+ {f"{request_id}.json" for request_id in request_ids} | {"batch.json"},
1151
+ )
1152
+ dump_json(authority_gate_path, {
1153
+ **authority_gate,
1154
+ "status": "searched",
1155
+ "searchedAt": now_iso(),
1156
+ "batch": str((run_dir / "authority" / "evidence" / "batch.json").resolve()),
1157
+ "evidenceHashes": evidence_hashes,
1158
+ })
1159
+ return result
1160
+
1161
+
1162
+ def finalize_authority(args: argparse.Namespace, skills: dict[str, Path]) -> dict:
1163
+ run_dir = require_run(args.run_dir)
1164
+ require_capture_gate(run_dir)
1165
+ authority_gate_path = run_dir / "authority-gate.json"
1166
+ if not authority_gate_path.exists():
1167
+ raise PipelineError("缺少权威核验门禁,禁止裁决")
1168
+ authority_gate = load_json(authority_gate_path)
1169
+ if (
1170
+ authority_gate.get("schemaVersion") != "fact-check-x/authority-gate@1"
1171
+ or authority_gate.get("status") != "searched"
1172
+ ):
1173
+ raise PipelineError("可信搜索尚未通过程序门禁,禁止写入裁决或进入最终报告")
1174
+ if not (run_dir / "authority" / "evidence" / "batch.json").exists():
1175
+ raise PipelineError("缺少可信搜索批次证明,禁止裁决")
1176
+ requests_dir = run_dir / "authority" / "requests"
1177
+ manifest = load_json(requests_dir / "manifest.json")
1178
+ request_ids = expected_request_ids(manifest)
1179
+ if json_file_manifest(
1180
+ requests_dir,
1181
+ {f"{request_id}.json" for request_id in request_ids} | {"manifest.json"},
1182
+ ) != authority_gate.get("requestHashes"):
1183
+ raise PipelineError("权威核验请求已被修改,禁止裁决")
1184
+ if json_file_manifest(
1185
+ run_dir / "authority" / "evidence",
1186
+ {f"{request_id}.json" for request_id in request_ids} | {"batch.json"},
1187
+ ) != authority_gate.get("evidenceHashes"):
1188
+ raise PipelineError("权威证据包已被修改或混入陈旧文件,禁止裁决")
1189
+ require_comparison_provenance(run_dir)
1190
+ evidence_dir = run_dir / "authority" / "evidence"
1191
+ assessments_dir = Path(args.assessments_dir).resolve() if args.assessments_dir else run_dir / "authority" / "assessments"
1192
+ results_dir = run_dir / "authority" / "results"
1193
+ results_dir.mkdir(parents=True, exist_ok=True)
1194
+ existing_results = list(results_dir.glob("*.json"))
1195
+ if existing_results:
1196
+ raise PipelineError("results 目录必须为空;检测到旧文件或手工结果,禁止覆盖式裁决")
1197
+ expected_assessments = set()
1198
+ for request_id in request_ids:
1199
+ evidence = load_json(evidence_dir / f"{request_id}.json")
1200
+ if evidence.get("status") == "verified":
1201
+ expected_assessments.add(f"{request_id}.json")
1202
+ assessment_hashes = json_file_manifest(assessments_dir, expected_assessments)
1203
+ statuses = []
1204
+ evidence_gap_count = 0
1205
+ verification_path = run_dir / "verification.json"
1206
+ authority_report = run_dir / "03-authority-report.html"
1207
+ try:
1208
+ for entry in manifest.get("requests") or []:
1209
+ request_id = entry["requestId"]
1210
+ command = [
1211
+ sys.executable,
1212
+ str(skills["authority"] / "scripts" / "authority_verify.py"),
1213
+ "finalize",
1214
+ "--request",
1215
+ str(requests_dir / f"{request_id}.json"),
1216
+ "--evidence",
1217
+ str(evidence_dir / f"{request_id}.json"),
1218
+ "--output",
1219
+ str(results_dir / f"{request_id}.json"),
1220
+ ]
1221
+ assessment = assessments_dir / f"{request_id}.json"
1222
+ if assessment.exists():
1223
+ command.extend(["--assessment", str(assessment)])
1224
+ statuses.append(run(command).get("status"))
1225
+ point_result = load_json(results_dir / f"{request_id}.json")
1226
+ evidence_gap_count += len(point_result.get("evidenceGaps") or [])
1227
+ final_status = "completed"
1228
+ result_hashes = json_file_manifest(
1229
+ results_dir, {f"{request_id}.json" for request_id in request_ids}
1230
+ )
1231
+ comparison = load_json(run_dir / "comparison.json")
1232
+ verification = merge_verification(comparison, results_dir)
1233
+ dump_json(verification_path, verification)
1234
+ run([
1235
+ sys.executable,
1236
+ str(skills["authority"] / "scripts" / "render_authority_report.py"),
1237
+ "--verification",
1238
+ str(verification_path),
1239
+ "--output",
1240
+ str(authority_report),
1241
+ ])
1242
+ normalize_report_file(authority_report, collect_technical_notices(run_dir))
1243
+ dump_json(authority_gate_path, {
1244
+ **authority_gate,
1245
+ "status": "finalized",
1246
+ "finalizedAt": now_iso(),
1247
+ "finalizeStatus": final_status,
1248
+ "results": str(results_dir.resolve()),
1249
+ "assessmentHashes": assessment_hashes,
1250
+ "resultHashes": result_hashes,
1251
+ "verificationSha256": capture_digest(verification_path),
1252
+ "authorityReportSha256": capture_digest(authority_report),
1253
+ })
1254
+ except Exception:
1255
+ for result_path in results_dir.glob("*.json"):
1256
+ result_path.unlink()
1257
+ verification_path.unlink(missing_ok=True)
1258
+ authority_report.unlink(missing_ok=True)
1259
+ dump_json(authority_gate_path, authority_gate)
1260
+ raise
1261
+ return {
1262
+ "status": final_status,
1263
+ "stage": "authority_completed",
1264
+ "taskCount": len(statuses),
1265
+ "evidenceGapCount": evidence_gap_count,
1266
+ "results": str(results_dir.resolve()),
1267
+ "artifacts": {
1268
+ "verification": str(verification_path.resolve()),
1269
+ "authorityReport": str(authority_report.resolve()),
1270
+ "authorityGate": str(authority_gate_path.resolve()),
1271
+ },
1272
+ "deliverables": [deliverable_descriptor("权威核验后的最终答案", authority_report)],
1273
+ "checkpoint": record_stage_checkpoint(
1274
+ run_dir,
1275
+ "authority",
1276
+ "权威核验后的最终答案",
1277
+ authority_report,
1278
+ "evaluation",
1279
+ ),
1280
+ }
1281
+
1282
+
1283
+ def reopen_authority(args: argparse.Namespace) -> dict:
1284
+ run_dir = require_run(args.run_dir)
1285
+ authority_gate_path = run_dir / "authority-gate.json"
1286
+ if not authority_gate_path.is_file():
1287
+ raise PipelineError("缺少已完成的权威核验门禁,无法重开")
1288
+ authority_gate = load_json(authority_gate_path)
1289
+ if (
1290
+ authority_gate.get("schemaVersion") != "fact-check-x/authority-gate@1"
1291
+ or authority_gate.get("status") != "finalized"
1292
+ or authority_gate.get("finalizeStatus") != "completed"
1293
+ ):
1294
+ raise PipelineError("只有已完成且未被修改的权威核验可以受控重开")
1295
+ requests_dir = run_dir / "authority" / "requests"
1296
+ evidence_dir = run_dir / "authority" / "evidence"
1297
+ results_dir = run_dir / "authority" / "results"
1298
+ assessments_dir = run_dir / "authority" / "assessments"
1299
+ manifest = load_json(requests_dir / "manifest.json")
1300
+ request_ids = expected_request_ids(manifest)
1301
+ if json_file_manifest(
1302
+ requests_dir,
1303
+ {f"{request_id}.json" for request_id in request_ids} | {"manifest.json"},
1304
+ ) != authority_gate.get("requestHashes"):
1305
+ raise PipelineError("请求文件已被修改,禁止重开")
1306
+ if json_file_manifest(
1307
+ evidence_dir,
1308
+ {f"{request_id}.json" for request_id in request_ids} | {"batch.json"},
1309
+ ) != authority_gate.get("evidenceHashes"):
1310
+ raise PipelineError("证据文件已被修改,禁止重开")
1311
+ if json_file_manifest(
1312
+ results_dir,
1313
+ {f"{request_id}.json" for request_id in request_ids},
1314
+ ) != authority_gate.get("resultHashes"):
1315
+ raise PipelineError("裁决结果已被修改或混入陈旧文件,禁止重开")
1316
+ if json_file_manifest(
1317
+ assessments_dir,
1318
+ set((authority_gate.get("assessmentHashes") or {}).keys()),
1319
+ ) != authority_gate.get("assessmentHashes"):
1320
+ raise PipelineError("assessment 已被修改;请先恢复到锁定版本再重开")
1321
+ verification_path = run_dir / "verification.json"
1322
+ authority_report = run_dir / "03-authority-report.html"
1323
+ if (
1324
+ not verification_path.is_file()
1325
+ or capture_digest(verification_path) != authority_gate.get("verificationSha256")
1326
+ or not authority_report.is_file()
1327
+ or capture_digest(authority_report) != authority_gate.get("authorityReportSha256")
1328
+ ):
1329
+ raise PipelineError("第三步锁定产物已缺失或被修改,禁止重开")
1330
+
1331
+ revisions_root = run_dir / "authority" / "revisions"
1332
+ revisions_root.mkdir(parents=True, exist_ok=True)
1333
+ revision_number = len([path for path in revisions_root.iterdir() if path.is_dir()]) + 1
1334
+ revision_dir = revisions_root / f"revision-{revision_number:03d}"
1335
+ revision_dir.mkdir()
1336
+ archive_paths = (
1337
+ Path("authority-gate.json"),
1338
+ Path(INTERACTION_GATE_FILE),
1339
+ Path("verification.json"),
1340
+ Path("03-authority-report.html"),
1341
+ Path("04-final-report.html"),
1342
+ Path("report.html"),
1343
+ Path("pipeline.json"),
1344
+ Path(PORTABLE_REPORT_PACKAGE),
1345
+ Path("report-input"),
1346
+ Path("authority/results"),
1347
+ Path("authority/assessments"),
1348
+ )
1349
+ archived = []
1350
+ for relative_path in archive_paths:
1351
+ source = run_dir / relative_path
1352
+ if not source.exists():
1353
+ continue
1354
+ destination = revision_dir / relative_path
1355
+ destination.parent.mkdir(parents=True, exist_ok=True)
1356
+ if source.is_dir():
1357
+ shutil.copytree(source, destination)
1358
+ else:
1359
+ shutil.copy2(source, destination)
1360
+ archived.append(str(relative_path).replace("\\", "/"))
1361
+ dump_json(revision_dir / "revision.json", {
1362
+ "schemaVersion": "fact-check-x/authority-revision@1",
1363
+ "revision": revision_number,
1364
+ "createdAt": now_iso(),
1365
+ "reason": str(args.reason).strip(),
1366
+ "archived": archived,
1367
+ "priorFinalizedAt": authority_gate.get("finalizedAt"),
1368
+ "priorVerificationSha256": authority_gate.get("verificationSha256"),
1369
+ "priorAuthorityReportSha256": authority_gate.get("authorityReportSha256"),
1370
+ })
1371
+
1372
+ for result_path in results_dir.glob("*.json"):
1373
+ result_path.unlink()
1374
+ for stale in (
1375
+ verification_path,
1376
+ authority_report,
1377
+ run_dir / "04-final-report.html",
1378
+ run_dir / "report.html",
1379
+ run_dir / "pipeline.json",
1380
+ run_dir / PORTABLE_REPORT_PACKAGE,
1381
+ ):
1382
+ stale.unlink(missing_ok=True)
1383
+ shutil.rmtree(run_dir / "report-input", ignore_errors=True)
1384
+ interaction_gate = load_interaction_gate(run_dir)
1385
+ stages = interaction_gate.setdefault("stages", {})
1386
+ stages.pop("authority", None)
1387
+ stages.pop("evaluation", None)
1388
+ dump_json(interaction_gate_path(run_dir), interaction_gate)
1389
+ reopened_at = now_iso()
1390
+ reopen_history = list(authority_gate.get("reopenHistory") or [])
1391
+ reopen_history.append({
1392
+ "revision": revision_number,
1393
+ "reopenedAt": reopened_at,
1394
+ "reason": str(args.reason).strip(),
1395
+ "archive": str(revision_dir.resolve()),
1396
+ })
1397
+ reset_gate = {
1398
+ key: value for key, value in authority_gate.items()
1399
+ if key not in {
1400
+ "finalizedAt", "finalizeStatus", "results", "assessmentHashes",
1401
+ "resultHashes", "verificationSha256", "authorityReportSha256"
1402
+ }
1403
+ }
1404
+ reset_gate.update({
1405
+ "status": "searched",
1406
+ "reopenedAt": reopened_at,
1407
+ "reopenReason": str(args.reason).strip(),
1408
+ "reopenHistory": reopen_history,
1409
+ })
1410
+ dump_json(authority_gate_path, reset_gate)
1411
+ return {
1412
+ "status": "completed",
1413
+ "stage": "authority_reopened",
1414
+ "revision": revision_number,
1415
+ "archive": str(revision_dir.resolve()),
1416
+ "nextCommand": "finalize-authority",
1417
+ "message": "旧裁决已完整归档;可修改 assessment 后重新 finalize,新的第三步报告仍需重新确认。",
1418
+ }
1419
+
1420
+
1421
+ def merge_verification(comparison: dict, results_dir: Path) -> dict:
1422
+ points = []
1423
+ evidence_gaps = []
1424
+ request_count = 0
1425
+ dknow_exempt_count = 0
1426
+ gov_exempt_count = 0
1427
+ downgraded_anchor_ids: list[str] = []
1428
+ authority_verdicts: dict[tuple[str, str], str] = {}
1429
+ for point in comparison.get("knowledgePoints") or []:
1430
+ path = results_dir / f"{point['id']}.json"
1431
+ if not path.exists():
1432
+ raise PipelineError(f"缺少单点核验结果 {point['id']}")
1433
+ authority = load_json(path)
1434
+ if authority.get("schemaVersion") != "fact-check-x/authority-result@1" or authority.get("requestId") != point["id"]:
1435
+ raise PipelineError(f"{point['id']} 的单点核验结果版本或 ID 不正确")
1436
+ exempt_policy = official_exempt_policy(point)
1437
+ expected_mode = {
1438
+ "dknow_official_reference": "dknow_exempt",
1439
+ "gov_cn_reference": "gov_exempt",
1440
+ }.get(exempt_policy, "trusted_search")
1441
+ expected_count = 0 if exempt_policy else 1
1442
+ actual_mode = str(authority.get("searchMode") or "")
1443
+ actual_count = int(authority.get("requestCount") or 0)
1444
+ # 非对称门禁:比较阶段依据引用池认定「免搜索」,核验阶段可能独立判定该锚点
1445
+ # 撑不住主张而改走可信搜索。多搜一次是保守方向,放行;反过来把本应搜索的
1446
+ # 知识点降级成免搜索属于洗白,必须拦住。
1447
+ if actual_mode == expected_mode:
1448
+ if actual_count != expected_count:
1449
+ raise PipelineError(
1450
+ f"{point['id']} 权威证据模式为 {actual_mode},"
1451
+ f"请求数应为 {expected_count},实际为 {actual_count}"
1452
+ )
1453
+ elif exempt_policy and actual_mode == "trusted_search" and actual_count >= 1:
1454
+ downgraded_anchor_ids.append(str(point["id"]))
1455
+ else:
1456
+ raise PipelineError(
1457
+ f"{point['id']} 权威证据模式应为 {expected_mode},"
1458
+ f"请求数应为 {expected_count},"
1459
+ f"实际为 {actual_mode or '未知'}/{actual_count}"
1460
+ )
1461
+ request_count += int(authority.get("requestCount") or 0)
1462
+ dknow_exempt_count += int(authority.get("searchMode") == "dknow_exempt")
1463
+ gov_exempt_count += int(authority.get("searchMode") == "gov_exempt")
1464
+ for platform, verdict in (authority.get("verdicts") or {}).items():
1465
+ authority_verdicts[(point["id"], platform)] = str(
1466
+ verdict.get("verdict") or ""
1467
+ )
1468
+ finding = str(authority.get("authoritativeFinding") or "").strip()
1469
+ if not finding:
1470
+ finding = "未取得足够权威证据;该知识点保持证据不足。"
1471
+ authority = {**authority, "authoritativeFinding": finding}
1472
+ for gap in authority.get("evidenceGaps") or []:
1473
+ evidence_gaps.append({"stage": "authority", "knowledgePointId": point["id"], **gap})
1474
+ points.append({**point, "authority": authority})
1475
+ for review in comparison.get("analysisGaps") or []:
1476
+ key = (
1477
+ str(review.get("knowledgePointId") or ""),
1478
+ str(review.get("platform") or ""),
1479
+ )
1480
+ if (
1481
+ review.get("stage") == "comparison"
1482
+ and authority_verdicts.get(key) in ("supported", "contradicted")
1483
+ ):
1484
+ continue
1485
+ evidence_gaps.append({
1486
+ **review,
1487
+ "reason": str(review.get("reason") or "")
1488
+ or "当前分析信息不足,且权威证据未能完成裁决",
1489
+ })
1490
+ final_answer_lines = []
1491
+ final_answer_items = []
1492
+ final_answer_point_ids = []
1493
+ excluded_point_ids = []
1494
+ supplemental_items = []
1495
+ supplemental_point_ids = []
1496
+ supplemental_excluded_point_ids = []
1497
+ for point in points:
1498
+ authority = point.get("authority") or {}
1499
+ claims = authority.get("claims") or {}
1500
+ verdicts = authority.get("verdicts") or {}
1501
+ resolved = any(
1502
+ claims.get(platform, {}).get("covered")
1503
+ and (
1504
+ verdict.get("verdict") in {"supported", "contradicted"}
1505
+ or verdict.get("category") == "recommendation"
1506
+ )
1507
+ for platform, verdict in verdicts.items()
1508
+ )
1509
+ point_id = str(point.get("id") or "")
1510
+ role = str(point.get("role") or "direct")
1511
+ if not resolved:
1512
+ if role == "direct":
1513
+ excluded_point_ids.append(point_id)
1514
+ else:
1515
+ supplemental_excluded_point_ids.append(point_id)
1516
+ continue
1517
+ finding = str(
1518
+ (authority.get("authoritativeFinding") or "")
1519
+ ).strip()
1520
+ title = str(point.get("description") or point.get("id") or "").strip()
1521
+ answer = finding
1522
+ item = {
1523
+ "knowledgePointId": point_id,
1524
+ "title": title,
1525
+ "answer": answer,
1526
+ }
1527
+ if role == "direct":
1528
+ final_answer_point_ids.append(point_id)
1529
+ final_answer_lines.append(answer)
1530
+ final_answer_items.append(item)
1531
+ else:
1532
+ supplemental_point_ids.append(point_id)
1533
+ supplemental_items.append(item)
1534
+ final_answer_status = (
1535
+ "verified"
1536
+ if final_answer_point_ids and not excluded_point_ids
1537
+ else "partially_verified"
1538
+ if final_answer_point_ids
1539
+ else "insufficient_evidence"
1540
+ )
1541
+ return {
1542
+ "schemaVersion": "fact-check-x/verification@2",
1543
+ "question": comparison.get("question"),
1544
+ "coreQuestion": comparison.get("coreQuestion"),
1545
+ "finalAnswer": {
1546
+ "status": final_answer_status,
1547
+ "answer": "\n".join(final_answer_lines)
1548
+ or "当前没有足够权威证据形成确定答案;各项证据边界已在报告中列明。",
1549
+ "items": final_answer_items,
1550
+ "knowledgePointIds": final_answer_point_ids,
1551
+ "excludedKnowledgePointIds": excluded_point_ids,
1552
+ },
1553
+ "supplementalFindings": {
1554
+ "items": supplemental_items,
1555
+ "knowledgePointIds": supplemental_point_ids,
1556
+ "excludedKnowledgePointIds": supplemental_excluded_point_ids,
1557
+ },
1558
+ "createdAt": now_iso(),
1559
+ "platforms": comparison.get("platforms") or [],
1560
+ "knowledgePoints": points,
1561
+ "trustedSearchRequestCount": request_count,
1562
+ "dknowExemptCount": dknow_exempt_count,
1563
+ "govExemptCount": gov_exempt_count,
1564
+ "anchorDowngrades": downgraded_anchor_ids,
1565
+ "officialExemptCount": dknow_exempt_count + gov_exempt_count,
1566
+ "evidenceGaps": evidence_gaps,
1567
+ "evidenceGapCount": len(evidence_gaps),
1568
+ "status": "completed",
1569
+ }
1570
+
1571
+
1572
+ def deliver(args: argparse.Namespace, skills: dict[str, Path]) -> dict:
1573
+ run_dir = require_run(args.run_dir)
1574
+ require_stage_acknowledged(run_dir, "capture")
1575
+ require_stage_acknowledged(run_dir, "comparison")
1576
+ require_stage_acknowledged(run_dir, "authority")
1577
+ results_path = Path(args.results).resolve()
1578
+ require_capture_gate(run_dir, results_path)
1579
+ results = validate_capture_results(results_path)
1580
+ authority_gate_path = run_dir / "authority-gate.json"
1581
+ if not authority_gate_path.exists():
1582
+ raise PipelineError("缺少权威核验门禁,禁止生成最终报告")
1583
+ authority_gate = load_json(authority_gate_path)
1584
+ if (
1585
+ authority_gate.get("schemaVersion") != "fact-check-x/authority-gate@1"
1586
+ or authority_gate.get("status") != "finalized"
1587
+ or authority_gate.get("finalizeStatus") != "completed"
1588
+ ):
1589
+ raise PipelineError("权威核验尚未通过取证与裁决门禁,禁止生成最终报告")
1590
+ comparison_gate = require_comparison_provenance(run_dir)
1591
+ comparison_path = Path(args.comparison).resolve() if args.comparison else run_dir / "comparison.json"
1592
+ if comparison_path != Path(comparison_gate["files"]["comparison"]["path"]).resolve():
1593
+ raise PipelineError("deliver 只能使用 provenance 门禁锁定的 comparison.json")
1594
+ requests_manifest = load_json(run_dir / "authority" / "requests" / "manifest.json")
1595
+ request_ids = expected_request_ids(requests_manifest)
1596
+ if json_file_manifest(
1597
+ run_dir / "authority" / "requests",
1598
+ {f"{request_id}.json" for request_id in request_ids} | {"manifest.json"},
1599
+ ) != authority_gate.get("requestHashes"):
1600
+ raise PipelineError("请求文件在裁决后被修改,禁止交付")
1601
+ if json_file_manifest(
1602
+ run_dir / "authority" / "evidence",
1603
+ {f"{request_id}.json" for request_id in request_ids} | {"batch.json"},
1604
+ ) != authority_gate.get("evidenceHashes"):
1605
+ raise PipelineError("证据文件在裁决后被修改,禁止交付")
1606
+ if json_file_manifest(
1607
+ run_dir / "authority" / "results",
1608
+ {f"{request_id}.json" for request_id in request_ids},
1609
+ ) != authority_gate.get("resultHashes"):
1610
+ raise PipelineError("结果文件被修改或混入额外/陈旧 ID,禁止交付")
1611
+ assessments_dir = run_dir / "authority" / "assessments"
1612
+ if json_file_manifest(assessments_dir, set((authority_gate.get("assessmentHashes") or {}).keys())) != authority_gate.get("assessmentHashes"):
1613
+ raise PipelineError("assessment 文件被修改、缺失或混入额外文件,禁止交付")
1614
+ verification_path = run_dir / "verification.json"
1615
+ report_path = run_dir / "report.html"
1616
+ capture_deliverable = run_dir / "01-capture-report.html"
1617
+ comparison_deliverable = run_dir / "02-comparison-report.html"
1618
+ authority_deliverable = run_dir / "03-authority-report.html"
1619
+ final_deliverable = run_dir / "04-final-report.html"
1620
+ report_package = run_dir / PORTABLE_REPORT_PACKAGE
1621
+ if not verification_path.is_file():
1622
+ raise PipelineError("缺少第三步已锁定的 verification.json,禁止重算或交付")
1623
+ if capture_digest(verification_path) != authority_gate.get("verificationSha256"):
1624
+ raise PipelineError("第三步权威核验结果已被修改,禁止第四步重算或交付")
1625
+ if (
1626
+ not authority_deliverable.is_file()
1627
+ or capture_digest(authority_deliverable)
1628
+ != authority_gate.get("authorityReportSha256")
1629
+ ):
1630
+ raise PipelineError("第三步报告已缺失或被修改,禁止第四步继续")
1631
+ verification = load_json(verification_path)
1632
+ for stale in (
1633
+ run_dir / "03-final-report.html",
1634
+ run_dir / "04-complete-report-package.zip",
1635
+ ):
1636
+ stale.unlink(missing_ok=True)
1637
+ capture_report = {
1638
+ "results": str((run_dir / "capture" / "results.json").resolve()),
1639
+ "report": str((run_dir / "capture" / "report.html").resolve()),
1640
+ "markdown": str((run_dir / "capture" / "report.md").resolve()),
1641
+ }
1642
+ for required in (
1643
+ capture_deliverable,
1644
+ comparison_deliverable,
1645
+ authority_deliverable,
1646
+ Path(capture_report["results"]),
1647
+ Path(capture_report["report"]),
1648
+ Path(capture_report["markdown"]),
1649
+ ):
1650
+ if not required.is_file():
1651
+ raise PipelineError(f"缺少已完成阶段的锁定产物:{required.name}")
1652
+ run([
1653
+ sys.executable,
1654
+ str(skills["authority"] / "scripts" / "render_final_report.py"),
1655
+ "--results",
1656
+ str(results_path),
1657
+ "--comparison",
1658
+ str(comparison_path),
1659
+ "--verification",
1660
+ str(verification_path),
1661
+ "--output",
1662
+ str(report_path),
1663
+ "--intermediate-dir",
1664
+ str(run_dir / "report-input"),
1665
+ ])
1666
+ shutil.copyfile(report_path, final_deliverable)
1667
+ normalize_report_file(final_deliverable, collect_technical_notices(run_dir))
1668
+ checkpoint = record_stage_checkpoint(
1669
+ run_dir,
1670
+ "evaluation",
1671
+ "各方答案测评报告",
1672
+ final_deliverable,
1673
+ None,
1674
+ )
1675
+ manifest = {
1676
+ "schemaVersion": "fact-check-x/pipeline@2",
1677
+ "createdAt": now_iso(),
1678
+ "status": verification["status"],
1679
+ "skills": DEPENDENCIES,
1680
+ "knowledgePointCount": len(verification["knowledgePoints"]),
1681
+ "trustedSearchRequestCount": verification["trustedSearchRequestCount"],
1682
+ "dknowExemptCount": verification["dknowExemptCount"],
1683
+ "govExemptCount": verification["govExemptCount"],
1684
+ "officialExemptCount": verification["officialExemptCount"],
1685
+ "artifacts": {
1686
+ "sourceResults": str(results_path),
1687
+ "captureGate": str((run_dir / "capture-gate.json").resolve()),
1688
+ "results": capture_report["results"],
1689
+ "answerReferenceReport": capture_report["report"],
1690
+ "captureDeliverable": str(capture_deliverable.resolve()),
1691
+ "answerReferenceMarkdown": capture_report["markdown"],
1692
+ "comparisonTask": str((run_dir / "comparison-task.json").resolve()),
1693
+ "comparisonAnalysis": str(
1694
+ (run_dir / "comparison-analysis.json").resolve()
1695
+ ),
1696
+ "comparison": str(comparison_path.resolve()),
1697
+ "comparisonReport": str((run_dir / "comparison.html").resolve()),
1698
+ "comparisonDeliverable": str(comparison_deliverable.resolve()),
1699
+ "comparisonGate": str((run_dir / "comparison-gate.json").resolve()),
1700
+ "authorityGate": str((run_dir / "authority-gate.json").resolve()),
1701
+ "stageCheckpoints": str((run_dir / INTERACTION_GATE_FILE).resolve()),
1702
+ "authorityDirectory": str((run_dir / "authority").resolve()),
1703
+ "verification": str(verification_path.resolve()),
1704
+ "authorityReport": str(authority_deliverable.resolve()),
1705
+ "authorityDeliverable": str(authority_deliverable.resolve()),
1706
+ "report": str(report_path.resolve()),
1707
+ "finalDeliverable": str(final_deliverable.resolve()),
1708
+ "reportPackage": str(report_package.resolve()),
1709
+ },
1710
+ }
1711
+ dump_json(run_dir / "pipeline.json", manifest)
1712
+ package = build_portable_report_package(run_dir)
1713
+ manifest["artifacts"].update({
1714
+ "reportPackageSha256": package["sha256"],
1715
+ "reportPackageBytes": package["bytes"],
1716
+ })
1717
+ dump_json(run_dir / "pipeline.json", manifest)
1718
+ return {
1719
+ "status": manifest["status"],
1720
+ "manifest": str((run_dir / "pipeline.json").resolve()),
1721
+ "evidenceGapCount": len(verification.get("evidenceGaps") or []),
1722
+ "artifacts": manifest["artifacts"],
1723
+ "answerReferenceReport": manifest["artifacts"]["answerReferenceReport"],
1724
+ "comparisonReport": manifest["artifacts"]["comparisonReport"],
1725
+ "report": str(report_path.resolve()),
1726
+ "deliverables": [
1727
+ deliverable_descriptor("各方答案汇总", capture_deliverable),
1728
+ deliverable_descriptor("各方答案聚合(未核验)", comparison_deliverable),
1729
+ deliverable_descriptor("权威核验后的最终答案", authority_deliverable),
1730
+ deliverable_descriptor("各方答案测评报告", final_deliverable),
1731
+ deliverable_descriptor(
1732
+ "完整可分发报告包",
1733
+ package["path"],
1734
+ sha256=package["sha256"],
1735
+ portable=True,
1736
+ ),
1737
+ ],
1738
+ "checkpoint": checkpoint,
1739
+ "trustedSearchRequestCount": manifest["trustedSearchRequestCount"],
1740
+ "dknowExemptCount": manifest["dknowExemptCount"],
1741
+ "govExemptCount": manifest["govExemptCount"],
1742
+ "officialExemptCount": manifest["officialExemptCount"],
1743
+ }
1744
+
1745
+
1746
+ def parser() -> argparse.ArgumentParser:
1747
+ root = argparse.ArgumentParser(description="Fact-Check-X 轻量统一编排入口。")
1748
+ commands = root.add_subparsers(dest="command", required=True)
1749
+ commands.add_parser("locate")
1750
+ commands.add_parser("prepare-runtime")
1751
+ prepare = commands.add_parser("prepare-comparison")
1752
+ prepare.add_argument("--results", required=True)
1753
+ prepare.add_argument("--run-dir", required=True)
1754
+ prepare.add_argument(
1755
+ "--execution-mode",
1756
+ choices=("interactive",),
1757
+ default="interactive",
1758
+ help="固定为 interactive;每阶段均需用户确认后才能继续。",
1759
+ )
1760
+ acknowledge = commands.add_parser("acknowledge-stage")
1761
+ acknowledge.add_argument("--run-dir", required=True)
1762
+ acknowledge.add_argument(
1763
+ "--stage",
1764
+ required=True,
1765
+ choices=("capture", "comparison", "authority"),
1766
+ )
1767
+ acknowledge.add_argument("--token", required=True)
1768
+ acknowledge.add_argument(
1769
+ "--decision",
1770
+ required=True,
1771
+ choices=("continue", "revise", "stop"),
1772
+ )
1773
+ complete = commands.add_parser("complete-comparison")
1774
+ complete.add_argument("--results", required=True)
1775
+ complete.add_argument("--run-dir", required=True)
1776
+ complete.add_argument("--analysis")
1777
+ authority = commands.add_parser("prepare-authority")
1778
+ authority.add_argument("--run-dir", required=True)
1779
+ authority.add_argument("--comparison")
1780
+ search = commands.add_parser("search-authority")
1781
+ search.add_argument("--run-dir", required=True)
1782
+ search.add_argument("--max-workers", type=int, default=12)
1783
+ search.add_argument("--service-area", default="")
1784
+ search.add_argument("--fixtures")
1785
+ final = commands.add_parser("finalize-authority")
1786
+ final.add_argument("--run-dir", required=True)
1787
+ final.add_argument("--assessments-dir")
1788
+ reopen = commands.add_parser("reopen-authority")
1789
+ reopen.add_argument("--run-dir", required=True)
1790
+ reopen.add_argument("--reason", required=True)
1791
+ delivery = commands.add_parser("deliver")
1792
+ delivery.add_argument("--results", required=True)
1793
+ delivery.add_argument("--run-dir", required=True)
1794
+ delivery.add_argument("--comparison")
1795
+ return root
1796
+
1797
+
1798
+ def main() -> int:
1799
+ args = parser().parse_args()
1800
+ try:
1801
+ skills = locate_skills()
1802
+ if args.command == "locate":
1803
+ result = {"status": "completed", "skills": {key: str(path) for key, path in skills.items()}}
1804
+ elif args.command == "prepare-runtime":
1805
+ result = prepare_runtime(skills)
1806
+ elif args.command == "prepare-comparison":
1807
+ result = prepare_comparison(args, skills)
1808
+ elif args.command == "acknowledge-stage":
1809
+ result = acknowledge_stage(args)
1810
+ elif args.command == "complete-comparison":
1811
+ result = complete_comparison(args, skills)
1812
+ elif args.command == "prepare-authority":
1813
+ result = prepare_authority(args)
1814
+ elif args.command == "search-authority":
1815
+ result = search_authority(args, skills)
1816
+ elif args.command == "finalize-authority":
1817
+ result = finalize_authority(args, skills)
1818
+ elif args.command == "reopen-authority":
1819
+ result = reopen_authority(args)
1820
+ else:
1821
+ result = deliver(args, skills)
1822
+ print(json.dumps(result, ensure_ascii=False))
1823
+ if result.get("status") == "configuration_required":
1824
+ return 3
1825
+ return 0
1826
+ except (PipelineError, OSError, ValueError, json.JSONDecodeError) as exc:
1827
+ print(json.dumps({"status": "failed", "error": str(exc)}, ensure_ascii=False))
1828
+ return 1
1829
+
1830
+
1831
+ if __name__ == "__main__":
1832
+ raise SystemExit(main())