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()
|