"""Conversion between the Clef question schema (JSON) and the visual editor's state. Editor state is a list of questions, each: {"id": str, "type": "noul"|"choice"|"score", "instructions": str, "options": [{"id": str, "desc": str}, ...], # choice: id + description; score: ordered levels "true": str, "false": str} # noul: optional descriptions Text fields hold strings; a value that parses as a JSON object/array is exported as JSON. """ import json QUESTION_TYPES = ("noul", "choice", "score") QUESTION_KEYS = {"type", "instructions", "criteria"} def dumps(value): return json.dumps(value, indent=2, ensure_ascii=False) def to_text(value): if value is None: return "" return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) def to_value(text): stripped = (text or "").strip() if not stripped: return None if stripped[:1] in "{[": try: return json.loads(stripped) except json.JSONDecodeError: pass return text def new_question(existing_ids, qtype="noul"): n = len(existing_ids) + 1 while f"question_{n}" in existing_ids: n += 1 question = {"id": f"question_{n}", "type": qtype, "instructions": "", "options": [], "true": "", "false": ""} seed_options(question) return question def seed_options(question): """Give choice/score questions two empty options if they have none.""" if question["type"] == "choice" and not question["options"]: question["options"] = [{"id": "option_1", "desc": ""}, {"id": "option_2", "desc": ""}] elif question["type"] == "score" and not question["options"]: question["options"] = [{"id": "0", "desc": ""}, {"id": "1", "desc": ""}] def new_option(question): ids = {o["id"] for o in question["options"]} n = len(ids) + 1 if question["type"] == "choice": while f"option_{n}" in ids: n += 1 return {"id": f"option_{n}", "desc": ""} return {"id": str(len(question["options"])), "desc": ""} def from_schema(schema): """Parse a questions object into editor state. Raises ValueError with a readable message.""" if not isinstance(schema, dict) or not schema: raise ValueError("Top level must be a non-empty JSON object mapping question ID to question.") questions = [] for qid, q in schema.items(): if not isinstance(q, dict): raise ValueError(f"`{qid}`: each question must be a JSON object.") qtype = q.get("type") if qtype not in QUESTION_TYPES: raise ValueError(f"`{qid}`: `type` must be noul, choice, or score (got {json.dumps(qtype)}).") unknown = sorted(set(q) - QUESTION_KEYS) if unknown: raise ValueError(f"`{qid}`: unsupported keys {unknown}; allowed keys are type, instructions, criteria.") criteria = q.get("criteria") item = { "id": str(qid), "type": qtype, "instructions": to_text(q.get("instructions")), "options": [], "true": "", "false": "", } if qtype == "choice": if not isinstance(criteria, dict): raise ValueError(f"`{qid}`: choice `criteria` must be an object of option ID to description.") item["options"] = [{"id": str(k), "desc": to_text(v)} for k, v in criteria.items()] elif qtype == "score": if not isinstance(criteria, list): raise ValueError(f"`{qid}`: score `criteria` must be a list of level descriptions.") item["options"] = [{"id": str(i), "desc": to_text(v)} for i, v in enumerate(criteria)] elif criteria is not None: if not isinstance(criteria, dict) or set(criteria) - {"true", "false"}: raise ValueError(f"`{qid}`: noul `criteria` may only contain `true` and `false` descriptions.") item["true"] = to_text(criteria.get("true")) item["false"] = to_text(criteria.get("false")) questions.append(item) return questions def to_schema(questions): schema = {} for q in questions: out = {"type": q["type"]} instructions = to_value(q["instructions"]) if instructions is not None: out["instructions"] = instructions if q["type"] == "choice": out["criteria"] = {o["id"]: to_value(o["desc"]) for o in q["options"]} elif q["type"] == "score": out["criteria"] = [to_value(o["desc"]) for o in q["options"]] else: criteria = {k: to_value(q[k]) for k in ("true", "false") if to_value(q[k]) is not None} if criteria: out["criteria"] = criteria schema[q["id"]] = out return schema def validate(questions): problems = [] if not questions: problems.append("Add at least one question.") seen = set() for i, q in enumerate(questions, 1): name = f"Question {i} (`{q['id']}`)" if q["id"].strip() else f"Question {i}" if not q["id"].strip(): problems.append(f"{name}: ID is empty.") elif q["id"] in seen: problems.append(f"{name}: duplicate ID; only the last one is kept.") seen.add(q["id"]) if q["type"] in ("choice", "score") and not q["options"]: problems.append(f"{name}: needs at least one option.") if q["type"] == "choice": option_ids = [o["id"] for o in q["options"]] if any(not oid.strip() for oid in option_ids): problems.append(f"{name}: an option ID is empty.") if len(set(option_ids)) != len(option_ids): problems.append(f"{name}: duplicate option IDs.") return problems def status_markdown(questions): problems = validate(questions) if not problems: return "" return "**Schema issues**\n\n" + "\n".join(f"- {p}" for p in problems)