QuizGenerator 0.7.1__py3-none-any.whl → 0.8.0__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 +6 -6
  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 -322
  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 -520
  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 +273 -202
  24. QuizGenerator/quiz.py +8 -5
  25. QuizGenerator/regenerate.py +14 -6
  26. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.0.dist-info}/METADATA +30 -2
  27. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.0.dist-info}/RECORD +30 -30
  28. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.0.dist-info}/WHEEL +0 -0
  29. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.0.dist-info}/entry_points.txt +0 -0
  30. {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.0.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,47 @@ 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):
344
385
  """
345
386
  Base class for all quiz questions with cross-format rendering support.
346
387
 
347
388
  CRITICAL: When implementing Question subclasses, ALWAYS use content AST elements
348
- for all content in get_body() and get_explanation() methods.
389
+ for all content in _build_body() and _build_explanation() methods.
349
390
 
350
391
  NEVER create manual LaTeX, HTML, or Markdown strings. The content AST system
351
392
  ensures consistent rendering across PDF/LaTeX and Canvas/HTML formats.
352
393
 
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.
394
+ Primary extension points:
395
+ - build(...): Simple path. Override to generate body + explanation in one place.
396
+ - _build_context/_build_body/_build_explanation: Context path.
397
+ Default _build_context is required for all questions.
359
398
 
360
399
  Required Class Attributes:
361
400
  - VERSION (str): Question version number (e.g., "1.0")
362
401
  Increment when RNG logic changes to ensure reproducibility
363
402
 
364
403
  Content AST Usage Examples:
365
- def _get_body(self):
404
+ def _build_body(cls, context):
366
405
  body = ca.Section()
367
406
  answers = []
368
407
  body.add_element(ca.Paragraph(["Calculate the matrix:"]))
@@ -375,7 +414,7 @@ class Question(abc.ABC):
375
414
  ans = ca.Answer.integer("result", 42, label="Result")
376
415
  answers.append(ans)
377
416
  body.add_element(ans)
378
- return body, answers
417
+ return body
379
418
 
380
419
  Common Content AST Elements:
381
420
  - ca.Paragraph: Text blocks
@@ -392,7 +431,7 @@ class Question(abc.ABC):
392
431
  - Do NOT increment for:
393
432
  * Cosmetic changes (formatting, wording)
394
433
  * Bug fixes that don't affect answer generation
395
- * Changes to get_explanation() only
434
+ * Changes to _build_explanation() only
396
435
 
397
436
  See existing questions in premade_questions/ for patterns and examples.
398
437
  """
@@ -480,14 +519,10 @@ class Question(abc.ABC):
480
519
 
481
520
  self.extra_attrs = kwargs # clear page, etc.
482
521
 
483
- self.answers = {}
484
522
  self.possible_variations = float('inf')
485
523
 
486
524
  self.rng_seed_offset = kwargs.get("rng_seed_offset", 0)
487
525
 
488
- # Component caching for unified Answer architecture
489
- self._components: QuestionComponents = None
490
-
491
526
  # To be used throughout when generating random things
492
527
  self.rng = random.Random()
493
528
 
@@ -504,214 +539,260 @@ class Question(abc.ABC):
504
539
  with open(path_to_yaml) as fid:
505
540
  question_dicts = yaml.safe_load_all(fid)
506
541
 
507
- def get_question(self, **kwargs) -> ca.Question:
542
+
543
+ def instantiate(self, **kwargs) -> QuestionInstance:
508
544
  """
509
- Gets the question in AST format
510
- :param kwargs:
511
- :return: (ca.Question) Containing question.
545
+ Instantiate a question once, returning content, answers, and regeneration metadata.
512
546
  """
513
547
  # 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
548
+ base_seed = kwargs.get("rng_seed", None)
549
+ build_kwargs = dict(kwargs)
550
+ build_kwargs.pop("rng_seed", None)
551
+ # Include config params so build() implementations can access YAML-provided settings.
552
+ build_kwargs = {**self.config_params, **build_kwargs}
553
+
554
+ # Pre-select any regenerable choices using the base seed
555
+ # This ensures the policy/algorithm stays constant across backoff attempts
556
+ self.pre_instantiate(base_seed, **kwargs)
531
557
 
558
+ instance = None
559
+ try:
532
560
  backoff_counter = 0
533
561
  is_interesting = False
562
+ ctx = None
534
563
  while not is_interesting:
535
564
  # Increment seed for each backoff attempt to maintain deterministic behavior
536
565
  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()
566
+ ctx = self._build_context(
567
+ rng_seed=current_seed,
568
+ **self.config_params
569
+ )
570
+ is_interesting = self.is_interesting_ctx(ctx)
540
571
  backoff_counter += 1
541
572
 
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']
573
+ # Store the actual seed used and question metadata for QR code generation
574
+ actual_seed = None if base_seed is None else base_seed + backoff_counter - 1
547
575
 
548
- with timer("question_body", question_name=self.name, question_type=self.__class__.__name__):
549
- body = self.get_body()
576
+ components = self.build(rng_seed=current_seed, context=ctx, **build_kwargs)
550
577
 
551
- with timer("question_explanation", question_name=self.name, question_type=self.__class__.__name__):
552
- explanation = self.get_explanation()
578
+ # Collect answers from explicit lists and inline AST
579
+ inline_body_answers = self._collect_answers_from_ast(components.body)
580
+ answers = self._merge_answers(
581
+ components.answers,
582
+ inline_body_answers
583
+ )
553
584
 
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
- )
585
+ can_be_numerical = self._can_be_numerical_from_answers(answers)
586
+
587
+ config_params = dict(self.config_params)
588
+ if isinstance(ctx, dict) and ctx.get("_config_params"):
589
+ config_params.update(ctx.get("_config_params"))
590
+
591
+ instance = QuestionInstance(
592
+ body=components.body,
593
+ explanation=components.explanation,
594
+ answers=answers,
595
+ answer_kind=self.answer_kind,
596
+ can_be_numerical=can_be_numerical,
597
+ value=self.points_value,
598
+ spacing=self.spacing,
599
+ topic=self.topic,
600
+ flags=RegenerationFlags(
601
+ question_class_name=self._get_registered_name(),
602
+ generation_seed=actual_seed,
603
+ question_version=self.VERSION,
604
+ config_params=config_params
605
+ )
606
+ )
607
+ return instance
608
+ finally:
609
+ self.post_instantiate(instance, **kwargs)
565
610
 
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)
611
+ def pre_instantiate(self, base_seed, **kwargs):
612
+ pass
574
613
 
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
- """
614
+ def post_instantiate(self, instance, **kwargs):
584
615
  pass
585
-
586
- def get_explanation(self, **kwargs) -> ca.Section:
616
+
617
+ def build(self, *, rng_seed=None, context=None, **kwargs) -> QuestionComponents:
587
618
  """
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
619
+ Build question content (body, answers, explanation) for a given seed.
620
+
621
+ This should only generate content; metadata like points/spacing belong in instantiate().
592
622
  """
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]")]
623
+ cls = self.__class__
624
+ if context is None:
625
+ context = self._build_context(rng_seed=rng_seed, **kwargs)
626
+
627
+ # Build body + explanation. Each may return just an Element or (Element, answers).
628
+ body, body_answers = cls._normalize_build_output(self._build_body(context))
629
+ explanation, explanation_answers = cls._normalize_build_output(self._build_explanation(context))
630
+
631
+ # Collect inline answers from both body and explanation.
632
+ inline_body_answers = cls._collect_answers_from_ast(body)
633
+ inline_explanation_answers = cls._collect_answers_from_ast(explanation)
634
+
635
+ answers = cls._merge_answers(
636
+ body_answers,
637
+ explanation_answers,
638
+ inline_body_answers,
639
+ inline_explanation_answers
600
640
  )
601
641
 
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.
642
+ return QuestionComponents(
643
+ body=body,
644
+ answers=answers,
645
+ explanation=explanation
646
+ )
606
647
 
607
- Returns:
608
- Tuple of (body_ast, answers_list)
648
+ def _build_context(self, *, rng_seed=None, **kwargs):
609
649
  """
610
- # Fallback: call old get_body() and return empty answers
611
- body = self.get_body()
612
- return body, []
650
+ Build the deterministic context for a question instance.
613
651
 
614
- def _get_explanation(self) -> Tuple[ca.Element, List[ca.Answer]]:
652
+ Override to return a context dict and avoid persistent self.* state.
615
653
  """
616
- Build question explanation and collect answers (new pattern).
617
- Questions can override this to include answers in explanations.
654
+ rng = random.Random(rng_seed)
655
+ # Keep instance rng in sync for questions that still use self.rng.
656
+ self.rng = rng
657
+ return {
658
+ "rng_seed": rng_seed,
659
+ "rng": rng,
660
+ }
618
661
 
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
- ), []
662
+ @classmethod
663
+ def is_interesting_ctx(cls, context) -> bool:
664
+ """Context-aware hook; defaults to existing is_interesting()."""
665
+ return True
625
666
 
626
- def build_question_components(self, **kwargs) -> QuestionComponents:
627
- """
628
- Build question components (body, answers, explanation) in single pass.
667
+ def _build_body(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
668
+ """Context-aware body builder."""
669
+ raise NotImplementedError("Questions must implement _build_body().")
629
670
 
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()
671
+ def _build_explanation(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
672
+ """Context-aware explanation builder."""
673
+ raise NotImplementedError("Questions must implement _build_explanation().")
635
674
 
636
- # Build explanation with its answers
637
- explanation, explanation_answers = self._get_explanation()
675
+ @classmethod
676
+ def _collect_answers_from_ast(cls, element: ca.Element) -> List[ca.Answer]:
677
+ """Traverse AST and collect embedded Answer elements."""
678
+ answers: List[ca.Answer] = []
679
+
680
+ def visit(node):
681
+ if node is None:
682
+ return
683
+ if isinstance(node, ca.Answer):
684
+ answers.append(node)
685
+ return
686
+ if isinstance(node, ca.Table):
687
+ if getattr(node, "headers", None):
688
+ for header in node.headers:
689
+ visit(header)
690
+ for row in node.data:
691
+ for cell in row:
692
+ visit(cell)
693
+ return
694
+ if isinstance(node, ca.TableGroup):
695
+ for _, table in node.tables:
696
+ visit(table)
697
+ return
698
+ if isinstance(node, ca.Container):
699
+ for child in node.elements:
700
+ visit(child)
701
+ return
702
+ if isinstance(node, (list, tuple)):
703
+ for child in node:
704
+ visit(child)
705
+
706
+ visit(element)
707
+ return answers
638
708
 
639
- # Combine all answers
640
- all_answers = body_answers + explanation_answers
709
+ @classmethod
710
+ def _merge_answers(cls, *answer_lists: List[ca.Answer]) -> List[ca.Answer]:
711
+ """Merge answers while preserving order and removing duplicates by key/id."""
712
+ merged: List[ca.Answer] = []
713
+ seen: set[str] = set()
714
+
715
+ for answers in answer_lists:
716
+ for ans in answers:
717
+ key = getattr(ans, "key", None)
718
+ if key is None:
719
+ key = str(id(ans))
720
+ if key in seen:
721
+ continue
722
+ seen.add(key)
723
+ merged.append(ans)
724
+ return merged
641
725
 
642
- return QuestionComponents(
643
- body=body,
644
- answers=all_answers,
645
- explanation=explanation
726
+ @classmethod
727
+ def _can_be_numerical_from_answers(cls, answers: List[ca.Answer]) -> bool:
728
+ return (
729
+ len(answers) == 1
730
+ and isinstance(answers[0], ca.AnswerTypes.Float)
646
731
  )
647
732
 
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
- )
733
+ @classmethod
734
+ def _normalize_build_output(
735
+ cls,
736
+ result: ca.Element | Tuple[ca.Element, List[ca.Answer]]
737
+ ) -> Tuple[ca.Element, List[ca.Answer]]:
738
+ if isinstance(result, tuple):
739
+ body, answers = result
740
+ return body, list(answers or [])
741
+ return result, []
742
+
743
+ def _answers_for_canvas(
744
+ self,
745
+ answers: List[ca.Answer],
746
+ can_be_numerical: bool
747
+ ) -> Tuple[ca.Answer.CanvasAnswerKind, List[Dict[str, Any]]]:
748
+ if len(answers) == 0:
749
+ return (ca.Answer.CanvasAnswerKind.ESSAY, [])
750
+
751
+ if can_be_numerical:
669
752
  return (
670
- self.answer_kind,
671
- list(itertools.chain(*[a.get_for_canvas() for a in answers]))
753
+ ca.Answer.CanvasAnswerKind.NUMERICAL_QUESTION,
754
+ list(itertools.chain(*[a.get_for_canvas(single_answer=True) for a in answers]))
672
755
  )
673
756
 
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
- )
757
+ return (
758
+ self.answer_kind,
759
+ list(itertools.chain(*[a.get_for_canvas() for a in answers]))
760
+ )
761
+
762
+ def _build_question_ast(self, instance: QuestionInstance) -> ca.Question:
763
+ question_ast = ca.Question(
764
+ body=instance.body,
765
+ explanation=instance.explanation,
766
+ value=instance.value,
767
+ spacing=instance.spacing,
768
+ topic=instance.topic,
769
+ can_be_numerical=instance.can_be_numerical
770
+ )
771
+
772
+ # Attach regeneration metadata to the question AST
773
+ question_ast.question_class_name = instance.flags.question_class_name
774
+ question_ast.generation_seed = instance.flags.generation_seed
775
+ question_ast.question_version = instance.flags.question_version
776
+ question_ast.config_params = dict(instance.flags.config_params)
777
+
778
+ return question_ast
685
779
 
686
- return (ca.Answer.CanvasAnswerKind.ESSAY, [])
687
-
688
780
  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
781
+ raise NotImplementedError("refresh() has been removed; use _build_context().")
704
782
 
705
783
  def is_interesting(self) -> bool:
706
784
  return True
707
785
 
708
786
  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)
787
+ # Instantiate once for both content and answers
788
+ instance = self.instantiate(**kwargs)
789
+ log.debug("got question instance")
790
+
791
+ questionAST = self._build_question_ast(instance)
792
+ question_type, answers = self._answers_for_canvas(
793
+ instance.answers,
794
+ instance.can_be_numerical
795
+ )
715
796
 
716
797
  # Define a helper function for uploading images to canvas
717
798
  def image_upload(img_data) -> str:
@@ -732,11 +813,8 @@ class Question(abc.ABC):
732
813
  return f"/courses/{course.id}/files/{f['id']}/preview"
733
814
 
734
815
  # 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)
816
+ question_html = questionAST.render("html", upload_func=image_upload)
817
+ explanation_html = questionAST.explanation.render("html", upload_func=image_upload)
740
818
 
741
819
  # Build appropriate dictionary to send to canvas
742
820
  return {
@@ -748,13 +826,6 @@ class Question(abc.ABC):
748
826
  "neutral_comments_html": explanation_html
749
827
  }
750
828
 
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
829
  def _get_registered_name(self) -> str:
759
830
  """
760
831
  Get the registered name for this question class.