#!/usr/bin/env python3 """Validate questions/**/*.jsonl against schema.json. Run before opening a PR: python scripts/validate_questions.py Covers both questions/corpus.jsonl and every submission in questions/pending/. """ import json import sys from pathlib import Path try: import jsonschema except ImportError: sys.exit("Missing dependency: pip install jsonschema") ROOT = Path(__file__).resolve().parent.parent SCHEMA = json.loads((ROOT / "schema.json").read_text(encoding="utf-8")) QUESTIONS_DIR = ROOT / "questions" PENDING_DIR = QUESTIONS_DIR / "pending" FORMAT_CHECKER = jsonschema.FormatChecker() BAND_FOR_TOTAL = {0: "easy", 1: "easy", 2: "medium", 3: "medium", 4: "hard"} def main() -> int: # Include recursive files so we validate corpus.jsonl and pending/*.jsonl files = sorted(QUESTIONS_DIR.rglob("*.jsonl")) files = [f for f in files if f.name != "template.jsonl"] if not files: print("No question files found in questions/ (besides template.jsonl).") return 1 seen_ids = set() total = 0 errors = 0 for path in files: label = path.relative_to(QUESTIONS_DIR).as_posix() in_pending = PENDING_DIR in path.parents # split("\n"), not splitlines(): that also breaks at U+2028, U+2029 and U+0085, which # JSON allows unescaped inside a string, so it would cut a valid question in two. for lineno, line in enumerate(path.read_text(encoding="utf-8").split("\n"), start=1): line = line.strip() if not line: continue try: item = json.loads(line) except json.JSONDecodeError as e: print(f"{label}:{lineno}: invalid JSON ({e})") errors += 1 continue try: jsonschema.validate(item, SCHEMA, format_checker=FORMAT_CHECKER) except jsonschema.ValidationError as e: print(f"{label}:{lineno}: {e.message}") errors += 1 continue try: json.dumps(item, ensure_ascii=False).encode("utf-8") except UnicodeEncodeError as e: # "\ud800" is valid JSON and passes the schema, but is not text: an eval run # would fail writing its log, and take the remaining items with it. print(f"{label}:{lineno}: contains a lone surrogate ({e.reason}), usually half an emoji") errors += 1 continue total += 1 # Submissions live in pending/ with the cb-pending placeholder; the # maintainer assigns real ids when moving them into corpus.jsonl # (see scripts/assign_ids.py). Multiple items may carry cb-pending. if in_pending and item["id"] != "cb-pending": print(f"{label}:{lineno}: files in pending/ must use id 'cb-pending', got {item['id']!r}") errors += 1 elif not in_pending and item["id"] == "cb-pending": print(f"{label}:{lineno}: 'cb-pending' outside pending/; run scripts/assign_ids.py") errors += 1 elif item["id"] != "cb-pending": if item["id"] in seen_ids: print(f"{label}:{lineno}: duplicate id {item['id']!r}") errors += 1 seen_ids.add(item["id"]) difficulty = item.get("difficulty") if isinstance(difficulty, dict): score = difficulty["evidence_scarcity"] + difficulty["question_openness"] expected_band = BAND_FOR_TOTAL[score] if difficulty["band"] != expected_band: print( f"{label}:{lineno}: difficulty.band {difficulty['band']!r} " f"does not match evidence_scarcity + question_openness = {score} " f"(expected {expected_band!r})" ) errors += 1 if errors: print(f"\n{errors} error(s).") return 1 print(f"OK - {total} question(s) validated across {len(files)} file(s).") return 0 if __name__ == "__main__": # Questions can be in any script; a console that cannot show it must not crash the run. sys.stdout.reconfigure(errors="backslashreplace") raise SystemExit(main())