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,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""citation_check — per-citation DOI verification against registries (plan E6).
|
|
3
|
+
|
|
4
|
+
Turns the v5.2.0 integrity audit into a first-class engine capability:
|
|
5
|
+
|
|
6
|
+
- every Source/Evidence citation can carry `doi_verified` and `retracted`
|
|
7
|
+
fields, produced by this module against Crossref (most DOIs) and DataCite
|
|
8
|
+
(arXiv 10.48550/*);
|
|
9
|
+
- verification is evidence-grade: it never guesses. A DOI that does not
|
|
10
|
+
resolve is `not_found`; a resolved record whose title clearly differs from
|
|
11
|
+
the local title is `mismatch`; a registered retraction is surfaced as
|
|
12
|
+
`retracted=True`.
|
|
13
|
+
|
|
14
|
+
Security: identical discipline to scripts/audit_dois.py — strict DOI shape,
|
|
15
|
+
two allow-listed HTTPS registry hosts, redirects confined to those hosts.
|
|
16
|
+
|
|
17
|
+
Stdlib only. Network calls happen only in `verify()`/`check_doi`;
|
|
18
|
+
`classify()` is pure and fully unit-testable offline.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import http.client
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
CROSSREF_API = "https://api.crossref.org/works/{doi}"
|
|
33
|
+
DATACITE_API = "https://api.datacite.org/dois/{doi}"
|
|
34
|
+
ALLOWED_HOSTS = {"api.crossref.org", "api.datacite.org"}
|
|
35
|
+
DATACITE_PREFIXES = {"10.48550"}
|
|
36
|
+
MAILTO = "citation-check@eduevidence.local"
|
|
37
|
+
|
|
38
|
+
STRICT_DOI_RE = re.compile(r"^10\.\d{4,9}/[A-Za-z0-9._()/:-]+$")
|
|
39
|
+
DOI_CLEAN_RE = re.compile(r"\b(10\.\d{4,9}/[A-Za-z0-9._()/:;-]+)")
|
|
40
|
+
STRIP_SUFFIX = ".,;)"
|
|
41
|
+
|
|
42
|
+
TITLE_OVERLAP_THRESHOLD = 0.5
|
|
43
|
+
|
|
44
|
+
# Classification vocabulary used by callers and schemas.
|
|
45
|
+
STATUS_OK = "ok"
|
|
46
|
+
STATUS_MISMATCH = "mismatch"
|
|
47
|
+
STATUS_NOT_FOUND = "not_found"
|
|
48
|
+
STATUS_ERROR = "error"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clean_doi(doi: str) -> str:
|
|
52
|
+
return doi.strip().rstrip(STRIP_SUFFIX)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def token_set(text: str) -> set[str]:
|
|
56
|
+
out: set[str] = set()
|
|
57
|
+
for tok in re.split(r"[^a-z0-9]+", text.lower()):
|
|
58
|
+
if len(tok) > 2:
|
|
59
|
+
out.add(tok)
|
|
60
|
+
if tok.endswith("s") and len(tok) > 3:
|
|
61
|
+
out.add(tok[:-1])
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def title_overlap(a: str | None, b: str | None) -> float | None:
|
|
66
|
+
if not a or not b:
|
|
67
|
+
return None
|
|
68
|
+
ta, tb = token_set(a), token_set(b)
|
|
69
|
+
if not ta or not tb:
|
|
70
|
+
return None
|
|
71
|
+
return len(ta & tb) / min(len(ta), len(tb))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def classify(doi: str, registry_status: str, registry_msg: dict[str, Any] | None,
|
|
75
|
+
local_title: str | None) -> dict[str, Any]:
|
|
76
|
+
"""Pure classifier: registry outcome + optional local title → verdict dict."""
|
|
77
|
+
cr_title = None
|
|
78
|
+
if isinstance(registry_msg, dict):
|
|
79
|
+
titles = registry_msg.get("title") or []
|
|
80
|
+
cr_title = titles[0] if titles else None
|
|
81
|
+
status = registry_status
|
|
82
|
+
similarity = title_overlap(local_title, cr_title)
|
|
83
|
+
if status == STATUS_OK and similarity is not None and similarity < TITLE_OVERLAP_THRESHOLD:
|
|
84
|
+
status = STATUS_MISMATCH
|
|
85
|
+
|
|
86
|
+
retracted = False
|
|
87
|
+
if isinstance(registry_msg, dict) and status == STATUS_OK:
|
|
88
|
+
updates = registry_msg.get("update-to") or []
|
|
89
|
+
if isinstance(updates, list):
|
|
90
|
+
retracted = any(
|
|
91
|
+
isinstance(u, dict) and str(u.get("type", "")).lower() == "retraction"
|
|
92
|
+
for u in updates)
|
|
93
|
+
if not retracted:
|
|
94
|
+
# Some deposits mark retraction in the title itself ("Retracted: ...").
|
|
95
|
+
t = (cr_title or "").strip().lower()
|
|
96
|
+
retracted = t.startswith("retracted") or "(retracted" in t
|
|
97
|
+
|
|
98
|
+
result: dict[str, Any] = {
|
|
99
|
+
"doi": doi,
|
|
100
|
+
"status": status,
|
|
101
|
+
"doi_verified": status == STATUS_OK,
|
|
102
|
+
"retracted": retracted,
|
|
103
|
+
"registry_title": cr_title,
|
|
104
|
+
}
|
|
105
|
+
if similarity is not None:
|
|
106
|
+
result["title_overlap"] = round(similarity, 3)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _SameHostRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
111
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
112
|
+
if urllib.parse.urlparse(newurl).hostname not in ALLOWED_HOSTS:
|
|
113
|
+
return None
|
|
114
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RegistryClient:
|
|
118
|
+
"""Small cached HTTP client bound to the two registry hosts."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, timeout_s: int = 20, sleep_s: float = 0.25):
|
|
121
|
+
self.timeout_s = timeout_s
|
|
122
|
+
self.sleep_s = sleep_s
|
|
123
|
+
self.cache: dict[str, tuple[str, dict | None]] = {}
|
|
124
|
+
self.opener = urllib.request.build_opener(_SameHostRedirectHandler)
|
|
125
|
+
|
|
126
|
+
def fetch_raw(self, doi: str) -> tuple[str, dict | None]:
|
|
127
|
+
"""Return (registry_status, payload_message) for a cleaned DOI."""
|
|
128
|
+
if not STRICT_DOI_RE.match(doi):
|
|
129
|
+
return (STATUS_ERROR, {"detail": f"strict shape check failed: {doi!r}"})
|
|
130
|
+
template = DATACITE_API if doi.split("/", 1)[0] in DATACITE_PREFIXES else CROSSREF_API
|
|
131
|
+
url = template.format(doi=urllib.request.quote(doi, safe=""))
|
|
132
|
+
req = urllib.request.Request(url, headers={
|
|
133
|
+
"User-Agent": f"EduEvidence-citation-check/1.0 (mailto:{MAILTO})"})
|
|
134
|
+
result: tuple[str, dict | None] = (STATUS_ERROR, None)
|
|
135
|
+
for attempt in range(2):
|
|
136
|
+
try:
|
|
137
|
+
with self.opener.open(req, timeout=self.timeout_s) as resp:
|
|
138
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
139
|
+
message = payload.get("message", payload.get("data", {}))
|
|
140
|
+
if isinstance(message, dict) and "attributes" in message:
|
|
141
|
+
attrs = message["attributes"]
|
|
142
|
+
titles = [t.get("title") for t in attrs.get("titles", []) if t.get("title")]
|
|
143
|
+
message = {"title": titles}
|
|
144
|
+
result = (STATUS_OK, message)
|
|
145
|
+
break
|
|
146
|
+
except urllib.error.HTTPError as exc:
|
|
147
|
+
result = ((STATUS_NOT_FOUND, {"status": exc.code}) if exc.code == 404
|
|
148
|
+
else (STATUS_ERROR, {"status": exc.code}))
|
|
149
|
+
break
|
|
150
|
+
except (urllib.error.URLError, http.client.RemoteDisconnected, ConnectionError,
|
|
151
|
+
TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
152
|
+
result = (STATUS_ERROR, {"detail": str(exc)})
|
|
153
|
+
if attempt == 0:
|
|
154
|
+
time.sleep(1.5)
|
|
155
|
+
time.sleep(self.sleep_s)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def check(self, doi: str, local_title: str | None = None) -> dict[str, Any]:
|
|
159
|
+
doi = clean_doi(doi)
|
|
160
|
+
cache_key = (doi, local_title or "")
|
|
161
|
+
if cache_key not in self.cache:
|
|
162
|
+
status, msg = self.fetch_raw(doi)
|
|
163
|
+
self.cache[cache_key] = classify(doi, status, msg, local_title)
|
|
164
|
+
return self.cache[cache_key]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def extract_dois(record: dict[str, Any]) -> list[tuple[str, str | None]]:
|
|
168
|
+
"""Pull (doi, paired_title) pairs from a flat artifact record."""
|
|
169
|
+
pairs: list[tuple[str, str | None]] = []
|
|
170
|
+
doi_val = None
|
|
171
|
+
title_val = None
|
|
172
|
+
for key, val in record.items():
|
|
173
|
+
if not isinstance(val, str):
|
|
174
|
+
continue
|
|
175
|
+
k = key.strip().lower()
|
|
176
|
+
if k.endswith("_doi") or k == "doi":
|
|
177
|
+
d = clean_doi(val)
|
|
178
|
+
if STRICT_DOI_RE.match(d):
|
|
179
|
+
doi_val = d
|
|
180
|
+
elif k == "title" or k == "source_title":
|
|
181
|
+
title_val = val
|
|
182
|
+
if doi_val:
|
|
183
|
+
pairs.append((doi_val, title_val))
|
|
184
|
+
else:
|
|
185
|
+
for key, val in record.items():
|
|
186
|
+
if isinstance(val, str):
|
|
187
|
+
for raw in DOI_CLEAN_RE.findall(val):
|
|
188
|
+
d = clean_doi(raw)
|
|
189
|
+
if STRICT_DOI_RE.match(d):
|
|
190
|
+
pairs.append((d, title_val))
|
|
191
|
+
break
|
|
192
|
+
return pairs
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""V2 schema registry and validation helpers.
|
|
2
|
+
|
|
3
|
+
Reuses the repository's existing zero-dependency Draft-07 validator
|
|
4
|
+
(`scripts.validate_schema`) — the engine layer never prints; errors are
|
|
5
|
+
returned as stable strings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from scripts.validate_schema import SchemaError, validate
|
|
14
|
+
|
|
15
|
+
_REPO_SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schemas" / "v2"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve_schema_dir() -> Path:
|
|
19
|
+
"""Repository layout first; wheel-installed share/ layout as fallback."""
|
|
20
|
+
if _REPO_SCHEMA_DIR.is_dir():
|
|
21
|
+
return _REPO_SCHEMA_DIR
|
|
22
|
+
import sys
|
|
23
|
+
share = Path(sys.prefix) / "share" / "eduevidence" / "schemas" / "v2"
|
|
24
|
+
if share.is_dir():
|
|
25
|
+
return share
|
|
26
|
+
return _REPO_SCHEMA_DIR
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SCHEMA_DIR = _resolve_schema_dir()
|
|
30
|
+
|
|
31
|
+
# name -> schema file name (frozen registry)
|
|
32
|
+
_REGISTRY: dict[str, str] = {
|
|
33
|
+
"research-intent": "research-intent.schema.json",
|
|
34
|
+
"project": "project.schema.json",
|
|
35
|
+
"run": "run.schema.json",
|
|
36
|
+
"source": "source.schema.json",
|
|
37
|
+
"study": "study.schema.json",
|
|
38
|
+
"finding": "finding.schema.json",
|
|
39
|
+
"outcome": "outcome.schema.json",
|
|
40
|
+
"claim": "claim.schema.json",
|
|
41
|
+
"evidence-link": "evidence-link.schema.json",
|
|
42
|
+
"methodology-audit": "methodology-audit.schema.json",
|
|
43
|
+
"graph-revision": "graph-revision.schema.json",
|
|
44
|
+
"knowledge-gap": "knowledge-gap.schema.json",
|
|
45
|
+
"study-design": "study-design.schema.json",
|
|
46
|
+
"dataset-asset": "dataset-asset.schema.json",
|
|
47
|
+
"analysis-plan": "analysis-plan.schema.json",
|
|
48
|
+
"analysis-run": "analysis-run.schema.json",
|
|
49
|
+
"decision-snapshot": "decision-snapshot.schema.json",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_cache: dict[str, dict] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def schema_path(name: str) -> Path:
|
|
56
|
+
"""Resolve a registered schema name to its file; raise FileNotFoundError."""
|
|
57
|
+
if name not in _REGISTRY:
|
|
58
|
+
raise FileNotFoundError(f"unknown V2 schema name {name!r}")
|
|
59
|
+
return SCHEMA_DIR / _REGISTRY[name]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_schema(name: str) -> dict:
|
|
63
|
+
"""Load (cached) the schema document for a registered name."""
|
|
64
|
+
if name not in _cache:
|
|
65
|
+
_cache[name] = __import__("json").loads(schema_path(name).read_text(encoding="utf-8"))
|
|
66
|
+
return _cache[name]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reload_schemas() -> None:
|
|
70
|
+
"""Drop the schema cache so later loads re-read schema files.
|
|
71
|
+
|
|
72
|
+
Needed after schema files change at runtime (e.g. tests that mutate a
|
|
73
|
+
schema to prove briefs are schema-derived). Use this instead of reaching
|
|
74
|
+
into the private `_cache`.
|
|
75
|
+
"""
|
|
76
|
+
_cache.clear()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_record(name: str, record: dict) -> list[str]:
|
|
80
|
+
"""Validate `record` against the named V2 schema.
|
|
81
|
+
|
|
82
|
+
Returns a list of stable error strings (empty == valid). Errors are
|
|
83
|
+
collected by re-validating per-property so a single record yields all
|
|
84
|
+
violations, not just the first.
|
|
85
|
+
"""
|
|
86
|
+
if name not in _REGISTRY:
|
|
87
|
+
raise FileNotFoundError(f"unknown V2 schema name {name!r}")
|
|
88
|
+
schema = load_schema(name)
|
|
89
|
+
errors: list[str] = []
|
|
90
|
+
try:
|
|
91
|
+
validate(record, schema)
|
|
92
|
+
except SchemaError as exc:
|
|
93
|
+
errors.append(str(exc))
|
|
94
|
+
_collect_property_errors(record, schema, errors)
|
|
95
|
+
return errors
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _collect_property_errors(record: dict, schema: dict, errors: list[str]) -> None:
|
|
99
|
+
"""Best-effort per-property error collection after a whole-record failure.
|
|
100
|
+
|
|
101
|
+
Individual-property validation keeps errors stable and granular without
|
|
102
|
+
changing the validator's semantics.
|
|
103
|
+
"""
|
|
104
|
+
props = schema.get("properties", {})
|
|
105
|
+
for key, prop_schema in props.items():
|
|
106
|
+
if key in record:
|
|
107
|
+
try:
|
|
108
|
+
validate(record[key], prop_schema, f"$.{key}")
|
|
109
|
+
except SchemaError as exc:
|
|
110
|
+
if str(exc) not in errors:
|
|
111
|
+
errors.append(str(exc))
|
|
112
|
+
required = schema.get("required", [])
|
|
113
|
+
for key in required:
|
|
114
|
+
if key not in record:
|
|
115
|
+
msg = f"$: required property {key!r} is missing"
|
|
116
|
+
if msg not in errors:
|
|
117
|
+
errors.append(msg)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Immutable DatasetAsset ingest + privacy gate.
|
|
2
|
+
|
|
3
|
+
Raw bytes are copied once into datasets/raw/<dataset_id>/ and SHA-256 hashed;
|
|
4
|
+
re-ingesting identical bytes deduplicates by hash inside the same Project.
|
|
5
|
+
Raw user data never enters the Shared Research Library. Missing privacy
|
|
6
|
+
classification fails the gate; deidentification_required=True with
|
|
7
|
+
deidentification_status=not_done blocks analysis. Raw files are never
|
|
8
|
+
mutated after ingest.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from engine.contracts import validate_record
|
|
21
|
+
from engine.ids import new_local_id
|
|
22
|
+
from engine.project import ProjectWorkspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _now_iso() -> str:
|
|
26
|
+
return datetime.now(timezone.utc).isoformat()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sha256(path: Path) -> str:
|
|
30
|
+
h = hashlib.sha256()
|
|
31
|
+
with path.open("rb") as f:
|
|
32
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
33
|
+
h.update(chunk)
|
|
34
|
+
return h.hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def derive_csv_profile(path: Path) -> dict:
|
|
38
|
+
"""Row/column count + per-column missingness without pandas."""
|
|
39
|
+
columns: list[str] | None = None
|
|
40
|
+
row_count = 0
|
|
41
|
+
missing: dict[str, int] = {}
|
|
42
|
+
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
|
43
|
+
reader = csv.reader(f)
|
|
44
|
+
for i, row in enumerate(reader):
|
|
45
|
+
if i == 0:
|
|
46
|
+
columns = row
|
|
47
|
+
for c in columns:
|
|
48
|
+
missing[c] = 0
|
|
49
|
+
continue
|
|
50
|
+
row_count += 1
|
|
51
|
+
if columns is not None:
|
|
52
|
+
for idx, c in enumerate(columns):
|
|
53
|
+
if idx >= len(row) or row[idx].strip() == "":
|
|
54
|
+
missing[c] += 1
|
|
55
|
+
if columns is None:
|
|
56
|
+
return {"row_count": 0, "column_count": 0, "missingness": {}}
|
|
57
|
+
return {
|
|
58
|
+
"row_count": row_count,
|
|
59
|
+
"column_count": len(columns),
|
|
60
|
+
"missingness": {c: missing[c] for c in columns},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_dataset_asset(project: ProjectWorkspace, asset: dict) -> list[str]:
|
|
65
|
+
"""Privacy gate + schema + project locality."""
|
|
66
|
+
errors: list[str] = []
|
|
67
|
+
schema_errors = validate_record("dataset-asset", asset)
|
|
68
|
+
if schema_errors:
|
|
69
|
+
errors.extend(schema_errors)
|
|
70
|
+
return errors
|
|
71
|
+
if asset.get("project_id") != project.project_id:
|
|
72
|
+
errors.append(
|
|
73
|
+
f"asset project_id {asset.get('project_id')} != workspace "
|
|
74
|
+
f"{project.project_id}")
|
|
75
|
+
return errors
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ingest_dataset(project: ProjectWorkspace, *, design_id: str,
|
|
79
|
+
source_path: Path, privacy: dict,
|
|
80
|
+
variable_dictionary: dict[str, str] | None = None) -> dict:
|
|
81
|
+
"""Ingest raw dataset bytes immutably; returns the DatasetAsset dict."""
|
|
82
|
+
source_path = Path(source_path).expanduser().resolve()
|
|
83
|
+
if not source_path.is_file():
|
|
84
|
+
raise FileNotFoundError(f"dataset source missing: {source_path}")
|
|
85
|
+
|
|
86
|
+
# privacy gate: classification required
|
|
87
|
+
classification = privacy.get("classification")
|
|
88
|
+
if classification not in ("public", "internal", "confidential", "restricted"):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"missing/invalid privacy classification {classification!r}; "
|
|
91
|
+
f"dataset ingest blocked")
|
|
92
|
+
|
|
93
|
+
content_hash = _sha256(source_path)
|
|
94
|
+
|
|
95
|
+
# dedupe by hash inside this project; a stricter classification request
|
|
96
|
+
# must never silently rebind to a weaker stored classification
|
|
97
|
+
raw_dir = project.path / "datasets" / "raw"
|
|
98
|
+
for existing in sorted(raw_dir.glob("DAT-*")):
|
|
99
|
+
manifest = existing / "manifest.json"
|
|
100
|
+
if manifest.is_file():
|
|
101
|
+
rec = json.loads(manifest.read_text(encoding="utf-8"))
|
|
102
|
+
if rec.get("content_hash") == content_hash:
|
|
103
|
+
stored = rec.get("privacy_classification")
|
|
104
|
+
order = ("public", "internal", "confidential", "restricted")
|
|
105
|
+
if order.index(classification) > order.index(stored):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"dataset {content_hash[:10]}… already stored with "
|
|
108
|
+
f"weaker classification {stored!r}; re-ingesting as "
|
|
109
|
+
f"{classification!r} is refused"
|
|
110
|
+
)
|
|
111
|
+
return rec
|
|
112
|
+
|
|
113
|
+
dataset_id = new_local_id("DAT", {
|
|
114
|
+
p.name for p in raw_dir.iterdir() if p.is_dir()})
|
|
115
|
+
dest_dir = raw_dir / dataset_id
|
|
116
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
|
|
118
|
+
# copy bytes once
|
|
119
|
+
dest_raw = dest_dir / source_path.name
|
|
120
|
+
shutil.copy2(source_path, dest_raw)
|
|
121
|
+
|
|
122
|
+
profile = derive_csv_profile(dest_raw)
|
|
123
|
+
asset = {
|
|
124
|
+
"dataset_id": dataset_id,
|
|
125
|
+
"project_id": project.project_id,
|
|
126
|
+
"design_id": design_id,
|
|
127
|
+
"source_type": source_path.suffix.lstrip(".").lower() or "unknown",
|
|
128
|
+
"path": str(dest_raw),
|
|
129
|
+
"content_hash": content_hash,
|
|
130
|
+
"schema_summary": {"columns": profile["column_count"]},
|
|
131
|
+
"row_count": profile["row_count"],
|
|
132
|
+
"column_count": profile["column_count"],
|
|
133
|
+
"variable_dictionary": variable_dictionary,
|
|
134
|
+
"privacy_classification": classification,
|
|
135
|
+
"consent_metadata": privacy.get("consent_metadata"),
|
|
136
|
+
"deidentification_status": privacy.get("deidentification_status", "not_done"),
|
|
137
|
+
"created_at": _now_iso(),
|
|
138
|
+
"extensions": {
|
|
139
|
+
"missingness": profile["missingness"],
|
|
140
|
+
"deidentification_required": bool(
|
|
141
|
+
privacy.get("deidentification_required", False)),
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
errors = validate_dataset_asset(project, asset)
|
|
145
|
+
if errors:
|
|
146
|
+
shutil.rmtree(dest_dir, ignore_errors=True)
|
|
147
|
+
raise ValueError("dataset asset invalid:\n- " + "\n- ".join(errors))
|
|
148
|
+
|
|
149
|
+
# atomic manifest write
|
|
150
|
+
tmp = dest_dir / "manifest.json.tmp"
|
|
151
|
+
tmp.write_text(json.dumps(asset, ensure_ascii=False, indent=2) + "\n",
|
|
152
|
+
encoding="utf-8")
|
|
153
|
+
tmp.replace(dest_dir / "manifest.json")
|
|
154
|
+
return asset
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def analysis_blocked_by_privacy(asset: dict) -> list[str]:
|
|
158
|
+
"""Return blocking reasons if analysis must not proceed."""
|
|
159
|
+
reasons: list[str] = []
|
|
160
|
+
if asset.get("deidentification_status") == "not_done" and (
|
|
161
|
+
asset.get("extensions") or {}).get("deidentification_required"):
|
|
162
|
+
reasons.append(
|
|
163
|
+
"deidentification_required but deidentification_status=not_done; "
|
|
164
|
+
"analysis blocked until deidentified")
|
|
165
|
+
return reasons
|
package/engine/events.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""engine/events.py — Real-time thread-safe EventBus for EduEvidence.
|
|
2
|
+
|
|
3
|
+
Provides event publishing and subscription across orchestrator, engine modules,
|
|
4
|
+
and dashboard SSE streams with bounded ring-buffer history.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import collections
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable, Deque, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventBus:
|
|
16
|
+
_instance: Optional[EventBus] = None
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def __new__(cls) -> EventBus:
|
|
20
|
+
with cls._lock:
|
|
21
|
+
if cls._instance is None:
|
|
22
|
+
cls._instance = super(EventBus, cls).__new__(cls)
|
|
23
|
+
cls._instance._subscribers = []
|
|
24
|
+
cls._instance._history = collections.deque(maxlen=500)
|
|
25
|
+
cls._instance._bus_lock = threading.RLock()
|
|
26
|
+
return cls._instance
|
|
27
|
+
|
|
28
|
+
def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None:
|
|
29
|
+
with self._bus_lock:
|
|
30
|
+
if callback not in self._subscribers:
|
|
31
|
+
self._subscribers.append(callback)
|
|
32
|
+
|
|
33
|
+
def unsubscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None:
|
|
34
|
+
with self._bus_lock:
|
|
35
|
+
if callback in self._subscribers:
|
|
36
|
+
self._subscribers.remove(callback)
|
|
37
|
+
|
|
38
|
+
def publish(self, event_type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
39
|
+
event = {
|
|
40
|
+
"type": event_type,
|
|
41
|
+
"payload": payload,
|
|
42
|
+
"timestamp": time.time(),
|
|
43
|
+
"iso_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
44
|
+
}
|
|
45
|
+
with self._bus_lock:
|
|
46
|
+
self._history.append(event)
|
|
47
|
+
subs = list(self._subscribers)
|
|
48
|
+
|
|
49
|
+
for sub in subs:
|
|
50
|
+
try:
|
|
51
|
+
sub(event)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return event
|
|
55
|
+
|
|
56
|
+
def get_history(self, event_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
57
|
+
with self._bus_lock:
|
|
58
|
+
if event_type:
|
|
59
|
+
return [e for e in self._history if e["type"] == event_type]
|
|
60
|
+
return list(self._history)
|
|
61
|
+
|
|
62
|
+
def clear(self) -> None:
|
|
63
|
+
with self._bus_lock:
|
|
64
|
+
self._history.clear()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
event_bus = EventBus()
|