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.
- QuizGenerator/README.md +5 -0
- QuizGenerator/__init__.py +27 -0
- QuizGenerator/__main__.py +7 -0
- QuizGenerator/canvas/__init__.py +13 -0
- QuizGenerator/canvas/canvas_interface.py +627 -0
- QuizGenerator/canvas/classes.py +235 -0
- QuizGenerator/constants.py +149 -0
- QuizGenerator/contentast.py +1955 -0
- QuizGenerator/generate.py +253 -0
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/misc.py +579 -0
- QuizGenerator/mixins.py +548 -0
- QuizGenerator/performance.py +202 -0
- QuizGenerator/premade_questions/__init__.py +0 -0
- QuizGenerator/premade_questions/basic.py +103 -0
- QuizGenerator/premade_questions/cst334/__init__.py +1 -0
- QuizGenerator/premade_questions/cst334/languages.py +391 -0
- QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
- QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
- QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
- QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
- QuizGenerator/premade_questions/cst334/process.py +648 -0
- QuizGenerator/premade_questions/cst463/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
- QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
- QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
- QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
- QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
- QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
- QuizGenerator/premade_questions/cst463/models/text.py +203 -0
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
- QuizGenerator/qrcode_generator.py +293 -0
- QuizGenerator/question.py +715 -0
- QuizGenerator/quiz.py +467 -0
- QuizGenerator/regenerate.py +472 -0
- QuizGenerator/typst_utils.py +113 -0
- quizgenerator-0.4.2.dist-info/METADATA +265 -0
- quizgenerator-0.4.2.dist-info/RECORD +52 -0
- quizgenerator-0.4.2.dist-info/WHEEL +4 -0
- quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
- 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
|