#!/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()