quizcomp.model.answer
1import abc 2import enum 3import math 4import random 5import typing 6 7import edq.util.enum 8import edq.util.parse 9import edq.util.serial 10 11import quizcomp.model.constants 12import quizcomp.model.errors 13import quizcomp.model.feedback 14 15class NumericAnswerType(enum.Enum): 16 """ The types of numeric answers supported by the Quiz Composer. """ 17 18 EXACT = 'exact' 19 RANGE = 'range' 20 PRECISION = 'precision' 21 22class TextOption(edq.util.serial.PODConverter): 23 """ 24 One possible text answer to a question. 25 """ 26 27 serialization_omit_none = True 28 serialization_omit_empty = True 29 30 def __init__(self, 31 text: quizcomp.parser.document.ParsedDocument, 32 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 33 **kwargs: typing.Any) -> None: 34 self.text: quizcomp.parser.document.ParsedDocument = text 35 """ The text/label for this choice. """ 36 37 if ((feedback is not None) and feedback.is_empty()): 38 feedback = None 39 40 self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback 41 """ Feedback specific to this choice. """ 42 43 def to_pod(self, 44 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 45 ) -> edq.util.serial.PODType: 46 if (self.feedback is None): 47 return self.text.to_pod(context) 48 49 return { 50 'text': self.text.to_pod(context), 51 'feedback': self.feedback.to_pod(context), 52 } 53 54 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 55 """ Collect all documents in this object. """ 56 57 documents = [self.text] 58 59 if (self.feedback is not None): 60 documents += self.feedback.collect_documents() 61 62 return documents 63 64 @classmethod 65 def from_pod(cls: typing.Type['TextOption'], 66 data: edq.util.serial.PODType, 67 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 68 ) -> 'TextOption': 69 if (context is None): 70 context = edq.util.serial.SerializationContext() 71 72 label = context.extra.get('label', '') 73 74 if (isinstance(data, str)): 75 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context) 76 return TextOption(parsed_text, None) 77 78 if (not isinstance(data, dict)): 79 raise quizcomp.model.errors.QuestionValidationError( 80 f"{label} has text in an unknown format (not a string or dict): '{data}' (type: {type(data)}.", 81 context = context) 82 83 raw_text = data.get('text', None) 84 if (raw_text is None): 85 raise quizcomp.model.errors.QuestionValidationError( 86 f"{label} has no 'text' field set.", 87 context = context) 88 89 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(str(raw_text), context) 90 feedback = quizcomp.model.feedback.Feedback.from_raw_data(data.get('feedback', None), context) 91 92 return TextOption(parsed_text, feedback) 93 94 @classmethod 95 def from_pod_with_error(cls: typing.Type['TextOption'], 96 data: edq.util.serial.PODType, 97 label: str, 98 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 99 ) -> 'TextOption': 100 """ Wrap from_pod() with some error information. """ 101 102 if (context is None): 103 context = edq.util.serial.SerializationContext() 104 else: 105 context = context.copy() 106 107 context.extra['label'] = label 108 109 return cls.from_pod(data, context) 110 111class NumericOption(edq.util.serial.PODConverter, abc.ABC): 112 """ 113 One possible numeric answer to a question. 114 """ 115 116 serialization_omit_none = True 117 serialization_omit_empty = True 118 119 def __init__(self, 120 type: NumericAnswerType, 121 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 122 **kwargs: typing.Any) -> None: 123 self.type: NumericAnswerType = type 124 """ The type of numeric answer. """ 125 126 if ((feedback is not None) and feedback.is_empty()): 127 feedback = None 128 129 self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback 130 """ Feedback specific to this choice. """ 131 132 @abc.abstractmethod 133 def to_text(self) -> TextOption: 134 """ Get a textual representation of this option. """ 135 136 @classmethod 137 def from_pod(cls: typing.Type['NumericOption'], 138 data: edq.util.serial.PODType, 139 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 140 ) -> 'NumericOption': 141 if (context is None): 142 context = edq.util.serial.SerializationContext() 143 144 label = context.extra.get('label', '') 145 146 quizcomp.model.errors.check_type(data, dict, label, context = context) 147 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 148 149 raw_answer_type = dict_data.get('type', None) 150 if (not edq.util.enum.has_value(NumericAnswerType, raw_answer_type)): 151 raise quizcomp.model.errors.QuestionValidationError( 152 f"{label} has an unknown answer type: '{raw_answer_type}'.", 153 context = context) 154 155 answer_type = NumericAnswerType(raw_answer_type) 156 157 feedback = quizcomp.model.feedback.Feedback.from_raw_data(dict_data.get('feedback', None), context = context) 158 159 if (answer_type == NumericAnswerType.EXACT): 160 value = dict_data.get('value', None) 161 if (value is None): 162 raise quizcomp.model.errors.QuestionValidationError( 163 f"{label} does not have a required 'value' key.", 164 context = context) 165 166 if (not isinstance(value, (int, float))): 167 raise quizcomp.model.errors.QuestionValidationError( 168 f"{label} has a 'value' that is not an int or float, found '{type(value)}'.", 169 context = context) 170 171 margin = dict_data.get('margin', 0.0) 172 if (not isinstance(margin, (int, float))): 173 raise quizcomp.model.errors.QuestionValidationError( 174 f"{label} has a 'margin' that is not an int or float, found '{type(margin)}'.", 175 context = context) 176 177 return NumericOptionExact(value, margin, feedback = feedback) 178 elif (answer_type == NumericAnswerType.RANGE): 179 min = dict_data.get('min', None) 180 if (min is None): 181 raise quizcomp.model.errors.QuestionValidationError( 182 f"{label} does not have a required 'min' key.", 183 context = context) 184 185 if (not isinstance(min, (int, float))): 186 raise quizcomp.model.errors.QuestionValidationError( 187 f"{label} has a 'min' that is not an int or float, found '{type(min)}'.", 188 context = context) 189 190 max = dict_data.get('max', None) 191 if (max is None): 192 raise quizcomp.model.errors.QuestionValidationError( 193 f"{label} does not have a required 'max' key.", 194 context = context) 195 196 if (not isinstance(max, (int, float))): 197 raise quizcomp.model.errors.QuestionValidationError( 198 f"{label} has a 'max' that is not an int or float, found '{type(max)}'.", 199 context = context) 200 201 return NumericOptionRange(min, max, feedback = feedback) 202 elif (answer_type == NumericAnswerType.PRECISION): 203 value = dict_data.get('value', None) 204 if (value is None): 205 raise quizcomp.model.errors.QuestionValidationError( 206 f"{label} does not have a required 'value' key.", 207 context = context) 208 209 if (not isinstance(value, (int, float))): 210 raise quizcomp.model.errors.QuestionValidationError( 211 f"{label} has a 'value' that is not an int or float, found '{type(value)}'.", 212 context = context) 213 214 precision = dict_data.get('precision', None) 215 if (precision is None): 216 raise quizcomp.model.errors.QuestionValidationError( 217 f"{label} does not have a required 'precision' key.", 218 context = context) 219 220 if (not isinstance(precision, int)): 221 raise quizcomp.model.errors.QuestionValidationError( 222 f"{label} has a 'precision' that is not an int, found '{type(precision)}'.", 223 context = context) 224 225 return NumericOptionPrecision(value, precision, feedback = feedback) 226 else: 227 raise quizcomp.model.errors.QuestionValidationError( 228 f"{label} has an unknown answer type: '{answer_type}'.", 229 context = context) 230 231 @classmethod 232 def from_pod_with_error(cls: typing.Type['NumericOption'], 233 data: edq.util.serial.PODType, 234 label: str, 235 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 236 ) -> 'NumericOption': 237 """ Wrap from_pod() with some error information. """ 238 239 if (context is None): 240 context = edq.util.serial.SerializationContext() 241 else: 242 context = context.copy() 243 244 context.extra['label'] = label 245 246 return cls.from_pod(data, context) 247 248class NumericOptionExact(NumericOption): 249 """ A numeric option to represent an exact value (within a margin). """ 250 251 def __init__(self, 252 value: typing.Union[float, int], 253 margin: typing.Union[float, int] = 0.0, 254 **kwargs: typing.Any) -> None: 255 super().__init__(type = NumericAnswerType.EXACT, **kwargs) 256 257 self.value: typing.Union[float, int] = value 258 """ The value for this answer. """ 259 260 self.margin: typing.Union[float, int] = margin 261 """ The allowed margin or error for this answer. """ 262 263 def to_text(self) -> TextOption: 264 text = str(self.value) 265 if (not math.isclose(self.margin, 0.0)): 266 text += f" ± {self.margin}" 267 268 return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback) 269 270class NumericOptionRange(NumericOption): 271 """ A numeric option to represent a value within a range. """ 272 273 def __init__(self, 274 min: typing.Union[float, int], 275 max: typing.Union[float, int], 276 **kwargs: typing.Any) -> None: 277 super().__init__(type = NumericAnswerType.RANGE, **kwargs) 278 279 self.min: typing.Union[float, int] = min 280 """ The minimum allowed value. """ 281 282 self.max: typing.Union[float, int] = max 283 """ The maximum allowed value. """ 284 285 def to_text(self) -> TextOption: 286 text = f"[{self.min}, {self.max}]" 287 return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback) 288 289class NumericOptionPrecision(NumericOption): 290 """ A numeric option to represent a value within a specified order of magnitudes. """ 291 292 def __init__(self, 293 value: typing.Union[float, int], 294 precision: int, 295 **kwargs: typing.Any) -> None: 296 super().__init__(type = NumericAnswerType.PRECISION, **kwargs) 297 298 self.value: typing.Union[float, int] = value 299 """ The value for this answer. """ 300 301 self.precision: int = precision 302 """ The number of order of magnitudes allowed. """ 303 304 def to_text(self) -> TextOption: 305 text = str(self.value) 306 if (self.precision != 1): 307 text += f" ({self.precision} decimal places)" 308 309 return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback) 310 311class Choice(TextOption): 312 """ 313 One possible choice for an answer. 314 This is for questions with a finite number of choices (e.g., MCQ, MA, TF). 315 """ 316 317 serialization_omit_none = True 318 319 def __init__(self, 320 text: quizcomp.parser.document.ParsedDocument, 321 correct: bool, 322 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 323 **kwargs: typing.Any) -> None: 324 super().__init__(text, feedback) 325 326 self.correct: bool = correct 327 """ Whether this choice is a correct answer. """ 328 329 def to_pod(self, 330 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 331 ) -> edq.util.serial.PODType: 332 data: typing.Dict[str, edq.util.serial.PODType] = { 333 'text': self.text.to_pod(context), 334 'correct': self.correct, 335 } 336 337 if (self.feedback is not None): 338 data['feedback'] = self.feedback.to_pod(context) 339 340 return data 341 342class QuestionAnswers(edq.util.serial.PODConverter): 343 """ 344 The base type that represents all the listed answers/choices for a question. 345 The exact contents of answers vary depending on the question's type. 346 """ 347 348 def shuffle(self, rng: random.Random) -> None: 349 """ Shuffle the choices/options (if applicable). """ 350 351 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 352 """ Collect all documents in this object. """ 353 354 return [] 355 356 @classmethod 357 def from_pod(cls: typing.Type['QuestionAnswers'], 358 data: edq.util.serial.PODType, 359 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 360 ) -> 'QuestionAnswers': 361 """ 362 Create an answers object for a specific question type from some serialized data 363 This data will normally come from a JSON file. 364 Because of the differing nature of questions, several different forms of answers may need to be processed 365 (even for the same question type). 366 367 This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers. 368 """ 369 370 if (context is None): 371 context = edq.util.serial.SerializationContext() 372 373 raw_question_type = context.extra.get('question_type', None) 374 375 if (raw_question_type is None): 376 raise quizcomp.model.errors.QuestionValidationError( 377 "Could not parse question answers because of lack of question type.", 378 context = context) 379 380 question_type = quizcomp.model.constants.QuestionType(raw_question_type) 381 382 if (question_type == quizcomp.model.constants.QuestionType.ESSAY): 383 return TextAnswers.from_pod(data, context) 384 elif (question_type == quizcomp.model.constants.QuestionType.FIMB): 385 return MultiplePartTextAnswers.from_pod(data, context) 386 elif (question_type == quizcomp.model.constants.QuestionType.FITB): 387 return TextAnswers.from_pod(data, context) 388 elif (question_type == quizcomp.model.constants.QuestionType.MA): 389 context = context.copy() 390 context.extra['min_correct'] = 0 391 return ChoiceAnswers.from_pod(data, context) 392 elif (question_type == quizcomp.model.constants.QuestionType.MATCHING): 393 return MatchingAnswers.from_pod(data, context) 394 elif (question_type == quizcomp.model.constants.QuestionType.MCQ): 395 context = context.copy() 396 context.extra['min_correct'] = 1 397 context.extra['max_correct'] = 1 398 return ChoiceAnswers.from_pod(data, context) 399 elif (question_type == quizcomp.model.constants.QuestionType.MDD): 400 context = context.copy() 401 context.extra['min_correct'] = 1 402 context.extra['max_correct'] = 1 403 return MultiplePartChoiceAnswers.from_pod(data, context) 404 elif (question_type == quizcomp.model.constants.QuestionType.NUMERICAL): 405 return NumericAnswers.from_pod(data, context) 406 elif (question_type == quizcomp.model.constants.QuestionType.SA): 407 return TextAnswers.from_pod(data, context) 408 elif (question_type == quizcomp.model.constants.QuestionType.TEXT_ONLY): 409 return TextAnswers.from_pod(data, context) 410 elif (question_type == quizcomp.model.constants.QuestionType.TF): 411 return TFAnswers.from_pod(data, context) 412 else: 413 raise quizcomp.model.errors.QuestionValidationError( 414 f"Unknown question type: '{raw_question_type}'.", 415 context = context) 416 417class TextAnswers(QuestionAnswers): 418 """ 419 Answers that include a list of possible text options. 420 Note that the text options are not choices (i.e., they are not presented in a multiple choice fashion), 421 instead they are possible answers. 422 Question types with this type of answers are often graded via text equality or manually 423 (where these answers would serve as a guide/rubric). 424 """ 425 426 def __init__(self, 427 options: typing.Union[typing.List[TextOption], None] = None, 428 **kwargs: typing.Any) -> None: 429 super().__init__(**kwargs) 430 431 if (options is None): 432 options = [] 433 434 self.options: typing.List[TextOption] = options 435 """ The possible text options. """ 436 437 def shuffle(self, rng: random.Random) -> None: 438 rng.shuffle(self.options) 439 440 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 441 documents = [] 442 443 for option in self.options: 444 documents += option.collect_documents() 445 446 return documents 447 448 def to_pod(self, 449 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 450 ) -> edq.util.serial.PODType: 451 return [option.to_pod(context) for option in self.options] 452 453 def _serialization_is_empty(self) -> bool: 454 """ A special method for the serialization library to check. """ 455 456 return (len(self.options) == 0) 457 458 def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument: 459 """ 460 Get the text document for the first option. 461 If there is no option, return an empty document. 462 """ 463 464 if (len(self.options) == 0): 465 return quizcomp.parser.document.ParsedDocument() 466 467 return self.options[0].text 468 469 @classmethod 470 def from_pod(cls: typing.Type['TextAnswers'], 471 data: edq.util.serial.PODType, 472 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 473 ) -> 'TextAnswers': 474 if (data is None): 475 return TextAnswers() 476 477 if (context is None): 478 context = edq.util.serial.SerializationContext() 479 480 if (isinstance(data, str)): 481 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context) 482 return TextAnswers([TextOption(parsed_text, None)]) 483 484 if (isinstance(data, dict)): 485 data = [data] 486 487 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 488 list_data = typing.cast(typing.List[edq.util.serial.PODType], data) 489 490 if (len(list_data) == 0): 491 return TextAnswers() 492 493 options = [] 494 for (i, raw_option) in enumerate(list_data): 495 label = f"Choice at index {i}" 496 497 option = TextOption.from_pod_with_error(raw_option, label, context) 498 options.append(option) 499 500 return TextAnswers(options) 501 502class MultiplePartTextAnswers(QuestionAnswers): 503 """ 504 Answers that have multiple parts, each having their own text-based answers. 505 """ 506 507 def __init__(self, 508 parts: typing.Dict[str, TextAnswers], 509 *args: typing.Any, 510 **kwargs: typing.Any) -> None: 511 super().__init__(*args, **kwargs) 512 513 self.parts: typing.Dict[str, TextAnswers] = parts 514 """ The different parts of this question. """ 515 516 def shuffle(self, rng: random.Random) -> None: 517 for part in self.parts.values(): 518 part.shuffle(rng) 519 520 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 521 documents = [] 522 523 for part in self.parts.values(): 524 documents += part.collect_documents() 525 526 return documents 527 528 def to_pod(self, 529 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 530 ) -> edq.util.serial.PODType: 531 return {key: value.to_pod(context) for (key, value) in self.parts.items()} 532 533 @classmethod 534 def from_pod(cls: typing.Type['MultiplePartTextAnswers'], 535 data: edq.util.serial.PODType, 536 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 537 ) -> 'MultiplePartTextAnswers': 538 if (context is None): 539 context = edq.util.serial.SerializationContext() 540 541 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 542 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 543 544 parts = {} 545 for (key, raw_options) in dict_data.items(): 546 # Try to parse the key, even though we are not storing it right now. 547 quizcomp.parser.document.ParsedDocument.parse_text(key, context) 548 549 parts[key] = TextAnswers.from_pod(raw_options, context) 550 551 return MultiplePartTextAnswers(parts) 552 553class ChoiceAnswers(QuestionAnswers): 554 """ Answers that include a finite set of choices. """ 555 556 def __init__(self, 557 choices: typing.List[Choice], 558 *args: typing.Any, 559 **kwargs: typing.Any) -> None: 560 super().__init__(*args, **kwargs) 561 562 self.choices: typing.List[Choice] = choices 563 """ The possible choices. """ 564 565 def shuffle(self, rng: random.Random) -> None: 566 rng.shuffle(self.choices) 567 568 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 569 documents = [] 570 571 for choice in self.choices: 572 documents += choice.collect_documents() 573 574 return documents 575 576 def to_pod(self, 577 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 578 ) -> edq.util.serial.PODType: 579 return [choice.to_pod(context) for choice in self.choices] 580 581 def get_choices_with_markers(self) -> typing.List[typing.Tuple[quizcomp.parser.document.ParsedDocument, Choice]]: 582 """ Get the choices for this answer along with markers for each (e.g., "A", "B", "C"). """ 583 584 return [ 585 (quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]), choice) 586 for (i, choice) 587 in enumerate(self.choices) 588 ] 589 590 @classmethod 591 def from_pod(cls: typing.Type['ChoiceAnswers'], 592 data: edq.util.serial.PODType, 593 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 594 ) -> 'ChoiceAnswers': 595 if (context is None): 596 context = edq.util.serial.SerializationContext() 597 598 min_correct = context.extra.get('min_correct', 0) 599 max_correct = context.extra.get('max_correct', quizcomp.model.constants.MAX_CHOICES) 600 min_incorrect = context.extra.get('min_incorrect', 0) 601 max_incorrect = context.extra.get('max_incorrect', quizcomp.model.constants.MAX_CHOICES) 602 603 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 604 raw_choices = typing.cast(typing.List[edq.util.serial.PODType], data) 605 606 if (len(raw_choices) == 0): 607 raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context) 608 609 num_correct = 0 610 num_incorrect = 0 611 612 choices = [] 613 614 for (i, raw_choice) in enumerate(raw_choices): 615 label = f"Choice at index {i}" 616 617 quizcomp.model.errors.check_type(raw_choice, dict, label, context = context) 618 choice_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], raw_choice) 619 620 raw_correct = choice_data.get('correct', None) 621 if (raw_correct is None): 622 raise quizcomp.model.errors.QuestionValidationError(f"{label} has no 'correct' field set.", context = context) 623 624 correct = edq.util.parse.soft_boolean(raw_correct) 625 if (correct is None): 626 raise quizcomp.model.errors.QuestionValidationError( 627 f"{label}'s 'correct' field does not contain a boolean: '{raw_correct}'.", 628 context = context) 629 630 if (correct): 631 num_correct += 1 632 else: 633 num_incorrect += 1 634 635 option = TextOption.from_pod_with_error(choice_data, label, context) 636 choices.append(Choice(option.text, correct, option.feedback)) 637 638 if (num_correct < min_correct): 639 raise quizcomp.model.errors.QuestionValidationError(("Did not find enough correct choices." 640 + f" Expected at least {min_correct}, found {num_correct}."), 641 context = context) 642 643 if (num_correct > max_correct): 644 raise quizcomp.model.errors.QuestionValidationError(("Found too many correct choices." 645 + f" Expected at most {max_correct}, found {num_correct}."), 646 context = context) 647 648 if (num_incorrect < min_incorrect): 649 raise quizcomp.model.errors.QuestionValidationError(("Did not find enough incorrect choices." 650 + f" Expected at least {min_incorrect}, found {num_incorrect}."), 651 context = context) 652 653 if (num_incorrect > max_incorrect): 654 raise quizcomp.model.errors.QuestionValidationError(("Found too many incorrect choices." 655 + f" Expected at most {max_incorrect}, found {num_incorrect}."), 656 context = context) 657 658 return ChoiceAnswers(choices) 659 660class TFAnswers(ChoiceAnswers): 661 """ Answers that must be true or false. """ 662 663 def __init__(self, 664 *args: typing.Any, 665 **kwargs: typing.Any) -> None: 666 super().__init__(*args, **kwargs) 667 668 @classmethod 669 def from_pod(cls: typing.Type['TFAnswers'], 670 data: edq.util.serial.PODType, 671 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 672 ) -> 'TFAnswers': 673 if (context is None): 674 context = edq.util.serial.SerializationContext() 675 else: 676 context = context.copy() 677 678 if (isinstance(data, bool)): 679 choices = [ 680 Choice(quizcomp.parser.document.ParsedDocument.parse_text("True", context), data), 681 Choice(quizcomp.parser.document.ParsedDocument.parse_text("False", context), (not data)), 682 ] 683 return TFAnswers(choices) 684 685 context.extra['min_correct'] = 1 686 context.extra['max_correct'] = 1 687 context.extra['min_incorrect'] = 1 688 context.extra['max_incorrect'] = 1 689 690 answers = ChoiceAnswers.from_pod(data, context) 691 692 return TFAnswers(answers.choices) 693 694class MultiplePartChoiceAnswers(QuestionAnswers): 695 """ 696 Answers that have multiple parts, each having their own choice-based answers. 697 """ 698 699 def __init__(self, 700 parts: typing.Dict[str, ChoiceAnswers], 701 *args: typing.Any, 702 **kwargs: typing.Any) -> None: 703 super().__init__(*args, **kwargs) 704 705 self.parts: typing.Dict[str, ChoiceAnswers] = parts 706 """ The different parts of this question. """ 707 708 def shuffle(self, rng: random.Random) -> None: 709 for part in self.parts.values(): 710 part.shuffle(rng) 711 712 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 713 documents = [] 714 715 for part in self.parts.values(): 716 documents += part.collect_documents() 717 718 return documents 719 720 def to_pod(self, 721 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 722 ) -> edq.util.serial.PODType: 723 return {key: value.to_pod(context) for (key, value) in self.parts.items()} 724 725 @classmethod 726 def from_pod(cls: typing.Type['MultiplePartChoiceAnswers'], 727 data: edq.util.serial.PODType, 728 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 729 ) -> 'MultiplePartChoiceAnswers': 730 if (context is None): 731 context = edq.util.serial.SerializationContext() 732 733 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 734 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 735 736 parts = {} 737 for (key, raw_options) in dict_data.items(): 738 # Try to parse the key, even though we are not storing it right now. 739 quizcomp.parser.document.ParsedDocument.parse_text(key, context) 740 741 parts[key] = ChoiceAnswers.from_pod(raw_options, context) 742 743 return MultiplePartChoiceAnswers(parts) 744 745class MatchingAnswerRow: 746 """ 747 A single "row" when writing out a matching problem as a table. 748 """ 749 750 def __init__(self, 751 left: typing.Union[TextOption, None], 752 right: TextOption, 753 right_marker: quizcomp.parser.document.ParsedDocument, 754 correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None], 755 correct_option: typing.Union[TextOption, None], 756 ) -> None: 757 self.left: typing.Union[TextOption, None] = left 758 """ 759 The query part of the match that needs to find its partner. 760 This may be None if there are distractors. 761 """ 762 763 self.right: TextOption = right 764 """ 765 The target part of the match. 766 Note that this may NOT be the correct partner to `self.left`, 767 it is just the target that should appear on the same row. 768 """ 769 770 self.right_marker: quizcomp.parser.document.ParsedDocument = right_marker 771 """ 772 The marker that accompanies this target. 773 """ 774 775 self.correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None] = correct_marker 776 """ 777 The marker for the correct partner to `self.left`. 778 Will be None if `self.left` is None. 779 """ 780 781 self.correct_option: typing.Union[TextOption, None] = correct_option 782 """ 783 The text for the correct partner to `self.left`. 784 Will be None if `self.left` is None. 785 """ 786 787class MatchingAnswers(QuestionAnswers): 788 """ Answers for matching-type questions. """ 789 790 serialization_omit_empty = True 791 serialization_skip_fields = { 792 '_shuffle_seed', 793 } 794 795 def __init__(self, 796 pairs: typing.List[typing.Tuple[TextOption, TextOption]], 797 distractors: typing.Union[typing.List[TextOption], None] = None, 798 **kwargs: typing.Any) -> None: 799 super().__init__(**kwargs) 800 801 self.pairs: typing.List[typing.Tuple[TextOption, TextOption]] = pairs 802 """ The matching pairs of items. """ 803 804 if (distractors is None): 805 distractors = [] 806 807 self.distractors: typing.List[TextOption] = distractors 808 """ Extra options to serve as a distraction. """ 809 810 self._shuffle_seed: typing.Union[int, None] = None 811 """ 812 A seed to use when shuffling the left and right sides. 813 814 This will be set in shuffle(). 815 A None value indicates that no shuffling will occur. 816 """ 817 818 def shuffle(self, rng: random.Random) -> None: 819 rng.shuffle(self.pairs) 820 rng.shuffle(self.distractors) 821 self._shuffle_seed = rng.randint(0, 2**64) 822 823 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 824 documents = [] 825 826 for (left, right) in self.pairs: 827 documents += left.collect_documents() 828 documents += right.collect_documents() 829 830 for distractor in self.distractors: 831 documents += distractor.collect_documents() 832 833 return documents 834 835 def to_pod(self, 836 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 837 ) -> edq.util.serial.PODType: 838 return { 839 'matches': [[left.to_pod(context), right.to_pod(context)] for (left, right) in self.pairs], 840 'distractors': [value.to_pod(context) for value in self.distractors], 841 } 842 843 def get_tabular_options(self) -> typing.List[MatchingAnswerRow]: 844 """ 845 Get all the matching options laid out in a table. 846 847 If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table. 848 """ 849 850 lefts = [] 851 rights = [] 852 853 for (pair_left, pair_right) in self.pairs: 854 lefts.append(pair_left) 855 rights.append(pair_right) 856 857 for distractor in self.distractors: 858 rights.append(distractor) 859 860 # The ordered indexes to use in the options table. 861 # This may be shuffled. 862 left_indexes = list(range(len(lefts))) 863 right_indexes = list(range(len(rights))) 864 865 if (self._shuffle_seed is not None): 866 rng = random.Random(self._shuffle_seed) 867 rng.shuffle(left_indexes) 868 rng.shuffle(right_indexes) 869 870 options = [] 871 for (i, right_index) in enumerate(right_indexes): 872 right = rights[right_index] 873 right_marker = quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]) 874 875 left: typing.Union[TextOption, None] = None 876 left_marker = None 877 correct_answer = None 878 if (i < len(left_indexes)): 879 left_index = left_indexes[i] 880 881 # Find the correct marker index for this left by looking up the matching index in the right indexes. 882 matching_right_marker_index = right_indexes.index(left_index) 883 884 left = lefts[left_index] 885 left_marker = quizcomp.parser.document.ParsedDocument.parse_text( 886 quizcomp.model.constants.DEFAULT_CHOICES[matching_right_marker_index]) 887 correct_answer = rights[left_index] 888 889 options.append(MatchingAnswerRow(left, right, right_marker, left_marker, correct_answer)) 890 891 return options 892 893 @classmethod 894 def from_pod(cls: typing.Type['MatchingAnswers'], 895 data: edq.util.serial.PODType, 896 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 897 ) -> 'MatchingAnswers': 898 if (context is None): 899 context = edq.util.serial.SerializationContext() 900 901 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 902 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 903 904 raw_matches = dict_data.get('matches', None) 905 if (raw_matches is None): 906 raise quizcomp.model.errors.QuestionValidationError( 907 "The 'matches' key was not provided for a matching-type question.", 908 context = context) 909 910 quizcomp.model.errors.check_type(raw_matches, list, "'matches'", context = context) 911 matches = typing.cast(typing.List[edq.util.serial.PODType], raw_matches) 912 913 if (len(matches) == 0): 914 raise quizcomp.model.errors.QuestionValidationError( 915 "At least one matching pair must be specified for matching questions.", 916 context = context) 917 918 pairs = [] 919 for (i, raw_match) in enumerate(matches): 920 label = f"Match pair at index {i}" 921 922 if (isinstance(raw_match, list)): 923 if (len(raw_match) != 2): 924 raise quizcomp.model.errors.QuestionValidationError( 925 f"{label} has an unexpected size. Expecting two items (left and right) found {len(raw_match)}.", 926 context = context) 927 928 left_option = TextOption.from_pod_with_error(raw_match[0], label + ' (left)', context) 929 right_option = TextOption.from_pod_with_error(raw_match[1], label + ' (right)', context) 930 931 pairs.append((left_option, right_option)) 932 elif (isinstance(raw_match, dict)): 933 if ('left' not in raw_match): 934 raise quizcomp.model.errors.QuestionValidationError( 935 f"{label} does not have a 'left' key.", 936 context = context) 937 938 if ('right' not in raw_match): 939 raise quizcomp.model.errors.QuestionValidationError( 940 f"{label} does not have a 'right' key.", 941 context = context) 942 943 left_option = TextOption.from_pod_with_error(raw_match['left'], label + ' (left)', context) 944 right_option = TextOption.from_pod_with_error(raw_match['right'], label + ' (right)', context) 945 946 pairs.append((left_option, right_option)) 947 else: 948 raise quizcomp.model.errors.QuestionValidationError( 949 f"{label} has an unknown format (not a list or dict): '{raw_match}' (type: {type(raw_match)}.", 950 context = context) 951 952 raw_distractors = dict_data.get('distractors', None) 953 if (raw_distractors is None): 954 raw_distractors = [] 955 956 quizcomp.model.errors.check_type(raw_distractors, list, "'distractors'", context = context) 957 distractors = typing.cast(typing.List[edq.util.serial.PODType], raw_distractors) 958 959 claen_distractors = [] 960 for (i, raw_distractor) in enumerate(distractors): 961 label = f"Match distractor at index {i}" 962 963 option = TextOption.from_pod_with_error(raw_distractor, label, context) 964 claen_distractors.append(option) 965 966 if ((len(pairs) + len(claen_distractors)) > quizcomp.model.constants.MAX_CHOICES): 967 raise quizcomp.model.errors.QuestionValidationError( 968 (f"Matching question has too many options. Found {(len(pairs) + len(claen_distractors))}," 969 f" while the max is {quizcomp.model.constants.MAX_CHOICES}."), 970 context = context) 971 972 return MatchingAnswers(pairs, claen_distractors) 973 974class NumericAnswers(QuestionAnswers): 975 """ Answers that include a finite set of numeric options. """ 976 977 def __init__(self, 978 options: typing.List[NumericOption], 979 *args: typing.Any, 980 **kwargs: typing.Any) -> None: 981 super().__init__(*args, **kwargs) 982 983 self.options: typing.List[NumericOption] = options 984 """ The possible options. """ 985 986 def shuffle(self, rng: random.Random) -> None: 987 rng.shuffle(self.options) 988 989 def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument: 990 """ 991 Get the text document for the first option. 992 If there is no option, return an empty document. 993 """ 994 995 if (len(self.options) == 0): 996 return quizcomp.parser.document.ParsedDocument() 997 998 return self.options[0].to_text().text 999 1000 def to_pod(self, 1001 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 1002 ) -> edq.util.serial.PODType: 1003 return [option.to_pod(context) for option in self.options] 1004 1005 @classmethod 1006 def from_pod(cls: typing.Type['NumericAnswers'], 1007 data: edq.util.serial.PODType, 1008 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 1009 ) -> 'NumericAnswers': 1010 if (context is None): 1011 context = edq.util.serial.SerializationContext() 1012 1013 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 1014 raw_options = typing.cast(typing.List[edq.util.serial.PODType], data) 1015 1016 if (len(raw_options) == 0): 1017 raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context) 1018 1019 options = [] 1020 for (i, raw_option) in enumerate(raw_options): 1021 label = f"Option at index {i}" 1022 1023 option = NumericOption.from_pod_with_error(raw_option, label, context) 1024 options.append(option) 1025 1026 return NumericAnswers(options)
16class NumericAnswerType(enum.Enum): 17 """ The types of numeric answers supported by the Quiz Composer. """ 18 19 EXACT = 'exact' 20 RANGE = 'range' 21 PRECISION = 'precision'
The types of numeric answers supported by the Quiz Composer.
23class TextOption(edq.util.serial.PODConverter): 24 """ 25 One possible text answer to a question. 26 """ 27 28 serialization_omit_none = True 29 serialization_omit_empty = True 30 31 def __init__(self, 32 text: quizcomp.parser.document.ParsedDocument, 33 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 34 **kwargs: typing.Any) -> None: 35 self.text: quizcomp.parser.document.ParsedDocument = text 36 """ The text/label for this choice. """ 37 38 if ((feedback is not None) and feedback.is_empty()): 39 feedback = None 40 41 self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback 42 """ Feedback specific to this choice. """ 43 44 def to_pod(self, 45 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 46 ) -> edq.util.serial.PODType: 47 if (self.feedback is None): 48 return self.text.to_pod(context) 49 50 return { 51 'text': self.text.to_pod(context), 52 'feedback': self.feedback.to_pod(context), 53 } 54 55 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 56 """ Collect all documents in this object. """ 57 58 documents = [self.text] 59 60 if (self.feedback is not None): 61 documents += self.feedback.collect_documents() 62 63 return documents 64 65 @classmethod 66 def from_pod(cls: typing.Type['TextOption'], 67 data: edq.util.serial.PODType, 68 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 69 ) -> 'TextOption': 70 if (context is None): 71 context = edq.util.serial.SerializationContext() 72 73 label = context.extra.get('label', '') 74 75 if (isinstance(data, str)): 76 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context) 77 return TextOption(parsed_text, None) 78 79 if (not isinstance(data, dict)): 80 raise quizcomp.model.errors.QuestionValidationError( 81 f"{label} has text in an unknown format (not a string or dict): '{data}' (type: {type(data)}.", 82 context = context) 83 84 raw_text = data.get('text', None) 85 if (raw_text is None): 86 raise quizcomp.model.errors.QuestionValidationError( 87 f"{label} has no 'text' field set.", 88 context = context) 89 90 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(str(raw_text), context) 91 feedback = quizcomp.model.feedback.Feedback.from_raw_data(data.get('feedback', None), context) 92 93 return TextOption(parsed_text, feedback) 94 95 @classmethod 96 def from_pod_with_error(cls: typing.Type['TextOption'], 97 data: edq.util.serial.PODType, 98 label: str, 99 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 100 ) -> 'TextOption': 101 """ Wrap from_pod() with some error information. """ 102 103 if (context is None): 104 context = edq.util.serial.SerializationContext() 105 else: 106 context = context.copy() 107 108 context.extra['label'] = label 109 110 return cls.from_pod(data, context)
One possible text answer to a question.
31 def __init__(self, 32 text: quizcomp.parser.document.ParsedDocument, 33 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 34 **kwargs: typing.Any) -> None: 35 self.text: quizcomp.parser.document.ParsedDocument = text 36 """ The text/label for this choice. """ 37 38 if ((feedback is not None) and feedback.is_empty()): 39 feedback = None 40 41 self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback 42 """ Feedback specific to this choice. """
Do not include empty fields in serialization. An empty field meets one of the following conditions:
- Has a
__len__method which returns 0. - Has a
_serialization_is_emptymethod that returns true.
44 def to_pod(self, 45 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 46 ) -> edq.util.serial.PODType: 47 if (self.feedback is None): 48 return self.text.to_pod(context) 49 50 return { 51 'text': self.text.to_pod(context), 52 'feedback': self.feedback.to_pod(context), 53 }
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
55 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 56 """ Collect all documents in this object. """ 57 58 documents = [self.text] 59 60 if (self.feedback is not None): 61 documents += self.feedback.collect_documents() 62 63 return documents
Collect all documents in this object.
65 @classmethod 66 def from_pod(cls: typing.Type['TextOption'], 67 data: edq.util.serial.PODType, 68 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 69 ) -> 'TextOption': 70 if (context is None): 71 context = edq.util.serial.SerializationContext() 72 73 label = context.extra.get('label', '') 74 75 if (isinstance(data, str)): 76 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context) 77 return TextOption(parsed_text, None) 78 79 if (not isinstance(data, dict)): 80 raise quizcomp.model.errors.QuestionValidationError( 81 f"{label} has text in an unknown format (not a string or dict): '{data}' (type: {type(data)}.", 82 context = context) 83 84 raw_text = data.get('text', None) 85 if (raw_text is None): 86 raise quizcomp.model.errors.QuestionValidationError( 87 f"{label} has no 'text' field set.", 88 context = context) 89 90 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(str(raw_text), context) 91 feedback = quizcomp.model.feedback.Feedback.from_raw_data(data.get('feedback', None), context) 92 93 return TextOption(parsed_text, feedback)
Create an instance of this class from a POD.
The default implementation will call the class' constructor with one of two things: a splat/unpacking (**) of the incoming data if the data is a dict, otherwise the data itself.
95 @classmethod 96 def from_pod_with_error(cls: typing.Type['TextOption'], 97 data: edq.util.serial.PODType, 98 label: str, 99 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 100 ) -> 'TextOption': 101 """ Wrap from_pod() with some error information. """ 102 103 if (context is None): 104 context = edq.util.serial.SerializationContext() 105 else: 106 context = context.copy() 107 108 context.extra['label'] = label 109 110 return cls.from_pod(data, context)
Wrap from_pod() with some error information.
112class NumericOption(edq.util.serial.PODConverter, abc.ABC): 113 """ 114 One possible numeric answer to a question. 115 """ 116 117 serialization_omit_none = True 118 serialization_omit_empty = True 119 120 def __init__(self, 121 type: NumericAnswerType, 122 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 123 **kwargs: typing.Any) -> None: 124 self.type: NumericAnswerType = type 125 """ The type of numeric answer. """ 126 127 if ((feedback is not None) and feedback.is_empty()): 128 feedback = None 129 130 self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback 131 """ Feedback specific to this choice. """ 132 133 @abc.abstractmethod 134 def to_text(self) -> TextOption: 135 """ Get a textual representation of this option. """ 136 137 @classmethod 138 def from_pod(cls: typing.Type['NumericOption'], 139 data: edq.util.serial.PODType, 140 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 141 ) -> 'NumericOption': 142 if (context is None): 143 context = edq.util.serial.SerializationContext() 144 145 label = context.extra.get('label', '') 146 147 quizcomp.model.errors.check_type(data, dict, label, context = context) 148 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 149 150 raw_answer_type = dict_data.get('type', None) 151 if (not edq.util.enum.has_value(NumericAnswerType, raw_answer_type)): 152 raise quizcomp.model.errors.QuestionValidationError( 153 f"{label} has an unknown answer type: '{raw_answer_type}'.", 154 context = context) 155 156 answer_type = NumericAnswerType(raw_answer_type) 157 158 feedback = quizcomp.model.feedback.Feedback.from_raw_data(dict_data.get('feedback', None), context = context) 159 160 if (answer_type == NumericAnswerType.EXACT): 161 value = dict_data.get('value', None) 162 if (value is None): 163 raise quizcomp.model.errors.QuestionValidationError( 164 f"{label} does not have a required 'value' key.", 165 context = context) 166 167 if (not isinstance(value, (int, float))): 168 raise quizcomp.model.errors.QuestionValidationError( 169 f"{label} has a 'value' that is not an int or float, found '{type(value)}'.", 170 context = context) 171 172 margin = dict_data.get('margin', 0.0) 173 if (not isinstance(margin, (int, float))): 174 raise quizcomp.model.errors.QuestionValidationError( 175 f"{label} has a 'margin' that is not an int or float, found '{type(margin)}'.", 176 context = context) 177 178 return NumericOptionExact(value, margin, feedback = feedback) 179 elif (answer_type == NumericAnswerType.RANGE): 180 min = dict_data.get('min', None) 181 if (min is None): 182 raise quizcomp.model.errors.QuestionValidationError( 183 f"{label} does not have a required 'min' key.", 184 context = context) 185 186 if (not isinstance(min, (int, float))): 187 raise quizcomp.model.errors.QuestionValidationError( 188 f"{label} has a 'min' that is not an int or float, found '{type(min)}'.", 189 context = context) 190 191 max = dict_data.get('max', None) 192 if (max is None): 193 raise quizcomp.model.errors.QuestionValidationError( 194 f"{label} does not have a required 'max' key.", 195 context = context) 196 197 if (not isinstance(max, (int, float))): 198 raise quizcomp.model.errors.QuestionValidationError( 199 f"{label} has a 'max' that is not an int or float, found '{type(max)}'.", 200 context = context) 201 202 return NumericOptionRange(min, max, feedback = feedback) 203 elif (answer_type == NumericAnswerType.PRECISION): 204 value = dict_data.get('value', None) 205 if (value is None): 206 raise quizcomp.model.errors.QuestionValidationError( 207 f"{label} does not have a required 'value' key.", 208 context = context) 209 210 if (not isinstance(value, (int, float))): 211 raise quizcomp.model.errors.QuestionValidationError( 212 f"{label} has a 'value' that is not an int or float, found '{type(value)}'.", 213 context = context) 214 215 precision = dict_data.get('precision', None) 216 if (precision is None): 217 raise quizcomp.model.errors.QuestionValidationError( 218 f"{label} does not have a required 'precision' key.", 219 context = context) 220 221 if (not isinstance(precision, int)): 222 raise quizcomp.model.errors.QuestionValidationError( 223 f"{label} has a 'precision' that is not an int, found '{type(precision)}'.", 224 context = context) 225 226 return NumericOptionPrecision(value, precision, feedback = feedback) 227 else: 228 raise quizcomp.model.errors.QuestionValidationError( 229 f"{label} has an unknown answer type: '{answer_type}'.", 230 context = context) 231 232 @classmethod 233 def from_pod_with_error(cls: typing.Type['NumericOption'], 234 data: edq.util.serial.PODType, 235 label: str, 236 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 237 ) -> 'NumericOption': 238 """ Wrap from_pod() with some error information. """ 239 240 if (context is None): 241 context = edq.util.serial.SerializationContext() 242 else: 243 context = context.copy() 244 245 context.extra['label'] = label 246 247 return cls.from_pod(data, context)
One possible numeric answer to a question.
Do not include empty fields in serialization. An empty field meets one of the following conditions:
- Has a
__len__method which returns 0. - Has a
_serialization_is_emptymethod that returns true.
133 @abc.abstractmethod 134 def to_text(self) -> TextOption: 135 """ Get a textual representation of this option. """
Get a textual representation of this option.
137 @classmethod 138 def from_pod(cls: typing.Type['NumericOption'], 139 data: edq.util.serial.PODType, 140 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 141 ) -> 'NumericOption': 142 if (context is None): 143 context = edq.util.serial.SerializationContext() 144 145 label = context.extra.get('label', '') 146 147 quizcomp.model.errors.check_type(data, dict, label, context = context) 148 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 149 150 raw_answer_type = dict_data.get('type', None) 151 if (not edq.util.enum.has_value(NumericAnswerType, raw_answer_type)): 152 raise quizcomp.model.errors.QuestionValidationError( 153 f"{label} has an unknown answer type: '{raw_answer_type}'.", 154 context = context) 155 156 answer_type = NumericAnswerType(raw_answer_type) 157 158 feedback = quizcomp.model.feedback.Feedback.from_raw_data(dict_data.get('feedback', None), context = context) 159 160 if (answer_type == NumericAnswerType.EXACT): 161 value = dict_data.get('value', None) 162 if (value is None): 163 raise quizcomp.model.errors.QuestionValidationError( 164 f"{label} does not have a required 'value' key.", 165 context = context) 166 167 if (not isinstance(value, (int, float))): 168 raise quizcomp.model.errors.QuestionValidationError( 169 f"{label} has a 'value' that is not an int or float, found '{type(value)}'.", 170 context = context) 171 172 margin = dict_data.get('margin', 0.0) 173 if (not isinstance(margin, (int, float))): 174 raise quizcomp.model.errors.QuestionValidationError( 175 f"{label} has a 'margin' that is not an int or float, found '{type(margin)}'.", 176 context = context) 177 178 return NumericOptionExact(value, margin, feedback = feedback) 179 elif (answer_type == NumericAnswerType.RANGE): 180 min = dict_data.get('min', None) 181 if (min is None): 182 raise quizcomp.model.errors.QuestionValidationError( 183 f"{label} does not have a required 'min' key.", 184 context = context) 185 186 if (not isinstance(min, (int, float))): 187 raise quizcomp.model.errors.QuestionValidationError( 188 f"{label} has a 'min' that is not an int or float, found '{type(min)}'.", 189 context = context) 190 191 max = dict_data.get('max', None) 192 if (max is None): 193 raise quizcomp.model.errors.QuestionValidationError( 194 f"{label} does not have a required 'max' key.", 195 context = context) 196 197 if (not isinstance(max, (int, float))): 198 raise quizcomp.model.errors.QuestionValidationError( 199 f"{label} has a 'max' that is not an int or float, found '{type(max)}'.", 200 context = context) 201 202 return NumericOptionRange(min, max, feedback = feedback) 203 elif (answer_type == NumericAnswerType.PRECISION): 204 value = dict_data.get('value', None) 205 if (value is None): 206 raise quizcomp.model.errors.QuestionValidationError( 207 f"{label} does not have a required 'value' key.", 208 context = context) 209 210 if (not isinstance(value, (int, float))): 211 raise quizcomp.model.errors.QuestionValidationError( 212 f"{label} has a 'value' that is not an int or float, found '{type(value)}'.", 213 context = context) 214 215 precision = dict_data.get('precision', None) 216 if (precision is None): 217 raise quizcomp.model.errors.QuestionValidationError( 218 f"{label} does not have a required 'precision' key.", 219 context = context) 220 221 if (not isinstance(precision, int)): 222 raise quizcomp.model.errors.QuestionValidationError( 223 f"{label} has a 'precision' that is not an int, found '{type(precision)}'.", 224 context = context) 225 226 return NumericOptionPrecision(value, precision, feedback = feedback) 227 else: 228 raise quizcomp.model.errors.QuestionValidationError( 229 f"{label} has an unknown answer type: '{answer_type}'.", 230 context = context)
Create an instance of this class from a POD.
The default implementation will call the class' constructor with one of two things: a splat/unpacking (**) of the incoming data if the data is a dict, otherwise the data itself.
232 @classmethod 233 def from_pod_with_error(cls: typing.Type['NumericOption'], 234 data: edq.util.serial.PODType, 235 label: str, 236 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 237 ) -> 'NumericOption': 238 """ Wrap from_pod() with some error information. """ 239 240 if (context is None): 241 context = edq.util.serial.SerializationContext() 242 else: 243 context = context.copy() 244 245 context.extra['label'] = label 246 247 return cls.from_pod(data, context)
Wrap from_pod() with some error information.
249class NumericOptionExact(NumericOption): 250 """ A numeric option to represent an exact value (within a margin). """ 251 252 def __init__(self, 253 value: typing.Union[float, int], 254 margin: typing.Union[float, int] = 0.0, 255 **kwargs: typing.Any) -> None: 256 super().__init__(type = NumericAnswerType.EXACT, **kwargs) 257 258 self.value: typing.Union[float, int] = value 259 """ The value for this answer. """ 260 261 self.margin: typing.Union[float, int] = margin 262 """ The allowed margin or error for this answer. """ 263 264 def to_text(self) -> TextOption: 265 text = str(self.value) 266 if (not math.isclose(self.margin, 0.0)): 267 text += f" ± {self.margin}" 268 269 return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)
A numeric option to represent an exact value (within a margin).
252 def __init__(self, 253 value: typing.Union[float, int], 254 margin: typing.Union[float, int] = 0.0, 255 **kwargs: typing.Any) -> None: 256 super().__init__(type = NumericAnswerType.EXACT, **kwargs) 257 258 self.value: typing.Union[float, int] = value 259 """ The value for this answer. """ 260 261 self.margin: typing.Union[float, int] = margin 262 """ The allowed margin or error for this answer. """
271class NumericOptionRange(NumericOption): 272 """ A numeric option to represent a value within a range. """ 273 274 def __init__(self, 275 min: typing.Union[float, int], 276 max: typing.Union[float, int], 277 **kwargs: typing.Any) -> None: 278 super().__init__(type = NumericAnswerType.RANGE, **kwargs) 279 280 self.min: typing.Union[float, int] = min 281 """ The minimum allowed value. """ 282 283 self.max: typing.Union[float, int] = max 284 """ The maximum allowed value. """ 285 286 def to_text(self) -> TextOption: 287 text = f"[{self.min}, {self.max}]" 288 return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)
A numeric option to represent a value within a range.
274 def __init__(self, 275 min: typing.Union[float, int], 276 max: typing.Union[float, int], 277 **kwargs: typing.Any) -> None: 278 super().__init__(type = NumericAnswerType.RANGE, **kwargs) 279 280 self.min: typing.Union[float, int] = min 281 """ The minimum allowed value. """ 282 283 self.max: typing.Union[float, int] = max 284 """ The maximum allowed value. """
290class NumericOptionPrecision(NumericOption): 291 """ A numeric option to represent a value within a specified order of magnitudes. """ 292 293 def __init__(self, 294 value: typing.Union[float, int], 295 precision: int, 296 **kwargs: typing.Any) -> None: 297 super().__init__(type = NumericAnswerType.PRECISION, **kwargs) 298 299 self.value: typing.Union[float, int] = value 300 """ The value for this answer. """ 301 302 self.precision: int = precision 303 """ The number of order of magnitudes allowed. """ 304 305 def to_text(self) -> TextOption: 306 text = str(self.value) 307 if (self.precision != 1): 308 text += f" ({self.precision} decimal places)" 309 310 return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)
A numeric option to represent a value within a specified order of magnitudes.
293 def __init__(self, 294 value: typing.Union[float, int], 295 precision: int, 296 **kwargs: typing.Any) -> None: 297 super().__init__(type = NumericAnswerType.PRECISION, **kwargs) 298 299 self.value: typing.Union[float, int] = value 300 """ The value for this answer. """ 301 302 self.precision: int = precision 303 """ The number of order of magnitudes allowed. """
312class Choice(TextOption): 313 """ 314 One possible choice for an answer. 315 This is for questions with a finite number of choices (e.g., MCQ, MA, TF). 316 """ 317 318 serialization_omit_none = True 319 320 def __init__(self, 321 text: quizcomp.parser.document.ParsedDocument, 322 correct: bool, 323 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 324 **kwargs: typing.Any) -> None: 325 super().__init__(text, feedback) 326 327 self.correct: bool = correct 328 """ Whether this choice is a correct answer. """ 329 330 def to_pod(self, 331 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 332 ) -> edq.util.serial.PODType: 333 data: typing.Dict[str, edq.util.serial.PODType] = { 334 'text': self.text.to_pod(context), 335 'correct': self.correct, 336 } 337 338 if (self.feedback is not None): 339 data['feedback'] = self.feedback.to_pod(context) 340 341 return data
One possible choice for an answer. This is for questions with a finite number of choices (e.g., MCQ, MA, TF).
320 def __init__(self, 321 text: quizcomp.parser.document.ParsedDocument, 322 correct: bool, 323 feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None, 324 **kwargs: typing.Any) -> None: 325 super().__init__(text, feedback) 326 327 self.correct: bool = correct 328 """ Whether this choice is a correct answer. """
330 def to_pod(self, 331 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 332 ) -> edq.util.serial.PODType: 333 data: typing.Dict[str, edq.util.serial.PODType] = { 334 'text': self.text.to_pod(context), 335 'correct': self.correct, 336 } 337 338 if (self.feedback is not None): 339 data['feedback'] = self.feedback.to_pod(context) 340 341 return data
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
Inherited Members
343class QuestionAnswers(edq.util.serial.PODConverter): 344 """ 345 The base type that represents all the listed answers/choices for a question. 346 The exact contents of answers vary depending on the question's type. 347 """ 348 349 def shuffle(self, rng: random.Random) -> None: 350 """ Shuffle the choices/options (if applicable). """ 351 352 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 353 """ Collect all documents in this object. """ 354 355 return [] 356 357 @classmethod 358 def from_pod(cls: typing.Type['QuestionAnswers'], 359 data: edq.util.serial.PODType, 360 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 361 ) -> 'QuestionAnswers': 362 """ 363 Create an answers object for a specific question type from some serialized data 364 This data will normally come from a JSON file. 365 Because of the differing nature of questions, several different forms of answers may need to be processed 366 (even for the same question type). 367 368 This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers. 369 """ 370 371 if (context is None): 372 context = edq.util.serial.SerializationContext() 373 374 raw_question_type = context.extra.get('question_type', None) 375 376 if (raw_question_type is None): 377 raise quizcomp.model.errors.QuestionValidationError( 378 "Could not parse question answers because of lack of question type.", 379 context = context) 380 381 question_type = quizcomp.model.constants.QuestionType(raw_question_type) 382 383 if (question_type == quizcomp.model.constants.QuestionType.ESSAY): 384 return TextAnswers.from_pod(data, context) 385 elif (question_type == quizcomp.model.constants.QuestionType.FIMB): 386 return MultiplePartTextAnswers.from_pod(data, context) 387 elif (question_type == quizcomp.model.constants.QuestionType.FITB): 388 return TextAnswers.from_pod(data, context) 389 elif (question_type == quizcomp.model.constants.QuestionType.MA): 390 context = context.copy() 391 context.extra['min_correct'] = 0 392 return ChoiceAnswers.from_pod(data, context) 393 elif (question_type == quizcomp.model.constants.QuestionType.MATCHING): 394 return MatchingAnswers.from_pod(data, context) 395 elif (question_type == quizcomp.model.constants.QuestionType.MCQ): 396 context = context.copy() 397 context.extra['min_correct'] = 1 398 context.extra['max_correct'] = 1 399 return ChoiceAnswers.from_pod(data, context) 400 elif (question_type == quizcomp.model.constants.QuestionType.MDD): 401 context = context.copy() 402 context.extra['min_correct'] = 1 403 context.extra['max_correct'] = 1 404 return MultiplePartChoiceAnswers.from_pod(data, context) 405 elif (question_type == quizcomp.model.constants.QuestionType.NUMERICAL): 406 return NumericAnswers.from_pod(data, context) 407 elif (question_type == quizcomp.model.constants.QuestionType.SA): 408 return TextAnswers.from_pod(data, context) 409 elif (question_type == quizcomp.model.constants.QuestionType.TEXT_ONLY): 410 return TextAnswers.from_pod(data, context) 411 elif (question_type == quizcomp.model.constants.QuestionType.TF): 412 return TFAnswers.from_pod(data, context) 413 else: 414 raise quizcomp.model.errors.QuestionValidationError( 415 f"Unknown question type: '{raw_question_type}'.", 416 context = context)
The base type that represents all the listed answers/choices for a question. The exact contents of answers vary depending on the question's type.
349 def shuffle(self, rng: random.Random) -> None: 350 """ Shuffle the choices/options (if applicable). """
Shuffle the choices/options (if applicable).
352 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 353 """ Collect all documents in this object. """ 354 355 return []
Collect all documents in this object.
357 @classmethod 358 def from_pod(cls: typing.Type['QuestionAnswers'], 359 data: edq.util.serial.PODType, 360 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 361 ) -> 'QuestionAnswers': 362 """ 363 Create an answers object for a specific question type from some serialized data 364 This data will normally come from a JSON file. 365 Because of the differing nature of questions, several different forms of answers may need to be processed 366 (even for the same question type). 367 368 This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers. 369 """ 370 371 if (context is None): 372 context = edq.util.serial.SerializationContext() 373 374 raw_question_type = context.extra.get('question_type', None) 375 376 if (raw_question_type is None): 377 raise quizcomp.model.errors.QuestionValidationError( 378 "Could not parse question answers because of lack of question type.", 379 context = context) 380 381 question_type = quizcomp.model.constants.QuestionType(raw_question_type) 382 383 if (question_type == quizcomp.model.constants.QuestionType.ESSAY): 384 return TextAnswers.from_pod(data, context) 385 elif (question_type == quizcomp.model.constants.QuestionType.FIMB): 386 return MultiplePartTextAnswers.from_pod(data, context) 387 elif (question_type == quizcomp.model.constants.QuestionType.FITB): 388 return TextAnswers.from_pod(data, context) 389 elif (question_type == quizcomp.model.constants.QuestionType.MA): 390 context = context.copy() 391 context.extra['min_correct'] = 0 392 return ChoiceAnswers.from_pod(data, context) 393 elif (question_type == quizcomp.model.constants.QuestionType.MATCHING): 394 return MatchingAnswers.from_pod(data, context) 395 elif (question_type == quizcomp.model.constants.QuestionType.MCQ): 396 context = context.copy() 397 context.extra['min_correct'] = 1 398 context.extra['max_correct'] = 1 399 return ChoiceAnswers.from_pod(data, context) 400 elif (question_type == quizcomp.model.constants.QuestionType.MDD): 401 context = context.copy() 402 context.extra['min_correct'] = 1 403 context.extra['max_correct'] = 1 404 return MultiplePartChoiceAnswers.from_pod(data, context) 405 elif (question_type == quizcomp.model.constants.QuestionType.NUMERICAL): 406 return NumericAnswers.from_pod(data, context) 407 elif (question_type == quizcomp.model.constants.QuestionType.SA): 408 return TextAnswers.from_pod(data, context) 409 elif (question_type == quizcomp.model.constants.QuestionType.TEXT_ONLY): 410 return TextAnswers.from_pod(data, context) 411 elif (question_type == quizcomp.model.constants.QuestionType.TF): 412 return TFAnswers.from_pod(data, context) 413 else: 414 raise quizcomp.model.errors.QuestionValidationError( 415 f"Unknown question type: '{raw_question_type}'.", 416 context = context)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
418class TextAnswers(QuestionAnswers): 419 """ 420 Answers that include a list of possible text options. 421 Note that the text options are not choices (i.e., they are not presented in a multiple choice fashion), 422 instead they are possible answers. 423 Question types with this type of answers are often graded via text equality or manually 424 (where these answers would serve as a guide/rubric). 425 """ 426 427 def __init__(self, 428 options: typing.Union[typing.List[TextOption], None] = None, 429 **kwargs: typing.Any) -> None: 430 super().__init__(**kwargs) 431 432 if (options is None): 433 options = [] 434 435 self.options: typing.List[TextOption] = options 436 """ The possible text options. """ 437 438 def shuffle(self, rng: random.Random) -> None: 439 rng.shuffle(self.options) 440 441 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 442 documents = [] 443 444 for option in self.options: 445 documents += option.collect_documents() 446 447 return documents 448 449 def to_pod(self, 450 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 451 ) -> edq.util.serial.PODType: 452 return [option.to_pod(context) for option in self.options] 453 454 def _serialization_is_empty(self) -> bool: 455 """ A special method for the serialization library to check. """ 456 457 return (len(self.options) == 0) 458 459 def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument: 460 """ 461 Get the text document for the first option. 462 If there is no option, return an empty document. 463 """ 464 465 if (len(self.options) == 0): 466 return quizcomp.parser.document.ParsedDocument() 467 468 return self.options[0].text 469 470 @classmethod 471 def from_pod(cls: typing.Type['TextAnswers'], 472 data: edq.util.serial.PODType, 473 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 474 ) -> 'TextAnswers': 475 if (data is None): 476 return TextAnswers() 477 478 if (context is None): 479 context = edq.util.serial.SerializationContext() 480 481 if (isinstance(data, str)): 482 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context) 483 return TextAnswers([TextOption(parsed_text, None)]) 484 485 if (isinstance(data, dict)): 486 data = [data] 487 488 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 489 list_data = typing.cast(typing.List[edq.util.serial.PODType], data) 490 491 if (len(list_data) == 0): 492 return TextAnswers() 493 494 options = [] 495 for (i, raw_option) in enumerate(list_data): 496 label = f"Choice at index {i}" 497 498 option = TextOption.from_pod_with_error(raw_option, label, context) 499 options.append(option) 500 501 return TextAnswers(options)
Answers that include a list of possible text options. Note that the text options are not choices (i.e., they are not presented in a multiple choice fashion), instead they are possible answers. Question types with this type of answers are often graded via text equality or manually (where these answers would serve as a guide/rubric).
441 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 442 documents = [] 443 444 for option in self.options: 445 documents += option.collect_documents() 446 447 return documents
Collect all documents in this object.
449 def to_pod(self, 450 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 451 ) -> edq.util.serial.PODType: 452 return [option.to_pod(context) for option in self.options]
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
459 def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument: 460 """ 461 Get the text document for the first option. 462 If there is no option, return an empty document. 463 """ 464 465 if (len(self.options) == 0): 466 return quizcomp.parser.document.ParsedDocument() 467 468 return self.options[0].text
Get the text document for the first option. If there is no option, return an empty document.
470 @classmethod 471 def from_pod(cls: typing.Type['TextAnswers'], 472 data: edq.util.serial.PODType, 473 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 474 ) -> 'TextAnswers': 475 if (data is None): 476 return TextAnswers() 477 478 if (context is None): 479 context = edq.util.serial.SerializationContext() 480 481 if (isinstance(data, str)): 482 parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context) 483 return TextAnswers([TextOption(parsed_text, None)]) 484 485 if (isinstance(data, dict)): 486 data = [data] 487 488 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 489 list_data = typing.cast(typing.List[edq.util.serial.PODType], data) 490 491 if (len(list_data) == 0): 492 return TextAnswers() 493 494 options = [] 495 for (i, raw_option) in enumerate(list_data): 496 label = f"Choice at index {i}" 497 498 option = TextOption.from_pod_with_error(raw_option, label, context) 499 options.append(option) 500 501 return TextAnswers(options)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
503class MultiplePartTextAnswers(QuestionAnswers): 504 """ 505 Answers that have multiple parts, each having their own text-based answers. 506 """ 507 508 def __init__(self, 509 parts: typing.Dict[str, TextAnswers], 510 *args: typing.Any, 511 **kwargs: typing.Any) -> None: 512 super().__init__(*args, **kwargs) 513 514 self.parts: typing.Dict[str, TextAnswers] = parts 515 """ The different parts of this question. """ 516 517 def shuffle(self, rng: random.Random) -> None: 518 for part in self.parts.values(): 519 part.shuffle(rng) 520 521 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 522 documents = [] 523 524 for part in self.parts.values(): 525 documents += part.collect_documents() 526 527 return documents 528 529 def to_pod(self, 530 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 531 ) -> edq.util.serial.PODType: 532 return {key: value.to_pod(context) for (key, value) in self.parts.items()} 533 534 @classmethod 535 def from_pod(cls: typing.Type['MultiplePartTextAnswers'], 536 data: edq.util.serial.PODType, 537 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 538 ) -> 'MultiplePartTextAnswers': 539 if (context is None): 540 context = edq.util.serial.SerializationContext() 541 542 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 543 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 544 545 parts = {} 546 for (key, raw_options) in dict_data.items(): 547 # Try to parse the key, even though we are not storing it right now. 548 quizcomp.parser.document.ParsedDocument.parse_text(key, context) 549 550 parts[key] = TextAnswers.from_pod(raw_options, context) 551 552 return MultiplePartTextAnswers(parts)
Answers that have multiple parts, each having their own text-based answers.
517 def shuffle(self, rng: random.Random) -> None: 518 for part in self.parts.values(): 519 part.shuffle(rng)
Shuffle the choices/options (if applicable).
521 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 522 documents = [] 523 524 for part in self.parts.values(): 525 documents += part.collect_documents() 526 527 return documents
Collect all documents in this object.
529 def to_pod(self, 530 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 531 ) -> edq.util.serial.PODType: 532 return {key: value.to_pod(context) for (key, value) in self.parts.items()}
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
534 @classmethod 535 def from_pod(cls: typing.Type['MultiplePartTextAnswers'], 536 data: edq.util.serial.PODType, 537 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 538 ) -> 'MultiplePartTextAnswers': 539 if (context is None): 540 context = edq.util.serial.SerializationContext() 541 542 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 543 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 544 545 parts = {} 546 for (key, raw_options) in dict_data.items(): 547 # Try to parse the key, even though we are not storing it right now. 548 quizcomp.parser.document.ParsedDocument.parse_text(key, context) 549 550 parts[key] = TextAnswers.from_pod(raw_options, context) 551 552 return MultiplePartTextAnswers(parts)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
554class ChoiceAnswers(QuestionAnswers): 555 """ Answers that include a finite set of choices. """ 556 557 def __init__(self, 558 choices: typing.List[Choice], 559 *args: typing.Any, 560 **kwargs: typing.Any) -> None: 561 super().__init__(*args, **kwargs) 562 563 self.choices: typing.List[Choice] = choices 564 """ The possible choices. """ 565 566 def shuffle(self, rng: random.Random) -> None: 567 rng.shuffle(self.choices) 568 569 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 570 documents = [] 571 572 for choice in self.choices: 573 documents += choice.collect_documents() 574 575 return documents 576 577 def to_pod(self, 578 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 579 ) -> edq.util.serial.PODType: 580 return [choice.to_pod(context) for choice in self.choices] 581 582 def get_choices_with_markers(self) -> typing.List[typing.Tuple[quizcomp.parser.document.ParsedDocument, Choice]]: 583 """ Get the choices for this answer along with markers for each (e.g., "A", "B", "C"). """ 584 585 return [ 586 (quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]), choice) 587 for (i, choice) 588 in enumerate(self.choices) 589 ] 590 591 @classmethod 592 def from_pod(cls: typing.Type['ChoiceAnswers'], 593 data: edq.util.serial.PODType, 594 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 595 ) -> 'ChoiceAnswers': 596 if (context is None): 597 context = edq.util.serial.SerializationContext() 598 599 min_correct = context.extra.get('min_correct', 0) 600 max_correct = context.extra.get('max_correct', quizcomp.model.constants.MAX_CHOICES) 601 min_incorrect = context.extra.get('min_incorrect', 0) 602 max_incorrect = context.extra.get('max_incorrect', quizcomp.model.constants.MAX_CHOICES) 603 604 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 605 raw_choices = typing.cast(typing.List[edq.util.serial.PODType], data) 606 607 if (len(raw_choices) == 0): 608 raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context) 609 610 num_correct = 0 611 num_incorrect = 0 612 613 choices = [] 614 615 for (i, raw_choice) in enumerate(raw_choices): 616 label = f"Choice at index {i}" 617 618 quizcomp.model.errors.check_type(raw_choice, dict, label, context = context) 619 choice_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], raw_choice) 620 621 raw_correct = choice_data.get('correct', None) 622 if (raw_correct is None): 623 raise quizcomp.model.errors.QuestionValidationError(f"{label} has no 'correct' field set.", context = context) 624 625 correct = edq.util.parse.soft_boolean(raw_correct) 626 if (correct is None): 627 raise quizcomp.model.errors.QuestionValidationError( 628 f"{label}'s 'correct' field does not contain a boolean: '{raw_correct}'.", 629 context = context) 630 631 if (correct): 632 num_correct += 1 633 else: 634 num_incorrect += 1 635 636 option = TextOption.from_pod_with_error(choice_data, label, context) 637 choices.append(Choice(option.text, correct, option.feedback)) 638 639 if (num_correct < min_correct): 640 raise quizcomp.model.errors.QuestionValidationError(("Did not find enough correct choices." 641 + f" Expected at least {min_correct}, found {num_correct}."), 642 context = context) 643 644 if (num_correct > max_correct): 645 raise quizcomp.model.errors.QuestionValidationError(("Found too many correct choices." 646 + f" Expected at most {max_correct}, found {num_correct}."), 647 context = context) 648 649 if (num_incorrect < min_incorrect): 650 raise quizcomp.model.errors.QuestionValidationError(("Did not find enough incorrect choices." 651 + f" Expected at least {min_incorrect}, found {num_incorrect}."), 652 context = context) 653 654 if (num_incorrect > max_incorrect): 655 raise quizcomp.model.errors.QuestionValidationError(("Found too many incorrect choices." 656 + f" Expected at most {max_incorrect}, found {num_incorrect}."), 657 context = context) 658 659 return ChoiceAnswers(choices)
Answers that include a finite set of choices.
569 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 570 documents = [] 571 572 for choice in self.choices: 573 documents += choice.collect_documents() 574 575 return documents
Collect all documents in this object.
577 def to_pod(self, 578 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 579 ) -> edq.util.serial.PODType: 580 return [choice.to_pod(context) for choice in self.choices]
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
582 def get_choices_with_markers(self) -> typing.List[typing.Tuple[quizcomp.parser.document.ParsedDocument, Choice]]: 583 """ Get the choices for this answer along with markers for each (e.g., "A", "B", "C"). """ 584 585 return [ 586 (quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]), choice) 587 for (i, choice) 588 in enumerate(self.choices) 589 ]
Get the choices for this answer along with markers for each (e.g., "A", "B", "C").
591 @classmethod 592 def from_pod(cls: typing.Type['ChoiceAnswers'], 593 data: edq.util.serial.PODType, 594 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 595 ) -> 'ChoiceAnswers': 596 if (context is None): 597 context = edq.util.serial.SerializationContext() 598 599 min_correct = context.extra.get('min_correct', 0) 600 max_correct = context.extra.get('max_correct', quizcomp.model.constants.MAX_CHOICES) 601 min_incorrect = context.extra.get('min_incorrect', 0) 602 max_incorrect = context.extra.get('max_incorrect', quizcomp.model.constants.MAX_CHOICES) 603 604 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 605 raw_choices = typing.cast(typing.List[edq.util.serial.PODType], data) 606 607 if (len(raw_choices) == 0): 608 raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context) 609 610 num_correct = 0 611 num_incorrect = 0 612 613 choices = [] 614 615 for (i, raw_choice) in enumerate(raw_choices): 616 label = f"Choice at index {i}" 617 618 quizcomp.model.errors.check_type(raw_choice, dict, label, context = context) 619 choice_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], raw_choice) 620 621 raw_correct = choice_data.get('correct', None) 622 if (raw_correct is None): 623 raise quizcomp.model.errors.QuestionValidationError(f"{label} has no 'correct' field set.", context = context) 624 625 correct = edq.util.parse.soft_boolean(raw_correct) 626 if (correct is None): 627 raise quizcomp.model.errors.QuestionValidationError( 628 f"{label}'s 'correct' field does not contain a boolean: '{raw_correct}'.", 629 context = context) 630 631 if (correct): 632 num_correct += 1 633 else: 634 num_incorrect += 1 635 636 option = TextOption.from_pod_with_error(choice_data, label, context) 637 choices.append(Choice(option.text, correct, option.feedback)) 638 639 if (num_correct < min_correct): 640 raise quizcomp.model.errors.QuestionValidationError(("Did not find enough correct choices." 641 + f" Expected at least {min_correct}, found {num_correct}."), 642 context = context) 643 644 if (num_correct > max_correct): 645 raise quizcomp.model.errors.QuestionValidationError(("Found too many correct choices." 646 + f" Expected at most {max_correct}, found {num_correct}."), 647 context = context) 648 649 if (num_incorrect < min_incorrect): 650 raise quizcomp.model.errors.QuestionValidationError(("Did not find enough incorrect choices." 651 + f" Expected at least {min_incorrect}, found {num_incorrect}."), 652 context = context) 653 654 if (num_incorrect > max_incorrect): 655 raise quizcomp.model.errors.QuestionValidationError(("Found too many incorrect choices." 656 + f" Expected at most {max_incorrect}, found {num_incorrect}."), 657 context = context) 658 659 return ChoiceAnswers(choices)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
661class TFAnswers(ChoiceAnswers): 662 """ Answers that must be true or false. """ 663 664 def __init__(self, 665 *args: typing.Any, 666 **kwargs: typing.Any) -> None: 667 super().__init__(*args, **kwargs) 668 669 @classmethod 670 def from_pod(cls: typing.Type['TFAnswers'], 671 data: edq.util.serial.PODType, 672 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 673 ) -> 'TFAnswers': 674 if (context is None): 675 context = edq.util.serial.SerializationContext() 676 else: 677 context = context.copy() 678 679 if (isinstance(data, bool)): 680 choices = [ 681 Choice(quizcomp.parser.document.ParsedDocument.parse_text("True", context), data), 682 Choice(quizcomp.parser.document.ParsedDocument.parse_text("False", context), (not data)), 683 ] 684 return TFAnswers(choices) 685 686 context.extra['min_correct'] = 1 687 context.extra['max_correct'] = 1 688 context.extra['min_incorrect'] = 1 689 context.extra['max_incorrect'] = 1 690 691 answers = ChoiceAnswers.from_pod(data, context) 692 693 return TFAnswers(answers.choices)
Answers that must be true or false.
669 @classmethod 670 def from_pod(cls: typing.Type['TFAnswers'], 671 data: edq.util.serial.PODType, 672 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 673 ) -> 'TFAnswers': 674 if (context is None): 675 context = edq.util.serial.SerializationContext() 676 else: 677 context = context.copy() 678 679 if (isinstance(data, bool)): 680 choices = [ 681 Choice(quizcomp.parser.document.ParsedDocument.parse_text("True", context), data), 682 Choice(quizcomp.parser.document.ParsedDocument.parse_text("False", context), (not data)), 683 ] 684 return TFAnswers(choices) 685 686 context.extra['min_correct'] = 1 687 context.extra['max_correct'] = 1 688 context.extra['min_incorrect'] = 1 689 context.extra['max_incorrect'] = 1 690 691 answers = ChoiceAnswers.from_pod(data, context) 692 693 return TFAnswers(answers.choices)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
Inherited Members
695class MultiplePartChoiceAnswers(QuestionAnswers): 696 """ 697 Answers that have multiple parts, each having their own choice-based answers. 698 """ 699 700 def __init__(self, 701 parts: typing.Dict[str, ChoiceAnswers], 702 *args: typing.Any, 703 **kwargs: typing.Any) -> None: 704 super().__init__(*args, **kwargs) 705 706 self.parts: typing.Dict[str, ChoiceAnswers] = parts 707 """ The different parts of this question. """ 708 709 def shuffle(self, rng: random.Random) -> None: 710 for part in self.parts.values(): 711 part.shuffle(rng) 712 713 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 714 documents = [] 715 716 for part in self.parts.values(): 717 documents += part.collect_documents() 718 719 return documents 720 721 def to_pod(self, 722 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 723 ) -> edq.util.serial.PODType: 724 return {key: value.to_pod(context) for (key, value) in self.parts.items()} 725 726 @classmethod 727 def from_pod(cls: typing.Type['MultiplePartChoiceAnswers'], 728 data: edq.util.serial.PODType, 729 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 730 ) -> 'MultiplePartChoiceAnswers': 731 if (context is None): 732 context = edq.util.serial.SerializationContext() 733 734 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 735 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 736 737 parts = {} 738 for (key, raw_options) in dict_data.items(): 739 # Try to parse the key, even though we are not storing it right now. 740 quizcomp.parser.document.ParsedDocument.parse_text(key, context) 741 742 parts[key] = ChoiceAnswers.from_pod(raw_options, context) 743 744 return MultiplePartChoiceAnswers(parts)
Answers that have multiple parts, each having their own choice-based answers.
709 def shuffle(self, rng: random.Random) -> None: 710 for part in self.parts.values(): 711 part.shuffle(rng)
Shuffle the choices/options (if applicable).
713 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 714 documents = [] 715 716 for part in self.parts.values(): 717 documents += part.collect_documents() 718 719 return documents
Collect all documents in this object.
721 def to_pod(self, 722 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 723 ) -> edq.util.serial.PODType: 724 return {key: value.to_pod(context) for (key, value) in self.parts.items()}
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
726 @classmethod 727 def from_pod(cls: typing.Type['MultiplePartChoiceAnswers'], 728 data: edq.util.serial.PODType, 729 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 730 ) -> 'MultiplePartChoiceAnswers': 731 if (context is None): 732 context = edq.util.serial.SerializationContext() 733 734 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 735 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 736 737 parts = {} 738 for (key, raw_options) in dict_data.items(): 739 # Try to parse the key, even though we are not storing it right now. 740 quizcomp.parser.document.ParsedDocument.parse_text(key, context) 741 742 parts[key] = ChoiceAnswers.from_pod(raw_options, context) 743 744 return MultiplePartChoiceAnswers(parts)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
746class MatchingAnswerRow: 747 """ 748 A single "row" when writing out a matching problem as a table. 749 """ 750 751 def __init__(self, 752 left: typing.Union[TextOption, None], 753 right: TextOption, 754 right_marker: quizcomp.parser.document.ParsedDocument, 755 correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None], 756 correct_option: typing.Union[TextOption, None], 757 ) -> None: 758 self.left: typing.Union[TextOption, None] = left 759 """ 760 The query part of the match that needs to find its partner. 761 This may be None if there are distractors. 762 """ 763 764 self.right: TextOption = right 765 """ 766 The target part of the match. 767 Note that this may NOT be the correct partner to `self.left`, 768 it is just the target that should appear on the same row. 769 """ 770 771 self.right_marker: quizcomp.parser.document.ParsedDocument = right_marker 772 """ 773 The marker that accompanies this target. 774 """ 775 776 self.correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None] = correct_marker 777 """ 778 The marker for the correct partner to `self.left`. 779 Will be None if `self.left` is None. 780 """ 781 782 self.correct_option: typing.Union[TextOption, None] = correct_option 783 """ 784 The text for the correct partner to `self.left`. 785 Will be None if `self.left` is None. 786 """
A single "row" when writing out a matching problem as a table.
751 def __init__(self, 752 left: typing.Union[TextOption, None], 753 right: TextOption, 754 right_marker: quizcomp.parser.document.ParsedDocument, 755 correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None], 756 correct_option: typing.Union[TextOption, None], 757 ) -> None: 758 self.left: typing.Union[TextOption, None] = left 759 """ 760 The query part of the match that needs to find its partner. 761 This may be None if there are distractors. 762 """ 763 764 self.right: TextOption = right 765 """ 766 The target part of the match. 767 Note that this may NOT be the correct partner to `self.left`, 768 it is just the target that should appear on the same row. 769 """ 770 771 self.right_marker: quizcomp.parser.document.ParsedDocument = right_marker 772 """ 773 The marker that accompanies this target. 774 """ 775 776 self.correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None] = correct_marker 777 """ 778 The marker for the correct partner to `self.left`. 779 Will be None if `self.left` is None. 780 """ 781 782 self.correct_option: typing.Union[TextOption, None] = correct_option 783 """ 784 The text for the correct partner to `self.left`. 785 Will be None if `self.left` is None. 786 """
The query part of the match that needs to find its partner. This may be None if there are distractors.
The target part of the match.
Note that this may NOT be the correct partner to self.left,
it is just the target that should appear on the same row.
The marker for the correct partner to self.left.
Will be None if self.left is None.
The text for the correct partner to self.left.
Will be None if self.left is None.
788class MatchingAnswers(QuestionAnswers): 789 """ Answers for matching-type questions. """ 790 791 serialization_omit_empty = True 792 serialization_skip_fields = { 793 '_shuffle_seed', 794 } 795 796 def __init__(self, 797 pairs: typing.List[typing.Tuple[TextOption, TextOption]], 798 distractors: typing.Union[typing.List[TextOption], None] = None, 799 **kwargs: typing.Any) -> None: 800 super().__init__(**kwargs) 801 802 self.pairs: typing.List[typing.Tuple[TextOption, TextOption]] = pairs 803 """ The matching pairs of items. """ 804 805 if (distractors is None): 806 distractors = [] 807 808 self.distractors: typing.List[TextOption] = distractors 809 """ Extra options to serve as a distraction. """ 810 811 self._shuffle_seed: typing.Union[int, None] = None 812 """ 813 A seed to use when shuffling the left and right sides. 814 815 This will be set in shuffle(). 816 A None value indicates that no shuffling will occur. 817 """ 818 819 def shuffle(self, rng: random.Random) -> None: 820 rng.shuffle(self.pairs) 821 rng.shuffle(self.distractors) 822 self._shuffle_seed = rng.randint(0, 2**64) 823 824 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 825 documents = [] 826 827 for (left, right) in self.pairs: 828 documents += left.collect_documents() 829 documents += right.collect_documents() 830 831 for distractor in self.distractors: 832 documents += distractor.collect_documents() 833 834 return documents 835 836 def to_pod(self, 837 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 838 ) -> edq.util.serial.PODType: 839 return { 840 'matches': [[left.to_pod(context), right.to_pod(context)] for (left, right) in self.pairs], 841 'distractors': [value.to_pod(context) for value in self.distractors], 842 } 843 844 def get_tabular_options(self) -> typing.List[MatchingAnswerRow]: 845 """ 846 Get all the matching options laid out in a table. 847 848 If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table. 849 """ 850 851 lefts = [] 852 rights = [] 853 854 for (pair_left, pair_right) in self.pairs: 855 lefts.append(pair_left) 856 rights.append(pair_right) 857 858 for distractor in self.distractors: 859 rights.append(distractor) 860 861 # The ordered indexes to use in the options table. 862 # This may be shuffled. 863 left_indexes = list(range(len(lefts))) 864 right_indexes = list(range(len(rights))) 865 866 if (self._shuffle_seed is not None): 867 rng = random.Random(self._shuffle_seed) 868 rng.shuffle(left_indexes) 869 rng.shuffle(right_indexes) 870 871 options = [] 872 for (i, right_index) in enumerate(right_indexes): 873 right = rights[right_index] 874 right_marker = quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]) 875 876 left: typing.Union[TextOption, None] = None 877 left_marker = None 878 correct_answer = None 879 if (i < len(left_indexes)): 880 left_index = left_indexes[i] 881 882 # Find the correct marker index for this left by looking up the matching index in the right indexes. 883 matching_right_marker_index = right_indexes.index(left_index) 884 885 left = lefts[left_index] 886 left_marker = quizcomp.parser.document.ParsedDocument.parse_text( 887 quizcomp.model.constants.DEFAULT_CHOICES[matching_right_marker_index]) 888 correct_answer = rights[left_index] 889 890 options.append(MatchingAnswerRow(left, right, right_marker, left_marker, correct_answer)) 891 892 return options 893 894 @classmethod 895 def from_pod(cls: typing.Type['MatchingAnswers'], 896 data: edq.util.serial.PODType, 897 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 898 ) -> 'MatchingAnswers': 899 if (context is None): 900 context = edq.util.serial.SerializationContext() 901 902 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 903 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 904 905 raw_matches = dict_data.get('matches', None) 906 if (raw_matches is None): 907 raise quizcomp.model.errors.QuestionValidationError( 908 "The 'matches' key was not provided for a matching-type question.", 909 context = context) 910 911 quizcomp.model.errors.check_type(raw_matches, list, "'matches'", context = context) 912 matches = typing.cast(typing.List[edq.util.serial.PODType], raw_matches) 913 914 if (len(matches) == 0): 915 raise quizcomp.model.errors.QuestionValidationError( 916 "At least one matching pair must be specified for matching questions.", 917 context = context) 918 919 pairs = [] 920 for (i, raw_match) in enumerate(matches): 921 label = f"Match pair at index {i}" 922 923 if (isinstance(raw_match, list)): 924 if (len(raw_match) != 2): 925 raise quizcomp.model.errors.QuestionValidationError( 926 f"{label} has an unexpected size. Expecting two items (left and right) found {len(raw_match)}.", 927 context = context) 928 929 left_option = TextOption.from_pod_with_error(raw_match[0], label + ' (left)', context) 930 right_option = TextOption.from_pod_with_error(raw_match[1], label + ' (right)', context) 931 932 pairs.append((left_option, right_option)) 933 elif (isinstance(raw_match, dict)): 934 if ('left' not in raw_match): 935 raise quizcomp.model.errors.QuestionValidationError( 936 f"{label} does not have a 'left' key.", 937 context = context) 938 939 if ('right' not in raw_match): 940 raise quizcomp.model.errors.QuestionValidationError( 941 f"{label} does not have a 'right' key.", 942 context = context) 943 944 left_option = TextOption.from_pod_with_error(raw_match['left'], label + ' (left)', context) 945 right_option = TextOption.from_pod_with_error(raw_match['right'], label + ' (right)', context) 946 947 pairs.append((left_option, right_option)) 948 else: 949 raise quizcomp.model.errors.QuestionValidationError( 950 f"{label} has an unknown format (not a list or dict): '{raw_match}' (type: {type(raw_match)}.", 951 context = context) 952 953 raw_distractors = dict_data.get('distractors', None) 954 if (raw_distractors is None): 955 raw_distractors = [] 956 957 quizcomp.model.errors.check_type(raw_distractors, list, "'distractors'", context = context) 958 distractors = typing.cast(typing.List[edq.util.serial.PODType], raw_distractors) 959 960 claen_distractors = [] 961 for (i, raw_distractor) in enumerate(distractors): 962 label = f"Match distractor at index {i}" 963 964 option = TextOption.from_pod_with_error(raw_distractor, label, context) 965 claen_distractors.append(option) 966 967 if ((len(pairs) + len(claen_distractors)) > quizcomp.model.constants.MAX_CHOICES): 968 raise quizcomp.model.errors.QuestionValidationError( 969 (f"Matching question has too many options. Found {(len(pairs) + len(claen_distractors))}," 970 f" while the max is {quizcomp.model.constants.MAX_CHOICES}."), 971 context = context) 972 973 return MatchingAnswers(pairs, claen_distractors)
Answers for matching-type questions.
796 def __init__(self, 797 pairs: typing.List[typing.Tuple[TextOption, TextOption]], 798 distractors: typing.Union[typing.List[TextOption], None] = None, 799 **kwargs: typing.Any) -> None: 800 super().__init__(**kwargs) 801 802 self.pairs: typing.List[typing.Tuple[TextOption, TextOption]] = pairs 803 """ The matching pairs of items. """ 804 805 if (distractors is None): 806 distractors = [] 807 808 self.distractors: typing.List[TextOption] = distractors 809 """ Extra options to serve as a distraction. """ 810 811 self._shuffle_seed: typing.Union[int, None] = None 812 """ 813 A seed to use when shuffling the left and right sides. 814 815 This will be set in shuffle(). 816 A None value indicates that no shuffling will occur. 817 """
Do not include empty fields in serialization. An empty field meets one of the following conditions:
- Has a
__len__method which returns 0. - Has a
_serialization_is_emptymethod that returns true.
819 def shuffle(self, rng: random.Random) -> None: 820 rng.shuffle(self.pairs) 821 rng.shuffle(self.distractors) 822 self._shuffle_seed = rng.randint(0, 2**64)
Shuffle the choices/options (if applicable).
824 def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]: 825 documents = [] 826 827 for (left, right) in self.pairs: 828 documents += left.collect_documents() 829 documents += right.collect_documents() 830 831 for distractor in self.distractors: 832 documents += distractor.collect_documents() 833 834 return documents
Collect all documents in this object.
836 def to_pod(self, 837 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 838 ) -> edq.util.serial.PODType: 839 return { 840 'matches': [[left.to_pod(context), right.to_pod(context)] for (left, right) in self.pairs], 841 'distractors': [value.to_pod(context) for value in self.distractors], 842 }
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
844 def get_tabular_options(self) -> typing.List[MatchingAnswerRow]: 845 """ 846 Get all the matching options laid out in a table. 847 848 If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table. 849 """ 850 851 lefts = [] 852 rights = [] 853 854 for (pair_left, pair_right) in self.pairs: 855 lefts.append(pair_left) 856 rights.append(pair_right) 857 858 for distractor in self.distractors: 859 rights.append(distractor) 860 861 # The ordered indexes to use in the options table. 862 # This may be shuffled. 863 left_indexes = list(range(len(lefts))) 864 right_indexes = list(range(len(rights))) 865 866 if (self._shuffle_seed is not None): 867 rng = random.Random(self._shuffle_seed) 868 rng.shuffle(left_indexes) 869 rng.shuffle(right_indexes) 870 871 options = [] 872 for (i, right_index) in enumerate(right_indexes): 873 right = rights[right_index] 874 right_marker = quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]) 875 876 left: typing.Union[TextOption, None] = None 877 left_marker = None 878 correct_answer = None 879 if (i < len(left_indexes)): 880 left_index = left_indexes[i] 881 882 # Find the correct marker index for this left by looking up the matching index in the right indexes. 883 matching_right_marker_index = right_indexes.index(left_index) 884 885 left = lefts[left_index] 886 left_marker = quizcomp.parser.document.ParsedDocument.parse_text( 887 quizcomp.model.constants.DEFAULT_CHOICES[matching_right_marker_index]) 888 correct_answer = rights[left_index] 889 890 options.append(MatchingAnswerRow(left, right, right_marker, left_marker, correct_answer)) 891 892 return options
Get all the matching options laid out in a table.
If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table.
894 @classmethod 895 def from_pod(cls: typing.Type['MatchingAnswers'], 896 data: edq.util.serial.PODType, 897 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 898 ) -> 'MatchingAnswers': 899 if (context is None): 900 context = edq.util.serial.SerializationContext() 901 902 quizcomp.model.errors.check_type(data, dict, "'answers'", context = context) 903 dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data) 904 905 raw_matches = dict_data.get('matches', None) 906 if (raw_matches is None): 907 raise quizcomp.model.errors.QuestionValidationError( 908 "The 'matches' key was not provided for a matching-type question.", 909 context = context) 910 911 quizcomp.model.errors.check_type(raw_matches, list, "'matches'", context = context) 912 matches = typing.cast(typing.List[edq.util.serial.PODType], raw_matches) 913 914 if (len(matches) == 0): 915 raise quizcomp.model.errors.QuestionValidationError( 916 "At least one matching pair must be specified for matching questions.", 917 context = context) 918 919 pairs = [] 920 for (i, raw_match) in enumerate(matches): 921 label = f"Match pair at index {i}" 922 923 if (isinstance(raw_match, list)): 924 if (len(raw_match) != 2): 925 raise quizcomp.model.errors.QuestionValidationError( 926 f"{label} has an unexpected size. Expecting two items (left and right) found {len(raw_match)}.", 927 context = context) 928 929 left_option = TextOption.from_pod_with_error(raw_match[0], label + ' (left)', context) 930 right_option = TextOption.from_pod_with_error(raw_match[1], label + ' (right)', context) 931 932 pairs.append((left_option, right_option)) 933 elif (isinstance(raw_match, dict)): 934 if ('left' not in raw_match): 935 raise quizcomp.model.errors.QuestionValidationError( 936 f"{label} does not have a 'left' key.", 937 context = context) 938 939 if ('right' not in raw_match): 940 raise quizcomp.model.errors.QuestionValidationError( 941 f"{label} does not have a 'right' key.", 942 context = context) 943 944 left_option = TextOption.from_pod_with_error(raw_match['left'], label + ' (left)', context) 945 right_option = TextOption.from_pod_with_error(raw_match['right'], label + ' (right)', context) 946 947 pairs.append((left_option, right_option)) 948 else: 949 raise quizcomp.model.errors.QuestionValidationError( 950 f"{label} has an unknown format (not a list or dict): '{raw_match}' (type: {type(raw_match)}.", 951 context = context) 952 953 raw_distractors = dict_data.get('distractors', None) 954 if (raw_distractors is None): 955 raw_distractors = [] 956 957 quizcomp.model.errors.check_type(raw_distractors, list, "'distractors'", context = context) 958 distractors = typing.cast(typing.List[edq.util.serial.PODType], raw_distractors) 959 960 claen_distractors = [] 961 for (i, raw_distractor) in enumerate(distractors): 962 label = f"Match distractor at index {i}" 963 964 option = TextOption.from_pod_with_error(raw_distractor, label, context) 965 claen_distractors.append(option) 966 967 if ((len(pairs) + len(claen_distractors)) > quizcomp.model.constants.MAX_CHOICES): 968 raise quizcomp.model.errors.QuestionValidationError( 969 (f"Matching question has too many options. Found {(len(pairs) + len(claen_distractors))}," 970 f" while the max is {quizcomp.model.constants.MAX_CHOICES}."), 971 context = context) 972 973 return MatchingAnswers(pairs, claen_distractors)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
975class NumericAnswers(QuestionAnswers): 976 """ Answers that include a finite set of numeric options. """ 977 978 def __init__(self, 979 options: typing.List[NumericOption], 980 *args: typing.Any, 981 **kwargs: typing.Any) -> None: 982 super().__init__(*args, **kwargs) 983 984 self.options: typing.List[NumericOption] = options 985 """ The possible options. """ 986 987 def shuffle(self, rng: random.Random) -> None: 988 rng.shuffle(self.options) 989 990 def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument: 991 """ 992 Get the text document for the first option. 993 If there is no option, return an empty document. 994 """ 995 996 if (len(self.options) == 0): 997 return quizcomp.parser.document.ParsedDocument() 998 999 return self.options[0].to_text().text 1000 1001 def to_pod(self, 1002 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 1003 ) -> edq.util.serial.PODType: 1004 return [option.to_pod(context) for option in self.options] 1005 1006 @classmethod 1007 def from_pod(cls: typing.Type['NumericAnswers'], 1008 data: edq.util.serial.PODType, 1009 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 1010 ) -> 'NumericAnswers': 1011 if (context is None): 1012 context = edq.util.serial.SerializationContext() 1013 1014 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 1015 raw_options = typing.cast(typing.List[edq.util.serial.PODType], data) 1016 1017 if (len(raw_options) == 0): 1018 raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context) 1019 1020 options = [] 1021 for (i, raw_option) in enumerate(raw_options): 1022 label = f"Option at index {i}" 1023 1024 option = NumericOption.from_pod_with_error(raw_option, label, context) 1025 options.append(option) 1026 1027 return NumericAnswers(options)
Answers that include a finite set of numeric options.
990 def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument: 991 """ 992 Get the text document for the first option. 993 If there is no option, return an empty document. 994 """ 995 996 if (len(self.options) == 0): 997 return quizcomp.parser.document.ParsedDocument() 998 999 return self.options[0].to_text().text
Get the text document for the first option. If there is no option, return an empty document.
1001 def to_pod(self, 1002 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 1003 ) -> edq.util.serial.PODType: 1004 return [option.to_pod(context) for option in self.options]
Get a POD representation of this object.
The default implementation will convert to a dict (similar to a DictSerializer).
1006 @classmethod 1007 def from_pod(cls: typing.Type['NumericAnswers'], 1008 data: edq.util.serial.PODType, 1009 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 1010 ) -> 'NumericAnswers': 1011 if (context is None): 1012 context = edq.util.serial.SerializationContext() 1013 1014 quizcomp.model.errors.check_type(data, list, "'answers'", context = context) 1015 raw_options = typing.cast(typing.List[edq.util.serial.PODType], data) 1016 1017 if (len(raw_options) == 0): 1018 raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context) 1019 1020 options = [] 1021 for (i, raw_option) in enumerate(raw_options): 1022 label = f"Option at index {i}" 1023 1024 option = NumericOption.from_pod_with_error(raw_option, label, context) 1025 options.append(option) 1026 1027 return NumericAnswers(options)
Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).
This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.