QuizGenerator 0.1.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 (44) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/__init__.py +27 -0
  3. QuizGenerator/__main__.py +7 -0
  4. QuizGenerator/canvas/__init__.py +13 -0
  5. QuizGenerator/canvas/canvas_interface.py +622 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1809 -0
  9. QuizGenerator/generate.py +362 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +480 -0
  12. QuizGenerator/mixins.py +539 -0
  13. QuizGenerator/performance.py +202 -0
  14. QuizGenerator/premade_questions/__init__.py +0 -0
  15. QuizGenerator/premade_questions/basic.py +103 -0
  16. QuizGenerator/premade_questions/cst334/__init__.py +1 -0
  17. QuizGenerator/premade_questions/cst334/languages.py +395 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1398 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +396 -0
  22. QuizGenerator/premade_questions/cst334/process.py +649 -0
  23. QuizGenerator/premade_questions/cst463/__init__.py +0 -0
  24. QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
  25. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
  26. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
  27. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
  28. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
  29. QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
  30. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
  31. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
  32. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
  33. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1264 -0
  34. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  35. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  36. QuizGenerator/qrcode_generator.py +293 -0
  37. QuizGenerator/question.py +657 -0
  38. QuizGenerator/quiz.py +468 -0
  39. QuizGenerator/typst_utils.py +113 -0
  40. quizgenerator-0.1.0.dist-info/METADATA +263 -0
  41. quizgenerator-0.1.0.dist-info/RECORD +44 -0
  42. quizgenerator-0.1.0.dist-info/WHEEL +4 -0
  43. quizgenerator-0.1.0.dist-info/entry_points.txt +2 -0
  44. quizgenerator-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,657 @@
1
+ #!env python
2
+ from __future__ import annotations
3
+
4
+ import abc
5
+ import io
6
+ import dataclasses
7
+ import datetime
8
+ import enum
9
+ import importlib
10
+ import itertools
11
+ import os
12
+ import pathlib
13
+ import pkgutil
14
+ import random
15
+ import re
16
+ import uuid
17
+
18
+ import pypandoc
19
+ import yaml
20
+ from typing import List, Dict, Any, Tuple, Optional
21
+ import canvasapi.course, canvasapi.quiz
22
+
23
+ from QuizGenerator.misc import OutputFormat, Answer
24
+ from QuizGenerator.contentast import ContentAST
25
+ from QuizGenerator.performance import timer, PerformanceTracker
26
+
27
+ import logging
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ # Spacing presets for questions
32
+ SPACING_PRESETS = {
33
+ "NONE": 0,
34
+ "SHORT": 4,
35
+ "MEDIUM": 6,
36
+ "LONG": 9,
37
+ "PAGE": 99, # Special value that will be handled during bin-packing
38
+ "EXTRA_PAGE": 199, # Special value that adds a full blank page after the question
39
+ }
40
+
41
+
42
+ def parse_spacing(spacing_value) -> float:
43
+ """
44
+ Parse spacing value from YAML config.
45
+
46
+ Args:
47
+ spacing_value: Either a preset name ("NONE", "SHORT", "LONG", "PAGE")
48
+ or a numeric value in cm
49
+
50
+ Returns:
51
+ Spacing in cm as a float
52
+
53
+ Examples:
54
+ parse_spacing("SHORT") -> 5.0
55
+ parse_spacing("NONE") -> 1.0
56
+ parse_spacing(3.5) -> 3.5
57
+ parse_spacing("3.5") -> 3.5
58
+ """
59
+ if isinstance(spacing_value, str):
60
+ # Check if it's a preset
61
+ if spacing_value.upper() in SPACING_PRESETS:
62
+ return float(SPACING_PRESETS[spacing_value.upper()])
63
+ # Try to parse as a number
64
+ try:
65
+ return float(spacing_value)
66
+ except ValueError:
67
+ log.warning(f"Invalid spacing value '{spacing_value}', defaulting to 0")
68
+ return 0.0
69
+ elif isinstance(spacing_value, (int, float)):
70
+ return float(spacing_value)
71
+ else:
72
+ log.warning(f"Invalid spacing type {type(spacing_value)}, defaulting to 0")
73
+ return 0.0
74
+
75
+
76
+ class QuestionRegistry:
77
+ _registry = {}
78
+ _scanned = False
79
+
80
+ @classmethod
81
+ def register(cls, question_type=None):
82
+ def decorator(subclass):
83
+ # Use the provided name or fall back to the class name
84
+ name = question_type.lower() if question_type else subclass.__name__.lower()
85
+ cls._registry[name] = subclass
86
+ return subclass
87
+ return decorator
88
+
89
+ @classmethod
90
+ def create(cls, question_type, **kwargs) -> Question:
91
+ """Instantiate a registered subclass."""
92
+ # If we haven't already loaded our premades, do so now
93
+ if not cls._scanned:
94
+ cls.load_premade_questions()
95
+
96
+ # Check to see if it's in the registry
97
+ question_key = question_type.lower()
98
+ if question_key not in cls._registry:
99
+ # Try stripping common course prefixes and module paths for backward compatibility
100
+ for prefix in ['cst334.', 'cst463.']:
101
+ if question_key.startswith(prefix):
102
+ stripped_name = question_key[len(prefix):]
103
+ if stripped_name in cls._registry:
104
+ question_key = stripped_name
105
+ break
106
+ # Also try extracting just the final class name after dots
107
+ if '.' in stripped_name:
108
+ final_name = stripped_name.split('.')[-1]
109
+ if final_name in cls._registry:
110
+ question_key = final_name
111
+ break
112
+ else:
113
+ # As a final fallback, try just the last part after dots
114
+ if '.' in question_key:
115
+ final_name = question_key.split('.')[-1]
116
+ if final_name in cls._registry:
117
+ question_key = final_name
118
+ else:
119
+ raise ValueError(f"Unknown question type: {question_type}")
120
+ else:
121
+ raise ValueError(f"Unknown question type: {question_type}")
122
+
123
+ new_question : Question = cls._registry[question_key](**kwargs)
124
+ # Note: Don't call refresh() here - it will be called by get_question()
125
+ # Calling it here would consume RNG calls and break QR code regeneration
126
+ return new_question
127
+
128
+
129
+ @classmethod
130
+ def load_premade_questions(cls):
131
+ package_name = "QuizGenerator.premade_questions" # Fully qualified package name
132
+ package_path = pathlib.Path(__file__).parent / "premade_questions"
133
+
134
+ def load_modules_recursively(path, package_prefix):
135
+ # Load modules from the current directory
136
+ for _, module_name, _ in pkgutil.iter_modules([str(path)]):
137
+ # Import the module
138
+ module = importlib.import_module(f"{package_prefix}.{module_name}")
139
+
140
+ # Recursively load modules from subdirectories
141
+ for subdir in path.iterdir():
142
+ if subdir.is_dir() and not subdir.name.startswith('_'):
143
+ subpackage_name = f"{package_prefix}.{subdir.name}"
144
+ load_modules_recursively(subdir, subpackage_name)
145
+
146
+ load_modules_recursively(package_path, package_name)
147
+
148
+ # Load user-registered questions via entry points (Option 1: Robust PyPI approach)
149
+ # Users can register custom questions in their package's pyproject.toml:
150
+ # [project.entry-points."quizgenerator.questions"]
151
+ # my_custom_question = "my_package.questions:CustomQuestion"
152
+ try:
153
+ # Python 3.10+ approach
154
+ from importlib.metadata import entry_points
155
+ eps = entry_points()
156
+ # Handle both Python 3.10+ (dict-like) and 3.12+ (select method)
157
+ if hasattr(eps, 'select'):
158
+ question_eps = eps.select(group='quizgenerator.questions')
159
+ else:
160
+ question_eps = eps.get('quizgenerator.questions', [])
161
+
162
+ for ep in question_eps:
163
+ try:
164
+ # Loading the entry point will trigger @QuestionRegistry.register() decorator
165
+ ep.load()
166
+ log.debug(f"Loaded custom question type from entry point: {ep.name}")
167
+ except Exception as e:
168
+ log.warning(f"Failed to load entry point '{ep.name}': {e}")
169
+ except ImportError:
170
+ # Python < 3.10 fallback using pkg_resources
171
+ try:
172
+ import pkg_resources
173
+ for ep in pkg_resources.iter_entry_points('quizgenerator.questions'):
174
+ try:
175
+ ep.load()
176
+ log.debug(f"Loaded custom question type from entry point: {ep.name}")
177
+ except Exception as e:
178
+ log.warning(f"Failed to load entry point '{ep.name}': {e}")
179
+ except ImportError:
180
+ # If pkg_resources isn't available either, just skip entry points
181
+ log.debug("Entry points not supported (importlib.metadata and pkg_resources unavailable)")
182
+
183
+ cls._scanned = True
184
+
185
+
186
+ class RegenerableChoiceMixin:
187
+ """
188
+ Mixin for questions that need to make random choices from enums/lists that are:
189
+ 1. Different across multiple refreshes (when the same Question instance is reused for multiple PDFs)
190
+ 2. Reproducible from QR code config_params
191
+
192
+ The Problem:
193
+ ------------
194
+ When generating multiple PDFs, Quiz.from_yaml() creates Question instances ONCE.
195
+ These instances are then refresh()ed multiple times with different RNG seeds.
196
+ If a question randomly selects an algorithm/policy in __init__(), all PDFs get the same choice
197
+ because __init__() only runs once with an unseeded RNG.
198
+
199
+ The Solution:
200
+ -------------
201
+ 1. In __init__(): Register choices with fixed values (if provided) or None (for random)
202
+ 2. In refresh(): Make random selections using the seeded RNG, store in config_params
203
+ 3. Result: Each refresh gets a different random choice, and it's captured for QR codes
204
+
205
+ Usage Example:
206
+ --------------
207
+ class SchedulingQuestion(Question, RegenerableChoiceMixin):
208
+ class Kind(enum.Enum):
209
+ FIFO = enum.auto()
210
+ SJF = enum.auto()
211
+
212
+ def __init__(self, scheduler_kind=None, **kwargs):
213
+ # Register the choice BEFORE calling super().__init__()
214
+ self.register_choice('scheduler_kind', self.Kind, scheduler_kind, kwargs)
215
+ super().__init__(**kwargs)
216
+
217
+ def refresh(self, **kwargs):
218
+ super().refresh(**kwargs)
219
+ # Get the choice (randomly selected or from config_params)
220
+ self.scheduler_algorithm = self.get_choice('scheduler_kind', self.Kind)
221
+ # ... rest of refresh logic
222
+ """
223
+
224
+ def __init__(self, *args, **kwargs):
225
+ # Initialize the choices registry if it doesn't exist
226
+ if not hasattr(self, '_regenerable_choices'):
227
+ self._regenerable_choices = {}
228
+ super().__init__(*args, **kwargs)
229
+
230
+ def register_choice(self, param_name: str, enum_class: type[enum.Enum], fixed_value: str | None, kwargs_dict: dict):
231
+ """
232
+ Register a choice parameter that needs to be regenerable.
233
+
234
+ Args:
235
+ param_name: The parameter name (e.g., 'scheduler_kind', 'policy')
236
+ enum_class: The enum class to choose from (e.g., SchedulingQuestion.Kind)
237
+ fixed_value: The fixed value if provided, or None for random selection
238
+ kwargs_dict: The kwargs dictionary to update (for config_params capture)
239
+
240
+ This should be called in __init__() BEFORE super().__init__().
241
+ """
242
+ # Store the enum class for later use
243
+ if not hasattr(self, '_regenerable_choices'):
244
+ self._regenerable_choices = {}
245
+
246
+ self._regenerable_choices[param_name] = {
247
+ 'enum_class': enum_class,
248
+ 'fixed_value': fixed_value
249
+ }
250
+
251
+ # Add to kwargs so config_params captures it
252
+ if fixed_value is not None:
253
+ kwargs_dict[param_name] = fixed_value
254
+
255
+ def get_choice(self, param_name: str, enum_class: type[enum.Enum]) -> enum.Enum:
256
+ """
257
+ Get the choice for a registered parameter.
258
+ Should be called in refresh() AFTER super().refresh().
259
+
260
+ Args:
261
+ param_name: The parameter name registered earlier
262
+ enum_class: The enum class to choose from
263
+
264
+ Returns:
265
+ The selected enum value (either fixed or randomly chosen)
266
+ """
267
+ choice_info = self._regenerable_choices.get(param_name)
268
+ if choice_info is None:
269
+ raise ValueError(f"Choice '{param_name}' not registered. Call register_choice() in __init__() first.")
270
+
271
+ # Check for temporary fixed value (set during backoff loop in get_question())
272
+ fixed_value = choice_info.get('_temp_fixed_value', choice_info['fixed_value'])
273
+
274
+ # CRITICAL: Always consume an RNG call to keep RNG state synchronized between
275
+ # original generation and QR code regeneration. During original generation,
276
+ # we pick randomly. During regeneration, we already know the answer from
277
+ # config_params, but we still need to consume the RNG call.
278
+ enum_list = list(enum_class)
279
+ random_choice = self.rng.choice(enum_list)
280
+
281
+ if fixed_value is None:
282
+ # No fixed value - use the random choice we just picked
283
+ self.config_params[param_name] = random_choice.name
284
+ return random_choice
285
+ else:
286
+ # Fixed value provided - ignore the random choice, use the fixed value
287
+ # (but we still consumed the RNG call above to keep state synchronized)
288
+
289
+ # If already an enum instance, return it directly
290
+ if isinstance(fixed_value, enum_class):
291
+ return fixed_value
292
+
293
+ # If it's a string, look up the enum member by name
294
+ if isinstance(fixed_value, str):
295
+ try:
296
+ # Try exact match first (handles "RoundRobin", "FIFO", etc.)
297
+ return enum_class[fixed_value]
298
+ except KeyError:
299
+ # Try uppercase as fallback (handles "roundrobin" -> "ROUNDROBIN")
300
+ try:
301
+ return enum_class[fixed_value.upper()]
302
+ except KeyError:
303
+ log.warning(
304
+ f"Invalid {param_name} '{fixed_value}'. Valid options are: {[k.name for k in enum_class]}. Defaulting to random"
305
+ )
306
+ self.config_params[param_name] = random_choice.name
307
+ return random_choice
308
+
309
+ # Unexpected type
310
+ log.warning(
311
+ f"Invalid {param_name} type {type(fixed_value)}. Expected enum or string. Defaulting to random"
312
+ )
313
+ self.config_params[param_name] = random_choice.name
314
+ return random_choice
315
+
316
+
317
+ class Question(abc.ABC):
318
+ """
319
+ Base class for all quiz questions with cross-format rendering support.
320
+
321
+ CRITICAL: When implementing Question subclasses, ALWAYS use ContentAST elements
322
+ for all content in get_body() and get_explanation() methods.
323
+
324
+ NEVER create manual LaTeX, HTML, or Markdown strings. The ContentAST system
325
+ ensures consistent rendering across PDF/LaTeX and Canvas/HTML formats.
326
+
327
+ Required Methods:
328
+ - get_body(): Return ContentAST.Section with question content
329
+ - get_explanation(): Return ContentAST.Section with solution steps
330
+
331
+ Required Class Attributes:
332
+ - VERSION (str): Question version number (e.g., "1.0")
333
+ Increment when RNG logic changes to ensure reproducibility
334
+
335
+ ContentAST Usage Examples:
336
+ def get_body(self):
337
+ body = ContentAST.Section()
338
+ body.add_element(ContentAST.Paragraph(["Calculate the matrix:"]))
339
+
340
+ # Use ContentAST.Matrix for math, NOT manual LaTeX
341
+ matrix_data = [[1, 2], [3, 4]]
342
+ body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="b"))
343
+
344
+ # Use ContentAST.Answer for input fields
345
+ body.add_element(ContentAST.Answer(answer=self.answers["result"]))
346
+ return body
347
+
348
+ Common ContentAST Elements:
349
+ - ContentAST.Paragraph: Text blocks
350
+ - ContentAST.Equation: Mathematical expressions
351
+ - ContentAST.Matrix: Matrices and vectors (use instead of manual LaTeX!)
352
+ - ContentAST.Table: Data tables
353
+ - ContentAST.OnlyHtml/OnlyLatex: Platform-specific content
354
+
355
+ Versioning Guidelines:
356
+ - Increment VERSION when changing:
357
+ * Order of random number generation calls
358
+ * Question generation logic
359
+ * Answer calculation methods
360
+ - Do NOT increment for:
361
+ * Cosmetic changes (formatting, wording)
362
+ * Bug fixes that don't affect answer generation
363
+ * Changes to get_explanation() only
364
+
365
+ See existing questions in premade_questions/ for patterns and examples.
366
+ """
367
+
368
+ # Default version - subclasses should override this
369
+ VERSION = "1.0"
370
+
371
+ class Topic(enum.Enum):
372
+ # CST334 (Operating Systems) Topics
373
+ SYSTEM_MEMORY = enum.auto() # Virtual memory, paging, segmentation, caching
374
+ SYSTEM_PROCESSES = enum.auto() # Process management, scheduling
375
+ SYSTEM_CONCURRENCY = enum.auto() # Threads, synchronization, locks
376
+ SYSTEM_IO = enum.auto() # File systems, persistence, I/O operations
377
+ SYSTEM_SECURITY = enum.auto() # Access control, protection mechanisms
378
+
379
+ # CST463 (Machine Learning/Data Science) Topics
380
+ ML_OPTIMIZATION = enum.auto() # Gradient descent, optimization algorithms
381
+ ML_LINEAR_ALGEBRA = enum.auto() # Matrix operations, vector mathematics
382
+ ML_STATISTICS = enum.auto() # Probability, distributions, statistical inference
383
+ ML_ALGORITHMS = enum.auto() # Classification, regression, clustering
384
+ DATA_PREPROCESSING = enum.auto() # Data cleaning, transformation, feature engineering
385
+
386
+ # General/Shared Topics
387
+ MATH_GENERAL = enum.auto() # Basic mathematics, calculus, algebra
388
+ PROGRAMMING = enum.auto() # General programming concepts
389
+ LANGUAGES = enum.auto() # Programming languages specifics
390
+ MISC = enum.auto() # Uncategorized questions
391
+
392
+ # Legacy aliases for backward compatibility
393
+ PROCESS = SYSTEM_PROCESSES
394
+ MEMORY = SYSTEM_MEMORY
395
+ CONCURRENCY = SYSTEM_CONCURRENCY
396
+ IO = SYSTEM_IO
397
+ SECURITY = SYSTEM_SECURITY
398
+ MATH = MATH_GENERAL
399
+
400
+ @classmethod
401
+ def from_string(cls, string) -> Question.Topic:
402
+ mappings = {
403
+ member.name.lower() : member for member in cls
404
+ }
405
+ mappings.update({
406
+ # Legacy mappings
407
+ "processes": cls.SYSTEM_PROCESSES,
408
+ "process": cls.SYSTEM_PROCESSES,
409
+ "threads": cls.SYSTEM_CONCURRENCY,
410
+ "concurrency": cls.SYSTEM_CONCURRENCY,
411
+ "persistance": cls.SYSTEM_IO,
412
+ "persistence": cls.SYSTEM_IO,
413
+ "io": cls.SYSTEM_IO,
414
+ "memory": cls.SYSTEM_MEMORY,
415
+ "security": cls.SYSTEM_SECURITY,
416
+ "math": cls.MATH_GENERAL,
417
+ "mathematics": cls.MATH_GENERAL,
418
+
419
+ # New mappings
420
+ "optimization": cls.ML_OPTIMIZATION,
421
+ "gradient_descent": cls.ML_OPTIMIZATION,
422
+ "machine_learning": cls.ML_ALGORITHMS,
423
+ "ml": cls.ML_ALGORITHMS,
424
+ "linear_algebra": cls.ML_LINEAR_ALGEBRA,
425
+ "matrix": cls.ML_LINEAR_ALGEBRA,
426
+ "statistics": cls.ML_STATISTICS,
427
+ "stats": cls.ML_STATISTICS,
428
+ "data": cls.DATA_PREPROCESSING,
429
+ "programming" : cls.PROGRAMMING,
430
+ "misc": cls.MISC,
431
+ })
432
+
433
+ if string.lower() in mappings:
434
+ return mappings.get(string.lower())
435
+ return cls.MISC
436
+
437
+ def __init__(self, name: str, points_value: float, topic: Question.Topic = Topic.MISC, *args, **kwargs):
438
+ if name is None:
439
+ name = self.__class__.__name__
440
+ self.name = name
441
+ self.points_value = points_value
442
+ self.topic = topic
443
+ self.spacing = parse_spacing(kwargs.get("spacing", 0))
444
+ self.answer_kind = Answer.AnswerKind.BLANK
445
+
446
+ # Support for multi-part questions (defaults to 1 for normal questions)
447
+ self.num_subquestions = kwargs.get("num_subquestions", 1)
448
+
449
+ self.extra_attrs = kwargs # clear page, etc.
450
+
451
+ self.answers = {}
452
+ self.possible_variations = float('inf')
453
+
454
+ self.rng_seed_offset = kwargs.get("rng_seed_offset", 0)
455
+
456
+ # To be used throughout when generating random things
457
+ self.rng = random.Random()
458
+
459
+ # Track question-specific configuration parameters (excluding framework parameters)
460
+ # These will be included in QR codes for exam regeneration
461
+ framework_params = {
462
+ 'name', 'points_value', 'topic', 'spacing', 'num_subquestions',
463
+ 'rng_seed_offset', 'rng_seed', 'class', 'kwargs', 'kind'
464
+ }
465
+ self.config_params = {k: v for k, v in kwargs.items() if k not in framework_params}
466
+
467
+ @classmethod
468
+ def from_yaml(cls, path_to_yaml):
469
+ with open(path_to_yaml) as fid:
470
+ question_dicts = yaml.safe_load_all(fid)
471
+
472
+ def get_question(self, **kwargs) -> ContentAST.Question:
473
+ """
474
+ Gets the question in AST format
475
+ :param kwargs:
476
+ :return: (ContentAST.Question) Containing question.
477
+ """
478
+ # Generate the question, retrying with incremented seeds until we get an interesting one
479
+ with timer("question_refresh", question_name=self.name, question_type=self.__class__.__name__):
480
+ base_seed = kwargs.get("rng_seed", None)
481
+
482
+ # Pre-select any regenerable choices using the base seed
483
+ # This ensures the policy/algorithm stays constant across backoff attempts
484
+ if hasattr(self, '_regenerable_choices') and self._regenerable_choices:
485
+ # Seed a temporary RNG with the base seed to make the choices
486
+ choice_rng = random.Random(base_seed)
487
+ for param_name, choice_info in self._regenerable_choices.items():
488
+ if choice_info['fixed_value'] is None:
489
+ # No fixed value - pick randomly and store it as fixed for this get_question() call
490
+ enum_class = choice_info['enum_class']
491
+ random_choice = choice_rng.choice(list(enum_class))
492
+ # Temporarily set this as the fixed value so all refresh() calls use it
493
+ choice_info['_temp_fixed_value'] = random_choice.name
494
+ # Store in config_params
495
+ self.config_params[param_name] = random_choice.name
496
+
497
+ backoff_counter = 0
498
+ is_interesting = False
499
+ while not is_interesting:
500
+ # Increment seed for each backoff attempt to maintain deterministic behavior
501
+ current_seed = None if base_seed is None else base_seed + backoff_counter
502
+ self.refresh(rng_seed=current_seed, hard_refresh=(backoff_counter > 0))
503
+ is_interesting = self.is_interesting()
504
+ backoff_counter += 1
505
+
506
+ # Clear temporary fixed values
507
+ if hasattr(self, '_regenerable_choices') and self._regenerable_choices:
508
+ for param_name, choice_info in self._regenerable_choices.items():
509
+ if '_temp_fixed_value' in choice_info:
510
+ del choice_info['_temp_fixed_value']
511
+
512
+ with timer("question_body", question_name=self.name, question_type=self.__class__.__name__):
513
+ body = self.get_body()
514
+
515
+ with timer("question_explanation", question_name=self.name, question_type=self.__class__.__name__):
516
+ explanation = self.get_explanation()
517
+
518
+ # Store the actual seed used and question metadata for QR code generation
519
+ actual_seed = None if base_seed is None else base_seed + backoff_counter - 1
520
+ question_ast = ContentAST.Question(
521
+ body=body,
522
+ explanation=explanation,
523
+ value=self.points_value,
524
+ spacing=self.spacing,
525
+ topic=self.topic
526
+ )
527
+
528
+ # Attach regeneration metadata to the question AST
529
+ question_ast.question_class_name = self.__class__.__name__
530
+ question_ast.generation_seed = actual_seed
531
+ question_ast.question_version = self.VERSION
532
+ # Make a copy of config_params so each question AST has its own
533
+ # (important when the same Question instance is reused for multiple PDFs)
534
+ question_ast.config_params = dict(self.config_params)
535
+
536
+ return question_ast
537
+
538
+ @abc.abstractmethod
539
+ def get_body(self, **kwargs) -> ContentAST.Section:
540
+ """
541
+ Gets the body of the question during generation
542
+ :param kwargs:
543
+ :return: (ContentAST.Section) Containing question body
544
+ """
545
+ pass
546
+
547
+ def get_explanation(self, **kwargs) -> ContentAST.Section:
548
+ """
549
+ Gets the body of the question during generation
550
+ :param kwargs:
551
+ :return: (ContentAST.Section) Containing question explanation or None
552
+ """
553
+ return ContentAST.Section(
554
+ [ContentAST.Text("[Please reach out to your professor for clarification]")]
555
+ )
556
+
557
+ def get_answers(self, *args, **kwargs) -> Tuple[Answer.AnswerKind, List[Dict[str,Any]]]:
558
+ return (
559
+ self.answer_kind,
560
+ list(itertools.chain(*[a.get_for_canvas() for a in self.answers.values()]))
561
+ )
562
+
563
+ def refresh(self, rng_seed=None, *args, **kwargs):
564
+ """If it is necessary to regenerate aspects between usages, this is the time to do it.
565
+ This base implementation simply resets everything.
566
+ :param rng_seed: random number generator seed to use when regenerating question
567
+ :param *args:
568
+ :param **kwargs:
569
+ :return: bool - True if the generated question is interesting, False otherwise
570
+ """
571
+ self.answers = {}
572
+ # Seed the RNG directly with the provided seed (no offset)
573
+ self.rng.seed(rng_seed)
574
+ # Note: We don't call is_interesting() here because child classes need to
575
+ # generate their workloads first. Child classes should call it at the end
576
+ # of their refresh() and return the result.
577
+ return self.is_interesting() # Default: assume interesting if no override
578
+
579
+ def is_interesting(self) -> bool:
580
+ return True
581
+
582
+ def get__canvas(self, course: canvasapi.course.Course, quiz : canvasapi.quiz.Quiz, interest_threshold=1.0, *args, **kwargs):
583
+ # Get the AST for the question
584
+ with timer("question_get_ast", question_name=self.name, question_type=self.__class__.__name__):
585
+ questionAST = self.get_question(**kwargs)
586
+ log.debug("got question ast")
587
+ # Get the answers and type of question
588
+ question_type, answers = self.get_answers(*args, **kwargs)
589
+
590
+ # Define a helper function for uploading images to canvas
591
+ def image_upload(img_data) -> str:
592
+
593
+ course.create_folder(f"{quiz.id}", parent_folder_path="Quiz Files")
594
+ file_name = f"{uuid.uuid4()}.png"
595
+
596
+ with io.FileIO(file_name, 'w+') as ffid:
597
+ ffid.write(img_data.getbuffer())
598
+ ffid.flush()
599
+ ffid.seek(0)
600
+ upload_success, f = course.upload(ffid, parent_folder_path=f"Quiz Files/{quiz.id}")
601
+ os.remove(file_name)
602
+
603
+ img_data.name = "img.png"
604
+ # upload_success, f = course.upload(img_data, parent_folder_path=f"Quiz Files/{quiz.id}")
605
+ log.debug("path: " + f"/courses/{course.id}/files/{f['id']}/preview")
606
+ return f"/courses/{course.id}/files/{f['id']}/preview"
607
+
608
+ # Render AST to HTML for Canvas
609
+ with timer("ast_render_body", question_name=self.name, question_type=self.__class__.__name__):
610
+ question_html = questionAST.render("html", upload_func=image_upload)
611
+
612
+ with timer("ast_render_explanation", question_name=self.name, question_type=self.__class__.__name__):
613
+ explanation_html = questionAST.explanation.render("html", upload_func=image_upload)
614
+
615
+ # Build appropriate dictionary to send to canvas
616
+ return {
617
+ "question_name": f"{self.name} ({datetime.datetime.now().strftime('%m/%d/%y %H:%M:%S.%f')})",
618
+ "question_text": question_html,
619
+ "question_type": question_type.value,
620
+ "points_possible": self.points_value,
621
+ "answers": answers,
622
+ "neutral_comments_html": explanation_html
623
+ }
624
+
625
+
626
+ class QuestionGroup():
627
+
628
+ def __init__(self, questions_in_group: List[Question], pick_once : bool):
629
+ self.questions = questions_in_group
630
+ self.pick_once = pick_once
631
+
632
+ self._current_question : Optional[Question] = None
633
+
634
+ def instantiate(self, *args, **kwargs):
635
+
636
+ # todo: Make work with rng_seed (or at least verify)
637
+ random.seed(kwargs.get("rng_seed", None))
638
+
639
+ if not self.pick_once or self._current_question is None:
640
+ self._current_question = random.choice(self.questions)
641
+
642
+ def __getattr__(self, name):
643
+ if self._current_question is None or name == "generate":
644
+ self.instantiate()
645
+ try:
646
+ attr = getattr(self._current_question, name)
647
+ except AttributeError:
648
+ raise AttributeError(
649
+ f"Neither QuestionGroup nor Question has attribute '{name}'"
650
+ )
651
+
652
+ if callable(attr):
653
+ def wrapped_method(*args, **kwargs):
654
+ return attr(*args, **kwargs)
655
+ return wrapped_method
656
+
657
+ return attr