File size: 14,358 Bytes
e2f6f15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/usr/bin/env python3
"""Export static lesson bundles for offline-first deploy (META-F-034 / plan §2.2).

Writes the lesson catalog as static JSON into web/public/data/ so the webapp works
WITHOUT a live API server — the low-connectivity Belize pilot. The Next.js lesson
route falls back to these bundles when NEXT_PUBLIC_API_BASE is unset or unreachable
(lib/lessons.ts). Committed so the Node-only production build is self-contained.

Regenerate whenever lessons change:
    cd 50_app/web && python3 scripts/export_lessons.py
"""
from __future__ import annotations
import json, sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
WEB = HERE.parent
API = WEB.parent / "api"
sys.path.insert(0, str(API))
import lesson_catalog as lc  # type: ignore  # noqa: E402

OUT = WEB / "public" / "data"

# AUDIT-020 RELEASE GATE: never ship un-reviewed content, sacred content, or IP-isolated curriculum to the public app.
import re as _re
EXCLUDE_ENVIRS = {"garifuna_religion"}  # sacred → private only (commission is director-cleared for private review 2026-05-28)
# LAUNCH SCOPE (director directive 2026-06-06): ship ONLY these 5 clean, illustrated, license-free lanes for the
# Commission review. All other envs (legacy/out-of-scope: guatemala, nicaragua, belize_national_curriculum, garicomm,
# honduras [old], history_humanities, stem_alternative, teachers_college) are held back until separately cleared.
# DE-SCOPED (director ruling via AUDIT-020, 2026-06-08): svg_yurumein HELD (worst fact-check offender — homeland
# authoring not done) and honduras_dcneb HELD (Spanish-first, primary_language=es / 0 Garifuna → EB-GLC-001, ready=0).
# These rulings previously shipped as memos but were never applied to code; now enforced here + asserted post-export.
# honduras REMOVED 2026-06-08: the launch "honduras" env was a verbatim Belize CLONE (AUDIT-029 fabrication,
# director catch). Authentic Honduras = honduras_dcneb (held until launch-ready). Re-add only after the real
# DCNEB content is promoted + gated. Same clone-quarantine applies to guatemala/nicaragua/svg_yurumein (held).
# 2026-06-08: surface the COMPLETED authentic curricula (director directive) — verified most-recent + clone-safe
# (J vs belize: belize_national 0.021, honduras_dcneb 0.0, teachers_college 0.049). Honduras=DCNEB (Spanish-only,
# director ship-ruling). The clone-gate (main()) blocks any relabel; these are all genuinely distinct.
LAUNCH_ENVIRS = {"belize", "stem_k12", "garifuna_commission_curriculum",
                 "belize_national_curriculum", "honduras_dcneb", "teachers_college",
                 # 2026-06-09 director "serve ALL my matter": every authentic non-clone, non-sacred env.
                 "stem_alternative", "history_humanities", "derived_curriculum",
                 "svg_yurumein", "garifuna_language", "nicaragua", "guatemala",
                 "belize_secondary", "honduras_media", "guatemala_diversificado"}  # CSEC secondary 7-11, shared Belize+SVG (director GO 2026-06-09, CXC transform-not-copy)

def gate_ok(full: dict) -> bool:
    """DIRECTOR DIRECTIVE 2026-06-09: NO content gating. Serve ALL of the director's matter in the launch envirs.
    Content readiness is the director's call, not a pipeline gate. The ONLY remaining gates are anti-fabrication
    (clone_gate + canonical_guard) and the envir-level sacred/IP exclusion (sacred never serves publicly — the
    director's own red line). Every real lesson ships."""
    return True


def main() -> None:
    # AUDIT-032 FAIL-CLOSED CLONE GATE: never export a launch envir that's a relabeled Belize-clone.
    _LMS_DIR = WEB.parent / "lms"
    sys.path.insert(0, str(_LMS_DIR / "_engine"))
    try:
        import clone_gate as _cg
        _lc = _cg.launch_clone_pairs(str(_LMS_DIR), LAUNCH_ENVIRS)
        if _lc:
            sys.exit("\U0001F534 CLONE GATE (AUDIT-032) BLOCKED EXPORT — launch envir(s) are clones (relabeled curriculum): "
                     + ", ".join(f"{a}{b} J={j}" for a, b, j in _lc)
                     + " — build from the real source or drop it from LAUNCH_ENVIRS before exporting.")
        print(f"clone-gate OK: no launch lane is a clone (checked {sorted(LAUNCH_ENVIRS)})")
    except ImportError:
        print("⚠ clone_gate.py not found — anti-clone gate SKIPPED (wire it).")
    # CANONICAL GUARD (director URGENT directive 2026-06-08): the authentic completed curricula are LOCKED
    # in the primary location — refuse to export if any was replaced, regressed (lesson-count drop), or duped.
    try:
        import canonical_guard as _cgd
        _viol = _cgd.violations(str(_LMS_DIR))
        if _viol:
            sys.exit("\U0001F512 CANONICAL GUARD BLOCKED EXPORT — " + " | ".join(_viol)
                     + " — restore the canonical from lms/_CANONICAL_REGISTRY.json before exporting.")
        print("canonical-guard OK: all canonicals present, at/above locked count, no dupes")
    except ImportError:
        print("⚠ canonical_guard.py not found — canonical lock SKIPPED (wire it).")
    (OUT / "lessons").mkdir(parents=True, exist_ok=True)
    # SCOPE-DRIFT PURGE (AUDIT-020): a de-scope ruling only takes effect if leftover bundle dirs are removed.
    # Purge any served cohort no longer in LAUNCH_ENVIRS so held lanes (svg_yurumein, honduras_dcneb, …) can't linger.
    import shutil as _shutil
    for sub in ("lesson", "lessons"):
        d = OUT / sub
        if d.is_dir():
            for child in d.iterdir():
                name = child.name[:-5] if child.name.endswith(".json") else child.name
                if name == "envirs":
                    continue
                if name not in LAUNCH_ENVIRS:
                    _shutil.rmtree(child, ignore_errors=True) if child.is_dir() else child.unlink(missing_ok=True)
                    print(f"  scope-purge: removed de-scoped {sub}/{child.name}")
    # build gate-passing lesson id sets per envir (load detail for the gate); exclude sacred/IP envirs
    passing: dict[str, set] = {}
    excluded_envir = 0; gate_excluded = 0
    for e in lc.envirs():
        envir = e["envir"]
        if envir in EXCLUDE_ENVIRS or envir not in LAUNCH_ENVIRS:
            excluded_envir += 1; continue
        ids = set()
        for s in (lc.lessons_for(envir) or []):
            full = lc.lesson_detail(envir, s["lesson_id"])
            if full and gate_ok(full):
                ids.add(s["lesson_id"])
            else:
                gate_excluded += 1
        if ids:
            passing[envir] = ids
    # ready = gate-passing lesson_count (a gate-passing lesson IS ready-for-review); keeps badges honest
    envs = [dict(e, lesson_count=len(passing[e["envir"]]), ready=len(passing[e["envir"]]))
            for e in lc.envirs() if e["envir"] in passing]
    total = sum(e["lesson_count"] for e in envs)
    (OUT / "envirs.json").write_text(
        json.dumps({"envirs": envs, "total_lessons": total,
                    "gate": "LAUNCH 5-lane scope (2026-06-06): Belize+SVG+STEM+Honduras+Commission · READY|english_corpus_complete only · sacred excluded · legacy/out-of-scope held back (AUDIT-020)"},
                   ensure_ascii=False, indent=2), encoding="utf-8")
    for e in envs:
        envir = e["envir"]
        lessons = [s for s in (lc.lessons_for(envir) or []) if s["lesson_id"] in passing[envir]]
        (OUT / "lessons" / f"{envir}.json").write_text(
            json.dumps({"envir": envir, "lessons": lessons}, ensure_ascii=False, indent=2),
            encoding="utf-8")
    print(f"GATE: excluded {excluded_envir} sacred/IP envirs · {gate_excluded} PENDING-review lessons")

    # Per-lesson TRIMMED detail bundles (offline-first DETAIL). Full lesson JSONs are too heavy
    # (~1 MB each w/ audit blocks) — emit only what the detail page renders so it works with no server.
    det = OUT / "lesson"
    KEEP = ("lesson_id", "envir", "pathway", "title", "grade_band", "subject", "discipline",
            "concept", "theme", "form", "learning_objectives", "assessment", "release_gate",
            "copyright", "technical_terms_original",
            "corpus_enrichment",   # attested word_study/dialogue/reading/practice (director 2026-06-06)
            "audio")               # Meta CAB TTS clips manifest (director 2026-06-10: global Garífuna pronunciation)
    nd = 0
    for e in envs:
        envir = e["envir"]
        (det / envir).mkdir(parents=True, exist_ok=True)
        for s in (lc.lessons_for(envir) or []):
            lid = s["lesson_id"]
            if lid not in passing.get(envir, set()):   # gate: skip un-reviewed / excluded
                continue
            full = lc.lesson_detail(envir, lid)
            if not full or not gate_ok(full):
                continue
            d = {k: full[k] for k in KEEP if k in full}
            d["envir"] = envir
            # DURABLE GARBLE GUARD (AUDIT-046): only serve audio clips that physically exist in public/audio.
            # Quarantined/garble/missing clips are dropped here, so a re-export can NEVER reintroduce a bad or
            # dangling audio reference. Red-line: absent audio > garble. Self-healing — no list to maintain.
            _au = d.get("audio")
            if isinstance(_au, dict) and isinstance(_au.get("clips"), dict):
                _au["clips"] = {w: p for w, p in _au["clips"].items()
                                if (WEB / "public" / str(p).lstrip("/")).exists()}
                if not _au["clips"]:
                    d.pop("audio", None)
            # A4 SECURITY: strip answer_index from the client bundle (grading is server-side at
            # /assessment/submit). Keep prompt/options/id so the player can render an interactive quiz.
            _a = d.get("assessment")
            if isinstance(_a, dict) and isinstance(_a.get("items"), list):
                # A-031: keep BOTH item schemas — language lanes use prompt/options; STEM uses q/choices/skill_id.
                # Drop ONLY answer_index (grading is server-side at /assessment/submit).
                _a["items"] = [{k: it.get(k) for k in ("id", "type", "pathway", "bloom",
                                "prompt", "options", "q", "choices", "skill_id",
                                "tests_foundry_key", "mastery_threshold") if k in it}
                               for it in _a["items"]]
            d["vocabulary"] = [{k: v.get(k) for k in ("cab", "es", "en", "pos", "semantic_domain") if k in v}
                               for v in (full.get("vocabulary") or [])]
            # Spanish-first lessons (Honduras DCNEB) carry content under es{}/en_mt{}, not the language-lane
            # learning_objectives/assessment shape. Map them so the detail page renders (else bundle is empty).
            es = full.get("es") if isinstance(full.get("es"), dict) else None
            if es and not d.get("learning_objectives"):
                d["learning_objectives"] = es.get("objetivos") or []
                en_objs = (full.get("en_mt") or {}).get("objectives") or []
                if en_objs:
                    d["learning_objectives_en"] = en_objs
                if not d.get("assessment") and es.get("evaluacion"):
                    mthr = (full.get("mastery") or {}).get("bkt_threshold", 0.8)
                    # Build REAL gradable items from es.evaluacion (not a count-stub) so the player renders
                    # an interactive quiz. answer_index omitted from the client bundle (grading server-side).
                    d["assessment"] = {"mastery_threshold": mthr, "items": [
                        {"id": f"q{_i+1}", "type": "multiple_choice",
                         "prompt": _q.get("pregunta") or _q.get("q") or "",
                         "options": _q.get("opciones") or _q.get("options") or []}
                        for _i, _q in enumerate(es["evaluacion"]) if (_q.get("opciones") or _q.get("options"))]}
                if not d.get("title"):
                    u = full.get("unidad") or ""
                    d["title"] = {"es": u, "english": u}
            # Map the lesson's illustration -> the plural {images:[{src,alt_text}],rights} shape the detail page renders.
            # Lessons store singular `illustration: {image:"images/X.png", theme, copyright}`; older data used plural.
            il = full.get("illustration") or full.get("illustrations")
            if isinstance(il, dict):
                ttl = full.get("title") or {}
                alt = ((ttl.get("en") or ttl.get("english")) if isinstance(ttl, dict) else "") or "Garifuturism illustration"
                if il.get("image"):                                    # singular form
                    d["illustrations"] = {"theme": il.get("theme"),
                                          "rights": il.get("copyright") or il.get("rights"),
                                          "images": [{"src": "/" + str(il["image"]).lstrip("/"), "alt_text": il.get("alt_text") or alt}]}
                elif il.get("images"):                                 # already-plural form (back-compat)
                    d["illustrations"] = {"theme": il.get("theme"),
                                          "rights": il.get("rights") or il.get("copyright"),
                                          "images": [{"src": im.get("src"), "alt_text": im.get("alt_text") or alt}
                                                     for im in il.get("images", []) if im.get("src")]}
            (det / envir / f"{lid}.json").write_text(json.dumps(d, ensure_ascii=False) + "\n", encoding="utf-8")
            nd += 1

    skipped = lc.skipped()
    print(f"exported {len(envs)} envirs, {total} lessons + {nd} detail bundles -> {OUT}"
          + (f"  (WARN: {len(skipped)} lesson files skipped — see logs)" if skipped else ""))
    # SCOPE-DRIFT ASSERTION (AUDIT-020): the served bundle MUST contain only LAUNCH_ENVIRS cohorts.
    served = {p.name for p in (OUT / "lesson").iterdir() if p.is_dir()} if (OUT / "lesson").is_dir() else set()
    drift = served - LAUNCH_ENVIRS
    if drift:
        raise SystemExit(f"SCOPE DRIFT (deploy-blocking): served bundle has de-scoped cohorts {sorted(drift)} "
                         f"— LAUNCH_ENVIRS={sorted(LAUNCH_ENVIRS)}")
    print(f"scope-drift check OK: served cohorts ⊆ LAUNCH_ENVIRS ({sorted(served)})")


if __name__ == "__main__":
    main()