eduevidence 6.2.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/README.md +22 -13
  3. package/README.zh-CN.md +15 -8
  4. package/SKILL.md +10 -9
  5. package/benchmarks/evidence-library.json +277 -1
  6. package/docs/architecture.md +6 -3
  7. package/docs/j-ev-experimental.md +250 -0
  8. package/docs/reproducibility.md +138 -0
  9. package/domains/_neutral/copy/few_shots.json +21 -0
  10. package/domains/_neutral/copy/framing_lexicon.json +19 -0
  11. package/domains/_neutral/copy/module_labels.json +5 -0
  12. package/domains/_neutral/copy/module_labels_footer.json +102 -0
  13. package/domains/_neutral/copy/module_labels_modules.json +204 -0
  14. package/domains/_neutral/copy/module_labels_nav.json +126 -0
  15. package/domains/_neutral/copy/module_labels_summary.json +98 -0
  16. package/domains/_neutral/copy/module_labels_tables.json +164 -0
  17. package/domains/_neutral/copy/module_labels_v2.json +90 -0
  18. package/domains/_neutral/copy/risk_constructs.json +20 -0
  19. package/domains/_neutral/copy/section_titles.json +66 -0
  20. package/domains/_neutral/copy/terminology.json +11 -0
  21. package/domains/check_copy_packs.py +103 -0
  22. package/domains/education/copy/few_shots.json +22 -0
  23. package/domains/education/copy/framing_enums.json +167 -0
  24. package/domains/education/copy/framing_lexicon.json +166 -0
  25. package/domains/education/copy/module_labels.json +169 -0
  26. package/domains/education/copy/risk_constructs.json +48 -0
  27. package/domains/education/copy/section_titles.json +186 -0
  28. package/domains/education/copy/terminology.json +70 -0
  29. package/domains/education/manifest.json +1 -1
  30. package/domains/education/outcome_taxonomy.json +2 -2
  31. package/domains/manifest.json +1 -1
  32. package/domains/policy/copy/few_shots.json +22 -0
  33. package/domains/policy/copy/framing_enums.json +94 -0
  34. package/domains/policy/copy/framing_lexicon.json +174 -0
  35. package/domains/policy/copy/module_labels.json +168 -0
  36. package/domains/policy/copy/risk_constructs.json +33 -0
  37. package/domains/policy/copy/section_titles.json +186 -0
  38. package/domains/policy/copy/terminology.json +64 -0
  39. package/engine/capabilities.py +57 -5
  40. package/engine/decision_policy.py +88 -17
  41. package/engine/library_builtin.py +7 -4
  42. package/engine/tribunal.py +17 -23
  43. package/engine/versions.py +1 -1
  44. package/examples/ai-coding-assistant-evidence/EduEvidence_Report.html +4 -4
  45. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_academic.html +4 -4
  46. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_claude.html +4 -4
  47. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab-dark.html +4 -4
  48. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab.html +4 -4
  49. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_presentation.html +4 -4
  50. package/examples/spaced-retrieval-practice/EduEvidence_Report.html +2728 -0
  51. package/examples/spaced-retrieval-practice/report.html +2522 -0
  52. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_academic.html +4 -4
  53. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_claude.html +4 -4
  54. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab-dark.html +4 -4
  55. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab.html +4 -4
  56. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_presentation.html +4 -4
  57. package/examples/workplace-ai-assistant/EduEvidence_Report.html +2814 -0
  58. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_academic.html +36 -36
  59. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_claude.html +36 -36
  60. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab-dark.html +36 -36
  61. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab.html +36 -36
  62. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_presentation.html +36 -36
  63. package/integrations/jev/__init__.py +115 -0
  64. package/integrations/jev/approval.py +212 -0
  65. package/integrations/jev/cli.py +84 -0
  66. package/integrations/jev/config.py +112 -0
  67. package/integrations/jev/gateway.py +128 -0
  68. package/integrations/jev/modes.py +38 -0
  69. package/integrations/jev/tools_classify.py +88 -0
  70. package/integrations/jev/tools_extract.py +111 -0
  71. package/integrations/jev/tools_rerank.py +71 -0
  72. package/integrations/jev/tools_screen.py +87 -0
  73. package/integrations/jev/tools_verify.py +95 -0
  74. package/integrations/jev_mcp.py +22 -0
  75. package/integrations/semantic_decide.py +286 -0
  76. package/integrations/semdecide_cli.py +55 -0
  77. package/package.json +9 -1
  78. package/pyproject.toml +1 -1
  79. package/references/report-copy-style.md +43 -3
  80. package/schemas/v2/decision-snapshot.schema.json +20 -9
  81. package/schemas/v2/intake.schema.json +191 -0
  82. package/scripts/build_evidence_library.py +15 -5
  83. package/scripts/dashboard_server.py +13 -2
  84. package/scripts/intake/__init__.py +31 -0
  85. package/scripts/intake/__main__.py +18 -0
  86. package/scripts/intake/background.py +78 -0
  87. package/scripts/intake/browser.py +79 -0
  88. package/scripts/intake/cli.py +57 -0
  89. package/scripts/intake/constants.py +57 -0
  90. package/scripts/intake/depth.py +53 -0
  91. package/scripts/intake/enhancements.py +106 -0
  92. package/scripts/intake/hooks.py +90 -0
  93. package/scripts/intake/prefs.py +76 -0
  94. package/scripts/intake/prompts.py +85 -0
  95. package/scripts/intake/session.py +152 -0
  96. package/scripts/lint_file_layers.py +126 -0
  97. package/scripts/orchestrator.py +68 -17
  98. package/scripts/pre_verdict_gate.py +21 -7
  99. package/scripts/skill_lint.py +11 -1
  100. package/scripts/skill_payload.py +3 -3
  101. package/scripts/test_adversarial_empirical.py +70 -6
  102. package/skill/agents/evidence-judge.md +49 -7
  103. package/skill/workflows/experimental-jev.md +170 -0
  104. package/skill/workflows/intake.md +120 -0
  105. package/visualization/eduevidence-report/scripts/build_infographics.py +32 -14
  106. package/visualization/eduevidence-report/scripts/build_report.py +75 -662
  107. package/visualization/eduevidence-report/scripts/report_copy_pack.py +296 -0
  108. package/visualization/eduevidence-report/scripts/report_copy_policy_guard.py +47 -0
  109. package/visualization/eduevidence-report/scripts/zh_labels.py +61 -0
  110. package/scripts/build_esl_artifacts.py +0 -1921
  111. package/scripts/build_killer_demo.py +0 -295
  112. package/scripts/enrich_projects_human_and_lieflat.py +0 -315
  113. package/scripts/generate_new_projects.py +0 -686
  114. package/scripts/sync_killer_demo_report.py +0 -270
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env python3
2
+ """report_copy_pack.py — domain copy-pack loader for the report layer.
3
+
4
+ Loads domains/<id>/copy/*.json and exposes display lookups so build_report.py
5
+ stays free of research-voice dictionaries.
6
+ Domain: result.meta.domain → frame.extensions.domain (default education).
7
+ Unknown domain: neutral fallback + warning; policy packs reject teaching words.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ #: Canonical six pack files (task contract).
17
+ COPY_PACK_FILES = (
18
+ "framing_lexicon.json",
19
+ "section_titles.json",
20
+ "module_labels.json",
21
+ "terminology.json",
22
+ "risk_constructs.json",
23
+ "few_shots.json",
24
+ )
25
+ #: Optional topic-split companions (merged into the canonical view on load).
26
+ COMPANION_FILES = (
27
+ "framing_enums.json",
28
+ "module_labels_modules.json",
29
+ "module_labels_nav.json",
30
+ "module_labels_summary.json",
31
+ "module_labels_tables.json",
32
+ "module_labels_footer.json",
33
+ "module_labels_v2.json",
34
+ )
35
+ NEUTRAL_DOMAIN = "_neutral"
36
+ DEFAULT_DOMAIN = "education"
37
+
38
+ #: Chapter skeleton is structure (not copy); titles/leads come from the pack.
39
+ DEFAULT_CHAPTER_SKELETON = (
40
+ {"key": "decision", "modules": ("decision", "scope")},
41
+ {"key": "evidence", "modules": ("retrieval", "outcomes", "evidence")},
42
+ {"key": "quality", "modules": ("quality", "conflicts", "trace")},
43
+ {"key": "action", "modules": ("applicability", "intervention")},
44
+ {"key": "evaluation", "modules": ("evaluation",)},
45
+ {"key": "sources", "modules": ("sources",)},
46
+ )
47
+ FULL_REPORT_MODULES = (
48
+ "decision", "scope", "retrieval", "outcomes", "evidence", "quality", "conflicts",
49
+ "trace", "applicability", "intervention", "evaluation", "sources",
50
+ )
51
+
52
+ from report_copy_policy_guard import assert_policy_copy_clean # noqa: E402
53
+
54
+ _ACTIVE_PACK: dict[str, Any] = {}
55
+ _COPY_WARNINGS: list[str] = []
56
+
57
+ def _repo_root() -> Path:
58
+ return Path(__file__).resolve().parents[3]
59
+
60
+ def domains_root() -> Path:
61
+ repo = _repo_root() / "domains"
62
+ if (repo / "manifest.json").is_file():
63
+ return repo
64
+ share = Path(sys.prefix) / "share" / "eduevidence" / "domains"
65
+ return share if (share / "manifest.json").is_file() else repo
66
+
67
+ def resolve_domain(result: dict) -> tuple[str, list[str]]:
68
+ warnings: list[str] = []
69
+ meta = result.get("meta") or {}
70
+ frame = result.get("research_frame") or {}
71
+ ext = frame.get("extensions") or {}
72
+ domain = str(meta.get("domain") or ext.get("domain") or "").strip().lower()
73
+ if not domain:
74
+ warnings.append(
75
+ "result carries no meta.domain / frame.extensions.domain; "
76
+ f"defaulting to {DEFAULT_DOMAIN}"
77
+ )
78
+ return DEFAULT_DOMAIN, warnings
79
+ return domain, warnings
80
+
81
+
82
+ def load_copy_pack(domain: str, *, strict: bool = False) -> dict[str, Any]:
83
+ warnings: list[str] = []
84
+ root = domains_root() / domain / "copy"
85
+ pack: dict[str, Any] = {"domain": domain, "files": {}, "warnings": warnings}
86
+ if not root.is_dir():
87
+ known = sorted(
88
+ p.parent.parent.name for p in domains_root().glob("*/copy/module_labels.json")
89
+ if p.parent.parent.name != NEUTRAL_DOMAIN
90
+ )
91
+ msg = f"unknown domain {domain!r} (copy packs: {', '.join(known) or 'none'})"
92
+ if strict:
93
+ raise KeyError(msg)
94
+ warnings.append(msg + "; using neutral fallback copy")
95
+ pack["domain"] = NEUTRAL_DOMAIN
96
+ pack["files"] = load_copy_pack(NEUTRAL_DOMAIN, strict=True)["files"]
97
+ return pack
98
+ # optional topic-split companions first (module_labels_*.json, framing_enums.json)
99
+ for extra in sorted(root.glob("*.json")):
100
+ pack["files"][extra.name] = json.loads(extra.read_text(encoding="utf-8"))
101
+ missing = [name for name in COPY_PACK_FILES if name not in pack["files"]]
102
+ if missing:
103
+ msg = f"domain {domain!r} copy pack incomplete: missing {', '.join(missing)}"
104
+ if strict:
105
+ raise FileNotFoundError(msg)
106
+ warnings.append(msg + "; merging neutral fallback for gaps")
107
+ neutral = load_copy_pack(NEUTRAL_DOMAIN, strict=True)["files"]
108
+ for name in missing:
109
+ pack["files"][name] = neutral[name]
110
+ _merge_companions(pack["files"])
111
+ if domain == "policy":
112
+ assert_policy_copy_clean(pack["files"])
113
+ return pack
114
+
115
+
116
+ def _merge_companions(files: dict[str, Any]) -> None:
117
+ """Fold topic-split companions into the canonical six-file view."""
118
+ enums = files.get("framing_enums.json") or {}
119
+ lex = files.setdefault("framing_lexicon.json", {})
120
+ if enums.get("frame_enums"):
121
+ lex["frame_enums"] = enums["frame_enums"]
122
+ base = files.setdefault("module_labels.json", {})
123
+ base_labels = base.setdefault("labels", {})
124
+ for name, payload in list(files.items()):
125
+ if name.startswith("module_labels") and name != "module_labels.json":
126
+ base_labels.update((payload or {}).get("labels") or {})
127
+
128
+
129
+ def activate_copy_pack(result: dict, *, strict: bool = False) -> dict[str, Any]:
130
+ global _ACTIVE_PACK, _COPY_WARNINGS
131
+ domain, warnings = resolve_domain(result)
132
+ pack = load_copy_pack(domain, strict=strict)
133
+ _COPY_WARNINGS = list(warnings) + list(pack.get("warnings") or [])
134
+ for message in _COPY_WARNINGS:
135
+ print(f"WARNING: {message}", file=sys.stderr)
136
+ _ACTIVE_PACK = pack
137
+ return pack
138
+
139
+ def active_copy_pack() -> dict[str, Any]:
140
+ # Prefer the most recently activated pack; fall back to neutral.
141
+ return _ACTIVE_PACK or load_copy_pack(NEUTRAL_DOMAIN)
142
+
143
+ def copy_warnings() -> list[str]:
144
+ return list(_COPY_WARNINGS)
145
+
146
+ def _bilingual(files: dict, file_name: str, *keys, lang: str, default=None):
147
+ node = files.get(file_name) or {}
148
+ for key in keys:
149
+ node = node.get(key) if isinstance(node, dict) else None
150
+ if node is None:
151
+ return default if default is not None else {}
152
+ if isinstance(node, dict) and ("zh" in node or "en" in node):
153
+ return node.get(lang, default)
154
+ return node if node is not None else default
155
+
156
+ def build_ui(lang: str, pack: dict | None = None) -> dict:
157
+ from zh_labels import merge_ui
158
+
159
+ pack = pack or active_copy_pack()
160
+ files = pack.get("files") or {}
161
+ ui = merge_ui(lang, _bilingual(files, "module_labels.json", "labels", lang=lang, default={}))
162
+ titles = _bilingual(files, "section_titles.json", "section_titles", lang=lang, default={}) or {}
163
+ leads = _bilingual(files, "section_titles.json", "section_leads", lang=lang, default={}) or {}
164
+ if titles:
165
+ ui["section_titles"] = titles
166
+ if leads:
167
+ ui["section_leads"] = leads
168
+ risk = files.get("risk_constructs.json") or {}
169
+ for ui_key, risk_key in (
170
+ ("outcome_separation_title", "separation_title"),
171
+ ("outcome_sep_note", "separation_note"),
172
+ ("method_guard", "method_guard"),
173
+ ):
174
+ value = risk.get(risk_key) or {}
175
+ picked = value.get(lang) if isinstance(value, dict) else value
176
+ if picked:
177
+ ui[ui_key] = picked
178
+ principle = (risk.get("guard_principle") or {}).get(lang)
179
+ if principle:
180
+ ui["guard_principle"] = principle
181
+ return ui
182
+
183
+ def frame_enum_table(lang: str) -> dict[str, str]:
184
+ files = active_copy_pack().get("files") or {}
185
+ merged = dict(_bilingual(files, "framing_enums.json", "frame_enums", lang=lang, default={}) or {})
186
+ merged.update(_bilingual(files, "framing_lexicon.json", "frame_enums", lang=lang, default={}) or {})
187
+ return merged
188
+
189
+ def methodology_label_table(lang: str) -> dict[str, str]:
190
+ files = active_copy_pack().get("files") or {}
191
+ return dict(_bilingual(files, "terminology.json", "methodology_labels", lang=lang, default={}) or {})
192
+
193
+ def scope_field_labels(lang: str) -> dict[str, str]:
194
+ files = active_copy_pack().get("files") or {}
195
+ return dict(_bilingual(files, "framing_lexicon.json", "frame_fields", lang=lang, default={}) or {})
196
+
197
+ def scope_subfield_labels(lang: str, section: str) -> dict[str, str]:
198
+ files = active_copy_pack().get("files") or {}
199
+ sub = _bilingual(files, "framing_lexicon.json", "frame_subfields", lang=lang, default={}) or {}
200
+ return dict(sub.get(section) or {})
201
+
202
+ def evidence_detail_label_table(lang: str) -> dict[str, str]:
203
+ files = active_copy_pack().get("files") or {}
204
+ return dict(_bilingual(files, "framing_lexicon.json", "evidence_detail_labels", lang=lang, default={}) or {})
205
+
206
+ def outcome_group_map() -> dict[str, list[str]]:
207
+ files = active_copy_pack().get("files") or {}
208
+ groups = (files.get("risk_constructs.json") or {}).get("outcome_groups") or {}
209
+ return {key: list(values or []) for key, values in groups.items()}
210
+
211
+ def default_full_report_plan() -> list[dict[str, Any]]:
212
+ files = active_copy_pack().get("files") or {}
213
+ chapters = (files.get("section_titles.json") or {}).get("default_chapters") or []
214
+ by_key = {str(c.get("key") or ""): c for c in chapters if isinstance(c, dict)}
215
+ plan = []
216
+ for skeleton in DEFAULT_CHAPTER_SKELETON:
217
+ chapter = dict(skeleton)
218
+ source = by_key.get(skeleton["key"]) or {}
219
+ chapter["title_zh"] = source.get("title_zh") or skeleton["key"]
220
+ chapter["title_en"] = source.get("title_en") or skeleton["key"]
221
+ chapter["lead_zh"] = source.get("lead_zh") or ""
222
+ chapter["lead_en"] = source.get("lead_en") or ""
223
+ if source.get("modules"):
224
+ chapter["modules"] = tuple(
225
+ m for m in source["modules"] if m in FULL_REPORT_MODULES
226
+ ) or skeleton["modules"]
227
+ plan.append(chapter)
228
+ return plan
229
+
230
+ def default_leads(lang: str) -> dict[str, str]:
231
+ key = "lead_zh" if lang == "zh" else "lead_en"
232
+ return {chapter["key"]: chapter.get(key) or "" for chapter in default_full_report_plan()}
233
+
234
+ def brief_block_titles(lang: str) -> dict[str, tuple[str, str]]:
235
+ files = active_copy_pack().get("files") or {}
236
+ raw = _bilingual(files, "section_titles.json", "brief_blocks", lang=lang, default={}) or {}
237
+ out: dict[str, tuple[str, str]] = {}
238
+ for key, value in raw.items():
239
+ if isinstance(value, (list, tuple)) and len(value) >= 2:
240
+ out[key] = (str(value[0]), str(value[1]))
241
+ return out
242
+
243
+ # Infographic SVG titles/keywords now resolve through build_infographics(ui=...).
244
+ # retitle_infographics remains a thin residual for callers that still render
245
+ # with the adapter's built-in education defaults and no ui.
246
+ _SVG_TITLE_FIXES = {
247
+ "zh": {
248
+ "教学干预时间线": "intervention",
249
+ "评价设计流程": "evaluation",
250
+ },
251
+ "en": {
252
+ "Intervention Timeline": "intervention",
253
+ "Evaluation Design Flow": "evaluation",
254
+ },
255
+ }
256
+
257
+
258
+ def retitle_infographics(infographics: dict, lang: str, ui: dict) -> dict:
259
+ """Replace any leftover education-hardcoded SVG strings with domain copy.
260
+
261
+ Prefer build_infographics(..., ui=build_ui(lang)); this residual only
262
+ patches titles/keywords that still carry the built-in education defaults.
263
+ """
264
+ ui_keys = {
265
+ "intervention": ("svg_intervention_title", "Intervention Timeline"),
266
+ "evaluation": ("svg_evaluation_title", "Evaluation Design Flow"),
267
+ }
268
+ measures = list(ui.get("evaluation_measures") or [])
269
+ # EVAL_NODES in build_infographics: baseline/pre, post, retention, transfer.
270
+ node_fixes = {
271
+ "zh": {
272
+ "保持测试": measures[2] if len(measures) > 2 else "持续监测",
273
+ "迁移测试(无 AI)": measures[3] if len(measures) > 3 else "扩展评估",
274
+ "迁移测试": measures[3] if len(measures) > 3 else "扩展评估",
275
+ },
276
+ "en": {
277
+ "retention test": measures[2] if len(measures) > 2 else "Durability",
278
+ "transfer test (no AI)": measures[3] if len(measures) > 3 else "Scale-out",
279
+ "transfer test": measures[3] if len(measures) > 3 else "Scale-out",
280
+ },
281
+ }.get(lang, {})
282
+ out = {}
283
+ for key, svg in (infographics or {}).items():
284
+ text = svg or ""
285
+ for old, slot in _SVG_TITLE_FIXES.get(lang, {}).items():
286
+ if old not in text:
287
+ continue
288
+ ui_key, fallback = ui_keys.get(slot, (None, None))
289
+ replacement = (ui.get(ui_key) if ui_key else None) or fallback or old
290
+ if replacement and replacement != old:
291
+ text = text.replace(old, replacement)
292
+ for old, replacement in node_fixes.items():
293
+ if replacement and replacement != old:
294
+ text = text.replace(old, replacement)
295
+ out[key] = text
296
+ return out
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ """report_copy_policy_guard.py — teaching-vocabulary ban for the policy copy pack."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ #: Teaching / education-research vocabulary banned from the policy pack.
8
+ TEACHING_WORDS_ZH = (
9
+ "教学", "学习者", "目标学习者", "学习指标", "学习效果", "学习结果", "学习过程",
10
+ "学习能力", "学习收获", "学习动机", "学习 /", "AI 干预", "AI干预", "教师",
11
+ "课程情境", "教育阶段", "教学决策", "教学行动", "教学干预",
12
+ "任务表现 ≠ 学习效果", "任务表现≠学习效果", "保持测试", "迁移测试", "独立学习",
13
+ )
14
+ TEACHING_WORDS_EN = (
15
+ "AI intervention", "Target learners", "Learning metrics", "Learning effect",
16
+ "Learning outcome", "Teaching intervention", "Teaching decision", "Teaching action",
17
+ "learning gain", "student learning",
18
+ )
19
+
20
+
21
+ def iter_strings(node, path="$"):
22
+ if isinstance(node, dict):
23
+ for key, value in node.items():
24
+ yield from iter_strings(value, f"{path}.{key}")
25
+ elif isinstance(node, list):
26
+ for index, value in enumerate(node):
27
+ yield from iter_strings(value, f"{path}[{index}]")
28
+ elif isinstance(node, str):
29
+ yield path, node
30
+
31
+
32
+ def assert_policy_copy_clean(files: dict[str, Any]) -> None:
33
+ """Policy pack must not contain teaching vocabulary (fail-clear)."""
34
+ hits = []
35
+ for name, data in files.items():
36
+ for path, text in iter_strings(data):
37
+ for word in TEACHING_WORDS_ZH:
38
+ if word in text:
39
+ hits.append(f"{name} {path}: {word!r} in {text!r}")
40
+ lowered = text.lower()
41
+ for phrase in TEACHING_WORDS_EN:
42
+ if phrase.lower() in lowered:
43
+ hits.append(f"{name} {path}: {phrase!r} in {text!r}")
44
+ if hits:
45
+ raise AssertionError(
46
+ "policy copy pack contains teaching vocabulary:\n " + "\n ".join(hits)
47
+ )
@@ -9,6 +9,10 @@
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
12
16
  OUTCOME_ZH = {
13
17
  "knowledge_gain": "知识获得",
14
18
  "concept_understanding": "概念理解",
@@ -34,6 +38,12 @@ OUTCOME_ZH = {
34
38
  "task_performance": "任务表现",
35
39
  "programming_skill": "编程技能",
36
40
  "writing_skill": "写作技能",
41
+ # policy-domain outcome tokens (domains/policy/outcome_taxonomy.json)
42
+ "policy_effectiveness": "政策有效性",
43
+ "cost_effectiveness": "成本效益",
44
+ "equity": "公平性",
45
+ "feasibility": "可行性",
46
+ "implementation_risk": "实施风险",
37
47
  }
38
48
 
39
49
  STUDY_ZH = {
@@ -145,6 +155,12 @@ OUTCOME_EN = {
145
155
  "task_performance": "Task performance",
146
156
  "programming_skill": "Programming skill",
147
157
  "writing_skill": "Writing skill",
158
+ # policy-domain outcome tokens (domains/policy/outcome_taxonomy.json)
159
+ "policy_effectiveness": "Policy effectiveness",
160
+ "cost_effectiveness": "Cost-effectiveness",
161
+ "equity": "Equity",
162
+ "feasibility": "Feasibility",
163
+ "implementation_risk": "Implementation risk",
148
164
  }
149
165
  STUDY_EN = {
150
166
  "rct": "Randomized controlled trial",
@@ -322,3 +338,48 @@ def humanize_identifier(value: str, lang: str = "en") -> str:
322
338
  out.append(word[:1].upper() + word[1:])
323
339
  return " ".join(out)
324
340
 
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Neutral UI dictionary lives in domains/_neutral/copy/module_labels.json.
344
+ # Domain packs overlay it via merge_ui(); this module stays enum-label only.
345
+ # ---------------------------------------------------------------------------
346
+ _NEUTRAL_UI_PATH = Path(__file__).resolve().parents[3] / "domains" / "_neutral" / "copy" / "module_labels.json"
347
+
348
+
349
+ def load_neutral_ui() -> dict[str, dict]:
350
+ """Return {key: {"zh": ..., "en": ...}} for domain-neutral UI labels.
351
+
352
+ Neutral labels may be topic-split across module_labels*.json.
353
+ """
354
+ labels: dict[str, dict] = {}
355
+ root = _NEUTRAL_UI_PATH.parent
356
+ if not root.is_dir():
357
+ return labels
358
+ for path in sorted(root.glob("module_labels*.json")):
359
+ data = json.loads(path.read_text(encoding="utf-8"))
360
+ labels.update(data.get("labels") or {})
361
+ return labels
362
+
363
+
364
+ def merge_ui(lang: str, domain_labels: dict | None = None) -> dict:
365
+ """Neutral UI overlaid with domain module_labels for `lang`."""
366
+ base: dict[str, Any] = {}
367
+ for key, value in load_neutral_ui().items():
368
+ if isinstance(value, dict) and ("zh" in value or "en" in value):
369
+ picked = value.get(lang)
370
+ if picked is not None:
371
+ base[key] = picked
372
+ else:
373
+ base[key] = value
374
+ if not domain_labels:
375
+ return base
376
+ for key, value in domain_labels.items():
377
+ if key == "section_key_note":
378
+ continue
379
+ if isinstance(value, dict) and ("zh" in value or "en" in value):
380
+ picked = value.get(lang)
381
+ if picked is not None:
382
+ base[key] = picked
383
+ else:
384
+ base[key] = value
385
+ return base