| |
| """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 |
|
|
| OUT = WEB / "public" / "data" |
|
|
| |
| import re as _re |
| EXCLUDE_ENVIRS = {"garifuna_religion"} |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| LAUNCH_ENVIRS = {"belize", "stem_k12", "garifuna_commission_curriculum", |
| "belize_national_curriculum", "honduras_dcneb", "teachers_college", |
| |
| "stem_alternative", "history_humanities", "derived_curriculum", |
| "svg_yurumein", "garifuna_language", "nicaragua", "guatemala", |
| "belize_secondary", "honduras_media", "guatemala_diversificado"} |
|
|
| 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: |
| |
| _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).") |
| |
| |
| 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) |
| |
| |
| 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}") |
| |
| 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 |
| |
| 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") |
|
|
| |
| |
| 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", |
| "audio") |
| 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()): |
| 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 |
| |
| |
| |
| _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) |
| |
| |
| _a = d.get("assessment") |
| if isinstance(_a, dict) and isinstance(_a.get("items"), list): |
| |
| |
| _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 [])] |
| |
| |
| 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) |
| |
| |
| 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} |
| |
| |
| 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"): |
| 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"): |
| 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 "")) |
| |
| 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() |
|
|