eduevidence 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/README.zh-CN.md +434 -0
- package/SKILL.md +195 -0
- package/bin/eduevidence.js +127 -0
- package/domains/education/manifest.json +183 -0
- package/domains/education/outcome_taxonomy.json +127 -0
- package/domains/manifest.json +26 -0
- package/domains/policy/frame.schema.json +234 -0
- package/domains/policy/manifest.json +10 -0
- package/domains/policy/methodology_checklist.json +109 -0
- package/domains/policy/outcome_taxonomy.json +53 -0
- package/domains/policy/references/causal-identification.md +45 -0
- package/domains/policy/references/cost-evidence.md +44 -0
- package/domains/policy/references/equity.md +42 -0
- package/domains/policy/references/evidence-hierarchy.md +41 -0
- package/domains/policy/references/implementation-evidence.md +47 -0
- package/eduevidence_cli.py +26 -0
- package/engine/__init__.py +11 -0
- package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/bias.cpython-312.pyc +0 -0
- package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
- package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
- package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
- package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
- package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
- package/engine/__pycache__/events.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
- package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
- package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
- package/engine/__pycache__/ids.cpython-312.pyc +0 -0
- package/engine/__pycache__/library.cpython-312.pyc +0 -0
- package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
- package/engine/__pycache__/living.cpython-312.pyc +0 -0
- package/engine/__pycache__/log.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/migration.cpython-312.pyc +0 -0
- package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
- package/engine/__pycache__/paths.cpython-312.pyc +0 -0
- package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
- package/engine/__pycache__/planner.cpython-312.pyc +0 -0
- package/engine/__pycache__/project.cpython-312.pyc +0 -0
- package/engine/__pycache__/projections.cpython-312.pyc +0 -0
- package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
- package/engine/__pycache__/run.cpython-312.pyc +0 -0
- package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
- package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
- package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
- package/engine/__pycache__/update.cpython-312.pyc +0 -0
- package/engine/__pycache__/versions.cpython-312.pyc +0 -0
- package/engine/analysis.py +308 -0
- package/engine/bias.py +178 -0
- package/engine/briefs.py +106 -0
- package/engine/capabilities.py +99 -0
- package/engine/citation_check.py +192 -0
- package/engine/contracts.py +117 -0
- package/engine/datasets.py +165 -0
- package/engine/events.py +67 -0
- package/engine/evidence_graph.py +571 -0
- package/engine/evidence_review.py +88 -0
- package/engine/evidencecore.py +182 -0
- package/engine/gap_lens.py +132 -0
- package/engine/gaps.py +169 -0
- package/engine/graph_store.py +335 -0
- package/engine/graph_validate.py +87 -0
- package/engine/ids.py +77 -0
- package/engine/library.py +268 -0
- package/engine/library_builtin.py +301 -0
- package/engine/living.py +671 -0
- package/engine/log.py +39 -0
- package/engine/meta_analysis.py +333 -0
- package/engine/meta_synthesis.py +111 -0
- package/engine/migration.py +397 -0
- package/engine/mode_router.py +72 -0
- package/engine/paths.py +15 -0
- package/engine/pilot.py +368 -0
- package/engine/planner.py +126 -0
- package/engine/project.py +118 -0
- package/engine/projections.py +240 -0
- package/engine/robustness.py +109 -0
- package/engine/run.py +85 -0
- package/engine/semantics.py +135 -0
- package/engine/study_design.py +87 -0
- package/engine/synthesis.py +187 -0
- package/engine/tribunal.py +408 -0
- package/engine/update.py +113 -0
- package/engine/versions.py +12 -0
- package/install.sh +510 -0
- package/integrations/__init__.py +1 -0
- package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
- package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
- package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
- package/integrations/agent_mcp.py +856 -0
- package/integrations/smart_web_fetch.py +59 -0
- package/package.json +50 -0
- package/pyproject.toml +55 -0
- package/references/applicability-policy.md +88 -0
- package/references/education-framing.md +132 -0
- package/references/effect_size_formulas.md +35 -0
- package/references/evaluation-design.md +111 -0
- package/references/evidence-quality.md +79 -0
- package/references/grade_framework.md +29 -0
- package/references/intervention-design.md +98 -0
- package/references/methodology-audit.md +103 -0
- package/references/outcome-taxonomy.md +106 -0
- package/references/retrieval-protocol.md +142 -0
- package/references/skeptic-protocol.md +93 -0
- package/references/social_science_pitfalls.md +48 -0
- package/references/source-validity.md +140 -0
- package/references/tribunal-policy.md +112 -0
- package/references/wwc_standards.md +29 -0
- package/retrieval/__init__.py +1 -0
- package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
- package/retrieval/corpus_store.py +181 -0
- package/retrieval/dedupe.py +127 -0
- package/retrieval/failures.py +90 -0
- package/retrieval/fetch.py +435 -0
- package/retrieval/search.py +493 -0
- package/retrieval/source.py +160 -0
- package/retrieval/validate.py +257 -0
- package/schemas/agent-mcp-approval.schema.json +57 -0
- package/schemas/chart-spec.schema.json +88 -0
- package/schemas/cross-model-review.schema.json +28 -0
- package/schemas/education-frame.schema.json +106 -0
- package/schemas/evaluation.schema.json +35 -0
- package/schemas/evidence.schema.json +81 -0
- package/schemas/fetch-result.schema.json +119 -0
- package/schemas/intervention.schema.json +46 -0
- package/schemas/methodology.schema.json +102 -0
- package/schemas/report-result.schema.json +381 -0
- package/schemas/report-spec.schema.json +130 -0
- package/schemas/source.schema.json +311 -0
- package/schemas/v2/analysis-plan.schema.json +28 -0
- package/schemas/v2/analysis-run.schema.json +33 -0
- package/schemas/v2/claim.schema.json +26 -0
- package/schemas/v2/dataset-asset.schema.json +40 -0
- package/schemas/v2/decision-snapshot.schema.json +53 -0
- package/schemas/v2/evidence-link.schema.json +38 -0
- package/schemas/v2/finding.schema.json +47 -0
- package/schemas/v2/graph-revision.schema.json +30 -0
- package/schemas/v2/knowledge-gap.schema.json +40 -0
- package/schemas/v2/methodology-audit.schema.json +30 -0
- package/schemas/v2/outcome.schema.json +18 -0
- package/schemas/v2/project.schema.json +31 -0
- package/schemas/v2/research-intent.schema.json +24 -0
- package/schemas/v2/run.schema.json +43 -0
- package/schemas/v2/source.schema.json +24 -0
- package/schemas/v2/study-design.schema.json +67 -0
- package/schemas/v2/study.schema.json +37 -0
- package/schemas/v3/pilot-outcome.schema.json +132 -0
- package/schemas/v3/run-manifest.schema.json +193 -0
- package/schemas/v3/synthesis.schema.json +86 -0
- package/schemas/v4/drift-report.schema.json +66 -0
- package/schemas/v4/evidence-library.schema.json +42 -0
- package/schemas/v4/living-subscription.schema.json +55 -0
- package/schemas/v4/meta-analysis.schema.json +152 -0
- package/schemas/verdict.schema.json +56 -0
- package/scripts/__init__.py +0 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
- package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
- package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
- package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
- package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
- package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
- package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
- package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
- package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
- package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
- package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
- package/scripts/audit_dois.py +292 -0
- package/scripts/bake_pack.sh +37 -0
- package/scripts/benchmark.py +183 -0
- package/scripts/benchmark_evaluator.py +371 -0
- package/scripts/benchmark_judge.py +535 -0
- package/scripts/benchmark_routing.py +120 -0
- package/scripts/benchmark_v2.py +304 -0
- package/scripts/benchmark_v3.py +552 -0
- package/scripts/build_esl_artifacts.py +1921 -0
- package/scripts/build_evidence_library.py +307 -0
- package/scripts/build_killer_demo.py +295 -0
- package/scripts/build_result.py +311 -0
- package/scripts/check_version_consistency.py +96 -0
- package/scripts/citation_check.py +123 -0
- package/scripts/claim_audit.py +157 -0
- package/scripts/complexity_gate.py +180 -0
- package/scripts/compute_confidence.py +176 -0
- package/scripts/dashboard_server.py +536 -0
- package/scripts/did_regression.py +315 -0
- package/scripts/effect_calculator.py +99 -0
- package/scripts/enrich_projects_human_and_lieflat.py +315 -0
- package/scripts/evidence_matrix.py +129 -0
- package/scripts/evidence_score.py +234 -0
- package/scripts/evidence_semantics.py +87 -0
- package/scripts/fetch_benchmark.py +177 -0
- package/scripts/generate_metrics.py +99 -0
- package/scripts/generate_new_projects.py +686 -0
- package/scripts/generate_promo.py +192 -0
- package/scripts/lint_report_layout.py +182 -0
- package/scripts/orchestrator.py +1456 -0
- package/scripts/pre_verdict_gate.py +513 -0
- package/scripts/quickstart.py +121 -0
- package/scripts/rebake_all_5themes.py +88 -0
- package/scripts/recompute_demo_quality.py +205 -0
- package/scripts/render_report.py +270 -0
- package/scripts/render_report_html.py +356 -0
- package/scripts/retraction_watch.py +110 -0
- package/scripts/run_workspace.py +337 -0
- package/scripts/serve_web.py +54 -0
- package/scripts/skill_lint.py +150 -0
- package/scripts/startup_probe.py +265 -0
- package/scripts/sync_killer_demo_report.py +270 -0
- package/scripts/test_adversarial_empirical.py +541 -0
- package/scripts/validate_schema.py +256 -0
- package/skill/agents/education-planner.md +80 -0
- package/skill/agents/evaluation-designer.md +74 -0
- package/skill/agents/evidence-analyst.md +106 -0
- package/skill/agents/evidence-judge.md +111 -0
- package/skill/agents/evidence-retriever.md +80 -0
- package/skill/agents/intervention-designer.md +82 -0
- package/skill/agents/method-reviewer.md +104 -0
- package/skill/agents/skeptic.md +89 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
- package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
- package/skill/sub-skills/data-analysis/SKILL.md +23 -0
- package/skill/sub-skills/ethics-review/SKILL.md +25 -0
- package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
- package/skill/sub-skills/evidence-review/SKILL.md +18 -0
- package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
- package/skill/sub-skills/literature-review/SKILL.md +21 -0
- package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
- package/skill/sub-skills/report-generation/SKILL.md +51 -0
- package/skill/sub-skills/research-planning/SKILL.md +21 -0
- package/skill/sub-skills/study-design/SKILL.md +16 -0
- package/skill/task-briefs/adjudicate.md +17 -0
- package/skill/task-briefs/audit.md +15 -0
- package/skill/task-briefs/challenge.md +15 -0
- package/skill/task-briefs/evaluate.md +13 -0
- package/skill/task-briefs/extract.md +16 -0
- package/skill/task-briefs/frame.md +17 -0
- package/skill/task-briefs/intervene.md +14 -0
- package/skill/task-briefs/present.md +16 -0
- package/skill/task-briefs/retrieve.md +15 -0
- package/visualization/eduevidence-report/assets/base.css +337 -0
- package/visualization/eduevidence-report/motion/motion.css +157 -0
- package/visualization/eduevidence-report/motion/motion.js +107 -0
- package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
- package/visualization/eduevidence-report/references/component-catalog.md +145 -0
- package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
- package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
- package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
- package/visualization/eduevidence-report/references/motion-system.md +31 -0
- package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
- package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
- package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
- package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
- package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
- package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
- package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
- package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
- package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
- package/visualization/eduevidence-report/themes/academic.css +94 -0
- package/visualization/eduevidence-report/themes/claude.css +96 -0
- package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
- package/visualization/eduevidence-report/themes/datalab.css +151 -0
- package/visualization/eduevidence-report/themes/presentation.css +140 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""charts_data.py — The ONLY numeric source for Lieflat gallery charts.
|
|
3
|
+
|
|
4
|
+
Every extractor reads result.json and returns a normalized, JSON-serializable
|
|
5
|
+
data bundle whose values can all be traced back to result.json. When the data
|
|
6
|
+
is insufficient for an honest encoding, the extractor returns (None, reason)
|
|
7
|
+
and the caller suppresses the chart (mirrors the Meaningful Visualization Gate).
|
|
8
|
+
|
|
9
|
+
Honesty rules (per the Lieflat Charts codex):
|
|
10
|
+
- Never invent units or per-record values that do not exist in result.json.
|
|
11
|
+
- Declare derived statistics in the bundle so renderers can state them in
|
|
12
|
+
the subtitle (e.g. position = net direction share).
|
|
13
|
+
- Area encodings receive raw values; renderers apply sqrt, never here.
|
|
14
|
+
|
|
15
|
+
Bundles are language-neutral (raw keys); `lang` only affects display labels.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from zh_labels import label as _label, OUTCOME_ZH as _OUTCOME_ZH, OUTCOME_EN as _OUTCOME_EN, STUDY_ZH as _STUDY_ZH
|
|
23
|
+
|
|
24
|
+
# Methodology audit item display labels (local copy — charts_data must not
|
|
25
|
+
# import build_report, which imports the renderers that import this module).
|
|
26
|
+
_AUDIT_ZH = {
|
|
27
|
+
"control_group": "对照组", "randomization": "随机分配", "pre_test": "前测",
|
|
28
|
+
"post_test": "后测", "retention_test": "保持测试", "transfer_test": "迁移测试",
|
|
29
|
+
"sample_bias": "样本偏差", "self_selection": "自我选择偏差",
|
|
30
|
+
"measurement_validity": "测量效度", "confounders": "混杂因素",
|
|
31
|
+
"instructor_effect": "教师效应", "novelty_effect": "新奇效应",
|
|
32
|
+
"tool_version_effect": "工具版本效应", "ai_usage_policy": "AI 使用规则",
|
|
33
|
+
"dropout": "样本流失",
|
|
34
|
+
}
|
|
35
|
+
_AUDIT_EN = {
|
|
36
|
+
"control_group": "Control group", "randomization": "Randomization", "pre_test": "Pre-test",
|
|
37
|
+
"post_test": "Post-test", "retention_test": "Retention test", "transfer_test": "Transfer test",
|
|
38
|
+
"sample_bias": "Sample bias", "self_selection": "Self-selection",
|
|
39
|
+
"measurement_validity": "Measurement validity", "confounders": "Confounders",
|
|
40
|
+
"instructor_effect": "Instructor effect", "novelty_effect": "Novelty effect",
|
|
41
|
+
"tool_version_effect": "Tool version effect", "ai_usage_policy": "AI usage policy",
|
|
42
|
+
"dropout": "Dropout",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_PHASE_RANGE_EN = re.compile(r"weeks?\s*(\d+)\s*[-–—]\s*(\d+)", re.IGNORECASE)
|
|
46
|
+
_PHASE_RANGE_ZH = re.compile(r"第\s*(\d+)\s*[-–—]\s*(\d+)\s*周")
|
|
47
|
+
_SPAN_RE = re.compile(r"(\d+)\s*[-–—]?\s*week", re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def outcome_label(value: str, lang: str) -> str:
|
|
51
|
+
table = _OUTCOME_ZH if lang == "zh" else _OUTCOME_EN
|
|
52
|
+
return table.get(value, value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def audit_label(key: str, lang: str) -> str:
|
|
56
|
+
table = _AUDIT_ZH if lang == "zh" else _AUDIT_EN
|
|
57
|
+
return table.get(key, key.replace("_", " ").title())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _num(value: Any) -> Optional[float]:
|
|
61
|
+
"""Coerce a scalar to float; None for anything non-numeric."""
|
|
62
|
+
if value is None or isinstance(value, bool):
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
f = float(value)
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
return None
|
|
68
|
+
if f != f or f in (float("inf"), float("-inf")): # NaN guard
|
|
69
|
+
return None
|
|
70
|
+
return f
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _effect_of(ev: dict) -> Optional[dict]:
|
|
74
|
+
"""Return the effect-size dict of an evidence item, if numeric."""
|
|
75
|
+
es = ev.get("effect_size")
|
|
76
|
+
if isinstance(es, dict):
|
|
77
|
+
g = _num(es.get("value"))
|
|
78
|
+
if g is None:
|
|
79
|
+
return None
|
|
80
|
+
return {
|
|
81
|
+
"g": g,
|
|
82
|
+
"ci_lower": _num(es.get("ci_lower")),
|
|
83
|
+
"ci_upper": _num(es.get("ci_upper")),
|
|
84
|
+
"p_value": _num(es.get("p_value")),
|
|
85
|
+
"metric": es.get("metric") or "effect size",
|
|
86
|
+
}
|
|
87
|
+
g = _num(es)
|
|
88
|
+
if g is None:
|
|
89
|
+
return None
|
|
90
|
+
return {"g": g, "ci_lower": None, "ci_upper": None, "p_value": None, "metric": "effect size"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _short_label(ev: dict) -> str:
|
|
94
|
+
"""Short human label for a study: author surname first, else ID."""
|
|
95
|
+
raw = ev.get("study_label") or ev.get("study_id") or ev.get("evidence_id") or "study"
|
|
96
|
+
text = str(raw).strip()
|
|
97
|
+
if "(" in text:
|
|
98
|
+
text = text.split("(")[0]
|
|
99
|
+
if "," in text and re.match(r"^[A-Za-z]", text):
|
|
100
|
+
text = text.split(",")[0]
|
|
101
|
+
return text.strip()[:24]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# meta.forest — forest plot (Hedges' g + CI per study, optional pooled)
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def extract_meta_forest(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
109
|
+
studies: list[dict] = []
|
|
110
|
+
meta = result.get("meta") or {}
|
|
111
|
+
forest = meta.get("forest") or {}
|
|
112
|
+
fpd = forest.get("forest_plot_data") if isinstance(forest, dict) else None
|
|
113
|
+
if isinstance(fpd, list) and fpd:
|
|
114
|
+
for row in fpd:
|
|
115
|
+
if not isinstance(row, dict):
|
|
116
|
+
continue
|
|
117
|
+
g = _num(row.get("effect_size") if isinstance(row.get("effect_size"), (int, float))
|
|
118
|
+
else (row.get("effect_size") or {}).get("value"))
|
|
119
|
+
if g is None:
|
|
120
|
+
continue
|
|
121
|
+
studies.append({
|
|
122
|
+
"label": str(row.get("study_label") or "study"),
|
|
123
|
+
"dimension": str(row.get("outcome_dimension") or row.get("outcome_type") or "GENERAL"),
|
|
124
|
+
"g": g,
|
|
125
|
+
"ci_lower": _num(row.get("ci_lower")),
|
|
126
|
+
"ci_upper": _num(row.get("ci_upper")),
|
|
127
|
+
"n": _num(row.get("sample_size")),
|
|
128
|
+
"wwc": row.get("wwc_rating"),
|
|
129
|
+
})
|
|
130
|
+
else:
|
|
131
|
+
for ev in result.get("evidence") or []:
|
|
132
|
+
if not isinstance(ev, dict):
|
|
133
|
+
continue
|
|
134
|
+
fx = _effect_of(ev)
|
|
135
|
+
if fx is None:
|
|
136
|
+
continue
|
|
137
|
+
studies.append({
|
|
138
|
+
"label": _short_label(ev),
|
|
139
|
+
"dimension": str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL"),
|
|
140
|
+
"g": fx["g"],
|
|
141
|
+
"ci_lower": fx["ci_lower"],
|
|
142
|
+
"ci_upper": fx["ci_upper"],
|
|
143
|
+
"n": _num(ev.get("sample_size")),
|
|
144
|
+
"wwc": ev.get("wwc_rating"),
|
|
145
|
+
})
|
|
146
|
+
max_studies = params.get("max_studies", 10)
|
|
147
|
+
studies = sorted(studies, key=lambda s: abs(s["g"]), reverse=True)[:max_studies]
|
|
148
|
+
pooled = None
|
|
149
|
+
if isinstance(forest, dict):
|
|
150
|
+
pv = forest.get("pooled_effect") or forest.get("summary")
|
|
151
|
+
if isinstance(pv, dict):
|
|
152
|
+
g = _num(pv.get("value") if "value" in pv else pv.get("g"))
|
|
153
|
+
if g is not None:
|
|
154
|
+
pooled = {"g": g, "ci_lower": _num(pv.get("ci_lower")), "ci_upper": _num(pv.get("ci_upper"))}
|
|
155
|
+
if len(studies) < 3:
|
|
156
|
+
return None, f"fewer than 3 studies with numeric effect size (got {len(studies)})"
|
|
157
|
+
return {
|
|
158
|
+
"studies": studies,
|
|
159
|
+
"pooled": pooled,
|
|
160
|
+
"unit": "Hedges' g",
|
|
161
|
+
"label_zh": "效应量森林图", "label_en": "Effect-size forest plot",
|
|
162
|
+
}, None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# evidence.ranked_effects — L2 Dot Cascade
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def extract_ranked_effects(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
170
|
+
rows = []
|
|
171
|
+
for ev in result.get("evidence") or []:
|
|
172
|
+
if not isinstance(ev, dict):
|
|
173
|
+
continue
|
|
174
|
+
fx = _effect_of(ev)
|
|
175
|
+
if fx is None:
|
|
176
|
+
continue
|
|
177
|
+
rows.append({"label": _short_label(ev), "g": fx["g"], "n": _num(ev.get("sample_size")),
|
|
178
|
+
"dimension": str(ev.get("outcome_dimension") or ev.get("outcome_type") or "")})
|
|
179
|
+
if len(rows) < 3:
|
|
180
|
+
return None, f"fewer than 3 studies with numeric effect size (got {len(rows)})"
|
|
181
|
+
rows = sorted(rows, key=lambda r: r["g"], reverse=True)
|
|
182
|
+
limit = int(params.get("limit", 12))
|
|
183
|
+
rows = rows[:max(3, min(limit, 20))]
|
|
184
|
+
return {"studies": rows, "unit": "Hedges' g",
|
|
185
|
+
"label_zh": "效应量梯队", "label_en": "Ranked effect sizes"}, None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# evidence.year_x_dimension — L9 Bubble Almanac
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def extract_year_x_dimension(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
193
|
+
years: list[str] = []
|
|
194
|
+
dims: list[str] = []
|
|
195
|
+
cells: dict[tuple[str, str], dict] = {}
|
|
196
|
+
for ev in result.get("evidence") or []:
|
|
197
|
+
if not isinstance(ev, dict):
|
|
198
|
+
continue
|
|
199
|
+
year = ev.get("year")
|
|
200
|
+
if year is None:
|
|
201
|
+
continue
|
|
202
|
+
y = str(year)
|
|
203
|
+
dim = str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL")
|
|
204
|
+
if y not in years:
|
|
205
|
+
years.append(y)
|
|
206
|
+
if dim not in dims:
|
|
207
|
+
dims.append(dim)
|
|
208
|
+
cell = cells.setdefault((y, dim), {"n": 0, "sig": 0})
|
|
209
|
+
cell["n"] += 1
|
|
210
|
+
fx = _effect_of(ev)
|
|
211
|
+
if fx and fx.get("p_value") is not None and fx["p_value"] < 0.05:
|
|
212
|
+
cell["sig"] += 1
|
|
213
|
+
if len(years) < 2:
|
|
214
|
+
return None, f"fewer than 2 distinct publication years (got {len(years)})"
|
|
215
|
+
if len(dims) < 2:
|
|
216
|
+
return None, f"fewer than 2 outcome dimensions (got {len(dims)})"
|
|
217
|
+
years = sorted(years)
|
|
218
|
+
grid = []
|
|
219
|
+
for y in years:
|
|
220
|
+
for d in dims:
|
|
221
|
+
c = cells.get((y, d))
|
|
222
|
+
if c:
|
|
223
|
+
grid.append({"year": y, "dim": d, "n": c["n"], "sig": c["sig"]})
|
|
224
|
+
if len(grid) < 3:
|
|
225
|
+
return None, f"fewer than 3 populated year×dimension cells (got {len(grid)})"
|
|
226
|
+
return {"years": years, "dimensions": dims, "cells": grid,
|
|
227
|
+
"unit": "1 study", "label_zh": "年份 × 维度文献年历", "label_en": "Year × dimension almanac"}, None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# evidence.grouped_distribution — G15 Jitter Strip
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def extract_grouped_distribution(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
235
|
+
groups: dict[str, list[float]] = {}
|
|
236
|
+
for ev in result.get("evidence") or []:
|
|
237
|
+
if not isinstance(ev, dict):
|
|
238
|
+
continue
|
|
239
|
+
fx = _effect_of(ev)
|
|
240
|
+
if fx is None:
|
|
241
|
+
continue
|
|
242
|
+
key = str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL")
|
|
243
|
+
groups.setdefault(key, []).append(fx["g"])
|
|
244
|
+
keep = {k: v for k, v in groups.items() if len(v) >= 3}
|
|
245
|
+
if len(keep) < 2:
|
|
246
|
+
return None, (f"need at least 2 groups with 3+ effect sizes "
|
|
247
|
+
f"(got {len(keep)} groups: " + ", ".join(f"{k}={len(v)}" for k, v in keep.items()) + ")")
|
|
248
|
+
limit = int(params.get("limit", 60))
|
|
249
|
+
return {"groups": [{"label": k, "values": sorted(v)[:limit]} for k, v in keep.items()],
|
|
250
|
+
"unit": "Hedges' g", "label_zh": "分组效应量分布", "label_en": "Grouped effect distribution"}, None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# evidence.multidim_top — L20 Parallel Coordinates (g / N / quality / year)
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def extract_multidim_top(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
258
|
+
rows = []
|
|
259
|
+
for ev in result.get("evidence") or []:
|
|
260
|
+
if not isinstance(ev, dict):
|
|
261
|
+
continue
|
|
262
|
+
fx = _effect_of(ev)
|
|
263
|
+
q = _num(ev.get("quality_score"))
|
|
264
|
+
y = _num(ev.get("year"))
|
|
265
|
+
n = _num(ev.get("sample_size"))
|
|
266
|
+
if fx is None or q is None or y is None or n is None:
|
|
267
|
+
continue
|
|
268
|
+
rows.append({"label": _short_label(ev), "g": fx["g"], "n": n, "quality": q, "year": y})
|
|
269
|
+
if len(rows) < 3:
|
|
270
|
+
return None, f"fewer than 3 studies with g/N/quality/year (got {len(rows)})"
|
|
271
|
+
rows = sorted(rows, key=lambda r: abs(r["g"]), reverse=True)
|
|
272
|
+
limit = int(params.get("limit", 12))
|
|
273
|
+
rows = rows[:max(3, min(limit, 12))]
|
|
274
|
+
return {
|
|
275
|
+
"axes": [
|
|
276
|
+
{"key": "g", "label_zh": "效应量 g", "label_en": "Effect g"},
|
|
277
|
+
{"key": "n", "label_zh": "样本量 N", "label_en": "Sample N"},
|
|
278
|
+
{"key": "quality", "label_zh": "质量分", "label_en": "Quality"},
|
|
279
|
+
{"key": "year", "label_zh": "发表年份", "label_en": "Year"},
|
|
280
|
+
],
|
|
281
|
+
"rows": rows,
|
|
282
|
+
"label_zh": "跨维度平行坐标", "label_en": "Parallel coordinates",
|
|
283
|
+
}, None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# evidence.study_type_composition / evidence.wwc_composition — L14 / F4
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _composition_counts(result: dict, measure: str) -> list[dict]:
|
|
291
|
+
counts: dict[str, int] = {}
|
|
292
|
+
for ev in result.get("evidence") or []:
|
|
293
|
+
if not isinstance(ev, dict):
|
|
294
|
+
continue
|
|
295
|
+
if measure == "wwc":
|
|
296
|
+
value = ev.get("wwc_rating")
|
|
297
|
+
else:
|
|
298
|
+
value = ev.get("study_type") or ev.get("study_design")
|
|
299
|
+
if value in (None, ""):
|
|
300
|
+
continue
|
|
301
|
+
counts[str(value)] = counts.get(str(value), 0) + 1
|
|
302
|
+
return [{"label": k, "count": v} for k, v in sorted(counts.items(), key=lambda kv: -kv[1])]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def extract_study_type_composition(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
306
|
+
cats = _composition_counts(result, "study_type")
|
|
307
|
+
if len(cats) < 2:
|
|
308
|
+
return None, f"fewer than 2 study types recorded (got {len(cats)})"
|
|
309
|
+
total = sum(c["count"] for c in cats)
|
|
310
|
+
if total < 3:
|
|
311
|
+
return None, f"fewer than 3 studies with study type (got {total})"
|
|
312
|
+
return {"categories": cats, "total": total, "unit": "1 study",
|
|
313
|
+
"label_zh": "研究类型构成", "label_en": "Study type composition"}, None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def extract_wwc_composition(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
317
|
+
cats = _composition_counts(result, "wwc")
|
|
318
|
+
if len(cats) < 2:
|
|
319
|
+
return None, f"fewer than 2 WWC ratings recorded (got {len(cats)})"
|
|
320
|
+
total = sum(c["count"] for c in cats)
|
|
321
|
+
if total < 3:
|
|
322
|
+
return None, f"fewer than 3 studies with WWC rating (got {total})"
|
|
323
|
+
return {"categories": cats, "total": total, "unit": "1 study",
|
|
324
|
+
"label_zh": "WWC 评级构成", "label_en": "WWC rating composition"}, None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# outcomes.direction_counts — F5 Tick Rows / F1 Rung Bars
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def extract_direction_counts(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
332
|
+
rows = []
|
|
333
|
+
for o in result.get("outcomes") or []:
|
|
334
|
+
if not isinstance(o, dict):
|
|
335
|
+
continue
|
|
336
|
+
pos = int(o.get("positive_count") or 0)
|
|
337
|
+
neg = int(o.get("negative_count") or 0)
|
|
338
|
+
nul = int(o.get("null_count") or 0)
|
|
339
|
+
if pos + neg + nul <= 0:
|
|
340
|
+
continue
|
|
341
|
+
key = str(o.get("outcome_type") or f"outcome-{len(rows)}")
|
|
342
|
+
rows.append({"label": key,
|
|
343
|
+
"label_zh": outcome_label(key, "zh"), "label_en": outcome_label(key, "en"),
|
|
344
|
+
"positive": pos, "negative": neg, "null": nul})
|
|
345
|
+
wanted = params.get("outcomes")
|
|
346
|
+
if isinstance(wanted, list) and wanted:
|
|
347
|
+
rows = [r for r in rows if r["label"] in [str(w) for w in wanted]]
|
|
348
|
+
if len(rows) < 3:
|
|
349
|
+
return None, f"fewer than 3 outcomes with effect counts (got {len(rows)})"
|
|
350
|
+
return {"rows": rows, "unit": "1 evidence item",
|
|
351
|
+
"label_zh": "结果效应方向分布", "label_en": "Outcome direction counts"}, None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# outcomes.paired_counts — F6 Paired Rungs
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def extract_paired_counts(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
359
|
+
bundle, reason = extract_direction_counts(result, params, lang)
|
|
360
|
+
if bundle is None:
|
|
361
|
+
return None, reason
|
|
362
|
+
rows = [{"label": r["label"], "label_zh": r["label_zh"], "label_en": r["label_en"],
|
|
363
|
+
"positive": r["positive"], "negative": r["negative"]} for r in bundle["rows"]]
|
|
364
|
+
if sum(1 for r in rows if r["positive"] or r["negative"]) < 2:
|
|
365
|
+
return None, "fewer than 2 outcomes with a positive or negative count"
|
|
366
|
+
return {"rows": rows, "unit": "1 evidence item",
|
|
367
|
+
"label_zh": "正向 vs 负向配对", "label_en": "Positive vs negative pairs"}, None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# outcomes.bipolar_axes — L7 Brand Spectrum (position = net direction share)
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def extract_bipolar_axes(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
375
|
+
bundle, reason = extract_direction_counts(result, params, lang)
|
|
376
|
+
if bundle is None:
|
|
377
|
+
return None, reason
|
|
378
|
+
axes = []
|
|
379
|
+
for r in bundle["rows"]:
|
|
380
|
+
total = r["positive"] + r["negative"]
|
|
381
|
+
if total <= 0:
|
|
382
|
+
continue
|
|
383
|
+
net = (r["positive"] - r["negative"]) / total # -1 .. +1
|
|
384
|
+
axes.append({
|
|
385
|
+
"label": r["label"], "label_zh": r["label_zh"], "label_en": r["label_en"],
|
|
386
|
+
"position": round(0.5 + net / 2.0, 4), # 0 .. 1 bipolar position
|
|
387
|
+
"net": round(net, 4),
|
|
388
|
+
"positive": r["positive"], "negative": r["negative"], "null": r["null"],
|
|
389
|
+
})
|
|
390
|
+
if len(axes) < 2:
|
|
391
|
+
return None, f"fewer than 2 outcomes with direction counts (got {len(axes)})"
|
|
392
|
+
return {"axes": axes,
|
|
393
|
+
"left_zh": "负向主导", "left_en": "Negative-led",
|
|
394
|
+
"right_zh": "正向主导", "right_en": "Positive-led",
|
|
395
|
+
"derived": "position = (positive − negative) ÷ total direction counts",
|
|
396
|
+
"label_zh": "结果双极光谱", "label_en": "Outcome bipolar spectrum"}, None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# intervention.phase_weeks — L3 Barcode Lollipop (phase membership per week)
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def _phase_ranges(result: dict) -> list[dict]:
|
|
404
|
+
"""Parse phase_1..phase_4 week ranges from phase names (zh + en)."""
|
|
405
|
+
inter = result.get("intervention") or {}
|
|
406
|
+
phases = []
|
|
407
|
+
for i in range(1, 5):
|
|
408
|
+
ph = inter.get(f"phase_{i}")
|
|
409
|
+
if not isinstance(ph, dict):
|
|
410
|
+
continue
|
|
411
|
+
name = str(ph.get("name") or "")
|
|
412
|
+
m = _PHASE_RANGE_ZH.search(name) or _PHASE_RANGE_EN.search(name)
|
|
413
|
+
start, end = (int(m.group(1)), int(m.group(2))) if m else (None, None)
|
|
414
|
+
if start is None or end is None or end < start or end - start > 60:
|
|
415
|
+
continue
|
|
416
|
+
phases.append({"index": i, "start": start, "end": end, "name": name,
|
|
417
|
+
"activities": ph.get("activities") or []})
|
|
418
|
+
return phases
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def extract_phase_weeks(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
422
|
+
phases = _phase_ranges(result)
|
|
423
|
+
if len(phases) < 3:
|
|
424
|
+
return None, f"fewer than 3 phases with parseable week ranges (got {len(phases)})"
|
|
425
|
+
first = min(p["start"] for p in phases)
|
|
426
|
+
last = max(p["end"] for p in phases)
|
|
427
|
+
span = last - first + 1
|
|
428
|
+
if span < 8:
|
|
429
|
+
return None, f"phase span too short for a barcode ({span} weeks)"
|
|
430
|
+
weeks = []
|
|
431
|
+
for week in range(first, last + 1):
|
|
432
|
+
owner = next((p["index"] for p in phases if p["start"] <= week <= p["end"]), 0)
|
|
433
|
+
weeks.append({"week": week, "phase": owner})
|
|
434
|
+
peaks = [{"week": p["start"], "phase": p["index"],
|
|
435
|
+
"label_zh": f"第{p['index']}阶段开始", "label_en": f"Phase {p['index']} starts"}
|
|
436
|
+
for p in phases]
|
|
437
|
+
phase_labels = [
|
|
438
|
+
{"phase": p["index"], "label_zh": f"第{p['index']}阶段", "label_en": f"Phase {p['index']}",
|
|
439
|
+
"start": p["start"], "end": p["end"]}
|
|
440
|
+
for p in phases
|
|
441
|
+
]
|
|
442
|
+
return {
|
|
443
|
+
"weeks": weeks, "peaks": peaks, "phases": phase_labels, "first": first, "last": last,
|
|
444
|
+
"derived": "stem height = phase index (1–4); no per-week activity data exists in result.json",
|
|
445
|
+
"label_zh": "16 周阶段归属", "label_en": "Weekly phase membership",
|
|
446
|
+
}, None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
# intervention.activity_weights — L1 Launch Fan (weight = activity count)
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def extract_activity_weights(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
454
|
+
phases = _phase_ranges(result)
|
|
455
|
+
if not phases:
|
|
456
|
+
# fall back to phase dicts without ranges (still honest: weight = activity count)
|
|
457
|
+
inter = result.get("intervention") or {}
|
|
458
|
+
phases = []
|
|
459
|
+
for i in range(1, 5):
|
|
460
|
+
ph = inter.get(f"phase_{i}")
|
|
461
|
+
if isinstance(ph, dict):
|
|
462
|
+
phases.append({"index": i, "start": None, "end": None,
|
|
463
|
+
"name": str(ph.get("name") or ""), "activities": ph.get("activities") or []})
|
|
464
|
+
items = []
|
|
465
|
+
for p in phases:
|
|
466
|
+
w = len(p.get("activities") or [])
|
|
467
|
+
if w <= 0:
|
|
468
|
+
continue
|
|
469
|
+
name = p["name"].split(":")[0].split("(")[0].strip()
|
|
470
|
+
items.append({"label": name[:16] or f"phase {p['index']}", "w": w, "index": p["index"]})
|
|
471
|
+
if len(items) < 3:
|
|
472
|
+
return None, f"fewer than 3 phases with activities (got {len(items)})"
|
|
473
|
+
max_items = int(params.get("max_items", 8))
|
|
474
|
+
items = items[:max_items]
|
|
475
|
+
return {"items": items, "unit": "1 activity",
|
|
476
|
+
"derived": "head dot area = number of activities listed for the phase",
|
|
477
|
+
"label_zh": "阶段活动权重扇", "label_en": "Phase activity fan"}, None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ---------------------------------------------------------------------------
|
|
481
|
+
# intervention.phase_groups — L8 Dotty Matrix (1 cell = 1 activity per phase)
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def extract_phase_groups(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
485
|
+
phases = _phase_ranges(result)
|
|
486
|
+
layers = []
|
|
487
|
+
for p in phases:
|
|
488
|
+
acts = p.get("activities") or []
|
|
489
|
+
if len(acts) < 2:
|
|
490
|
+
continue
|
|
491
|
+
cells = [{"r": i % 6, "c": i // 6} for i in range(min(len(acts), 36))]
|
|
492
|
+
name = p["name"].split(":")[0].split("(")[0].strip()
|
|
493
|
+
layers.append({"label": name[:16] or f"phase {p['index']}", "cells": cells, "index": p["index"]})
|
|
494
|
+
if len(layers) < 3:
|
|
495
|
+
return None, f"fewer than 3 phases with 2+ activities (got {len(layers)})"
|
|
496
|
+
return {"layers": layers, "unit": "1 activity",
|
|
497
|
+
"derived": "one dot = one listed activity; intensity is uniform (no mastery data)",
|
|
498
|
+
"label_zh": "阶段活动点阵", "label_en": "Phase activity matrix"}, None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
# decision.confidence_score — F11 Tick Gauge
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
def extract_confidence_score(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
506
|
+
decision = result.get("decision") or {}
|
|
507
|
+
score = _num(decision.get("confidence_score"))
|
|
508
|
+
if score is None:
|
|
509
|
+
return None, "decision.confidence_score missing or non-numeric"
|
|
510
|
+
score = max(0.0, min(1.0, score))
|
|
511
|
+
return {"score": round(score, 4), "label": str(decision.get("confidence") or ""),
|
|
512
|
+
"label_zh": "决策置信度", "label_en": "Decision confidence"}, None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
# methodology.flag_rates — L15 Ballot Tally (1 tick = 1 audit verdict)
|
|
517
|
+
# ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
def extract_flag_rates(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
520
|
+
# tally per audit item: how many review entries mark it below "met"
|
|
521
|
+
tally: dict[str, dict] = {}
|
|
522
|
+
for review in result.get("methodology_reviews") or []:
|
|
523
|
+
if not isinstance(review, dict):
|
|
524
|
+
continue
|
|
525
|
+
items = review.get("audit_items") or {}
|
|
526
|
+
if not isinstance(items, dict):
|
|
527
|
+
continue
|
|
528
|
+
for key, entry in items.items():
|
|
529
|
+
status = (entry or {}).get("status") if isinstance(entry, dict) else None
|
|
530
|
+
if status is None:
|
|
531
|
+
continue
|
|
532
|
+
slot = tally.setdefault(key, {"total": 0, "flagged": 0})
|
|
533
|
+
slot["total"] += 1
|
|
534
|
+
if str(status).lower() not in ("met", "pass", "passed", "satisfied"):
|
|
535
|
+
slot["flagged"] += 1
|
|
536
|
+
items = [{"key": k, "total": v["total"], "flagged": v["flagged"]} for k, v in tally.items()]
|
|
537
|
+
items = [i for i in items if i["total"] >= 1]
|
|
538
|
+
if len(items) < 3 or not any(i["flagged"] for i in items):
|
|
539
|
+
return None, (f"audit tally insufficient for a ballot ({len(items)} items, "
|
|
540
|
+
f"{sum(i['flagged'] for i in items)} flagged)")
|
|
541
|
+
items = sorted(items, key=lambda i: (-i["flagged"] / i["total"], -i["total"]))
|
|
542
|
+
out = []
|
|
543
|
+
for i in items:
|
|
544
|
+
out.append({"label": i["key"],
|
|
545
|
+
"label_zh": audit_label(i["key"], "zh"), "label_en": audit_label(i["key"], "en"),
|
|
546
|
+
"total": i["total"], "flagged": i["flagged"]})
|
|
547
|
+
return {"items": out, "unit": "1 audit verdict",
|
|
548
|
+
"derived": "one tick = one methodology-review verdict; filled = below 'met'",
|
|
549
|
+
"label_zh": "审计未达标计票", "label_en": "Audit flag tally"}, None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ---------------------------------------------------------------------------
|
|
553
|
+
# evidence.year_x_outcome_counts — L16 Matrix Heat
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
def extract_year_x_outcome_counts(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
557
|
+
years: list[str] = []
|
|
558
|
+
outcomes: list[str] = []
|
|
559
|
+
counts: dict[tuple[str, str], int] = {}
|
|
560
|
+
for ev in result.get("evidence") or []:
|
|
561
|
+
if not isinstance(ev, dict):
|
|
562
|
+
continue
|
|
563
|
+
year = ev.get("year")
|
|
564
|
+
if year is None:
|
|
565
|
+
continue
|
|
566
|
+
y = str(year)
|
|
567
|
+
o = str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL")
|
|
568
|
+
if y not in years:
|
|
569
|
+
years.append(y)
|
|
570
|
+
if o not in outcomes:
|
|
571
|
+
outcomes.append(o)
|
|
572
|
+
counts[(y, o)] = counts.get((y, o), 0) + 1
|
|
573
|
+
if len(years) < 2:
|
|
574
|
+
return None, f"fewer than 2 years (got {len(years)})"
|
|
575
|
+
if len(outcomes) < 2:
|
|
576
|
+
return None, f"fewer than 2 outcomes (got {len(outcomes)})"
|
|
577
|
+
years = sorted(years)
|
|
578
|
+
cells = [[counts.get((y, o), 0) for y in years] for o in outcomes]
|
|
579
|
+
return {"years": years,
|
|
580
|
+
"outcomes": [{"label": o, "label_zh": outcome_label(o, "zh"), "label_en": outcome_label(o, "en")}
|
|
581
|
+
for o in outcomes],
|
|
582
|
+
"cells": cells, "unit": "1 study",
|
|
583
|
+
"label_zh": "年份 × 结果计数矩阵", "label_en": "Year × outcome count matrix"}, None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
# Dispatcher — used by lieflat_engine.REGISTRY
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
EXTRACTORS = {
|
|
591
|
+
"meta.forest": extract_meta_forest,
|
|
592
|
+
"evidence.ranked_effects": extract_ranked_effects,
|
|
593
|
+
"evidence.year_x_dimension": extract_year_x_dimension,
|
|
594
|
+
"evidence.grouped_distribution": extract_grouped_distribution,
|
|
595
|
+
"evidence.multidim_top": extract_multidim_top,
|
|
596
|
+
"evidence.study_type_composition": extract_study_type_composition,
|
|
597
|
+
"evidence.wwc_composition": extract_wwc_composition,
|
|
598
|
+
"outcomes.direction_counts": extract_direction_counts,
|
|
599
|
+
"outcomes.paired_counts": extract_paired_counts,
|
|
600
|
+
"outcomes.bipolar_axes": extract_bipolar_axes,
|
|
601
|
+
"intervention.phase_weeks": extract_phase_weeks,
|
|
602
|
+
"intervention.activity_weights": extract_activity_weights,
|
|
603
|
+
"intervention.phase_groups": extract_phase_groups,
|
|
604
|
+
"decision.confidence_score": extract_confidence_score,
|
|
605
|
+
"methodology.flag_rates": extract_flag_rates,
|
|
606
|
+
"evidence.year_x_outcome_counts": extract_year_x_outcome_counts,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def run_extractor(source: str, result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
|
|
611
|
+
fn = EXTRACTORS.get(source)
|
|
612
|
+
if fn is None:
|
|
613
|
+
return None, f"unknown extractor source {source!r}"
|
|
614
|
+
try:
|
|
615
|
+
return fn(result, params or {}, lang)
|
|
616
|
+
except Exception as exc: # extractors must never crash the report
|
|
617
|
+
return None, f"extractor error: {type(exc).__name__}: {exc}"
|