eduevidence 6.0.0 → 6.3.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 (267) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/CONTRIBUTING.md +105 -0
  3. package/README.md +113 -49
  4. package/README.zh-CN.md +39 -12
  5. package/SKILL.md +15 -5
  6. package/assets/readme/landing-tour.gif +0 -0
  7. package/assets/readme/studio-tour.gif +0 -0
  8. package/benchmarks/evidence-library.json +277 -1
  9. package/bin/eduevidence.js +2 -1
  10. package/docs/architecture.md +325 -46
  11. package/docs/demo-workplace-ai.md +1 -1
  12. package/docs/install-guide.md +1 -1
  13. package/docs/j-ev-experimental.md +250 -0
  14. package/docs/orchestration-role-model.md +1 -1
  15. package/docs/release-closeout/README.md +1 -1
  16. package/docs/reproducibility.md +138 -0
  17. package/docs/sciverse-api.md +125 -0
  18. package/domains/_neutral/copy/few_shots.json +21 -0
  19. package/domains/_neutral/copy/framing_lexicon.json +19 -0
  20. package/domains/_neutral/copy/module_labels.json +5 -0
  21. package/domains/_neutral/copy/module_labels_footer.json +102 -0
  22. package/domains/_neutral/copy/module_labels_modules.json +204 -0
  23. package/domains/_neutral/copy/module_labels_nav.json +126 -0
  24. package/domains/_neutral/copy/module_labels_summary.json +98 -0
  25. package/domains/_neutral/copy/module_labels_tables.json +164 -0
  26. package/domains/_neutral/copy/module_labels_v2.json +90 -0
  27. package/domains/_neutral/copy/risk_constructs.json +20 -0
  28. package/domains/_neutral/copy/section_titles.json +66 -0
  29. package/domains/_neutral/copy/terminology.json +11 -0
  30. package/domains/check_copy_packs.py +103 -0
  31. package/domains/education/copy/few_shots.json +22 -0
  32. package/domains/education/copy/framing_enums.json +167 -0
  33. package/domains/education/copy/framing_lexicon.json +166 -0
  34. package/domains/education/copy/module_labels.json +169 -0
  35. package/domains/education/copy/risk_constructs.json +48 -0
  36. package/domains/education/copy/section_titles.json +186 -0
  37. package/domains/education/copy/terminology.json +70 -0
  38. package/domains/education/manifest.json +1 -1
  39. package/domains/education/outcome_taxonomy.json +2 -2
  40. package/domains/manifest.json +1 -1
  41. package/domains/policy/copy/few_shots.json +22 -0
  42. package/domains/policy/copy/framing_enums.json +94 -0
  43. package/domains/policy/copy/framing_lexicon.json +174 -0
  44. package/domains/policy/copy/module_labels.json +168 -0
  45. package/domains/policy/copy/risk_constructs.json +33 -0
  46. package/domains/policy/copy/section_titles.json +186 -0
  47. package/domains/policy/copy/terminology.json +64 -0
  48. package/eduevidence_cli.py +10 -0
  49. package/engine/capabilities.py +57 -5
  50. package/engine/decision_policy.py +167 -0
  51. package/engine/evidence_graph.py +14 -10
  52. package/engine/gaps.py +42 -22
  53. package/engine/ids.py +2 -0
  54. package/engine/library.py +6 -2
  55. package/engine/library_builtin.py +7 -4
  56. package/engine/living.py +34 -4
  57. package/engine/migration.py +88 -3
  58. package/engine/orchestration.py +5 -5
  59. package/engine/paths.py +2 -0
  60. package/engine/pilot.py +34 -32
  61. package/engine/taxonomy.py +211 -0
  62. package/engine/tribunal.py +49 -43
  63. package/engine/versions.py +1 -1
  64. package/examples/ai-coding-assistant-evidence/EduEvidence_Report.html +1361 -147
  65. package/examples/ai-coding-assistant-evidence/artifact_manifest.json +3 -3
  66. package/examples/ai-coding-assistant-evidence/citation_check.json +1 -1
  67. package/examples/ai-coding-assistant-evidence/final_verdict.json +107 -0
  68. package/examples/ai-coding-assistant-evidence/gate_report.json +101 -0
  69. package/examples/ai-coding-assistant-evidence/report_spec.json +23 -12
  70. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_academic.html +448 -128
  71. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_claude.html +448 -128
  72. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab-dark.html +448 -128
  73. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab.html +448 -128
  74. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_presentation.html +448 -128
  75. package/examples/ai-coding-assistant-evidence/reports-5themes/report_academic.html +1360 -146
  76. package/examples/ai-coding-assistant-evidence/reports-5themes/report_claude.html +1360 -146
  77. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab-dark.html +1360 -146
  78. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab.html +1360 -146
  79. package/examples/ai-coding-assistant-evidence/reports-5themes/report_presentation.html +1360 -146
  80. package/examples/ai-coding-assistant-evidence/result.json +13 -9
  81. package/examples/ai-coding-assistant-evidence/result.zh.json +45 -41
  82. package/examples/ai-coding-assistant-evidence/skeptic.json +72 -0
  83. package/examples/ai-coding-assistant-evidence/verdict.json +6 -2
  84. package/examples/spaced-retrieval-practice/EduEvidence_Report.html +2728 -0
  85. package/examples/spaced-retrieval-practice/applicability.json +14 -0
  86. package/examples/spaced-retrieval-practice/artifact_manifest.json +15 -0
  87. package/examples/spaced-retrieval-practice/claims.jsonl +3 -0
  88. package/examples/spaced-retrieval-practice/evidence.jsonl +6 -0
  89. package/examples/spaced-retrieval-practice/final_verdict.json +93 -0
  90. package/examples/spaced-retrieval-practice/frame.json +58 -0
  91. package/examples/spaced-retrieval-practice/gate_report.json +101 -0
  92. package/examples/spaced-retrieval-practice/methodology.json +78 -0
  93. package/examples/spaced-retrieval-practice/report.html +2522 -0
  94. package/examples/spaced-retrieval-practice/report_spec.json +212 -0
  95. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_academic.html +2728 -0
  96. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_claude.html +2728 -0
  97. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab-dark.html +2728 -0
  98. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab.html +2728 -0
  99. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_presentation.html +2728 -0
  100. package/examples/spaced-retrieval-practice/reports-5themes/report_academic.html +2728 -0
  101. package/examples/spaced-retrieval-practice/reports-5themes/report_claude.html +2728 -0
  102. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab-dark.html +2728 -0
  103. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab.html +2728 -0
  104. package/examples/spaced-retrieval-practice/reports-5themes/report_presentation.html +2728 -0
  105. package/examples/spaced-retrieval-practice/result.json +942 -0
  106. package/examples/spaced-retrieval-practice/result.zh.json +942 -0
  107. package/examples/spaced-retrieval-practice/skeptic.json +70 -0
  108. package/examples/spaced-retrieval-practice/sources.jsonl +7 -0
  109. package/examples/spaced-retrieval-practice/verdict.json +93 -0
  110. package/examples/workplace-ai-assistant/EduEvidence_Report.html +2814 -0
  111. package/examples/workplace-ai-assistant/artifact_manifest.json +15 -0
  112. package/examples/workplace-ai-assistant/claims.jsonl +4 -4
  113. package/examples/workplace-ai-assistant/evidence.jsonl +4 -4
  114. package/examples/workplace-ai-assistant/evidence_graph.json +15 -15
  115. package/examples/workplace-ai-assistant/final_verdict.json +78 -0
  116. package/examples/workplace-ai-assistant/gate_report.json +101 -0
  117. package/examples/workplace-ai-assistant/report_spec.json +209 -40
  118. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_academic.html +449 -119
  119. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_claude.html +449 -119
  120. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab-dark.html +449 -119
  121. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab.html +449 -119
  122. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_presentation.html +449 -119
  123. package/examples/workplace-ai-assistant/reports-5themes/report_academic.html +2814 -0
  124. package/examples/workplace-ai-assistant/reports-5themes/report_claude.html +2814 -0
  125. package/examples/workplace-ai-assistant/reports-5themes/report_datalab-dark.html +2814 -0
  126. package/examples/workplace-ai-assistant/reports-5themes/report_datalab.html +2814 -0
  127. package/examples/workplace-ai-assistant/reports-5themes/report_presentation.html +2814 -0
  128. package/examples/workplace-ai-assistant/result.json +82 -20
  129. package/examples/workplace-ai-assistant/result.zh.json +82 -20
  130. package/examples/workplace-ai-assistant/skeptic.json +72 -0
  131. package/examples/workplace-ai-assistant/verdict.json +36 -10
  132. package/integrations/agent_mcp.py +2 -2
  133. package/integrations/jev/__init__.py +115 -0
  134. package/integrations/jev/approval.py +212 -0
  135. package/integrations/jev/cli.py +84 -0
  136. package/integrations/jev/config.py +112 -0
  137. package/integrations/jev/gateway.py +128 -0
  138. package/integrations/jev/modes.py +38 -0
  139. package/integrations/jev/tools_classify.py +88 -0
  140. package/integrations/jev/tools_extract.py +111 -0
  141. package/integrations/jev/tools_rerank.py +71 -0
  142. package/integrations/jev/tools_screen.py +87 -0
  143. package/integrations/jev/tools_verify.py +95 -0
  144. package/integrations/jev_mcp.py +22 -0
  145. package/integrations/semantic_decide.py +286 -0
  146. package/integrations/semdecide_cli.py +55 -0
  147. package/package.json +19 -2
  148. package/pyproject.toml +4 -3
  149. package/references/report-copy-style.md +107 -0
  150. package/references/retrieval-compliance.md +75 -0
  151. package/references/retrieval-protocol.md +20 -0
  152. package/retrieval/audit.py +27 -3
  153. package/retrieval/fetch.py +96 -0
  154. package/retrieval/sciverse.py +398 -0
  155. package/retrieval/search.py +47 -7
  156. package/schemas/applicability.schema.json +94 -0
  157. package/schemas/chart-spec.schema.json +10 -3
  158. package/schemas/evidence.schema.json +316 -43
  159. package/schemas/fetch-result.schema.json +2 -1
  160. package/schemas/report-result.schema.json +3 -3
  161. package/schemas/report-spec.schema.json +98 -100
  162. package/schemas/skeptic.schema.json +86 -0
  163. package/schemas/source.schema.json +21 -2
  164. package/schemas/v2/decision-snapshot.schema.json +20 -9
  165. package/schemas/v2/finding.schema.json +5 -1
  166. package/schemas/v2/intake.schema.json +191 -0
  167. package/schemas/v2/methodology-audit.schema.json +5 -1
  168. package/schemas/v2/outcome.schema.json +28 -5
  169. package/schemas/v2/study.schema.json +5 -1
  170. package/schemas/vNext/autoevolve-session.schema.json +34 -1
  171. package/schemas/vNext/eval-snapshot.schema.json +77 -1
  172. package/schemas/vNext/execution-plan.schema.json +50 -1
  173. package/schemas/vNext/gap-priority.schema.json +54 -1
  174. package/schemas/vNext/negative-search-record.schema.json +68 -1
  175. package/schemas/vNext/research-iteration.schema.json +87 -1
  176. package/schemas/vNext/research-strategy.schema.json +62 -1
  177. package/schemas/vNext/skill-experiment.schema.json +90 -1
  178. package/schemas/vNext/task-spec.schema.json +156 -1
  179. package/schemas/vNext/worker-result.schema.json +60 -1
  180. package/schemas/verdict.schema.json +164 -28
  181. package/scripts/build_evidence_library.py +15 -5
  182. package/scripts/build_report_variants.py +18 -2
  183. package/scripts/build_result.py +74 -9
  184. package/scripts/check_package_parity.py +85 -0
  185. package/scripts/check_protocol_alignment.py +375 -0
  186. package/scripts/check_versioned_schemas.py +254 -0
  187. package/scripts/claim_audit.py +13 -8
  188. package/scripts/compute_confidence.py +10 -0
  189. package/scripts/dashboard_server.py +13 -2
  190. package/scripts/did_regression.py +12 -2
  191. package/scripts/evidence_score.py +5 -2
  192. package/scripts/intake/__init__.py +31 -0
  193. package/scripts/intake/__main__.py +18 -0
  194. package/scripts/intake/background.py +78 -0
  195. package/scripts/intake/browser.py +79 -0
  196. package/scripts/intake/cli.py +57 -0
  197. package/scripts/intake/constants.py +57 -0
  198. package/scripts/intake/depth.py +53 -0
  199. package/scripts/intake/enhancements.py +106 -0
  200. package/scripts/intake/hooks.py +90 -0
  201. package/scripts/intake/prefs.py +76 -0
  202. package/scripts/intake/prompts.py +85 -0
  203. package/scripts/intake/session.py +152 -0
  204. package/scripts/lint_file_layers.py +126 -0
  205. package/scripts/orchestrator.py +187 -40
  206. package/scripts/pre_verdict_gate.py +241 -29
  207. package/scripts/quickstart.py +18 -2
  208. package/scripts/run_workspace.py +7 -1
  209. package/scripts/skill_lint.py +11 -1
  210. package/scripts/skill_payload.py +6 -3
  211. package/scripts/test_adversarial_empirical.py +96 -25
  212. package/scripts/validate_schema.py +31 -1
  213. package/skill/agents/evaluation-designer.md +20 -4
  214. package/skill/agents/evidence-analyst.md +19 -3
  215. package/skill/agents/evidence-judge.md +98 -8
  216. package/skill/agents/evidence-retriever.md +20 -3
  217. package/skill/agents/intervention-designer.md +20 -4
  218. package/skill/agents/method-reviewer.md +18 -2
  219. package/skill/agents/{education-planner.md → research-planner.md} +19 -3
  220. package/skill/agents/skeptic.md +18 -2
  221. package/skill/roles/registry.yaml +11 -11
  222. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +28 -9
  223. package/skill/sub-skills/contradiction-analysis/SKILL.md +31 -11
  224. package/skill/sub-skills/data-analysis/SKILL.md +34 -15
  225. package/skill/sub-skills/ethics-review/SKILL.md +33 -10
  226. package/skill/sub-skills/evidence-extraction/SKILL.md +29 -11
  227. package/skill/sub-skills/evidence-review/SKILL.md +31 -12
  228. package/skill/sub-skills/gap-analysis/SKILL.md +31 -9
  229. package/skill/sub-skills/literature-review/SKILL.md +35 -14
  230. package/skill/sub-skills/methodology-audit/SKILL.md +29 -12
  231. package/skill/sub-skills/report-generation/SKILL.md +28 -0
  232. package/skill/sub-skills/research-planning/SKILL.md +41 -14
  233. package/skill/sub-skills/study-design/SKILL.md +30 -9
  234. package/skill/task-briefs/adjudicate.md +32 -7
  235. package/skill/task-briefs/applicability.md +37 -2
  236. package/skill/task-briefs/audit.md +32 -7
  237. package/skill/task-briefs/challenge.md +34 -5
  238. package/skill/task-briefs/evaluate.md +30 -5
  239. package/skill/task-briefs/extract.md +31 -8
  240. package/skill/task-briefs/frame.md +39 -10
  241. package/skill/task-briefs/intervene.md +32 -6
  242. package/skill/task-briefs/present.md +32 -8
  243. package/skill/task-briefs/projection.md +36 -2
  244. package/skill/task-briefs/retrieve.md +36 -6
  245. package/skill/workflows/decision-and-pilot.md +76 -1
  246. package/skill/workflows/evaluate-and-update.md +83 -0
  247. package/skill/workflows/evidence-review.md +104 -0
  248. package/skill/workflows/experimental-jev.md +170 -0
  249. package/skill/workflows/intake.md +120 -0
  250. package/visualization/eduevidence-report/scripts/build_figures.py +25 -3
  251. package/visualization/eduevidence-report/scripts/build_infographics.py +37 -15
  252. package/visualization/eduevidence-report/scripts/build_report.py +435 -575
  253. package/visualization/eduevidence-report/scripts/charts_data.py +2 -0
  254. package/visualization/eduevidence-report/scripts/lieflat_engine.py +349 -38
  255. package/visualization/eduevidence-report/scripts/report_copy_pack.py +296 -0
  256. package/visualization/eduevidence-report/scripts/report_copy_policy_guard.py +47 -0
  257. package/visualization/eduevidence-report/scripts/zh_labels.py +141 -1
  258. package/web/architecture.html +14885 -0
  259. package/web/studio/assets/index-B8tkF44Q.css +1 -0
  260. package/web/studio/index.html +2 -2
  261. package/scripts/build_esl_artifacts.py +0 -1921
  262. package/scripts/build_killer_demo.py +0 -295
  263. package/scripts/enrich_projects_human_and_lieflat.py +0 -315
  264. package/scripts/generate_new_projects.py +0 -686
  265. package/scripts/sync_killer_demo_report.py +0 -270
  266. package/web/studio/assets/index-CzXocaGv.css +0 -1
  267. /package/web/studio/assets/{index-pa7jD7n4.js → index-CQ6Keoyc.js} +0 -0
@@ -37,7 +37,7 @@ from typing import Any
37
37
  from build_charts import build_all as build_chart_specs, effect_outcomes
38
38
  from build_figures import build_figure_data, render_figures, render_lieflat_gallery
39
39
  from build_infographics import build_all as build_infographics
40
- from lieflat_engine import REGISTRY as LIEFLAT_REGISTRY, LEGACY_TYPES, ACADEMIC_FIGURE_KEYS
40
+ from lieflat_engine import REGISTRY as LIEFLAT_REGISTRY, ACADEMIC_FIGURE_KEYS
41
41
  from zh_labels import label
42
42
 
43
43
  THEMES_DIR = Path(__file__).resolve().parent.parent / "themes"
@@ -65,56 +65,21 @@ DATA_ORIGIN_LABELS = {
65
65
  # Full report is intentionally NOT a fixed 12-chapter template. The template exposes
66
66
  # semantic modules; an upstream AI may group them into any 5–7 chapter outline by writing
67
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",
68
+ # Domain copy packs (domains/<id>/copy/) — UI 词典 + 域文案, see report_copy_pack.py.
69
+ from report_copy_pack import ( # noqa: E402
70
+ FULL_REPORT_MODULES,
71
+ activate_copy_pack,
72
+ brief_block_titles,
73
+ build_ui,
74
+ default_full_report_plan,
75
+ default_leads,
76
+ evidence_detail_label_table,
77
+ frame_enum_table,
78
+ methodology_label_table,
79
+ outcome_group_map,
80
+ scope_field_labels,
81
+ scope_subfield_labels,
71
82
  )
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
83
 
119
84
  DIR_LABEL = {"support": "支持", "contradict": "反驳", "neutral": "中性"}
120
85
  DIR_CLASS = {"support": "pos", "contradict": "neg", "neutral": "neu"}
@@ -125,359 +90,6 @@ EFFECT_CLASS = {"positive": "pos", "negative": "neg", "null": "neu", "neutral":
125
90
  # 双语 UI 文案
126
91
  # ---------------------------------------------------------------------------
127
92
 
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
93
  class ReportInvalid(Exception):
482
94
  """Scientific Integrity Gate failure (§27/§60): report must not be published."""
483
95
 
@@ -489,7 +101,7 @@ def esc(text: Any) -> str:
489
101
  def _svg_a11y(svg: str, title: str, desc: str) -> str:
490
102
  """6.5: 为嵌入的 SVG 注入双语 <title>/<desc>,并把 aria-label 换成 UI 字典文案。
491
103
 
492
- title/desc 由调用方按当前语言从 UI_ZH / UI_EN 取;aria-label 已存在时覆盖,
104
+ title/desc 由调用方按当前语言从 build_ui() 取;aria-label 已存在时覆盖,
493
105
  不存在时补上,保证英文模式下不残留中文 aria-label。
494
106
  """
495
107
  if not svg:
@@ -640,39 +252,135 @@ def visualization_decisions(result: dict, charts: dict) -> dict[str, dict[str, A
640
252
  # 0b. Lieflat gallery composition — visual_layout contract (§三)
641
253
  # ---------------------------------------------------------------------------
642
254
 
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": {}},
255
+ # Deterministic fallback when visual_layout is missing or all entries are
256
+ # invalid. The composition is DATA-DRIVEN: instead of a fixed quartet that
257
+ # silently suppresses most charts on an evidence set without numeric effect
258
+ # sizes, candidates are probed against the extractors and only the shapes the
259
+ # data actually supports are kept. Numbers still come exclusively from
260
+ # scripts/charts_data.py, so this remains an honest gallery.
261
+ FALLBACK_CANDIDATES = (
262
+ ("forest_plot", ("证据效应量森林图", "Effect-size forest plot",
263
+ "Hedges' g 与 95% 置信区间 · 一行一篇研究",
264
+ "Hedges' g with 95% CI · one row per study",
265
+ "仅当证据集携带数值效应量时绘制;无 g/CI 数据时不画假图。",
266
+ "Drawn only when numeric effect sizes exist in the evidence set.")),
267
+ ("dot_cascade", ("证据效应量梯队级联", "Ranked effect-size cascade",
268
+ "按效应量由高到低排序 · 圆点高度 = Hedges' g",
269
+ "Sorted by effect size · dot height = Hedges' g",
270
+ "仅当存在逐研究数值效应量时绘制。",
271
+ "Drawn only when per-study numeric effect sizes exist.")),
272
+ ("bubble_almanac", ("发表年份 × 结果维度文献年历", "Year × dimension evidence almanac",
273
+ "气泡面积 ∝ 该格研究数 · 实心圆 = 有显著结果",
274
+ "Bubble area ∝ study count · solid core = significant results",
275
+ "仅当证据集携带发表年份与结果维度时绘制。",
276
+ "Drawn only when years and outcome dimensions exist.")),
277
+ ("matrix_heat", ("年份 × 结果维度证据密度", "Year × outcome evidence density",
278
+ "每格数字 = 该年份该结果维度的证据条数",
279
+ "Each cell counts evidence items for that year and outcome",
280
+ "当证据跨多个年份与结果维度时,展示研究密度的分布。",
281
+ "Shows where the evidence sits across years and outcomes.")),
282
+ ("tick_rows", ("各结果类型效应方向分布", "Effect direction by outcome",
283
+ "每 1 个圆点 = 1 条证据 · 绿 = 正向 · 灰 = 零效应 · 橙 = 负向",
284
+ "One dot = one evidence item · green = positive · grey = null · orange = negative",
285
+ "基于 effect_direction 计数,全部数值来自 result.json。",
286
+ "Based on effect_direction counts; all numbers come from result.json.")),
287
+ ("paired_rungs", ("各结果类型的正负证据对照", "Positive vs negative evidence by outcome",
288
+ "左右两列分别汇总正向与负向证据条数",
289
+ "Two columns summarise positive and negative evidence counts",
290
+ "当同一结果同时存在正向与负向证据时,分列呈现避免相互抵消。",
291
+ "Splits positive and negative evidence so they never cancel out.")),
292
+ ("brand_spectrum", ("各结果类型的净效应倾向", "Net effect direction by outcome",
293
+ "位置 =(正向 − 负向)÷ 方向计数 · 中点为中性",
294
+ "Position = (positive - negative) / directional count · centre is neutral",
295
+ "双极展示各结果构念整体偏向支持还是反对。",
296
+ "Bipolar view of whether each outcome leans supportive or against.")),
297
+ ("hundred_field", ("研究设计构成", "Study-design composition",
298
+ "每格 = 1 篇研究 · 显示证据来自哪些研究设计",
299
+ "One cell = one study · shows which designs produced the evidence",
300
+ "当证据包含多种研究设计时,构成图比表格更快暴露设计偏斜。",
301
+ "Reveals design skew faster than a table when several designs are present.")),
302
+ ("tick_donut", ("方法学评级构成", "Methodology rating composition",
303
+ "每 tick ≈ 1% 构成 · 按 WWC 评级汇总",
304
+ "One tick ~ 1% share · aggregated by WWC rating",
305
+ "仅当证据集记录 WWC 评级时绘制。",
306
+ "Drawn only when WWC ratings are recorded.")),
307
+ ("tick_gauge", ("决策置信度", "Decision confidence",
308
+ "0–100% 单值仪表 · 由确定性置信度公式给出",
309
+ "Single 0-100% gauge from the deterministic confidence formula",
310
+ "置信度值来自 result.json 的决策字段,不由模型自评。",
311
+ "The value comes from the result's decision field, never self-rated.")),
312
+ ("ballot_tally", ("方法学检查项计票", "Methodology checklist tally",
313
+ "每 tick = 1 条审计结论 · 按检查项统计通过与否",
314
+ "One tick = one audit verdict · grouped by checklist item",
315
+ "让读者一眼看出方法学短板集中在哪些检查项。",
316
+ "Shows which checklist items concentrate the weaknesses.")),
317
+ ("launch_fan", ("干预阶段与活动权重", "Intervention phases and activity weight",
318
+ "每段 = 一个试点阶段 · 宽度 = 活动条数",
319
+ "Each segment is one pilot phase · width = number of activities",
320
+ "展示试点在时间上的铺开方式。",
321
+ "Shows how the pilot is spread across its phases.")),
322
+ ("barcode_lollipop", ("试点阶段周次分布", "Pilot phase week spans",
323
+ "每根 = 一个阶段 · 高度 = 阶段序号",
324
+ "One bar per phase · height = phase index",
325
+ "仅当阶段定义包含可解析的周次区间时绘制。",
326
+ "Drawn only when phases carry parseable week ranges.")),
327
+ ("dotty_matrix", ("阶段 × 活动矩阵", "Phase × activity matrix",
328
+ "每点 = 1 项活动 · 行 = 阶段",
329
+ "One dot = one activity · rows are phases",
330
+ "当阶段内含多项活动时,矩阵比列表更易比较。",
331
+ "Compares activity load across phases better than a list.")),
332
+ ("jitter_strip", ("分组效应量分布", "Grouped effect-size distribution",
333
+ "每点 = 一篇研究 · 按结果维度分组",
334
+ "One point = one study · grouped by outcome dimension",
335
+ "仅当多个结果维度各有 3 篇以上带效应量的研究时绘制。",
336
+ "Drawn only when several dimensions each carry 3+ numeric effects.")),
337
+ ("parallel_coordinates", ("跨维度研究画像", "Studies across dimensions",
338
+ "同一批研究在效应量 / 样本量 / 质量 / 年份上的走势",
339
+ "The same studies traced across effect size / N / quality / year",
340
+ "仅当至少 3 篇研究同时具备四项数值时绘制。",
341
+ "Drawn only when at least 3 studies carry all four values.")),
674
342
  )
675
343
 
344
+
345
+ def _fallback_layout_for(result: dict, lang: str) -> list[dict]:
346
+ """Probe the registry and keep only the chart shapes this result supports.
347
+
348
+ ``visual_layout`` is the AI's job; when it is absent this keeps the gallery
349
+ useful without inventing anything: every candidate runs through its own
350
+ extractor, and a candidate whose data is missing is simply not offered.
351
+ One chart per data source avoids repeating the same shape twice.
352
+ """
353
+ from lieflat_engine import REGISTRY
354
+
355
+ chosen: list[dict] = []
356
+ used_sources: set[str] = set()
357
+ for fig_type, copy in FALLBACK_CANDIDATES:
358
+ reg = REGISTRY.get(fig_type)
359
+ if reg is None or reg["source"] in used_sources:
360
+ continue
361
+ try:
362
+ bundle, _reason = reg["extractor"](result, {}, lang)
363
+ except Exception:
364
+ bundle = None
365
+ if bundle is None:
366
+ continue
367
+ used_sources.add(reg["source"])
368
+ title_zh, title_en, sub_zh, sub_en, cap_zh, cap_en = copy
369
+ chosen.append({
370
+ "chart_id": "lieflat-" + fig_type.replace("_", "-") + ".svg",
371
+ "type": fig_type,
372
+ "catalog_ref": reg["catalog_ref"],
373
+ "source": reg["source"],
374
+ "params": {},
375
+ "title_zh": title_zh, "title_en": title_en,
376
+ "subtitle_zh": sub_zh, "subtitle_en": sub_en,
377
+ "caption_zh": cap_zh, "caption_en": cap_en,
378
+ })
379
+ if len(chosen) >= 6:
380
+ break
381
+ return chosen
382
+
383
+
676
384
  LIEFLAT_PARAM_TYPES = {"int": int, "list": list}
677
385
 
678
386
 
@@ -794,9 +502,18 @@ def resolve_visual_layout(result: dict) -> dict[str, Any]:
794
502
  if entries:
795
503
  return {"entries": entries, "fallback": False, "warnings": warnings, "rejected": rejected}
796
504
 
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]
505
+ # Data-driven fallback: probe every candidate shape and keep the ones this
506
+ # result can honestly support, instead of a fixed quartet that suppresses
507
+ # most charts when numeric effect sizes are absent.
508
+ lang_hint = "zh" if result.get("meta", {}).get("lang") == "zh" else "en"
509
+ fallback_entries = _fallback_layout_for(result, lang_hint)
510
+ if fallback_entries:
511
+ warnings.append(
512
+ "visual_layout missing or fully invalid — data-driven fallback selected "
513
+ + ", ".join(e["type"] for e in fallback_entries))
514
+ else:
515
+ warnings.append("visual_layout missing or fully invalid — no chart shape had "
516
+ "sufficient data; gallery suppressed rather than drawn empty")
800
517
  return {"entries": fallback_entries, "fallback": True, "warnings": warnings, "rejected": rejected}
801
518
 
802
519
 
@@ -948,6 +665,9 @@ TEXT_LEAF_KEYS = {
948
665
  "population", "intervention", "comparison", "outcome_measure", "effect", "method",
949
666
  "ai_usage_policy", "target_learners", "ai_usage_rule", "outcome_check",
950
667
  "research_question", "treatment", "baseline", "post_test", "retention_test",
668
+ # Evaluation milestone fields name a measurement point in the reader
669
+ # language; they are prose, not structural identifiers.
670
+ "immediate_post", "retention", "transfer",
951
671
  "transfer_test", "success_threshold", "analysis_plan",
952
672
  "suitable_for", "not_suitable_for", "search_provider",
953
673
  "learner_match", "subject_match", "tool_match", "scope",
@@ -963,6 +683,10 @@ PROSE_LIST_KEYS = {
963
683
  "stop_conditions", "strengths", "limitations", "confounders", "required_conditions",
964
684
  "process_metrics", "learning_metrics", "risk_metrics", "suggestions", "risk_control",
965
685
  "evidence_alignment",
686
+ # Evaluation prose: criteria are sentences, and the milestone fields
687
+ # name a measurement point in the reader language.
688
+ "success_criteria", "stop_criteria", "immediate_post", "retention",
689
+ "transfer", "baseline",
966
690
  }
967
691
 
968
692
 
@@ -1091,7 +815,34 @@ _ZH_RESIDUE_RE = [
1091
815
  (re.compile(r"\bCONCERN\b|\bPASS\b|\bFAIL\b"), "unmapped English audit code in zh narrative"),
1092
816
  ]
1093
817
 
818
+ #: Raw storage identifiers inside a sentence. A single-token value may be an
819
+ #: enum rendered through frame_enum_table(), but an underscore inside a multi-word
820
+ #: string or inside a zh sentence is a copy defect the reader would see.
821
+ _RAW_IDENTIFIER_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+")
822
+
823
+
824
+ def _scan_raw_identifiers(problems: list[str], path: str, text: str) -> None:
825
+ """Flag snake_case storage identifiers that leaked into visible prose."""
826
+ if not text or not isinstance(text, str):
827
+ return
828
+ cleaned = text.strip()
829
+ has_cjk = bool(_HAS_CJK.search(cleaned))
830
+ is_sentence = len(cleaned.split()) > 1
831
+ if not (has_cjk or is_sentence):
832
+ return
833
+ for match in _RAW_IDENTIFIER_RE.finditer(cleaned):
834
+ token = match.group(0)
835
+ enum_tokens = set(frame_enum_table("zh")) | set(frame_enum_table("en"))
836
+ if token in enum_tokens:
837
+ continue
838
+ problems.append(path + ": raw identifier " + repr(token) + " leaked into prose")
839
+ return
840
+
841
+
1094
842
  STRICT_NARRATIVE_PATHS = [
843
+ # Reader-facing decision prose written by the adjudicator.
844
+ "decision.strongest_support", "decision.key_uncertainty",
845
+ "decision.main_risk", "decision.next_action",
1095
846
  "decision.decision_rationale", "decision.rationale", "decision.strongest_support",
1096
847
  "decision.key_uncertainty", "decision.main_risk", "decision.next_action",
1097
848
  "decision.next_steps", "decision.what_can_be_claimed", "decision.what_cannot_be_claimed",
@@ -1108,6 +859,21 @@ _SKIP_HINTS = ("id", "url", "doi", "author", "venue", "year", "status", "score",
1108
859
  "verdict", "target", "decision")
1109
860
 
1110
861
 
862
+ COPY_LIMITS_ZH = {
863
+ "decision.strongest_support": 60,
864
+ "decision.key_uncertainty": 70,
865
+ "decision.main_risk": 60,
866
+ "decision.next_action": 80,
867
+ "decision.decision_rationale": 160,
868
+ "decision.rationale": 160,
869
+ }
870
+
871
+
872
+ #: Structural tokens that look like prose to a language check but are parsed
873
+ #: They stay identical across languages by contract.
874
+ NON_NARRATIVE_KEYS = ("weeks", "pilot_duration")
875
+
876
+
1111
877
  def _scan_narrative(problems, path, en_text, zh_text, strict=False, kind=""):
1112
878
  en_text = en_text or ""
1113
879
  zh_text = zh_text or ""
@@ -1130,6 +896,10 @@ def _scan_narrative(problems, path, en_text, zh_text, strict=False, kind=""):
1130
896
  problems.append(path + ": 含" + label)
1131
897
  if strict and kind in ("rationale", "reason") and len(zh_text) < 40:
1132
898
  problems.append(path + ": 决策理由过短(<40 字)")
899
+ ceiling = COPY_LIMITS_ZH.get(path)
900
+ if ceiling and len(zh_text) > ceiling:
901
+ problems.append(
902
+ f"{path}: 叙述超出 {ceiling} 字上限({len(zh_text)} 字)")
1133
903
 
1134
904
 
1135
905
  def check_language_parallel(result_en: dict, result_zh: dict) -> list[str]:
@@ -1154,6 +924,9 @@ def check_language_parallel(result_en: dict, result_zh: dict) -> list[str]:
1154
924
  _walk_strings(result_zh.get(root), "", zh_nodes, _SKIP_HINTS)
1155
925
  by_path_en = {p: t for p, t in en_nodes}
1156
926
  for p, z in zh_nodes:
927
+ leaf = p.rsplit(".", 1)[-1]
928
+ if leaf in NON_NARRATIVE_KEYS:
929
+ continue
1157
930
  _scan_narrative(problems, root + p, by_path_en.get(p, ""), z, strict=False)
1158
931
  for key in ("claims", "evidence"):
1159
932
  en_items = result_en.get(key) or []
@@ -1163,6 +936,37 @@ def check_language_parallel(result_en: dict, result_zh: dict) -> list[str]:
1163
936
  z_text = z_item.get("claim") if isinstance(z_item, dict) else None
1164
937
  if z_text:
1165
938
  _scan_narrative(problems, key + "[" + str(i) + "].claim", e_text, z_text, strict=False)
939
+ # Frame prose: field values may be enums (rendered via frame_enum_table), but a
940
+ # storage identifier must never reach the reader inside a sentence. Both
941
+ # language versions are checked because the leak is an authoring defect.
942
+ # Weeks and pilot duration are structural tokens (W1, W2-W13, one_semester):
943
+
944
+ for label, root in (("research_frame", result_en), ("research_frame.zh", result_zh)):
945
+ nodes: list = []
946
+ _walk_strings(root.get("research_frame"), "", nodes, ())
947
+ for node_path, text_value in nodes:
948
+ _scan_raw_identifiers(problems, label + node_path, text_value)
949
+ # Structural parallel: ids, source/study/sample keys, sample sizes and the
950
+ # direction fields must match item by item. Free text may differ between
951
+ # languages; these may not, or the zh report describes a different study.
952
+ structural_fields = ("evidence_id", "source_id", "study_id", "sample_id",
953
+ "sample_size", "outcome_type", "effect_direction",
954
+ "relation_to_claim")
955
+ en_ev = result_en.get("evidence")
956
+ zh_ev = result_zh.get("evidence")
957
+ if isinstance(en_ev, list) and isinstance(zh_ev, list):
958
+ if len(en_ev) != len(zh_ev):
959
+ problems.append(
960
+ "evidence: en and zh rows differ in count "
961
+ + f"({len(en_ev)} vs {len(zh_ev)})")
962
+ for i, (a, b) in enumerate(zip(en_ev, zh_ev)):
963
+ if not (isinstance(a, dict) and isinstance(b, dict)):
964
+ continue
965
+ for field_name in structural_fields:
966
+ if a.get(field_name) != b.get(field_name):
967
+ problems.append(
968
+ f"evidence[{i}].{field_name}: en={a.get(field_name)!r} "
969
+ f"!= zh={b.get(field_name)!r} (structural fields must agree)")
1166
970
  return problems
1167
971
 
1168
972
 
@@ -1352,13 +1156,21 @@ def diverging_bar_svg(option: dict, width: int = 720, height: int = 300,
1352
1156
  """真 diverging 静态图(P0-10):support 从中心向右、contradict 从中心向左,
1353
1157
  neutral 走独立的细条道(第二网格),三系列互不覆盖。计数轴整数刻度(P0-11)。
1354
1158
  6.5: aria-label / title / desc 从 UI 字典按语言取。"""
1355
- ui = ui or UI_ZH
1159
+ ui = ui or build_ui("zh")
1356
1160
  cats = option.get("yAxis", [{}])[0].get("data", []) if isinstance(option.get("yAxis"), list) \
1357
1161
  else option.get("yAxis", {}).get("data", [])
1358
1162
  series = option.get("series", [])
1359
1163
  if not cats:
1360
1164
  return ""
1361
- left, right = 150, 40
1165
+ # Reserve enough gutter for the longest y-axis label. A fixed 150px gutter
1166
+ # pushed a long outcome name off the left edge (x0 = -42), so the reader
1167
+ # saw a clipped label instead of the outcome it names.
1168
+ def _label_w(text: str) -> float:
1169
+ cjk = sum(1 for ch in str(text) if "\u4e00" <= ch <= "\u9fff")
1170
+ return cjk * 11 + (len(str(text)) - cjk) * 6.6
1171
+
1172
+ left = max(150, int(max((_label_w(c) for c in cats), default=0)) + 20)
1173
+ right = 40
1362
1174
  top, bottom = 46, 34
1363
1175
  main_h = int((height - top - bottom) * 0.62)
1364
1176
  neutral_h = height - top - bottom - main_h - 14
@@ -1432,7 +1244,7 @@ def diverging_bar_svg(option: dict, width: int = 720, height: int = 300,
1432
1244
 
1433
1245
  def grouped_bar_svg(option: dict, width: int = 720, height: int = 260,
1434
1246
  note: str = "", lang: str = "zh", ui: dict | None = None) -> str:
1435
- ui = ui or UI_ZH
1247
+ ui = ui or build_ui("zh")
1436
1248
  cats = option.get("xAxis", {}).get("data", [])
1437
1249
  series = option.get("series", [])
1438
1250
  parts = [f'<rect x="0" y="0" width="{width}" height="{height}" fill="#FFFFFF"/>']
@@ -1544,26 +1356,21 @@ def _outcome_support_score(evidence: list[dict], outcome: dict) -> float:
1544
1356
  def first_screen(result: dict, lang: str, ui: dict) -> str:
1545
1357
  """Decision-first hero: meaning before raw counts."""
1546
1358
  decision = result.get("decision", {})
1547
- outcomes = result.get("outcomes", [])
1548
1359
  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
1360
  action = decision.get("recommended_action", "insufficient_evidence")
1556
1361
  cls = {"adopt": "adopt", "pilot": "pilot", "reject": "reject"}.get(action, "")
1557
1362
 
1558
- missing = "当前结果未提供此项信息。" if lang == "zh" else "Not provided in this research result."
1559
- strongest = (decision.get("strongest_support") or (can_claim[0] if can_claim else None)
1560
- or (supported_claims[0] if supported_claims else None) or missing)
1561
- uncertainty = (decision.get("key_uncertainty")
1562
- or (uncertain_claims[0] if uncertain_claims else None)
1563
- or (contradicted_claims[0] if contradicted_claims else None)
1564
- or decision.get("reason_for_disagreement") or missing)
1565
- risk = decision.get("main_risk") or decision.get("risk_effect") or missing
1566
- next_action = decision.get("next_action") or decision.get("next_steps") or missing
1363
+ # These four are written by the adjudicator (schemas/verdict.schema.json).
1364
+ # The renderer must not synthesise them from claim fragments: assembling a
1365
+ # sentence out of list items is what made the first screen read as patched
1366
+ # together rather than written. A missing field is reported as missing,
1367
+ # which is also what the copy gate looks for.
1368
+ missing = ("该字段未产出(应由裁决角色撰写)。" if lang == "zh"
1369
+ else "Not produced by the adjudicator.")
1370
+ strongest = decision.get("strongest_support") or missing
1371
+ uncertainty = decision.get("key_uncertainty") or missing
1372
+ risk = decision.get("main_risk") or missing
1373
+ next_action = decision.get("next_action") or missing
1567
1374
 
1568
1375
  rationale = decision.get("decision_rationale") or decision.get("rationale") or ""
1569
1376
  rationale_html = expandable_text(rationale, ui["expand_details"], 380, "hero-rationale")
@@ -1586,23 +1393,17 @@ def first_screen(result: dict, lang: str, ui: dict) -> str:
1586
1393
  </div>"""
1587
1394
 
1588
1395
 
1589
- OUTCOME_GROUPS = {
1590
- "task": {"completion_time", "accuracy", "assignment_score", "task_performance", "code_quality"},
1591
- "learning": {"knowledge_gain", "learning_gain", "concept_understanding", "retention", "transfer",
1592
- "independent_problem_solving", "programming_skill", "writing_skill"},
1593
- "risk": {"ai_dependency", "over_reliance", "reduced_effort", "reduced_transfer",
1594
- "academic_integrity_risk", "false_confidence", "cognitive_load"},
1595
- }
1596
-
1597
-
1598
1396
  def render_outcome_separation(result: dict, lang: str, ui: dict) -> str:
1599
1397
  outcomes = effect_outcomes(result)
1600
1398
  if len(outcomes) < 2:
1601
1399
  return ""
1602
1400
  buckets: dict[str, list[dict]] = {"task": [], "learning": [], "risk": [], "other": []}
1401
+ group_map = outcome_group_map()
1603
1402
  for outcome in outcomes:
1604
1403
  kind = outcome.get("outcome_type") or ""
1605
- group = next((name for name, values in OUTCOME_GROUPS.items() if kind in values), "other")
1404
+ group = next((name for name, values in group_map.items() if kind in values), "other")
1405
+ if group not in buckets:
1406
+ group = "other"
1606
1407
  buckets[group].append(outcome)
1607
1408
  group_labels = {
1608
1409
  "task": ui["outcome_group_task"], "learning": ui["outcome_group_learning"],
@@ -1722,33 +1523,33 @@ def effect_label(lang: str, value: Any) -> str:
1722
1523
  "neutral": "Null effect"}.get(effect, effect)
1723
1524
 
1724
1525
 
1526
+ def _quality_dims_text(lang: str, dims: Any) -> str:
1527
+ """Render quality dimensions as readable pairs (D1 Study design = 2)."""
1528
+ if not isinstance(dims, dict) or not dims:
1529
+ return ""
1530
+ DIM_LABELS_ZH = {
1531
+ "D1_study_design": "D1 研究设计", "D2_sample_quality": "D2 样本质量",
1532
+ "D3_measurement_validity": "D3 测量效度", "D4_temporal_strength": "D4 时间强度",
1533
+ "D5_directness": "D5 直接性",
1534
+ }
1535
+ DIM_LABELS_EN = {
1536
+ "D1_study_design": "D1 Study design", "D2_sample_quality": "D2 Sample quality",
1537
+ "D3_measurement_validity": "D3 Measurement validity",
1538
+ "D4_temporal_strength": "D4 Temporal strength", "D5_directness": "D5 Directness",
1539
+ }
1540
+ table = DIM_LABELS_ZH if lang == "zh" else DIM_LABELS_EN
1541
+ sep = " · " if lang == "zh" else " · "
1542
+ joiner = "=" if lang == "zh" else " = "
1543
+ parts = []
1544
+ for key, value in dims.items():
1545
+ name = table.get(key) or _humanize_identifier(str(key), lang)
1546
+ parts.append(f"{name}{joiner}{value}")
1547
+ return sep.join(parts)
1548
+
1549
+
1725
1550
  def render_evidence_detail(ev: dict, source: dict, lang: str, ui: dict) -> str:
1726
1551
  """Render complete traceable evidence detail without inventing missing fields."""
1727
- labels = ({
1728
- "study_id": "研究 ID", "sample_id": "样本 ID", "title": "研究标题",
1729
- "year": "年份", "study_type": "研究设计", "education_level": "教育阶段",
1730
- "population": "研究人群", "sample_size": "样本量", "intervention": "干预",
1731
- "comparison": "对照 / 比较条件", "outcome_measure": "结果测量", "claim": "完整主张",
1732
- "effect": "效应 / 结果", "effect_direction": "效应方向", "relation_to_claim": "与主张关系",
1733
- "duration": "干预时长", "method": "方法", "strengths": "优势",
1734
- "limitations": "局限", "confounders": "混杂因素", "quality_dimensions": "质量维度",
1735
- "quality_score": "质量分", "evidence_level": "证据等级", "directness": "直接性",
1736
- "applicability": "适用性", "confidence": "置信度", "status": "证据状态",
1737
- "source_location": "来源位置", "source_title": "来源标题", "source_url": "可验证链接",
1738
- "claim_id": "Claim ID",
1739
- } if lang == "zh" else {
1740
- "study_id": "Study ID", "sample_id": "Sample ID", "title": "Study title",
1741
- "year": "Year", "study_type": "Study design", "education_level": "Education level",
1742
- "population": "Population", "sample_size": "Sample size", "intervention": "Intervention",
1743
- "comparison": "Comparison", "outcome_measure": "Outcome measure", "claim": "Full claim",
1744
- "effect": "Effect / result", "effect_direction": "Effect direction", "relation_to_claim": "Relation to claim",
1745
- "duration": "Duration", "method": "Method", "strengths": "Strengths",
1746
- "limitations": "Limitations", "confounders": "Confounders", "quality_dimensions": "Quality dimensions",
1747
- "quality_score": "Quality score", "evidence_level": "Evidence level", "directness": "Directness",
1748
- "applicability": "Applicability", "confidence": "Confidence", "status": "Evidence status",
1749
- "source_location": "Source location", "source_title": "Source title", "source_url": "Verifiable link",
1750
- "claim_id": "Claim ID",
1751
- })
1552
+ labels = evidence_detail_label_table(lang)
1752
1553
 
1753
1554
  values: list[tuple[str, Any]] = []
1754
1555
  source_title = source.get("title") or ev.get("title")
@@ -1758,14 +1559,20 @@ def render_evidence_detail(ev: dict, source: dict, lang: str, ui: dict) -> str:
1758
1559
  ("study_id", ev.get("study_id")), ("sample_id", ev.get("sample_id")),
1759
1560
  ("title", ev.get("title")), ("source_title", source_title), ("year", source_year),
1760
1561
  ("study_type", label(lang, "study", ev.get("study_type") or "")),
1761
- ("education_level", ev.get("education_level")), ("population", ev.get("population")),
1762
- ("sample_size", ev.get("sample_size")), ("intervention", ev.get("intervention")),
1763
- ("comparison", ev.get("comparison")), ("outcome_measure", ev.get("outcome_measure")),
1562
+ ("education_level", _frame_value_label(lang, ev.get("education_level"))),
1563
+ ("population", _frame_value_label(lang, ev.get("population"))),
1564
+ ("sample_size", ev.get("sample_size")),
1565
+ ("intervention", _frame_value_label(lang, ev.get("intervention"))),
1566
+ ("comparison", _frame_value_label(lang, ev.get("comparison"))),
1567
+ ("outcome_measure", _frame_value_label(lang, ev.get("outcome_measure"))),
1764
1568
  ("effect", ev.get("effect")), ("effect_direction", effect_label(lang, ev.get("effect_direction"))),
1765
1569
  ("relation_to_claim", label(lang, "dir", ev.get("relation_to_claim") or ev.get("direction") or "neutral")),
1766
- ("duration", ev.get("duration")), ("method", ev.get("method")),
1570
+ ("duration", _frame_value_label(lang, ev.get("duration"))),
1571
+ ("method", _frame_value_label(lang, ev.get("method"))),
1767
1572
  ("strengths", ev.get("strengths")), ("limitations", ev.get("limitations")),
1768
- ("confounders", ev.get("confounders")), ("quality_dimensions", ev.get("quality_dimensions")),
1573
+ ("confounders", [_frame_value_label(lang, c) for c in (ev.get("confounders") or [])]
1574
+ if isinstance(ev.get("confounders"), list) else _frame_value_label(lang, ev.get("confounders"))),
1575
+ ("quality_dimensions", _quality_dims_text(lang, ev.get("quality_dimensions"))),
1769
1576
  ("quality_score", ev.get("quality_score")), ("evidence_level", ev.get("evidence_level")),
1770
1577
  ("directness", ev.get("directness")), ("applicability", ev.get("applicability")),
1771
1578
  ("confidence", ev.get("confidence")), ("status", label(lang, "status", ev.get("status") or "")),
@@ -2000,9 +1807,10 @@ def render_tribunal(result: dict, workflow_svg: str, tribunal_svg: str, lang: st
2000
1807
 
2001
1808
  def methodology_item_label(lang: str, item: Any) -> str:
2002
1809
  key = str(item or "")
2003
- if lang == "zh":
2004
- return METHODOLOGY_LABELS_ZH.get(key, key.replace("_", " "))
2005
- return key.replace("_", " ").strip().title()
1810
+ table = methodology_label_table(lang)
1811
+ if key in table:
1812
+ return table[key]
1813
+ return key.replace("_", " ").strip().title() if lang != "zh" else key.replace("_", " ")
2006
1814
 
2007
1815
 
2008
1816
  def render_methodology(result: dict, lang: str, ui: dict) -> str:
@@ -2086,12 +1894,32 @@ def render_applicability(result: dict, lang: str, ui: dict) -> str:
2086
1894
  return "\n".join(out) or f"<p>{esc(ui['no_data'])}</p>"
2087
1895
 
2088
1896
 
1897
+ def _weeks_label(value: str, lang: str) -> str:
1898
+ """Render a week-range token for the reader, keeping data identical.
1899
+
1900
+ The bilingual contract requires the stored value to match across
1901
+ languages, so the Chinese form is produced here rather than stored.
1902
+ """
1903
+ text = str(value or "")
1904
+ if lang != "zh" or not text:
1905
+ return text
1906
+ import re as _re
1907
+ m = _re.fullmatch(r"W(\d+)(?:-W?(\d+))?", text)
1908
+ if not m:
1909
+ return text
1910
+ start, end = m.group(1), m.group(2)
1911
+ if end:
1912
+ return f"第 {start}-{end} 周"
1913
+ return f"第 {start} 周"
1914
+
1915
+
2089
1916
  def render_intervention(result: dict, svg: str, lang: str, ui: dict) -> str:
2090
1917
  intervention = result.get("intervention", {})
2091
1918
  if not intervention:
2092
1919
  return f"<p>{esc(ui['no_data'])}</p>"
2093
1920
  population = intervention.get("target_population") or intervention.get("target_learners")
2094
- population_label = ("目标人群" if lang == "zh" else "Target population") if intervention.get("target_population") else ui['intervention_learners']
1921
+ population_key = "intervention_population" if intervention.get("target_population") else "intervention_learners"
1922
+ population_label = ui.get(population_key) or ui.get("intervention_learners") or ("目标人群" if lang == "zh" else "Target population")
2095
1923
  lines = [f"<p><strong>{esc(population_label)}{esc(ui['colon'])}</strong>{esc(population)} · "
2096
1924
  f"<strong>{esc(ui['intervention_duration'])}{esc(ui['colon'])}</strong>{esc(intervention.get('pilot_duration'))}</p>"]
2097
1925
  if intervention.get("ai_usage_policy"):
@@ -2258,16 +2086,16 @@ def resolve_full_report_plan(result: dict) -> list[dict[str, Any]]:
2258
2086
  raw = result.get("report_outline") or result.get("report_structure") or {}
2259
2087
  chapters = raw.get("chapters") if isinstance(raw, dict) else raw if isinstance(raw, list) else None
2260
2088
  if not isinstance(chapters, list) or not 5 <= len(chapters) <= 7:
2261
- return [dict(chapter) for chapter in DEFAULT_FULL_REPORT_PLAN]
2089
+ return default_full_report_plan()
2262
2090
 
2263
2091
  normalized: list[dict[str, Any]] = []
2264
2092
  seen_modules: list[str] = []
2265
2093
  for index, chapter in enumerate(chapters, 1):
2266
2094
  if not isinstance(chapter, dict):
2267
- return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2095
+ return default_full_report_plan()
2268
2096
  modules = [m for m in (chapter.get("modules") or []) if m in FULL_REPORT_MODULES]
2269
2097
  if not modules or any(m in seen_modules for m in modules):
2270
- return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2098
+ return [dict(item) for item in default_full_report_plan()]
2271
2099
  seen_modules.extend(modules)
2272
2100
  key = re.sub(r"[^a-z0-9-]+", "-", str(chapter.get("key") or f"chapter-{index}").lower()).strip("-")
2273
2101
  normalized.append({
@@ -2279,9 +2107,9 @@ def resolve_full_report_plan(result: dict) -> list[dict[str, Any]]:
2279
2107
  "modules": tuple(modules),
2280
2108
  })
2281
2109
  if set(seen_modules) != set(FULL_REPORT_MODULES):
2282
- return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2110
+ return [dict(item) for item in default_full_report_plan()]
2283
2111
  if "decision" not in normalized[0]["modules"] or "sources" not in normalized[-1]["modules"]:
2284
- return [dict(item) for item in DEFAULT_FULL_REPORT_PLAN]
2112
+ return [dict(item) for item in default_full_report_plan()]
2285
2113
  return normalized
2286
2114
 
2287
2115
 
@@ -2303,8 +2131,31 @@ def render_full_chapter(chapter_id: str, title: str, content: str, lead: str = "
2303
2131
 
2304
2132
 
2305
2133
  def frame_enum_label(lang: str, value: Any) -> str:
2134
+ """Label a frame enum value for display.
2135
+
2136
+ Registered values use the curated label. Anything else must still read as
2137
+ prose: a raw snake_case identifier in the report is a copy defect, so the
2138
+ fallback humanises it instead of leaking the storage form to the reader.
2139
+ """
2306
2140
  text = str(value or "")
2307
- return (FRAME_ENUM_ZH if lang == "zh" else FRAME_ENUM_EN).get(text, text)
2141
+ if not text:
2142
+ return ""
2143
+ table = frame_enum_table(lang)
2144
+ if text in table:
2145
+ return table[text]
2146
+ return _humanize_identifier(text, lang)
2147
+
2148
+
2149
+ def _humanize_identifier(value: str, lang: str = "en") -> str:
2150
+ """Turn a snake_case / kebab-case token into a readable phrase.
2151
+
2152
+ Delegates to zh_labels.humanize_identifier so the report body and the
2153
+ charts share one display rule (curated label first, acronym-aware prose
2154
+ second) instead of leaking storage identifiers to the reader.
2155
+ """
2156
+ from zh_labels import humanize_identifier
2157
+
2158
+ return humanize_identifier(value, lang)
2308
2159
 
2309
2160
 
2310
2161
  def labeled_pairs(lang: str, data: dict, labels_zh: dict[str, str], labels_en: dict[str, str]) -> str:
@@ -2313,33 +2164,39 @@ def labeled_pairs(lang: str, data: dict, labels_zh: dict[str, str], labels_en: d
2313
2164
  for key, value in data.items():
2314
2165
  if value in (None, "", [], {}):
2315
2166
  continue
2316
- rendered = frame_enum_label(lang, value) if isinstance(value, str) else str(value)
2167
+ rendered = _frame_value_label(lang, value) if isinstance(value, str) else str(value)
2317
2168
  parts.append(f"{labels.get(key, key)}:{rendered}" if lang == "zh" else f"{labels.get(key, key)}: {rendered}")
2318
2169
  return ";".join(parts) if lang == "zh" else "; ".join(parts)
2319
2170
 
2320
2171
 
2172
+ def _frame_value_label(lang: str, value: Any) -> str:
2173
+ """Render a frame value for display.
2174
+
2175
+ Free prose passes through; a bare storage identifier is humanised so the
2176
+ comparison/success cards never show snake_case. Multi-word prose that
2177
+ merely contains an underscore is returned unchanged.
2178
+ """
2179
+ if value in (None, "", [], {}):
2180
+ return ""
2181
+ text_value = str(value)
2182
+ table = frame_enum_table(lang)
2183
+ if text_value in table:
2184
+ return table[text_value]
2185
+ if " " in text_value.strip() or _HAS_CJK.search(text_value):
2186
+ return text_value
2187
+ return _humanize_identifier(text_value, lang)
2188
+
2189
+
2321
2190
  def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2322
2191
  frame = result.get("research_frame", {}) or {}
2323
2192
  learner = frame.get("learner", {}) or {}
2324
2193
  course = frame.get("course", {}) or {}
2325
2194
  intervention = frame.get("intervention", {}) or {}
2326
2195
  scope = frame.get("scope", {}) or {}
2327
- labels = ({
2328
- "question": "研究问题", "learner": "目标学习者", "course": "课程情境", "intervention": "AI 干预",
2329
- "comparison": "比较条件", "outcomes": "结果构念", "scope": "研究范围", "success": "决策成功条件",
2330
- } if lang == "zh" else {
2331
- "question": "Research question", "learner": "Target learners", "course": "Course context", "intervention": "AI intervention",
2332
- "comparison": "Comparison", "outcomes": "Outcome constructs", "scope": "Research scope", "success": "Decision success condition",
2333
- })
2334
- learner_text = labeled_pairs(lang, learner,
2335
- {"education_level":"教育阶段", "major":"专业", "prior_knowledge":"先验知识", "special_characteristics":"学习者特征"},
2336
- {"education_level":"Education level", "major":"Major", "prior_knowledge":"Prior knowledge", "special_characteristics":"Learner characteristics"})
2337
- course_text = labeled_pairs(lang, course,
2338
- {"subject":"课程", "course_type":"课程类型", "duration":"课程周期"},
2339
- {"subject":"Subject", "course_type":"Course type", "duration":"Duration"})
2340
- intervention_text = labeled_pairs(lang, intervention,
2341
- {"ai_tool":"AI 工具", "allowed_usage":"允许使用", "frequency":"使用频率", "duration":"干预周期"},
2342
- {"ai_tool":"AI tool", "allowed_usage":"Allowed usage", "frequency":"Frequency", "duration":"Duration"})
2196
+ labels = scope_field_labels(lang)
2197
+ learner_text = labeled_pairs(lang, learner, scope_subfield_labels(lang, "learner"), scope_subfield_labels(lang, "learner"))
2198
+ course_text = labeled_pairs(lang, course, scope_subfield_labels(lang, "course"), scope_subfield_labels(lang, "course"))
2199
+ intervention_text = labeled_pairs(lang, intervention, scope_subfield_labels(lang, "intervention"), scope_subfield_labels(lang, "intervention"))
2343
2200
  outcome_map = frame.get("outcomes", {}) or {}
2344
2201
  outcome_parts = []
2345
2202
  for group, values in outcome_map.items():
@@ -2347,24 +2204,34 @@ def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2347
2204
  rendered = "、".join(label(lang, "outcome", v) for v in values) if lang == "zh" else ", ".join(label(lang, "outcome", v) for v in values)
2348
2205
  outcome_parts.append(f"{frame_enum_label(lang, group)}:{rendered}" if lang == "zh" else f"{frame_enum_label(lang, group)}: {rendered}")
2349
2206
  scope_parts = []
2350
- scope_labels_zh = {"time_range":"时间范围", "geography":"地域", "study_types":"研究设计"}
2351
- scope_labels_en = {"time_range":"Time range", "geography":"Geography", "study_types":"Study designs"}
2207
+ scope_labels_zh = scope_subfield_labels("zh", "scope")
2208
+ scope_labels_en = scope_subfield_labels("en", "scope")
2352
2209
  for key, value in scope.items():
2353
2210
  if value in (None, "", [], {}):
2354
2211
  continue
2355
- if key == "study_types" and isinstance(value, list):
2356
- 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)
2212
+ if isinstance(value, list):
2213
+ # Any list-valued scope field renders as a joined list. Only
2214
+ # study_types used to be unpacked, so a list-valued evidence_types
2215
+ # fell through to str() and printed its Python repr
2216
+ # ("['quasi Experimental', 'rct']") straight into the card.
2217
+ kind = "study" if key == "study_types" else ""
2218
+ parts = [label(lang, kind, str(v)) if kind else _frame_value_label(lang, v)
2219
+ for v in value]
2220
+ rendered = ("、".join(parts) if lang == "zh" else ", ".join(parts))
2357
2221
  else:
2358
2222
  rendered = frame_enum_label(lang, value)
2359
- field_label = (scope_labels_zh if lang == "zh" else scope_labels_en).get(key, key)
2223
+ field_label = (scope_labels_zh if lang == "zh" else scope_labels_en).get(
2224
+ key, _humanize_identifier(key, lang))
2360
2225
  scope_parts.append(f"{field_label}:{rendered}" if lang == "zh" else f"{field_label}: {rendered}")
2361
2226
  scope_text = ";".join(scope_parts) if lang == "zh" else "; ".join(scope_parts)
2362
2227
  cards = [
2363
2228
  (labels["question"], frame.get("question") or result.get("meta", {}).get("question")),
2364
2229
  (labels["learner"], learner_text), (labels["course"], course_text),
2365
- (labels["intervention"], intervention_text), (labels["comparison"], frame.get("comparison")),
2230
+ (labels["intervention"], intervention_text),
2231
+ (labels["comparison"], _frame_value_label(lang, frame.get("comparison"))),
2366
2232
  (labels["outcomes"], ";".join(outcome_parts) if lang == "zh" else "; ".join(outcome_parts)),
2367
- (labels["scope"], scope_text), (labels["success"], frame.get("success_condition")),
2233
+ (labels["scope"], scope_text),
2234
+ (labels["success"], _frame_value_label(lang, frame.get("success_condition"))),
2368
2235
  ]
2369
2236
  return '<div class="scope-grid">' + "".join(
2370
2237
  f'<article class="scope-card"><h3>{esc(title)}</h3>{expandable_text(text, ui["expand_details"], 260, "scope-text")}</article>'
@@ -2373,8 +2240,8 @@ def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2373
2240
 
2374
2241
  def render_retrieval_context(result: dict, lang: str, ui: dict) -> str:
2375
2242
  frame = result.get("research_frame", {}) or {}
2376
- inclusion = frame.get("inclusion_criteria") or []
2377
- exclusion = frame.get("exclusion_criteria") or []
2243
+ inclusion = [_frame_value_label(lang, v) for v in (frame.get("inclusion_criteria") or [])]
2244
+ exclusion = [_frame_value_label(lang, v) for v in (frame.get("exclusion_criteria") or [])]
2378
2245
  provenance = result.get("provenance", {}) or {}
2379
2246
  sources = result.get("sources", []) or []
2380
2247
  source_ids = " ".join(f'<code>{esc(s.get("source_id"))}</code>' for s in sources)
@@ -2448,20 +2315,13 @@ def render_full_report(result: dict, lang: str, ui: dict, charts: dict, infograp
2448
2315
  + render_v2_history(result, lang, ui)),
2449
2316
  }
2450
2317
 
2451
- default_leads = {
2452
- "decision": ("先明确最终裁决与研究边界,再解释为什么。" if lang == "zh" else "State the final adjudication and research boundary before explaining why."),
2453
- "evidence": ("把任务表现、真实学习、保持与风险放在同一证据地图中,但不混为一谈。" if lang == "zh" else "Place task performance, actual learning, retention and risk on one evidence map without conflating them."),
2454
- "quality": ("检查证据为什么可信、哪里冲突,以及哪些结论必须降级。" if lang == "zh" else "Examine why evidence is credible, where it conflicts, and which conclusions require downgrading."),
2455
- "action": ("把可外推范围、护栏和教学动作连接到具体证据。" if lang == "zh" else "Connect applicability, guardrails and teaching actions to specific evidence."),
2456
- "evaluation": ("用独立学习结果验证试点,并预先写清停止条件。" if lang == "zh" else "Validate the pilot with independent learning outcomes and pre-specified stop conditions."),
2457
- "sources": ("保留原始来源、URL、证据 ID 和获取信息,确保可回查。" if lang == "zh" else "Preserve original sources, URLs, evidence IDs and retrieval metadata for auditability."),
2458
- }
2318
+ leads_by_key = default_leads(lang)
2459
2319
 
2460
2320
  plan = resolve_full_report_plan(result)
2461
2321
  rendered = []
2462
2322
  for index, chapter in enumerate(plan, 1):
2463
2323
  content = "".join(module_content.get(module, "") for module in chapter.get("modules", ()))
2464
- lead = chapter.get("lead_zh" if lang == "zh" else "lead_en") or default_leads.get(chapter.get("key"), "")
2324
+ lead = chapter.get("lead_zh" if lang == "zh" else "lead_en") or leads_by_key.get(chapter.get("key"), "")
2465
2325
  rendered.append(render_full_chapter(
2466
2326
  chapter_dom_id(lang, str(chapter.get("key") or f"chapter-{index}"), index),
2467
2327
  full_chapter_title(chapter, lang, index), content, str(lead or "")))
@@ -2640,12 +2500,6 @@ def _enhancer_js(charts_zh: dict, charts_en: dict, result_en: dict) -> str:
2640
2500
  trace_en = next((c for c in charts_en.get("charts", [])
2641
2501
  if c.get("chart_id") == "claim-evidence-trace"), None)
2642
2502
  benchmark_en = charts_en.get("benchmark") or {}
2643
- matrix_rows = [
2644
- {"id": e.get("evidence_id"), "title": e.get("title", ""),
2645
- "direction": e.get("direction", "neutral"),
2646
- "outcome": e.get("outcome_type", ""), "quality": e.get("quality_score", 0)}
2647
- for e in result_en.get("evidence", [])
2648
- ]
2649
2503
  return f"""
2650
2504
  (function () {{
2651
2505
  'use strict';
@@ -2845,7 +2699,12 @@ def render_lieflat_gallery_brief(result: dict, figures: dict, layout: dict,
2845
2699
  title = entry.get("title_zh" if zh else "title_en") or ""
2846
2700
  subtitle = entry.get("subtitle_zh" if zh else "subtitle_en") or ""
2847
2701
  caption = entry.get("caption_zh" if zh else "caption_en") or ""
2848
- source_line = f"{entry.get('catalog_ref', '')} · {entry.get('source', '')}".strip(" ·")
2702
+ # The caption names the catalogue figure and, in prose, what it was
2703
+ # drawn from. The raw bundle path ("evidence.year_x_outcome_counts") is
2704
+ # an internal pointer and must not reach the reader, so it goes through
2705
+ # the same humaniser as every other enum in the report.
2706
+ source_ref = _humanize_identifier(str(entry.get('source', '')), lang)
2707
+ source_line = f"{entry.get('catalog_ref', '')} · {source_ref}".strip(" ·")
2849
2708
  fig_type = entry.get("type", "")
2850
2709
  cards.append(
2851
2710
  f'<figure class="lieflat-card" data-lieflat data-visual="lieflat-{esc(fig_type)}" '
@@ -2879,24 +2738,7 @@ def render_body(result: dict, lang: str, ui: dict, charts: dict, infographics: d
2879
2738
  outcome_chart = next((c for c in charts.get("charts", [])
2880
2739
  if c.get("chart_id") == "outcome-evidence-overview"), None)
2881
2740
 
2882
- if lang == "zh":
2883
- brief_titles = {
2884
- "decision": ("先看结论", "该不该做、置信度多高、最关键的证据边界在哪。"),
2885
- "lieflat": ("Lieflat 实证手作画廊", "AI 按数据形状从 Lieflat 目录选型编排;每张图的数字都可溯源到 result.json。"),
2886
- "outcomes": ("任务表现 ≠ 学习效果", "只展示真正有解释力的结果分离;正向、负向与零效应按 effect_direction 编码。"),
2887
- "tribunal": ("证据裁决", "支持、不确定、被反驳与缺失证据分开放置,不把长段落平铺在同一层。"),
2888
- "action": ("从证据到行动", "适用性、护栏、停止条件与评价连成一条可执行路径。"),
2889
- "sources": ("关键来源", "摘要页只列最关键的来源;完整溯源在完整报告中展开。"),
2890
- }
2891
- else:
2892
- brief_titles = {
2893
- "decision": ("Decision first", "What to do, how confident we are, and the most important evidence boundary."),
2894
- "lieflat": ("Lieflat Editorial Gallery", "Charts selected and composed by AI from the Lieflat catalog; every number traces back to result.json."),
2895
- "outcomes": ("Task performance ≠ learning", "Only informative outcome separation; positive, negative and null effects use effect_direction."),
2896
- "tribunal": ("Evidence tribunal", "Supported, uncertain, contradicted and missing evidence stay separated instead of flattened into long prose."),
2897
- "action": ("Evidence to action", "Applicability, guardrails, stop conditions and evaluation form one executable path."),
2898
- "sources": ("Key sources", "Only the key sources in the brief; full traceability expands in the full report."),
2899
- }
2741
+ brief_titles = brief_block_titles(lang)
2900
2742
 
2901
2743
  lieflat_layout = viz.get("lieflat_layout") or {"entries": []}
2902
2744
  lieflat_meta = (viz.get("lieflat_meta") or {}).get(lang, {})
@@ -2922,7 +2764,7 @@ def render_body(result: dict, lang: str, ui: dict, charts: dict, infographics: d
2922
2764
  nav_items = [("decision", brief_titles["decision"][0]), ("outcomes", brief_titles["outcomes"][0]),
2923
2765
  ("tribunal", brief_titles["tribunal"][0]), ("action", brief_titles["action"][0]),
2924
2766
  ("sources", brief_titles["sources"][0])]
2925
- brief_nav = '<nav class="brief-navigation" aria-label="' + ("摘要导航" if lang == "zh" else "Brief navigation") + '">' + ''.join(
2767
+ brief_nav = '<nav class="brief-navigation" aria-label="' + esc(ui["brief_nav_aria"]) + '">' + ''.join(
2926
2768
  f'<a href="#brief-{key}-{lang}"><span>{i:02d}</span>{esc(title)}</a>'
2927
2769
  for i, (key, title) in enumerate(nav_items, 1)) + '</nav>'
2928
2770
  full_report = render_full_report(result, lang, ui, charts, infographics, figures, viz)
@@ -2967,9 +2809,11 @@ def render_html(result_en: dict, result_zh: dict, charts_zh: dict, charts_en: di
2967
2809
  figures_en: dict, theme: str, viz: dict,
2968
2810
  result_sha256: str = "", integrity: dict | None = None) -> str:
2969
2811
  # Theme is fixed at generation time; language remains switchable in the HTML.
2970
- body_zh = render_body(result_zh, "zh", UI_ZH, charts_zh, infographics_zh, figures_zh, viz, theme,
2812
+ ui_zh = build_ui("zh")
2813
+ ui_en = build_ui("en")
2814
+ body_zh = render_body(result_zh, "zh", ui_zh, charts_zh, infographics_zh, figures_zh, viz, theme,
2971
2815
  integrity=integrity)
2972
- body_en = render_body(result_en, "en", UI_EN, charts_en, infographics_en, figures_en, viz, theme,
2816
+ body_en = render_body(result_en, "en", ui_en, charts_en, infographics_en, figures_en, viz, theme,
2973
2817
  integrity=integrity)
2974
2818
  hash_meta = (f'<meta name="eduevidence-result-sha256" content="{esc(result_sha256)}">\n'
2975
2819
  if result_sha256 else "")
@@ -2993,10 +2837,10 @@ def render_html(result_en: dict, result_zh: dict, charts_zh: dict, charts_en: di
2993
2837
  <div class="controls reader-toolbar">
2994
2838
  <a class="reader-home" href="#" aria-label="Back to report start">EduEvidence<span class="generated-theme">{esc(THEME_DISPLAY[theme])}</span></a>
2995
2839
  <div class="reader-view-controls" role="group" aria-label="Report view">
2996
- <button type="button" class="report-view-btn active" data-report-view="brief" data-copy="brief" aria-pressed="true">摘要</button>
2997
- <button type="button" class="report-view-btn" data-report-view="full" data-copy="full" aria-pressed="false">完整报告</button>
2840
+ <button type="button" class="report-view-btn active" data-report-view="brief" data-copy="brief" aria-pressed="true">{esc(ui_zh['toolbar_brief'])}</button>
2841
+ <button type="button" class="report-view-btn" data-report-view="full" data-copy="full" aria-pressed="false">{esc(ui_zh['toolbar_full'])}</button>
2998
2842
  </div>
2999
- <div class="reader-tools">{_lang_switcher(UI_ZH, UI_EN)}<button class="reader-print" type="button" data-copy="print">打印</button></div>
2843
+ <div class="reader-tools">{_lang_switcher(ui_zh, ui_en)}<button class="reader-print" type="button" data-copy="print">{esc(ui_zh['toolbar_print'])}</button></div>
3000
2844
  </div>
3001
2845
  {body_zh}
3002
2846
  {body_en}
@@ -3093,6 +2937,13 @@ def main() -> int:
3093
2937
  return 2
3094
2938
  result_zh = json.loads(zh_path.read_text(encoding="utf-8"))
3095
2939
 
2940
+ # 0. Domain copy pack(result.meta.domain / frame.extensions.domain)
2941
+ try:
2942
+ activate_copy_pack(result_en)
2943
+ except AssertionError as exc:
2944
+ print(f"REPORT_INVALID — domain copy pack rejected:\n{exc}")
2945
+ return 2
2946
+
3096
2947
  # 1. Contract validation(两份数据分别校验)
3097
2948
  for label, data in (("result.json", result_en), ("result.zh.json", result_zh)):
3098
2949
  problems = validate_contract(data)
@@ -3122,8 +2973,8 @@ def main() -> int:
3122
2973
  # 3. Adapters(两份数据分别生成 spec / 信息图 / 学术图;数字同构)
3123
2974
  charts_zh = build_chart_specs(result_zh, lang="zh")
3124
2975
  charts_en = build_chart_specs(result_en, lang="en")
3125
- infographics_zh = build_infographics(result_zh, lang="zh")
3126
- infographics_en = build_infographics(result_en, lang="en")
2976
+ infographics_zh = build_infographics(result_zh, lang="zh", ui=build_ui("zh"))
2977
+ infographics_en = build_infographics(result_en, lang="en", ui=build_ui("en"))
3127
2978
  figure_data = build_figure_data(result_en)
3128
2979
  figures_zh = render_figures(figure_data, theme=args.theme, lang="zh")
3129
2980
  figures_en = render_figures(figure_data, theme=args.theme, lang="en")
@@ -3200,5 +3051,14 @@ def main() -> int:
3200
3051
  return 0
3201
3052
 
3202
3053
 
3054
+ def __getattr__(name: str):
3055
+ """Back-compat UI dicts for tests: br.UI_ZH / br.UI_EN."""
3056
+ if name == "UI_ZH":
3057
+ return build_ui("zh")
3058
+ if name == "UI_EN":
3059
+ return build_ui("en")
3060
+ raise AttributeError(name)
3061
+
3062
+
3203
3063
  if __name__ == "__main__":
3204
3064
  sys.exit(main())