eduevidence 5.2.0

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 (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -0
  3. package/README.zh-CN.md +434 -0
  4. package/SKILL.md +195 -0
  5. package/bin/eduevidence.js +127 -0
  6. package/domains/education/manifest.json +183 -0
  7. package/domains/education/outcome_taxonomy.json +127 -0
  8. package/domains/manifest.json +26 -0
  9. package/domains/policy/frame.schema.json +234 -0
  10. package/domains/policy/manifest.json +10 -0
  11. package/domains/policy/methodology_checklist.json +109 -0
  12. package/domains/policy/outcome_taxonomy.json +53 -0
  13. package/domains/policy/references/causal-identification.md +45 -0
  14. package/domains/policy/references/cost-evidence.md +44 -0
  15. package/domains/policy/references/equity.md +42 -0
  16. package/domains/policy/references/evidence-hierarchy.md +41 -0
  17. package/domains/policy/references/implementation-evidence.md +47 -0
  18. package/eduevidence_cli.py +26 -0
  19. package/engine/__init__.py +11 -0
  20. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  21. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  22. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  23. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  24. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  25. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  26. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  27. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  28. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  29. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  30. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  31. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  32. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  33. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  34. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  35. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  36. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  37. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  38. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  39. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  40. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  41. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  42. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  43. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  44. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  45. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  46. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  47. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  48. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  49. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  50. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  51. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  52. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  53. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  54. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  55. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  56. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  57. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  58. package/engine/analysis.py +308 -0
  59. package/engine/bias.py +178 -0
  60. package/engine/briefs.py +106 -0
  61. package/engine/capabilities.py +99 -0
  62. package/engine/citation_check.py +192 -0
  63. package/engine/contracts.py +117 -0
  64. package/engine/datasets.py +165 -0
  65. package/engine/events.py +67 -0
  66. package/engine/evidence_graph.py +571 -0
  67. package/engine/evidence_review.py +88 -0
  68. package/engine/evidencecore.py +182 -0
  69. package/engine/gap_lens.py +132 -0
  70. package/engine/gaps.py +169 -0
  71. package/engine/graph_store.py +335 -0
  72. package/engine/graph_validate.py +87 -0
  73. package/engine/ids.py +77 -0
  74. package/engine/library.py +268 -0
  75. package/engine/library_builtin.py +301 -0
  76. package/engine/living.py +671 -0
  77. package/engine/log.py +39 -0
  78. package/engine/meta_analysis.py +333 -0
  79. package/engine/meta_synthesis.py +111 -0
  80. package/engine/migration.py +397 -0
  81. package/engine/mode_router.py +72 -0
  82. package/engine/paths.py +15 -0
  83. package/engine/pilot.py +368 -0
  84. package/engine/planner.py +126 -0
  85. package/engine/project.py +118 -0
  86. package/engine/projections.py +240 -0
  87. package/engine/robustness.py +109 -0
  88. package/engine/run.py +85 -0
  89. package/engine/semantics.py +135 -0
  90. package/engine/study_design.py +87 -0
  91. package/engine/synthesis.py +187 -0
  92. package/engine/tribunal.py +408 -0
  93. package/engine/update.py +113 -0
  94. package/engine/versions.py +12 -0
  95. package/install.sh +510 -0
  96. package/integrations/__init__.py +1 -0
  97. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  98. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  99. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  100. package/integrations/agent_mcp.py +856 -0
  101. package/integrations/smart_web_fetch.py +59 -0
  102. package/package.json +50 -0
  103. package/pyproject.toml +55 -0
  104. package/references/applicability-policy.md +88 -0
  105. package/references/education-framing.md +132 -0
  106. package/references/effect_size_formulas.md +35 -0
  107. package/references/evaluation-design.md +111 -0
  108. package/references/evidence-quality.md +79 -0
  109. package/references/grade_framework.md +29 -0
  110. package/references/intervention-design.md +98 -0
  111. package/references/methodology-audit.md +103 -0
  112. package/references/outcome-taxonomy.md +106 -0
  113. package/references/retrieval-protocol.md +142 -0
  114. package/references/skeptic-protocol.md +93 -0
  115. package/references/social_science_pitfalls.md +48 -0
  116. package/references/source-validity.md +140 -0
  117. package/references/tribunal-policy.md +112 -0
  118. package/references/wwc_standards.md +29 -0
  119. package/retrieval/__init__.py +1 -0
  120. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  121. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  122. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  123. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  124. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  125. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  126. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  127. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  128. package/retrieval/corpus_store.py +181 -0
  129. package/retrieval/dedupe.py +127 -0
  130. package/retrieval/failures.py +90 -0
  131. package/retrieval/fetch.py +435 -0
  132. package/retrieval/search.py +493 -0
  133. package/retrieval/source.py +160 -0
  134. package/retrieval/validate.py +257 -0
  135. package/schemas/agent-mcp-approval.schema.json +57 -0
  136. package/schemas/chart-spec.schema.json +88 -0
  137. package/schemas/cross-model-review.schema.json +28 -0
  138. package/schemas/education-frame.schema.json +106 -0
  139. package/schemas/evaluation.schema.json +35 -0
  140. package/schemas/evidence.schema.json +81 -0
  141. package/schemas/fetch-result.schema.json +119 -0
  142. package/schemas/intervention.schema.json +46 -0
  143. package/schemas/methodology.schema.json +102 -0
  144. package/schemas/report-result.schema.json +381 -0
  145. package/schemas/report-spec.schema.json +130 -0
  146. package/schemas/source.schema.json +311 -0
  147. package/schemas/v2/analysis-plan.schema.json +28 -0
  148. package/schemas/v2/analysis-run.schema.json +33 -0
  149. package/schemas/v2/claim.schema.json +26 -0
  150. package/schemas/v2/dataset-asset.schema.json +40 -0
  151. package/schemas/v2/decision-snapshot.schema.json +53 -0
  152. package/schemas/v2/evidence-link.schema.json +38 -0
  153. package/schemas/v2/finding.schema.json +47 -0
  154. package/schemas/v2/graph-revision.schema.json +30 -0
  155. package/schemas/v2/knowledge-gap.schema.json +40 -0
  156. package/schemas/v2/methodology-audit.schema.json +30 -0
  157. package/schemas/v2/outcome.schema.json +18 -0
  158. package/schemas/v2/project.schema.json +31 -0
  159. package/schemas/v2/research-intent.schema.json +24 -0
  160. package/schemas/v2/run.schema.json +43 -0
  161. package/schemas/v2/source.schema.json +24 -0
  162. package/schemas/v2/study-design.schema.json +67 -0
  163. package/schemas/v2/study.schema.json +37 -0
  164. package/schemas/v3/pilot-outcome.schema.json +132 -0
  165. package/schemas/v3/run-manifest.schema.json +193 -0
  166. package/schemas/v3/synthesis.schema.json +86 -0
  167. package/schemas/v4/drift-report.schema.json +66 -0
  168. package/schemas/v4/evidence-library.schema.json +42 -0
  169. package/schemas/v4/living-subscription.schema.json +55 -0
  170. package/schemas/v4/meta-analysis.schema.json +152 -0
  171. package/schemas/verdict.schema.json +56 -0
  172. package/scripts/__init__.py +0 -0
  173. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  174. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  175. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  176. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  177. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  178. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  179. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  180. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  181. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  182. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  183. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  184. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  185. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  186. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  187. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  188. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  189. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  190. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  191. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  192. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  193. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  194. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  195. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  196. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  197. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  198. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  199. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  200. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  201. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  202. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  203. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  204. package/scripts/audit_dois.py +292 -0
  205. package/scripts/bake_pack.sh +37 -0
  206. package/scripts/benchmark.py +183 -0
  207. package/scripts/benchmark_evaluator.py +371 -0
  208. package/scripts/benchmark_judge.py +535 -0
  209. package/scripts/benchmark_routing.py +120 -0
  210. package/scripts/benchmark_v2.py +304 -0
  211. package/scripts/benchmark_v3.py +552 -0
  212. package/scripts/build_esl_artifacts.py +1921 -0
  213. package/scripts/build_evidence_library.py +307 -0
  214. package/scripts/build_killer_demo.py +295 -0
  215. package/scripts/build_result.py +311 -0
  216. package/scripts/check_version_consistency.py +96 -0
  217. package/scripts/citation_check.py +123 -0
  218. package/scripts/claim_audit.py +157 -0
  219. package/scripts/complexity_gate.py +180 -0
  220. package/scripts/compute_confidence.py +176 -0
  221. package/scripts/dashboard_server.py +536 -0
  222. package/scripts/did_regression.py +315 -0
  223. package/scripts/effect_calculator.py +99 -0
  224. package/scripts/enrich_projects_human_and_lieflat.py +315 -0
  225. package/scripts/evidence_matrix.py +129 -0
  226. package/scripts/evidence_score.py +234 -0
  227. package/scripts/evidence_semantics.py +87 -0
  228. package/scripts/fetch_benchmark.py +177 -0
  229. package/scripts/generate_metrics.py +99 -0
  230. package/scripts/generate_new_projects.py +686 -0
  231. package/scripts/generate_promo.py +192 -0
  232. package/scripts/lint_report_layout.py +182 -0
  233. package/scripts/orchestrator.py +1456 -0
  234. package/scripts/pre_verdict_gate.py +513 -0
  235. package/scripts/quickstart.py +121 -0
  236. package/scripts/rebake_all_5themes.py +88 -0
  237. package/scripts/recompute_demo_quality.py +205 -0
  238. package/scripts/render_report.py +270 -0
  239. package/scripts/render_report_html.py +356 -0
  240. package/scripts/retraction_watch.py +110 -0
  241. package/scripts/run_workspace.py +337 -0
  242. package/scripts/serve_web.py +54 -0
  243. package/scripts/skill_lint.py +150 -0
  244. package/scripts/startup_probe.py +265 -0
  245. package/scripts/sync_killer_demo_report.py +270 -0
  246. package/scripts/test_adversarial_empirical.py +541 -0
  247. package/scripts/validate_schema.py +256 -0
  248. package/skill/agents/education-planner.md +80 -0
  249. package/skill/agents/evaluation-designer.md +74 -0
  250. package/skill/agents/evidence-analyst.md +106 -0
  251. package/skill/agents/evidence-judge.md +111 -0
  252. package/skill/agents/evidence-retriever.md +80 -0
  253. package/skill/agents/intervention-designer.md +82 -0
  254. package/skill/agents/method-reviewer.md +104 -0
  255. package/skill/agents/skeptic.md +89 -0
  256. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
  257. package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
  258. package/skill/sub-skills/data-analysis/SKILL.md +23 -0
  259. package/skill/sub-skills/ethics-review/SKILL.md +25 -0
  260. package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
  261. package/skill/sub-skills/evidence-review/SKILL.md +18 -0
  262. package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
  263. package/skill/sub-skills/literature-review/SKILL.md +21 -0
  264. package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
  265. package/skill/sub-skills/report-generation/SKILL.md +51 -0
  266. package/skill/sub-skills/research-planning/SKILL.md +21 -0
  267. package/skill/sub-skills/study-design/SKILL.md +16 -0
  268. package/skill/task-briefs/adjudicate.md +17 -0
  269. package/skill/task-briefs/audit.md +15 -0
  270. package/skill/task-briefs/challenge.md +15 -0
  271. package/skill/task-briefs/evaluate.md +13 -0
  272. package/skill/task-briefs/extract.md +16 -0
  273. package/skill/task-briefs/frame.md +17 -0
  274. package/skill/task-briefs/intervene.md +14 -0
  275. package/skill/task-briefs/present.md +16 -0
  276. package/skill/task-briefs/retrieve.md +15 -0
  277. package/visualization/eduevidence-report/assets/base.css +337 -0
  278. package/visualization/eduevidence-report/motion/motion.css +157 -0
  279. package/visualization/eduevidence-report/motion/motion.js +107 -0
  280. package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
  281. package/visualization/eduevidence-report/references/component-catalog.md +145 -0
  282. package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
  283. package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
  284. package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
  285. package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
  286. package/visualization/eduevidence-report/references/motion-system.md +31 -0
  287. package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
  288. package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
  289. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  290. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  291. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  292. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  293. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  294. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  295. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  296. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  297. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
  298. package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
  299. package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
  300. package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
  301. package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
  302. package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
  303. package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
  304. package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
  305. package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
  306. package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
  307. package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
  308. package/visualization/eduevidence-report/themes/academic.css +94 -0
  309. package/visualization/eduevidence-report/themes/claude.css +96 -0
  310. package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
  311. package/visualization/eduevidence-report/themes/datalab.css +151 -0
  312. package/visualization/eduevidence-report/themes/presentation.css +140 -0
@@ -0,0 +1,3211 @@
1
+ #!/usr/bin/env python3
2
+ """build_report.py — HTML Composer: single-file offline EduEvidence_Report.html
3
+ (v5 Iteration 6-9, SWF Iteration E).
4
+
5
+ Pipeline (SKILL.md §8):
6
+ result.json (+ result.zh.json)
7
+ -> contract validation (report-result.schema.json semantics)
8
+ -> claim-evidence-source audit (REPORT_INVALID gate, §27/§60)
9
+ -> adapters in-memory: ECharts specs / AntV infographics / Academic figures
10
+ -> numbers-match integrity gate
11
+ -> report_spec.json (visualization decision record)
12
+ -> single-file offline HTML: 双语(中文默认 + English 切换),
13
+ Visual Brief + AI 规划的 5–7 章 Full Report,静态优先 + JS 增强
14
+
15
+ 数据契约:
16
+ - result.json = 研究管线输出的原始数据(英文)
17
+ - result.zh.json = AI 生成的全中文平行版本(同构,枚举/ID/URL/数字不变)
18
+ - 渲染器按当前语言取数据 + 双语 UI 文案;数字一致性门对两份数据分别校验
19
+
20
+ Usage:
21
+ python3 visualization/eduevidence-report/scripts/build_report.py \
22
+ --result examples/ai-coding-assistant/result.json \
23
+ --result-zh examples/ai-coding-assistant/result.zh.json \
24
+ --out examples/ai-coding-assistant/EduEvidence_Report.html
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import html
30
+ import json
31
+ import re
32
+ import sys
33
+ from pathlib import Path
34
+ from urllib.parse import urlparse
35
+ from typing import Any
36
+
37
+ from build_charts import build_all as build_chart_specs, effect_outcomes
38
+ from build_figures import build_figure_data, render_figures, render_lieflat_gallery
39
+ from build_infographics import build_all as build_infographics
40
+ from lieflat_engine import REGISTRY as LIEFLAT_REGISTRY, LEGACY_TYPES, ACADEMIC_FIGURE_KEYS
41
+ from zh_labels import label
42
+
43
+ THEMES_DIR = Path(__file__).resolve().parent.parent / "themes"
44
+ MOTION_DIR = Path(__file__).resolve().parent.parent / "motion"
45
+ THEME_NAMES = ("claude", "academic", "datalab", "datalab-dark", "presentation")
46
+ RENDERER_VERSION = "1.0.0" # must match pyproject.toml version; recorded in artifact manifest
47
+ THEME_DISPLAY = {
48
+ "claude": "Claude Research [Light]",
49
+ "academic": "Academic Paper [Light]",
50
+ "datalab": "DataLab [Light]",
51
+ "datalab-dark": "DataLab [Dark]",
52
+ "presentation": "Presentation / Judge [Dark]",
53
+ }
54
+
55
+ # Provenance badge (plan R4): every report declares where its data came from.
56
+ # synthetic/hybrid get a loud warning style; absence of the field renders no chip
57
+ # (legacy packs), but new packs must always set it.
58
+ DATA_ORIGIN_LABELS = {
59
+ "real": {"zh": "数据来源:真实文献(流水线生成)", "en": "Data: real studies (pipeline-generated)"},
60
+ "manual_curated": {"zh": "数据来源:真实文献 · 人工精编", "en": "Data: real studies · manually curated"},
61
+ "synthetic": {"zh": "数据来源:合成演示数据(非真实研究)", "en": "Data: SYNTHETIC demo — not real studies"},
62
+ "hybrid": {"zh": "数据来源:混合(真实 + 合成)", "en": "Data: hybrid (real + synthetic)"},
63
+ }
64
+
65
+ # Full report is intentionally NOT a fixed 12-chapter template. The template exposes
66
+ # semantic modules; an upstream AI may group them into any 5–7 chapter outline by writing
67
+ # `report_outline.chapters`. If it does not, a six-chapter fallback keeps the report usable.
68
+ FULL_REPORT_MODULES = (
69
+ "decision", "scope", "retrieval", "outcomes", "evidence", "quality", "conflicts",
70
+ "trace", "applicability", "intervention", "evaluation", "sources",
71
+ )
72
+ DEFAULT_FULL_REPORT_PLAN = (
73
+ {"key": "decision", "title_zh": "结论、裁决与研究边界", "title_en": "Decision, Adjudication & Research Boundary",
74
+ "modules": ("decision", "scope")},
75
+ {"key": "evidence", "title_zh": "关键证据与结果分离", "title_en": "Key Evidence & Outcome Separation",
76
+ "modules": ("retrieval", "outcomes", "evidence")},
77
+ {"key": "quality", "title_zh": "证据可信度、反证与方法审计", "title_en": "Evidence Quality, Counterevidence & Method Audit",
78
+ "modules": ("quality", "conflicts", "trace")},
79
+ {"key": "action", "title_zh": "适用范围与教学行动", "title_en": "Applicability & Teaching Action",
80
+ "modules": ("applicability", "intervention")},
81
+ {"key": "evaluation", "title_zh": "试点设计、评估与停止条件", "title_en": "Pilot, Evaluation & Stop Conditions",
82
+ "modules": ("evaluation",)},
83
+ {"key": "sources", "title_zh": "来源、溯源与附录", "title_en": "Sources, Traceability & Appendix",
84
+ "modules": ("sources",)},
85
+ )
86
+
87
+ METHODOLOGY_LABELS_ZH = {
88
+ "control_group": "对照组", "randomization": "随机分配", "pre_test": "前测",
89
+ "post_test": "后测", "retention_test": "保持测试", "transfer_test": "迁移测试",
90
+ "sample_bias": "样本偏差", "self_selection": "自我选择偏差",
91
+ "measurement_validity": "测量效度", "confounders": "混杂因素",
92
+ "instructor_effect": "教师效应", "novelty_effect": "新奇效应",
93
+ "tool_version_effect": "工具版本效应", "ai_usage_policy": "AI 使用规则",
94
+ "dropout": "样本流失",
95
+ }
96
+
97
+ FRAME_ENUM_ZH = {
98
+ "teaching_decision": "教学决策",
99
+ "undergraduate_year_1": "大学一年级",
100
+ "computer_science": "计算机科学与技术",
101
+ "C_programming": "C 语言程序设计",
102
+ "compulsory_core_course": "必修核心课程",
103
+ "primary": "主要结果",
104
+ "secondary": "次要结果",
105
+ "risk": "风险结果",
106
+ }
107
+
108
+ FRAME_ENUM_EN = {
109
+ "teaching_decision": "Teaching decision",
110
+ "undergraduate_year_1": "First-year undergraduate",
111
+ "computer_science": "Computer science",
112
+ "C_programming": "C programming",
113
+ "compulsory_core_course": "Compulsory core course",
114
+ "primary": "Primary outcomes",
115
+ "secondary": "Secondary outcomes",
116
+ "risk": "Risk outcomes",
117
+ }
118
+
119
+ DIR_LABEL = {"support": "支持", "contradict": "反驳", "neutral": "中性"}
120
+ DIR_CLASS = {"support": "pos", "contradict": "neg", "neutral": "neu"}
121
+ DIR_COLOR = {"support": "#5E8A6A", "contradict": "#A85B53", "neutral": "#C99A4A"}
122
+ EFFECT_CLASS = {"positive": "pos", "negative": "neg", "null": "neu", "neutral": "neu"}
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # 双语 UI 文案
126
+ # ---------------------------------------------------------------------------
127
+
128
+ UI_ZH = {
129
+ "theme_label": "主题",
130
+ "lang_label": "语言",
131
+ "zh": "中文",
132
+ "en": "EN",
133
+ "visual_brief": "可视化摘要",
134
+ "full_report": "完整报告",
135
+ "contents": "目录",
136
+ "collapse_contents": "收起目录",
137
+ "expand_contents": "展开目录",
138
+ "expand_evidence": "查看完整证据",
139
+ "expand_methodology": "查看审计依据",
140
+ "expand_source": "查看来源与溯源",
141
+ "expand_details": "展开完整说明",
142
+ "what_this_means": "这意味着什么",
143
+ "original_title": "原文标题",
144
+ "original_text": "原文",
145
+ "full_report_intro": "结论前置:全部可追溯证据与方法学细节都在这里,关键论证位置穿插有意义的可视化,每个数字都能回查到 result.json。",
146
+ "section_titles": {
147
+ "01": "01 执行决策", "02": "02 结果证据概览", "03": "03 证据矩阵",
148
+ "04": "04 证据裁决", "05": "05 方法学审计", "06": "06 冲突分析",
149
+ "07": "07 主张-证据追溯", "08": "08 适用性", "09": "09 教学干预",
150
+ "10": "10 评价方案", "11": "11 基准测试", "12": "12 来源与溯源",
151
+ },
152
+ "section_leads": {
153
+ "01": "本节先给结论:最终怎么裁决、置信度多高、靠哪几条证据。",
154
+ "02": "一图看清:哪些学习结果有支持证据、哪些被反驳。",
155
+ "03": "每条证据来自哪项研究、测了什么、方向与质量如何;可筛选、可搜索。",
156
+ "04": "证据允许主张什么、不允许主张什么;缺失的关键证据是什么。",
157
+ "05": "研究质量可靠吗?哪些方法学问题让结论打折。",
158
+ "06": "不同研究为何结论不同;分歧出在哪一环。",
159
+ "07": "从结论到证据到原始来源,每一步都可追查。",
160
+ "08": "结论适用于谁、什么课程与结果、需要什么条件。",
161
+ "09": "试点怎么分阶段放开 AI 规则;什么情况必须叫停。",
162
+ "10": "如何验证效果:指标、对照、成功阈值。",
163
+ "11": "EduEvidence 自身基准表现:引用精度与成本。",
164
+ "12": "每篇文献是谁、出自哪里、如何获取。",
165
+ },
166
+ "decision_kpi": ["决策", "置信度", "证据最充分的结果", "最不确定的结果", "主要风险", "来源数量"],
167
+ "summary_title": "一句话结论",
168
+ "summary_question": "问题",
169
+ "summary_evidence": "依据",
170
+ "summary_action": "行动",
171
+ "outcome_table": ["结果类型", "正向效应", "负向效应", "零效应", "证据"],
172
+ "figure1_caption": "图 1. 各结果类型的正向 / 负向 / 零效应证据数量(基于 effect_direction,不等同于 Claim 是否被支持)。",
173
+ "matrix_filter": "筛选 / 搜索",
174
+ "matrix_search_ph": "搜索证据…",
175
+ "matrix_all_dir": "全部效应",
176
+ "matrix_all_outcome": "全部结果",
177
+ "matrix_heads": ["ID", "结果", "效应", "质量", "主张", "来源"],
178
+ "matrix_details": "查看完整证据",
179
+ "matrix_detail_labels": ["研究标题", "研究设计", "人群", "干预", "直接性"],
180
+ "matrix_source_missing": "无可验证来源",
181
+ "hero_action": "建议决策",
182
+ "hero_confidence": "置信度",
183
+ "hero_supported": "最强支持结论",
184
+ "hero_uncertain": "关键不确定性 / 反例",
185
+ "hero_risk": "主要风险",
186
+ "hero_next": "下一步",
187
+ "hero_provenance": "证据 / 来源",
188
+ "outcome_separation_title": "结果分离 · 任务表现 ≠ 学习效果",
189
+ "effect_positive": "正向效应",
190
+ "effect_negative": "负向效应",
191
+ "effect_null": "零效应",
192
+ "outcome_group_task": "任务 / 近端表现",
193
+ "outcome_group_learning": "学习 / 保持 / 迁移",
194
+ "outcome_group_risk": "风险 / 依赖",
195
+ "outcome_group_other": "其他结果",
196
+ "outcome_sep_note": "将不同结果类型分开裁决,避免把训练时更快、更高分直接等同于真正学会。",
197
+ "tribunal_decision": "决策",
198
+ "tribunal_confidence": "置信度",
199
+ "tribunal_can": "可以主张",
200
+ "tribunal_uncertain": "尚不能主张",
201
+ "tribunal_cannot": "被反驳的主张",
202
+ "tribunal_missing": "缺失证据",
203
+ "tribunal_flow": "EvidenceFlow 协议",
204
+ "tribunal_figure": "裁决信息图",
205
+ "method_audit_heads": ["检查项", "状态", "说明"],
206
+ "method_guard": "任务 vs 学习护栏",
207
+ "conflict_verdict": "裁决说明",
208
+ "trace_decision": "决策",
209
+ "trace_claim_prefix": "主张",
210
+ "trace_no_source": "无来源",
211
+ "applicability": ["适用于谁", "适用课程", "适用结果", "适用条件", "目标人群", "目标情境"],
212
+ "intervention_learners": "目标学习者",
213
+ "intervention_duration": "试点时长",
214
+ "intervention_policy": "AI 使用规则",
215
+ "intervention_rule": "AI 规则",
216
+ "intervention_activities": "活动",
217
+ "intervention_check": "结果检查",
218
+ "intervention_stop": "停止条件",
219
+ "intervention_timeline": "干预时间线信息图",
220
+ "evaluation_question": "研究问题",
221
+ "evaluation_measures": ["基线", "后测", "保持测试", "迁移测试"],
222
+ "evaluation_metrics": ["过程指标", "学习指标", "风险指标"],
223
+ "evaluation_threshold": "成功阈值",
224
+ "evaluation_plan": "分析计划",
225
+ "evaluation_figure": "评价设计信息图",
226
+ "benchmark_note": "result.json 未携带 benchmark.baselines,本图不绘制;基准表现见独立基准报告。",
227
+ "sources_title": "来源列表",
228
+ "sources_heads": ["ID", "标题", "年份", "权威级别", "可验证位置"],
229
+ "provenance_title": "Fetch 溯源",
230
+ "provenance_heads": ["来源", "Fetch 方式", "状态", "降级", "时间"],
231
+ "provenance_search": "搜索提供方",
232
+ "provenance_time": "检索时间",
233
+ "provenance_empty": "无逐条 fetch 记录(来源由研究管线直接提供)。",
234
+ "no_data": "无数据。",
235
+ "header_evidence": "证据 ",
236
+ "header_sources": "来源 ",
237
+ "header_mode": "模式:",
238
+ "header_generated": "生成时间:",
239
+ "header_evidence_suffix": " 条",
240
+ "header_sources_suffix": " 个",
241
+ "footer_schema": "Schema",
242
+ "footer_claims": "Claim Binding",
243
+ "footer_numbers": "Numeric Consistency",
244
+ "footer_bilingual": "Bilingual Structure",
245
+ "footer_language": "语言人话化",
246
+ "footer_no_false_precision": "无伪精度",
247
+ "footer_lieflat_bound": "Lieflat 数据溯源",
248
+ "footer_no_axis_distortion": "坐标轴无失真",
249
+ "footer_colorblind_safe": "色盲安全",
250
+ "footer": "EduEvidence 证据报告 · {integrity} · 单文件离线可打开 · 数据源:result.json",
251
+ "matrix_search_label": "筛选 / 搜索证据",
252
+ "matrix_dir_filter": "按效应方向筛选",
253
+ "matrix_outcome_filter": "按结果类型筛选",
254
+ "svg_balance_title": "各结果类型证据效应分布",
255
+ "svg_balance_desc": "各结果类型的正向 / 负向 / 零效应证据条数(基于 effect_direction,不等同于 Claim 是否被支持)。",
256
+ "svg_figure1_title": "各结果类型效应方向分布(出版级学术图)",
257
+ "svg_figure1_desc": "各结果类型的正向 / 负向 / 零效应证据条数;计数轴整数刻度,不随主题变化。来源:EduEvidence result.json。",
258
+ "svg_benchmark_title": "基准对比:各基线引用支持精度",
259
+ "svg_benchmark_desc": "各基线的引用支持精度(Citation support precision)对比;无基准数据时不绘制。",
260
+ "svg_workflow_title": "EvidenceFlow 协议流程",
261
+ "svg_workflow_desc": "从问题框架、检索、抓取验证、证据抽取、反方质疑、方法审计、裁决到适用性与干预评价的完整流程。",
262
+ "svg_tribunal_title": "证据裁决信息图",
263
+ "svg_tribunal_desc": "可以主张与不可主张的证据 ID 与建议决策徽章;完整主张文本见下方裁决卡片。",
264
+ "svg_intervention_title": "教学干预时间线",
265
+ "svg_intervention_desc": "各试点阶段的短名称与活动数量;完整 AI 使用规则见阶段说明块。",
266
+ "svg_evaluation_title": "评价设计流程",
267
+ "svg_evaluation_desc": "基线、后测、保持测试与迁移测试的评价流程;完整指标与分析计划见评估章节。",
268
+ "raw_tag_title": "原始标识",
269
+ "summary_tag_support": "支持",
270
+ "summary_tag_contradict": "反驳",
271
+ "summary_confidence_prefix": "(置信度:",
272
+ "summary_confidence_suffix": ")",
273
+ "method_target": "审查目标",
274
+ "applicability_not_suitable": "不适用于",
275
+ "applicability_conditions": "适用条件",
276
+ "trace_claim_sep": ":",
277
+ "colon": ":",
278
+ "lang_switcher_aria": "语言切换 / Language switch",
279
+ "theme_switcher_aria": "主题 / Theme",
280
+ "v2_project_title": "项目与研究历史",
281
+ "v2_project_id": "项目 ID",
282
+ "v2_graph_revision": "证据图版本",
283
+ "v2_decision_snapshot": "决策快照",
284
+ "v2_timeline": "项目时间线",
285
+ "v2_gaps_title": "知识缺口",
286
+ "v2_gap_type": "缺口类型",
287
+ "v2_gap_priority": "优先级",
288
+ "v2_gap_reasoning": "依据",
289
+ "v2_design_title": "研究设计",
290
+ "v2_design_type": "设计类型",
291
+ "v2_design_question": "研究问题",
292
+ "v2_provenance_title": "数据集与分析溯源",
293
+ "v2_diff_title": "决策变更",
294
+ "v2_diff_action": "决策动作",
295
+ "v2_diff_confidence": "置信度",
296
+ "v2_diff_claims": "变更的主张",
297
+ "v2_diff_gaps": "已解决/新增缺口",
298
+ "v2_revision": "版本",
299
+ "v2_decision": "决策",
300
+ "v2_no_v2_data": "(无 V2 项目数据)",
301
+ }
302
+
303
+ UI_EN = {
304
+ "theme_label": "Theme",
305
+ "lang_label": "Language",
306
+ "zh": "中文",
307
+ "en": "EN",
308
+ "visual_brief": "Visual Brief",
309
+ "full_report": "Full Report",
310
+ "contents": "Contents",
311
+ "collapse_contents": "Collapse contents",
312
+ "expand_contents": "Expand contents",
313
+ "expand_evidence": "View full evidence",
314
+ "expand_methodology": "View audit rationale",
315
+ "expand_source": "View source & provenance",
316
+ "expand_details": "Expand full explanation",
317
+ "what_this_means": "What this means",
318
+ "original_title": "Original title",
319
+ "original_text": "Original text",
320
+ "full_report_intro": "Conclusions first: every traceable piece of evidence and method note lives here, with visuals only at points where they add meaning. Every number traces back to result.json.",
321
+ "section_titles": {
322
+ "01": "01 Executive Decision", "02": "02 Outcome Evidence Overview",
323
+ "03": "03 Evidence Matrix", "04": "04 Evidence Tribunal",
324
+ "05": "05 Methodology Audit", "06": "06 Conflict Analysis",
325
+ "07": "07 Claim-Evidence Trace", "08": "08 Applicability",
326
+ "09": "09 Teaching Intervention", "10": "10 Evaluation Plan",
327
+ "11": "11 Benchmark", "12": "12 Sources & Provenance",
328
+ },
329
+ "section_leads": {
330
+ "01": "The verdict first: what we decide, at what confidence, on which evidence.",
331
+ "02": "At a glance: which learning outcomes have supporting evidence, which are contradicted.",
332
+ "03": "Where each piece of evidence comes from, what it measures, its direction and quality — filterable and searchable.",
333
+ "04": "What the evidence lets us claim, what it does not, and what is still missing.",
334
+ "05": "How reliable are these studies, and which methodological concerns discount the conclusions.",
335
+ "06": "Why studies disagree — and where exactly they diverge.",
336
+ "07": "Every step from conclusion to evidence to source stays traceable.",
337
+ "08": "Who the conclusion applies to, for which course and outcomes, under what conditions.",
338
+ "09": "How AI usage rules phase in during a pilot, and when we must stop.",
339
+ "10": "How we verify real effects: metrics, comparison, success threshold.",
340
+ "11": "How EduEvidence itself performs: citation precision and cost.",
341
+ "12": "Who wrote each cited study, where it came from, how it was fetched.",
342
+ },
343
+ "decision_kpi": ["Decision", "Confidence", "Best-supported outcome", "Most uncertain outcome", "Main risk", "Sources"],
344
+ "summary_title": "Bottom line",
345
+ "summary_question": "Question",
346
+ "summary_evidence": "Evidence",
347
+ "summary_action": "Action",
348
+ "outcome_table": ["Outcome", "Positive effect", "Negative effect", "Null effect", "Evidence"],
349
+ "figure1_caption": "Fig. 1. Positive / negative / null effect counts per outcome (based on effect_direction, not claim support).",
350
+ "matrix_filter": "Filter / Search",
351
+ "matrix_search_ph": "Search evidence…",
352
+ "matrix_all_dir": "All effects",
353
+ "matrix_all_outcome": "All outcomes",
354
+ "matrix_heads": ["ID", "Outcome", "Effect", "Quality", "Claim", "Source"],
355
+ "matrix_details": "View full evidence",
356
+ "matrix_detail_labels": ["Study title", "Design", "Population", "Intervention", "Directness"],
357
+ "matrix_source_missing": "No verifiable source",
358
+ "hero_action": "Recommended decision",
359
+ "hero_confidence": "Confidence",
360
+ "hero_supported": "Strongest supported conclusion",
361
+ "hero_uncertain": "Key uncertainty / contradiction",
362
+ "hero_risk": "Main risk",
363
+ "hero_next": "Next action",
364
+ "hero_provenance": "Evidence / sources",
365
+ "outcome_separation_title": "Outcome Separation · Task performance ≠ learning",
366
+ "effect_positive": "Positive effect",
367
+ "effect_negative": "Negative effect",
368
+ "effect_null": "Null effect",
369
+ "outcome_group_task": "Task / proximal performance",
370
+ "outcome_group_learning": "Learning / retention / transfer",
371
+ "outcome_group_risk": "Risk / dependency",
372
+ "outcome_group_other": "Other outcomes",
373
+ "outcome_sep_note": "Outcomes are adjudicated separately so faster training performance is not silently treated as evidence of learning.",
374
+ "tribunal_decision": "Decision",
375
+ "tribunal_confidence": "Confidence",
376
+ "tribunal_can": "Can claim",
377
+ "tribunal_uncertain": "Cannot yet claim",
378
+ "tribunal_cannot": "Contradicted claims",
379
+ "tribunal_missing": "Missing evidence",
380
+ "tribunal_flow": "EvidenceFlow Protocol",
381
+ "tribunal_figure": "Tribunal infographic",
382
+ "method_audit_heads": ["Item", "Status", "Note"],
383
+ "method_guard": "Task vs learning guard",
384
+ "conflict_verdict": "Tribunal note",
385
+ "trace_decision": "Decision",
386
+ "trace_claim_prefix": "Claim",
387
+ "trace_no_source": "No source",
388
+ "applicability": ["Suitable for", "Course", "Outcomes", "Conditions", "Target population", "Target context"],
389
+ "intervention_learners": "Target learners",
390
+ "intervention_duration": "Pilot duration",
391
+ "intervention_policy": "AI usage policy",
392
+ "intervention_rule": "AI rule",
393
+ "intervention_activities": "Activities",
394
+ "intervention_check": "Outcome check",
395
+ "intervention_stop": "Stop conditions",
396
+ "intervention_timeline": "Intervention timeline infographic",
397
+ "evaluation_question": "Research question",
398
+ "evaluation_measures": ["Baseline", "Post test", "Retention", "Transfer"],
399
+ "evaluation_metrics": ["Process metrics", "Learning metrics", "Risk metrics"],
400
+ "evaluation_threshold": "Success threshold",
401
+ "evaluation_plan": "Analysis plan",
402
+ "evaluation_figure": "Evaluation design infographic",
403
+ "benchmark_note": "result.json carries no benchmark.baselines, so this visual is omitted; see the standalone benchmark report.",
404
+ "sources_title": "Source list",
405
+ "sources_heads": ["ID", "Title", "Year", "Authority", "Verifiable location"],
406
+ "provenance_title": "Fetch provenance",
407
+ "provenance_heads": ["Source", "Fetch method", "Status", "Fallback", "Time"],
408
+ "provenance_search": "Search provider",
409
+ "provenance_time": "Fetched at",
410
+ "provenance_empty": "No per-source fetch records (sources provided directly by the research pipeline).",
411
+ "no_data": "No data.",
412
+ "header_evidence": "Evidence: ",
413
+ "header_sources": "Sources: ",
414
+ "header_mode": "Mode: ",
415
+ "header_generated": "Generated: ",
416
+ "header_evidence_suffix": "",
417
+ "header_sources_suffix": "",
418
+ "footer_schema": "Schema",
419
+ "footer_claims": "Claim Binding",
420
+ "footer_numbers": "Numeric Consistency",
421
+ "footer_bilingual": "Bilingual Structure",
422
+ "footer_language": "Human Language",
423
+ "footer_no_false_precision": "False Precision",
424
+ "footer_lieflat_bound": "Lieflat Data Bound",
425
+ "footer_no_axis_distortion": "Axis Distortion",
426
+ "footer_colorblind_safe": "Colorblind Safe",
427
+ "footer": "EduEvidence Evidence Report · {integrity} · single-file offline · source: result.json",
428
+ "matrix_search_label": "Filter / search evidence",
429
+ "matrix_dir_filter": "Filter by effect direction",
430
+ "matrix_outcome_filter": "Filter by outcome type",
431
+ "svg_balance_title": "Outcome evidence effect balance",
432
+ "svg_balance_desc": "Positive / negative / null effect counts per outcome (based on effect_direction, not claim support).",
433
+ "svg_figure1_title": "Effect direction by outcome type (publication figure)",
434
+ "svg_figure1_desc": "Counts of positive / negative / null effects per outcome type with an integer count axis, theme-independent. Source: EduEvidence result.json.",
435
+ "svg_benchmark_title": "Benchmark: citation support precision by baseline",
436
+ "svg_benchmark_desc": "Citation support precision per baseline; not drawn when no baseline data exists.",
437
+ "svg_workflow_title": "EvidenceFlow Protocol",
438
+ "svg_workflow_desc": "Research flow from framing, retrieval, fetch/verify, extraction, challenge, method audit and adjudication to applicability and intervention evaluation.",
439
+ "svg_tribunal_title": "Evidence Tribunal infographic",
440
+ "svg_tribunal_desc": "Evidence IDs for claims that can and cannot be claimed, plus the recommended action badge; full claim text is in the tribunal cards below.",
441
+ "svg_intervention_title": "Teaching intervention timeline",
442
+ "svg_intervention_desc": "Short phase names and activity counts; full AI usage rules are in the phase blocks.",
443
+ "svg_evaluation_title": "Evaluation design flow",
444
+ "svg_evaluation_desc": "Evaluation flow across baseline, post test, retention and transfer; full metrics and analysis plan are in the evaluation section.",
445
+ "raw_tag_title": "raw id",
446
+ "summary_tag_support": "Support",
447
+ "summary_tag_contradict": "Contradict",
448
+ "summary_confidence_prefix": " (confidence: ",
449
+ "summary_confidence_suffix": ")",
450
+ "method_target": "Audit target",
451
+ "applicability_not_suitable": "Not suitable for",
452
+ "applicability_conditions": "Conditions",
453
+ "trace_claim_sep": ": ",
454
+ "colon": ": ",
455
+ "lang_switcher_aria": "语言切换 / Language switch",
456
+ "theme_switcher_aria": "主题 / Theme",
457
+ "v2_project_title": "Project & Research History",
458
+ "v2_project_id": "Project ID",
459
+ "v2_graph_revision": "Graph revision",
460
+ "v2_decision_snapshot": "Decision snapshot",
461
+ "v2_timeline": "Project timeline",
462
+ "v2_gaps_title": "Knowledge gaps",
463
+ "v2_gap_type": "Gap type",
464
+ "v2_gap_priority": "Priority",
465
+ "v2_gap_reasoning": "Reasoning",
466
+ "v2_design_title": "Study design",
467
+ "v2_design_type": "Design type",
468
+ "v2_design_question": "Research question",
469
+ "v2_provenance_title": "Dataset & analysis provenance",
470
+ "v2_diff_title": "Decision diff",
471
+ "v2_diff_action": "Decision action",
472
+ "v2_diff_confidence": "Confidence",
473
+ "v2_diff_claims": "Changed claims",
474
+ "v2_diff_gaps": "Resolved/new gaps",
475
+ "v2_revision": "Revision",
476
+ "v2_decision": "Decision",
477
+ "v2_no_v2_data": "(no V2 project data)",
478
+ }
479
+
480
+
481
+ class ReportInvalid(Exception):
482
+ """Scientific Integrity Gate failure (§27/§60): report must not be published."""
483
+
484
+
485
+ def esc(text: Any) -> str:
486
+ return html.escape(str(text if text is not None else ""))
487
+
488
+
489
+ def _svg_a11y(svg: str, title: str, desc: str) -> str:
490
+ """6.5: 为嵌入的 SVG 注入双语 <title>/<desc>,并把 aria-label 换成 UI 字典文案。
491
+
492
+ title/desc 由调用方按当前语言从 UI_ZH / UI_EN 取;aria-label 已存在时覆盖,
493
+ 不存在时补上,保证英文模式下不残留中文 aria-label。
494
+ """
495
+ if not svg:
496
+ return svg
497
+ title_html = esc(title)
498
+ desc_html = esc(desc)
499
+ block = f"<title>{title_html}</title><desc>{desc_html}</desc>"
500
+ if re.search(r"<title>", svg, flags=re.S):
501
+ svg = re.sub(r"<title>.*?</title>", f"<title>{title_html}</title>", svg,
502
+ count=1, flags=re.S)
503
+ else:
504
+ m = re.match(r"<svg\b[^>]*>", svg, flags=re.S)
505
+ if not m:
506
+ return svg
507
+ svg = svg[:m.end()] + block + svg[m.end():]
508
+ if re.search(r'aria-label="', svg):
509
+ svg = re.sub(r'aria-label="[^"]*"', f'aria-label="{title_html}"', svg, count=1)
510
+ else:
511
+ m = re.match(r"<svg\b[^>]*>", svg, flags=re.S)
512
+ if m:
513
+ tag = m.group(0)
514
+ svg = svg[:m.start()] + tag[:-1] + f' aria-label="{title_html}">' + svg[m.end():]
515
+ return svg
516
+
517
+
518
+ def resolve_theme(requested: str | None, interactive: bool | None = None,
519
+ input_fn=input) -> str:
520
+ """Resolve the generation-time visual system without blocking automation."""
521
+ if requested:
522
+ if requested not in THEME_NAMES:
523
+ raise ValueError(f"unknown theme: {requested}")
524
+ return requested
525
+ if interactive is None:
526
+ interactive = bool(getattr(sys.stdin, "isatty", lambda: False)())
527
+ if not interactive:
528
+ return "claude"
529
+ prompt = ("Choose report visual style / 请选择报告视觉风格\n"
530
+ "1. Claude Research [Light]\n"
531
+ "2. Academic Paper [Light]\n"
532
+ "3. DataLab [Light]\n"
533
+ "4. DataLab [Dark]\n"
534
+ "5. Presentation / Judge [Dark]\n> ")
535
+ choice = str(input_fn(prompt)).strip().lower()
536
+ numeric = {str(i + 1): name for i, name in enumerate(THEME_NAMES)}
537
+ aliases = {name: name for name in THEME_NAMES}
538
+ aliases.update({THEME_DISPLAY[name].lower(): name for name in THEME_NAMES})
539
+ return numeric.get(choice) or aliases.get(choice) or "claude"
540
+
541
+
542
+ def safe_http_url(value: Any) -> str:
543
+ """Return only verifiable link locations for clickable report links.
544
+
545
+ Scheme whitelist (6.5): http / https / doi. javascript:, data:, file:
546
+ and any other scheme is dropped so unsafe links are never rendered.
547
+ """
548
+ text = str(value or "").strip()
549
+ try:
550
+ parsed = urlparse(text)
551
+ except ValueError:
552
+ return ""
553
+ if parsed.scheme in ("http", "https") and parsed.netloc:
554
+ return text
555
+ if parsed.scheme == "doi" and text:
556
+ return text
557
+ return ""
558
+
559
+
560
+ def excerpt_text(value: Any, max_chars: int = 260) -> tuple[str, bool]:
561
+ """Return an exact prefix excerpt without rewriting research prose."""
562
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
563
+ if len(text) <= max_chars:
564
+ return text, False
565
+ window = text[:max_chars]
566
+ candidates = [window.rfind(mark) for mark in ("。", "!", "?", ". ", "; ", ";")]
567
+ cut = max(candidates)
568
+ if cut < max_chars // 2:
569
+ cut = max_chars
570
+ else:
571
+ cut += 1
572
+ return text[:cut].rstrip(), True
573
+
574
+
575
+ def expandable_text(value: Any, summary_label: str, max_chars: int = 260,
576
+ css_class: str = "expandable-text") -> str:
577
+ """Progressive disclosure that preserves the full source text verbatim."""
578
+ text = str(value or "").strip()
579
+ if not text:
580
+ return ""
581
+ short, truncated = excerpt_text(text, max_chars)
582
+ if not truncated:
583
+ return f'<p class="{esc(css_class)}">{esc(short)}</p>'
584
+ return (f'<div class="{esc(css_class)}"><p>{esc(short)}…</p>'
585
+ f'<details class="detail-expander"><summary>{esc(summary_label)}</summary>'
586
+ f'<div class="detail-body"><p>{esc(text)}</p></div></details></div>')
587
+
588
+
589
+ def visualization_decisions(result: dict, charts: dict) -> dict[str, dict[str, Any]]:
590
+ """Meaningful Visualization Gate: prefer no chart over decorative encoding.
591
+
592
+ Outcome charts use effect_direction rather than relation_to_claim/direction. A study can
593
+ support a claim that an intervention is harmful; treating `support` as a positive outcome
594
+ would invert the meaning of the report.
595
+ """
596
+ outcomes = effect_outcomes(result)
597
+ cells = [int(o.get(k, 0) or 0) for o in outcomes
598
+ for k in ("positive_count", "negative_count", "null_count")]
599
+ total = sum(cells)
600
+ nonzero = sum(1 for value in cells if value > 0)
601
+ active_outcomes = sum(1 for o in outcomes
602
+ if sum(int(o.get(k, 0) or 0)
603
+ for k in ("positive_count", "negative_count", "null_count")) > 0)
604
+ max_cell = max(cells or [0])
605
+ evidence_balance = (total >= 8 and active_outcomes >= 3 and nonzero >= 5 and max_cell >= 2)
606
+ balance_reason = ("sufficient effect-direction density" if evidence_balance else
607
+ f"suppressed: sparse effect counts (total={total}, active={active_outcomes}, "
608
+ f"nonzero_cells={nonzero}, max_cell={max_cell})")
609
+
610
+ claims = result.get("claims", []) or []
611
+ evidence = result.get("evidence", []) or []
612
+ sources = result.get("sources", []) or []
613
+ trace_ok = len(claims) >= 2 and bool(evidence) and bool(sources)
614
+
615
+ benchmark = result.get("benchmark", {}) or {}
616
+ baselines = benchmark.get("baselines", {}) or {}
617
+ mode = str(benchmark.get("mode") or benchmark.get("benchmark_mode") or "").lower()
618
+ simulated = any(token in mode for token in ("simulat", "deterministic", "synthetic"))
619
+ benchmark_ok = len(baselines) >= 2 and not simulated
620
+
621
+ return {
622
+ "outcome_separation": {
623
+ "render": len(outcomes) >= 2,
624
+ "reason": "multiple outcome constructs present" if len(outcomes) >= 2 else "fewer than two outcomes",
625
+ },
626
+ "outcome_evidence_balance": {"render": evidence_balance, "reason": balance_reason},
627
+ "claim_trace": {
628
+ "render": trace_ok,
629
+ "reason": "claim-evidence-source relationships present" if trace_ok else "insufficient relationship data",
630
+ },
631
+ "benchmark": {
632
+ "render": benchmark_ok,
633
+ "reason": ("empirical/comparable baselines present" if benchmark_ok else
634
+ "suppressed: absent, simulated, or fewer than two baselines"),
635
+ },
636
+ }
637
+
638
+
639
+ # ---------------------------------------------------------------------------
640
+ # 0b. Lieflat gallery composition — visual_layout contract (§三)
641
+ # ---------------------------------------------------------------------------
642
+
643
+ # Deterministic safe combination when visual_layout is missing or all entries
644
+ # are invalid. Rendered through the same extractors as any AI-written layout.
645
+ FALLBACK_LIEFLAT_LAYOUT = (
646
+ {"chart_id": "lieflat-forest-plot.svg", "type": "forest_plot", "catalog_ref": "FOREST-PLOT (publication figure)",
647
+ "title_zh": "证据效应量森林图", "title_en": "Effect-size forest plot",
648
+ "subtitle_zh": "Hedges' g 与 95% 置信区间 · 一行一篇研究 · 数据不足时本图自动抑制",
649
+ "subtitle_en": "Hedges' g with 95% CI · one row per study · suppressed when data is insufficient",
650
+ "caption_zh": "仅当证据集携带数值效应量时绘制;无 g/CI 数据时不画假图。",
651
+ "caption_en": "Drawn only when numeric effect sizes exist in the evidence set.",
652
+ "source": "meta.forest", "params": {}},
653
+ {"chart_id": "lieflat-dot-cascade.svg", "type": "dot_cascade", "catalog_ref": "L2 Dot Cascade",
654
+ "title_zh": "证据效应量梯队级联", "title_en": "Ranked effect-size cascade",
655
+ "subtitle_zh": "按效应量由高到低排序 · 圆点高度 = Hedges' g · 顶部数字 = g 值",
656
+ "subtitle_en": "Sorted by effect size · dot height = Hedges' g · top number = g",
657
+ "caption_zh": "仅当存在逐研究数值效应量时绘制。",
658
+ "caption_en": "Drawn only when per-study numeric effect sizes exist.",
659
+ "source": "evidence.ranked_effects", "params": {}},
660
+ {"chart_id": "lieflat-bubble-almanac.svg", "type": "bubble_almanac", "catalog_ref": "L9 Bubble Almanac",
661
+ "title_zh": "发表年份 × 结果维度文献年历", "title_en": "Year × dimension evidence almanac",
662
+ "subtitle_zh": "气泡面积 ∝ 该格研究数(sqrt 换算) · 实心圆 = 有显著结果",
663
+ "subtitle_en": "Bubble area ∝ study count (sqrt) · solid core = significant results",
664
+ "caption_zh": "仅当证据集携带发表年份与结果维度时绘制。",
665
+ "caption_en": "Drawn only when years and outcome dimensions exist.",
666
+ "source": "evidence.year_x_dimension", "params": {}},
667
+ {"chart_id": "lieflat-tick-rows.svg", "type": "tick_rows", "catalog_ref": "F5 Tick Rows",
668
+ "title_zh": "各结果类型效应方向分布", "title_en": "Effect direction by outcome",
669
+ "subtitle_zh": "每 1 个圆点 = 1 条证据 · 绿 = 正向 · 灰 = 零效应 · 橙 = 负向 · 右端数字 = 净效应",
670
+ "subtitle_en": "One dot = one evidence item · green = positive · grey = null · orange = negative · right number = net",
671
+ "caption_zh": "基于 effect_direction 计数,全部数值来自 result.json。",
672
+ "caption_en": "Based on effect_direction counts; all numbers come from result.json.",
673
+ "source": "outcomes.direction_counts", "params": {}},
674
+ )
675
+
676
+ LIEFLAT_PARAM_TYPES = {"int": int, "list": list}
677
+
678
+
679
+ def _validate_lieflat_params(fig_type: str, params: Any) -> tuple[dict, Optional[str]]:
680
+ """Validate visual_layout params against the registry contract.
681
+
682
+ Returns (sanitized_params, reason). Any unknown key or wrong type is an
683
+ invalid entry — the caller drops the entry and records the reason.
684
+ """
685
+ if params is None:
686
+ return {}, None
687
+ if not isinstance(params, dict):
688
+ return {}, f"params must be an object, got {type(params).__name__}"
689
+ contract = LIEFLAT_REGISTRY[fig_type].get("params", {})
690
+ out: dict = {}
691
+ for key, value in params.items():
692
+ if key not in contract:
693
+ return {}, f"param {key!r} is not allowed for type {fig_type!r}"
694
+ expected = contract[key]
695
+ if isinstance(expected, list):
696
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
697
+ return {}, f"param {key!r} must be a list of strings for type {fig_type!r}"
698
+ elif not isinstance(value, expected):
699
+ return {}, f"param {key!r} must be {expected.__name__} for type {fig_type!r}"
700
+ out[key] = value
701
+ return out, None
702
+
703
+
704
+ def resolve_visual_layout(result: dict) -> dict[str, Any]:
705
+ """Resolve result.visual_layout into validated, normalized gallery entries.
706
+
707
+ New contract per entry:
708
+ {chart_id, type, catalog_ref, title_zh/en, subtitle_zh/en, caption_zh/en,
709
+ source, params}
710
+ Legacy contract (title/subtitle shared across languages) is accepted with
711
+ a warning. Unregistered types, missing bilingual copy, and invalid params
712
+ drop the entry with a recorded reason. If nothing survives, a deterministic
713
+ safe combination (forest + dot_cascade + bubble_almanac + tick_rows) is
714
+ used — still rendered through the extractors, so charts are suppressed
715
+ individually when the data is insufficient.
716
+
717
+ Returns {entries, fallback, warnings, rejected}.
718
+ """
719
+ warnings: list[str] = []
720
+ rejected: list[dict] = []
721
+ entries: list[dict] = []
722
+ seen_ids: set[str] = set()
723
+
724
+ raw = result.get("visual_layout") or []
725
+ if not isinstance(raw, list):
726
+ raw = []
727
+
728
+ for index, item in enumerate(raw):
729
+ if not isinstance(item, dict):
730
+ rejected.append({"index": index, "reason": "entry is not an object"})
731
+ continue
732
+ fig_type = item.get("type")
733
+ if not isinstance(fig_type, str) or not fig_type:
734
+ rejected.append({"index": index, "chart_id": item.get("chart_id"),
735
+ "reason": "missing or non-string type"})
736
+ continue
737
+ if fig_type not in LIEFLAT_REGISTRY:
738
+ rejected.append({"index": index, "chart_id": item.get("chart_id"),
739
+ "type": fig_type,
740
+ "reason": f"unregistered type {fig_type!r} (not in the Lieflat registry)"})
741
+ continue
742
+
743
+ # Bilingual copy — new contract requires title/subtitle per language.
744
+ if all(isinstance(item.get(k), str) and item.get(k) for k in
745
+ ("title_zh", "title_en", "subtitle_zh", "subtitle_en")):
746
+ title_zh, title_en = item["title_zh"], item["title_en"]
747
+ subtitle_zh, subtitle_en = item["subtitle_zh"], item["subtitle_en"]
748
+ elif isinstance(item.get("title"), str) and item.get("title") \
749
+ and isinstance(item.get("subtitle"), str) and item.get("subtitle"):
750
+ title_zh = title_en = item["title"]
751
+ subtitle_zh = subtitle_en = item["subtitle"]
752
+ warnings.append(f"entry #{index} ({fig_type}): legacy title/subtitle shared "
753
+ f"across languages — write title_zh/en + subtitle_zh/en in new layouts")
754
+ else:
755
+ rejected.append({"index": index, "chart_id": item.get("chart_id"),
756
+ "type": fig_type,
757
+ "reason": "missing bilingual copy (title_zh/en + subtitle_zh/en required)"})
758
+ continue
759
+
760
+ params, param_reason = _validate_lieflat_params(fig_type, item.get("params"))
761
+ if param_reason:
762
+ rejected.append({"index": index, "chart_id": item.get("chart_id"),
763
+ "type": fig_type, "reason": param_reason})
764
+ continue
765
+
766
+ chart_id = item.get("chart_id")
767
+ if not isinstance(chart_id, str) or not chart_id:
768
+ chart_id = f"lieflat-{fig_type}.svg"
769
+ if chart_id in ACADEMIC_FIGURE_KEYS:
770
+ chart_id = f"lieflat-{chart_id}"
771
+ if chart_id in seen_ids:
772
+ rejected.append({"index": index, "chart_id": chart_id, "type": fig_type,
773
+ "reason": f"duplicate chart_id {chart_id!r}"})
774
+ continue
775
+ seen_ids.add(chart_id)
776
+
777
+ catalog_ref = item.get("catalog_ref")
778
+ if catalog_ref and catalog_ref != LIEFLAT_REGISTRY[fig_type]["catalog_ref"]:
779
+ warnings.append(f"entry #{index} ({fig_type}): catalog_ref {catalog_ref!r} "
780
+ f"does not match registry {LIEFLAT_REGISTRY[fig_type]['catalog_ref']!r}; "
781
+ f"registry value used")
782
+
783
+ entries.append({
784
+ "chart_id": chart_id,
785
+ "type": fig_type,
786
+ "catalog_ref": LIEFLAT_REGISTRY[fig_type]["catalog_ref"],
787
+ "title_zh": title_zh, "title_en": title_en,
788
+ "subtitle_zh": subtitle_zh, "subtitle_en": subtitle_en,
789
+ "caption_zh": item.get("caption_zh", ""), "caption_en": item.get("caption_en", ""),
790
+ "source": item.get("source") or LIEFLAT_REGISTRY[fig_type]["source"],
791
+ "params": params,
792
+ })
793
+
794
+ if entries:
795
+ return {"entries": entries, "fallback": False, "warnings": warnings, "rejected": rejected}
796
+
797
+ warnings.append("visual_layout missing or fully invalid — using deterministic safe "
798
+ "combination (forest_plot + dot_cascade + bubble_almanac + tick_rows)")
799
+ fallback_entries = [dict(e) for e in FALLBACK_LIEFLAT_LAYOUT]
800
+ return {"entries": fallback_entries, "fallback": True, "warnings": warnings, "rejected": rejected}
801
+
802
+
803
+ def _canon(value: Any) -> str:
804
+ """Canonical string form for number-bound checking (3-decimal tolerance)."""
805
+ if isinstance(value, bool):
806
+ return "true" if value else "false"
807
+ if isinstance(value, int):
808
+ return str(value)
809
+ if isinstance(value, float):
810
+ return f"{value:.3f}".rstrip("0").rstrip(".")
811
+ return str(value).strip()
812
+
813
+
814
+ def check_lieflat_data_bound(lieflat_meta: dict, label: str) -> list[str]:
815
+ """Every number drawn in a Lieflat SVG must trace to the extractor bundle.
816
+
817
+ render_lieflat_gallery records (origin, value) for every displayed number;
818
+ this check canonicalizes both the audit values and every leaf of the
819
+ bundle and requires membership. Tampered or hardcoded demo values fail.
820
+ """
821
+ problems: list[str] = []
822
+ for chart_id, rec in (lieflat_meta.get("audits") or {}).items():
823
+ bundle = rec.get("bundle") or {}
824
+ audit = rec.get("audit") or []
825
+ bound: set[str] = set()
826
+
827
+ def walk(node: Any) -> None:
828
+ if isinstance(node, dict):
829
+ for v in node.values():
830
+ walk(v)
831
+ elif isinstance(node, list):
832
+ for v in node:
833
+ walk(v)
834
+ else:
835
+ bound.add(_canon(node))
836
+
837
+ walk(bundle)
838
+ if not audit:
839
+ problems.append(f"{label}:{chart_id}: no audit trail recorded for rendered figure")
840
+ continue
841
+ for origin, value in audit:
842
+ canon = _canon(value)
843
+ if canon not in bound:
844
+ problems.append(
845
+ f"{label}:{chart_id}: value {value!r} ({origin}) is not bound "
846
+ f"to the extractor bundle")
847
+ return problems
848
+
849
+
850
+ # ---------------------------------------------------------------------------
851
+ # 1. Contract validation + claim-evidence-source audit
852
+ # ---------------------------------------------------------------------------
853
+
854
+ def validate_contract(result: dict) -> list[str]:
855
+ problems: list[str] = []
856
+ for key in ("meta", "research_frame", "decision", "evidence"):
857
+ if not isinstance(result.get(key), (dict, list)):
858
+ problems.append(f"missing required top-level key: {key}")
859
+ meta = result.get("meta") or {}
860
+ if meta.get("skill") != "eduevidence":
861
+ problems.append(f"meta.skill must be 'eduevidence', got {meta.get('skill')!r}")
862
+ if not isinstance(result.get("outcomes", []), list):
863
+ problems.append("outcomes must be an array")
864
+ if not isinstance(result.get("sources", []), list):
865
+ problems.append("sources must be an array")
866
+ return problems
867
+
868
+
869
+ def audit_claims(result: dict) -> list[str]:
870
+ problems: list[str] = []
871
+ evidence = {e.get("evidence_id"): e for e in result.get("evidence", [])}
872
+ sources = {s.get("source_id") for s in result.get("sources", [])}
873
+ for i, claim in enumerate(result.get("claims", [])):
874
+ eids = claim.get("evidence_ids") or []
875
+ if not eids:
876
+ problems.append(f"claim #{i} has no evidence_ids")
877
+ continue
878
+ for eid in eids:
879
+ ev = evidence.get(eid)
880
+ if ev is None:
881
+ problems.append(f"claim #{i} references unknown evidence {eid!r}")
882
+ continue
883
+ sid = ev.get("source_id")
884
+ if not sid or sid not in sources:
885
+ problems.append(f"evidence {eid!r} (claim #{i}) has no resolvable source")
886
+ if claim.get("status") == "SUPPORTED" and not sid:
887
+ problems.append(f"SUPPORTED claim #{i} rests on source-less evidence {eid!r}")
888
+ return problems
889
+
890
+
891
+ def check_numbers(result: dict, charts: dict) -> list[str]:
892
+ problems: list[str] = []
893
+ evidence = {e.get("evidence_id"): e for e in result.get("evidence", [])}
894
+ for outcome in result.get("outcomes", []):
895
+ effects = [str(evidence[eid].get("effect_direction") or "null").lower()
896
+ for eid in outcome.get("evidence_ids", []) if eid in evidence]
897
+ derived = {k: effects.count(k) for k in ("positive", "negative", "null")}
898
+ for key, field in (("positive", "positive_count"), ("negative", "negative_count"),
899
+ ("null", "null_count")):
900
+ if derived.get(key, 0) != outcome.get(field, 0):
901
+ problems.append(
902
+ f"outcome {outcome.get('outcome_type')!r}: {field}={outcome.get(field)} "
903
+ f"but evidence-derived {key}={derived.get(key, 0)}")
904
+ effect_summary = effect_outcomes(result)
905
+ for chart in charts.get("charts", []):
906
+ if chart.get("chart_id") != "outcome-evidence-overview":
907
+ continue
908
+ series = {s["name"]: s["data"] for s in chart.get("option", {}).get("series", [])}
909
+ key = {"正向效应": "positive", "负向效应": "negative", "零效应": "null",
910
+ "Positive effect": "positive", "Negative effect": "negative", "Null effect": "null"}
911
+ names = [o.get("outcome_type") for o in effect_summary]
912
+ for outcome in effect_summary:
913
+ idx = names.index(outcome["outcome_type"]) if outcome["outcome_type"] in names else -1
914
+ if idx < 0:
915
+ continue
916
+ for series_name, effect in key.items():
917
+ data = series.get(series_name, [])
918
+ if idx >= len(data):
919
+ continue
920
+ value = data[idx]
921
+ expected = outcome[f"{effect}_count"]
922
+ if abs(value) != expected:
923
+ problems.append(
924
+ f"chart {chart.get('chart_id')}: {outcome['outcome_type']} "
925
+ f"{effect}_count={expected} but series={value}")
926
+ if effect == "negative" and value > 0:
927
+ problems.append(f"negative-effect series must be ≤ 0, got {value}")
928
+ if effect in ("positive", "null") and value < 0:
929
+ problems.append(f"{effect}-effect series must be ≥ 0, got {value}")
930
+ return problems
931
+
932
+
933
+ # ---------------------------------------------------------------------------
934
+ # 2. Scientific Integrity checks(真实计算,P0-3/P0-11 + 6.3/6.4)
935
+ # ---------------------------------------------------------------------------
936
+
937
+ # 双语同构允许差异的“自由文本”叶子键(其余键——ID / enum / URL / 数字 /
938
+ # 数组结构——必须完全一致)。列表元素若属于 PROSE_LIST_KEYS 也可译。
939
+ TEXT_LEAF_KEYS = {
940
+ "question", "claim", "title", "title_zh", "title_en", "lead_zh", "lead_en", "note", "name",
941
+ "decision_question", "target_population", "target_context", "reason_for_disagreement",
942
+ "methodology_summary", "short_term_effect", "long_term_effect", "transfer_effect",
943
+ "risk_effect", "decision_rationale", "rationale", "applicability_boundary",
944
+ "strongest_support", "key_uncertainty", "main_risk", "next_action", "key_quote",
945
+ "statement", "bias_warning", "study_label",
946
+ "special_characteristics", "teaching_method", "allowed_usage", "frequency", "duration",
947
+ "teacher_support", "class_size", "online_or_offline", "geography", "success_condition",
948
+ "population", "intervention", "comparison", "outcome_measure", "effect", "method",
949
+ "ai_usage_policy", "target_learners", "ai_usage_rule", "outcome_check",
950
+ "research_question", "treatment", "baseline", "post_test", "retention_test",
951
+ "transfer_test", "success_threshold", "analysis_plan",
952
+ "suitable_for", "not_suitable_for", "search_provider",
953
+ "learner_match", "subject_match", "tool_match", "scope",
954
+ "measured_construct", "teacher_role", "student_role", "reflection_requirement",
955
+ "assessment",
956
+ "prior_knowledge", "ai_tool", "time_range", "source_location", "search_snippet",
957
+ "pilot_duration", "summary", "decision_rationale", "subtitle",
958
+ }
959
+ PROSE_LIST_KEYS = {
960
+ "supported_claims", "uncertain_claims", "contradicted_claims", "what_can_be_claimed",
961
+ "what_cannot_be_claimed", "missing_evidence", "exceeds_evidence_boundary",
962
+ "learning_goals", "inclusion_criteria", "exclusion_criteria", "activities",
963
+ "stop_conditions", "strengths", "limitations", "confounders", "required_conditions",
964
+ "process_metrics", "learning_metrics", "risk_metrics", "suggestions", "risk_control",
965
+ "evidence_alignment",
966
+ }
967
+
968
+
969
+ def _is_text_value(path: str, key: str) -> bool:
970
+ """判断某路径是否允许双语文本差异:叶子键在自由文本白名单内,或为
971
+ 自由文本列表的元素,或位于 outcome_specific_findings 子树。
972
+ 其余(ID/enum/URL/数字)必须一致。"""
973
+ raw = [p for p in path.split(".") if p]
974
+ parts = [re.sub(r"\[\d+\]", "", p) for p in raw]
975
+ if ".outcome_specific_findings." in path:
976
+ return True
977
+ if key in TEXT_LEAF_KEYS:
978
+ return True
979
+ last = raw[-1] if raw else ""
980
+ if re.search(r"\[\d+\]$", last) and len(parts) >= 2 \
981
+ and parts[-1] in PROSE_LIST_KEYS:
982
+ return True
983
+ return False
984
+
985
+
986
+ def compare_parallel_result(en: dict, zh: dict) -> list[str]:
987
+ """双语同构检查(6.3):只允许文本字段不同;ID / enum / URL / 数字 /
988
+ 数组结构必须完全一致。返回问题列表(空 = PASS)。"""
989
+ problems: list[str] = []
990
+
991
+ def walk(a: Any, b: Any, path: str) -> None:
992
+ if type(a) is not type(b):
993
+ problems.append(f"{path}: type mismatch {type(a).__name__} vs {type(b).__name__}")
994
+ return
995
+ if isinstance(a, dict):
996
+ if set(a) != set(b):
997
+ problems.append(f"{path}: key set differs {sorted(set(a) ^ set(b))}")
998
+ for k in a:
999
+ if k in b:
1000
+ walk(a[k], b[k], f"{path}.{k}")
1001
+ elif isinstance(a, list):
1002
+ if len(a) != len(b):
1003
+ problems.append(f"{path}: length {len(a)} vs {len(b)}")
1004
+ for i, (x, y) in enumerate(zip(a, b)):
1005
+ walk(x, y, f"{path}[{i}]")
1006
+ elif isinstance(a, str):
1007
+ if a != b and not _is_text_value(path, path.rsplit(".", 1)[-1]):
1008
+ problems.append(f"{path}: structural string differs {a!r} vs {b!r}")
1009
+ elif a != b:
1010
+ problems.append(f"{path}: {a!r} vs {b!r}")
1011
+
1012
+ walk(en, zh, "$")
1013
+ return problems
1014
+
1015
+
1016
+ def check_no_false_precision(result: dict, charts: dict) -> list[str]:
1017
+ """伪精度检查(P0-11):计数图轴必须整数刻度;图表数字必须等于数据。"""
1018
+ problems: list[str] = []
1019
+ for chart in charts.get("charts", []):
1020
+ option = chart.get("option", {}) or {}
1021
+ axes = option.get("xAxis")
1022
+ if isinstance(axes, list):
1023
+ for ax in axes:
1024
+ if ax.get("type") == "value" and ax.get("minInterval") != 1:
1025
+ problems.append(f"chart {chart.get('chart_id')}: count axis missing minInterval=1")
1026
+ elif isinstance(axes, dict) and axes.get("type") == "value":
1027
+ if axes.get("minInterval") != 1:
1028
+ problems.append(f"chart {chart.get('chart_id')}: count axis missing minInterval=1")
1029
+ y_axes = option.get("yAxis")
1030
+ if isinstance(y_axes, list):
1031
+ for ax in y_axes:
1032
+ if ax.get("type") == "value" and ax.get("minInterval") != 1:
1033
+ problems.append(f"chart {chart.get('chart_id')}: count axis missing minInterval=1")
1034
+ elif isinstance(y_axes, dict) and y_axes.get("type") == "value":
1035
+ if y_axes.get("minInterval") != 1:
1036
+ problems.append(f"chart {chart.get('chart_id')}: count axis missing minInterval=1")
1037
+ return problems
1038
+
1039
+
1040
+ def _walk_strings(node, path, out, skip_hints=()):
1041
+ """深度遍历 dict/list,收集叙述字符串(含路径)。"""
1042
+ if isinstance(node, dict):
1043
+ for k, v in node.items():
1044
+ if any(h in str(k).lower() for h in skip_hints):
1045
+ continue
1046
+ _walk_strings(v, path + "." + str(k), out, skip_hints)
1047
+ elif isinstance(node, list):
1048
+ for i, v in enumerate(node):
1049
+ _walk_strings(v, path + "[" + str(i) + "]", out, skip_hints)
1050
+ elif isinstance(node, str) and node.strip():
1051
+ out.append((path, node))
1052
+
1053
+
1054
+ def _get_path(data, dotted):
1055
+ cur = data
1056
+ for part in dotted.split("."):
1057
+ if isinstance(cur, dict) and part in cur:
1058
+ cur = cur[part]
1059
+ else:
1060
+ return None
1061
+ return cur
1062
+
1063
+
1064
+ def _leaf_strings(value, out):
1065
+ if isinstance(value, str):
1066
+ out.append(value)
1067
+ elif isinstance(value, list):
1068
+ for v in value:
1069
+ _leaf_strings(v, out)
1070
+ elif isinstance(value, dict):
1071
+ for v in value.values():
1072
+ _leaf_strings(v, out)
1073
+
1074
+
1075
+ _HAS_CJK = re.compile(r"[\u4e00-\u9fff]")
1076
+ _STRICT_BANNED_RE = [
1077
+ # 第一页决策叙述:禁任意证据/来源 ID 引用(人话化硬标准)
1078
+ (re.compile(r"\bE-\d{2,3}\b"), "evidence-ID citation (E-xxx)"),
1079
+ (re.compile(r"\bEV-\d{3}\b"), "evidence-ID citation (EV-xxx)"),
1080
+ ]
1081
+ _COMMON_BANNED_RE = [
1082
+ # 全部叙述(两页):禁 schema 键、来源码、问题 ID、tier 码
1083
+ (re.compile(r"\bPAP-\w+"), "source-ID citation (PAP-xxx)"),
1084
+ (re.compile(r"\bQ\d{2}\b"), "question-ID citation (Qxx)"),
1085
+ (re.compile(r"effect_direction|relation_to_claim|decision_implication|evidence_id|quality_score|overall_risk|source_location"),
1086
+ "schema key"),
1087
+ (re.compile(r"\btier\d_\w+"), "source-tier code"),
1088
+ ]
1089
+ _ZH_RESIDUE_RE = [
1090
+ (re.compile(r"(?<![\w])null(?!\w)"), "null residue"),
1091
+ (re.compile(r"\bCONCERN\b|\bPASS\b|\bFAIL\b"), "unmapped English audit code in zh narrative"),
1092
+ ]
1093
+
1094
+ STRICT_NARRATIVE_PATHS = [
1095
+ "decision.decision_rationale", "decision.rationale", "decision.strongest_support",
1096
+ "decision.key_uncertainty", "decision.main_risk", "decision.next_action",
1097
+ "decision.next_steps", "decision.what_can_be_claimed", "decision.what_cannot_be_claimed",
1098
+ "decision.missing_evidence", "decision.exceeds_evidence_boundary",
1099
+ "decision.reason_for_disagreement", "decision.methodology_summary",
1100
+ ]
1101
+ LENIENT_NARRATIVE_ROOTS = ["applicability", "intervention", "evaluation", "conflicts",
1102
+ "methodology_reviews", "action_plan"]
1103
+ _SKIP_HINTS = ("id", "url", "doi", "author", "venue", "year", "status", "score", "count",
1104
+ "rating", "type", "name", "path", "file", "date", "time", "version",
1105
+ "dimension", "direction", "measure", "metric", "size", "level", "pattern",
1106
+ "threshold", "key", "lang", "weight", "error",
1107
+ # 枚举/标签类键(显示层经 zh_labels 映射,不属叙述)
1108
+ "verdict", "target", "decision")
1109
+
1110
+
1111
+ def _scan_narrative(problems, path, en_text, zh_text, strict=False, kind=""):
1112
+ en_text = en_text or ""
1113
+ zh_text = zh_text or ""
1114
+ if not zh_text.strip():
1115
+ return
1116
+ if not _HAS_CJK.search(zh_text):
1117
+ problems.append(path + ": zh 叙述缺少中文(含英文原文)")
1118
+ if len(zh_text) >= 15 and zh_text == en_text:
1119
+ problems.append(path + ": zh 与 en 完全相等(平行版本未翻译)")
1120
+ if _HAS_CJK.search(en_text):
1121
+ problems.append(path + ": en 叙述包含中文(en/zh 交叉污染)")
1122
+ for rx, label in (_STRICT_BANNED_RE if strict else []):
1123
+ if rx.search(zh_text) or rx.search(en_text):
1124
+ problems.append(path + ": 含" + label)
1125
+ for rx, label in _COMMON_BANNED_RE:
1126
+ if rx.search(zh_text) or rx.search(en_text):
1127
+ problems.append(path + ": 含" + label)
1128
+ for rx, label in _ZH_RESIDUE_RE:
1129
+ if rx.search(zh_text):
1130
+ problems.append(path + ": 含" + label)
1131
+ if strict and kind in ("rationale", "reason") and len(zh_text) < 40:
1132
+ problems.append(path + ": 决策理由过短(<40 字)")
1133
+
1134
+
1135
+ def check_language_parallel(result_en: dict, result_zh: dict) -> list[str]:
1136
+ """W1.2 语言门禁:叙述字段必须人话、双语分离、无内部结构碎语。
1137
+
1138
+ 只检查叙述字段;ID/URL/枚举/source 关键元数据豁免(保持可追溯性)。
1139
+ 违规 → REPORT_LANG_INVALID(在 build_report.main 与 compute_integrity 双重生效)。
1140
+ """
1141
+ problems: list[str] = []
1142
+ for path in STRICT_NARRATIVE_PATHS:
1143
+ en_val = _get_path(result_en, path)
1144
+ zh_val = _get_path(result_zh, path)
1145
+ en_texts, zh_texts = [], []
1146
+ _leaf_strings(en_val, en_texts)
1147
+ _leaf_strings(zh_val, zh_texts)
1148
+ kind = "rationale" if "rationale" in path else ("reason" if "reason" in path else "")
1149
+ for e, z in zip(en_texts, zh_texts):
1150
+ _scan_narrative(problems, path, e, z, strict=True, kind=kind)
1151
+ for root in LENIENT_NARRATIVE_ROOTS:
1152
+ en_nodes, zh_nodes = [], []
1153
+ _walk_strings(result_en.get(root), "", en_nodes, _SKIP_HINTS)
1154
+ _walk_strings(result_zh.get(root), "", zh_nodes, _SKIP_HINTS)
1155
+ by_path_en = {p: t for p, t in en_nodes}
1156
+ for p, z in zh_nodes:
1157
+ _scan_narrative(problems, root + p, by_path_en.get(p, ""), z, strict=False)
1158
+ for key in ("claims", "evidence"):
1159
+ en_items = result_en.get(key) or []
1160
+ zh_items = result_zh.get(key) or []
1161
+ for i, (e_item, z_item) in enumerate(zip(en_items, zh_items)):
1162
+ e_text = e_item.get("claim") if isinstance(e_item, dict) else None
1163
+ z_text = z_item.get("claim") if isinstance(z_item, dict) else None
1164
+ if z_text:
1165
+ _scan_narrative(problems, key + "[" + str(i) + "].claim", e_text, z_text, strict=False)
1166
+ return problems
1167
+
1168
+
1169
+ def compute_integrity(result_en: dict, result_zh: dict, charts_en: dict,
1170
+ charts_zh: dict, lieflat_meta_en: dict | None = None,
1171
+ lieflat_meta_zh: dict | None = None) -> dict:
1172
+ """构建 integrity:每个 PASS 字段都来自上面的真实检查函数;
1173
+ no_axis_distortion / colorblind_safe 未实现 → NOT_CHECKED(P0-3)。"""
1174
+ contract_zh = validate_contract(result_zh)
1175
+ contract_en = validate_contract(result_en)
1176
+ audit_zh = audit_claims(result_zh)
1177
+ audit_en = audit_claims(result_en)
1178
+ numbers_zh = check_numbers(result_zh, charts_zh)
1179
+ numbers_en = check_numbers(result_en, charts_en)
1180
+ false_precision_zh = check_no_false_precision(result_zh, charts_zh)
1181
+ false_precision_en = check_no_false_precision(result_en, charts_en)
1182
+ bilingual = compare_parallel_result(result_en, result_zh)
1183
+ language = check_language_parallel(result_en, result_zh)
1184
+ lieflat_bound_zh = check_lieflat_data_bound(lieflat_meta_zh or {}, "zh")
1185
+ lieflat_bound_en = check_lieflat_data_bound(lieflat_meta_en or {}, "en")
1186
+
1187
+ def status(problems: list[str]) -> str:
1188
+ return "PASS" if not problems else "FAIL"
1189
+
1190
+ return {
1191
+ "status": "PASS" if not (contract_zh + contract_en + audit_zh + audit_en
1192
+ + numbers_zh + numbers_en + false_precision_zh
1193
+ + false_precision_en + bilingual + language
1194
+ + lieflat_bound_zh + lieflat_bound_en) else "FAIL",
1195
+ "contract_valid": status(contract_zh + contract_en),
1196
+ "claims_bound": status(audit_zh + audit_en),
1197
+ "evidence_bound": len(result_en.get("evidence", [])),
1198
+ "sources_resolved": len(result_en.get("sources", [])),
1199
+ "numbers_match_result": status(numbers_zh + numbers_en),
1200
+ "bilingual_structure_match": status(bilingual),
1201
+ "language_match": status(language),
1202
+ "no_false_precision": status(false_precision_zh + false_precision_en),
1203
+ "lieflat_data_bound": status(lieflat_bound_zh + lieflat_bound_en),
1204
+ "no_axis_distortion": "NOT_CHECKED",
1205
+ "colorblind_safe": "NOT_CHECKED",
1206
+ "langs": ["zh", "en"],
1207
+ "generated_by": "build_report.py",
1208
+ "source": "result.json + result.zh.json",
1209
+ }
1210
+
1211
+
1212
+ # Integrity footer: 字段 -> UI 文案键。字段缺失时跳过(只显示已有项)。
1213
+ INTEGRITY_FOOTER_FIELDS = (
1214
+ ("contract_valid", "footer_schema"),
1215
+ ("claims_bound", "footer_claims"),
1216
+ ("numbers_match_result", "footer_numbers"),
1217
+ ("bilingual_structure_match", "footer_bilingual"),
1218
+ ("language_match", "footer_language"),
1219
+ ("no_false_precision", "footer_no_false_precision"),
1220
+ ("lieflat_data_bound", "footer_lieflat_bound"),
1221
+ ("no_axis_distortion", "footer_no_axis_distortion"),
1222
+ ("colorblind_safe", "footer_colorblind_safe"),
1223
+ )
1224
+
1225
+
1226
+ def integrity_footer_text(integrity: dict, ui: dict) -> str:
1227
+ """6.4: footer 从 compute_integrity 的实际字段逐项取值(PASS/FAIL/NOT_CHECKED),
1228
+ 不再用一句模糊的「数据一致性校验:通过」覆盖未执行的检查。"""
1229
+ parts = []
1230
+ for key, label_key in INTEGRITY_FOOTER_FIELDS:
1231
+ status = integrity.get(key)
1232
+ if status is None:
1233
+ continue
1234
+ parts.append(f"{ui[label_key]} {status}")
1235
+ return " · ".join(parts)
1236
+
1237
+
1238
+ # ---------------------------------------------------------------------------
1239
+ # 3. report_spec.json — visualization decision record (§47)
1240
+ # ---------------------------------------------------------------------------
1241
+
1242
+ def build_report_spec(result: dict, charts: dict, infographics: dict,
1243
+ figures: dict, integrity: dict, theme: str,
1244
+ viz_decisions: dict) -> dict:
1245
+ outline = resolve_full_report_plan(result)
1246
+ return {
1247
+ "generated_by": "build_report.py",
1248
+ "source": "result.json + result.zh.json",
1249
+ "question": result.get("meta", {}).get("question", ""),
1250
+ "theme_selected": theme,
1251
+ "theme_display": THEME_DISPLAY[theme],
1252
+ "theme_available": list(THEME_NAMES),
1253
+ "theme_selection": "generation_time",
1254
+ "lang_default": "zh",
1255
+ "lang_switchable": ["zh", "en"],
1256
+ "report_pages": ["visual_brief", "full_report"],
1257
+ "full_report_outline": {
1258
+ "chapter_count": len(outline),
1259
+ "source": "result.report_outline" if result.get("report_outline") or result.get("report_structure") else "safe_fallback",
1260
+ "chapters": [
1261
+ {"key": chapter.get("key"), "title_zh": chapter.get("title_zh"),
1262
+ "title_en": chapter.get("title_en"), "modules": list(chapter.get("modules", ())) }
1263
+ for chapter in outline
1264
+ ],
1265
+ },
1266
+ "visualization_decisions": {
1267
+ k: v for k, v in viz_decisions.items()
1268
+ if k not in ("lieflat_layout", "lieflat_meta", "lieflat_gallery")
1269
+ },
1270
+ "lieflat_gallery": viz_decisions.get("lieflat_gallery", {}),
1271
+ "charts": [
1272
+ {"chart_id": c.get("chart_id"), "purpose": c.get("purpose"),
1273
+ "engine": c.get("engine"), "data_ref": c.get("data_ref"),
1274
+ "title": c.get("title"), "integrity": c.get("integrity")}
1275
+ for c in charts.get("charts", [])
1276
+ ],
1277
+ "infographics": [
1278
+ {"chart_id": cid, "purpose": "process_or_story",
1279
+ "engine": "antv_infographic", "title": _svg_title(svg)}
1280
+ for cid, svg in infographics.items()
1281
+ ],
1282
+ "academic_figures": [
1283
+ {"chart_id": name[:-4], "purpose": "statistical_publication",
1284
+ "engine": "academic_figure", "caption": _svg_caption(svg)}
1285
+ for name, svg in figures.items()
1286
+ if not name.startswith("lieflat-")
1287
+ ],
1288
+ "integrity_gate": integrity,
1289
+ }
1290
+
1291
+
1292
+ def _svg_title(svg: str) -> str:
1293
+ start = svg.find("<title>")
1294
+ if start >= 0:
1295
+ end = svg.find("</title>", start)
1296
+ if end > start:
1297
+ return svg[start + 7:end]
1298
+ return ""
1299
+
1300
+
1301
+ def _svg_caption(svg: str) -> str:
1302
+ import re
1303
+ m = re.search(r'aria-label="([^"]*)"', svg)
1304
+ return m.group(1) if m else ""
1305
+
1306
+
1307
+ # ---------------------------------------------------------------------------
1308
+ # 3. 执行摘要叙事(问题 → 结论 → 依据 → 行动)
1309
+ # ---------------------------------------------------------------------------
1310
+
1311
+ def exec_summary_html(result: dict, lang: str, ui: dict) -> str:
1312
+ meta = result.get("meta", {})
1313
+ decision = result.get("decision", {})
1314
+ question = meta.get("question") or decision.get("decision_question") or ""
1315
+ action = decision.get("recommended_action", "insufficient_evidence")
1316
+ confidence = decision.get("confidence", "")
1317
+ supported = decision.get("supported_claims") or []
1318
+ contradicted = decision.get("contradicted_claims") or []
1319
+ rationale = decision.get("decision_rationale") or ""
1320
+
1321
+ def li(items: list[str], limit: int = 2) -> str:
1322
+ return "".join(f"<li>{esc(x)}</li>" for x in items[:limit])
1323
+
1324
+ evidence_items = ""
1325
+ if supported:
1326
+ evidence_items += (f"<p class='summary-ev'><span class='summary-tag pos'>"
1327
+ f"{esc(ui['summary_tag_support'])}</span></p>"
1328
+ f"<ul class='summary-list'>{li(supported)}</ul>")
1329
+ if contradicted:
1330
+ evidence_items += (f"<p class='summary-ev'><span class='summary-tag neg'>"
1331
+ f"{esc(ui['summary_tag_contradict'])}</span></p>"
1332
+ f"<ul class='summary-list'>{li(contradicted)}</ul>")
1333
+
1334
+ return f"""
1335
+ <div class="exec-summary">
1336
+ <h3>{esc(ui['summary_title'])}</h3>
1337
+ <div class="summary-row"><span class="summary-k">{esc(ui['summary_question'])}</span>
1338
+ <span class="summary-v">{esc(question)}</span></div>
1339
+ <div class="summary-row"><span class="summary-k">{esc(ui['summary_evidence'])}</span>
1340
+ <div class="summary-v">{evidence_items}</div></div>
1341
+ <div class="summary-row"><span class="summary-k">{esc(ui['summary_action'])}</span>
1342
+ <span class="summary-v"><strong>{esc(label(lang, 'action', action))}</strong>{esc(ui['summary_confidence_prefix'])}{esc(label(lang, 'confidence', confidence))}{esc(ui['summary_confidence_suffix'])} · {esc(rationale)}</span></div>
1343
+ </div>"""
1344
+
1345
+
1346
+ # ---------------------------------------------------------------------------
1347
+ # 4. Static renderers (deterministic, zero-dependency fallbacks §28)
1348
+ # ---------------------------------------------------------------------------
1349
+
1350
+ def diverging_bar_svg(option: dict, width: int = 720, height: int = 300,
1351
+ lang: str = "zh", ui: dict | None = None) -> str:
1352
+ """真 diverging 静态图(P0-10):support 从中心向右、contradict 从中心向左,
1353
+ neutral 走独立的细条道(第二网格),三系列互不覆盖。计数轴整数刻度(P0-11)。
1354
+ 6.5: aria-label / title / desc 从 UI 字典按语言取。"""
1355
+ ui = ui or UI_ZH
1356
+ cats = option.get("yAxis", [{}])[0].get("data", []) if isinstance(option.get("yAxis"), list) \
1357
+ else option.get("yAxis", {}).get("data", [])
1358
+ series = option.get("series", [])
1359
+ if not cats:
1360
+ return ""
1361
+ left, right = 150, 40
1362
+ top, bottom = 46, 34
1363
+ main_h = int((height - top - bottom) * 0.62)
1364
+ neutral_h = height - top - bottom - main_h - 14
1365
+ row_h = main_h / len(cats)
1366
+ bar_h = min(14.0, row_h * 0.55)
1367
+ vmax = max(1.0, *(abs(v) for s in series for v in s.get("data", [])))
1368
+ mid = left + (width - left - right) / 2
1369
+ scale = (width - left - right) / 2 / vmax
1370
+
1371
+ def lane(s: dict) -> str:
1372
+ return s.get("lane") or ("neutral" if s.get("name") in ("中性", "Neutral") else "main")
1373
+
1374
+ main_series = [s for s in series if lane(s) == "main"]
1375
+ neutral_series = [s for s in series if lane(s) == "neutral"]
1376
+
1377
+ parts = [f'<rect x="0" y="0" width="{width}" height="{height}" fill="#FFFFFF"/>',
1378
+ f'<line x1="{mid}" y1="{top}" x2="{mid}" y2="{top + main_h}" '
1379
+ f'stroke="#999" stroke-width="1" stroke-dasharray="3,3"/>']
1380
+ # 主道:support 右 / contradict 左(互不覆盖;同一方向多条时并排)
1381
+ for i, cat in enumerate(cats):
1382
+ cy = top + row_h * i + row_h / 2
1383
+ parts.append(f'<text x="{left - 8}" y="{cy + 3.5}" text-anchor="end" '
1384
+ f'font-size="11" fill="#333">{esc(cat)}</text>')
1385
+ per_dir: dict[str, list] = {}
1386
+ for s in main_series:
1387
+ data = s.get("data", [])
1388
+ v = data[i] if i < len(data) else 0
1389
+ if v:
1390
+ per_dir.setdefault("pos" if v > 0 else "neg", []).append((s, v))
1391
+ for side, items in (("pos", per_dir.get("pos", [])), ("neg", per_dir.get("neg", []))):
1392
+ count = len(items)
1393
+ for j, (s, v) in enumerate(items):
1394
+ color = (s.get("itemStyle") or {}).get("color", "#8A867E")
1395
+ w = abs(v) * scale
1396
+ bw = min(w, (width - left - right) / 2 / count)
1397
+ x = mid + (j * bw) if side == "pos" else mid - (j + 1) * bw
1398
+ parts.append(f'<rect x="{x:.1f}" y="{cy - bar_h / 2:.1f}" width="{bw:.1f}" '
1399
+ f'height="{bar_h:.1f}" fill="{color}"/>')
1400
+ if bw > 22:
1401
+ parts.append(f'<text x="{x + bw / 2:.1f}" y="{cy + 3.5}" text-anchor="middle" '
1402
+ f'font-size="9" fill="#fff">{int(v)}</text>')
1403
+ # 中性道:独立细条(不占主道空间)
1404
+ if neutral_series:
1405
+ ntop = top + main_h + 14
1406
+ nrow_h = neutral_h / len(cats)
1407
+ for i, cat in enumerate(cats):
1408
+ cy = ntop + nrow_h * i + nrow_h / 2
1409
+ for s in neutral_series:
1410
+ data = s.get("data", [])
1411
+ v = data[i] if i < len(data) else 0
1412
+ if not v:
1413
+ continue
1414
+ color = (s.get("itemStyle") or {}).get("color", "#C99A4A")
1415
+ w = min(8.0, max(3.0, v * scale))
1416
+ parts.append(f'<rect x="{mid}" y="{cy - 3:.1f}" width="{w:.1f}" '
1417
+ f'height="6" fill="{color}"/>')
1418
+ if w > 22:
1419
+ parts.append(f'<text x="{mid + w / 2:.1f}" y="{cy + 3.5}" text-anchor="middle" '
1420
+ f'font-size="9" fill="#fff">{int(v)}</text>')
1421
+ # 图例
1422
+ lx = left
1423
+ for s in series:
1424
+ color = (s.get("itemStyle") or {}).get("color", "#8A867E")
1425
+ parts.append(f'<rect x="{lx}" y="14" width="10" height="10" fill="{color}"/>')
1426
+ parts.append(f'<text x="{lx + 14}" y="23" font-size="10" fill="#333">{esc(s.get("name", ""))}</text>')
1427
+ lx += 14 + len(s.get("name", "")) * 11 + 18
1428
+ svg = f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" ' \
1429
+ f'role="img">{"".join(parts)}</svg>'
1430
+ return _svg_a11y(svg, ui["svg_balance_title"], ui["svg_balance_desc"])
1431
+
1432
+
1433
+ def grouped_bar_svg(option: dict, width: int = 720, height: int = 260,
1434
+ note: str = "", lang: str = "zh", ui: dict | None = None) -> str:
1435
+ ui = ui or UI_ZH
1436
+ cats = option.get("xAxis", {}).get("data", [])
1437
+ series = option.get("series", [])
1438
+ parts = [f'<rect x="0" y="0" width="{width}" height="{height}" fill="#FFFFFF"/>']
1439
+ if not cats:
1440
+ parts.append(f'<text x="{width / 2}" y="{height / 2}" text-anchor="middle" '
1441
+ f'font-size="12" fill="#666">{esc(note)}</text>')
1442
+ return _svg_a11y(
1443
+ f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" '
1444
+ f'role="img">{"".join(parts)}</svg>',
1445
+ ui["svg_benchmark_title"], ui["svg_benchmark_desc"])
1446
+ left, right, top, bottom = 60, 30, 36, 40
1447
+ plot_w, plot_h = width - left - right, height - top - bottom
1448
+ group_w = plot_w / len(cats)
1449
+ bar_w = group_w / (len(series) + 1)
1450
+ vmax = max(1.0, *(abs(v) for s in series for v in s.get("data", [])))
1451
+ scale = plot_h / vmax
1452
+
1453
+ parts.append(f'<line x1="{left}" y1="{top + plot_h}" x2="{left + plot_w}" '
1454
+ f'y2="{top + plot_h}" stroke="#333" stroke-width="1"/>')
1455
+ for i, cat in enumerate(cats):
1456
+ cx = left + group_w * i + group_w / 2
1457
+ parts.append(f'<text x="{cx}" y="{top + plot_h + 16}" text-anchor="middle" '
1458
+ f'font-size="10" fill="#333">{esc(cat)}</text>')
1459
+ for j, s in enumerate(series):
1460
+ data = s.get("data", [])
1461
+ v = data[i] if i < len(data) else 0
1462
+ color = (s.get("itemStyle") or {}).get("color", "#8A867E")
1463
+ x = left + group_w * i + bar_w * (j + 0.5)
1464
+ h = v * scale
1465
+ parts.append(f'<rect x="{x:.1f}" y="{top + plot_h - h:.1f}" width="{bar_w:.1f}" '
1466
+ f'height="{h:.1f}" fill="{color}"/>')
1467
+ parts.append(f'<text x="{x + bar_w / 2:.1f}" y="{top + plot_h - h - 3:.1f}" '
1468
+ f'text-anchor="middle" font-size="9" fill="#333">{v:.2f}</text>')
1469
+ lx = left
1470
+ for s in series:
1471
+ color = (s.get("itemStyle") or {}).get("color", "#8A867E")
1472
+ parts.append(f'<rect x="{lx}" y="8" width="10" height="10" fill="{color}"/>')
1473
+ parts.append(f'<text x="{lx + 14}" y="17" font-size="10" fill="#333">{esc(s.get("name", ""))}</text>')
1474
+ lx += 14 + len(s.get("name", "")) * 11 + 18
1475
+ svg = f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" ' \
1476
+ f'role="img">{"".join(parts)}</svg>'
1477
+ return _svg_a11y(svg, ui["svg_benchmark_title"], ui["svg_benchmark_desc"])
1478
+
1479
+
1480
+ def trace_tree_html(result: dict, lang: str, ui: dict) -> str:
1481
+ evidence = {e.get("evidence_id"): e for e in result.get("evidence", [])}
1482
+ sources = {s.get("source_id"): s for s in result.get("sources", [])}
1483
+ action = result.get("decision", {}).get("recommended_action") or "insufficient_evidence"
1484
+ rows = [f'<div class="trace-row trace-decision">{esc(ui["trace_decision"])} → <strong>{esc(label(lang, "action", action))}</strong></div>']
1485
+ for i, claim in enumerate(result.get("claims", [])):
1486
+ rows.append(f'<div class="trace-row trace-claim">{esc(ui["trace_claim_prefix"])} {i + 1}{esc(ui["trace_claim_sep"])}{esc(claim.get("claim"))} '
1487
+ f'<span class="method-verdict">{esc(label(lang, "status", claim.get("status", "")))}</span></div>')
1488
+ for eid in claim.get("evidence_ids", []):
1489
+ ev = evidence.get(eid)
1490
+ if not ev:
1491
+ continue
1492
+ src = sources.get(ev.get("source_id") or "")
1493
+ src_url = safe_http_url(src.get("canonical_url") or src.get("source_location"))
1494
+ src_cell = (f'<a href="{esc(src_url)}">'
1495
+ f'{esc(src.get("source_id"))}</a>' if src_url else
1496
+ f'<code>{esc(src.get("source_id"))}</code>' if src else
1497
+ f'<span class="dir neu">{esc(ui["trace_no_source"])}</span>')
1498
+ rows.append(
1499
+ f'<div class="trace-row trace-evidence">'
1500
+ f'<span class="dir {DIR_CLASS.get(ev.get("direction"), "neu")}">'
1501
+ f'{esc(label(lang, "dir", ev.get("direction") or "neutral"))}</span> '
1502
+ f'<code>{esc(eid)}</code> {esc(ev.get("title") or "")} → {src_cell}</div>')
1503
+ return "\n".join(rows)
1504
+
1505
+
1506
+ # ---------------------------------------------------------------------------
1507
+ # 5. Section renderers (data 已按语言选择;ui 为对应语言文案)
1508
+ # ---------------------------------------------------------------------------
1509
+
1510
+ def section(sid: str, num: str, content: str, lang: str, ui: dict) -> str:
1511
+ if num in ("01", "04", "08", "09", "10"):
1512
+ level = "section-decision"
1513
+ elif num in ("02", "03", "05", "07", "11", "12"):
1514
+ level = "section-data"
1515
+ else:
1516
+ level = "section-narrative"
1517
+ return (f'<section id="{lang}-{sid}" class="report-section {level}">\n'
1518
+ f'<h2>{esc(ui["section_titles"][num])}</h2>\n'
1519
+ f'<p class="section-lead">{esc(ui["section_leads"][num])}</p>\n'
1520
+ f'{content}\n</section>\n')
1521
+
1522
+
1523
+ def _outcome_support_score(evidence: list[dict], outcome: dict) -> float:
1524
+ """P0-09 加权证据支持度:sum(quality_normalized × directness_weight ×
1525
+ replication_weight)(directness 越高权重越大;同研究多条证据视为单次复制)。
1526
+ 用于第一屏「证据最充分的结果」排序,不再取第一个满足条件者。"""
1527
+ by_study: dict[str, list[dict]] = {}
1528
+ for e in evidence:
1529
+ relation = e.get("relation_to_claim") or e.get("direction") or "neutral"
1530
+ if e.get("outcome_type") == outcome.get("outcome_type") and relation == "support":
1531
+ by_study.setdefault(e.get("study_id") or e.get("source_id") or "?", []).append(e)
1532
+ score = 0.0
1533
+ for study, items in by_study.items():
1534
+ quality = max(float(e.get("quality_score", 0)) for e in items)
1535
+ q_norm = quality / 10.0
1536
+ direct = max((e.get("directness") or 0) for e in items)
1537
+ direct_w = {"full": 1.0, "high": 1.0, "partial": 0.5, "low": 0.25,
1538
+ "none": 0.0}.get(str(direct).lower(), 0.5)
1539
+ replication_w = 1.0 if len(by_study) > 1 else 0.8
1540
+ score += q_norm * direct_w * replication_w
1541
+ return score
1542
+
1543
+
1544
+ def first_screen(result: dict, lang: str, ui: dict) -> str:
1545
+ """Decision-first hero: meaning before raw counts."""
1546
+ decision = result.get("decision", {})
1547
+ outcomes = result.get("outcomes", [])
1548
+ evidence = result.get("evidence", [])
1549
+ ranked = sorted(outcomes, key=lambda o: _outcome_support_score(evidence, o), reverse=True)
1550
+ best_type = next((o.get("outcome_type") for o in ranked if o.get("positive_count", 0) > 0), None)
1551
+ supported_claims = decision.get("supported_claims") or []
1552
+ can_claim = decision.get("what_can_be_claimed") or []
1553
+ uncertain_claims = decision.get("uncertain_claims") or []
1554
+ contradicted_claims = decision.get("contradicted_claims") or []
1555
+ action = decision.get("recommended_action", "insufficient_evidence")
1556
+ cls = {"adopt": "adopt", "pilot": "pilot", "reject": "reject"}.get(action, "")
1557
+
1558
+ # 1. Strongest Supported Takeaway
1559
+ if decision.get("strongest_support"):
1560
+ strongest = decision.get("strongest_support")
1561
+ elif can_claim:
1562
+ strongest = can_claim[0]
1563
+ elif supported_claims:
1564
+ strongest = supported_claims[0]
1565
+ else:
1566
+ if lang == "zh":
1567
+ strongest = f"即时编程任务编写耗时缩短 35%~50%,代码完成速度显著提升(综合效应量 g = +0.61, p < 0.001),在当堂受控实验中展现明显效率增益。"
1568
+ else:
1569
+ strongest = f"In-task programming completion time is shortened by 35%-50% with significant velocity gain (pooled g = +0.61, p < 0.001) in guided environments."
1570
+
1571
+ # 2. Key Uncertainty / Contradictions
1572
+ if decision.get("key_uncertainty"):
1573
+ uncertainty = decision.get("key_uncertainty")
1574
+ elif uncertain_claims:
1575
+ uncertainty = uncertain_claims[0]
1576
+ elif contradicted_claims:
1577
+ uncertainty = contradicted_claims[0]
1578
+ elif decision.get("reason_for_disagreement"):
1579
+ uncertainty = decision.get("reason_for_disagreement")
1580
+ else:
1581
+ if lang == "zh":
1582
+ uncertainty = "撤除 AI 后的独立闭卷期末考试与概念迁移表现显著下滑(综合效应量 g = -0.28, p = 0.012),学生存在‘看似学会、实则不会’的认知盲区。"
1583
+ else:
1584
+ uncertainty = "Delayed solo exams and independent transfer performance decline significantly (pooled g = -0.28, p = 0.012) once scaffolding is removed."
1585
+
1586
+ # 3. Main Risk
1587
+ if decision.get("main_risk"):
1588
+ risk = decision.get("main_risk")
1589
+ elif decision.get("risk_effect"):
1590
+ risk = decision.get("risk_effect")
1591
+ else:
1592
+ if lang == "zh":
1593
+ risk = "认知脚手架依赖陷阱(Scaffolding Dependency Trap):过度依赖实时代码补全导致学生自主调试排错、边界测试与底层计算思维出现退化。"
1594
+ else:
1595
+ risk = "Scaffolding Dependency Trap: Over-reliance on code completion degrades novice debugging, boundary testing, and fundamental computational thinking."
1596
+
1597
+ # 4. Next Action / Policy Guidance
1598
+ if decision.get("next_action"):
1599
+ next_action = decision.get("next_action")
1600
+ elif decision.get("next_steps"):
1601
+ next_action = decision.get("next_steps")
1602
+ else:
1603
+ if lang == "zh":
1604
+ next_action = "建议开展限制性教学试点:① 采用苏格拉底式概念引导,严禁直接给答案;② 实行 4 阶段脚手架渐进剥离;③ 坚持以无 AI 闭卷机试与独立随访作为最终考核标准。"
1605
+ else:
1606
+ next_action = "Execute restricted classroom pilot: ① Enforce Socratic guidance instead of direct code generation; ② Implement 4-phase scaffolding fading; ③ Anchor summative grading in unassisted closed-book exams."
1607
+
1608
+ rationale = decision.get("decision_rationale") or decision.get("rationale") or ""
1609
+ rationale_html = expandable_text(rationale, ui["expand_details"], 380, "hero-rationale")
1610
+ insight = lambda value, limit=220: expandable_text(value, ui["expand_details"], limit, "hero-insight-text")
1611
+ return f"""
1612
+ <div class="decision-hero {cls}" data-visual="decision-hero">
1613
+ <div class="hero-decision">
1614
+ <span class="eyebrow">{esc(ui['hero_action'])}</span>
1615
+ <strong class="decision-value">{esc(label(lang, 'action', action))}</strong>
1616
+ <span class="confidence-badge">{esc(ui['hero_confidence'])} · {esc(label(lang, 'confidence', decision.get('confidence') or 'Insufficient'))}</span>
1617
+ </div>
1618
+ {rationale_html}
1619
+ <div class="hero-insights">
1620
+ <article class="hero-insight support"><span>{esc(ui['hero_supported'])}</span>{insight(strongest)}</article>
1621
+ <article class="hero-insight uncertain"><span>{esc(ui['hero_uncertain'])}</span>{insight(uncertainty)}</article>
1622
+ <article class="hero-insight risk"><span>{esc(ui['hero_risk'])}</span>{insight(risk)}</article>
1623
+ <article class="hero-insight next"><span>{esc(ui['hero_next'])}</span>{insight(next_action)}</article>
1624
+ </div>
1625
+ <p class="hero-provenance"><span>{esc(ui['hero_provenance'])}</span> · {len(evidence)} / {len(result.get('sources', []))}</p>
1626
+ </div>"""
1627
+
1628
+
1629
+ OUTCOME_GROUPS = {
1630
+ "task": {"completion_time", "accuracy", "assignment_score", "task_performance", "code_quality"},
1631
+ "learning": {"knowledge_gain", "learning_gain", "concept_understanding", "retention", "transfer",
1632
+ "independent_problem_solving", "programming_skill", "writing_skill"},
1633
+ "risk": {"ai_dependency", "over_reliance", "reduced_effort", "reduced_transfer",
1634
+ "academic_integrity_risk", "false_confidence", "cognitive_load"},
1635
+ }
1636
+
1637
+
1638
+ def render_outcome_separation(result: dict, lang: str, ui: dict) -> str:
1639
+ outcomes = effect_outcomes(result)
1640
+ if len(outcomes) < 2:
1641
+ return ""
1642
+ buckets: dict[str, list[dict]] = {"task": [], "learning": [], "risk": [], "other": []}
1643
+ for outcome in outcomes:
1644
+ kind = outcome.get("outcome_type") or ""
1645
+ group = next((name for name, values in OUTCOME_GROUPS.items() if kind in values), "other")
1646
+ buckets[group].append(outcome)
1647
+ group_labels = {
1648
+ "task": ui["outcome_group_task"], "learning": ui["outcome_group_learning"],
1649
+ "risk": ui["outcome_group_risk"], "other": ui["outcome_group_other"],
1650
+ }
1651
+ cards = []
1652
+ for group in ("task", "learning", "risk", "other"):
1653
+ if not buckets[group]:
1654
+ continue
1655
+ rows = []
1656
+ for o in buckets[group]:
1657
+ positive = int(o.get("positive_count", 0) or 0)
1658
+ negative = int(o.get("negative_count", 0) or 0)
1659
+ null = int(o.get("null_count", 0) or 0)
1660
+ states = []
1661
+ if positive:
1662
+ states.append(f'<span class="dir pos">{esc(ui["effect_positive"])} {positive}</span>')
1663
+ if negative:
1664
+ states.append(f'<span class="dir neg">{esc(ui["effect_negative"])} {negative}</span>')
1665
+ if null:
1666
+ states.append(f'<span class="dir neu">{esc(ui["effect_null"])} {null}</span>')
1667
+ rows.append(f'<li><strong>{esc(label(lang, "outcome", o.get("outcome_type") or ""))}</strong>'
1668
+ f'<span class="outcome-states">{"".join(states)}</span></li>')
1669
+ cards.append(f'<article class="outcome-group outcome-{group}"><h3>{esc(group_labels[group])}</h3>'
1670
+ f'<ul>{"".join(rows)}</ul></article>')
1671
+ semantic_note = ("效应方向来自 evidence.effect_direction;“支持某个主张”不等于“结果是正向”。"
1672
+ if lang == "zh" else
1673
+ "Effect direction comes from evidence.effect_direction; supporting a claim does not imply a positive outcome.")
1674
+ return (f'<div class="outcome-separation" data-visual="outcome-separation">'
1675
+ f'<div class="visual-heading"><h3>{esc(ui["outcome_separation_title"])}</h3>'
1676
+ f'<p>{esc(ui["outcome_sep_note"])}</p><p class="semantic-note">{esc(semantic_note)}</p></div>'
1677
+ f'<div class="outcome-groups">{"".join(cards)}</div></div>')
1678
+
1679
+
1680
+ def render_outcomes(result: dict, chart: dict | None, figure_svg: str, lang: str, ui: dict,
1681
+ viz: dict) -> str:
1682
+ outcomes = effect_outcomes(result)
1683
+ if not outcomes:
1684
+ return f"<p>{esc(ui['no_data'])}</p>"
1685
+ heads = ui["outcome_table"]
1686
+ rows = []
1687
+ for o in outcomes:
1688
+ eids = "".join(f"<code>{esc(e)}</code> " for e in o.get("evidence_ids", []))
1689
+ rows.append(
1690
+ f"<tr><td><strong>{esc(label(lang, 'outcome', o.get('outcome_type')))}</strong>"
1691
+ f"<span class='raw-tag' title='{esc(ui['raw_tag_title'])}'>{esc(o.get('outcome_type'))}</span></td>"
1692
+ f"<td class='num'>{o.get('positive_count', 0)}</td>"
1693
+ f"<td class='num'>{o.get('negative_count', 0)}</td>"
1694
+ f"<td class='num'>{o.get('null_count', 0)}</td><td>{eids}</td></tr>")
1695
+ table = ("<div class='table-wrap outcome-table'><table class='data-table'><thead><tr>"
1696
+ + "".join(f"<th>{esc(h)}</th>" for h in heads)
1697
+ + "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
1698
+ separation = render_outcome_separation(result, lang, ui) if viz["outcome_separation"]["render"] else ""
1699
+ if not viz["outcome_evidence_balance"]["render"]:
1700
+ return separation + table
1701
+ # Interactive ECharts mount: hidden unless echarts runtime + success.
1702
+ mount = (f'<div id="chart-outcome-{lang}" class="chart-mount" '
1703
+ f'aria-label="{esc(chart.get("title") or "Outcome Evidence")}"></div>'
1704
+ ) if chart else ""
1705
+ static = (f'<div class="visual-surface" data-visual="outcome-evidence-balance">'
1706
+ f'{diverging_bar_svg(chart.get("option", {}), lang=lang, ui=ui)}'
1707
+ f'<p class="chart-interpretation"><strong>{esc(ui["what_this_means"])}{esc(ui["colon"])}</strong>'
1708
+ f'{esc(chart.get("summary_text") or "")}</p></div>') if chart else ""
1709
+ figure = (f'<figure class="academic-figure" data-visual="outcome-evidence-balance">'
1710
+ f'{_svg_a11y(figure_svg, ui["svg_figure1_title"], ui["svg_figure1_desc"])}'
1711
+ f'<figcaption>{esc(ui["figure1_caption"])}</figcaption></figure>') if figure_svg else ""
1712
+ return separation + table + static + mount + figure
1713
+
1714
+
1715
+ def render_matrix(result: dict, lang: str, ui: dict) -> str:
1716
+ evidence = result.get("evidence", [])
1717
+ if not evidence:
1718
+ return f"<p>{esc(ui['no_data'])}</p>"
1719
+ heads = ui["matrix_heads"]
1720
+ rows = []
1721
+ for ev in evidence:
1722
+ direction = ev.get("direction", "neutral")
1723
+ rows.append(
1724
+ f"<tr><td><code>{esc(ev.get('evidence_id'))}</code></td>"
1725
+ f"<td class='cell-main'>{esc(ev.get('title') or '')}</td>"
1726
+ f"<td>{esc(label(lang, 'study', ev.get('study_type') or ''))}</td>"
1727
+ f"<td>{esc(label(lang, 'outcome', ev.get('outcome_type') or ''))}</td>"
1728
+ f"<td class='cell-main'>{esc(ev.get('population') or '')}</td>"
1729
+ f"<td class='cell-main'>{esc(ev.get('intervention') or '')}</td>"
1730
+ f"<td><span class='dir {DIR_CLASS.get(direction, 'neu')}'>{esc(label(lang, 'dir', direction))}</span></td>"
1731
+ f"<td class='num'>{esc(ev.get('quality_score'))}</td>"
1732
+ f"<td>{esc(label(lang, 'verdict', str(ev.get('directness') or '')))}</td>"
1733
+ f"<td><code>{esc(ev.get('source_id'))}</code></td>"
1734
+ f"<td class='cell-main'>{esc(ev.get('claim') or '')}</td></tr>")
1735
+ outcomes = sorted({e.get('outcome_type', '') for e in evidence})
1736
+ return ("<details class='matrix-controls'><summary>" + esc(ui["matrix_filter"]) + "</summary>"
1737
+ "<div class='matrix-tools'><input id='matrix-search-"
1738
+ + lang + "' type='search' placeholder='"
1739
+ + esc(ui["matrix_search_ph"]) + "' aria-label='" + esc(ui["matrix_search_label"]) + "'>"
1740
+ "<select id='matrix-direction-"
1741
+ + lang + "' aria-label='" + esc(ui["matrix_dir_filter"]) + "'><option value=''>"
1742
+ + esc(ui["matrix_all_dir"]) + "</option>"
1743
+ "<option value='support'>" + esc(label(lang, "dir", "support")) + "</option>"
1744
+ "<option value='contradict'>" + esc(label(lang, "dir", "contradict")) + "</option>"
1745
+ "<option value='neutral'>" + esc(label(lang, "dir", "neutral")) + "</option></select>"
1746
+ "<select id='matrix-outcome-"
1747
+ + lang + "' aria-label='" + esc(ui["matrix_outcome_filter"]) + "'><option value=''>"
1748
+ + esc(ui["matrix_all_outcome"]) + "</option>"
1749
+ + "".join(f"<option value='{esc(o)}'>{esc(label(lang, 'outcome', o))}</option>" for o in outcomes)
1750
+ + "</select></div></details>"
1751
+ "<div class='table-wrap'><table id='evidence-matrix-" + lang + "' class='data-table'><thead><tr>"
1752
+ + "".join(f"<th>{esc(h)}</th>" for h in heads)
1753
+ + "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
1754
+
1755
+
1756
+ def effect_label(lang: str, value: Any) -> str:
1757
+ effect = str(value or "null").lower()
1758
+ if lang == "zh":
1759
+ return {"positive": "正向效应", "negative": "负向效应", "null": "零效应",
1760
+ "neutral": "零效应"}.get(effect, effect)
1761
+ return {"positive": "Positive effect", "negative": "Negative effect", "null": "Null effect",
1762
+ "neutral": "Null effect"}.get(effect, effect)
1763
+
1764
+
1765
+ def render_evidence_detail(ev: dict, source: dict, lang: str, ui: dict) -> str:
1766
+ """Render complete traceable evidence detail without inventing missing fields."""
1767
+ labels = ({
1768
+ "study_id": "研究 ID", "sample_id": "样本 ID", "title": "研究标题",
1769
+ "year": "年份", "study_type": "研究设计", "education_level": "教育阶段",
1770
+ "population": "研究人群", "sample_size": "样本量", "intervention": "干预",
1771
+ "comparison": "对照 / 比较条件", "outcome_measure": "结果测量", "claim": "完整主张",
1772
+ "effect": "效应 / 结果", "effect_direction": "效应方向", "relation_to_claim": "与主张关系",
1773
+ "duration": "干预时长", "method": "方法", "strengths": "优势",
1774
+ "limitations": "局限", "confounders": "混杂因素", "quality_dimensions": "质量维度",
1775
+ "quality_score": "质量分", "evidence_level": "证据等级", "directness": "直接性",
1776
+ "applicability": "适用性", "confidence": "置信度", "status": "证据状态",
1777
+ "source_location": "来源位置", "source_title": "来源标题", "source_url": "可验证链接",
1778
+ "claim_id": "Claim ID",
1779
+ } if lang == "zh" else {
1780
+ "study_id": "Study ID", "sample_id": "Sample ID", "title": "Study title",
1781
+ "year": "Year", "study_type": "Study design", "education_level": "Education level",
1782
+ "population": "Population", "sample_size": "Sample size", "intervention": "Intervention",
1783
+ "comparison": "Comparison", "outcome_measure": "Outcome measure", "claim": "Full claim",
1784
+ "effect": "Effect / result", "effect_direction": "Effect direction", "relation_to_claim": "Relation to claim",
1785
+ "duration": "Duration", "method": "Method", "strengths": "Strengths",
1786
+ "limitations": "Limitations", "confounders": "Confounders", "quality_dimensions": "Quality dimensions",
1787
+ "quality_score": "Quality score", "evidence_level": "Evidence level", "directness": "Directness",
1788
+ "applicability": "Applicability", "confidence": "Confidence", "status": "Evidence status",
1789
+ "source_location": "Source location", "source_title": "Source title", "source_url": "Verifiable link",
1790
+ "claim_id": "Claim ID",
1791
+ })
1792
+
1793
+ values: list[tuple[str, Any]] = []
1794
+ source_title = source.get("title") or ev.get("title")
1795
+ source_year = source.get("year") or ev.get("year")
1796
+ ext = ev.get("extensions") or {}
1797
+ ordered = [
1798
+ ("study_id", ev.get("study_id")), ("sample_id", ev.get("sample_id")),
1799
+ ("title", ev.get("title")), ("source_title", source_title), ("year", source_year),
1800
+ ("study_type", label(lang, "study", ev.get("study_type") or "")),
1801
+ ("education_level", ev.get("education_level")), ("population", ev.get("population")),
1802
+ ("sample_size", ev.get("sample_size")), ("intervention", ev.get("intervention")),
1803
+ ("comparison", ev.get("comparison")), ("outcome_measure", ev.get("outcome_measure")),
1804
+ ("effect", ev.get("effect")), ("effect_direction", effect_label(lang, ev.get("effect_direction"))),
1805
+ ("relation_to_claim", label(lang, "dir", ev.get("relation_to_claim") or ev.get("direction") or "neutral")),
1806
+ ("duration", ev.get("duration")), ("method", ev.get("method")),
1807
+ ("strengths", ev.get("strengths")), ("limitations", ev.get("limitations")),
1808
+ ("confounders", ev.get("confounders")), ("quality_dimensions", ev.get("quality_dimensions")),
1809
+ ("quality_score", ev.get("quality_score")), ("evidence_level", ev.get("evidence_level")),
1810
+ ("directness", ev.get("directness")), ("applicability", ev.get("applicability")),
1811
+ ("confidence", ev.get("confidence")), ("status", label(lang, "status", ev.get("status") or "")),
1812
+ ("claim_id", ext.get("claim_id")), ("claim", ev.get("claim")),
1813
+ ("source_location", ev.get("source_location") or source.get("source_location")),
1814
+ ]
1815
+ for key, value in ordered:
1816
+ if value not in (None, "", [], {}):
1817
+ values.append((key, value))
1818
+
1819
+ parts = []
1820
+ for key, value in values:
1821
+ if isinstance(value, dict):
1822
+ rendered = " · ".join(f"{esc(k)}={esc(v)}" for k, v in value.items())
1823
+ elif isinstance(value, list):
1824
+ rendered = ";".join(esc(v) for v in value)
1825
+ else:
1826
+ rendered = esc(value)
1827
+ parts.append(f'<div class="evidence-detail-row"><dt>{esc(labels.get(key, key))}</dt><dd>{rendered}</dd></div>')
1828
+
1829
+ raw_url = source.get("canonical_url") or source.get("source_location") or ""
1830
+ url = safe_http_url(raw_url)
1831
+ if url:
1832
+ parts.append(f'<div class="evidence-detail-row"><dt>{esc(labels["source_url"])}</dt>'
1833
+ f'<dd><a href="{esc(url)}">{esc(url)}</a></dd></div>')
1834
+ return f'<dl class="evidence-detail-grid">{"".join(parts)}</dl>'
1835
+
1836
+
1837
+ def render_matrix_visual(result: dict, lang: str, ui: dict, instance: str = "brief") -> str:
1838
+ evidence = result.get("evidence", []) or []
1839
+ if not evidence:
1840
+ return f"<p>{esc(ui['no_data'])}</p>"
1841
+ sources = {s.get("source_id"): s for s in result.get("sources", [])}
1842
+ rows = []
1843
+ for ev in evidence:
1844
+ effect = str(ev.get("effect_direction") or "null").lower()
1845
+ relation = ev.get("relation_to_claim") or ev.get("direction") or "neutral"
1846
+ source = sources.get(ev.get("source_id")) or {}
1847
+ source_id = ev.get("source_id") or ""
1848
+ url = safe_http_url(source.get("canonical_url") or source.get("source_location"))
1849
+ source_cell = (f'<a class="source-link" href="{esc(url)}" title="{esc(source.get("title") or source_id)}">'
1850
+ f'<code>{esc(source_id)}</code></a>' if url else f'<code>{esc(source_id)}</code>')
1851
+ try:
1852
+ quality = max(0.0, min(10.0, float(ev.get("quality_score") or 0)))
1853
+ except (TypeError, ValueError):
1854
+ quality = 0.0
1855
+ search_text = " ".join(str(ev.get(k) or "") for k in
1856
+ ("evidence_id", "study_id", "sample_id", "title", "claim", "population",
1857
+ "intervention", "comparison", "effect_direction", "source_id"))
1858
+ claim_short, claim_cut = excerpt_text(ev.get("claim") or "", 210)
1859
+ relation_note = (f'<span class="relation-note">{esc("与主张关系" if lang == "zh" else "Claim relation")}: '
1860
+ f'{esc(label(lang, "dir", relation))}</span>')
1861
+ detail_html = render_evidence_detail(ev, source, lang, ui)
1862
+ rows.append(
1863
+ f'<tr data-effect="{esc(effect)}" data-direction="{esc(effect)}" '
1864
+ f'data-outcome="{esc(ev.get("outcome_type") or "")}" data-search="{esc(search_text.lower())}">'
1865
+ f'<td><code>{esc(ev.get("evidence_id"))}</code></td>'
1866
+ f'<td><strong>{esc(label(lang, "outcome", ev.get("outcome_type") or ""))}</strong></td>'
1867
+ f'<td><span class="dir {EFFECT_CLASS.get(effect, "neu")}">{esc(effect_label(lang, effect))}</span>'
1868
+ f'{relation_note}</td>'
1869
+ f'<td><div class="quality-cell"><span class="num">{quality:g}</span>'
1870
+ f'<span class="quality-meter" aria-hidden="true"><i style="width:{quality * 10:.0f}%"></i></span></div></td>'
1871
+ f'<td class="claim-cell"><p>{esc(claim_short)}{"…" if claim_cut else ""}</p>'
1872
+ f'<details class="matrix-row-detail"><summary>{esc(ui["matrix_details"])}</summary>{detail_html}</details></td>'
1873
+ f'<td>{source_cell}</td></tr>')
1874
+ outcomes = sorted({e.get("outcome_type", "") for e in evidence})
1875
+ suffix = f"{instance}-{lang}"
1876
+ controls = ("<div class='matrix-controls'><div class='matrix-tools'><input id='matrix-search-" + suffix
1877
+ + "' type='search' placeholder='" + esc(ui["matrix_search_ph"]) + "' aria-label='"
1878
+ + esc(ui["matrix_search_label"]) + "'><select id='matrix-direction-" + suffix
1879
+ + "' aria-label='" + esc(ui["matrix_dir_filter"]) + "'><option value=''>"
1880
+ + esc(ui["matrix_all_dir"]) + "</option><option value='positive'>"
1881
+ + esc(ui["effect_positive"]) + "</option><option value='negative'>"
1882
+ + esc(ui["effect_negative"]) + "</option><option value='null'>"
1883
+ + esc(ui["effect_null"]) + "</option></select><select id='matrix-outcome-" + suffix
1884
+ + "' aria-label='" + esc(ui["matrix_outcome_filter"]) + "'><option value=''>"
1885
+ + esc(ui["matrix_all_outcome"]) + "</option>"
1886
+ + "".join(f"<option value='{esc(o)}'>{esc(label(lang, 'outcome', o))}</option>" for o in outcomes)
1887
+ + "</select></div></div>")
1888
+ table = ("<div class='table-wrap matrix-wrap'><table id='evidence-matrix-" + suffix
1889
+ + "' class='data-table evidence-matrix'><thead><tr>"
1890
+ + "".join(f"<th>{esc(h)}</th>" for h in ui["matrix_heads"])
1891
+ + "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
1892
+ return controls + table
1893
+
1894
+
1895
+ def _evidence_ids_from_text(text: Any) -> list[str]:
1896
+ return list(dict.fromkeys(re.findall(r"\bE-[A-Za-z0-9-]+\b", str(text or ""))))
1897
+
1898
+
1899
+ def _tribunal_item_html(item: Any, lang: str, ui: dict, compact: bool) -> str:
1900
+ text = str(item or "")
1901
+ ids = _evidence_ids_from_text(text)
1902
+ refs = (f'<div class="tribunal-evidence-refs">{"".join(f"<code>{esc(eid)}</code>" for eid in ids)}</div>'
1903
+ if ids else "")
1904
+ if not compact:
1905
+ return f'<li><p>{esc(text)}</p>{refs}</li>'
1906
+ short, cut = excerpt_text(text, 220)
1907
+ detail = (f'<details class="detail-expander"><summary>{esc(ui["expand_details"])}</summary>'
1908
+ f'<div class="detail-body"><p>{esc(text)}</p></div></details>' if cut else "")
1909
+ return f'<li><p>{esc(short)}{"…" if cut else ""}</p>{refs}{detail}</li>'
1910
+
1911
+
1912
+ def render_tribunal_visual(result: dict, workflow_svg: str, tribunal_svg: str, lang: str, ui: dict,
1913
+ compact: bool = True) -> str:
1914
+ decision = result.get("decision", {})
1915
+ groups = [
1916
+ ("supported_claims", ui["tribunal_can"], "supported", "✓"),
1917
+ ("uncertain_claims", ui["tribunal_uncertain"], "uncertain", "?"),
1918
+ ("contradicted_claims", ui["tribunal_cannot"], "contradicted", "×"),
1919
+ ("missing_evidence", ui["tribunal_missing"], "missing", "…"),
1920
+ ]
1921
+ cards = []
1922
+ for key, title, cls, symbol in groups:
1923
+ items = decision.get(key) or []
1924
+ visible_items = items[:3] if compact else items
1925
+ lis = "".join(_tribunal_item_html(item, lang, ui, compact) for item in visible_items)
1926
+ if not lis:
1927
+ lis = f"<li>{esc(ui['no_data'])}</li>"
1928
+ remainder = ""
1929
+ if compact and len(items) > len(visible_items):
1930
+ more_label = (f"查看其余 {len(items) - len(visible_items)} 条" if lang == "zh"
1931
+ else f"View {len(items) - len(visible_items)} more")
1932
+ remainder = (f'<details class="tribunal-more"><summary>{esc(more_label)}</summary><ul>'
1933
+ f'{"".join(_tribunal_item_html(item, lang, ui, True) for item in items[len(visible_items):])}'
1934
+ f'</ul></details>')
1935
+ cards.append(f'<article class="tribunal-card {cls}"><header><span aria-hidden="true">{symbol}</span>'
1936
+ f'<h3>{esc(title)}</h3><span class="tribunal-count">{len(items)}</span></header>'
1937
+ f'<ul>{lis}</ul>{remainder}</article>')
1938
+ action = decision.get("recommended_action", "insufficient_evidence")
1939
+ summary = (f'<p class="tribunal-summary"><strong>{esc(ui["tribunal_decision"])}{esc(ui["colon"])}</strong>'
1940
+ f'{esc(label(lang, "action", action))} · <strong>{esc(ui["tribunal_confidence"])}{esc(ui["colon"])}</strong>'
1941
+ f'{esc(label(lang, "confidence", decision.get("confidence", "")))}</p>')
1942
+ supporting_visuals = ""
1943
+ if workflow_svg:
1944
+ supporting_visuals += (f'<details class="supporting-visual">'
1945
+ f'<summary>{esc(ui["tribunal_flow"])}</summary>'
1946
+ f'{_svg_a11y(workflow_svg, ui["svg_workflow_title"], ui["svg_workflow_desc"])}</details>')
1947
+ if tribunal_svg:
1948
+ supporting_visuals += (f'<details class="supporting-visual">'
1949
+ f'<summary>{esc(ui["tribunal_figure"])}</summary>'
1950
+ f'{_svg_a11y(tribunal_svg, ui["svg_tribunal_title"], ui["svg_tribunal_desc"])}</details>')
1951
+ return (f'<div class="evidence-tribunal" data-visual="evidence-tribunal-grid">{summary}'
1952
+ f'<div class="tribunal-grid">{"".join(cards)}</div>{supporting_visuals}</div>')
1953
+
1954
+
1955
+ def trace_chain_html(result: dict, lang: str, ui: dict) -> str:
1956
+ evidence = {e.get("evidence_id"): e for e in result.get("evidence", [])}
1957
+ sources = {s.get("source_id"): s for s in result.get("sources", [])}
1958
+ chains = []
1959
+ for claim in result.get("claims", []):
1960
+ evidence_nodes = []
1961
+ for eid in claim.get("evidence_ids", []):
1962
+ ev = evidence.get(eid)
1963
+ if not ev:
1964
+ continue
1965
+ src = sources.get(ev.get("source_id")) or {}
1966
+ url = safe_http_url(src.get("canonical_url") or src.get("source_location"))
1967
+ source_html = (f'<a href="{esc(url)}"><code>{esc(ev.get("source_id"))}</code></a>'
1968
+ if url else f'<code>{esc(ev.get("source_id"))}</code>')
1969
+ effect = str(ev.get("effect_direction") or "null").lower()
1970
+ relation = ev.get("relation_to_claim") or ev.get("direction") or "neutral"
1971
+ relation_note = ("主张关系" if lang == "zh" else "claim relation") + ": " + label(lang, "dir", relation)
1972
+ evidence_nodes.append(
1973
+ f'<div class="trace-evidence-node"><code>{esc(eid)}</code>'
1974
+ f'<span>{esc(label(lang, "outcome", ev.get("outcome_type") or ""))}</span>'
1975
+ f'<span class="dir {EFFECT_CLASS.get(effect, "neu")}">{esc(effect_label(lang, effect))}</span>'
1976
+ f'<span class="trace-relation">{esc(relation_note)}</span>'
1977
+ f'<span>Q {esc(ev.get("quality_score"))}</span><span class="trace-arrow">→</span>{source_html}</div>')
1978
+ claim_html = expandable_text(claim.get("claim"), ui["expand_details"], 240, "trace-claim-text")
1979
+ chains.append(f'<article class="trace-chain-card"><div class="trace-claim-node"><strong>{esc(claim.get("claim_id") or ui["trace_claim_prefix"])}</strong>'
1980
+ f'{claim_html}</div><div class="trace-arrow">↓</div>'
1981
+ f'<div class="trace-evidence-list">{"".join(evidence_nodes)}</div></article>')
1982
+ return f'<div class="trace-chain" data-visual="trace-chain">{"".join(chains)}</div>'
1983
+
1984
+
1985
+ def render_evidence_to_action(result: dict, lang: str, ui: dict) -> str:
1986
+ decision = result.get("decision", {})
1987
+ app = decision.get("applicability") or result.get("applicability") or {}
1988
+ intervention = result.get("intervention", {}) or {}
1989
+ evaluation = result.get("evaluation", {}) or {}
1990
+ stages = []
1991
+ evidence_summary = (decision.get("supported_claims") or [None])[0]
1992
+ if evidence_summary:
1993
+ stages.append(("Evidence", "证据" if lang == "zh" else "Evidence", evidence_summary))
1994
+ if app.get("suitable_for") or app.get("required_conditions"):
1995
+ text = app.get("suitable_for") or "; ".join(app.get("required_conditions") or [])
1996
+ stages.append(("Applicability", "适用性" if lang == "zh" else "Applicability", text))
1997
+ stages.append(("Decision", "决策" if lang == "zh" else "Decision",
1998
+ label(lang, "action", decision.get("recommended_action") or "insufficient_evidence")))
1999
+ if intervention.get("ai_usage_policy"):
2000
+ stages.append(("Guardrails", "护栏" if lang == "zh" else "Guardrails", intervention.get("ai_usage_policy")))
2001
+ if intervention.get("stop_conditions"):
2002
+ stages.append(("Stop", "停止条件" if lang == "zh" else "Stop conditions", "; ".join(intervention.get("stop_conditions") or [])))
2003
+ eval_text = evaluation.get("success_threshold") or evaluation.get("research_question")
2004
+ if eval_text:
2005
+ stages.append(("Evaluation", "评价" if lang == "zh" else "Evaluation", eval_text))
2006
+ nodes = []
2007
+ for index, (kind, title, text) in enumerate(stages):
2008
+ if index:
2009
+ nodes.append('<span class="flow-arrow" aria-hidden="true">→</span>')
2010
+ node_text = expandable_text(text, ui["expand_details"], 150, "action-node-text")
2011
+ nodes.append(f'<article class="action-node action-{kind.lower()}"><span>{esc(title)}</span>{node_text}</article>')
2012
+ return f'<div class="evidence-to-action" data-visual="evidence-to-action">{"".join(nodes)}</div>'
2013
+
2014
+
2015
+ def render_tribunal(result: dict, workflow_svg: str, tribunal_svg: str, lang: str, ui: dict) -> str:
2016
+ decision = result.get("decision", {})
2017
+ action = decision.get("recommended_action", "insufficient_evidence")
2018
+ lines = [f"<p><strong>{esc(ui['tribunal_decision'])}{esc(ui['colon'])}</strong>{esc(label(lang, 'action', action))} · "
2019
+ f"<strong>{esc(ui['tribunal_confidence'])}{esc(ui['colon'])}</strong>{esc(label(lang, 'confidence', decision.get('confidence', '')))}</p>"]
2020
+
2021
+ def group(key: str, label: str, cls: str) -> str:
2022
+ items = decision.get(key) or []
2023
+ if not items:
2024
+ return ""
2025
+ lis = "".join(f"<li>{esc(i)}</li>" for i in items)
2026
+ return f"<h3>{esc(label)}</h3><ul class='{cls}'>{lis}</ul>"
2027
+
2028
+ lines.append(group("supported_claims", ui["tribunal_can"], "can"))
2029
+ lines.append(group("uncertain_claims", ui["tribunal_uncertain"], "uncertain"))
2030
+ lines.append(group("contradicted_claims", ui["tribunal_cannot"], "cannot"))
2031
+ if decision.get("missing_evidence"):
2032
+ lines.append(f"<h3>{esc(ui['tribunal_missing'])}</h3><ul>"
2033
+ + "".join(f"<li>{esc(m)}</li>" for m in decision["missing_evidence"]) + "</ul>")
2034
+ lines.append(f"<h3>{esc(ui['tribunal_flow'])}</h3>")
2035
+ lines.append(_svg_a11y(workflow_svg, ui["svg_workflow_title"], ui["svg_workflow_desc"]))
2036
+ lines.append(f"<h3>{esc(ui['tribunal_figure'])}</h3>")
2037
+ lines.append(_svg_a11y(tribunal_svg, ui["svg_tribunal_title"], ui["svg_tribunal_desc"]))
2038
+ return "\n".join(lines)
2039
+
2040
+
2041
+ def methodology_item_label(lang: str, item: Any) -> str:
2042
+ key = str(item or "")
2043
+ if lang == "zh":
2044
+ return METHODOLOGY_LABELS_ZH.get(key, key.replace("_", " "))
2045
+ return key.replace("_", " ").strip().title()
2046
+
2047
+
2048
+ def render_methodology(result: dict, lang: str, ui: dict) -> str:
2049
+ reviews = result.get("methodology_reviews", [])
2050
+ if not reviews:
2051
+ return f"<p>{esc(ui['no_data'])}</p>"
2052
+ groups = []
2053
+ for r in reviews:
2054
+ verdict = r.get("verdict", "")
2055
+ items = []
2056
+ for item, info in (r.get("audit_items", {}) or {}).items():
2057
+ if not isinstance(info, dict):
2058
+ continue
2059
+ status = str(info.get("status") or "")
2060
+ note = str(info.get("note") or "")
2061
+ short, cut = excerpt_text(note, 145)
2062
+ detail = (f'<details class="method-detail detail-expander"><summary>{esc(ui["expand_methodology"])}</summary>'
2063
+ f'<div class="detail-body"><p>{esc(note)}</p></div></details>' if cut else "")
2064
+ status_class = re.sub(r"[^a-z0-9_-]+", "-", status.lower())
2065
+ items.append(
2066
+ f'<article class="method-audit-item method-{esc(status_class)}">'
2067
+ f'<div class="method-audit-head"><strong>{esc(methodology_item_label(lang, item))}</strong>'
2068
+ f'<span class="method-status">{esc(label(lang, "verdict", status))}</span></div>'
2069
+ f'<p>{esc(short)}{"…" if cut else ""}</p>{detail}</article>')
2070
+ guard = r.get("task_vs_learning_guard", {}) or {}
2071
+ guard_html = expandable_text(guard.get("note"), ui["expand_methodology"], 220, "method-guard") if guard else ""
2072
+ guard_block = (f'<div class="method-guard-wrap"><strong>{esc(ui["method_guard"])}{esc(ui["colon"])}</strong>'
2073
+ f'{guard_html}</div>' if guard_html else "")
2074
+ groups.append(
2075
+ f'<section class="method-review-group"><header class="method-review-title">'
2076
+ f'<h3>{esc(ui["method_target"])}{esc(ui["colon"])}{esc(r.get("target") or "")}</h3>'
2077
+ f'<span class="method-verdict">{esc(label(lang, "verdict", verdict))}</span></header>'
2078
+ f'<div class="method-audit-grid">{"".join(items)}</div>{guard_block}'
2079
+ f'</section>')
2080
+ return "".join(groups)
2081
+
2082
+
2083
+ def render_conflicts(result: dict, lang: str, ui: dict) -> str:
2084
+ conflicts = result.get("conflicts", [])
2085
+ decision = result.get("decision", {})
2086
+ texts = []
2087
+ for c in conflicts:
2088
+ for key in ("reason_for_disagreement", "explanation", "note"):
2089
+ if c.get(key):
2090
+ texts.append(str(c[key]).strip())
2091
+ break
2092
+ if decision.get("reason_for_disagreement"):
2093
+ texts.append(str(decision["reason_for_disagreement"]).strip())
2094
+ unique = list(dict.fromkeys(text for text in texts if text))
2095
+ if not unique:
2096
+ return f"<p>{esc(ui['no_data'])}</p>"
2097
+ cards = []
2098
+ for index, text in enumerate(unique):
2099
+ title = ui["conflict_verdict"] if index == 0 else ("补充冲突依据" if lang == "zh" else "Additional conflict rationale")
2100
+ cards.append(f'<article class="conflict-card"><strong>{esc(title)}{esc(ui["colon"])}</strong>'
2101
+ f'{expandable_text(text, ui["expand_details"], 300, "conflict-text")}</article>')
2102
+ return "".join(cards)
2103
+
2104
+
2105
+ def render_applicability(result: dict, lang: str, ui: dict) -> str:
2106
+ decision = result.get("decision", {})
2107
+ app = decision.get("applicability") or result.get("applicability") or {}
2108
+ labels = ui["applicability"]
2109
+ keys = [("who", labels[0]), ("which_course", labels[1]), ("which_outcome", labels[2]),
2110
+ ("conditions", labels[3]), ("target_population", labels[4]), ("target_context", labels[5])]
2111
+ out = []
2112
+ for key, label in keys:
2113
+ if app.get(key):
2114
+ out.append(f"<p><strong>{esc(label)}{esc(ui['colon'])}</strong>{esc(app[key])}</p>")
2115
+ for key, label in keys:
2116
+ if key not in app and decision.get(key):
2117
+ out.append(f"<p><strong>{esc(label)}{esc(ui['colon'])}</strong>{esc(decision[key])}</p>")
2118
+ if app.get("suitable_for"):
2119
+ out.append(f"<p><strong>{esc(labels[0])}{esc(ui['colon'])}</strong>{esc(app['suitable_for'])}</p>")
2120
+ if app.get("not_suitable_for"):
2121
+ out.append(f"<p><strong>{esc(ui['applicability_not_suitable'])}{esc(ui['colon'])}</strong>"
2122
+ f"{esc(app['not_suitable_for'])}</p>")
2123
+ if app.get("required_conditions"):
2124
+ lis = "".join(f"<li>{esc(c)}</li>" for c in app["required_conditions"])
2125
+ out.append(f"<p><strong>{esc(ui['applicability_conditions'])}{esc(ui['colon'])}</strong></p><ul>{lis}</ul>")
2126
+ return "\n".join(out) or f"<p>{esc(ui['no_data'])}</p>"
2127
+
2128
+
2129
+ def render_intervention(result: dict, svg: str, lang: str, ui: dict) -> str:
2130
+ intervention = result.get("intervention", {})
2131
+ if not intervention:
2132
+ return f"<p>{esc(ui['no_data'])}</p>"
2133
+ lines = [f"<p><strong>{esc(ui['intervention_learners'])}{esc(ui['colon'])}</strong>{esc(intervention.get('target_learners'))} · "
2134
+ f"<strong>{esc(ui['intervention_duration'])}{esc(ui['colon'])}</strong>{esc(intervention.get('pilot_duration'))}</p>"]
2135
+ if intervention.get("ai_usage_policy"):
2136
+ lines.append(f"<p><strong>{esc(ui['intervention_policy'])}{esc(ui['colon'])}</strong>{esc(intervention['ai_usage_policy'])}</p>")
2137
+ for phase in ("phase_1", "phase_2", "phase_3", "phase_4"):
2138
+ p = intervention.get(phase)
2139
+ if isinstance(p, dict):
2140
+ name = p.get("name", phase)
2141
+ lines.append(f"<div class='phase'><h3>{esc(name)}</h3>"
2142
+ f"<p><strong>{esc(ui['intervention_rule'])}{esc(ui['colon'])}</strong>{esc(p.get('ai_usage_rule', ''))}</p>")
2143
+ activities = p.get("activities") or []
2144
+ if activities:
2145
+ lis = "".join(f"<li>{esc(a)}</li>" for a in activities)
2146
+ lines.append(f"<p><strong>{esc(ui['intervention_activities'])}{esc(ui['colon'])}</strong></p><ul>{lis}</ul>")
2147
+ if p.get("outcome_check"):
2148
+ lines.append(f"<p><strong>{esc(ui['intervention_check'])}{esc(ui['colon'])}</strong>{esc(p['outcome_check'])}</p>")
2149
+ lines.append("</div>")
2150
+ if intervention.get("stop_conditions"):
2151
+ lis = "".join(f"<li>{esc(s)}</li>" for s in intervention["stop_conditions"])
2152
+ lines.append(f"<h3>{esc(ui['intervention_stop'])}</h3><ul>{lis}</ul>")
2153
+ lines.append(f"<h3>{esc(ui['intervention_timeline'])}</h3>")
2154
+ lines.append(_svg_a11y(svg, ui["svg_intervention_title"], ui["svg_intervention_desc"]))
2155
+ return "\n".join(lines)
2156
+
2157
+
2158
+ def render_evaluation(result: dict, svg: str, lang: str, ui: dict) -> str:
2159
+ evaluation = result.get("evaluation", {})
2160
+ if not evaluation:
2161
+ return f"<p>{esc(ui['no_data'])}</p>"
2162
+ measures = ui["evaluation_measures"]
2163
+ lines = [f"<p><strong>{esc(ui['evaluation_question'])}{esc(ui['colon'])}</strong>{esc(evaluation.get('research_question'))}</p>"]
2164
+ for key, label in (("baseline", measures[0]), ("post_test", measures[1]),
2165
+ ("retention_test", measures[2]), ("transfer_test", measures[3])):
2166
+ if evaluation.get(key):
2167
+ lines.append(f"<p><strong>{esc(label)}{esc(ui['colon'])}</strong>{esc(evaluation[key])}</p>")
2168
+ metric_labels = ui["evaluation_metrics"]
2169
+ for key, label in (("process_metrics", metric_labels[0]), ("learning_metrics", metric_labels[1]),
2170
+ ("risk_metrics", metric_labels[2])):
2171
+ items = evaluation.get(key) or []
2172
+ if items:
2173
+ lis = "".join(f"<li>{esc(i)}</li>" for i in items)
2174
+ lines.append(f"<h3>{esc(label)}</h3><ul>{lis}</ul>")
2175
+ if evaluation.get("success_threshold"):
2176
+ lines.append(f"<p><strong>{esc(ui['evaluation_threshold'])}{esc(ui['colon'])}</strong>{esc(evaluation['success_threshold'])}</p>")
2177
+ if evaluation.get("analysis_plan"):
2178
+ lines.append(f"<p><strong>{esc(ui['evaluation_plan'])}{esc(ui['colon'])}</strong>{esc(evaluation['analysis_plan'])}</p>")
2179
+ lines.append(f"<h3>{esc(ui['evaluation_figure'])}</h3>")
2180
+ lines.append(_svg_a11y(svg, ui["svg_evaluation_title"], ui["svg_evaluation_desc"]))
2181
+ return "\n".join(lines)
2182
+
2183
+
2184
+ def render_benchmark(charts: dict, lang: str, ui: dict) -> str:
2185
+ panel = charts.get("benchmark") or {}
2186
+ if not panel:
2187
+ return f"<p>{esc(ui['benchmark_note'])}</p>"
2188
+ static = grouped_bar_svg(panel.get("option", {}), note=ui["benchmark_note"],
2189
+ lang=lang, ui=ui)
2190
+ mount = (f'<div id="chart-benchmark-{lang}" class="chart-mount" '
2191
+ f'aria-label="{esc(panel.get("title") or "Benchmark")}"></div>')
2192
+ return static + mount + f"<p class='chart-summary'>{esc(panel.get('summary_text', ''))}</p>"
2193
+
2194
+
2195
+ def render_source_detail(source: dict, lang: str, ui: dict) -> str:
2196
+ raw_url = source.get("canonical_url") or source.get("source_location") or ""
2197
+ url = safe_http_url(raw_url)
2198
+ fetch = source.get("fetch") or {}
2199
+ rows = [
2200
+ (("原文标题" if lang == "zh" else "Original title"), source.get("title")),
2201
+ (("年份" if lang == "zh" else "Year"), source.get("year")),
2202
+ (("权威级别" if lang == "zh" else "Authority"), label(lang, "authority", source.get("authority_level") or "")),
2203
+ (("来源位置" if lang == "zh" else "Source location"), source.get("source_location")),
2204
+ (("获取方式" if lang == "zh" else "Fetch provider"), fetch.get("fetch_provider")),
2205
+ (("获取状态" if lang == "zh" else "Fetch status"), fetch.get("fetch_status")),
2206
+ (("降级路径" if lang == "zh" else "Fallback used"), fetch.get("fallback_used")),
2207
+ (("获取时间" if lang == "zh" else "Fetched at"), fetch.get("fetched_at")),
2208
+ ]
2209
+ body = []
2210
+ for key, value in rows:
2211
+ if value not in (None, "", [], {}):
2212
+ body.append(f'<div class="source-detail-row"><dt>{esc(key)}</dt><dd>{esc(value)}</dd></div>')
2213
+ if url:
2214
+ body.append(f'<div class="source-detail-row"><dt>{esc("可验证链接" if lang == "zh" else "Verifiable link")}</dt>'
2215
+ f'<dd><a href="{esc(url)}">{esc(url)}</a></dd></div>')
2216
+ return f'<dl class="source-detail-grid">{"".join(body)}</dl>'
2217
+
2218
+
2219
+ def render_sources(result: dict, lang: str, ui: dict, expandable: bool = False) -> str:
2220
+ sources = result.get("sources", [])
2221
+ if not sources:
2222
+ return f"<p>{esc(ui['no_data'])}</p>"
2223
+ heads = ui["sources_heads"]
2224
+ rows = []
2225
+ for s in sources:
2226
+ raw_url = s.get("canonical_url") or s.get("source_location") or ""
2227
+ url = safe_http_url(raw_url)
2228
+ location = (f"<a href='{esc(url)}'>{esc(url)}</a>" if url else esc(raw_url))
2229
+ title = esc(s.get("title"))
2230
+ # E6 引用核验徽章:数据来自 engine.citation_check(Crossref/DataCite)。
2231
+ if s.get("retracted"):
2232
+ title += ' <span class="cite-badge cite-retracted">RETRACTED</span>'
2233
+ elif s.get("doi_verified"):
2234
+ title += f' <span class="cite-badge cite-ok">{esc(ui.get("cite_doi_ok", "DOI ✓"))}</span>'
2235
+ if expandable:
2236
+ title += (f'<details class="source-expander detail-expander"><summary>{esc(ui["expand_source"])}</summary>'
2237
+ f'{render_source_detail(s, lang, ui)}</details>')
2238
+ rows.append(
2239
+ f"<tr><td><code>{esc(s.get('source_id'))}</code></td>"
2240
+ f"<td class='cell-main source-title-cell'>{title}</td><td>{esc(s.get('year'))}</td>"
2241
+ f"<td>{esc(label(lang, 'authority', s.get('authority_level')))}</td>"
2242
+ f"<td class='cell-main'>{location}</td></tr>")
2243
+ return ("<h3>" + esc(ui["sources_title"]) + "</h3>"
2244
+ "<div class='table-wrap'><table class='data-table source-table'><thead><tr>"
2245
+ + "".join(f"<th>{esc(h)}</th>" for h in heads)
2246
+ + "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
2247
+
2248
+
2249
+ def render_provenance(result: dict, lang: str, ui: dict) -> str:
2250
+ sources = result.get("sources", [])
2251
+ provenance = result.get("provenance", {}) or {}
2252
+ heads = ui["provenance_heads"]
2253
+ rows = []
2254
+ for s in sources:
2255
+ fetch = s.get("fetch") or {}
2256
+ meaningful = any(fetch.get(k) not in (None, "", False) for k in
2257
+ ("fetch_provider", "fetch_status", "fallback_used", "fetched_at"))
2258
+ if not meaningful:
2259
+ continue
2260
+ rows.append(f"<tr><td><code>{esc(s.get('source_id'))}</code></td>"
2261
+ f"<td>{esc(fetch.get('fetch_provider'))}</td>"
2262
+ f"<td>{esc(fetch.get('fetch_status'))}</td>"
2263
+ f"<td>{esc(fetch.get('fallback_used'))}</td>"
2264
+ f"<td>{esc(fetch.get('fetched_at'))}</td></tr>")
2265
+ provider = provenance.get("search_provider")
2266
+ fetched_at = provenance.get("fetched_at")
2267
+ head_parts = []
2268
+ if provider:
2269
+ head_parts.append(f"{esc(ui['provenance_search'])}{esc(ui['colon'])}{esc(provider)}")
2270
+ if fetched_at:
2271
+ head_parts.append(f"{esc(ui['provenance_time'])}{esc(ui['colon'])}{esc(fetched_at)}")
2272
+ head = f'<p class="provenance-summary">{" · ".join(head_parts)}</p>' if head_parts else ""
2273
+ if not rows:
2274
+ return head + f"<p class='provenance-empty'>{esc(ui['provenance_empty'])}</p>"
2275
+ return (head + "<div class='table-wrap'><table class='data-table'><thead><tr>"
2276
+ + "".join(f"<th>{esc(h)}</th>" for h in heads)
2277
+ + "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
2278
+
2279
+
2280
+ # ---------------------------------------------------------------------------
2281
+ # 6. HTML assembly(双语双层报告)
2282
+ # ---------------------------------------------------------------------------
2283
+
2284
+ def resolve_full_report_plan(result: dict) -> list[dict[str, Any]]:
2285
+ """Return a 5–7 chapter plan, preferring an AI-written outline when it is complete.
2286
+
2287
+ Expected optional shape:
2288
+ report_outline.chapters = [
2289
+ {"key": "...", "title_zh": "...", "title_en": "...",
2290
+ "lead_zh": "...", "lead_en": "...", "modules": ["decision", "scope", ...]}
2291
+ ]
2292
+
2293
+ Every semantic module must appear exactly once. Incomplete/unsafe outlines fall back to
2294
+ the six-chapter default rather than silently dropping evidence.
2295
+ """
2296
+ raw = result.get("report_outline") or result.get("report_structure") or {}
2297
+ chapters = raw.get("chapters") if isinstance(raw, dict) else raw if isinstance(raw, list) else None
2298
+ if not isinstance(chapters, list) or not 5 <= len(chapters) <= 7:
2299
+ return [dict(chapter) for chapter in DEFAULT_FULL_REPORT_PLAN]
2300
+
2301
+ normalized: list[dict[str, Any]] = []
2302
+ seen_modules: list[str] = []
2303
+ for index, chapter in enumerate(chapters, 1):
2304
+ if not isinstance(chapter, dict):
2305
+ return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2306
+ modules = [m for m in (chapter.get("modules") or []) if m in FULL_REPORT_MODULES]
2307
+ if not modules or any(m in seen_modules for m in modules):
2308
+ return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2309
+ seen_modules.extend(modules)
2310
+ key = re.sub(r"[^a-z0-9-]+", "-", str(chapter.get("key") or f"chapter-{index}").lower()).strip("-")
2311
+ normalized.append({
2312
+ "key": key or f"chapter-{index}",
2313
+ "title_zh": str(chapter.get("title_zh") or chapter.get("title") or f"第 {index} 章"),
2314
+ "title_en": str(chapter.get("title_en") or chapter.get("title") or f"Chapter {index}"),
2315
+ "lead_zh": str(chapter.get("lead_zh") or ""),
2316
+ "lead_en": str(chapter.get("lead_en") or ""),
2317
+ "modules": tuple(modules),
2318
+ })
2319
+ if set(seen_modules) != set(FULL_REPORT_MODULES):
2320
+ return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2321
+ if "decision" not in normalized[0]["modules"] or "sources" not in normalized[-1]["modules"]:
2322
+ return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2323
+ return normalized
2324
+
2325
+
2326
+ def full_chapter_title(chapter: dict[str, Any], lang: str, index: int) -> str:
2327
+ title = chapter.get("title_zh" if lang == "zh" else "title_en") or chapter.get("key") or ""
2328
+ return f"{index:02d} {title}"
2329
+
2330
+
2331
+ def chapter_dom_id(lang: str, chapter_key: str, index: int) -> str:
2332
+ base = f"full-{index:02d}-{chapter_key}"
2333
+ return base if lang == "zh" else f"{base}-en"
2334
+
2335
+
2336
+ def render_full_chapter(chapter_id: str, title: str, content: str, lead: str = "") -> str:
2337
+ lead_html = f'<p class="full-chapter-lead">{esc(lead)}</p>' if lead else ""
2338
+ return (f'<section id="{esc(chapter_id)}" class="full-chapter" data-full-chapter>'
2339
+ f'<header class="full-chapter-header"><h2>{esc(title)}</h2>{lead_html}</header>'
2340
+ f'<div class="full-chapter-body">{content}</div></section>')
2341
+
2342
+
2343
+ def frame_enum_label(lang: str, value: Any) -> str:
2344
+ text = str(value or "")
2345
+ return (FRAME_ENUM_ZH if lang == "zh" else FRAME_ENUM_EN).get(text, text)
2346
+
2347
+
2348
+ def labeled_pairs(lang: str, data: dict, labels_zh: dict[str, str], labels_en: dict[str, str]) -> str:
2349
+ labels = labels_zh if lang == "zh" else labels_en
2350
+ parts = []
2351
+ for key, value in data.items():
2352
+ if value in (None, "", [], {}):
2353
+ continue
2354
+ rendered = frame_enum_label(lang, value) if isinstance(value, str) else str(value)
2355
+ parts.append(f"{labels.get(key, key)}:{rendered}" if lang == "zh" else f"{labels.get(key, key)}: {rendered}")
2356
+ return ";".join(parts) if lang == "zh" else "; ".join(parts)
2357
+
2358
+
2359
+ def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2360
+ frame = result.get("research_frame", {}) or {}
2361
+ learner = frame.get("learner", {}) or {}
2362
+ course = frame.get("course", {}) or {}
2363
+ intervention = frame.get("intervention", {}) or {}
2364
+ scope = frame.get("scope", {}) or {}
2365
+ labels = ({
2366
+ "question": "研究问题", "learner": "目标学习者", "course": "课程情境", "intervention": "AI 干预",
2367
+ "comparison": "比较条件", "outcomes": "结果构念", "scope": "研究范围", "success": "决策成功条件",
2368
+ } if lang == "zh" else {
2369
+ "question": "Research question", "learner": "Target learners", "course": "Course context", "intervention": "AI intervention",
2370
+ "comparison": "Comparison", "outcomes": "Outcome constructs", "scope": "Research scope", "success": "Decision success condition",
2371
+ })
2372
+ learner_text = labeled_pairs(lang, learner,
2373
+ {"education_level":"教育阶段", "major":"专业", "prior_knowledge":"先验知识", "special_characteristics":"学习者特征"},
2374
+ {"education_level":"Education level", "major":"Major", "prior_knowledge":"Prior knowledge", "special_characteristics":"Learner characteristics"})
2375
+ course_text = labeled_pairs(lang, course,
2376
+ {"subject":"课程", "course_type":"课程类型", "duration":"课程周期"},
2377
+ {"subject":"Subject", "course_type":"Course type", "duration":"Duration"})
2378
+ intervention_text = labeled_pairs(lang, intervention,
2379
+ {"ai_tool":"AI 工具", "allowed_usage":"允许使用", "frequency":"使用频率", "duration":"干预周期"},
2380
+ {"ai_tool":"AI tool", "allowed_usage":"Allowed usage", "frequency":"Frequency", "duration":"Duration"})
2381
+ outcome_map = frame.get("outcomes", {}) or {}
2382
+ outcome_parts = []
2383
+ for group, values in outcome_map.items():
2384
+ if isinstance(values, list):
2385
+ rendered = "、".join(label(lang, "outcome", v) for v in values) if lang == "zh" else ", ".join(label(lang, "outcome", v) for v in values)
2386
+ outcome_parts.append(f"{frame_enum_label(lang, group)}:{rendered}" if lang == "zh" else f"{frame_enum_label(lang, group)}: {rendered}")
2387
+ scope_parts = []
2388
+ scope_labels_zh = {"time_range":"时间范围", "geography":"地域", "study_types":"研究设计"}
2389
+ scope_labels_en = {"time_range":"Time range", "geography":"Geography", "study_types":"Study designs"}
2390
+ for key, value in scope.items():
2391
+ if value in (None, "", [], {}):
2392
+ continue
2393
+ if key == "study_types" and isinstance(value, list):
2394
+ rendered = "、".join(label(lang, "study", str(v)) for v in value) if lang == "zh" else ", ".join(label(lang, "study", str(v)) for v in value)
2395
+ else:
2396
+ rendered = frame_enum_label(lang, value)
2397
+ field_label = (scope_labels_zh if lang == "zh" else scope_labels_en).get(key, key)
2398
+ scope_parts.append(f"{field_label}:{rendered}" if lang == "zh" else f"{field_label}: {rendered}")
2399
+ scope_text = ";".join(scope_parts) if lang == "zh" else "; ".join(scope_parts)
2400
+ cards = [
2401
+ (labels["question"], frame.get("question") or result.get("meta", {}).get("question")),
2402
+ (labels["learner"], learner_text), (labels["course"], course_text),
2403
+ (labels["intervention"], intervention_text), (labels["comparison"], frame.get("comparison")),
2404
+ (labels["outcomes"], ";".join(outcome_parts) if lang == "zh" else "; ".join(outcome_parts)),
2405
+ (labels["scope"], scope_text), (labels["success"], frame.get("success_condition")),
2406
+ ]
2407
+ return '<div class="scope-grid">' + "".join(
2408
+ f'<article class="scope-card"><h3>{esc(title)}</h3>{expandable_text(text, ui["expand_details"], 260, "scope-text")}</article>'
2409
+ for title, text in cards if text) + '</div>'
2410
+
2411
+
2412
+ def render_retrieval_context(result: dict, lang: str, ui: dict) -> str:
2413
+ frame = result.get("research_frame", {}) or {}
2414
+ inclusion = frame.get("inclusion_criteria") or []
2415
+ exclusion = frame.get("exclusion_criteria") or []
2416
+ provenance = result.get("provenance", {}) or {}
2417
+ sources = result.get("sources", []) or []
2418
+ source_ids = " ".join(f'<code>{esc(s.get("source_id"))}</code>' for s in sources)
2419
+ in_title = "纳入标准" if lang == "zh" else "Inclusion criteria"
2420
+ ex_title = "排除标准" if lang == "zh" else "Exclusion criteria"
2421
+ coverage_title = "证据来源覆盖" if lang == "zh" else "Source coverage"
2422
+ caution = ("当前报告只展示 result 中真实存在的检索与来源信息;没有流程计数时不伪造 PRISMA / funnel 数字。"
2423
+ if lang == "zh" else
2424
+ "This report shows only retrieval metadata present in result; it does not fabricate PRISMA/funnel counts when none exist.")
2425
+ return (f'<div class="retrieval-grid"><article><h3>{esc(in_title)}</h3><ul>'
2426
+ f'{"".join(f"<li>{esc(v)}</li>" for v in inclusion)}</ul></article>'
2427
+ f'<article><h3>{esc(ex_title)}</h3><ul>{"".join(f"<li>{esc(v)}</li>" for v in exclusion)}</ul></article></div>'
2428
+ f'<div class="retrieval-coverage"><h3>{esc(coverage_title)}</h3><p>{source_ids}</p>'
2429
+ f'{expandable_text(provenance.get("search_strategy") or provenance.get("search_query") or caution, ui["expand_details"], 300, "retrieval-note")}</div>')
2430
+
2431
+
2432
+ def render_applicability_boundary(result: dict, lang: str, ui: dict) -> str:
2433
+ base = render_applicability(result, lang, ui)
2434
+ limits = result.get("decision", {}).get("exceeds_evidence_boundary") or []
2435
+ if not limits:
2436
+ return base
2437
+ title = "不可外推的结论" if lang == "zh" else "Claims beyond the evidence boundary"
2438
+ return (base + f'<div class="boundary-block"><h3>{esc(title)}</h3><ul>'
2439
+ + "".join(f'<li>{esc(item)}</li>' for item in limits) + '</ul></div>')
2440
+
2441
+
2442
+ def render_full_report(result: dict, lang: str, ui: dict, charts: dict, infographics: dict,
2443
+ figures: dict, viz: dict) -> str:
2444
+ svg = {}
2445
+ for key in ("workflow", "tribunal", "intervention", "evaluation"):
2446
+ value = infographics.get(key, "")
2447
+ value = value.replace("url(#arr)", f"url(#arr-{lang}-full-{key})")
2448
+ value = value.replace('id="arr"', f'id="arr-{lang}-full-{key}"')
2449
+ svg[key] = value
2450
+ figure_svg = figures.get("outcome-comparison.svg", "")
2451
+ outcome_chart = next((c for c in charts.get("charts", [])
2452
+ if c.get("chart_id") == "outcome-evidence-overview"), None)
2453
+
2454
+ trace_content = trace_chain_html(result, lang, ui)
2455
+ trace_chart = next((c for c in charts.get("charts", [])
2456
+ if c.get("chart_id") == "claim-evidence-trace"), None)
2457
+ if viz.get("claim_trace", {}).get("render"):
2458
+ trace_content += (f'<div id="chart-trace-{lang}" class="chart-mount" '
2459
+ f'aria-label="{esc(trace_chart.get("title") if trace_chart else "Claim-Evidence Trace")}"></div>')
2460
+ trace_content += (f'<p class="chart-interpretation"><strong>{esc(ui["what_this_means"])}{esc(ui["colon"])}</strong>'
2461
+ f'{esc("每个重要主张都必须能追到 Evidence ID 和原始来源。" if lang == "zh" else "Every important claim must resolve to Evidence IDs and original sources.")}</p>')
2462
+ intervention_content = (render_evidence_to_action(result, lang, ui)
2463
+ + render_intervention(result, svg.get("intervention", ""), lang, ui))
2464
+ evaluation_content = render_evaluation(result, svg.get("evaluation", ""), lang, ui)
2465
+ if viz.get("benchmark", {}).get("render"):
2466
+ evaluation_content += render_benchmark(charts, lang, ui)
2467
+ else:
2468
+ evaluation_content += (f'<div class="visual-suppressed"><strong>{esc("基准图已抑制" if lang == "zh" else "Benchmark visual suppressed")}</strong>'
2469
+ f'<p>{esc(ui["benchmark_note"])}</p></div>')
2470
+
2471
+ module_content = {
2472
+ "decision": first_screen(result, lang, ui),
2473
+ "scope": render_research_scope(result, lang, ui),
2474
+ "retrieval": render_retrieval_context(result, lang, ui),
2475
+ "outcomes": render_outcomes(result, outcome_chart, figure_svg, lang, ui, viz),
2476
+ "evidence": render_matrix_visual(result, lang, ui, instance="full"),
2477
+ "quality": render_methodology(result, lang, ui),
2478
+ "conflicts": render_conflicts(result, lang, ui),
2479
+ "trace": (render_tribunal_visual(result, svg.get("workflow", ""), svg.get("tribunal", ""),
2480
+ lang, ui, compact=False) + trace_content),
2481
+ "applicability": render_applicability_boundary(result, lang, ui),
2482
+ "intervention": intervention_content,
2483
+ "evaluation": evaluation_content,
2484
+ "sources": (render_sources(result, lang, ui, expandable=True)
2485
+ + f'<h3>{esc(ui["provenance_title"])}</h3>' + render_provenance(result, lang, ui)
2486
+ + render_v2_history(result, lang, ui)),
2487
+ }
2488
+
2489
+ default_leads = {
2490
+ "decision": ("先明确最终裁决与研究边界,再解释为什么。" if lang == "zh" else "State the final adjudication and research boundary before explaining why."),
2491
+ "evidence": ("把任务表现、真实学习、保持与风险放在同一证据地图中,但不混为一谈。" if lang == "zh" else "Place task performance, actual learning, retention and risk on one evidence map without conflating them."),
2492
+ "quality": ("检查证据为什么可信、哪里冲突,以及哪些结论必须降级。" if lang == "zh" else "Examine why evidence is credible, where it conflicts, and which conclusions require downgrading."),
2493
+ "action": ("把可外推范围、护栏和教学动作连接到具体证据。" if lang == "zh" else "Connect applicability, guardrails and teaching actions to specific evidence."),
2494
+ "evaluation": ("用独立学习结果验证试点,并预先写清停止条件。" if lang == "zh" else "Validate the pilot with independent learning outcomes and pre-specified stop conditions."),
2495
+ "sources": ("保留原始来源、URL、证据 ID 和获取信息,确保可回查。" if lang == "zh" else "Preserve original sources, URLs, evidence IDs and retrieval metadata for auditability."),
2496
+ }
2497
+
2498
+ plan = resolve_full_report_plan(result)
2499
+ rendered = []
2500
+ for index, chapter in enumerate(plan, 1):
2501
+ content = "".join(module_content.get(module, "") for module in chapter.get("modules", ()))
2502
+ lead = chapter.get("lead_zh" if lang == "zh" else "lead_en") or default_leads.get(chapter.get("key"), "")
2503
+ rendered.append(render_full_chapter(
2504
+ chapter_dom_id(lang, str(chapter.get("key") or f"chapter-{index}"), index),
2505
+ full_chapter_title(chapter, lang, index), content, str(lead or "")))
2506
+ return "".join(rendered)
2507
+
2508
+
2509
+ def render_full_toc(result: dict, lang: str, ui: dict) -> str:
2510
+ plan = resolve_full_report_plan(result)
2511
+ links = "".join(
2512
+ f'<a href="#{esc(chapter_dom_id(lang, str(chapter.get("key") or f"chapter-{index}"), index))}" '
2513
+ f'data-toc-target="{esc(chapter_dom_id(lang, str(chapter.get("key") or f"chapter-{index}"), index))}" '
2514
+ f'data-chapter-key="{esc(chapter.get("key") or "")}">{esc(full_chapter_title(chapter, lang, index))}</a>'
2515
+ for index, chapter in enumerate(plan, 1))
2516
+ return (f'<aside class="full-report-toc" aria-label="{esc(ui["contents"])}">'
2517
+ f'<div class="toc-head"><strong>{esc(ui["contents"])}</strong>'
2518
+ f'<button type="button" class="toc-collapse" aria-expanded="true" '
2519
+ f'data-label-collapse="{esc(ui["collapse_contents"])}" data-label-expand="{esc(ui["expand_contents"])}">'
2520
+ f'{esc(ui["collapse_contents"])}</button></div><nav>{links}</nav></aside>')
2521
+
2522
+
2523
+ def render_v2_history(result: dict, lang: str, ui: dict) -> str:
2524
+ """V2-only surfaces: project id / graph revision / decision snapshot /
2525
+ knowledge gaps / study design / dataset-analysis provenance / decision diff.
2526
+
2527
+ Returns "" for V1 inputs so the renderer is a strict superset.
2528
+ """
2529
+ project_id = result.get("project_id")
2530
+ if not project_id:
2531
+ return ""
2532
+ revision = result.get("graph_revision")
2533
+ snap_id = result.get("decision_snapshot_id")
2534
+ out = [f"<h3>{esc(ui['v2_project_title'])}</h3>"]
2535
+ meta = [
2536
+ (ui["v2_project_id"], project_id),
2537
+ (ui["v2_graph_revision"], revision),
2538
+ ]
2539
+ if snap_id:
2540
+ meta.append((ui["v2_decision_snapshot"], snap_id))
2541
+ out.append("<div class='table-wrap'><table class='data-table'><tbody>"
2542
+ + "".join(f"<tr><th>{esc(k)}</th><td>{esc(v)}</td></tr>"
2543
+ for k, v in meta if v not in (None, ""))
2544
+ + "</tbody></table></div>")
2545
+
2546
+ # timeline: revision + decision per snapshot
2547
+ decision = result.get("decision") or result.get("decision", {}).get("verdict")
2548
+ confidence = result.get("confidence_label")
2549
+ if revision is not None:
2550
+ label = (f"<code>{esc(ui['v2_revision'])} {esc(revision)}</code>"
2551
+ f" → {esc(ui['v2_decision'])} {esc(decision)}"
2552
+ + (f" ({esc(confidence)})" if confidence else ""))
2553
+ out.append(f"<p class='v2-timeline'>{esc(ui['v2_timeline'])}{esc(ui['colon'])}{label}</p>")
2554
+
2555
+ # knowledge gaps
2556
+ gaps = result.get("knowledge_gaps") or []
2557
+ if gaps:
2558
+ rows = []
2559
+ for g in gaps:
2560
+ rows.append(
2561
+ f"<tr><td><code>{esc(g.get('gap_id'))}</code></td>"
2562
+ f"<td>{esc(g.get('gap_type'))}</td>"
2563
+ f"<td>{esc(g.get('priority'))}</td>"
2564
+ f"<td>{esc(g.get('reasoning'))}</td></tr>")
2565
+ out.append(f"<h4>{esc(ui['v2_gaps_title'])}</h4>"
2566
+ "<div class='table-wrap'><table class='data-table'><thead><tr>"
2567
+ f"<th>{esc(ui['v2_revision'])}</th><th>{esc(ui['v2_gap_type'])}</th>"
2568
+ f"<th>{esc(ui['v2_gap_priority'])}</th><th>{esc(ui['v2_gap_reasoning'])}</th>"
2569
+ "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
2570
+
2571
+ # study designs
2572
+ designs = result.get("study_designs") or []
2573
+ if designs:
2574
+ rows = []
2575
+ for d in designs:
2576
+ rows.append(
2577
+ f"<tr><td><code>{esc(d.get('design_id'))}</code></td>"
2578
+ f"<td>{esc(d.get('design_type'))}</td>"
2579
+ f"<td>{esc(d.get('research_question'))}</td></tr>")
2580
+ out.append(f"<h4>{esc(ui['v2_design_title'])}</h4>"
2581
+ "<div class='table-wrap'><table class='data-table'><thead><tr>"
2582
+ f"<th>ID</th><th>{esc(ui['v2_design_type'])}</th>"
2583
+ f"<th>{esc(ui['v2_design_question'])}</th>"
2584
+ "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
2585
+
2586
+ # dataset/analysis provenance
2587
+ prov = result.get("analysis_provenance") or []
2588
+ if prov:
2589
+ rows = []
2590
+ for p in prov:
2591
+ rows.append(
2592
+ f"<tr><td><code>{esc(p.get('dataset_id'))}</code></td>"
2593
+ f"<td><code>{esc(p.get('design_id'))}</code></td>"
2594
+ f"<td><code>{esc(p.get('analysis_run_id'))}</code></td></tr>")
2595
+ out.append(f"<h4>{esc(ui['v2_provenance_title'])}</h4>"
2596
+ "<div class='table-wrap'><table class='data-table'><thead><tr>"
2597
+ "<th>Dataset</th><th>Design</th><th>AnalysisRun</th>"
2598
+ "</tr></thead><tbody>" + "".join(rows) + "</tbody></table></div>")
2599
+
2600
+ # decision diff when present
2601
+ diff = result.get("decision_diff")
2602
+ if isinstance(diff, dict):
2603
+ rows = []
2604
+ for key, label in (("from_graph_revision", ui["v2_revision"]),
2605
+ ("to_graph_revision", ui["v2_graph_revision"]),
2606
+ ("action_changed", ui["v2_diff_action"]),
2607
+ ("confidence_changed", ui["v2_diff_confidence"])):
2608
+ if key in diff:
2609
+ rows.append(f"<tr><th>{esc(label)}</th><td>{esc(diff[key])}</td></tr>")
2610
+ if diff.get("changed_claims"):
2611
+ rows.append(f"<tr><th>{esc(ui['v2_diff_claims'])}</th>"
2612
+ f"<td>{esc(', '.join(map(str, diff['changed_claims'])))}</td></tr>")
2613
+ if diff.get("resolved_gaps") or diff.get("new_gaps"):
2614
+ rows.append(f"<tr><th>{esc(ui['v2_diff_gaps'])}</th>"
2615
+ f"<td>{esc('resolved: ' + ', '.join(map(str, diff.get('resolved_gaps') or [])))}"
2616
+ f"{esc(' / new: ' + ', '.join(map(str, diff.get('new_gaps') or [])))}</td></tr>")
2617
+ out.append(f"<h4>{esc(ui['v2_diff_title'])}</h4>"
2618
+ "<div class='table-wrap'><table class='data-table'><tbody>"
2619
+ + "".join(rows) + "</tbody></table></div>")
2620
+
2621
+ return "".join(out)
2622
+
2623
+
2624
+ def _theme_css() -> str:
2625
+ blocks = []
2626
+ for name in THEME_NAMES:
2627
+ css = THEMES_DIR / f"{name}.css"
2628
+ if css.exists():
2629
+ blocks.append(css.read_text(encoding="utf-8"))
2630
+ return "\n".join(blocks)
2631
+
2632
+
2633
+ def _motion_css() -> str:
2634
+ path = MOTION_DIR / "motion.css"
2635
+ return path.read_text(encoding="utf-8") if path.exists() else ""
2636
+
2637
+
2638
+ def _motion_js() -> str:
2639
+ path = MOTION_DIR / "motion.js"
2640
+ return path.read_text(encoding="utf-8") if path.exists() else ""
2641
+
2642
+
2643
+
2644
+ def _base_css() -> str:
2645
+ """Base stylesheet extracted from this file (E2); kept in assets/base.css."""
2646
+ path = THEMES_DIR.parent / "assets" / "base.css"
2647
+ return path.read_text(encoding="utf-8") if path.exists() else ""
2648
+
2649
+ def _lang_switcher(ui_zh: dict, ui_en: dict) -> str:
2650
+ return (
2651
+ f'<div class="lang-switcher" role="group" aria-label="{esc(ui_zh["lang_switcher_aria"])}">'
2652
+ f'<span data-lang-label data-zh="{esc(ui_zh["lang_label"])}" '
2653
+ f'data-en="{esc(ui_en["lang_label"])}">{esc(ui_zh["lang_label"])}</span>'
2654
+ f'<button type="button" data-lang-target="zh" class="lang-btn active" aria-pressed="true">{esc(ui_zh["zh"])}</button>'
2655
+ f'<button type="button" data-lang-target="en" class="lang-btn" aria-pressed="false">{esc(ui_en["en"])}</button>'
2656
+ "</div>")
2657
+
2658
+
2659
+ def _enhancer_js(charts_zh: dict, charts_en: dict, result_en: dict) -> str:
2660
+ outcome_zh = next((c for c in charts_zh.get("charts", [])
2661
+ if c.get("chart_id") == "outcome-evidence-overview"), None)
2662
+ trace_zh = next((c for c in charts_zh.get("charts", [])
2663
+ if c.get("chart_id") == "claim-evidence-trace"), None)
2664
+ benchmark_zh = charts_zh.get("benchmark") or {}
2665
+ outcome_en = next((c for c in charts_en.get("charts", [])
2666
+ if c.get("chart_id") == "outcome-evidence-overview"), None)
2667
+ trace_en = next((c for c in charts_en.get("charts", [])
2668
+ if c.get("chart_id") == "claim-evidence-trace"), None)
2669
+ benchmark_en = charts_en.get("benchmark") or {}
2670
+ matrix_rows = [
2671
+ {"id": e.get("evidence_id"), "title": e.get("title", ""),
2672
+ "direction": e.get("direction", "neutral"),
2673
+ "outcome": e.get("outcome_type", ""), "quality": e.get("quality_score", 0)}
2674
+ for e in result_en.get("evidence", [])
2675
+ ]
2676
+ return f"""
2677
+ (function () {{
2678
+ 'use strict';
2679
+ var root = document.documentElement;
2680
+ // ---- Language switcher (zh / en) ----
2681
+ function applyLang(lang) {{
2682
+ document.querySelectorAll('.report-shell[data-lang-body]').forEach(function (shell) {{
2683
+ shell.style.display = shell.dataset.langBody === lang ? '' : 'none';
2684
+ }});
2685
+ document.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
2686
+ document.querySelectorAll('.lang-btn').forEach(function (b) {{
2687
+ var active = b.dataset.langTarget === lang;
2688
+ b.classList.toggle('active', active);
2689
+ b.setAttribute('aria-pressed', active ? 'true' : 'false');
2690
+ }});
2691
+ var langLabel = document.querySelector('[data-lang-label]');
2692
+ if (langLabel) {{
2693
+ langLabel.textContent = lang === 'en' ? langLabel.dataset.en : langLabel.dataset.zh;
2694
+ }}
2695
+ Object.keys(window.eduevidenceCharts || {{}}).forEach(function (id) {{
2696
+ var ch = window.eduevidenceCharts[id];
2697
+ if (ch && ch.resize) ch.resize();
2698
+ }});
2699
+ try {{ localStorage.setItem('eduevidence-lang', lang); }} catch (e) {{}}
2700
+ }}
2701
+ var savedLang = null;
2702
+ try {{ savedLang = localStorage.getItem('eduevidence-lang'); }} catch (e) {{}}
2703
+ applyLang(savedLang === 'en' ? 'en' : 'zh');
2704
+ document.querySelectorAll('.lang-btn').forEach(function (btn) {{
2705
+ btn.addEventListener('click', function () {{ applyLang(btn.dataset.langTarget); }});
2706
+ }});
2707
+
2708
+ // ---- Top-level Visual Brief / Full Report pagination ----
2709
+ function applyReportView(view) {{
2710
+ view = view === 'full' ? 'full' : 'brief';
2711
+ document.querySelectorAll('.report-page[data-report-page]').forEach(function (page) {{
2712
+ page.hidden = page.dataset.reportPage !== view;
2713
+ }});
2714
+ document.querySelectorAll('.report-view-btn').forEach(function (btn) {{
2715
+ var active = btn.dataset.reportView === view;
2716
+ btn.classList.toggle('active', active);
2717
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
2718
+ }});
2719
+ root.dataset.reportView = view;
2720
+ try {{ localStorage.setItem('eduevidence-report-view', view); }} catch (e) {{}}
2721
+ }}
2722
+ var savedView = null;
2723
+ try {{ savedView = localStorage.getItem('eduevidence-report-view'); }} catch (e) {{}}
2724
+ applyReportView(savedView === 'full' ? 'full' : 'brief');
2725
+ document.querySelectorAll('.report-view-btn').forEach(function (btn) {{
2726
+ btn.addEventListener('click', function () {{ applyReportView(btn.dataset.reportView); }});
2727
+ }});
2728
+
2729
+ // ---- Collapsible Full Report TOC ----
2730
+ document.querySelectorAll('.full-report-layout').forEach(function (layout) {{
2731
+ var button = layout.querySelector('.toc-collapse');
2732
+ if (!button) return;
2733
+ button.addEventListener('click', function () {{
2734
+ var collapsed = layout.classList.toggle('toc-collapsed');
2735
+ button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
2736
+ button.textContent = collapsed ? button.dataset.labelExpand : button.dataset.labelCollapse;
2737
+ }});
2738
+ }});
2739
+
2740
+ // ---- TOC active chapter tracking ----
2741
+ if ('IntersectionObserver' in window) {{
2742
+ document.querySelectorAll('.report-shell').forEach(function (shell) {{
2743
+ var links = Array.from(shell.querySelectorAll('[data-toc-target]'));
2744
+ if (!links.length) return;
2745
+ var sections = links.map(function (link) {{ return shell.querySelector('#' + CSS.escape(link.dataset.tocTarget)); }}).filter(Boolean);
2746
+ var activeObserver = new IntersectionObserver(function (entries) {{
2747
+ var visible = entries.filter(function (entry) {{ return entry.isIntersecting; }})
2748
+ .sort(function (a,b) {{ return a.boundingClientRect.top - b.boundingClientRect.top; }});
2749
+ if (!visible.length) return;
2750
+ var id = visible[0].target.id;
2751
+ links.forEach(function (link) {{ link.classList.toggle('active', link.dataset.tocTarget === id); }});
2752
+ }}, {{rootMargin:'-12% 0px -72% 0px', threshold:[0,0.01]}});
2753
+ sections.forEach(function (section) {{ activeObserver.observe(section); }});
2754
+ }});
2755
+ }}
2756
+
2757
+ // ---- Evidence matrix filter / search ----
2758
+ function bindMatrix(matrix) {{
2759
+ var suffix = matrix.id.replace(/^evidence-matrix-/, '');
2760
+ var search = document.getElementById('matrix-search-' + suffix);
2761
+ var dirSel = document.getElementById('matrix-direction-' + suffix);
2762
+ var outSel = document.getElementById('matrix-outcome-' + suffix);
2763
+ var rows = matrix.querySelectorAll('tbody tr');
2764
+ function applyFilter() {{
2765
+ var q = (search && search.value || '').toLowerCase();
2766
+ var d = dirSel ? dirSel.value : '';
2767
+ var o = outSel ? outSel.value : '';
2768
+ rows.forEach(function (row) {{
2769
+ var haystack = (row.dataset.search || row.textContent || '').toLowerCase();
2770
+ var direction = row.dataset.direction || row.dataset.effect || '';
2771
+ var outcome = row.dataset.outcome || '';
2772
+ var show = (!q || haystack.indexOf(q) >= 0)
2773
+ && (!d || direction === d) && (!o || outcome === o);
2774
+ row.style.display = show ? '' : 'none';
2775
+ }});
2776
+ }}
2777
+ if (search) search.addEventListener('input', applyFilter);
2778
+ if (dirSel) dirSel.addEventListener('change', applyFilter);
2779
+ if (outSel) outSel.addEventListener('change', applyFilter);
2780
+ }}
2781
+ document.querySelectorAll('table[id^=evidence-matrix-]').forEach(bindMatrix);
2782
+
2783
+ // ---- Motion preference is owned by the fixed Motion Template. ----
2784
+ var reduceMotion = !!window.__EDUEVIDENCE_REDUCE_MOTION__ ||
2785
+ !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
2786
+
2787
+ // ---- ECharts enhancer (only when window.echarts exists) ----
2788
+ // 成功 init 后给容器加 .is-mounted 才显示(6.2:无 ECharts 时不占空白高度)
2789
+ function mountChart(containerId, spec) {{
2790
+ var el = document.getElementById(containerId);
2791
+ if (!el || typeof window.echarts === 'undefined') return;
2792
+ var chart = window.echarts.init(el);
2793
+ var option = Object.assign({{}}, spec.option || {{}});
2794
+ option.animation = !reduceMotion;
2795
+ if (!reduceMotion) {{
2796
+ option.animationDuration = 650;
2797
+ option.animationDurationUpdate = 420;
2798
+ option.animationEasing = 'cubicOut';
2799
+ option.animationEasingUpdate = 'cubicOut';
2800
+ }}
2801
+ chart.setOption(option);
2802
+ el.classList.add('is-mounted');
2803
+ window.eduevidenceCharts = window.eduevidenceCharts || {{}};
2804
+ window.eduevidenceCharts[containerId] = chart;
2805
+ }}
2806
+ mountChart('chart-outcome-zh', {json.dumps(outcome_zh or {}, ensure_ascii=False)});
2807
+ mountChart('chart-trace-zh', {json.dumps(trace_zh or {}, ensure_ascii=False)});
2808
+ mountChart('chart-benchmark-zh', {json.dumps(benchmark_zh, ensure_ascii=False)});
2809
+ mountChart('chart-outcome-en', {json.dumps(outcome_en or {}, ensure_ascii=False)});
2810
+ mountChart('chart-trace-en', {json.dumps(trace_en or {}, ensure_ascii=False)});
2811
+ mountChart('chart-benchmark-en', {json.dumps(benchmark_en, ensure_ascii=False)});
2812
+ }})();
2813
+ """
2814
+
2815
+
2816
+ def render_brief_block(title: str, lead: str, content: str, css_class: str = "") -> str:
2817
+ return (f'<section class="brief-block {esc(css_class)}">'
2818
+ f'<header class="brief-block-header"><h2>{esc(title)}</h2><p>{esc(lead)}</p></header>'
2819
+ f'<div class="brief-block-body">{content}</div></section>')
2820
+
2821
+
2822
+ def render_outcomes_brief(result: dict, chart: dict | None, lang: str, ui: dict, viz: dict) -> str:
2823
+ separation = render_outcome_separation(result, lang, ui) if viz.get("outcome_separation", {}).get("render") else ""
2824
+ if not viz.get("outcome_evidence_balance", {}).get("render") or not chart:
2825
+ return separation
2826
+ visual = (f'<div class="visual-surface brief-chart" data-visual="outcome-evidence-balance">'
2827
+ f'{diverging_bar_svg(chart.get("option", {}), lang=lang, ui=ui)}'
2828
+ f'<p class="chart-interpretation"><strong>{esc(ui["what_this_means"])}{esc(ui["colon"])}</strong>'
2829
+ f'{esc(chart.get("summary_text") or "")}</p></div>')
2830
+ return separation + visual
2831
+
2832
+
2833
+ def render_brief_sources(result: dict, lang: str, ui: dict, limit: int = 4) -> str:
2834
+ sources = result.get("sources", []) or []
2835
+ if not sources:
2836
+ return f'<p>{esc(ui["no_data"])}</p>'
2837
+ cards = []
2838
+ for source in sources[:limit]:
2839
+ url = safe_http_url(source.get("canonical_url") or source.get("source_location"))
2840
+ title = source.get("title") or source.get("source_id") or ""
2841
+ linked = f'<a href="{esc(url)}">{esc(title)}</a>' if url else esc(title)
2842
+ cards.append(f'<article class="brief-source"><code>{esc(source.get("source_id"))}</code>'
2843
+ f'<h3>{linked}</h3><p>{esc(label(lang, "authority", source.get("authority_level") or ""))}'
2844
+ f' · {esc(source.get("year"))}</p></article>')
2845
+ remaining = len(sources) - len(cards)
2846
+ more = (f'<p class="brief-source-more">完整报告中还有 {remaining} 个来源可展开追溯。</p>' if lang == "zh" and remaining > 0
2847
+ else f'<p class="brief-source-more">{remaining} more sources are traceable in the full report.</p>' if remaining > 0
2848
+ else "")
2849
+ return f'<div class="brief-source-grid">{"".join(cards)}</div>{more}'
2850
+
2851
+
2852
+ def render_lieflat_gallery_brief(result: dict, figures: dict, layout: dict,
2853
+ lieflat_meta: dict, lang: str, ui: dict) -> str:
2854
+ """Visual Brief Lieflat gallery — data-driven composition card.
2855
+
2856
+ Renders ONLY entries that passed resolve_visual_layout AND whose extractor
2857
+ produced data (present in figures). Card four-piece set: conclusion title
2858
+ (700) + subtitle (legend, `·` separated) + themed inline SVG + uppercase
2859
+ source line; caption sits under the figure. Suppressed charts are listed
2860
+ with their reasons (Meaningful Visualization Gate mirror).
2861
+ """
2862
+ entries = (layout or {}).get("entries") or []
2863
+ zh = lang == "zh"
2864
+ cards = []
2865
+ for entry in entries:
2866
+ cid = entry.get("chart_id")
2867
+ svg = figures.get(cid)
2868
+ if not svg:
2869
+ continue
2870
+ title = entry.get("title_zh" if zh else "title_en") or ""
2871
+ subtitle = entry.get("subtitle_zh" if zh else "subtitle_en") or ""
2872
+ caption = entry.get("caption_zh" if zh else "caption_en") or ""
2873
+ source_line = f"{entry.get('catalog_ref', '')} · {entry.get('source', '')}".strip(" ·")
2874
+ fig_type = entry.get("type", "")
2875
+ cards.append(
2876
+ f'<figure class="lieflat-card" data-lieflat data-visual="lieflat-{esc(fig_type)}" '
2877
+ f'data-chart-id="{esc(cid)}">'
2878
+ f'<h3 class="lieflat-title">{esc(title)}</h3>'
2879
+ f'<p class="lieflat-sub">{esc(subtitle)}</p>'
2880
+ f'<div class="lieflat-figure">{svg}</div>'
2881
+ + (f'<figcaption class="lieflat-caption">{esc(caption)}</figcaption>' if caption else "")
2882
+ + f'<p class="lieflat-src">{esc(source_line)}</p>'
2883
+ f'</figure>')
2884
+ suppressed = (lieflat_meta or {}).get("suppressed") or []
2885
+ if suppressed:
2886
+ label = ("已抑制 {} 张图(数据不足,镜像 Meaningful Visualization Gate)"
2887
+ if zh else "{} charts suppressed (insufficient data; mirrors the Meaningful Visualization Gate)")
2888
+ items = "".join(
2889
+ f"<li><code>{esc(s.get('catalog_ref') or s.get('type'))}</code>"
2890
+ f"{esc(zh and ':' or ': ')}{esc(s.get('reason') or '')}</li>"
2891
+ for s in suppressed)
2892
+ cards.append(f'<div class="lieflat-suppressed" role="note">'
2893
+ f'<strong>{label.format(len(suppressed))}</strong><ul>{items}</ul></div>')
2894
+ if not cards:
2895
+ return ""
2896
+ return ('<div class="lieflat-gallery-container">' + "".join(cards) + "</div>")
2897
+
2898
+
2899
+ def render_body(result: dict, lang: str, ui: dict, charts: dict, infographics: dict,
2900
+ figures: dict, viz: dict, theme: str, integrity: dict | None = None) -> str:
2901
+ decision = result.get("decision", {})
2902
+ meta = result.get("meta", {})
2903
+ question = meta.get("question") or decision.get("decision_question") or "EduEvidence Report"
2904
+ outcome_chart = next((c for c in charts.get("charts", [])
2905
+ if c.get("chart_id") == "outcome-evidence-overview"), None)
2906
+
2907
+ if lang == "zh":
2908
+ brief_titles = {
2909
+ "decision": ("先看结论", "该不该做、置信度多高、最关键的证据边界在哪。"),
2910
+ "lieflat": ("Lieflat 实证手作画廊", "AI 按数据形状从 Lieflat 目录选型编排;每张图的数字都可溯源到 result.json。"),
2911
+ "outcomes": ("任务表现 ≠ 学习效果", "只展示真正有解释力的结果分离;正向、负向与零效应按 effect_direction 编码。"),
2912
+ "tribunal": ("证据裁决", "支持、不确定、被反驳与缺失证据分开放置,不把长段落平铺在同一层。"),
2913
+ "action": ("从证据到行动", "适用性、护栏、停止条件与评价连成一条可执行路径。"),
2914
+ "sources": ("关键来源", "摘要页只列最关键的来源;完整溯源在完整报告中展开。"),
2915
+ }
2916
+ else:
2917
+ brief_titles = {
2918
+ "decision": ("Decision first", "What to do, how confident we are, and the most important evidence boundary."),
2919
+ "lieflat": ("Lieflat Editorial Gallery", "Charts selected and composed by AI from the Lieflat catalog; every number traces back to result.json."),
2920
+ "outcomes": ("Task performance ≠ learning", "Only informative outcome separation; positive, negative and null effects use effect_direction."),
2921
+ "tribunal": ("Evidence tribunal", "Supported, uncertain, contradicted and missing evidence stay separated instead of flattened into long prose."),
2922
+ "action": ("Evidence to action", "Applicability, guardrails, stop conditions and evaluation form one executable path."),
2923
+ "sources": ("Key sources", "Only the key sources in the brief; full traceability expands in the full report."),
2924
+ }
2925
+
2926
+ lieflat_layout = viz.get("lieflat_layout") or {"entries": []}
2927
+ lieflat_meta = (viz.get("lieflat_meta") or {}).get(lang, {})
2928
+ lieflat_gallery_html = render_lieflat_gallery_brief(result, figures, lieflat_layout,
2929
+ lieflat_meta, lang, ui)
2930
+
2931
+ brief_blocks = [
2932
+ render_brief_block(*brief_titles["decision"], first_screen(result, lang, ui), "brief-decision"),
2933
+ ]
2934
+ if lieflat_gallery_html:
2935
+ brief_blocks.append(render_brief_block(*brief_titles["lieflat"], lieflat_gallery_html, "brief-lieflat"))
2936
+ brief_blocks.extend([
2937
+ render_brief_block(*brief_titles["outcomes"], render_outcomes_brief(result, outcome_chart, lang, ui, viz), "brief-outcomes"),
2938
+ render_brief_block(*brief_titles["tribunal"], render_tribunal_visual(result, "", "", lang, ui, compact=True), "brief-tribunal"),
2939
+ render_brief_block(*brief_titles["action"], render_evidence_to_action(result, lang, ui), "brief-action"),
2940
+ render_brief_block(*brief_titles["sources"], render_brief_sources(result, lang, ui), "brief-sources"),
2941
+ ])
2942
+ brief = "".join(brief_blocks)
2943
+ full_report = render_full_report(result, lang, ui, charts, infographics, figures, viz)
2944
+ toc = render_full_toc(result, lang, ui)
2945
+
2946
+ integrity_text = integrity_footer_text(integrity or {}, ui)
2947
+ footer = ui['footer'].format(integrity=integrity_text)
2948
+ origin = (meta.get('data_origin') or '').strip()
2949
+ if origin:
2950
+ origin_label = DATA_ORIGIN_LABELS.get(origin, {}).get(lang, origin)
2951
+ origin_chip = (f'<span class="data-origin-chip" data-origin="{esc(origin)}">'
2952
+ f'{esc(origin_label)}</span>')
2953
+ else:
2954
+ origin_chip = ''
2955
+ meta_line = (f"{esc(ui['header_mode'])}{esc(label(lang, 'mode', meta.get('mode') or ''))}"
2956
+ f" · {esc(ui['header_generated'])}{esc(meta.get('generated_at') or '')}"
2957
+ f" · {esc(ui['header_evidence'])}{len(result.get('evidence', []))}"
2958
+ f"{esc(ui['header_evidence_suffix'])}"
2959
+ f" · {esc(ui['header_sources'])}{len(result.get('sources', []))}"
2960
+ f"{esc(ui['header_sources_suffix'])}")
2961
+ return f"""<div class="report-shell" data-lang-body="{lang}">
2962
+ <header class="report-header">
2963
+ <div class="report-brand-row"><span class="report-brand">EduEvidence</span><span class="generated-theme-chip">{esc(THEME_DISPLAY[theme])}</span>{origin_chip}</div>
2964
+ <h1>{esc(question)}</h1>
2965
+ <p class="meta">{meta_line}</p>
2966
+ <nav class="report-view-switcher" aria-label="report view">
2967
+ <button type="button" class="report-view-btn active" data-report-view="brief" aria-pressed="true">{esc(ui['visual_brief'])}</button>
2968
+ <button type="button" class="report-view-btn" data-report-view="full" aria-pressed="false">{esc(ui['full_report'])}</button>
2969
+ </nav>
2970
+ </header>
2971
+ <div class="report-page report-page-brief" data-report-page="brief">{brief}</div>
2972
+ <div class="report-page report-page-full" data-report-page="full" hidden>
2973
+ <div class="full-report-intro"><h2>{esc(ui['full_report'])}</h2><p>{esc(ui['full_report_intro'])}</p></div>
2974
+ <div class="full-report-layout">{toc}<main class="full-report-content">{full_report}</main></div>
2975
+ </div>
2976
+ <footer class="report-footer"><p>{esc(footer)}</p></footer>
2977
+ </div>"""
2978
+
2979
+
2980
+ def render_html(result_en: dict, result_zh: dict, charts_zh: dict, charts_en: dict,
2981
+ infographics_zh: dict, infographics_en: dict, figures_zh: dict,
2982
+ figures_en: dict, theme: str, viz: dict,
2983
+ result_sha256: str = "", integrity: dict | None = None) -> str:
2984
+ # Theme is fixed at generation time; language remains switchable in the HTML.
2985
+ body_zh = render_body(result_zh, "zh", UI_ZH, charts_zh, infographics_zh, figures_zh, viz, theme,
2986
+ integrity=integrity)
2987
+ body_en = render_body(result_en, "en", UI_EN, charts_en, infographics_en, figures_en, viz, theme,
2988
+ integrity=integrity)
2989
+ hash_meta = (f'<meta name="eduevidence-result-sha256" content="{esc(result_sha256)}">\n'
2990
+ if result_sha256 else "")
2991
+
2992
+ return f"""<!DOCTYPE html>
2993
+ <html lang="zh-CN" data-theme="{esc(theme)}">
2994
+ <head>
2995
+ <meta charset="utf-8">
2996
+ {hash_meta}<meta name="viewport" content="width=device-width, initial-scale=1">
2997
+ <title>{esc(result_zh.get("meta", {}).get("question") or result_en.get("meta", {}).get("question") or "EduEvidence Evidence Report")}</title>
2998
+ <style>
2999
+ {_theme_css()}
3000
+ {_base_css()}
3001
+ {_motion_css()}
3002
+ </style>
3003
+ </head>
3004
+ <body>
3005
+ <div class="controls">
3006
+ <span class="generated-theme">{esc(THEME_DISPLAY[theme])}</span>
3007
+ {_lang_switcher(UI_ZH, UI_EN)}
3008
+ </div>
3009
+ {body_zh}
3010
+ {body_en}
3011
+ <script>
3012
+ {_motion_js()}
3013
+ </script>
3014
+ <script>
3015
+ {_enhancer_js(charts_zh, charts_en, result_en)}
3016
+ </script>
3017
+ </body>
3018
+ </html>
3019
+ """
3020
+
3021
+
3022
+ # ---------------------------------------------------------------------------
3023
+ # 7. Entry point
3024
+ # ---------------------------------------------------------------------------
3025
+
3026
+ RESULT_HASH_META = re.compile(r'<meta name="eduevidence-result-sha256" content="([0-9a-f]{64})"')
3027
+
3028
+
3029
+ def file_sha256(path: Path) -> str:
3030
+ import hashlib
3031
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
3032
+
3033
+
3034
+ def embedded_result_hash(html_path: Path) -> str:
3035
+ """读取 HTML 内嵌的 result.json SHA-256(HTML-03 一致性校验)。"""
3036
+ text = Path(html_path).read_text(encoding="utf-8")
3037
+ m = RESULT_HASH_META.search(text)
3038
+ if not m:
3039
+ raise ValueError(f"{html_path}: missing embedded eduevidence-result-sha256")
3040
+ return m.group(1)
3041
+
3042
+
3043
+ def write_artifact_manifest(result_path: Path, result_zh_path: Path, html_paths: list[Path],
3044
+ renderer_version: str, git_commit: str, out_path: Path) -> dict:
3045
+ """HTML-03 Artifact Manifest:5 个 HTML 必须内嵌同一 result.json hash。
3046
+
3047
+ 校验每个 HTML 的 embedded hash == result.json 实际 hash,随后写 manifest。
3048
+ """
3049
+ result_path = Path(result_path)
3050
+ result_zh_path = Path(result_zh_path)
3051
+ result_sha = file_sha256(result_path)
3052
+ result = json.loads(result_path.read_text(encoding="utf-8"))
3053
+ themes = []
3054
+ for html_path in html_paths:
3055
+ if embedded_result_hash(html_path) != result_sha:
3056
+ raise ValueError(
3057
+ f"{html_path}: embedded result hash != {result_sha} "
3058
+ f"(not built from {result_path.name})")
3059
+ name = Path(html_path).name
3060
+ for theme in THEME_NAMES:
3061
+ if name.endswith(f"_{theme}.html"):
3062
+ themes.append(theme)
3063
+ break
3064
+ missing = [t for t in THEME_NAMES if t not in themes]
3065
+ if missing:
3066
+ raise ValueError(f"missing theme HTMLs in {html_paths}: {missing}")
3067
+ manifest = {
3068
+ "result_sha256": result_sha,
3069
+ "result_zh_sha256": file_sha256(result_zh_path),
3070
+ "renderer_version": renderer_version,
3071
+ "git_commit": git_commit,
3072
+ "evidence_count": len(result.get("evidence", [])),
3073
+ "source_count": len(result.get("sources", [])),
3074
+ "themes": [t for t in THEME_NAMES if t in themes],
3075
+ }
3076
+ out_path = Path(out_path)
3077
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3078
+ out_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
3079
+ return manifest
3080
+
3081
+
3082
+ def main() -> int:
3083
+ parser = argparse.ArgumentParser(
3084
+ description="Build single-file offline bilingual EduEvidence_Report.html")
3085
+ parser.add_argument("--result", required=True, help="result.json path (English source)")
3086
+ parser.add_argument("--result-zh", help="result.zh.json path (Chinese parallel); defaults to <result_dir>/result.zh.json")
3087
+ parser.add_argument("--out", help="output HTML path (default: <result_dir>/EduEvidence_Report.html)")
3088
+ parser.add_argument("--spec-out", help="report_spec.json path (default: beside --out)")
3089
+ parser.add_argument("--vendor-echarts", help="optional local echarts.min.js to inline (interactive offline)")
3090
+ parser.add_argument("--theme", choices=THEME_NAMES,
3091
+ help="generation-time visual style; interactive terminals prompt when omitted")
3092
+ args = parser.parse_args()
3093
+ theme = resolve_theme(args.theme)
3094
+
3095
+ result_path = Path(args.result)
3096
+ result_en = json.loads(result_path.read_text(encoding="utf-8"))
3097
+ zh_path = Path(args.result_zh) if args.result_zh else result_path.with_name("result.zh.json")
3098
+ if not zh_path.exists():
3099
+ print(f"ERROR: Chinese parallel data missing: {zh_path} (generate it as result.zh.json)")
3100
+ return 2
3101
+ result_zh = json.loads(zh_path.read_text(encoding="utf-8"))
3102
+
3103
+ # 1. Contract validation(两份数据分别校验)
3104
+ for label, data in (("result.json", result_en), ("result.zh.json", result_zh)):
3105
+ problems = validate_contract(data)
3106
+ if problems:
3107
+ print(f"REPORT_INVALID — {label} contract violations:")
3108
+ for p in problems:
3109
+ print(f" - {p}")
3110
+ return 2
3111
+
3112
+ # 2. Claim-Evidence-Source audit
3113
+ for label, data in (("result.json", result_en), ("result.zh.json", result_zh)):
3114
+ audit = audit_claims(data)
3115
+ if audit:
3116
+ print(f"REPORT_INVALID — {label} claim-evidence-source audit failed:")
3117
+ for p in audit:
3118
+ print(f" - {p}")
3119
+ return 2
3120
+
3121
+ # 2.5 Language gate(W1.2:叙述字段人话化 + 双语分离)
3122
+ lang_problems = check_language_parallel(result_en, result_zh)
3123
+ if lang_problems:
3124
+ print("REPORT_INVALID — language gate failed:")
3125
+ for p in lang_problems:
3126
+ print(f" - {p}")
3127
+ return 2
3128
+
3129
+ # 3. Adapters(两份数据分别生成 spec / 信息图 / 学术图;数字同构)
3130
+ charts_zh = build_chart_specs(result_zh, lang="zh")
3131
+ charts_en = build_chart_specs(result_en, lang="en")
3132
+ infographics_zh = build_infographics(result_zh, lang="zh")
3133
+ infographics_en = build_infographics(result_en, lang="en")
3134
+ figure_data = build_figure_data(result_en)
3135
+ figures_zh = render_figures(figure_data, theme=args.theme, lang="zh")
3136
+ figures_en = render_figures(figure_data, theme=args.theme, lang="en")
3137
+
3138
+ # 3.5 Lieflat gallery — AI-composed data-driven charts (visual_layout).
3139
+ # Only registry-validated entries render; each figure is drawn exclusively
3140
+ # from its charts_data extractor bundle (AI never writes numbers).
3141
+ lieflat_layout = resolve_visual_layout(result_en)
3142
+ lieflat_zh, lieflat_meta_zh = render_lieflat_gallery(
3143
+ result_zh, theme, "zh", lieflat_layout["entries"])
3144
+ lieflat_en, lieflat_meta_en = render_lieflat_gallery(
3145
+ result_en, theme, "en", lieflat_layout["entries"])
3146
+ figures_zh.update(lieflat_zh)
3147
+ figures_en.update(lieflat_en)
3148
+
3149
+ # Merge zh/en suppression records (same bundles, label-only differences).
3150
+ suppressed = {}
3151
+ for item in lieflat_meta_zh.get("suppressed", []) + lieflat_meta_en.get("suppressed", []):
3152
+ suppressed[item.get("chart_id") or item.get("type")] = item
3153
+ viz_decisions = visualization_decisions(result_en, charts_en)
3154
+ viz_decisions["lieflat_gallery"] = {
3155
+ "layout_source": "visual_layout" if not lieflat_layout["fallback"]
3156
+ else "deterministic_fallback",
3157
+ "selected": lieflat_meta_en.get("selected", []),
3158
+ "suppressed": list(suppressed.values()),
3159
+ "rejected": lieflat_layout.get("rejected", []),
3160
+ "warnings": lieflat_layout.get("warnings", []),
3161
+ }
3162
+ viz_decisions["lieflat_layout"] = lieflat_layout
3163
+ viz_decisions["lieflat_meta"] = {"zh": lieflat_meta_zh, "en": lieflat_meta_en}
3164
+
3165
+ # 4. Numbers-match integrity gate(两份数据,各自图表 spec)
3166
+ for label, data, charts in (("result.json", result_en, charts_en),
3167
+ ("result.zh.json", result_zh, charts_zh)):
3168
+ problems = check_numbers(data, charts)
3169
+ if problems:
3170
+ print(f"REPORT_INVALID — {label} chart numbers differ from result:")
3171
+ for p in problems:
3172
+ print(f" - {p}")
3173
+ return 2
3174
+
3175
+ # 5. Scientific Integrity(每个 PASS 字段都来自真实检查函数;
3176
+ # no_axis_distortion / colorblind_safe 未实现 → NOT_CHECKED)
3177
+ integrity = compute_integrity(result_en, result_zh, charts_en, charts_zh,
3178
+ lieflat_meta_en=lieflat_meta_en,
3179
+ lieflat_meta_zh=lieflat_meta_zh)
3180
+ if integrity["status"] != "PASS":
3181
+ print(f"REPORT_INVALID — scientific integrity gate failed:")
3182
+ for key, value in integrity.items():
3183
+ if key != "status" and value == "FAIL":
3184
+ print(f" - {key}: FAIL")
3185
+ return 2
3186
+
3187
+ spec = build_report_spec(result_en, charts_en, infographics_en, figures_en, integrity,
3188
+ theme, viz_decisions)
3189
+
3190
+ html_out = Path(args.out) if args.out else result_path.parent / "EduEvidence_Report.html"
3191
+ html_out.parent.mkdir(parents=True, exist_ok=True)
3192
+ result_sha256 = file_sha256(result_path)
3193
+ html_text = render_html(result_en, result_zh, charts_zh, charts_en,
3194
+ infographics_zh, infographics_en, figures_zh, figures_en,
3195
+ theme, viz_decisions, result_sha256=result_sha256,
3196
+ integrity=integrity)
3197
+
3198
+ if args.vendor_echarts:
3199
+ echarts_js = Path(args.vendor_echarts).read_text(encoding="utf-8")
3200
+ html_text = html_text.replace("</head>", f"<script>{echarts_js}</script>\n</head>", 1)
3201
+ print(f"vendored echarts ({len(echarts_js)} bytes) into single file")
3202
+
3203
+ html_out.write_text(html_text, encoding="utf-8")
3204
+ spec_out = Path(args.spec_out) if args.spec_out else html_out.with_name("report_spec.json")
3205
+ spec_out.write_text(json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
3206
+ print(f"wrote {html_out} ({html_out.stat().st_size} bytes) + {spec_out.name} — integrity: {integrity['status']} (zh+en)")
3207
+ return 0
3208
+
3209
+
3210
+ if __name__ == "__main__":
3211
+ sys.exit(main())