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