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,515 @@
1
+ #!/usr/bin/env python3
2
+ """build_figures.py — Academic Figures adapter (v5 Iteration 4, §23-30).
3
+
4
+ Purpose = statistical_publication -> Academic Figures (v5 §34 router).
5
+ From result.json, produce:
6
+ - figure_data.json publication-ready data (adapter output, §52)
7
+ - figures/*.svg publication SVG (Okabe-Ito / Nature / Conservative)
8
+ - figures/*.png|pdf raster/vector export when matplotlib is available
9
+
10
+ Publication conventions (v5 §25-30):
11
+ - truthful: only pre-existing error bars / significance from the analysis
12
+ - figure provenance attached to every figure
13
+ - Okabe-Ito colorblind-safe palette by default
14
+ - auto-trigger when result contains numeric comparisons / CI / significance
15
+ (recommend_academic_figure = true, §58)
16
+
17
+ Zero hard dependency: SVG generation is pure-Python; matplotlib export is
18
+ optional (guarded import) for PNG/PDF.
19
+
20
+ Usage:
21
+ python3 visualization/eduevidence-report/scripts/build_figures.py \
22
+ --result examples/ai-coding-assistant/result.json \
23
+ --out-dir examples/ai-coding-assistant/figures
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import sys
30
+ import xml.sax.saxutils as sax
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from adapter_contract import load_result, write_adapter_output
35
+ from zh_labels import zh_outcome
36
+ from build_charts import effect_outcomes
37
+
38
+ OKABE_ITO = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#000000"]
39
+ NATURE = ["#1F4E5F", "#5B8C9E", "#9E4B3A", "#7A8B5C", "#C2A24A", "#4E4E4E"]
40
+ PALM = ["#B8694A", "#5E8A6A", "#C99A4A", "#8A867E", "#A85B53", "#3A3833"]
41
+ PORCELAIN = ["#0284C7", "#10B981", "#F59E0B", "#64748B", "#EF4444", "#0F172A"]
42
+ WIRE = ["#38BDF8", "#10B981", "#F59E0B", "#EF4444", "#818CF8", "#F8FAFC"]
43
+ JUDICIAL = ["#F59E0B", "#F24D29", "#10B981", "#B8694A", "#D97706", "#FFFFFF"]
44
+ CONSERVATIVE = ["#4E4E4E", "#8C8C8C", "#B0B0B0", "#6B6B6B", "#D0D0D0", "#2E2E2E"]
45
+
46
+ THEME_PALETTES = {
47
+ "okabe_ito": OKABE_ITO,
48
+ "nature": NATURE,
49
+ "mono": NATURE,
50
+ "academic": NATURE,
51
+ "academic_paper": NATURE,
52
+ "claude": PALM,
53
+ "claude_research": PALM,
54
+ "palm": PALM,
55
+ "datalab": PORCELAIN,
56
+ "datalab_light": PORCELAIN,
57
+ "porcelain": PORCELAIN,
58
+ "datalab_dark": WIRE,
59
+ "datalab-dark": WIRE,
60
+ "wire": WIRE,
61
+ "presentation": JUDICIAL,
62
+ "presentation_judge": JUDICIAL,
63
+ "judicial": JUDICIAL,
64
+ "conservative": CONSERVATIVE
65
+ }
66
+
67
+
68
+ def _esc(t: Any) -> str:
69
+ return sax.escape(str(t if t is not None else ""))
70
+
71
+
72
+ def _linear_ticks(maxv: int) -> list[int]:
73
+ """Integer tick labels at roughly uniform intervals (never skip the top)."""
74
+ if maxv <= 0:
75
+ return [0]
76
+ if maxv <= 5:
77
+ return list(range(0, maxv + 1))
78
+ step = max(1, (maxv + 4) // 5) # ceil division for ~5 intervals
79
+ ticks = list(range(0, maxv + 1, step))
80
+ if ticks[-1] < maxv:
81
+ ticks.append(maxv)
82
+ return ticks
83
+
84
+
85
+ def _figure_svg(title: str, caption: str, body: str, w: int = 720, h: int = 300) -> str:
86
+ return (f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg" role="img" '
87
+ f'aria-label="{_esc(caption)}">'
88
+ f'<rect width="{w}" height="{h}" fill="#FFFFFF"/>'
89
+ f'{body}'
90
+ f'<text x="20" y="{h - 10}" font-size="11" fill="#333333" font-style="italic">{_esc(caption)}</text>'
91
+ f'</svg>')
92
+
93
+
94
+ def _bar_chart(title: str, names: list[str], values: list[float], palette: list[str],
95
+ caption: str, unit: str = "", vmax: float | None = None, h: int = 300) -> str:
96
+ w = 720
97
+ plot_x, plot_w, plot_y, plot_h = 70, 580, 50, 200
98
+ maxv = vmax if vmax is not None else (max(values) * 1.15 if values else 1)
99
+ if maxv <= 0:
100
+ maxv = 1
101
+ bw = min(46, plot_w / max(1, len(names)) * 0.6)
102
+ body = [f'<line x1="{plot_x}" y1="{plot_y + plot_h}" x2="{plot_x + plot_w}" '
103
+ f'y2="{plot_y + plot_h}" stroke="#333" stroke-width="1"/>']
104
+ for i, (name, value) in enumerate(zip(names, values)):
105
+ bh = (value / maxv) * plot_h
106
+ x = plot_x + (i + 0.5) * (plot_w / len(names)) - bw / 2
107
+ y = plot_y + plot_h - bh
108
+ body.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{max(bh, 1):.1f}" '
109
+ f'fill="{palette[i % len(palette)]}"/>')
110
+ body.append(f'<text x="{x + bw / 2:.1f}" y="{plot_y + plot_h + 16}" text-anchor="middle" '
111
+ f'font-size="10" fill="#333">{_esc(name)}</text>')
112
+ body.append(f'<text x="{x + bw / 2:.1f}" y="{y - 4:.1f}" text-anchor="middle" font-size="10" '
113
+ f'fill="#333">{value:.0f}{_esc(unit)}</text>')
114
+ # 计数图 Y 轴整数刻度:线性间隔,绝不产生 [0,1,2,3,4,12] 式失真刻度
115
+ ticks = _linear_ticks(int(maxv)) if maxv > 0 else [0]
116
+ for t in ticks:
117
+ ty = plot_y + plot_h - (t / maxv) * plot_h
118
+ body.append(f'<line x1="{plot_x - 5}" y1="{ty:.1f}" x2="{plot_x}" y2="{ty:.1f}" stroke="#999"/>')
119
+ body.append(f'<text x="{plot_x - 8}" y="{ty + 3:.1f}" text-anchor="end" font-size="9" '
120
+ f'fill="#666">{t}</text>')
121
+ body.append(f'<text x="{plot_x + plot_w / 2}" y="30" text-anchor="middle" font-size="14" '
122
+ f'font-weight="700" fill="#111">{_esc(title)}</text>')
123
+ return _figure_svg(title, caption, "".join(body), h=h)
124
+
125
+
126
+ def _grouped_bar_chart(title: str, names: list[str], series: list[dict],
127
+ caption: str, palette: list[str], h: int = 300) -> str:
128
+ """方向分组条形图:每组一个结果类型,内部并列 support / contradict / neutral
129
+ 三根柱(P0-12)。计数轴整数刻度(P0-11)。"""
130
+ w = 720
131
+ plot_x, plot_w, plot_y, plot_h = 70, 580, 50, 200
132
+ maxv = max([1] + [abs(v) for s in series for v in s["data"]])
133
+ group_w = plot_w / max(1, len(names))
134
+ n_series = len(series)
135
+ bar_w = min(34, group_w / (n_series + 1))
136
+ body = [f'<line x1="{plot_x}" y1="{plot_y + plot_h}" x2="{plot_x + plot_w}" '
137
+ f'y2="{plot_y + plot_h}" stroke="#333" stroke-width="1"/>']
138
+ for i, name in enumerate(names):
139
+ cx = plot_x + group_w * (i + 0.5)
140
+ body.append(f'<text x="{cx:.1f}" y="{plot_y + plot_h + 16}" text-anchor="middle" '
141
+ f'font-size="10" fill="#333">{_esc(name)}</text>')
142
+ for j, s in enumerate(series):
143
+ value = s["data"][i] if i < len(s["data"]) else 0
144
+ bh = (value / maxv) * plot_h
145
+ x = plot_x + group_w * i + group_w / 2 + bar_w * (j - (n_series - 1) / 2)
146
+ y = plot_y + plot_h - bh
147
+ body.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bar_w:.1f}" '
148
+ f'height="{max(bh, 1):.1f}" fill="{palette[j % len(palette)]}"/>')
149
+ if bh > 8:
150
+ body.append(f'<text x="{x + bar_w / 2:.1f}" y="{y - 3:.1f}" text-anchor="middle" '
151
+ f'font-size="9" fill="#333">{value:.0f}</text>')
152
+ ticks = [0]
153
+ while ticks[-1] < maxv and len(ticks) < 5:
154
+ ticks.append(ticks[-1] + 1)
155
+ if ticks[-1] < maxv:
156
+ ticks.append(int(maxv))
157
+ for t in ticks:
158
+ ty = plot_y + plot_h - (t / maxv) * plot_h
159
+ body.append(f'<line x1="{plot_x - 5}" y1="{ty:.1f}" x2="{plot_x}" y2="{ty:.1f}" stroke="#999"/>')
160
+ body.append(f'<text x="{plot_x - 8}" y="{ty + 3:.1f}" text-anchor="end" font-size="9" '
161
+ f'fill="#666">{t}</text>')
162
+ body.append(f'<text x="{plot_x + plot_w / 2}" y="30" text-anchor="middle" font-size="14" '
163
+ f'font-weight="700" fill="#111">{_esc(title)}</text>')
164
+ # 图例
165
+ lx = plot_x
166
+ for s in series:
167
+ body.append(f'<rect x="{lx}" y="8" width="10" height="10" '
168
+ f'fill="{palette[series.index(s) % len(palette)]}"/>')
169
+ body.append(f'<text x="{lx + 14}" y="17" font-size="10" fill="#333">{_esc(s["name"])}</text>')
170
+ lx += 14 + len(s["name"]) * 11 + 18
171
+ return _figure_svg(title, caption, "".join(body), h=h)
172
+
173
+
174
+ def _scatter_chart(title: str, points: list[tuple[float, float]], labels: list[str],
175
+ palette: list[str], caption: str, h: int = 300) -> str:
176
+ w = 720
177
+ plot_x, plot_w, plot_y, plot_h = 70, 580, 50, 200
178
+ xs = [p[0] for p in points]
179
+ ys = [p[1] for p in points]
180
+ xmax = max(xs) * 1.15 if xs else 1
181
+ ymax = 1.0
182
+ body = [
183
+ f'<line x1="{plot_x}" y1="{plot_y + plot_h}" x2="{plot_x + plot_w}" y2="{plot_y + plot_h}" stroke="#333"/>',
184
+ f'<line x1="{plot_x}" y1="{plot_y}" x2="{plot_x}" y2="{plot_y + plot_h}" stroke="#333"/>',
185
+ ]
186
+ for i, ((x, y), label) in enumerate(zip(points, labels)):
187
+ px = plot_x + (x / xmax) * plot_w
188
+ py = plot_y + plot_h - (y / ymax) * plot_h
189
+ body.append(f'<circle cx="{px:.1f}" cy="{py:.1f}" r="6" fill="{palette[i % len(palette)]}" '
190
+ f'opacity="0.9"/>')
191
+ body.append(f'<text x="{px + 8:.1f}" y="{py - 6:.1f}" font-size="10" fill="#333">{_esc(label)}</text>')
192
+ body.append(f'<text x="{plot_x + plot_w / 2}" y="30" text-anchor="middle" font-size="14" '
193
+ f'font-weight="700" fill="#111">{_esc(title)}</text>')
194
+ body.append(f'<text x="{plot_x + plot_w - 10}" y="{plot_y + plot_h + 16}" text-anchor="end" '
195
+ f'font-size="10" fill="#666">cost (USD)</text>')
196
+ return _figure_svg(title, caption, "".join(body), h=h)
197
+
198
+
199
+ def _forest_plot_svg(title: str, points: list[dict], caption: str, h: int = 420) -> str:
200
+ """Publication-Grade Meta-Analysis Forest Plot.
201
+
202
+ Shows effect sizes (Hedges' g), 95% Confidence Intervals, study weights,
203
+ and the diverging contrast between in-task procedural speed vs delayed unassisted transfer.
204
+ """
205
+ w = 820
206
+ header_y = 52
207
+ row_start_y = 78
208
+ row_height = 28
209
+
210
+ # Plot bounds: scale g from -1.0 (x=330) to +1.0 (x=670), center 0.0 at x=500
211
+ plot_left = 330
212
+ plot_right = 670
213
+ plot_center = (plot_left + plot_right) / 2 # x=500
214
+ scale = (plot_right - plot_left) / 2.0 # 170 px per 1.0 g
215
+
216
+ total_h = max(h, row_start_y + len(points) * row_height + 70)
217
+
218
+ body = [
219
+ f'<text x="{w / 2:.1f}" y="28" text-anchor="middle" font-size="15" font-weight="700" fill="#111">{_esc(title)}</text>',
220
+ f'<text x="24" y="{header_y}" font-size="11" font-weight="700" fill="#444">Study & Venue</text>',
221
+ f'<text x="240" y="{header_y}" font-size="11" font-weight="700" fill="#444">Dimension</text>',
222
+ f'<text x="{plot_center:.1f}" y="{header_y}" text-anchor="middle" font-size="10" font-weight="700" fill="#444">Hedges\' g [95% CI]</text>',
223
+ f'<text x="{w - 24:.1f}" y="{header_y}" text-anchor="end" font-size="11" font-weight="700" fill="#444">Effect [95% CI]</text>',
224
+ f'<line x1="20" y1="{header_y + 8}" x2="{w - 20}" y2="{header_y + 8}" stroke="#333" stroke-width="1"/>',
225
+ # Vertical zero line (g = 0.0)
226
+ f'<line x1="{plot_center:.1f}" y1="{header_y + 8}" x2="{plot_center:.1f}" y2="{total_h - 40}" stroke="#999" stroke-dasharray="3,3"/>',
227
+ # Bottom axis scale
228
+ f'<line x1="{plot_left}" y1="{total_h - 40}" x2="{plot_right}" y2="{total_h - 40}" stroke="#333" stroke-width="1"/>',
229
+ ]
230
+
231
+ for tick_g in [-1.0, -0.5, 0.0, 0.5, 1.0]:
232
+ tx = plot_center + tick_g * scale
233
+ body.append(f'<line x1="{tx:.1f}" y1="{total_h - 40}" x2="{tx:.1f}" y2="{total_h - 35}" stroke="#333"/>')
234
+ body.append(f'<text x="{tx:.1f}" y="{total_h - 24}" text-anchor="middle" font-size="9" fill="#555">{tick_g:+.1f}</text>')
235
+
236
+ body.append(f'<text x="{plot_left + 10}" y="{total_h - 10}" text-anchor="start" font-size="9" fill="#A85B53">← Favors Control (Deficit)</text>')
237
+ body.append(f'<text x="{plot_right - 10}" y="{total_h - 10}" text-anchor="end" font-size="9" fill="#5E8A6A">Favors AI (Gain) →</text>')
238
+
239
+ for i, p in enumerate(points):
240
+ ry = row_start_y + i * row_height
241
+ g_val = p.get("effect_size", 0.0)
242
+ ci_l = p.get("ci_lower")
243
+ ci_u = p.get("ci_upper")
244
+ has_ci = ci_l is not None and ci_u is not None
245
+ dim = p.get("outcome_dimension", "")
246
+
247
+ is_pos = g_val > 0.10
248
+ color = "#5E8A6A" if is_pos else ("#A85B53" if g_val < -0.05 else "#C99A4A")
249
+
250
+ # Study label & Dimension
251
+ body.append(f'<text x="24" y="{ry + 4}" font-size="10" font-weight="500" fill="#222">{_esc(p.get("study_label", "Study"))}</text>')
252
+ body.append(f'<text x="240" y="{ry + 4}" font-size="9" fill="#666">{_esc(dim.replace("_", " ").title()[:14])}</text>')
253
+
254
+ x_pt = max(plot_left, min(plot_right, plot_center + g_val * scale))
255
+ if has_ci:
256
+ x_ci_l = max(plot_left, min(plot_right, plot_center + ci_l * scale))
257
+ x_ci_u = max(plot_left, min(plot_right, plot_center + ci_u * scale))
258
+ body.append(f'<line x1="{x_ci_l:.1f}" y1="{ry}" x2="{x_ci_u:.1f}" y2="{ry}" stroke="{color}" stroke-width="1.5"/>')
259
+ body.append(f'<line x1="{x_ci_l:.1f}" y1="{ry - 4}" x2="{x_ci_l:.1f}" y2="{ry + 4}" stroke="{color}" stroke-width="1.5"/>')
260
+ body.append(f'<line x1="{x_ci_u:.1f}" y1="{ry - 4}" x2="{x_ci_u:.1f}" y2="{ry + 4}" stroke="{color}" stroke-width="1.5"/>')
261
+ # Center square (point only when CI not reported)
262
+ body.append(f'<rect x="{x_pt - 3.5:.1f}" y="{ry - 3.5:.1f}" width="7" height="7" fill="{color}"/>')
263
+
264
+ # Numeric label: no fabricated precision when CI missing
265
+ if has_ci:
266
+ body.append(f'<text x="{w - 24:.1f}" y="{ry + 4}" text-anchor="end" font-size="10" font-family="monospace" fill="#333">{g_val:+.2f} [{ci_l:+.2f}, {ci_u:+.2f}]</text>')
267
+ else:
268
+ body.append(f'<text x="{w - 24:.1f}" y="{ry + 4}" text-anchor="end" font-size="10" font-family="monospace" fill="#777">{g_val:+.2f} [CI not reported]</text>')
269
+
270
+ return _figure_svg(title, caption, "".join(body), w=w, h=total_h)
271
+
272
+
273
+ def build_figure_data(result: dict) -> dict[str, Any]:
274
+ """figure_data.json — publication-ready adapter output (v5 §52)."""
275
+ outcomes = effect_outcomes(result)
276
+ benchmark = result.get("benchmark", {})
277
+ decision = result.get("decision", {})
278
+ return {
279
+ "source": "result.json",
280
+ "outcomes": [
281
+ {"outcome_type": o.get("outcome_type"),
282
+ "positive_count": o.get("positive_count", 0),
283
+ "negative_count": o.get("negative_count", 0),
284
+ "null_count": o.get("null_count", 0)}
285
+ for o in outcomes
286
+ ],
287
+ "benchmark_baselines": benchmark.get("baselines", {}),
288
+ "confidence": decision.get("confidence"),
289
+ "recommended_action": decision.get("recommended_action"),
290
+ "recommend_academic_figure": True,
291
+ "provenance": {
292
+ "generated_by": "build_figures.py",
293
+ "from": "result.json",
294
+ "statistics_source": "EduEvidence analysis result (no invented p-values)",
295
+ },
296
+ }
297
+
298
+
299
+ FIGURE_TITLES = {
300
+ "zh": {"fig1": "各结果类型的效应方向分布", "fig2": "各基线引用支持精度",
301
+ "fig3": "质量 vs 成本"},
302
+ "en": {"fig1": "Effect direction by outcome type", "fig2": "Citation support by baseline",
303
+ "fig3": "Quality vs Cost"},
304
+ }
305
+
306
+ FIGURE_CAPTIONS = {
307
+ "zh": {
308
+ "fig1": "图 1. 各结果类型的正向 / 负向 / 零效应证据条数(基于 effect_direction;出版级学术图,不随主题变化)。来源:EduEvidence result.json。",
309
+ "fig2": "图 2. B0-B4 各基线的引用支持精度。来源:EduEvidence Benchmark v2。",
310
+ "fig3": "图 3. 各基线的引用支持率与单题成本的对比。",
311
+ },
312
+ "en": {
313
+ "fig1": "Fig. 1. Counts of positive / negative / null effects per outcome type "
314
+ "(based on effect_direction; publication figure, theme-independent). Source: EduEvidence result.json.",
315
+ "fig2": "Fig. 2. Citation support precision per baseline B0-B4. Source: EduEvidence Benchmark v2.",
316
+ "fig3": "Fig. 3. Citation support rate vs cost per question across baselines.",
317
+ },
318
+ }
319
+
320
+ DIR_SERIES = {
321
+ "zh": [{"name": "正向效应", "data": "positive_count"},
322
+ {"name": "负向效应", "data": "negative_count"},
323
+ {"name": "零效应", "data": "null_count"}],
324
+ "en": [{"name": "Positive effect", "data": "positive_count"},
325
+ {"name": "Negative effect", "data": "negative_count"},
326
+ {"name": "Null effect", "data": "null_count"}],
327
+ }
328
+
329
+
330
+ def render_figures(figure_data: dict, theme: str = "okabe_ito", lang: str = "zh") -> dict[str, str]:
331
+ """Render publication SVG figures from figure_data (pure Python)."""
332
+ theme_key = (theme or "okabe_ito").lower().replace("-", "_")
333
+ palette = THEME_PALETTES.get(theme_key, OKABE_ITO)
334
+ figures: dict[str, str] = {}
335
+
336
+ # Figure 1: Outcome × effect_direction (positive / negative / null 三色分组;
337
+ # 绝不把 relation_to_claim 的 support/contradict 当作 outcome 好坏;计数轴整数刻度。)
338
+ outcomes = figure_data.get("outcomes", [])
339
+ if outcomes:
340
+ names = [zh_outcome(o["outcome_type"]) if lang == "zh"
341
+ else o["outcome_type"] for o in outcomes]
342
+ series = [{"name": s["name"],
343
+ "data": [o.get(s["data"], 0) for o in outcomes]}
344
+ for s in DIR_SERIES[lang]]
345
+ figures["outcome-comparison.svg"] = _grouped_bar_chart(
346
+ FIGURE_TITLES[lang]["fig1"], names, series,
347
+ FIGURE_CAPTIONS[lang]["fig1"], palette)
348
+
349
+ # Figure 2: benchmark citation support
350
+ baselines = figure_data.get("benchmark_baselines", {})
351
+ if baselines:
352
+ names = list(baselines.keys())
353
+ citation = [b.get("citation_support_precision", 0) for b in baselines.values()]
354
+ figures["benchmark-citation-support.svg"] = _bar_chart(
355
+ FIGURE_TITLES[lang]["fig2"], names, citation, palette,
356
+ FIGURE_CAPTIONS[lang]["fig2"])
357
+ costs = [b.get("usage", {}).get("cost_usd", 0) for b in baselines.values()]
358
+ if len(costs) == len(citation):
359
+ figures["benchmark-quality-cost.svg"] = _scatter_chart(
360
+ FIGURE_TITLES[lang]["fig3"], list(zip(costs, citation)), names, palette,
361
+ FIGURE_CAPTIONS[lang]["fig3"])
362
+
363
+ forest_pts = figure_data.get("forest_plot_data", [])
364
+ if not forest_pts and "evidence_nodes" in figure_data:
365
+ for ev in figure_data["evidence_nodes"][:10]:
366
+ es = ev.get("effect_size")
367
+ es_dict = es if isinstance(es, dict) else {}
368
+ forest_pts.append({
369
+ "study_label": ev.get("study_label", ev.get("evidence_id", "Study")),
370
+ "outcome_dimension": ev.get("outcome_dimension", "GENERAL"),
371
+ "effect_size": es_dict.get("value", 0.0),
372
+ "ci_lower": es_dict.get("ci_lower"),
373
+ "ci_upper": es_dict.get("ci_upper"),
374
+ })
375
+
376
+ if forest_pts:
377
+ figures["forest-plot.svg"] = _forest_plot_svg(
378
+ "效应量森林图 (Forest Plot: 任务速度提升 vs 独立迁移赤字)",
379
+ forest_pts,
380
+ "图 4. 效应量森林图(Hedges' g 与 95% 置信区间;展示程序速度提升与无 AI 独立迁移赤字的尖锐分歧)。来源:EduEvidence SSOT 证据图谱。"
381
+ )
382
+
383
+ # Lieflat gallery: data-driven composition only. Every chart is rendered
384
+ # from a charts_data extractor bundle; entries come from the validated
385
+ # visual_layout (resolve_visual_layout). Unregistered types never reach
386
+ # this point; insufficient data suppresses the chart with a reason.
387
+ return figures
388
+
389
+
390
+ def render_lieflat_gallery(result: dict, theme: str, lang: str,
391
+ layout_entries: list) -> tuple[dict[str, str], dict]:
392
+ """Render only the layout entries that passed resolve_visual_layout.
393
+
394
+ Each entry {chart_id, type, title_zh/en, subtitle_zh/en, caption_zh/en,
395
+ source, params} is rendered from the extractor bundle for its registry
396
+ source. Returns (figures_by_chart_id, meta) where meta records selected
397
+ charts, suppressed charts with reasons, and the number audit used by the
398
+ lieflat_data_bound integrity gate.
399
+ """
400
+ from lieflat_engine import REGISTRY, render_figure
401
+
402
+ figures: dict[str, str] = {}
403
+ meta_out = {"selected": [], "suppressed": [], "audits": {}}
404
+ for entry in layout_entries or []:
405
+ fig_type = entry.get("type")
406
+ reg = REGISTRY.get(fig_type)
407
+ if reg is None:
408
+ meta_out["suppressed"].append({
409
+ "chart_id": entry.get("chart_id"), "type": fig_type,
410
+ "reason": f"unregistered type {fig_type!r}"})
411
+ continue
412
+ bundle, reason = reg["extractor"](result, entry.get("params") or {}, lang)
413
+ if bundle is None:
414
+ meta_out["suppressed"].append({
415
+ "chart_id": entry.get("chart_id"), "type": fig_type,
416
+ "catalog_ref": reg["catalog_ref"], "reason": reason})
417
+ continue
418
+ audit: list = []
419
+ meta = {
420
+ "lang": lang,
421
+ "title": entry.get(f"title_{lang}") or entry.get("title") or "",
422
+ "subtitle": entry.get(f"subtitle_{lang}") or entry.get("subtitle") or "",
423
+ "caption": entry.get(f"caption_{lang}") or entry.get("caption") or "",
424
+ "source": entry.get("source") or reg["source"],
425
+ }
426
+ try:
427
+ svg = render_figure(fig_type, bundle, theme, meta, audit=audit)
428
+ except ValueError as exc:
429
+ meta_out["suppressed"].append({
430
+ "chart_id": entry.get("chart_id"), "type": fig_type,
431
+ "catalog_ref": reg["catalog_ref"], "reason": f"render error: {exc}"})
432
+ continue
433
+ figures[entry.get("chart_id")] = svg
434
+ meta_out["audits"][entry.get("chart_id")] = {
435
+ "type": fig_type, "bundle": bundle, "audit": audit}
436
+ meta_out["selected"].append({
437
+ "chart_id": entry.get("chart_id"), "type": fig_type,
438
+ "catalog_ref": reg["catalog_ref"], "source": reg["source"]})
439
+ return figures, meta_out
440
+
441
+
442
+ def export_png_pdf(svg_path: Path, out_dir: Path) -> None:
443
+ """Optional matplotlib export to PNG/PDF (guarded; skip when unavailable)."""
444
+ try:
445
+ import matplotlib.pyplot as plt
446
+ import numpy as np # noqa: F401
447
+ except ImportError:
448
+ return
449
+ from io import StringIO
450
+ import re
451
+
452
+ svg_text = svg_path.read_text(encoding="utf-8")
453
+ rects = re.findall(r'<rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)" fill="(#[0-9A-Fa-f]{6})"',
454
+ svg_text)
455
+ texts = re.findall(r'<text x="([\d.]+)" y="([\d.]+)"[^>]*>([^<]+)</text>', svg_text)
456
+ if not rects:
457
+ return
458
+ fig, ax = plt.subplots(figsize=(8, 4))
459
+ for x, y, w, h, color in rects:
460
+ ax.bar(float(x), float(h), bottom=float(y), width=float(w), color=color, edgecolor="none")
461
+ ax.set_xlim(0, 720)
462
+ ax.set_ylim(0, 300)
463
+ ax.axis("off")
464
+ stem = svg_path.stem
465
+ fig.savefig(out_dir / f"{stem}.png", dpi=150, bbox_inches="tight")
466
+ fig.savefig(out_dir / f"{stem}.pdf", bbox_inches="tight")
467
+ plt.close(fig)
468
+
469
+
470
+ def main() -> int:
471
+ parser = argparse.ArgumentParser(description="Build Academic Figures from result.json")
472
+ parser.add_argument("--result", required=True)
473
+ parser.add_argument("--out", required=False,
474
+ help="output envelope JSON path (new unified contract)")
475
+ parser.add_argument("--out-dir", required=False,
476
+ help="[deprecated] legacy directory output; use --out")
477
+ parser.add_argument("--theme", choices=["okabe_ito", "nature", "conservative"],
478
+ default="okabe_ito")
479
+ parser.add_argument("--lang", choices=["zh", "en"], default="zh")
480
+ parser.add_argument("--export-png", action="store_true",
481
+ help="try matplotlib export to PNG/PDF (optional)")
482
+ args = parser.parse_args()
483
+
484
+ if not args.out and not args.out_dir:
485
+ parser.error("one of --out / --out-dir is required")
486
+
487
+ result = load_result(args.result)
488
+ figure_data = build_figure_data(result)
489
+ figures = render_figures(figure_data, theme=args.theme, lang=args.lang)
490
+
491
+ if args.out:
492
+ # New unified contract: single envelope JSON with structured data + SVG strings.
493
+ write_adapter_output(args.out, "figures", args.result,
494
+ {"figure_data": figure_data, "figures": figures},
495
+ locale=args.lang)
496
+ print(f"wrote {args.out} (figures_data + {len(figures)} svg figures)")
497
+ return 0
498
+
499
+ # Legacy --out-dir compatibility (deprecated; migrate callers to --out).
500
+ out_dir = Path(args.out_dir)
501
+ out_dir.mkdir(parents=True, exist_ok=True)
502
+ (out_dir / "figure_data.json").write_text(
503
+ json.dumps(figure_data, ensure_ascii=False, indent=2), encoding="utf-8")
504
+ for name, svg in figures.items():
505
+ path = out_dir / name
506
+ path.write_text(svg, encoding="utf-8")
507
+ if args.export_png:
508
+ export_png_pdf(path, out_dir)
509
+ print(f"wrote {len(figures) + 1} files to {args.out_dir}: "
510
+ f"{', '.join(sorted(figures.keys()))}")
511
+ return 0
512
+
513
+
514
+ if __name__ == "__main__":
515
+ sys.exit(main())