eduevidence 5.2.0 → 6.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 (386) hide show
  1. package/CONTRIBUTING.md +105 -0
  2. package/README.md +142 -75
  3. package/README.zh-CN.md +73 -30
  4. package/SKILL.md +397 -131
  5. package/agents/openai.yaml +4 -0
  6. package/assets/readme/controlled-execution.svg +34 -0
  7. package/assets/readme/landing-tour.gif +0 -0
  8. package/assets/readme/logo.png +0 -0
  9. package/assets/readme/research-workflow.svg +56 -0
  10. package/assets/readme/studio-graph.png +0 -0
  11. package/assets/readme/studio-overview.png +0 -0
  12. package/assets/readme/studio-reports.png +0 -0
  13. package/assets/readme/studio-tour.gif +0 -0
  14. package/autoevolve/config.yaml +17 -0
  15. package/autoevolve/program.md +25 -0
  16. package/autoevolve/protected.manifest.yaml +34 -0
  17. package/benchmarks/adversarial/cases.jsonl +7 -0
  18. package/benchmarks/evidence-library.json +5268 -0
  19. package/benchmarks/partitions.json +8 -0
  20. package/bin/eduevidence.js +2 -1
  21. package/docs/architecture.md +496 -0
  22. package/docs/autoresearch-evolution-plan.md +2903 -0
  23. package/docs/autoresearch-implementation-status.md +101 -0
  24. package/docs/demo-storyboard.md +20 -0
  25. package/docs/demo-workplace-ai.md +92 -0
  26. package/docs/demo.md +32 -0
  27. package/docs/install-guide.md +150 -0
  28. package/docs/orchestration-role-model.md +1254 -0
  29. package/docs/release-closeout/README.md +17 -0
  30. package/docs/release-closeout/frontend-acceptance.md +23 -0
  31. package/docs/release-closeout/issues.md +19 -0
  32. package/docs/release-closeout/verification.md +28 -0
  33. package/docs/release-contract.md +108 -0
  34. package/docs/research-studio-guide.zh-CN.md +166 -0
  35. package/docs/sciverse-api.md +125 -0
  36. package/eduevidence_cli.py +29 -13
  37. package/engine/_resources.py +13 -0
  38. package/engine/autoevolve/__init__.py +3 -0
  39. package/engine/autoevolve/agent_view.py +167 -0
  40. package/engine/autoevolve/core.py +357 -0
  41. package/engine/autoevolve/events.py +11 -0
  42. package/engine/autoevolve/git_workspace.py +77 -0
  43. package/engine/autoevolve/projection.py +23 -0
  44. package/engine/autoevolve/runner.py +413 -0
  45. package/engine/autoevolve/trust.py +146 -0
  46. package/engine/autoresearch/__init__.py +6 -0
  47. package/engine/autoresearch/commit.py +132 -0
  48. package/engine/autoresearch/contracts.py +126 -0
  49. package/engine/autoresearch/controller.py +207 -0
  50. package/engine/autoresearch/events.py +12 -0
  51. package/engine/autoresearch/gap_priority.py +168 -0
  52. package/engine/autoresearch/projection.py +30 -0
  53. package/engine/autoresearch/research_memory.py +59 -0
  54. package/engine/autoresearch/saturation.py +91 -0
  55. package/engine/briefs.py +2 -1
  56. package/engine/capabilities.py +1 -0
  57. package/engine/contracts.py +3 -1
  58. package/engine/decision_policy.py +96 -0
  59. package/engine/evidence_graph.py +14 -10
  60. package/engine/evidencecore.py +7 -5
  61. package/engine/gaps.py +132 -73
  62. package/engine/ids.py +2 -0
  63. package/engine/judge_pack.py +65 -0
  64. package/engine/library.py +6 -2
  65. package/engine/library_builtin.py +3 -1
  66. package/engine/living.py +36 -5
  67. package/engine/meta_synthesis.py +3 -1
  68. package/engine/migration.py +88 -3
  69. package/engine/orchestration.py +460 -0
  70. package/engine/paths.py +2 -0
  71. package/engine/pilot.py +36 -33
  72. package/engine/project.py +2 -2
  73. package/engine/research_service.py +113 -0
  74. package/engine/studio_read_model.py +400 -0
  75. package/engine/taxonomy.py +211 -0
  76. package/engine/tribunal.py +44 -33
  77. package/engine/update.py +1 -0
  78. package/engine/versions.py +1 -1
  79. package/engine/worker_result.py +109 -0
  80. package/engine/workflows.py +70 -0
  81. package/examples/ai-coding-assistant-evidence/EduEvidence_Report.html +2934 -0
  82. package/examples/ai-coding-assistant-evidence/artifact_manifest.json +15 -0
  83. package/examples/ai-coding-assistant-evidence/citation_check.json +79 -0
  84. package/examples/ai-coding-assistant-evidence/claims.jsonl +12 -0
  85. package/examples/ai-coding-assistant-evidence/evaluation.json +35 -0
  86. package/examples/ai-coding-assistant-evidence/evidence.jsonl +12 -0
  87. package/examples/ai-coding-assistant-evidence/final_verdict.json +107 -0
  88. package/examples/ai-coding-assistant-evidence/frame.json +48 -0
  89. package/examples/ai-coding-assistant-evidence/gate_report.json +101 -0
  90. package/examples/ai-coding-assistant-evidence/intervention.json +51 -0
  91. package/examples/ai-coding-assistant-evidence/methodology.json +36 -0
  92. package/examples/ai-coding-assistant-evidence/raw_verdict.json +86 -0
  93. package/examples/ai-coding-assistant-evidence/report_spec.json +230 -0
  94. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_academic.html +2934 -0
  95. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_claude.html +2934 -0
  96. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab-dark.html +2934 -0
  97. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab.html +2934 -0
  98. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_presentation.html +2934 -0
  99. package/examples/ai-coding-assistant-evidence/reports-5themes/report_academic.html +2934 -0
  100. package/examples/ai-coding-assistant-evidence/reports-5themes/report_claude.html +2934 -0
  101. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab-dark.html +2934 -0
  102. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab.html +2934 -0
  103. package/examples/ai-coding-assistant-evidence/reports-5themes/report_presentation.html +2934 -0
  104. package/examples/ai-coding-assistant-evidence/result.json +1457 -0
  105. package/examples/ai-coding-assistant-evidence/result.zh.json +1457 -0
  106. package/examples/ai-coding-assistant-evidence/skeptic.json +72 -0
  107. package/examples/ai-coding-assistant-evidence/sources.jsonl +8 -0
  108. package/examples/ai-coding-assistant-evidence/verdict.json +107 -0
  109. package/examples/spaced-retrieval-practice/applicability.json +14 -0
  110. package/examples/spaced-retrieval-practice/artifact_manifest.json +15 -0
  111. package/examples/spaced-retrieval-practice/claims.jsonl +3 -0
  112. package/examples/spaced-retrieval-practice/evidence.jsonl +6 -0
  113. package/examples/spaced-retrieval-practice/final_verdict.json +93 -0
  114. package/examples/spaced-retrieval-practice/frame.json +58 -0
  115. package/examples/spaced-retrieval-practice/gate_report.json +101 -0
  116. package/examples/spaced-retrieval-practice/methodology.json +78 -0
  117. package/examples/spaced-retrieval-practice/report_spec.json +212 -0
  118. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_academic.html +2728 -0
  119. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_claude.html +2728 -0
  120. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab-dark.html +2728 -0
  121. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab.html +2728 -0
  122. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_presentation.html +2728 -0
  123. package/examples/spaced-retrieval-practice/reports-5themes/report_academic.html +2728 -0
  124. package/examples/spaced-retrieval-practice/reports-5themes/report_claude.html +2728 -0
  125. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab-dark.html +2728 -0
  126. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab.html +2728 -0
  127. package/examples/spaced-retrieval-practice/reports-5themes/report_presentation.html +2728 -0
  128. package/examples/spaced-retrieval-practice/result.json +942 -0
  129. package/examples/spaced-retrieval-practice/result.zh.json +942 -0
  130. package/examples/spaced-retrieval-practice/skeptic.json +70 -0
  131. package/examples/spaced-retrieval-practice/sources.jsonl +7 -0
  132. package/examples/spaced-retrieval-practice/verdict.json +93 -0
  133. package/examples/workplace-ai-assistant/artifact_manifest.json +15 -0
  134. package/examples/workplace-ai-assistant/claims.jsonl +4 -0
  135. package/examples/workplace-ai-assistant/evaluation.json +19 -0
  136. package/examples/workplace-ai-assistant/evidence.jsonl +4 -0
  137. package/examples/workplace-ai-assistant/evidence_graph.json +444 -0
  138. package/examples/workplace-ai-assistant/final_verdict.json +78 -0
  139. package/examples/workplace-ai-assistant/frame.json +41 -0
  140. package/examples/workplace-ai-assistant/gate_report.json +101 -0
  141. package/examples/workplace-ai-assistant/intervention.json +27 -0
  142. package/examples/workplace-ai-assistant/legacy-link-check.json +16 -0
  143. package/examples/workplace-ai-assistant/methodology.json +60 -0
  144. package/examples/workplace-ai-assistant/report_spec.json +224 -0
  145. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_academic.html +2814 -0
  146. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_claude.html +2814 -0
  147. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab-dark.html +2814 -0
  148. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab.html +2814 -0
  149. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_presentation.html +2814 -0
  150. package/examples/workplace-ai-assistant/reports-5themes/report_academic.html +2814 -0
  151. package/examples/workplace-ai-assistant/reports-5themes/report_claude.html +2814 -0
  152. package/examples/workplace-ai-assistant/reports-5themes/report_datalab-dark.html +2814 -0
  153. package/examples/workplace-ai-assistant/reports-5themes/report_datalab.html +2814 -0
  154. package/examples/workplace-ai-assistant/reports-5themes/report_presentation.html +2814 -0
  155. package/examples/workplace-ai-assistant/result.json +615 -0
  156. package/examples/workplace-ai-assistant/result.zh.json +615 -0
  157. package/examples/workplace-ai-assistant/search_log.json +19 -0
  158. package/examples/workplace-ai-assistant/skeptic.json +72 -0
  159. package/examples/workplace-ai-assistant/sources.jsonl +3 -0
  160. package/examples/workplace-ai-assistant/validation_result.json +9 -0
  161. package/examples/workplace-ai-assistant/verdict.json +78 -0
  162. package/install.sh +7 -7
  163. package/integrations/agent_mcp.py +2 -2
  164. package/integrations/orchestration_dispatch.py +146 -0
  165. package/package.json +46 -3
  166. package/pyproject.toml +14 -22
  167. package/references/autoresearch.md +30 -0
  168. package/references/evaluation-policy.md +24 -0
  169. package/references/orchestration.md +22 -0
  170. package/references/report-copy-style.md +67 -0
  171. package/references/retrieval-compliance.md +75 -0
  172. package/references/retrieval-protocol.md +20 -0
  173. package/references/scientific-invariants.md +19 -0
  174. package/retrieval/audit.py +178 -0
  175. package/retrieval/fetch.py +96 -0
  176. package/retrieval/sciverse.py +398 -0
  177. package/retrieval/search.py +47 -7
  178. package/schemas/applicability.schema.json +94 -0
  179. package/schemas/chart-spec.schema.json +10 -3
  180. package/schemas/evidence.schema.json +316 -43
  181. package/schemas/fetch-result.schema.json +2 -1
  182. package/schemas/intervention.schema.json +106 -21
  183. package/schemas/report-result.schema.json +12 -4
  184. package/schemas/report-spec.schema.json +98 -100
  185. package/schemas/skeptic.schema.json +86 -0
  186. package/schemas/source.schema.json +21 -2
  187. package/schemas/v2/finding.schema.json +5 -1
  188. package/schemas/v2/methodology-audit.schema.json +5 -1
  189. package/schemas/v2/outcome.schema.json +28 -5
  190. package/schemas/v2/project.schema.json +2 -2
  191. package/schemas/v2/run.schema.json +1 -1
  192. package/schemas/v2/study.schema.json +5 -1
  193. package/schemas/vNext/autoevolve-session.schema.json +34 -0
  194. package/schemas/vNext/eval-snapshot.schema.json +77 -0
  195. package/schemas/vNext/execution-plan.schema.json +50 -0
  196. package/schemas/vNext/gap-priority.schema.json +54 -0
  197. package/schemas/vNext/negative-search-record.schema.json +68 -0
  198. package/schemas/vNext/research-iteration.schema.json +87 -0
  199. package/schemas/vNext/research-strategy.schema.json +62 -0
  200. package/schemas/vNext/skill-experiment.schema.json +90 -0
  201. package/schemas/vNext/task-spec.schema.json +156 -0
  202. package/schemas/vNext/worker-result.schema.json +60 -0
  203. package/schemas/verdict.schema.json +164 -28
  204. package/scripts/benchmark_judge.py +2 -2
  205. package/scripts/benchmark_v3.py +26 -43
  206. package/scripts/build_esl_artifacts.py +4 -4
  207. package/scripts/build_evidence_library.py +2 -2
  208. package/scripts/build_gh_pages.py +98 -0
  209. package/scripts/build_readme_diagrams.py +72 -0
  210. package/scripts/build_report_variants.py +101 -0
  211. package/scripts/build_result.py +74 -9
  212. package/scripts/check_autoresearch_invariants.py +95 -0
  213. package/scripts/check_package_parity.py +85 -0
  214. package/scripts/check_protocol_alignment.py +375 -0
  215. package/scripts/check_versioned_schemas.py +254 -0
  216. package/scripts/claim_audit.py +13 -8
  217. package/scripts/compute_confidence.py +10 -0
  218. package/scripts/daily_evolve.py +30 -0
  219. package/scripts/dashboard_server.py +130 -101
  220. package/scripts/did_regression.py +17 -32
  221. package/scripts/enrich_projects_human_and_lieflat.py +1 -1
  222. package/scripts/evidence_score.py +5 -2
  223. package/scripts/generate_metrics.py +4 -3
  224. package/scripts/generate_new_projects.py +5 -5
  225. package/scripts/orchestrator.py +286 -36
  226. package/scripts/pre_verdict_gate.py +224 -26
  227. package/scripts/quickstart.py +18 -2
  228. package/scripts/rebake_all_5themes.py +1 -2
  229. package/scripts/research_auto_cli.py +475 -0
  230. package/scripts/run_workspace.py +24 -8
  231. package/scripts/search_provenance.py +64 -0
  232. package/scripts/serve_web.py +9 -10
  233. package/scripts/skill_lint.py +1 -1
  234. package/scripts/skill_payload.py +81 -0
  235. package/scripts/test_adversarial_empirical.py +26 -19
  236. package/scripts/validate_schema.py +46 -2
  237. package/scripts/vnext_cli.py +133 -0
  238. package/setup.py +12 -0
  239. package/skill/agents/evaluation-designer.md +20 -4
  240. package/skill/agents/evidence-analyst.md +19 -3
  241. package/skill/agents/evidence-judge.md +50 -2
  242. package/skill/agents/evidence-retriever.md +20 -3
  243. package/skill/agents/intervention-designer.md +20 -4
  244. package/skill/agents/method-reviewer.md +18 -2
  245. package/skill/agents/{education-planner.md → research-planner.md} +19 -3
  246. package/skill/agents/skeptic.md +18 -2
  247. package/skill/roles/registry.yaml +45 -0
  248. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +28 -9
  249. package/skill/sub-skills/contradiction-analysis/SKILL.md +31 -11
  250. package/skill/sub-skills/data-analysis/SKILL.md +34 -15
  251. package/skill/sub-skills/ethics-review/SKILL.md +33 -10
  252. package/skill/sub-skills/evidence-extraction/SKILL.md +29 -11
  253. package/skill/sub-skills/evidence-review/SKILL.md +31 -12
  254. package/skill/sub-skills/gap-analysis/SKILL.md +31 -9
  255. package/skill/sub-skills/literature-review/SKILL.md +35 -14
  256. package/skill/sub-skills/methodology-audit/SKILL.md +29 -12
  257. package/skill/sub-skills/report-generation/SKILL.md +40 -6
  258. package/skill/sub-skills/research-planning/SKILL.md +41 -14
  259. package/skill/sub-skills/study-design/SKILL.md +30 -9
  260. package/skill/task-briefs/adjudicate.md +32 -7
  261. package/skill/task-briefs/applicability.md +38 -0
  262. package/skill/task-briefs/audit.md +32 -7
  263. package/skill/task-briefs/challenge.md +34 -5
  264. package/skill/task-briefs/evaluate.md +30 -5
  265. package/skill/task-briefs/extract.md +31 -8
  266. package/skill/task-briefs/frame.md +39 -10
  267. package/skill/task-briefs/intervene.md +32 -6
  268. package/skill/task-briefs/present.md +32 -8
  269. package/skill/task-briefs/projection.md +37 -0
  270. package/skill/task-briefs/retrieve.md +36 -6
  271. package/skill/workflows/decision-and-pilot.md +85 -0
  272. package/skill/workflows/evaluate-and-update.md +93 -0
  273. package/skill/workflows/evidence-review.md +117 -0
  274. package/visualization/eduevidence-report/assets/base.css +2 -2
  275. package/visualization/eduevidence-report/assets/reader.css +752 -0
  276. package/visualization/eduevidence-report/assets/reader.js +132 -0
  277. package/visualization/eduevidence-report/references/chart-selection-catalog.md +109 -0
  278. package/visualization/eduevidence-report/references/lieflat-composition.md +3 -1
  279. package/visualization/eduevidence-report/scripts/build_figures.py +25 -3
  280. package/visualization/eduevidence-report/scripts/build_infographics.py +5 -1
  281. package/visualization/eduevidence-report/scripts/build_report.py +561 -121
  282. package/visualization/eduevidence-report/scripts/charts_data.py +2 -0
  283. package/visualization/eduevidence-report/scripts/lieflat_engine.py +371 -136
  284. package/visualization/eduevidence-report/scripts/zh_labels.py +80 -1
  285. package/visualization/eduevidence-report/themes/academic.css +1 -1
  286. package/visualization/eduevidence-report/themes/claude.css +1 -1
  287. package/visualization/eduevidence-report/themes/datalab-dark.css +2 -2
  288. package/visualization/eduevidence-report/themes/datalab.css +2 -2
  289. package/visualization/eduevidence-report/themes/presentation.css +2 -2
  290. package/web/README.md +18 -0
  291. package/web/architecture.html +14885 -0
  292. package/web/index.html +53 -0
  293. package/web/studio/THIRD_PARTY_LICENSES.txt +146 -0
  294. package/web/studio/assets/index-B8tkF44Q.css +1 -0
  295. package/web/studio/assets/index-CQ6Keoyc.js +230 -0
  296. package/web/studio/config.json +1 -0
  297. package/web/studio/index.html +14 -0
  298. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  299. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  300. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  301. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  302. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  303. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  304. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  305. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  306. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  307. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  308. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  309. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  310. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  311. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  312. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  313. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  314. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  315. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  316. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  317. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  318. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  319. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  320. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  321. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  322. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  323. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  324. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  325. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  326. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  327. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  328. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  329. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  330. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  331. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  332. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  333. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  334. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  335. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  336. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  337. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  338. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  339. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  340. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  341. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  342. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  343. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  344. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  345. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  346. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  347. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  348. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  349. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  350. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  351. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  352. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  353. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  354. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  355. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  356. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  357. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  358. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  359. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  360. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  361. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  362. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  363. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  364. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  365. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  366. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  367. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  368. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  369. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  370. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  371. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  372. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  373. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  374. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  375. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  376. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  377. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  378. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  379. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  380. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  381. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  382. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  383. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  384. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  385. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  386. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
@@ -96,24 +96,165 @@ METHODOLOGY_LABELS_ZH = {
96
96
 
97
97
  FRAME_ENUM_ZH = {
98
98
  "teaching_decision": "教学决策",
99
+ "evidence_review": "证据评审",
100
+ "pilot_design": "试点设计",
101
+ "evaluation_design": "评价设计",
99
102
  "undergraduate_year_1": "大学一年级",
103
+ "undergraduate_year_2": "大学二年级",
104
+ "undergraduate_year_3": "大学三年级",
105
+ "undergraduate_year_4": "大学四年级",
106
+ "postgraduate": "研究生",
107
+ "high_school": "高中",
108
+ "middle_school": "初中",
109
+ "primary_school": "小学",
110
+ "workplace_adult": "在职成人",
100
111
  "computer_science": "计算机科学与技术",
112
+ "information_systems": "信息管理",
113
+ "software_engineering": "软件工程",
114
+ "data_science": "数据科学",
115
+ "mathematics": "数学",
116
+ "medicine": "医学",
117
+ "humanities": "人文",
118
+ "social_sciences": "社会科学",
119
+ "business": "商科",
101
120
  "C_programming": "C 语言程序设计",
121
+ "introductory_programming": "程序设计入门",
122
+ "data_structures": "数据结构",
123
+ "algorithms": "算法",
124
+ "database_systems": "数据库系统",
125
+ "machine_learning": "机器学习",
126
+ "academic_writing": "学术写作",
127
+ "tesol": "英语教学",
102
128
  "compulsory_core_course": "必修核心课程",
129
+ "elective_course": "选修课程",
130
+ "lecture_lab": "讲授 + 实验课",
131
+ "seminar": "研讨课",
132
+ "online_course": "在线课程",
133
+ "blended_learning": "混合式教学",
134
+ "professional_training": "职业培训",
135
+ "enterprise_customer_support": "企业客户支持",
136
+ "software_development": "软件开发",
137
+ "knowledge_work": "知识型工作",
103
138
  "primary": "主要结果",
104
139
  "secondary": "次要结果",
105
140
  "risk": "风险结果",
141
+ "adopt": "采纳",
142
+ "modify": "修改",
143
+ "terminate": "终止",
144
+ "maintain": "维持",
145
+ "evaluate_impact": "评估影响",
146
+ "rct": "随机对照试验",
147
+ "quasi_experimental": "准实验",
148
+ "observational": "观察性研究",
149
+ "meta_analysis": "元分析",
150
+ "systematic_review": "系统综述",
151
+ "qualitative": "质性研究",
152
+ "mixed_methods": "混合方法",
153
+ "survey": "调查研究",
154
+ "case_study": "案例研究",
155
+ "literature_review": "文献综述",
156
+ "online": "线上",
157
+ "offline": "线下",
158
+ "hybrid": "混合",
159
+ "under_design_pending_evidence_review": "设计中(待证据评审)",
160
+ "human_supervised_customer_support_assistant": "人工监督的客服助手",
161
+ "approved_knowledge_base_and_agent_review": "已批准知识库 + 坐席复核",
162
+ "institutional_reform": "机构改革",
163
+ "enterprise_customer_support_staff": "企业客服人员",
164
+ "autonomous_agents_and_high_stakes_specialist_advice": "自主代理与高风险专业建议",
165
+ "lecture_with_lab_exercises": "讲授 + 实验练习",
166
+ "generative_ai_coding_assistant": "生成式 AI 编程助手",
167
+ "first_programming_course_no_prior_text_based_programming": "首次程序设计课程,无文本编程基础",
168
+ "mixed_ability_large_class_60_students": "混合能力大班(60 人)",
169
+ "weekly_lab_sessions": "每周实验课",
170
+ "one_semester": "一学期",
171
+ "16_weeks_one_semester": "16 周(一学期)",
172
+ "60_students": "60 名学生",
173
+ "TA_supported_two_TAs": "助教支持(2 名助教)",
174
+ "daily": "每日",
175
+ "weekly": "每周",
176
+ "monthly": "每月",
106
177
  }
107
178
 
108
179
  FRAME_ENUM_EN = {
109
180
  "teaching_decision": "Teaching decision",
181
+ "evidence_review": "Evidence review",
182
+ "pilot_design": "Pilot design",
183
+ "evaluation_design": "Evaluation design",
110
184
  "undergraduate_year_1": "First-year undergraduate",
185
+ "undergraduate_year_2": "Second-year undergraduate",
186
+ "undergraduate_year_3": "Third-year undergraduate",
187
+ "undergraduate_year_4": "Fourth-year undergraduate",
188
+ "postgraduate": "Postgraduate",
189
+ "high_school": "High school",
190
+ "middle_school": "Middle school",
191
+ "primary_school": "Primary school",
192
+ "workplace_adult": "Working adults",
111
193
  "computer_science": "Computer science",
194
+ "information_systems": "Information systems",
195
+ "software_engineering": "Software engineering",
196
+ "data_science": "Data science",
197
+ "mathematics": "Mathematics",
198
+ "medicine": "Medicine",
199
+ "humanities": "Humanities",
200
+ "social_sciences": "Social sciences",
201
+ "business": "Business",
112
202
  "C_programming": "C programming",
203
+ "introductory_programming": "Introductory programming",
204
+ "data_structures": "Data structures",
205
+ "algorithms": "Algorithms",
206
+ "database_systems": "Database systems",
207
+ "machine_learning": "Machine learning",
208
+ "academic_writing": "Academic writing",
209
+ "tesol": "TESOL",
113
210
  "compulsory_core_course": "Compulsory core course",
211
+ "elective_course": "Elective course",
212
+ "lecture_lab": "Lecture + lab",
213
+ "seminar": "Seminar",
214
+ "online_course": "Online course",
215
+ "blended_learning": "Blended learning",
216
+ "professional_training": "Professional training",
217
+ "enterprise_customer_support": "Enterprise customer support",
218
+ "software_development": "Software development",
219
+ "knowledge_work": "Knowledge work",
114
220
  "primary": "Primary outcomes",
115
221
  "secondary": "Secondary outcomes",
116
222
  "risk": "Risk outcomes",
223
+ "adopt": "Adopt",
224
+ "modify": "Modify",
225
+ "terminate": "Terminate",
226
+ "maintain": "Maintain",
227
+ "evaluate_impact": "Evaluate impact",
228
+ "rct": "Randomised controlled trial",
229
+ "quasi_experimental": "Quasi-experimental",
230
+ "observational": "Observational",
231
+ "meta_analysis": "Meta-analysis",
232
+ "systematic_review": "Systematic review",
233
+ "qualitative": "Qualitative",
234
+ "mixed_methods": "Mixed methods",
235
+ "survey": "Survey",
236
+ "case_study": "Case study",
237
+ "literature_review": "Literature review",
238
+ "online": "Online",
239
+ "offline": "Offline",
240
+ "hybrid": "Hybrid",
241
+ "under_design_pending_evidence_review": "Under design (pending evidence review)",
242
+ "approved_knowledge_base_and_agent_review": "Approved knowledge base + agent review",
243
+ "institutional_reform": "Institutional reform",
244
+ "enterprise_customer_support_staff": "Customer-support staff",
245
+ "autonomous_agents_and_high_stakes_specialist_advice": "Autonomous agents and high-stakes specialist advice",
246
+ "lecture_with_lab_exercises": "Lecture with lab exercises",
247
+ "generative_ai_coding_assistant": "Generative AI coding assistant",
248
+ "first_programming_course_no_prior_text_based_programming": "First programming course, no prior text-based programming",
249
+ "mixed_ability_large_class_60_students": "Mixed-ability large class (60 students)",
250
+ "weekly_lab_sessions": "Weekly lab sessions",
251
+ "one_semester": "One semester",
252
+ "16_weeks_one_semester": "16 weeks (one semester)",
253
+ "60_students": "60 students",
254
+ "TA_supported_two_TAs": "TA supported (2 TAs)",
255
+ "daily": "Daily",
256
+ "weekly": "Weekly",
257
+ "monthly": "Monthly",
117
258
  }
118
259
 
119
260
  DIR_LABEL = {"support": "支持", "contradict": "反驳", "neutral": "中性"}
@@ -640,39 +781,135 @@ def visualization_decisions(result: dict, charts: dict) -> dict[str, dict[str, A
640
781
  # 0b. Lieflat gallery composition — visual_layout contract (§三)
641
782
  # ---------------------------------------------------------------------------
642
783
 
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": {}},
784
+ # Deterministic fallback when visual_layout is missing or all entries are
785
+ # invalid. The composition is DATA-DRIVEN: instead of a fixed quartet that
786
+ # silently suppresses most charts on an evidence set without numeric effect
787
+ # sizes, candidates are probed against the extractors and only the shapes the
788
+ # data actually supports are kept. Numbers still come exclusively from
789
+ # scripts/charts_data.py, so this remains an honest gallery.
790
+ FALLBACK_CANDIDATES = (
791
+ ("forest_plot", ("证据效应量森林图", "Effect-size forest plot",
792
+ "Hedges' g 95% 置信区间 · 一行一篇研究",
793
+ "Hedges' g with 95% CI · one row per study",
794
+ "仅当证据集携带数值效应量时绘制;无 g/CI 数据时不画假图。",
795
+ "Drawn only when numeric effect sizes exist in the evidence set.")),
796
+ ("dot_cascade", ("证据效应量梯队级联", "Ranked effect-size cascade",
797
+ "按效应量由高到低排序 · 圆点高度 = Hedges' g",
798
+ "Sorted by effect size · dot height = Hedges' g",
799
+ "仅当存在逐研究数值效应量时绘制。",
800
+ "Drawn only when per-study numeric effect sizes exist.")),
801
+ ("bubble_almanac", ("发表年份 × 结果维度文献年历", "Year × dimension evidence almanac",
802
+ "气泡面积 该格研究数 · 实心圆 = 有显著结果",
803
+ "Bubble areastudy count · solid core = significant results",
804
+ "仅当证据集携带发表年份与结果维度时绘制。",
805
+ "Drawn only when years and outcome dimensions exist.")),
806
+ ("matrix_heat", ("年份 × 结果维度证据密度", "Year × outcome evidence density",
807
+ "每格数字 = 该年份该结果维度的证据条数",
808
+ "Each cell counts evidence items for that year and outcome",
809
+ "当证据跨多个年份与结果维度时,展示研究密度的分布。",
810
+ "Shows where the evidence sits across years and outcomes.")),
811
+ ("tick_rows", ("各结果类型效应方向分布", "Effect direction by outcome",
812
+ " 1 个圆点 = 1 条证据 · 绿 = 正向 · 灰 = 零效应 · 橙 = 负向",
813
+ "One dot = one evidence item · green = positive · grey = null · orange = negative",
814
+ "基于 effect_direction 计数,全部数值来自 result.json。",
815
+ "Based on effect_direction counts; all numbers come from result.json.")),
816
+ ("paired_rungs", ("各结果类型的正负证据对照", "Positive vs negative evidence by outcome",
817
+ "左右两列分别汇总正向与负向证据条数",
818
+ "Two columns summarise positive and negative evidence counts",
819
+ "当同一结果同时存在正向与负向证据时,分列呈现避免相互抵消。",
820
+ "Splits positive and negative evidence so they never cancel out.")),
821
+ ("brand_spectrum", ("各结果类型的净效应倾向", "Net effect direction by outcome",
822
+ "位置 =(正向 − 负向)÷ 方向计数 · 中点为中性",
823
+ "Position = (positive - negative) / directional count · centre is neutral",
824
+ "双极展示各结果构念整体偏向支持还是反对。",
825
+ "Bipolar view of whether each outcome leans supportive or against.")),
826
+ ("hundred_field", ("研究设计构成", "Study-design composition",
827
+ "每格 = 1 篇研究 · 显示证据来自哪些研究设计",
828
+ "One cell = one study · shows which designs produced the evidence",
829
+ "当证据包含多种研究设计时,构成图比表格更快暴露设计偏斜。",
830
+ "Reveals design skew faster than a table when several designs are present.")),
831
+ ("tick_donut", ("方法学评级构成", "Methodology rating composition",
832
+ "每 tick ≈ 1% 构成 · 按 WWC 评级汇总",
833
+ "One tick ~ 1% share · aggregated by WWC rating",
834
+ "仅当证据集记录 WWC 评级时绘制。",
835
+ "Drawn only when WWC ratings are recorded.")),
836
+ ("tick_gauge", ("决策置信度", "Decision confidence",
837
+ "0–100% 单值仪表 · 由确定性置信度公式给出",
838
+ "Single 0-100% gauge from the deterministic confidence formula",
839
+ "置信度值来自 result.json 的决策字段,不由模型自评。",
840
+ "The value comes from the result's decision field, never self-rated.")),
841
+ ("ballot_tally", ("方法学检查项计票", "Methodology checklist tally",
842
+ "每 tick = 1 条审计结论 · 按检查项统计通过与否",
843
+ "One tick = one audit verdict · grouped by checklist item",
844
+ "让读者一眼看出方法学短板集中在哪些检查项。",
845
+ "Shows which checklist items concentrate the weaknesses.")),
846
+ ("launch_fan", ("干预阶段与活动权重", "Intervention phases and activity weight",
847
+ "每段 = 一个试点阶段 · 宽度 = 活动条数",
848
+ "Each segment is one pilot phase · width = number of activities",
849
+ "展示试点在时间上的铺开方式。",
850
+ "Shows how the pilot is spread across its phases.")),
851
+ ("barcode_lollipop", ("试点阶段周次分布", "Pilot phase week spans",
852
+ "每根 = 一个阶段 · 高度 = 阶段序号",
853
+ "One bar per phase · height = phase index",
854
+ "仅当阶段定义包含可解析的周次区间时绘制。",
855
+ "Drawn only when phases carry parseable week ranges.")),
856
+ ("dotty_matrix", ("阶段 × 活动矩阵", "Phase × activity matrix",
857
+ "每点 = 1 项活动 · 行 = 阶段",
858
+ "One dot = one activity · rows are phases",
859
+ "当阶段内含多项活动时,矩阵比列表更易比较。",
860
+ "Compares activity load across phases better than a list.")),
861
+ ("jitter_strip", ("分组效应量分布", "Grouped effect-size distribution",
862
+ "每点 = 一篇研究 · 按结果维度分组",
863
+ "One point = one study · grouped by outcome dimension",
864
+ "仅当多个结果维度各有 3 篇以上带效应量的研究时绘制。",
865
+ "Drawn only when several dimensions each carry 3+ numeric effects.")),
866
+ ("parallel_coordinates", ("跨维度研究画像", "Studies across dimensions",
867
+ "同一批研究在效应量 / 样本量 / 质量 / 年份上的走势",
868
+ "The same studies traced across effect size / N / quality / year",
869
+ "仅当至少 3 篇研究同时具备四项数值时绘制。",
870
+ "Drawn only when at least 3 studies carry all four values.")),
674
871
  )
675
872
 
873
+
874
+ def _fallback_layout_for(result: dict, lang: str) -> list[dict]:
875
+ """Probe the registry and keep only the chart shapes this result supports.
876
+
877
+ ``visual_layout`` is the AI's job; when it is absent this keeps the gallery
878
+ useful without inventing anything: every candidate runs through its own
879
+ extractor, and a candidate whose data is missing is simply not offered.
880
+ One chart per data source avoids repeating the same shape twice.
881
+ """
882
+ from lieflat_engine import REGISTRY
883
+
884
+ chosen: list[dict] = []
885
+ used_sources: set[str] = set()
886
+ for fig_type, copy in FALLBACK_CANDIDATES:
887
+ reg = REGISTRY.get(fig_type)
888
+ if reg is None or reg["source"] in used_sources:
889
+ continue
890
+ try:
891
+ bundle, _reason = reg["extractor"](result, {}, lang)
892
+ except Exception:
893
+ bundle = None
894
+ if bundle is None:
895
+ continue
896
+ used_sources.add(reg["source"])
897
+ title_zh, title_en, sub_zh, sub_en, cap_zh, cap_en = copy
898
+ chosen.append({
899
+ "chart_id": "lieflat-" + fig_type.replace("_", "-") + ".svg",
900
+ "type": fig_type,
901
+ "catalog_ref": reg["catalog_ref"],
902
+ "source": reg["source"],
903
+ "params": {},
904
+ "title_zh": title_zh, "title_en": title_en,
905
+ "subtitle_zh": sub_zh, "subtitle_en": sub_en,
906
+ "caption_zh": cap_zh, "caption_en": cap_en,
907
+ })
908
+ if len(chosen) >= 6:
909
+ break
910
+ return chosen
911
+
912
+
676
913
  LIEFLAT_PARAM_TYPES = {"int": int, "list": list}
677
914
 
678
915
 
@@ -794,9 +1031,18 @@ def resolve_visual_layout(result: dict) -> dict[str, Any]:
794
1031
  if entries:
795
1032
  return {"entries": entries, "fallback": False, "warnings": warnings, "rejected": rejected}
796
1033
 
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]
1034
+ # Data-driven fallback: probe every candidate shape and keep the ones this
1035
+ # result can honestly support, instead of a fixed quartet that suppresses
1036
+ # most charts when numeric effect sizes are absent.
1037
+ lang_hint = "zh" if result.get("meta", {}).get("lang") == "zh" else "en"
1038
+ fallback_entries = _fallback_layout_for(result, lang_hint)
1039
+ if fallback_entries:
1040
+ warnings.append(
1041
+ "visual_layout missing or fully invalid — data-driven fallback selected "
1042
+ + ", ".join(e["type"] for e in fallback_entries))
1043
+ else:
1044
+ warnings.append("visual_layout missing or fully invalid — no chart shape had "
1045
+ "sufficient data; gallery suppressed rather than drawn empty")
800
1046
  return {"entries": fallback_entries, "fallback": True, "warnings": warnings, "rejected": rejected}
801
1047
 
802
1048
 
@@ -948,6 +1194,9 @@ TEXT_LEAF_KEYS = {
948
1194
  "population", "intervention", "comparison", "outcome_measure", "effect", "method",
949
1195
  "ai_usage_policy", "target_learners", "ai_usage_rule", "outcome_check",
950
1196
  "research_question", "treatment", "baseline", "post_test", "retention_test",
1197
+ # Evaluation milestone fields name a measurement point in the reader
1198
+ # language; they are prose, not structural identifiers.
1199
+ "immediate_post", "retention", "transfer",
951
1200
  "transfer_test", "success_threshold", "analysis_plan",
952
1201
  "suitable_for", "not_suitable_for", "search_provider",
953
1202
  "learner_match", "subject_match", "tool_match", "scope",
@@ -963,6 +1212,10 @@ PROSE_LIST_KEYS = {
963
1212
  "stop_conditions", "strengths", "limitations", "confounders", "required_conditions",
964
1213
  "process_metrics", "learning_metrics", "risk_metrics", "suggestions", "risk_control",
965
1214
  "evidence_alignment",
1215
+ # Evaluation prose: criteria are sentences, and the milestone fields
1216
+ # name a measurement point in the reader language.
1217
+ "success_criteria", "stop_criteria", "immediate_post", "retention",
1218
+ "transfer", "baseline",
966
1219
  }
967
1220
 
968
1221
 
@@ -1091,7 +1344,33 @@ _ZH_RESIDUE_RE = [
1091
1344
  (re.compile(r"\bCONCERN\b|\bPASS\b|\bFAIL\b"), "unmapped English audit code in zh narrative"),
1092
1345
  ]
1093
1346
 
1347
+ #: Raw storage identifiers inside a sentence. A single-token value may be an
1348
+ #: enum rendered through FRAME_ENUM_*, but an underscore inside a multi-word
1349
+ #: string or inside a zh sentence is a copy defect the reader would see.
1350
+ _RAW_IDENTIFIER_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+")
1351
+
1352
+
1353
+ def _scan_raw_identifiers(problems: list[str], path: str, text: str) -> None:
1354
+ """Flag snake_case storage identifiers that leaked into visible prose."""
1355
+ if not text or not isinstance(text, str):
1356
+ return
1357
+ cleaned = text.strip()
1358
+ has_cjk = bool(_HAS_CJK.search(cleaned))
1359
+ is_sentence = len(cleaned.split()) > 1
1360
+ if not (has_cjk or is_sentence):
1361
+ return
1362
+ for match in _RAW_IDENTIFIER_RE.finditer(cleaned):
1363
+ token = match.group(0)
1364
+ if token in FRAME_ENUM_ZH or token in FRAME_ENUM_EN:
1365
+ continue
1366
+ problems.append(path + ": raw identifier " + repr(token) + " leaked into prose")
1367
+ return
1368
+
1369
+
1094
1370
  STRICT_NARRATIVE_PATHS = [
1371
+ # Reader-facing decision prose written by the adjudicator.
1372
+ "decision.strongest_support", "decision.key_uncertainty",
1373
+ "decision.main_risk", "decision.next_action",
1095
1374
  "decision.decision_rationale", "decision.rationale", "decision.strongest_support",
1096
1375
  "decision.key_uncertainty", "decision.main_risk", "decision.next_action",
1097
1376
  "decision.next_steps", "decision.what_can_be_claimed", "decision.what_cannot_be_claimed",
@@ -1108,6 +1387,21 @@ _SKIP_HINTS = ("id", "url", "doi", "author", "venue", "year", "status", "score",
1108
1387
  "verdict", "target", "decision")
1109
1388
 
1110
1389
 
1390
+ COPY_LIMITS_ZH = {
1391
+ "decision.strongest_support": 60,
1392
+ "decision.key_uncertainty": 70,
1393
+ "decision.main_risk": 60,
1394
+ "decision.next_action": 80,
1395
+ "decision.decision_rationale": 160,
1396
+ "decision.rationale": 160,
1397
+ }
1398
+
1399
+
1400
+ #: Structural tokens that look like prose to a language check but are parsed
1401
+ #: They stay identical across languages by contract.
1402
+ NON_NARRATIVE_KEYS = ("weeks", "pilot_duration")
1403
+
1404
+
1111
1405
  def _scan_narrative(problems, path, en_text, zh_text, strict=False, kind=""):
1112
1406
  en_text = en_text or ""
1113
1407
  zh_text = zh_text or ""
@@ -1130,6 +1424,10 @@ def _scan_narrative(problems, path, en_text, zh_text, strict=False, kind=""):
1130
1424
  problems.append(path + ": 含" + label)
1131
1425
  if strict and kind in ("rationale", "reason") and len(zh_text) < 40:
1132
1426
  problems.append(path + ": 决策理由过短(<40 字)")
1427
+ ceiling = COPY_LIMITS_ZH.get(path)
1428
+ if ceiling and len(zh_text) > ceiling:
1429
+ problems.append(
1430
+ f"{path}: 叙述超出 {ceiling} 字上限({len(zh_text)} 字)")
1133
1431
 
1134
1432
 
1135
1433
  def check_language_parallel(result_en: dict, result_zh: dict) -> list[str]:
@@ -1154,6 +1452,9 @@ def check_language_parallel(result_en: dict, result_zh: dict) -> list[str]:
1154
1452
  _walk_strings(result_zh.get(root), "", zh_nodes, _SKIP_HINTS)
1155
1453
  by_path_en = {p: t for p, t in en_nodes}
1156
1454
  for p, z in zh_nodes:
1455
+ leaf = p.rsplit(".", 1)[-1]
1456
+ if leaf in NON_NARRATIVE_KEYS:
1457
+ continue
1157
1458
  _scan_narrative(problems, root + p, by_path_en.get(p, ""), z, strict=False)
1158
1459
  for key in ("claims", "evidence"):
1159
1460
  en_items = result_en.get(key) or []
@@ -1163,6 +1464,37 @@ def check_language_parallel(result_en: dict, result_zh: dict) -> list[str]:
1163
1464
  z_text = z_item.get("claim") if isinstance(z_item, dict) else None
1164
1465
  if z_text:
1165
1466
  _scan_narrative(problems, key + "[" + str(i) + "].claim", e_text, z_text, strict=False)
1467
+ # Frame prose: field values may be enums (rendered via FRAME_ENUM_*), but a
1468
+ # storage identifier must never reach the reader inside a sentence. Both
1469
+ # language versions are checked because the leak is an authoring defect.
1470
+ # Weeks and pilot duration are structural tokens (W1, W2-W13, one_semester):
1471
+
1472
+ for label, root in (("research_frame", result_en), ("research_frame.zh", result_zh)):
1473
+ nodes: list = []
1474
+ _walk_strings(root.get("research_frame"), "", nodes, ())
1475
+ for node_path, text_value in nodes:
1476
+ _scan_raw_identifiers(problems, label + node_path, text_value)
1477
+ # Structural parallel: ids, source/study/sample keys, sample sizes and the
1478
+ # direction fields must match item by item. Free text may differ between
1479
+ # languages; these may not, or the zh report describes a different study.
1480
+ structural_fields = ("evidence_id", "source_id", "study_id", "sample_id",
1481
+ "sample_size", "outcome_type", "effect_direction",
1482
+ "relation_to_claim")
1483
+ en_ev = result_en.get("evidence")
1484
+ zh_ev = result_zh.get("evidence")
1485
+ if isinstance(en_ev, list) and isinstance(zh_ev, list):
1486
+ if len(en_ev) != len(zh_ev):
1487
+ problems.append(
1488
+ "evidence: en and zh rows differ in count "
1489
+ + f"({len(en_ev)} vs {len(zh_ev)})")
1490
+ for i, (a, b) in enumerate(zip(en_ev, zh_ev)):
1491
+ if not (isinstance(a, dict) and isinstance(b, dict)):
1492
+ continue
1493
+ for field_name in structural_fields:
1494
+ if a.get(field_name) != b.get(field_name):
1495
+ problems.append(
1496
+ f"evidence[{i}].{field_name}: en={a.get(field_name)!r} "
1497
+ f"!= zh={b.get(field_name)!r} (structural fields must agree)")
1166
1498
  return problems
1167
1499
 
1168
1500
 
@@ -1358,7 +1690,15 @@ def diverging_bar_svg(option: dict, width: int = 720, height: int = 300,
1358
1690
  series = option.get("series", [])
1359
1691
  if not cats:
1360
1692
  return ""
1361
- left, right = 150, 40
1693
+ # Reserve enough gutter for the longest y-axis label. A fixed 150px gutter
1694
+ # pushed a long outcome name off the left edge (x0 = -42), so the reader
1695
+ # saw a clipped label instead of the outcome it names.
1696
+ def _label_w(text: str) -> float:
1697
+ cjk = sum(1 for ch in str(text) if "\u4e00" <= ch <= "\u9fff")
1698
+ return cjk * 11 + (len(str(text)) - cjk) * 6.6
1699
+
1700
+ left = max(150, int(max((_label_w(c) for c in cats), default=0)) + 20)
1701
+ right = 40
1362
1702
  top, bottom = 46, 34
1363
1703
  main_h = int((height - top - bottom) * 0.62)
1364
1704
  neutral_h = height - top - bottom - main_h - 14
@@ -1548,62 +1888,20 @@ def first_screen(result: dict, lang: str, ui: dict) -> str:
1548
1888
  evidence = result.get("evidence", [])
1549
1889
  ranked = sorted(outcomes, key=lambda o: _outcome_support_score(evidence, o), reverse=True)
1550
1890
  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
1891
  action = decision.get("recommended_action", "insufficient_evidence")
1556
1892
  cls = {"adopt": "adopt", "pilot": "pilot", "reject": "reject"}.get(action, "")
1557
1893
 
1558
- # 1. Strongest Supported Takeaway
1559
- if decision.get("strongest_support"):
1560
- strongest = decision.get("strongest_support")
1561
- elif can_claim:
1562
- strongest = can_claim[0]
1563
- elif supported_claims:
1564
- strongest = supported_claims[0]
1565
- else:
1566
- if lang == "zh":
1567
- strongest = f"即时编程任务编写耗时缩短 35%~50%,代码完成速度显著提升(综合效应量 g = +0.61, p < 0.001),在当堂受控实验中展现明显效率增益。"
1568
- else:
1569
- strongest = f"In-task programming completion time is shortened by 35%-50% with significant velocity gain (pooled g = +0.61, p < 0.001) in guided environments."
1570
-
1571
- # 2. Key Uncertainty / Contradictions
1572
- if decision.get("key_uncertainty"):
1573
- uncertainty = decision.get("key_uncertainty")
1574
- elif uncertain_claims:
1575
- uncertainty = uncertain_claims[0]
1576
- elif contradicted_claims:
1577
- uncertainty = contradicted_claims[0]
1578
- elif decision.get("reason_for_disagreement"):
1579
- uncertainty = decision.get("reason_for_disagreement")
1580
- else:
1581
- if lang == "zh":
1582
- uncertainty = "撤除 AI 后的独立闭卷期末考试与概念迁移表现显著下滑(综合效应量 g = -0.28, p = 0.012),学生存在‘看似学会、实则不会’的认知盲区。"
1583
- else:
1584
- uncertainty = "Delayed solo exams and independent transfer performance decline significantly (pooled g = -0.28, p = 0.012) once scaffolding is removed."
1585
-
1586
- # 3. Main Risk
1587
- if decision.get("main_risk"):
1588
- risk = decision.get("main_risk")
1589
- elif decision.get("risk_effect"):
1590
- risk = decision.get("risk_effect")
1591
- else:
1592
- if lang == "zh":
1593
- risk = "认知脚手架依赖陷阱(Scaffolding Dependency Trap):过度依赖实时代码补全导致学生自主调试排错、边界测试与底层计算思维出现退化。"
1594
- else:
1595
- risk = "Scaffolding Dependency Trap: Over-reliance on code completion degrades novice debugging, boundary testing, and fundamental computational thinking."
1596
-
1597
- # 4. Next Action / Policy Guidance
1598
- if decision.get("next_action"):
1599
- next_action = decision.get("next_action")
1600
- elif decision.get("next_steps"):
1601
- next_action = decision.get("next_steps")
1602
- else:
1603
- if lang == "zh":
1604
- next_action = "建议开展限制性教学试点:① 采用苏格拉底式概念引导,严禁直接给答案;② 实行 4 阶段脚手架渐进剥离;③ 坚持以无 AI 闭卷机试与独立随访作为最终考核标准。"
1605
- else:
1606
- next_action = "Execute restricted classroom pilot: ① Enforce Socratic guidance instead of direct code generation; ② Implement 4-phase scaffolding fading; ③ Anchor summative grading in unassisted closed-book exams."
1894
+ # These four are written by the adjudicator (schemas/verdict.schema.json).
1895
+ # The renderer must not synthesise them from claim fragments: assembling a
1896
+ # sentence out of list items is what made the first screen read as patched
1897
+ # together rather than written. A missing field is reported as missing,
1898
+ # which is also what the copy gate looks for.
1899
+ missing = ("该字段未产出(应由裁决角色撰写)。" if lang == "zh"
1900
+ else "Not produced by the adjudicator.")
1901
+ strongest = decision.get("strongest_support") or missing
1902
+ uncertainty = decision.get("key_uncertainty") or missing
1903
+ risk = decision.get("main_risk") or missing
1904
+ next_action = decision.get("next_action") or missing
1607
1905
 
1608
1906
  rationale = decision.get("decision_rationale") or decision.get("rationale") or ""
1609
1907
  rationale_html = expandable_text(rationale, ui["expand_details"], 380, "hero-rationale")
@@ -1762,6 +2060,30 @@ def effect_label(lang: str, value: Any) -> str:
1762
2060
  "neutral": "Null effect"}.get(effect, effect)
1763
2061
 
1764
2062
 
2063
+ def _quality_dims_text(lang: str, dims: Any) -> str:
2064
+ """Render quality dimensions as readable pairs (D1 Study design = 2)."""
2065
+ if not isinstance(dims, dict) or not dims:
2066
+ return ""
2067
+ DIM_LABELS_ZH = {
2068
+ "D1_study_design": "D1 研究设计", "D2_sample_quality": "D2 样本质量",
2069
+ "D3_measurement_validity": "D3 测量效度", "D4_temporal_strength": "D4 时间强度",
2070
+ "D5_directness": "D5 直接性",
2071
+ }
2072
+ DIM_LABELS_EN = {
2073
+ "D1_study_design": "D1 Study design", "D2_sample_quality": "D2 Sample quality",
2074
+ "D3_measurement_validity": "D3 Measurement validity",
2075
+ "D4_temporal_strength": "D4 Temporal strength", "D5_directness": "D5 Directness",
2076
+ }
2077
+ table = DIM_LABELS_ZH if lang == "zh" else DIM_LABELS_EN
2078
+ sep = " · " if lang == "zh" else " · "
2079
+ joiner = "=" if lang == "zh" else " = "
2080
+ parts = []
2081
+ for key, value in dims.items():
2082
+ name = table.get(key) or _humanize_identifier(str(key), lang)
2083
+ parts.append(f"{name}{joiner}{value}")
2084
+ return sep.join(parts)
2085
+
2086
+
1765
2087
  def render_evidence_detail(ev: dict, source: dict, lang: str, ui: dict) -> str:
1766
2088
  """Render complete traceable evidence detail without inventing missing fields."""
1767
2089
  labels = ({
@@ -1798,14 +2120,20 @@ def render_evidence_detail(ev: dict, source: dict, lang: str, ui: dict) -> str:
1798
2120
  ("study_id", ev.get("study_id")), ("sample_id", ev.get("sample_id")),
1799
2121
  ("title", ev.get("title")), ("source_title", source_title), ("year", source_year),
1800
2122
  ("study_type", label(lang, "study", ev.get("study_type") or "")),
1801
- ("education_level", ev.get("education_level")), ("population", ev.get("population")),
1802
- ("sample_size", ev.get("sample_size")), ("intervention", ev.get("intervention")),
1803
- ("comparison", ev.get("comparison")), ("outcome_measure", ev.get("outcome_measure")),
2123
+ ("education_level", _frame_value_label(lang, ev.get("education_level"))),
2124
+ ("population", _frame_value_label(lang, ev.get("population"))),
2125
+ ("sample_size", ev.get("sample_size")),
2126
+ ("intervention", _frame_value_label(lang, ev.get("intervention"))),
2127
+ ("comparison", _frame_value_label(lang, ev.get("comparison"))),
2128
+ ("outcome_measure", _frame_value_label(lang, ev.get("outcome_measure"))),
1804
2129
  ("effect", ev.get("effect")), ("effect_direction", effect_label(lang, ev.get("effect_direction"))),
1805
2130
  ("relation_to_claim", label(lang, "dir", ev.get("relation_to_claim") or ev.get("direction") or "neutral")),
1806
- ("duration", ev.get("duration")), ("method", ev.get("method")),
2131
+ ("duration", _frame_value_label(lang, ev.get("duration"))),
2132
+ ("method", _frame_value_label(lang, ev.get("method"))),
1807
2133
  ("strengths", ev.get("strengths")), ("limitations", ev.get("limitations")),
1808
- ("confounders", ev.get("confounders")), ("quality_dimensions", ev.get("quality_dimensions")),
2134
+ ("confounders", [_frame_value_label(lang, c) for c in (ev.get("confounders") or [])]
2135
+ if isinstance(ev.get("confounders"), list) else _frame_value_label(lang, ev.get("confounders"))),
2136
+ ("quality_dimensions", _quality_dims_text(lang, ev.get("quality_dimensions"))),
1809
2137
  ("quality_score", ev.get("quality_score")), ("evidence_level", ev.get("evidence_level")),
1810
2138
  ("directness", ev.get("directness")), ("applicability", ev.get("applicability")),
1811
2139
  ("confidence", ev.get("confidence")), ("status", label(lang, "status", ev.get("status") or "")),
@@ -2126,11 +2454,32 @@ def render_applicability(result: dict, lang: str, ui: dict) -> str:
2126
2454
  return "\n".join(out) or f"<p>{esc(ui['no_data'])}</p>"
2127
2455
 
2128
2456
 
2457
+ def _weeks_label(value: str, lang: str) -> str:
2458
+ """Render a week-range token for the reader, keeping data identical.
2459
+
2460
+ The bilingual contract requires the stored value to match across
2461
+ languages, so the Chinese form is produced here rather than stored.
2462
+ """
2463
+ text = str(value or "")
2464
+ if lang != "zh" or not text:
2465
+ return text
2466
+ import re as _re
2467
+ m = _re.fullmatch(r"W(\d+)(?:-W?(\d+))?", text)
2468
+ if not m:
2469
+ return text
2470
+ start, end = m.group(1), m.group(2)
2471
+ if end:
2472
+ return f"第 {start}-{end} 周"
2473
+ return f"第 {start} 周"
2474
+
2475
+
2129
2476
  def render_intervention(result: dict, svg: str, lang: str, ui: dict) -> str:
2130
2477
  intervention = result.get("intervention", {})
2131
2478
  if not intervention:
2132
2479
  return f"<p>{esc(ui['no_data'])}</p>"
2133
- lines = [f"<p><strong>{esc(ui['intervention_learners'])}{esc(ui['colon'])}</strong>{esc(intervention.get('target_learners'))} · "
2480
+ population = intervention.get("target_population") or intervention.get("target_learners")
2481
+ population_label = ("目标人群" if lang == "zh" else "Target population") if intervention.get("target_population") else ui['intervention_learners']
2482
+ lines = [f"<p><strong>{esc(population_label)}{esc(ui['colon'])}</strong>{esc(population)} · "
2134
2483
  f"<strong>{esc(ui['intervention_duration'])}{esc(ui['colon'])}</strong>{esc(intervention.get('pilot_duration'))}</p>"]
2135
2484
  if intervention.get("ai_usage_policy"):
2136
2485
  lines.append(f"<p><strong>{esc(ui['intervention_policy'])}{esc(ui['colon'])}</strong>{esc(intervention['ai_usage_policy'])}</p>")
@@ -2341,8 +2690,31 @@ def render_full_chapter(chapter_id: str, title: str, content: str, lead: str = "
2341
2690
 
2342
2691
 
2343
2692
  def frame_enum_label(lang: str, value: Any) -> str:
2693
+ """Label a frame enum value for display.
2694
+
2695
+ Registered values use the curated label. Anything else must still read as
2696
+ prose: a raw snake_case identifier in the report is a copy defect, so the
2697
+ fallback humanises it instead of leaking the storage form to the reader.
2698
+ """
2344
2699
  text = str(value or "")
2345
- return (FRAME_ENUM_ZH if lang == "zh" else FRAME_ENUM_EN).get(text, text)
2700
+ if not text:
2701
+ return ""
2702
+ table = FRAME_ENUM_ZH if lang == "zh" else FRAME_ENUM_EN
2703
+ if text in table:
2704
+ return table[text]
2705
+ return _humanize_identifier(text, lang)
2706
+
2707
+
2708
+ def _humanize_identifier(value: str, lang: str = "en") -> str:
2709
+ """Turn a snake_case / kebab-case token into a readable phrase.
2710
+
2711
+ Delegates to zh_labels.humanize_identifier so the report body and the
2712
+ charts share one display rule (curated label first, acronym-aware prose
2713
+ second) instead of leaking storage identifiers to the reader.
2714
+ """
2715
+ from zh_labels import humanize_identifier
2716
+
2717
+ return humanize_identifier(value, lang)
2346
2718
 
2347
2719
 
2348
2720
  def labeled_pairs(lang: str, data: dict, labels_zh: dict[str, str], labels_en: dict[str, str]) -> str:
@@ -2351,11 +2723,29 @@ def labeled_pairs(lang: str, data: dict, labels_zh: dict[str, str], labels_en: d
2351
2723
  for key, value in data.items():
2352
2724
  if value in (None, "", [], {}):
2353
2725
  continue
2354
- rendered = frame_enum_label(lang, value) if isinstance(value, str) else str(value)
2726
+ rendered = _frame_value_label(lang, value) if isinstance(value, str) else str(value)
2355
2727
  parts.append(f"{labels.get(key, key)}:{rendered}" if lang == "zh" else f"{labels.get(key, key)}: {rendered}")
2356
2728
  return ";".join(parts) if lang == "zh" else "; ".join(parts)
2357
2729
 
2358
2730
 
2731
+ def _frame_value_label(lang: str, value: Any) -> str:
2732
+ """Render a frame value for display.
2733
+
2734
+ Free prose passes through; a bare storage identifier is humanised so the
2735
+ comparison/success cards never show snake_case. Multi-word prose that
2736
+ merely contains an underscore is returned unchanged.
2737
+ """
2738
+ if value in (None, "", [], {}):
2739
+ return ""
2740
+ text_value = str(value)
2741
+ table = FRAME_ENUM_ZH if lang == "zh" else FRAME_ENUM_EN
2742
+ if text_value in table:
2743
+ return table[text_value]
2744
+ if " " in text_value.strip() or _HAS_CJK.search(text_value):
2745
+ return text_value
2746
+ return _humanize_identifier(text_value, lang)
2747
+
2748
+
2359
2749
  def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2360
2750
  frame = result.get("research_frame", {}) or {}
2361
2751
  learner = frame.get("learner", {}) or {}
@@ -2376,8 +2766,12 @@ def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2376
2766
  {"subject":"课程", "course_type":"课程类型", "duration":"课程周期"},
2377
2767
  {"subject":"Subject", "course_type":"Course type", "duration":"Duration"})
2378
2768
  intervention_text = labeled_pairs(lang, intervention,
2379
- {"ai_tool":"AI 工具", "allowed_usage":"允许使用", "frequency":"使用频率", "duration":"干预周期"},
2380
- {"ai_tool":"AI tool", "allowed_usage":"Allowed usage", "frequency":"Frequency", "duration":"Duration"})
2769
+ {"teaching_method": "干预方式", "ai_tool": "AI 工具", "allowed_usage": "允许使用",
2770
+ "frequency": "使用频率", "duration": "干预周期", "policy_name": "政策名称",
2771
+ "policy_type": "政策类型", "jurisdiction": "适用辖区", "mechanism": "作用机制"},
2772
+ {"teaching_method": "Method", "ai_tool": "AI tool", "allowed_usage": "Allowed usage",
2773
+ "frequency": "Frequency", "duration": "Duration", "policy_name": "Policy",
2774
+ "policy_type": "Policy type", "jurisdiction": "Jurisdiction", "mechanism": "Mechanism"})
2381
2775
  outcome_map = frame.get("outcomes", {}) or {}
2382
2776
  outcome_parts = []
2383
2777
  for group, values in outcome_map.items():
@@ -2390,19 +2784,29 @@ def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2390
2784
  for key, value in scope.items():
2391
2785
  if value in (None, "", [], {}):
2392
2786
  continue
2393
- if key == "study_types" and isinstance(value, list):
2394
- rendered = "、".join(label(lang, "study", str(v)) for v in value) if lang == "zh" else ", ".join(label(lang, "study", str(v)) for v in value)
2787
+ if isinstance(value, list):
2788
+ # Any list-valued scope field renders as a joined list. Only
2789
+ # study_types used to be unpacked, so a list-valued evidence_types
2790
+ # fell through to str() and printed its Python repr
2791
+ # ("['quasi Experimental', 'rct']") straight into the card.
2792
+ kind = "study" if key == "study_types" else ""
2793
+ parts = [label(lang, kind, str(v)) if kind else _frame_value_label(lang, v)
2794
+ for v in value]
2795
+ rendered = ("、".join(parts) if lang == "zh" else ", ".join(parts))
2395
2796
  else:
2396
2797
  rendered = frame_enum_label(lang, value)
2397
- field_label = (scope_labels_zh if lang == "zh" else scope_labels_en).get(key, key)
2798
+ field_label = (scope_labels_zh if lang == "zh" else scope_labels_en).get(
2799
+ key, _humanize_identifier(key, lang))
2398
2800
  scope_parts.append(f"{field_label}:{rendered}" if lang == "zh" else f"{field_label}: {rendered}")
2399
2801
  scope_text = ";".join(scope_parts) if lang == "zh" else "; ".join(scope_parts)
2400
2802
  cards = [
2401
2803
  (labels["question"], frame.get("question") or result.get("meta", {}).get("question")),
2402
2804
  (labels["learner"], learner_text), (labels["course"], course_text),
2403
- (labels["intervention"], intervention_text), (labels["comparison"], frame.get("comparison")),
2805
+ (labels["intervention"], intervention_text),
2806
+ (labels["comparison"], _frame_value_label(lang, frame.get("comparison"))),
2404
2807
  (labels["outcomes"], ";".join(outcome_parts) if lang == "zh" else "; ".join(outcome_parts)),
2405
- (labels["scope"], scope_text), (labels["success"], frame.get("success_condition")),
2808
+ (labels["scope"], scope_text),
2809
+ (labels["success"], _frame_value_label(lang, frame.get("success_condition"))),
2406
2810
  ]
2407
2811
  return '<div class="scope-grid">' + "".join(
2408
2812
  f'<article class="scope-card"><h3>{esc(title)}</h3>{expandable_text(text, ui["expand_details"], 260, "scope-text")}</article>'
@@ -2411,8 +2815,8 @@ def render_research_scope(result: dict, lang: str, ui: dict) -> str:
2411
2815
 
2412
2816
  def render_retrieval_context(result: dict, lang: str, ui: dict) -> str:
2413
2817
  frame = result.get("research_frame", {}) or {}
2414
- inclusion = frame.get("inclusion_criteria") or []
2415
- exclusion = frame.get("exclusion_criteria") or []
2818
+ inclusion = [_frame_value_label(lang, v) for v in (frame.get("inclusion_criteria") or [])]
2819
+ exclusion = [_frame_value_label(lang, v) for v in (frame.get("exclusion_criteria") or [])]
2416
2820
  provenance = result.get("provenance", {}) or {}
2417
2821
  sources = result.get("sources", []) or []
2418
2822
  source_ids = " ".join(f'<code>{esc(s.get("source_id"))}</code>' for s in sources)
@@ -2656,6 +3060,17 @@ def _lang_switcher(ui_zh: dict, ui_en: dict) -> str:
2656
3060
  "</div>")
2657
3061
 
2658
3062
 
3063
+ def script_json(value: object) -> str:
3064
+ """Serialize untrusted evidence/chart data for an inline script context."""
3065
+ return (json.dumps(value, ensure_ascii=False, allow_nan=False)
3066
+ .replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e")
3067
+ .replace("\u2028", "\\u2028").replace("\u2029", "\\u2029"))
3068
+
3069
+
3070
+ def _reader_asset(name: str) -> str:
3071
+ return (Path(__file__).resolve().parent.parent / "assets" / name).read_text(encoding="utf-8")
3072
+
3073
+
2659
3074
  def _enhancer_js(charts_zh: dict, charts_en: dict, result_en: dict) -> str:
2660
3075
  outcome_zh = next((c for c in charts_zh.get("charts", [])
2661
3076
  if c.get("chart_id") == "outcome-evidence-overview"), None)
@@ -2700,7 +3115,8 @@ def _enhancer_js(charts_zh: dict, charts_en: dict, result_en: dict) -> str:
2700
3115
  }}
2701
3116
  var savedLang = null;
2702
3117
  try {{ savedLang = localStorage.getItem('eduevidence-lang'); }} catch (e) {{}}
2703
- applyLang(savedLang === 'en' ? 'en' : 'zh');
3118
+ var queryLang = new URLSearchParams(window.location.search).get('lang');
3119
+ applyLang((queryLang || savedLang) === 'en' ? 'en' : 'zh');
2704
3120
  document.querySelectorAll('.lang-btn').forEach(function (btn) {{
2705
3121
  btn.addEventListener('click', function () {{ applyLang(btn.dataset.langTarget); }});
2706
3122
  }});
@@ -2721,7 +3137,8 @@ def _enhancer_js(charts_zh: dict, charts_en: dict, result_en: dict) -> str:
2721
3137
  }}
2722
3138
  var savedView = null;
2723
3139
  try {{ savedView = localStorage.getItem('eduevidence-report-view'); }} catch (e) {{}}
2724
- applyReportView(savedView === 'full' ? 'full' : 'brief');
3140
+ var queryView = new URLSearchParams(window.location.search).get('view');
3141
+ applyReportView((queryView || savedView) === 'full' ? 'full' : 'brief');
2725
3142
  document.querySelectorAll('.report-view-btn').forEach(function (btn) {{
2726
3143
  btn.addEventListener('click', function () {{ applyReportView(btn.dataset.reportView); }});
2727
3144
  }});
@@ -2803,12 +3220,12 @@ def _enhancer_js(charts_zh: dict, charts_en: dict, result_en: dict) -> str:
2803
3220
  window.eduevidenceCharts = window.eduevidenceCharts || {{}};
2804
3221
  window.eduevidenceCharts[containerId] = chart;
2805
3222
  }}
2806
- mountChart('chart-outcome-zh', {json.dumps(outcome_zh or {}, ensure_ascii=False)});
2807
- mountChart('chart-trace-zh', {json.dumps(trace_zh or {}, ensure_ascii=False)});
2808
- mountChart('chart-benchmark-zh', {json.dumps(benchmark_zh, ensure_ascii=False)});
2809
- mountChart('chart-outcome-en', {json.dumps(outcome_en or {}, ensure_ascii=False)});
2810
- mountChart('chart-trace-en', {json.dumps(trace_en or {}, ensure_ascii=False)});
2811
- mountChart('chart-benchmark-en', {json.dumps(benchmark_en, ensure_ascii=False)});
3223
+ mountChart('chart-outcome-zh', {script_json(outcome_zh or {})});
3224
+ mountChart('chart-trace-zh', {script_json(trace_zh or {})});
3225
+ mountChart('chart-benchmark-zh', {script_json(benchmark_zh)});
3226
+ mountChart('chart-outcome-en', {script_json(outcome_en or {})});
3227
+ mountChart('chart-trace-en', {script_json(trace_en or {})});
3228
+ mountChart('chart-benchmark-en', {script_json(benchmark_en)});
2812
3229
  }})();
2813
3230
  """
2814
3231
 
@@ -2870,7 +3287,12 @@ def render_lieflat_gallery_brief(result: dict, figures: dict, layout: dict,
2870
3287
  title = entry.get("title_zh" if zh else "title_en") or ""
2871
3288
  subtitle = entry.get("subtitle_zh" if zh else "subtitle_en") or ""
2872
3289
  caption = entry.get("caption_zh" if zh else "caption_en") or ""
2873
- source_line = f"{entry.get('catalog_ref', '')} · {entry.get('source', '')}".strip(" ·")
3290
+ # The caption names the catalogue figure and, in prose, what it was
3291
+ # drawn from. The raw bundle path ("evidence.year_x_outcome_counts") is
3292
+ # an internal pointer and must not reach the reader, so it goes through
3293
+ # the same humaniser as every other enum in the report.
3294
+ source_ref = _humanize_identifier(str(entry.get('source', '')), lang)
3295
+ source_line = f"{entry.get('catalog_ref', '')} · {source_ref}".strip(" ·")
2874
3296
  fig_type = entry.get("type", "")
2875
3297
  cards.append(
2876
3298
  f'<figure class="lieflat-card" data-lieflat data-visual="lieflat-{esc(fig_type)}" '
@@ -2931,15 +3353,25 @@ def render_body(result: dict, lang: str, ui: dict, charts: dict, infographics: d
2931
3353
  brief_blocks = [
2932
3354
  render_brief_block(*brief_titles["decision"], first_screen(result, lang, ui), "brief-decision"),
2933
3355
  ]
2934
- if lieflat_gallery_html:
2935
- brief_blocks.append(render_brief_block(*brief_titles["lieflat"], lieflat_gallery_html, "brief-lieflat"))
2936
3356
  brief_blocks.extend([
2937
3357
  render_brief_block(*brief_titles["outcomes"], render_outcomes_brief(result, outcome_chart, lang, ui, viz), "brief-outcomes"),
2938
3358
  render_brief_block(*brief_titles["tribunal"], render_tribunal_visual(result, "", "", lang, ui, compact=True), "brief-tribunal"),
2939
3359
  render_brief_block(*brief_titles["action"], render_evidence_to_action(result, lang, ui), "brief-action"),
2940
3360
  render_brief_block(*brief_titles["sources"], render_brief_sources(result, lang, ui), "brief-sources"),
2941
3361
  ])
3362
+ if lieflat_gallery_html:
3363
+ brief_blocks.insert(-1, render_brief_block(*brief_titles["lieflat"], lieflat_gallery_html, "brief-lieflat"))
2942
3364
  brief = "".join(brief_blocks)
3365
+ # Unique fragment targets per language, stable across theme variants.
3366
+ for section_key in ("decision", "outcomes", "tribunal", "action", "lieflat", "sources"):
3367
+ brief = brief.replace(f'class="brief-block brief-{section_key}"',
3368
+ f'class="brief-block brief-{section_key}" id="brief-{section_key}-{lang}"')
3369
+ nav_items = [("decision", brief_titles["decision"][0]), ("outcomes", brief_titles["outcomes"][0]),
3370
+ ("tribunal", brief_titles["tribunal"][0]), ("action", brief_titles["action"][0]),
3371
+ ("sources", brief_titles["sources"][0])]
3372
+ brief_nav = '<nav class="brief-navigation" aria-label="' + ("摘要导航" if lang == "zh" else "Brief navigation") + '">' + ''.join(
3373
+ f'<a href="#brief-{key}-{lang}"><span>{i:02d}</span>{esc(title)}</a>'
3374
+ for i, (key, title) in enumerate(nav_items, 1)) + '</nav>'
2943
3375
  full_report = render_full_report(result, lang, ui, charts, infographics, figures, viz)
2944
3376
  toc = render_full_toc(result, lang, ui)
2945
3377
 
@@ -2953,7 +3385,7 @@ def render_body(result: dict, lang: str, ui: dict, charts: dict, infographics: d
2953
3385
  else:
2954
3386
  origin_chip = ''
2955
3387
  meta_line = (f"{esc(ui['header_mode'])}{esc(label(lang, 'mode', meta.get('mode') or ''))}"
2956
- f" · {esc(ui['header_generated'])}{esc(meta.get('generated_at') or '')}"
3388
+ f" · {esc(ui['header_generated'])}{esc(str(meta.get('generated_at') or '')[:10])}"
2957
3389
  f" · {esc(ui['header_evidence'])}{len(result.get('evidence', []))}"
2958
3390
  f"{esc(ui['header_evidence_suffix'])}"
2959
3391
  f" · {esc(ui['header_sources'])}{len(result.get('sources', []))}"
@@ -2968,7 +3400,7 @@ def render_body(result: dict, lang: str, ui: dict, charts: dict, infographics: d
2968
3400
  <button type="button" class="report-view-btn" data-report-view="full" aria-pressed="false">{esc(ui['full_report'])}</button>
2969
3401
  </nav>
2970
3402
  </header>
2971
- <div class="report-page report-page-brief" data-report-page="brief">{brief}</div>
3403
+ <div class="report-page report-page-brief" data-report-page="brief">{brief_nav}<div class="brief-reading-content">{brief}</div></div>
2972
3404
  <div class="report-page report-page-full" data-report-page="full" hidden>
2973
3405
  <div class="full-report-intro"><h2>{esc(ui['full_report'])}</h2><p>{esc(ui['full_report_intro'])}</p></div>
2974
3406
  <div class="full-report-layout">{toc}<main class="full-report-content">{full_report}</main></div>
@@ -2993,18 +3425,25 @@ def render_html(result_en: dict, result_zh: dict, charts_zh: dict, charts_en: di
2993
3425
  <html lang="zh-CN" data-theme="{esc(theme)}">
2994
3426
  <head>
2995
3427
  <meta charset="utf-8">
3428
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'">
2996
3429
  {hash_meta}<meta name="viewport" content="width=device-width, initial-scale=1">
2997
3430
  <title>{esc(result_zh.get("meta", {}).get("question") or result_en.get("meta", {}).get("question") or "EduEvidence Evidence Report")}</title>
2998
3431
  <style>
2999
3432
  {_theme_css()}
3000
3433
  {_base_css()}
3001
3434
  {_motion_css()}
3435
+ {_reader_asset("reader.css")}
3002
3436
  </style>
3003
3437
  </head>
3004
3438
  <body>
3005
- <div class="controls">
3006
- <span class="generated-theme">{esc(THEME_DISPLAY[theme])}</span>
3007
- {_lang_switcher(UI_ZH, UI_EN)}
3439
+ <div class="reader-progress" aria-hidden="true"><span></span></div>
3440
+ <div class="controls reader-toolbar">
3441
+ <a class="reader-home" href="#" aria-label="Back to report start">EduEvidence<span class="generated-theme">{esc(THEME_DISPLAY[theme])}</span></a>
3442
+ <div class="reader-view-controls" role="group" aria-label="Report view">
3443
+ <button type="button" class="report-view-btn active" data-report-view="brief" data-copy="brief" aria-pressed="true">摘要</button>
3444
+ <button type="button" class="report-view-btn" data-report-view="full" data-copy="full" aria-pressed="false">完整报告</button>
3445
+ </div>
3446
+ <div class="reader-tools">{_lang_switcher(UI_ZH, UI_EN)}<button class="reader-print" type="button" data-copy="print">打印</button></div>
3008
3447
  </div>
3009
3448
  {body_zh}
3010
3449
  {body_en}
@@ -3013,6 +3452,7 @@ def render_html(result_en: dict, result_zh: dict, charts_zh: dict, charts_en: di
3013
3452
  </script>
3014
3453
  <script>
3015
3454
  {_enhancer_js(charts_zh, charts_en, result_en)}
3455
+ {_reader_asset("reader.js")}
3016
3456
  </script>
3017
3457
  </body>
3018
3458
  </html>