QuizGenerator 0.7.1__py3-none-any.whl → 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. QuizGenerator/contentast.py +48 -15
  2. QuizGenerator/generate.py +2 -1
  3. QuizGenerator/mixins.py +14 -100
  4. QuizGenerator/premade_questions/basic.py +24 -29
  5. QuizGenerator/premade_questions/cst334/languages.py +100 -99
  6. QuizGenerator/premade_questions/cst334/math_questions.py +112 -122
  7. QuizGenerator/premade_questions/cst334/memory_questions.py +621 -621
  8. QuizGenerator/premade_questions/cst334/persistence_questions.py +137 -163
  9. QuizGenerator/premade_questions/cst334/process.py +312 -328
  10. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +34 -35
  11. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +41 -36
  12. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +48 -41
  13. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +285 -521
  14. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +149 -126
  15. QuizGenerator/premade_questions/cst463/models/attention.py +44 -50
  16. QuizGenerator/premade_questions/cst463/models/cnns.py +43 -47
  17. QuizGenerator/premade_questions/cst463/models/matrices.py +61 -11
  18. QuizGenerator/premade_questions/cst463/models/rnns.py +48 -50
  19. QuizGenerator/premade_questions/cst463/models/text.py +65 -67
  20. QuizGenerator/premade_questions/cst463/models/weight_counting.py +47 -46
  21. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +100 -156
  22. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +93 -141
  23. QuizGenerator/question.py +310 -202
  24. QuizGenerator/quiz.py +8 -5
  25. QuizGenerator/regenerate.py +14 -6
  26. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/METADATA +30 -2
  27. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/RECORD +30 -30
  28. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/WHEEL +0 -0
  29. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/entry_points.txt +0 -0
  30. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/question.py CHANGED
@@ -22,7 +22,6 @@ from typing import List, Dict, Any, Tuple, Optional
22
22
  import canvasapi.course, canvasapi.quiz
23
23
 
24
24
  import QuizGenerator.contentast as ca
25
- from QuizGenerator.performance import timer, PerformanceTracker
26
25
 
27
26
  import logging
28
27
  log = logging.getLogger(__name__)
@@ -36,6 +35,29 @@ class QuestionComponents:
36
35
  explanation: ca.Element
37
36
 
38
37
 
38
+ @dataclasses.dataclass(frozen=True)
39
+ class RegenerationFlags:
40
+ """Minimal metadata needed to regenerate a question instance."""
41
+ question_class_name: str
42
+ generation_seed: Optional[int]
43
+ question_version: str
44
+ config_params: Dict[str, Any]
45
+
46
+
47
+ @dataclasses.dataclass(frozen=True)
48
+ class QuestionInstance:
49
+ """Fully-instantiated question with content, answers, and regeneration metadata."""
50
+ body: ca.Element
51
+ explanation: ca.Element
52
+ answers: List[ca.Answer]
53
+ answer_kind: ca.Answer.CanvasAnswerKind
54
+ can_be_numerical: bool
55
+ value: float
56
+ spacing: float
57
+ topic: "Question.Topic"
58
+ flags: RegenerationFlags
59
+
60
+
39
61
  # Spacing presets for questions
40
62
  SPACING_PRESETS = {
41
63
  "NONE": 0,
@@ -147,7 +169,7 @@ class QuestionRegistry:
147
169
  raise ValueError(f"Unknown question type: {question_type}")
148
170
 
149
171
  new_question : Question = cls._registry[question_key](**kwargs)
150
- # Note: Don't call refresh() here - it will be called by get_question()
172
+ # Note: Don't build context here - instantiate() handles it
151
173
  # Calling it here would consume RNG calls and break QR code regeneration
152
174
  return new_question
153
175
 
@@ -212,21 +234,21 @@ class QuestionRegistry:
212
234
  class RegenerableChoiceMixin:
213
235
  """
214
236
  Mixin for questions that need to make random choices from enums/lists that are:
215
- 1. Different across multiple refreshes (when the same Question instance is reused for multiple PDFs)
237
+ 1. Different across multiple builds (when the same Question instance is reused for multiple PDFs)
216
238
  2. Reproducible from QR code config_params
217
239
 
218
240
  The Problem:
219
241
  ------------
220
242
  When generating multiple PDFs, Quiz.from_yaml() creates Question instances ONCE.
221
- These instances are then refresh()ed multiple times with different RNG seeds.
243
+ These instances are then built multiple times with different RNG seeds.
222
244
  If a question randomly selects an algorithm/policy in __init__(), all PDFs get the same choice
223
245
  because __init__() only runs once with an unseeded RNG.
224
246
 
225
247
  The Solution:
226
248
  -------------
227
249
  1. In __init__(): Register choices with fixed values (if provided) or None (for random)
228
- 2. In refresh(): Make random selections using the seeded RNG, store in config_params
229
- 3. Result: Each refresh gets a different random choice, and it's captured for QR codes
250
+ 2. In _build_context(): Make random selections using the seeded RNG, store in config_params
251
+ 3. Result: Each build gets a different random choice, and it's captured for QR codes
230
252
 
231
253
  Usage Example:
232
254
  --------------
@@ -240,11 +262,11 @@ class RegenerableChoiceMixin:
240
262
  self.register_choice('scheduler_kind', self.Kind, scheduler_kind, kwargs)
241
263
  super().__init__(**kwargs)
242
264
 
243
- def refresh(self, **kwargs):
244
- super().refresh(**kwargs)
265
+ def _build_context(self, rng_seed=None, **kwargs):
266
+ self.rng.seed(rng_seed)
245
267
  # Get the choice (randomly selected or from config_params)
246
268
  self.scheduler_algorithm = self.get_choice('scheduler_kind', self.Kind)
247
- # ... rest of refresh logic
269
+ # ... rest of build logic
248
270
  """
249
271
 
250
272
  def __init__(self, *args, **kwargs):
@@ -281,7 +303,7 @@ class RegenerableChoiceMixin:
281
303
  def get_choice(self, param_name: str, enum_class: type[enum.Enum]) -> enum.Enum:
282
304
  """
283
305
  Get the choice for a registered parameter.
284
- Should be called in refresh() AFTER super().refresh().
306
+ Should be called in _build_context() AFTER seeding the RNG.
285
307
 
286
308
  Args:
287
309
  param_name: The parameter name registered earlier
@@ -294,7 +316,7 @@ class RegenerableChoiceMixin:
294
316
  if choice_info is None:
295
317
  raise ValueError(f"Choice '{param_name}' not registered. Call register_choice() in __init__() first.")
296
318
 
297
- # Check for temporary fixed value (set during backoff loop in get_question())
319
+ # Check for temporary fixed value (set during backoff loop in instantiate())
298
320
  fixed_value = choice_info.get('_temp_fixed_value', choice_info['fixed_value'])
299
321
 
300
322
  # CRITICAL: Always consume an RNG call to keep RNG state synchronized between
@@ -339,30 +361,48 @@ class RegenerableChoiceMixin:
339
361
  self.config_params[param_name] = random_choice.name
340
362
  return random_choice
341
363
 
364
+ def pre_instantiate(self, base_seed, **kwargs):
365
+ if not (hasattr(self, '_regenerable_choices') and self._regenerable_choices):
366
+ return
367
+ choice_rng = random.Random(base_seed)
368
+ for param_name, choice_info in self._regenerable_choices.items():
369
+ if choice_info['fixed_value'] is None:
370
+ enum_class = choice_info['enum_class']
371
+ random_choice = choice_rng.choice(list(enum_class))
372
+ # Temporarily set this as the fixed value so all builds use it
373
+ choice_info['_temp_fixed_value'] = random_choice.name
374
+ # Store in config_params
375
+ self.config_params[param_name] = random_choice.name
376
+
377
+ def post_instantiate(self, instance, **kwargs):
378
+ if not (hasattr(self, '_regenerable_choices') and self._regenerable_choices):
379
+ return
380
+ for param_name, choice_info in self._regenerable_choices.items():
381
+ if '_temp_fixed_value' in choice_info:
382
+ del choice_info['_temp_fixed_value']
342
383
 
343
384
  class Question(abc.ABC):
385
+ AUTO_ENTRY_WARNINGS = True
344
386
  """
345
387
  Base class for all quiz questions with cross-format rendering support.
346
388
 
347
389
  CRITICAL: When implementing Question subclasses, ALWAYS use content AST elements
348
- for all content in get_body() and get_explanation() methods.
390
+ for all content in _build_body() and _build_explanation() methods.
349
391
 
350
392
  NEVER create manual LaTeX, HTML, or Markdown strings. The content AST system
351
393
  ensures consistent rendering across PDF/LaTeX and Canvas/HTML formats.
352
394
 
353
- Required Methods:
354
- - _get_body(): Return Tuple[ca.Section, List[ca.Answer]] with body and answers
355
- - _get_explanation(): Return Tuple[ca.Section, List[ca.Answer]] with explanation
356
-
357
- Note: get_body() and get_explanation() are provided for backward compatibility
358
- and call the _get_* methods, returning just the first element of the tuple.
395
+ Primary extension points:
396
+ - build(...): Simple path. Override to generate body + explanation in one place.
397
+ - _build_context/_build_body/_build_explanation: Context path.
398
+ Default _build_context is required for all questions.
359
399
 
360
400
  Required Class Attributes:
361
401
  - VERSION (str): Question version number (e.g., "1.0")
362
402
  Increment when RNG logic changes to ensure reproducibility
363
403
 
364
404
  Content AST Usage Examples:
365
- def _get_body(self):
405
+ def _build_body(cls, context):
366
406
  body = ca.Section()
367
407
  answers = []
368
408
  body.add_element(ca.Paragraph(["Calculate the matrix:"]))
@@ -375,7 +415,7 @@ class Question(abc.ABC):
375
415
  ans = ca.Answer.integer("result", 42, label="Result")
376
416
  answers.append(ans)
377
417
  body.add_element(ans)
378
- return body, answers
418
+ return body
379
419
 
380
420
  Common Content AST Elements:
381
421
  - ca.Paragraph: Text blocks
@@ -392,7 +432,7 @@ class Question(abc.ABC):
392
432
  - Do NOT increment for:
393
433
  * Cosmetic changes (formatting, wording)
394
434
  * Bug fixes that don't affect answer generation
395
- * Changes to get_explanation() only
435
+ * Changes to _build_explanation() only
396
436
 
397
437
  See existing questions in premade_questions/ for patterns and examples.
398
438
  """
@@ -480,14 +520,10 @@ class Question(abc.ABC):
480
520
 
481
521
  self.extra_attrs = kwargs # clear page, etc.
482
522
 
483
- self.answers = {}
484
523
  self.possible_variations = float('inf')
485
524
 
486
525
  self.rng_seed_offset = kwargs.get("rng_seed_offset", 0)
487
526
 
488
- # Component caching for unified Answer architecture
489
- self._components: QuestionComponents = None
490
-
491
527
  # To be used throughout when generating random things
492
528
  self.rng = random.Random()
493
529
 
@@ -504,214 +540,296 @@ class Question(abc.ABC):
504
540
  with open(path_to_yaml) as fid:
505
541
  question_dicts = yaml.safe_load_all(fid)
506
542
 
507
- def get_question(self, **kwargs) -> ca.Question:
543
+
544
+ def instantiate(self, **kwargs) -> QuestionInstance:
508
545
  """
509
- Gets the question in AST format
510
- :param kwargs:
511
- :return: (ca.Question) Containing question.
546
+ Instantiate a question once, returning content, answers, and regeneration metadata.
512
547
  """
513
548
  # Generate the question, retrying with incremented seeds until we get an interesting one
514
- with timer("question_refresh", question_name=self.name, question_type=self.__class__.__name__):
515
- base_seed = kwargs.get("rng_seed", None)
516
-
517
- # Pre-select any regenerable choices using the base seed
518
- # This ensures the policy/algorithm stays constant across backoff attempts
519
- if hasattr(self, '_regenerable_choices') and self._regenerable_choices:
520
- # Seed a temporary RNG with the base seed to make the choices
521
- choice_rng = random.Random(base_seed)
522
- for param_name, choice_info in self._regenerable_choices.items():
523
- if choice_info['fixed_value'] is None:
524
- # No fixed value - pick randomly and store it as fixed for this get_question() call
525
- enum_class = choice_info['enum_class']
526
- random_choice = choice_rng.choice(list(enum_class))
527
- # Temporarily set this as the fixed value so all refresh() calls use it
528
- choice_info['_temp_fixed_value'] = random_choice.name
529
- # Store in config_params
530
- self.config_params[param_name] = random_choice.name
549
+ base_seed = kwargs.get("rng_seed", None)
550
+ build_kwargs = dict(kwargs)
551
+ build_kwargs.pop("rng_seed", None)
552
+ # Include config params so build() implementations can access YAML-provided settings.
553
+ build_kwargs = {**self.config_params, **build_kwargs}
554
+
555
+ # Pre-select any regenerable choices using the base seed
556
+ # This ensures the policy/algorithm stays constant across backoff attempts
557
+ self.pre_instantiate(base_seed, **kwargs)
531
558
 
559
+ instance = None
560
+ try:
532
561
  backoff_counter = 0
533
562
  is_interesting = False
563
+ ctx = None
534
564
  while not is_interesting:
535
565
  # Increment seed for each backoff attempt to maintain deterministic behavior
536
566
  current_seed = None if base_seed is None else base_seed + backoff_counter
537
- # Pass config_params to refresh so custom kwargs from YAML are available
538
- self.refresh(rng_seed=current_seed, hard_refresh=(backoff_counter > 0), **self.config_params)
539
- is_interesting = self.is_interesting()
567
+ ctx = self._build_context(
568
+ rng_seed=current_seed,
569
+ **self.config_params
570
+ )
571
+ is_interesting = self.is_interesting_ctx(ctx)
540
572
  backoff_counter += 1
541
573
 
542
- # Clear temporary fixed values
543
- if hasattr(self, '_regenerable_choices') and self._regenerable_choices:
544
- for param_name, choice_info in self._regenerable_choices.items():
545
- if '_temp_fixed_value' in choice_info:
546
- del choice_info['_temp_fixed_value']
574
+ # Store the actual seed used and question metadata for QR code generation
575
+ actual_seed = None if base_seed is None else base_seed + backoff_counter - 1
547
576
 
548
- with timer("question_body", question_name=self.name, question_type=self.__class__.__name__):
549
- body = self.get_body()
577
+ components = self.build(rng_seed=current_seed, context=ctx, **build_kwargs)
550
578
 
551
- with timer("question_explanation", question_name=self.name, question_type=self.__class__.__name__):
552
- explanation = self.get_explanation()
579
+ # Collect answers from explicit lists and inline AST
580
+ inline_body_answers = self._collect_answers_from_ast(components.body)
581
+ answers = self._merge_answers(
582
+ components.answers,
583
+ inline_body_answers
584
+ )
553
585
 
554
- # Store the actual seed used and question metadata for QR code generation
555
- actual_seed = None if base_seed is None else base_seed + backoff_counter - 1
556
- question_ast = ca.Question(
557
- body=body,
558
- explanation=explanation,
559
- value=self.points_value,
560
- spacing=self.spacing,
561
- topic=self.topic,
562
-
563
- can_be_numerical=self.can_be_numerical()
564
- )
586
+ can_be_numerical = self._can_be_numerical_from_answers(answers)
587
+
588
+ if self.AUTO_ENTRY_WARNINGS:
589
+ warnings = self._entry_warnings_from_answers(answers)
590
+ components.body = self._append_entry_warnings(components.body, warnings)
591
+
592
+ config_params = dict(self.config_params)
593
+ if isinstance(ctx, dict) and ctx.get("_config_params"):
594
+ config_params.update(ctx.get("_config_params"))
595
+
596
+ instance = QuestionInstance(
597
+ body=components.body,
598
+ explanation=components.explanation,
599
+ answers=answers,
600
+ answer_kind=self.answer_kind,
601
+ can_be_numerical=can_be_numerical,
602
+ value=self.points_value,
603
+ spacing=self.spacing,
604
+ topic=self.topic,
605
+ flags=RegenerationFlags(
606
+ question_class_name=self._get_registered_name(),
607
+ generation_seed=actual_seed,
608
+ question_version=self.VERSION,
609
+ config_params=config_params
610
+ )
611
+ )
612
+ return instance
613
+ finally:
614
+ self.post_instantiate(instance, **kwargs)
565
615
 
566
- # Attach regeneration metadata to the question AST
567
- # Use the registered name instead of class name for better QR code regeneration
568
- question_ast.question_class_name = self._get_registered_name()
569
- question_ast.generation_seed = actual_seed
570
- question_ast.question_version = self.VERSION
571
- # Make a copy of config_params so each question AST has its own
572
- # (important when the same Question instance is reused for multiple PDFs)
573
- question_ast.config_params = dict(self.config_params)
616
+ def pre_instantiate(self, base_seed, **kwargs):
617
+ pass
574
618
 
575
- return question_ast
576
-
577
- @abc.abstractmethod
578
- def get_body(self, **kwargs) -> ca.Section:
579
- """
580
- Gets the body of the question during generation
581
- :param kwargs:
582
- :return: (ca.Section) Containing question body
583
- """
619
+ def post_instantiate(self, instance, **kwargs):
584
620
  pass
585
-
586
- def get_explanation(self, **kwargs) -> ca.Section:
621
+
622
+ def build(self, *, rng_seed=None, context=None, **kwargs) -> QuestionComponents:
587
623
  """
588
- Gets the body of the question during generation (backward compatible wrapper).
589
- Calls _get_explanation() and returns just the explanation.
590
- :param kwargs:
591
- :return: (ca.Section) Containing question explanation or None
624
+ Build question content (body, answers, explanation) for a given seed.
625
+
626
+ This should only generate content; metadata like points/spacing belong in instantiate().
592
627
  """
593
- # Try new pattern first
594
- if hasattr(self, '_get_explanation') and callable(getattr(self, '_get_explanation')):
595
- explanation, _ = self._get_explanation()
596
- return explanation
597
- # Fallback: default explanation
598
- return ca.Section(
599
- [ca.Text("[Please reach out to your professor for clarification]")]
628
+ cls = self.__class__
629
+ if context is None:
630
+ context = self._build_context(rng_seed=rng_seed, **kwargs)
631
+
632
+ # Build body + explanation. Each may return just an Element or (Element, answers).
633
+ body, body_answers = cls._normalize_build_output(self._build_body(context))
634
+ explanation, explanation_answers = cls._normalize_build_output(self._build_explanation(context))
635
+
636
+ # Collect inline answers from both body and explanation.
637
+ inline_body_answers = cls._collect_answers_from_ast(body)
638
+ inline_explanation_answers = cls._collect_answers_from_ast(explanation)
639
+
640
+ answers = cls._merge_answers(
641
+ body_answers,
642
+ explanation_answers,
643
+ inline_body_answers,
644
+ inline_explanation_answers
600
645
  )
601
646
 
602
- def _get_body(self) -> Tuple[ca.Element, List[ca.Answer]]:
603
- """
604
- Build question body and collect answers (new pattern).
605
- Questions should override this to return (body, answers) tuple.
647
+ return QuestionComponents(
648
+ body=body,
649
+ answers=answers,
650
+ explanation=explanation
651
+ )
606
652
 
607
- Returns:
608
- Tuple of (body_ast, answers_list)
653
+ def _build_context(self, *, rng_seed=None, **kwargs):
609
654
  """
610
- # Fallback: call old get_body() and return empty answers
611
- body = self.get_body()
612
- return body, []
655
+ Build the deterministic context for a question instance.
613
656
 
614
- def _get_explanation(self) -> Tuple[ca.Element, List[ca.Answer]]:
657
+ Override to return a context dict and avoid persistent self.* state.
615
658
  """
616
- Build question explanation and collect answers (new pattern).
617
- Questions can override this to include answers in explanations.
659
+ rng = random.Random(rng_seed)
660
+ # Keep instance rng in sync for questions that still use self.rng.
661
+ self.rng = rng
662
+ return {
663
+ "rng_seed": rng_seed,
664
+ "rng": rng,
665
+ }
618
666
 
619
- Returns:
620
- Tuple of (explanation_ast, answers_list)
621
- """
622
- return ca.Section(
623
- [ca.Text("[Please reach out to your professor for clarification]")]
624
- ), []
667
+ @classmethod
668
+ def is_interesting_ctx(cls, context) -> bool:
669
+ """Context-aware hook; defaults to existing is_interesting()."""
670
+ return True
625
671
 
626
- def build_question_components(self, **kwargs) -> QuestionComponents:
627
- """
628
- Build question components (body, answers, explanation) in single pass.
672
+ def _build_body(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
673
+ """Context-aware body builder."""
674
+ raise NotImplementedError("Questions must implement _build_body().")
629
675
 
630
- Calls _get_body() and _get_explanation() which return tuples of
631
- (content, answers).
632
- """
633
- # Build body with its answers
634
- body, body_answers = self._get_body()
676
+ def _build_explanation(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
677
+ """Context-aware explanation builder."""
678
+ raise NotImplementedError("Questions must implement _build_explanation().")
635
679
 
636
- # Build explanation with its answers
637
- explanation, explanation_answers = self._get_explanation()
680
+ @classmethod
681
+ def _collect_answers_from_ast(cls, element: ca.Element) -> List[ca.Answer]:
682
+ """Traverse AST and collect embedded Answer elements."""
683
+ answers: List[ca.Answer] = []
684
+
685
+ def visit(node):
686
+ if node is None:
687
+ return
688
+ if isinstance(node, ca.Answer):
689
+ answers.append(node)
690
+ return
691
+ if isinstance(node, ca.Table):
692
+ if getattr(node, "headers", None):
693
+ for header in node.headers:
694
+ visit(header)
695
+ for row in node.data:
696
+ for cell in row:
697
+ visit(cell)
698
+ return
699
+ if isinstance(node, ca.TableGroup):
700
+ for _, table in node.tables:
701
+ visit(table)
702
+ return
703
+ if isinstance(node, ca.Container):
704
+ for child in node.elements:
705
+ visit(child)
706
+ return
707
+ if isinstance(node, (list, tuple)):
708
+ for child in node:
709
+ visit(child)
710
+
711
+ visit(element)
712
+ return answers
638
713
 
639
- # Combine all answers
640
- all_answers = body_answers + explanation_answers
714
+ @classmethod
715
+ def _merge_answers(cls, *answer_lists: List[ca.Answer]) -> List[ca.Answer]:
716
+ """Merge answers while preserving order and removing duplicates by key/id."""
717
+ merged: List[ca.Answer] = []
718
+ seen: set[str] = set()
719
+
720
+ for answers in answer_lists:
721
+ for ans in answers:
722
+ key = getattr(ans, "key", None)
723
+ if key is None:
724
+ key = str(id(ans))
725
+ if key in seen:
726
+ continue
727
+ seen.add(key)
728
+ merged.append(ans)
729
+ return merged
641
730
 
642
- return QuestionComponents(
643
- body=body,
644
- answers=all_answers,
645
- explanation=explanation
731
+ @classmethod
732
+ def _entry_warnings_from_answers(cls, answers: List[ca.Answer]) -> List[str]:
733
+ warnings: List[str] = []
734
+ seen: set[str] = set()
735
+ for answer in answers:
736
+ warning = None
737
+ if hasattr(answer.__class__, "get_entry_warning"):
738
+ warning = answer.__class__.get_entry_warning()
739
+ if not warning:
740
+ continue
741
+ if isinstance(warning, str):
742
+ warning_list = [warning]
743
+ else:
744
+ warning_list = list(warning)
745
+ for item in warning_list:
746
+ if item and item not in seen:
747
+ warnings.append(item)
748
+ seen.add(item)
749
+ return warnings
750
+
751
+ @classmethod
752
+ def _append_entry_warnings(cls, body: ca.Element, warnings: List[str]) -> ca.Element:
753
+ if not warnings:
754
+ return body
755
+ notes_lines = ["**Notes for answer entry**", ""]
756
+ notes_lines.extend(f"- {warning}" for warning in warnings)
757
+ warning_elements = ca.OnlyHtml([ca.Text("\n".join(notes_lines))])
758
+ if isinstance(body, ca.Container):
759
+ body.add_element(warning_elements)
760
+ return body
761
+ return ca.Section([body, warning_elements])
762
+
763
+ @classmethod
764
+ def _can_be_numerical_from_answers(cls, answers: List[ca.Answer]) -> bool:
765
+ return (
766
+ len(answers) == 1
767
+ and isinstance(answers[0], ca.AnswerTypes.Float)
646
768
  )
647
769
 
648
- def get_answers(self, *args, **kwargs) -> Tuple[ca.Answer.CanvasAnswerKind, List[Dict[str,Any]]]:
649
- """
650
- Return answers from cached components (new pattern) or self.answers dict (old pattern).
651
- """
652
- # Try component-based approach first (new pattern)
653
- if self._components is None:
654
- try:
655
- self._components = self.build_question_components()
656
- except Exception as e:
657
- # If component building fails, fall back to dict
658
- log.debug(f"Failed to build question components: {e}, falling back to dict")
659
- pass
660
-
661
- # Use components if available and non-empty
662
- if self._components is not None and len(self._components.answers) > 0:
663
- answers = self._components.answers
664
- if self.can_be_numerical():
665
- return (
666
- ca.Answer.CanvasAnswerKind.NUMERICAL_QUESTION,
667
- list(itertools.chain(*[a.get_for_canvas(single_answer=True) for a in answers]))
668
- )
770
+ @classmethod
771
+ def _normalize_build_output(
772
+ cls,
773
+ result: ca.Element | Tuple[ca.Element, List[ca.Answer]]
774
+ ) -> Tuple[ca.Element, List[ca.Answer]]:
775
+ if isinstance(result, tuple):
776
+ body, answers = result
777
+ return body, list(answers or [])
778
+ return result, []
779
+
780
+ def _answers_for_canvas(
781
+ self,
782
+ answers: List[ca.Answer],
783
+ can_be_numerical: bool
784
+ ) -> Tuple[ca.Answer.CanvasAnswerKind, List[Dict[str, Any]]]:
785
+ if len(answers) == 0:
786
+ return (ca.Answer.CanvasAnswerKind.ESSAY, [])
787
+
788
+ if can_be_numerical:
669
789
  return (
670
- self.answer_kind,
671
- list(itertools.chain(*[a.get_for_canvas() for a in answers]))
790
+ ca.Answer.CanvasAnswerKind.NUMERICAL_QUESTION,
791
+ list(itertools.chain(*[a.get_for_canvas(single_answer=True) for a in answers]))
672
792
  )
673
793
 
674
- # Fall back to dict pattern (old pattern)
675
- if len(self.answers.values()) > 0:
676
- if self.can_be_numerical():
677
- return (
678
- ca.Answer.CanvasAnswerKind.NUMERICAL_QUESTION,
679
- list(itertools.chain(*[a.get_for_canvas(single_answer=True) for a in self.answers.values()]))
680
- )
681
- return (
682
- self.answer_kind,
683
- list(itertools.chain(*[a.get_for_canvas() for a in self.answers.values()]))
684
- )
794
+ return (
795
+ self.answer_kind,
796
+ list(itertools.chain(*[a.get_for_canvas() for a in answers]))
797
+ )
798
+
799
+ def _build_question_ast(self, instance: QuestionInstance) -> ca.Question:
800
+ question_ast = ca.Question(
801
+ body=instance.body,
802
+ explanation=instance.explanation,
803
+ value=instance.value,
804
+ spacing=instance.spacing,
805
+ topic=instance.topic,
806
+ can_be_numerical=instance.can_be_numerical
807
+ )
808
+
809
+ # Attach regeneration metadata to the question AST
810
+ question_ast.question_class_name = instance.flags.question_class_name
811
+ question_ast.generation_seed = instance.flags.generation_seed
812
+ question_ast.question_version = instance.flags.question_version
813
+ question_ast.config_params = dict(instance.flags.config_params)
814
+
815
+ return question_ast
685
816
 
686
- return (ca.Answer.CanvasAnswerKind.ESSAY, [])
687
-
688
817
  def refresh(self, rng_seed=None, *args, **kwargs):
689
- """If it is necessary to regenerate aspects between usages, this is the time to do it.
690
- This base implementation simply resets everything.
691
- :param rng_seed: random number generator seed to use when regenerating question
692
- :param *args:
693
- :param **kwargs:
694
- :return: bool - True if the generated question is interesting, False otherwise
695
- """
696
- self.answers = {}
697
- self._components = None # Clear component cache
698
- # Seed the RNG directly with the provided seed (no offset)
699
- self.rng.seed(rng_seed)
700
- # Note: We don't call is_interesting() here because child classes need to
701
- # generate their workloads first. Child classes should call it at the end
702
- # of their refresh() and return the result.
703
- return self.is_interesting() # Default: assume interesting if no override
818
+ raise NotImplementedError("refresh() has been removed; use _build_context().")
704
819
 
705
820
  def is_interesting(self) -> bool:
706
821
  return True
707
822
 
708
823
  def get__canvas(self, course: canvasapi.course.Course, quiz : canvasapi.quiz.Quiz, interest_threshold=1.0, *args, **kwargs):
709
- # Get the AST for the question
710
- with timer("question_get_ast", question_name=self.name, question_type=self.__class__.__name__):
711
- questionAST = self.get_question(**kwargs)
712
- log.debug("got question ast")
713
- # Get the answers and type of question
714
- question_type, answers = self.get_answers(*args, **kwargs)
824
+ # Instantiate once for both content and answers
825
+ instance = self.instantiate(**kwargs)
826
+ log.debug("got question instance")
827
+
828
+ questionAST = self._build_question_ast(instance)
829
+ question_type, answers = self._answers_for_canvas(
830
+ instance.answers,
831
+ instance.can_be_numerical
832
+ )
715
833
 
716
834
  # Define a helper function for uploading images to canvas
717
835
  def image_upload(img_data) -> str:
@@ -732,11 +850,8 @@ class Question(abc.ABC):
732
850
  return f"/courses/{course.id}/files/{f['id']}/preview"
733
851
 
734
852
  # Render AST to HTML for Canvas
735
- with timer("ast_render_body", question_name=self.name, question_type=self.__class__.__name__):
736
- question_html = questionAST.render("html", upload_func=image_upload)
737
-
738
- with timer("ast_render_explanation", question_name=self.name, question_type=self.__class__.__name__):
739
- explanation_html = questionAST.explanation.render("html", upload_func=image_upload)
853
+ question_html = questionAST.render("html", upload_func=image_upload)
854
+ explanation_html = questionAST.explanation.render("html", upload_func=image_upload)
740
855
 
741
856
  # Build appropriate dictionary to send to canvas
742
857
  return {
@@ -748,13 +863,6 @@ class Question(abc.ABC):
748
863
  "neutral_comments_html": explanation_html
749
864
  }
750
865
 
751
- def can_be_numerical(self):
752
- if (len(self.answers.values()) == 1
753
- and isinstance(list(self.answers.values())[0], ca.AnswerTypes.Float)
754
- ):
755
- return True
756
- return False
757
-
758
866
  def _get_registered_name(self) -> str:
759
867
  """
760
868
  Get the registered name for this question class.