garibel / scripts /export_lessons.py
wamaraga's picture
LAUNCH C's GariFuturism UI — convert garibel gradio→docker (Next UI + grounded brain), retire Gradio app.py
e2f6f15 verified
Raw
History Blame Contribute Delete
14.4 kB
#!/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()