{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://vo-luyen-tap.example/schema/bo-de-3.1.json",
  "title": "Hợp đồng bộ đề 3.1",
  "description": "Định dạng mở cho bộ câu hỏi và collection. Bản 3.1 thêm: câu tự luận (essay), câu chùm (group), chia phần (sections), media (ảnh, âm thanh, video qua URL https). File 3.0 vẫn hợp lệ. Bản 3.2 thêm set.blueprint và set.certification (tuỳ chọn); file 3.0 / 3.1 vẫn hợp lệ.",
  "type": "object",
  "required": [
    "schema",
    "sets"
  ],
  "additionalProperties": false,
  "properties": {
    "schema": {
      "type": "string",
      "pattern": "^bo-de/3\\.[0-9]+$"
    },
    "generator": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "name"
      ],
      "properties": {
        "name": {
          "$ref": "#/$defs/shortText"
        },
        "version": {
          "$ref": "#/$defs/shortText"
        }
      }
    },
    "sets": {
      "type": "array",
      "minItems": 1,
      "maxItems": 50,
      "items": {
        "$ref": "#/$defs/set"
      }
    },
    "collections": {
      "type": "array",
      "maxItems": 20,
      "items": {
        "$ref": "#/$defs/collection"
      }
    }
  },
  "$defs": {
    "id": {
      "type": "string",
      "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$"
    },
    "shortText": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "text": {
      "type": "string",
      "minLength": 1,
      "maxLength": 2000
    },
    "optionText": {
      "type": "string",
      "minLength": 1,
      "maxLength": 500
    },
    "tags": {
      "type": "array",
      "maxItems": 20,
      "uniqueItems": true,
      "items": {
        "type": "string",
        "pattern": "^[a-z0-9][a-z0-9_-]{0,39}$"
      }
    },
    "percent": {
      "type": "number",
      "minimum": 0,
      "maximum": 100
    },
    "ext": {
      "type": "object",
      "propertyNames": {
        "pattern": "^[a-z][a-z0-9_-]{0,31}$"
      },
      "additionalProperties": {
        "type": "object"
      }
    },
    "scoring": {
      "enum": [
        "all_or_nothing",
        "partial",
        "thpt_tf"
      ]
    },
    "set": {
      "type": "object",
      "required": [
        "id",
        "title",
        "lang",
        "items"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "$ref": "#/$defs/id"
        },
        "title": {
          "$ref": "#/$defs/shortText"
        },
        "lang": {
          "type": "string",
          "pattern": "^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
        },
        "description": {
          "$ref": "#/$defs/text"
        },
        "version": {
          "type": "integer",
          "minimum": 1
        },
        "tags": {
          "$ref": "#/$defs/tags"
        },
        "scoring": {
          "$ref": "#/$defs/scoring"
        },
        "pass_score": {
          "$ref": "#/$defs/percent"
        },
        "time_limit_sec": {
          "type": "integer",
          "minimum": 10,
          "maximum": 86400
        },
        "shuffle": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "items": {
              "type": "boolean"
            },
            "options": {
              "type": "boolean"
            }
          }
        },
        "provenance": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "source": {
              "$ref": "#/$defs/shortText"
            },
            "license": {
              "$ref": "#/$defs/shortText"
            },
            "attribution": {
              "$ref": "#/$defs/shortText"
            },
            "url": {
              "type": "string",
              "format": "uri",
              "maxLength": 500
            }
          }
        },
        "ext": {
          "$ref": "#/$defs/ext"
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 500,
          "items": {
            "$ref": "#/$defs/item"
          }
        },
        "sections": {
          "type": "array",
          "minItems": 1,
          "maxItems": 20,
          "items": {
            "type": "object",
            "required": [
              "id",
              "title"
            ],
            "additionalProperties": false,
            "properties": {
              "id": {
                "$ref": "#/$defs/id"
              },
              "title": {
                "$ref": "#/$defs/shortText"
              },
              "description": {
                "$ref": "#/$defs/text"
              },
              "time_limit_sec": {
                "type": "integer",
                "minimum": 10,
                "maximum": 86400
              },
              "pass_score": {
                "$ref": "#/$defs/percent"
              }
            }
          }
        },
        "blueprint": {
          "description": "3.2 – ma trận rút đề đi kèm bộ (giống ma trận của app: label, subject, grade, time_limit_sec, include_essay, scope, sections[].cells[] { skill, level, type, count }).",
          "type": "object",
          "required": [
            "sections"
          ],
          "properties": {
            "label": {
              "type": "string",
              "maxLength": 60
            },
            "subject": {
              "type": "string",
              "maxLength": 40
            },
            "grade": {
              "type": [
                "integer",
                "string"
              ]
            },
            "time_limit_sec": {
              "type": "integer",
              "minimum": 60,
              "maximum": 36000
            },
            "include_essay": {
              "type": "boolean"
            },
            "freeze_variants": {
              "type": "boolean"
            },
            "scope": {
              "type": "object"
            },
            "sections": {
              "type": "array",
              "minItems": 1,
              "maxItems": 20,
              "items": {
                "type": "object",
                "required": [
                  "id",
                  "cells"
                ],
                "properties": {
                  "id": {
                    "type": "string",
                    "maxLength": 40
                  },
                  "title": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "time_limit_sec": {
                    "type": "integer",
                    "minimum": 60
                  },
                  "cells": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 50,
                    "items": {
                      "type": "object",
                      "required": [
                        "count"
                      ],
                      "properties": {
                        "skill": {
                          "type": "string",
                          "maxLength": 64
                        },
                        "level": {
                          "type": [
                            "integer",
                            "string"
                          ]
                        },
                        "type": {
                          "type": "string",
                          "maxLength": 20
                        },
                        "subject": {
                          "type": "string",
                          "maxLength": 40
                        },
                        "count": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 200
                        }
                      },
                      "additionalProperties": false
                    }
                  }
                },
                "additionalProperties": false
              }
            }
          },
          "additionalProperties": false
        },
        "certification": {
          "description": "3.2 – thông tin kỳ thi chứng chỉ: điểm đạt (thang 10) dùng pass_score của bộ; ở đây là hạn hiệu lực, số lượt, đơn vị cấp.",
          "type": "object",
          "properties": {
            "title": {
              "type": "string",
              "maxLength": 200
            },
            "issuer": {
              "type": "string",
              "maxLength": 200
            },
            "valid_days": {
              "type": "integer",
              "minimum": 1,
              "maximum": 3650
            },
            "max_attempts": {
              "type": "integer",
              "minimum": 1,
              "maximum": 10
            }
          },
          "additionalProperties": false
        }
      }
    },
    "choice": {
      "type": "object",
      "required": [
        "text"
      ],
      "additionalProperties": false,
      "properties": {
        "text": {
          "$ref": "#/$defs/optionText"
        },
        "correct": {
          "type": "boolean"
        },
        "feedback": {
          "$ref": "#/$defs/text"
        },
        "ext": {
          "$ref": "#/$defs/ext"
        }
      }
    },
    "correctChoice": {
      "type": "object",
      "required": [
        "correct"
      ],
      "properties": {
        "correct": {
          "const": true
        }
      }
    },
    "acceptList": {
      "type": "array",
      "minItems": 1,
      "maxItems": 20,
      "items": {
        "type": "string",
        "minLength": 1,
        "maxLength": 200
      }
    },
    "matchRules": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "case_sensitive": {
          "type": "boolean"
        },
        "ignore_diacritics": {
          "type": "boolean"
        }
      }
    },
    "item": {
      "type": "object",
      "required": [
        "type",
        "prompt"
      ],
      "properties": {
        "id": {
          "$ref": "#/$defs/id"
        },
        "type": {
          "enum": [
            "single",
            "multi",
            "tf",
            "match",
            "order",
            "fill",
            "short",
            "essay",
            "group"
          ]
        },
        "prompt": {
          "$ref": "#/$defs/text"
        },
        "hints": {
          "type": "array",
          "minItems": 1,
          "maxItems": 5,
          "items": {
            "$ref": "#/$defs/text"
          },
          "description": "Gợi ý từng nấc, hiện lần lượt mỗi lần bấm."
        },
        "explain": {
          "$ref": "#/$defs/text"
        },
        "difficulty": {
          "type": "integer",
          "minimum": 1,
          "maximum": 5
        },
        "variant_group": {
          "$ref": "#/$defs/id",
          "description": "Các câu cùng nhóm là biến thể tương đương; mỗi lượt chỉ dùng một câu."
        },
        "points": {
          "type": "number",
          "exclusiveMinimum": 0,
          "maximum": 100
        },
        "scoring": {
          "$ref": "#/$defs/scoring"
        },
        "graded": {
          "type": "boolean"
        },
        "tags": {
          "$ref": "#/$defs/tags"
        },
        "ext": {
          "$ref": "#/$defs/ext"
        },
        "media": {
          "$ref": "#/$defs/mediaList"
        },
        "section": {
          "$ref": "#/$defs/id",
          "description": "id của một phần trong sections của bộ (chỉ câu cấp bộ, không dùng cho câu con của group)."
        }
      },
      "allOf": [
        {
          "if": {
            "properties": {
              "type": {
                "const": "single"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_single"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "multi"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_multi"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "tf"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_tf"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "match"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_match"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "order"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_order"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "fill"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_fill"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "short"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_short"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "essay"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_essay"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "group"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/t_group"
          }
        },
        {
          "if": {
            "required": [
              "scoring"
            ],
            "properties": {
              "scoring": {
                "const": "thpt_tf"
              }
            }
          },
          "then": {
            "properties": {
              "type": {
                "const": "tf"
              },
              "statements": {
                "minItems": 4,
                "maxItems": 4
              }
            }
          }
        }
      ],
      "unevaluatedProperties": false
    },
    "t_single": {
      "required": [
        "options"
      ],
      "properties": {
        "options": {
          "type": "array",
          "minItems": 2,
          "maxItems": 8,
          "items": {
            "$ref": "#/$defs/choiceM"
          }
        }
      },
      "if": {
        "required": [
          "graded"
        ],
        "properties": {
          "graded": {
            "const": false
          }
        }
      },
      "then": true,
      "else": {
        "properties": {
          "options": {
            "contains": {
              "$ref": "#/$defs/correctChoice"
            },
            "minContains": 1,
            "maxContains": 1
          }
        }
      }
    },
    "t_multi": {
      "required": [
        "options"
      ],
      "properties": {
        "options": {
          "type": "array",
          "minItems": 2,
          "maxItems": 10,
          "items": {
            "$ref": "#/$defs/choiceM"
          }
        },
        "min_select": {
          "type": "integer",
          "minimum": 0
        },
        "max_select": {
          "type": "integer",
          "minimum": 1
        }
      },
      "if": {
        "required": [
          "graded"
        ],
        "properties": {
          "graded": {
            "const": false
          }
        }
      },
      "then": true,
      "else": {
        "properties": {
          "options": {
            "contains": {
              "$ref": "#/$defs/correctChoice"
            },
            "minContains": 1
          }
        }
      }
    },
    "t_tf": {
      "required": [
        "statements"
      ],
      "properties": {
        "statements": {
          "type": "array",
          "minItems": 1,
          "maxItems": 8,
          "items": {
            "type": "object",
            "required": [
              "text",
              "correct"
            ],
            "additionalProperties": false,
            "properties": {
              "text": {
                "$ref": "#/$defs/optionText"
              },
              "correct": {
                "type": "boolean"
              },
              "feedback": {
                "$ref": "#/$defs/text"
              },
              "ext": {
                "$ref": "#/$defs/ext"
              },
              "media": {
                "$ref": "#/$defs/media"
              }
            }
          }
        }
      }
    },
    "t_match": {
      "required": [
        "pairs"
      ],
      "properties": {
        "pairs": {
          "type": "array",
          "minItems": 2,
          "maxItems": 10,
          "items": {
            "type": "object",
            "required": [
              "left",
              "right"
            ],
            "additionalProperties": false,
            "properties": {
              "left": {
                "$ref": "#/$defs/optionText"
              },
              "right": {
                "$ref": "#/$defs/optionText"
              }
            }
          }
        },
        "distractors": {
          "type": "array",
          "maxItems": 5,
          "items": {
            "$ref": "#/$defs/optionText"
          }
        }
      }
    },
    "t_order": {
      "required": [
        "items"
      ],
      "properties": {
        "items": {
          "type": "array",
          "minItems": 2,
          "maxItems": 10,
          "uniqueItems": true,
          "items": {
            "$ref": "#/$defs/optionText"
          }
        }
      }
    },
    "t_fill": {
      "required": [
        "text",
        "blanks"
      ],
      "properties": {
        "text": {
          "type": "string",
          "minLength": 1,
          "maxLength": 2000,
          "pattern": "\\{\\{[0-9]+\\}\\}"
        },
        "blanks": {
          "type": "array",
          "minItems": 1,
          "maxItems": 10,
          "items": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "accept": {
                "$ref": "#/$defs/acceptList"
              },
              "choices": {
                "type": "array",
                "minItems": 2,
                "maxItems": 8,
                "items": {
                  "$ref": "#/$defs/choice"
                },
                "contains": {
                  "$ref": "#/$defs/correctChoice"
                },
                "minContains": 1,
                "maxContains": 1
              },
              "match": {
                "$ref": "#/$defs/matchRules"
              }
            },
            "oneOf": [
              {
                "required": [
                  "accept"
                ]
              },
              {
                "required": [
                  "choices"
                ]
              }
            ]
          }
        }
      }
    },
    "t_short": {
      "properties": {
        "accept": {
          "$ref": "#/$defs/acceptList"
        },
        "numeric": {
          "type": "object",
          "required": [
            "value"
          ],
          "additionalProperties": false,
          "properties": {
            "value": {
              "type": "number"
            },
            "tolerance": {
              "type": "number",
              "minimum": 0
            },
            "unit": {
              "type": "string",
              "maxLength": 20
            }
          }
        },
        "match": {
          "$ref": "#/$defs/matchRules"
        }
      },
      "if": {
        "required": [
          "graded"
        ],
        "properties": {
          "graded": {
            "const": false
          }
        }
      },
      "then": true,
      "else": {
        "anyOf": [
          {
            "required": [
              "accept"
            ]
          },
          {
            "required": [
              "numeric"
            ]
          }
        ]
      }
    },
    "collection": {
      "type": "object",
      "required": [
        "id",
        "title",
        "entries"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "$ref": "#/$defs/id"
        },
        "title": {
          "$ref": "#/$defs/shortText"
        },
        "description": {
          "$ref": "#/$defs/text"
        },
        "ordered": {
          "type": "boolean"
        },
        "pass_score": {
          "$ref": "#/$defs/percent"
        },
        "tags": {
          "$ref": "#/$defs/tags"
        },
        "ext": {
          "$ref": "#/$defs/ext"
        },
        "entries": {
          "type": "array",
          "minItems": 1,
          "maxItems": 100,
          "items": {
            "$ref": "#/$defs/entry"
          }
        }
      }
    },
    "entry": {
      "type": "object",
      "properties": {
        "title": {
          "$ref": "#/$defs/shortText"
        },
        "pass_score": {
          "$ref": "#/$defs/percent"
        },
        "requires_previous": {
          "type": "boolean"
        },
        "set": {
          "$ref": "#/$defs/id"
        },
        "version": {
          "type": "integer",
          "minimum": 1
        },
        "draw": {
          "type": "object",
          "required": [
            "from",
            "count"
          ],
          "additionalProperties": false,
          "properties": {
            "from": {
              "type": "array",
              "minItems": 1,
              "maxItems": 50,
              "uniqueItems": true,
              "items": {
                "$ref": "#/$defs/id"
              }
            },
            "count": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200
            },
            "tags": {
              "$ref": "#/$defs/tags"
            },
            "types": {
              "type": "array",
              "uniqueItems": true,
              "items": {
                "enum": [
                  "single",
                  "multi",
                  "tf",
                  "match",
                  "order",
                  "fill",
                  "short",
                  "essay",
                  "group"
                ]
              }
            },
            "difficulty": {
              "type": "object",
              "additionalProperties": false,
              "properties": {
                "min": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 5
                },
                "max": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 5
                }
              }
            }
          }
        }
      },
      "oneOf": [
        {
          "required": [
            "set"
          ],
          "not": {
            "required": [
              "draw"
            ]
          }
        },
        {
          "required": [
            "draw"
          ],
          "not": {
            "anyOf": [
              {
                "required": [
                  "set"
                ]
              },
              {
                "required": [
                  "version"
                ]
              }
            ]
          }
        }
      ],
      "additionalProperties": false
    },
    "media": {
      "type": "object",
      "required": [
        "type",
        "url"
      ],
      "additionalProperties": false,
      "description": "Ảnh / âm thanh / video qua URL https. Video YouTube dùng link youtube.com/watch?v=…, youtu.be/… hoặc youtube.com/shorts/…; còn lại là link thẳng tới tệp.",
      "properties": {
        "type": {
          "enum": [
            "image",
            "audio",
            "video"
          ]
        },
        "url": {
          "type": "string",
          "pattern": "^https://[^\\s\"'<>]+$",
          "maxLength": 1000
        },
        "alt": {
          "type": "string",
          "minLength": 1,
          "maxLength": 300
        },
        "caption": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "start": {
          "type": "integer",
          "minimum": 0,
          "maximum": 86400
        },
        "end": {
          "type": "integer",
          "minimum": 1,
          "maximum": 86400
        }
      },
      "if": {
        "properties": {
          "type": {
            "const": "image"
          }
        }
      },
      "then": {
        "required": [
          "alt"
        ]
      }
    },
    "mediaList": {
      "type": "array",
      "minItems": 1,
      "maxItems": 4,
      "items": {
        "$ref": "#/$defs/media"
      }
    },
    "choiceM": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "text": {
          "$ref": "#/$defs/optionText"
        },
        "media": {
          "$ref": "#/$defs/media"
        },
        "correct": {
          "type": "boolean"
        },
        "feedback": {
          "$ref": "#/$defs/text"
        },
        "ext": {
          "$ref": "#/$defs/ext"
        }
      },
      "anyOf": [
        {
          "required": [
            "text"
          ]
        },
        {
          "required": [
            "media"
          ]
        }
      ]
    },
    "t_essay": {
      "properties": {
        "sample_answer": {
          "type": "string",
          "minLength": 1,
          "maxLength": 5000
        },
        "rubric": {
          "type": "array",
          "minItems": 1,
          "maxItems": 10,
          "items": {
            "type": "object",
            "required": [
              "criterion",
              "points"
            ],
            "additionalProperties": false,
            "properties": {
              "criterion": {
                "$ref": "#/$defs/shortText"
              },
              "points": {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 100
              }
            }
          }
        },
        "review": {
          "enum": [
            "self",
            "manual"
          ]
        },
        "max_chars": {
          "type": "integer",
          "minimum": 1,
          "maximum": 20000
        }
      }
    },
    "part": {
      "allOf": [
        {
          "$ref": "#/$defs/item"
        },
        {
          "not": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "group"
              }
            }
          }
        }
      ]
    },
    "t_group": {
      "required": [
        "parts"
      ],
      "properties": {
        "context": {
          "type": "string",
          "minLength": 1,
          "maxLength": 8000
        },
        "parts": {
          "type": "array",
          "minItems": 1,
          "maxItems": 10,
          "items": {
            "$ref": "#/$defs/part"
          }
        },
        "sequential": {
          "type": "boolean"
        }
      }
    }
  }
}