clef / schema_editor.py
mfn
Add visual question editor synced with JSON
a1d743f
Raw History Blame Contribute Delete
5.95 kB
"""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)