"""
Curriculum outline JSON schema validation.
Validates lesson outline JSON against the Jinja2 template requirements.
Returns (valid: bool, errors: list[str]).
"""
import json
from typing import Tuple
# All section types supported by the History and ELA templates
VALID_SECTION_TYPES = {
"reading",
"questions",
"vocab_grid",
"sort_table",
"true_false",
"draw_grid",
"fill_blank",
"activity",
"think",
"review_table",
"timeline",
"vocab",
"writing_space",
"draw",
"note",
"summary",
"objectives",
}
# Required top-level keys
REQUIRED_TOP_KEYS = {"week", "day", "topic", "subtitle", "icon", "pages"}
# Required page keys
REQUIRED_PAGE_KEYS = {"title", "sections"}
# Required section keys (all types need at least type + title)
REQUIRED_SECTION_KEYS = {"type", "title"}
# Type-specific required fields
TYPE_REQUIRED_FIELDS = {
"reading": {"paragraphs"},
"questions": {"questions"},
"vocab_grid": {"words"},
"sort_table": {"header", "rows"},
"true_false": {"items"},
"fill_blank": {"items"},
"activity": {"content"},
"think": {"questions"},
"review_table": {"header", "rows"},
"timeline": {"entries"},
"vocab": {"words"},
"writing_space": {"prompts"},
"draw": {"instruction"},
"note": {"content"},
"summary": {"items"},
"objectives": {"items"},
}
def validate_outline(data) -> Tuple[bool, list[str]]:
"""Validate a lesson outline dict. Returns (is_valid, list_of_errors)."""
errors = []
# Top-level keys
missing = REQUIRED_TOP_KEYS - set(data.keys())
if missing:
errors.append(f"Missing top-level keys: {missing}")
# Week/day sanity
week = data.get("week")
if not isinstance(week, int) or week < 1 or week > 32:
errors.append(f"Invalid week: {week} (must be int 1-32)")
day = data.get("day", "")
if day not in ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday"):
errors.append(f"Invalid day: {day}")
# Pages
pages = data.get("pages", [])
if not isinstance(pages, list) or len(pages) == 0:
errors.append("Missing or empty 'pages' array")
else:
if len(pages) > 5:
errors.append(f"Too many pages: {len(pages)} (max 5)")
for i, page in enumerate(pages):
# Page title required on first page only, optional after
page_required = {"sections"} if i > 0 else REQUIRED_PAGE_KEYS
pmissing = page_required - set(page.keys())
if pmissing:
errors.append(f"Page {i+1}: missing keys {pmissing}")
# Sections
sections = page.get("sections", [])
if not isinstance(sections, list) or len(sections) == 0:
errors.append(f"Page {i+1}: missing or empty 'sections'")
else:
for j, sec in enumerate(sections):
# Required section keys
smissing = REQUIRED_SECTION_KEYS - set(sec.keys())
if smissing:
errors.append(f"Page {i+1}, Section {j+1}: missing keys {smissing}")
continue
stype = sec.get("type", "")
if stype not in VALID_SECTION_TYPES:
errors.append(
f"Page {i+1}, Section {j+1}: unsupported type '{stype}' "
f"(valid: {', '.join(sorted(VALID_SECTION_TYPES))})"
)
continue
# Type-specific fields
required = TYPE_REQUIRED_FIELDS.get(stype, set())
smissing_type = required - set(sec.keys())
if smissing_type:
errors.append(
f"Page {i+1}, Section {j+1}: type '{stype}' missing fields {smissing_type}"
)
# Content sanity
if stype == "reading":
paras = sec.get("paragraphs", [])
if len(paras) == 0:
errors.append(f"Page {i+1}: reading section has no paragraphs")
else:
for k, p in enumerate(paras):
word_count = len(str(p).split())
if word_count > 150:
errors.append(
f"Page {i+1}, Para {k+1}: {word_count} words "
f"(target 80-120)"
)
if stype in ("questions", "think"):
qs = sec.get("questions", [])
for k, q in enumerate(qs):
if "text" not in q:
errors.append(
f"Page {i+1}, Q{k+1}: missing 'text' in question"
)
return (len(errors) == 0, errors)
def validate_file(path: str) -> Tuple[bool, list[str]]:
"""Validate a JSON outline file. Returns (is_valid, errors)."""
try:
with open(path) as f:
data = json.load(f)
except json.JSONDecodeError as e:
return False, [f"Invalid JSON: {e}"]
except FileNotFoundError:
return False, [f"File not found: {path}"]
return validate_outline(data)
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python curriculum_schema.py <outline.json> [outline2.json ...]")
sys.exit(1)
all_ok = True
for path in sys.argv[1:]:
valid, errs = validate_file(path)
status = "✓" if valid else "✗"
print(f"{status} {path}")
if not valid:
all_ok = False
for e in errs:
print(f" {e}")
sys.exit(0 if all_ok else 1)