QuizGenerator 0.8.1__py3-none-any.whl → 0.10.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.
- QuizGenerator/README.md +5 -0
- QuizGenerator/canvas/canvas_interface.py +6 -2
- QuizGenerator/contentast.py +33 -11
- QuizGenerator/generate.py +51 -10
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/mixins.py +6 -2
- QuizGenerator/premade_questions/basic.py +49 -7
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +92 -82
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +68 -45
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +238 -162
- QuizGenerator/premade_questions/cst463/models/attention.py +0 -1
- QuizGenerator/premade_questions/cst463/models/cnns.py +0 -1
- QuizGenerator/premade_questions/cst463/models/rnns.py +0 -1
- QuizGenerator/premade_questions/cst463/models/text.py +0 -1
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +20 -1
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +51 -45
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +212 -215
- QuizGenerator/qrcode_generator.py +116 -54
- QuizGenerator/question.py +168 -23
- QuizGenerator/regenerate.py +23 -9
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/METADATA +34 -22
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/RECORD +25 -23
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/WHEEL +0 -0
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,17 +9,21 @@ The QR codes include encrypted data that allows regenerating question answers
|
|
|
9
9
|
without storing separate files, enabling efficient grading of randomized exams.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
12
14
|
import json
|
|
13
|
-
import tempfile
|
|
14
15
|
import logging
|
|
15
16
|
import os
|
|
16
|
-
import
|
|
17
|
+
import tempfile
|
|
18
|
+
import zlib
|
|
19
|
+
from datetime import datetime
|
|
17
20
|
from io import BytesIO
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
from typing import Optional, Dict, Any
|
|
20
23
|
|
|
21
24
|
import segno
|
|
22
25
|
from cryptography.fernet import Fernet
|
|
26
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
27
|
|
|
24
28
|
log = logging.getLogger(__name__)
|
|
25
29
|
|
|
@@ -38,6 +42,22 @@ class QuestionQRCode:
|
|
|
38
42
|
|
|
39
43
|
# Error correction level: M = 15% recovery (balanced for compact encoded data)
|
|
40
44
|
ERROR_CORRECTION = 'M'
|
|
45
|
+
_generated_key: Optional[bytes] = None
|
|
46
|
+
V2_PREFIX = "v2."
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _persist_generated_key(cls, key: bytes) -> None:
|
|
50
|
+
try:
|
|
51
|
+
os.makedirs("out/keys", exist_ok=True)
|
|
52
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
53
|
+
path = os.path.join("out", "keys", f"quiz_encryption_key-{timestamp}.log")
|
|
54
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
55
|
+
handle.write("QUIZ_ENCRYPTION_KEY=")
|
|
56
|
+
handle.write(key.decode("ascii"))
|
|
57
|
+
handle.write("\n")
|
|
58
|
+
log.warning(f"Wrote generated QUIZ_ENCRYPTION_KEY to {path}")
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
log.warning(f"Failed to persist generated QUIZ_ENCRYPTION_KEY: {exc}")
|
|
41
61
|
|
|
42
62
|
@classmethod
|
|
43
63
|
def get_encryption_key(cls) -> bytes:
|
|
@@ -61,67 +81,95 @@ class QuestionQRCode:
|
|
|
61
81
|
"QUIZ_ENCRYPTION_KEY not set! Generating temporary key. "
|
|
62
82
|
"Set this environment variable for production use!"
|
|
63
83
|
)
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
if cls._generated_key is None:
|
|
85
|
+
cls._generated_key = Fernet.generate_key()
|
|
86
|
+
os.environ["QUIZ_ENCRYPTION_KEY"] = cls._generated_key.decode("ascii")
|
|
87
|
+
cls._persist_generated_key(cls._generated_key)
|
|
88
|
+
return cls._generated_key
|
|
66
89
|
|
|
67
90
|
# Key should be stored as base64 string in env
|
|
68
91
|
return key_str.encode()
|
|
69
92
|
|
|
70
93
|
@classmethod
|
|
71
|
-
def
|
|
94
|
+
def _derive_aead_key(cls, key: bytes) -> bytes:
|
|
95
|
+
return hashlib.sha256(key).digest()
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def _encrypt_v2(cls, payload: Dict[str, Any], *, key: Optional[bytes] = None) -> str:
|
|
99
|
+
if key is None:
|
|
100
|
+
key = cls.get_encryption_key()
|
|
101
|
+
aead_key = cls._derive_aead_key(key)
|
|
102
|
+
aesgcm = AESGCM(aead_key)
|
|
103
|
+
nonce = os.urandom(12)
|
|
104
|
+
json_bytes = json.dumps(payload, separators=(',', ':'), ensure_ascii=False).encode("utf-8")
|
|
105
|
+
compressed = zlib.compress(json_bytes)
|
|
106
|
+
ciphertext = aesgcm.encrypt(nonce, compressed, None)
|
|
107
|
+
token = base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
|
|
108
|
+
return f"{cls.V2_PREFIX}{token}"
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def _decrypt_v2(cls, encrypted_data: str, *, key: Optional[bytes] = None) -> Dict[str, Any]:
|
|
112
|
+
if not encrypted_data.startswith(cls.V2_PREFIX):
|
|
113
|
+
raise ValueError("Not a v2 payload")
|
|
114
|
+
if key is None:
|
|
115
|
+
key = cls.get_encryption_key()
|
|
116
|
+
aead_key = cls._derive_aead_key(key)
|
|
117
|
+
token = encrypted_data[len(cls.V2_PREFIX):]
|
|
118
|
+
raw = base64.urlsafe_b64decode(token.encode("ascii"))
|
|
119
|
+
nonce, ciphertext = raw[:12], raw[12:]
|
|
120
|
+
aesgcm = AESGCM(aead_key)
|
|
121
|
+
compressed = aesgcm.decrypt(nonce, ciphertext, None)
|
|
122
|
+
json_bytes = zlib.decompress(compressed)
|
|
123
|
+
return json.loads(json_bytes.decode("utf-8"))
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def encrypt_question_data(cls, question_type: str, seed: int, version: Optional[str] = None,
|
|
72
127
|
config: Optional[Dict[str, Any]] = None,
|
|
128
|
+
context: Optional[Dict[str, Any]] = None,
|
|
129
|
+
points_value: Optional[float] = None,
|
|
73
130
|
key: Optional[bytes] = None) -> str:
|
|
74
131
|
"""
|
|
75
|
-
Encode question regeneration data
|
|
132
|
+
Encode question regeneration data for QR embedding.
|
|
76
133
|
|
|
77
134
|
Args:
|
|
78
135
|
question_type: Class name of the question (e.g., "VectorDotProduct")
|
|
79
136
|
seed: Random seed used to generate this specific question
|
|
80
|
-
version:
|
|
137
|
+
version: Optional question version string
|
|
81
138
|
config: Optional dictionary of configuration parameters
|
|
139
|
+
context: Optional dictionary of context extras
|
|
140
|
+
points_value: Optional points value (for redundancy)
|
|
82
141
|
key: Encryption key (uses environment key if None)
|
|
83
142
|
|
|
84
143
|
Returns:
|
|
85
|
-
str: Base64-encoded
|
|
144
|
+
str: Base64-encoded encrypted payload with v2 prefix
|
|
86
145
|
|
|
87
146
|
Example:
|
|
88
147
|
>>> encrypted = QuestionQRCode.encrypt_question_data("VectorDot", 12345, "1.0")
|
|
89
148
|
>>> print(encrypted)
|
|
90
149
|
'VmVjdG9yRG90OjEyMzQ1OjEuMA=='
|
|
91
150
|
"""
|
|
92
|
-
|
|
151
|
+
payload: Dict[str, Any] = {
|
|
152
|
+
"t": question_type,
|
|
153
|
+
"s": seed,
|
|
154
|
+
}
|
|
155
|
+
if points_value is not None:
|
|
156
|
+
payload["p"] = points_value
|
|
93
157
|
if config:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if key is None:
|
|
103
|
-
key = cls.get_encryption_key()
|
|
104
|
-
|
|
105
|
-
if key:
|
|
106
|
-
# Use first 16 bytes of key for simple XOR obfuscation
|
|
107
|
-
key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
|
|
108
|
-
# XOR each byte with repeating key pattern
|
|
109
|
-
obfuscated = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(data_bytes))
|
|
110
|
-
else:
|
|
111
|
-
obfuscated = data_bytes
|
|
112
|
-
|
|
113
|
-
# Base64 encode for compact representation
|
|
114
|
-
encoded = base64.urlsafe_b64encode(obfuscated).decode('utf-8')
|
|
115
|
-
|
|
116
|
-
log.debug(f"Encoded question data: {question_type} seed={seed} version={version} ({len(encoded)} chars)")
|
|
117
|
-
|
|
158
|
+
payload["c"] = config
|
|
159
|
+
if context:
|
|
160
|
+
payload["x"] = context
|
|
161
|
+
if version:
|
|
162
|
+
payload["v"] = version
|
|
163
|
+
|
|
164
|
+
encoded = cls._encrypt_v2(payload, key=key)
|
|
165
|
+
log.debug(f"Encoded question data v2: {question_type} seed={seed} ({len(encoded)} chars)")
|
|
118
166
|
return encoded
|
|
119
167
|
|
|
120
168
|
@classmethod
|
|
121
169
|
def decrypt_question_data(cls, encrypted_data: str,
|
|
122
170
|
key: Optional[bytes] = None) -> Dict[str, Any]:
|
|
123
171
|
"""
|
|
124
|
-
Decode question regeneration data from QR code.
|
|
172
|
+
Decode question regeneration data from QR code (v2 preferred, v1 fallback).
|
|
125
173
|
|
|
126
174
|
Args:
|
|
127
175
|
encrypted_data: Base64-encoded (optionally XOR-obfuscated) string from QR code
|
|
@@ -142,10 +190,24 @@ class QuestionQRCode:
|
|
|
142
190
|
key = cls.get_encryption_key()
|
|
143
191
|
|
|
144
192
|
try:
|
|
145
|
-
|
|
193
|
+
if encrypted_data.startswith(cls.V2_PREFIX):
|
|
194
|
+
payload = cls._decrypt_v2(encrypted_data, key=key)
|
|
195
|
+
result = {
|
|
196
|
+
"question_type": payload.get("t"),
|
|
197
|
+
"seed": int(payload.get("s")),
|
|
198
|
+
}
|
|
199
|
+
if "v" in payload:
|
|
200
|
+
result["version"] = payload.get("v")
|
|
201
|
+
if "c" in payload:
|
|
202
|
+
result["config"] = payload.get("c")
|
|
203
|
+
if "x" in payload:
|
|
204
|
+
result["context"] = payload.get("x")
|
|
205
|
+
if "p" in payload:
|
|
206
|
+
result["points"] = payload.get("p")
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
# V1 fallback (XOR obfuscation)
|
|
146
210
|
obfuscated = base64.urlsafe_b64decode(encrypted_data.encode())
|
|
147
|
-
|
|
148
|
-
# Reverse XOR obfuscation if key is provided
|
|
149
211
|
if key:
|
|
150
212
|
key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
|
|
151
213
|
data_bytes = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(obfuscated))
|
|
@@ -153,29 +215,26 @@ class QuestionQRCode:
|
|
|
153
215
|
data_bytes = obfuscated
|
|
154
216
|
|
|
155
217
|
data_str = data_bytes.decode('utf-8')
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if len(parts) < 3:
|
|
160
|
-
raise ValueError(f"Invalid encoded data format: expected at least 3 parts, got {len(parts)}")
|
|
218
|
+
parts = data_str.split(':', 3)
|
|
219
|
+
if len(parts) < 2:
|
|
220
|
+
raise ValueError(f"Invalid encoded data format: expected at least 2 parts, got {len(parts)}")
|
|
161
221
|
|
|
162
222
|
question_type = parts[0]
|
|
163
223
|
seed_str = parts[1]
|
|
164
|
-
version = parts[2]
|
|
224
|
+
version = parts[2] if len(parts) >= 3 else None
|
|
165
225
|
|
|
166
226
|
result = {
|
|
167
227
|
"question_type": question_type,
|
|
168
228
|
"seed": int(seed_str),
|
|
169
|
-
"version": version
|
|
170
229
|
}
|
|
230
|
+
if version:
|
|
231
|
+
result["version"] = version
|
|
171
232
|
|
|
172
|
-
# Parse config JSON if present
|
|
173
233
|
if len(parts) == 4:
|
|
174
234
|
try:
|
|
175
235
|
result["config"] = json.loads(parts[3])
|
|
176
236
|
except json.JSONDecodeError as e:
|
|
177
237
|
log.warning(f"Failed to parse config JSON: {e}")
|
|
178
|
-
# Continue without config rather than failing
|
|
179
238
|
|
|
180
239
|
return result
|
|
181
240
|
|
|
@@ -215,24 +274,27 @@ class QuestionQRCode:
|
|
|
215
274
|
"""
|
|
216
275
|
data = {
|
|
217
276
|
"q": question_number,
|
|
218
|
-
"
|
|
277
|
+
"p": points_value
|
|
219
278
|
}
|
|
220
279
|
|
|
221
280
|
# If question regeneration data provided, encrypt it
|
|
222
|
-
if all(k in extra_data for k in ['question_type', 'seed'
|
|
223
|
-
# Include config in encrypted data if present
|
|
281
|
+
if all(k in extra_data for k in ['question_type', 'seed']):
|
|
224
282
|
config = extra_data.get('config', {})
|
|
283
|
+
context = extra_data.get('context', {})
|
|
225
284
|
encrypted = cls.encrypt_question_data(
|
|
226
285
|
extra_data['question_type'],
|
|
227
286
|
extra_data['seed'],
|
|
228
|
-
extra_data
|
|
229
|
-
config=config
|
|
287
|
+
extra_data.get('version'),
|
|
288
|
+
config=config,
|
|
289
|
+
context=context,
|
|
290
|
+
points_value=points_value
|
|
230
291
|
)
|
|
231
292
|
data['s'] = encrypted
|
|
232
293
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
294
|
+
extra_data = {
|
|
295
|
+
k: v for k, v in extra_data.items()
|
|
296
|
+
if k not in ['question_type', 'seed', 'version', 'config', 'context']
|
|
297
|
+
}
|
|
236
298
|
|
|
237
299
|
# Add any remaining extra metadata
|
|
238
300
|
data.update(extra_data)
|
QuizGenerator/question.py
CHANGED
|
@@ -15,10 +15,13 @@ import pprint
|
|
|
15
15
|
import random
|
|
16
16
|
import re
|
|
17
17
|
import uuid
|
|
18
|
+
import types
|
|
19
|
+
import inspect
|
|
20
|
+
from types import MappingProxyType
|
|
18
21
|
|
|
19
22
|
import pypandoc
|
|
20
23
|
import yaml
|
|
21
|
-
from typing import List, Dict, Any, Tuple, Optional
|
|
24
|
+
from typing import List, Dict, Any, Tuple, Optional, Mapping, MutableMapping
|
|
22
25
|
import canvasapi.course, canvasapi.quiz
|
|
23
26
|
|
|
24
27
|
import QuizGenerator.contentast as ca
|
|
@@ -42,6 +45,7 @@ class RegenerationFlags:
|
|
|
42
45
|
generation_seed: Optional[int]
|
|
43
46
|
question_version: str
|
|
44
47
|
config_params: Dict[str, Any]
|
|
48
|
+
context_extras: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -58,6 +62,83 @@ class QuestionInstance:
|
|
|
58
62
|
flags: RegenerationFlags
|
|
59
63
|
|
|
60
64
|
|
|
65
|
+
@dataclasses.dataclass
|
|
66
|
+
class QuestionContext:
|
|
67
|
+
rng_seed: Optional[int]
|
|
68
|
+
rng: random.Random
|
|
69
|
+
data: MutableMapping[str, Any] | Mapping[str, Any] = dataclasses.field(default_factory=dict)
|
|
70
|
+
frozen: bool = False
|
|
71
|
+
question_cls: type | None = None
|
|
72
|
+
|
|
73
|
+
def __getitem__(self, key: str) -> Any:
|
|
74
|
+
if key == "rng_seed":
|
|
75
|
+
return self.rng_seed
|
|
76
|
+
if key == "rng":
|
|
77
|
+
return self.rng
|
|
78
|
+
return self.data[key]
|
|
79
|
+
|
|
80
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
81
|
+
if self.frozen:
|
|
82
|
+
raise TypeError("QuestionContext is frozen.")
|
|
83
|
+
if key == "rng_seed":
|
|
84
|
+
self.rng_seed = value
|
|
85
|
+
return
|
|
86
|
+
if key == "rng":
|
|
87
|
+
self.rng = value
|
|
88
|
+
return
|
|
89
|
+
if isinstance(self.data, MappingProxyType):
|
|
90
|
+
raise TypeError("QuestionContext is frozen.")
|
|
91
|
+
self.data[key] = value
|
|
92
|
+
|
|
93
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
94
|
+
if key == "rng_seed":
|
|
95
|
+
return self.rng_seed
|
|
96
|
+
if key == "rng":
|
|
97
|
+
return self.rng
|
|
98
|
+
if hasattr(self.data, "get"):
|
|
99
|
+
return self.data.get(key, default)
|
|
100
|
+
return default
|
|
101
|
+
|
|
102
|
+
def __contains__(self, key: object) -> bool:
|
|
103
|
+
if key in ("rng_seed", "rng"):
|
|
104
|
+
return True
|
|
105
|
+
return key in self.data
|
|
106
|
+
|
|
107
|
+
def __getattr__(self, name: str) -> Any:
|
|
108
|
+
if name in self.data:
|
|
109
|
+
return self.data[name]
|
|
110
|
+
if self.question_cls is not None and hasattr(self.question_cls, name):
|
|
111
|
+
raw_attr = inspect.getattr_static(self.question_cls, name)
|
|
112
|
+
if isinstance(raw_attr, staticmethod):
|
|
113
|
+
return raw_attr.__func__
|
|
114
|
+
if isinstance(raw_attr, classmethod):
|
|
115
|
+
return raw_attr.__func__.__get__(self.question_cls, self.question_cls)
|
|
116
|
+
attr = getattr(self.question_cls, name)
|
|
117
|
+
if callable(attr):
|
|
118
|
+
return types.MethodType(attr, self)
|
|
119
|
+
return attr
|
|
120
|
+
raise AttributeError(f"{type(self).__name__} has no attribute {name!r}")
|
|
121
|
+
|
|
122
|
+
def keys(self):
|
|
123
|
+
return self.data.keys()
|
|
124
|
+
|
|
125
|
+
def items(self):
|
|
126
|
+
return self.data.items()
|
|
127
|
+
|
|
128
|
+
def values(self):
|
|
129
|
+
return self.data.values()
|
|
130
|
+
|
|
131
|
+
def freeze(self) -> "QuestionContext":
|
|
132
|
+
frozen_data = MappingProxyType(dict(self.data))
|
|
133
|
+
return QuestionContext(
|
|
134
|
+
rng_seed=self.rng_seed,
|
|
135
|
+
rng=self.rng,
|
|
136
|
+
data=frozen_data,
|
|
137
|
+
frozen=True,
|
|
138
|
+
question_cls=self.question_cls,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
61
142
|
# Spacing presets for questions
|
|
62
143
|
SPACING_PRESETS = {
|
|
63
144
|
"NONE": 0,
|
|
@@ -183,7 +264,12 @@ class QuestionRegistry:
|
|
|
183
264
|
# Load modules from the current directory
|
|
184
265
|
for _, module_name, _ in pkgutil.iter_modules([str(path)]):
|
|
185
266
|
# Import the module
|
|
186
|
-
|
|
267
|
+
try:
|
|
268
|
+
importlib.import_module(f"{package_prefix}.{module_name}")
|
|
269
|
+
except ImportError as e:
|
|
270
|
+
log.warning(
|
|
271
|
+
f"Skipping module '{package_prefix}.{module_name}' due to import error: {e}"
|
|
272
|
+
)
|
|
187
273
|
|
|
188
274
|
# Recursively load modules from subdirectories
|
|
189
275
|
for subdir in path.iterdir():
|
|
@@ -262,7 +348,7 @@ class RegenerableChoiceMixin:
|
|
|
262
348
|
self.register_choice('scheduler_kind', self.Kind, scheduler_kind, kwargs)
|
|
263
349
|
super().__init__(**kwargs)
|
|
264
350
|
|
|
265
|
-
def _build_context(
|
|
351
|
+
def _build_context(cls, rng_seed=None, **kwargs):
|
|
266
352
|
self.rng.seed(rng_seed)
|
|
267
353
|
# Get the choice (randomly selected or from config_params)
|
|
268
354
|
self.scheduler_algorithm = self.get_choice('scheduler_kind', self.Kind)
|
|
@@ -439,6 +525,7 @@ class Question(abc.ABC):
|
|
|
439
525
|
|
|
440
526
|
# Default version - subclasses should override this
|
|
441
527
|
VERSION = "1.0"
|
|
528
|
+
FREEZE_CONTEXT = False
|
|
442
529
|
|
|
443
530
|
class Topic(enum.Enum):
|
|
444
531
|
# CST334 (Operating Systems) Topics
|
|
@@ -547,8 +634,10 @@ class Question(abc.ABC):
|
|
|
547
634
|
"""
|
|
548
635
|
# Generate the question, retrying with incremented seeds until we get an interesting one
|
|
549
636
|
base_seed = kwargs.get("rng_seed", None)
|
|
637
|
+
max_backoff_attempts = kwargs.get("max_backoff_attempts", None)
|
|
550
638
|
build_kwargs = dict(kwargs)
|
|
551
639
|
build_kwargs.pop("rng_seed", None)
|
|
640
|
+
build_kwargs.pop("max_backoff_attempts", None)
|
|
552
641
|
# Include config params so build() implementations can access YAML-provided settings.
|
|
553
642
|
build_kwargs = {**self.config_params, **build_kwargs}
|
|
554
643
|
|
|
@@ -562,6 +651,10 @@ class Question(abc.ABC):
|
|
|
562
651
|
is_interesting = False
|
|
563
652
|
ctx = None
|
|
564
653
|
while not is_interesting:
|
|
654
|
+
if max_backoff_attempts is not None and backoff_counter >= max_backoff_attempts:
|
|
655
|
+
raise RuntimeError(
|
|
656
|
+
f"Exceeded max_backoff_attempts={max_backoff_attempts} for {self.__class__.__name__}"
|
|
657
|
+
)
|
|
565
658
|
# Increment seed for each backoff attempt to maintain deterministic behavior
|
|
566
659
|
current_seed = None if base_seed is None else base_seed + backoff_counter
|
|
567
660
|
ctx = self._build_context(
|
|
@@ -574,7 +667,17 @@ class Question(abc.ABC):
|
|
|
574
667
|
# Store the actual seed used and question metadata for QR code generation
|
|
575
668
|
actual_seed = None if base_seed is None else base_seed + backoff_counter - 1
|
|
576
669
|
|
|
577
|
-
|
|
670
|
+
# Keep instance rng in sync for any legacy usage.
|
|
671
|
+
if isinstance(ctx, QuestionContext):
|
|
672
|
+
self.rng = ctx.rng
|
|
673
|
+
elif isinstance(ctx, dict) and "rng" in ctx:
|
|
674
|
+
self.rng = ctx["rng"]
|
|
675
|
+
|
|
676
|
+
components = self.__class__.build(
|
|
677
|
+
rng_seed=current_seed,
|
|
678
|
+
context=ctx,
|
|
679
|
+
**build_kwargs
|
|
680
|
+
)
|
|
578
681
|
|
|
579
682
|
# Collect answers from explicit lists and inline AST
|
|
580
683
|
inline_body_answers = self._collect_answers_from_ast(components.body)
|
|
@@ -593,6 +696,20 @@ class Question(abc.ABC):
|
|
|
593
696
|
if isinstance(ctx, dict) and ctx.get("_config_params"):
|
|
594
697
|
config_params.update(ctx.get("_config_params"))
|
|
595
698
|
|
|
699
|
+
context_extras: Dict[str, Any] = {}
|
|
700
|
+
if isinstance(ctx, QuestionContext):
|
|
701
|
+
include_list = ctx.get("qr_include_list", None)
|
|
702
|
+
if isinstance(include_list, (list, tuple)):
|
|
703
|
+
for key in include_list:
|
|
704
|
+
if key in ctx:
|
|
705
|
+
context_extras[key] = ctx[key]
|
|
706
|
+
elif isinstance(ctx, dict):
|
|
707
|
+
include_list = ctx.get("qr_include_list", None)
|
|
708
|
+
if isinstance(include_list, (list, tuple)):
|
|
709
|
+
for key in include_list:
|
|
710
|
+
if key in ctx:
|
|
711
|
+
context_extras[key] = ctx[key]
|
|
712
|
+
|
|
596
713
|
instance = QuestionInstance(
|
|
597
714
|
body=components.body,
|
|
598
715
|
explanation=components.explanation,
|
|
@@ -606,7 +723,8 @@ class Question(abc.ABC):
|
|
|
606
723
|
question_class_name=self._get_registered_name(),
|
|
607
724
|
generation_seed=actual_seed,
|
|
608
725
|
question_version=self.VERSION,
|
|
609
|
-
config_params=config_params
|
|
726
|
+
config_params=config_params,
|
|
727
|
+
context_extras=context_extras
|
|
610
728
|
)
|
|
611
729
|
)
|
|
612
730
|
return instance
|
|
@@ -619,19 +737,27 @@ class Question(abc.ABC):
|
|
|
619
737
|
def post_instantiate(self, instance, **kwargs):
|
|
620
738
|
pass
|
|
621
739
|
|
|
622
|
-
|
|
740
|
+
@classmethod
|
|
741
|
+
def build(cls, *, rng_seed=None, context=None, **kwargs) -> QuestionComponents:
|
|
623
742
|
"""
|
|
624
743
|
Build question content (body, answers, explanation) for a given seed.
|
|
625
744
|
|
|
626
745
|
This should only generate content; metadata like points/spacing belong in instantiate().
|
|
627
746
|
"""
|
|
628
|
-
cls = self.__class__
|
|
629
747
|
if context is None:
|
|
630
|
-
context =
|
|
748
|
+
context = cls._coerce_context(
|
|
749
|
+
cls._build_context(rng_seed=rng_seed, **kwargs),
|
|
750
|
+
rng_seed=rng_seed
|
|
751
|
+
)
|
|
752
|
+
else:
|
|
753
|
+
context = cls._coerce_context(context, rng_seed=rng_seed)
|
|
754
|
+
|
|
755
|
+
if cls.FREEZE_CONTEXT:
|
|
756
|
+
context = context.freeze()
|
|
631
757
|
|
|
632
758
|
# Build body + explanation. Each may return just an Element or (Element, answers).
|
|
633
|
-
body, body_answers = cls._normalize_build_output(
|
|
634
|
-
explanation, explanation_answers = cls._normalize_build_output(
|
|
759
|
+
body, body_answers = cls._normalize_build_output(cls._build_body(context))
|
|
760
|
+
explanation, explanation_answers = cls._normalize_build_output(cls._build_explanation(context))
|
|
635
761
|
|
|
636
762
|
# Collect inline answers from both body and explanation.
|
|
637
763
|
inline_body_answers = cls._collect_answers_from_ast(body)
|
|
@@ -650,30 +776,48 @@ class Question(abc.ABC):
|
|
|
650
776
|
explanation=explanation
|
|
651
777
|
)
|
|
652
778
|
|
|
653
|
-
|
|
779
|
+
@classmethod
|
|
780
|
+
def _coerce_context(cls, context, *, rng_seed=None) -> QuestionContext:
|
|
781
|
+
if isinstance(context, QuestionContext):
|
|
782
|
+
return context
|
|
783
|
+
if isinstance(context, dict):
|
|
784
|
+
ctx_seed = context.get("rng_seed", rng_seed)
|
|
785
|
+
rng = context.get("rng") or random.Random(ctx_seed)
|
|
786
|
+
ctx = QuestionContext(rng_seed=ctx_seed, rng=rng)
|
|
787
|
+
for key, value in context.items():
|
|
788
|
+
if key in ("rng_seed", "rng"):
|
|
789
|
+
continue
|
|
790
|
+
ctx.data[key] = value
|
|
791
|
+
return ctx
|
|
792
|
+
raise TypeError(f"Unsupported context type: {type(context)}")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@classmethod
|
|
796
|
+
def _build_context(cls, *, rng_seed=None, **kwargs) -> QuestionContext:
|
|
654
797
|
"""
|
|
655
798
|
Build the deterministic context for a question instance.
|
|
656
799
|
|
|
657
|
-
Override to return a
|
|
800
|
+
Override to return a QuestionContext and avoid persistent self.* state.
|
|
658
801
|
"""
|
|
659
802
|
rng = random.Random(rng_seed)
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
803
|
+
return QuestionContext(
|
|
804
|
+
rng_seed=rng_seed,
|
|
805
|
+
rng=rng,
|
|
806
|
+
question_cls=cls,
|
|
807
|
+
)
|
|
666
808
|
|
|
667
809
|
@classmethod
|
|
668
810
|
def is_interesting_ctx(cls, context) -> bool:
|
|
669
811
|
"""Context-aware hook; defaults to existing is_interesting()."""
|
|
670
812
|
return True
|
|
671
813
|
|
|
672
|
-
|
|
814
|
+
@classmethod
|
|
815
|
+
def _build_body(cls, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
|
|
673
816
|
"""Context-aware body builder."""
|
|
674
817
|
raise NotImplementedError("Questions must implement _build_body().")
|
|
675
818
|
|
|
676
|
-
|
|
819
|
+
@classmethod
|
|
820
|
+
def _build_explanation(cls, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
|
|
677
821
|
"""Context-aware explanation builder."""
|
|
678
822
|
raise NotImplementedError("Questions must implement _build_explanation().")
|
|
679
823
|
|
|
@@ -811,6 +955,7 @@ class Question(abc.ABC):
|
|
|
811
955
|
question_ast.generation_seed = instance.flags.generation_seed
|
|
812
956
|
question_ast.question_version = instance.flags.question_version
|
|
813
957
|
question_ast.config_params = dict(instance.flags.config_params)
|
|
958
|
+
question_ast.qr_context_extras = dict(instance.flags.context_extras)
|
|
814
959
|
|
|
815
960
|
return question_ast
|
|
816
961
|
|
|
@@ -893,11 +1038,11 @@ class QuestionGroup():
|
|
|
893
1038
|
|
|
894
1039
|
def instantiate(self, *args, **kwargs):
|
|
895
1040
|
|
|
896
|
-
#
|
|
897
|
-
random.
|
|
1041
|
+
# Use a local RNG to avoid global side effects.
|
|
1042
|
+
rng = random.Random(kwargs.get("rng_seed", None))
|
|
898
1043
|
|
|
899
1044
|
if not self.pick_once or self._current_question is None:
|
|
900
|
-
self._current_question =
|
|
1045
|
+
self._current_question = rng.choice(self.questions)
|
|
901
1046
|
|
|
902
1047
|
def __getattr__(self, name):
|
|
903
1048
|
if self._current_question is None or name == "generate":
|
QuizGenerator/regenerate.py
CHANGED
|
@@ -194,7 +194,9 @@ def regenerate_question_answer(
|
|
|
194
194
|
}
|
|
195
195
|
"""
|
|
196
196
|
question_num = qr_data.get('q')
|
|
197
|
-
points = qr_data.get('
|
|
197
|
+
points = qr_data.get('p')
|
|
198
|
+
if points is None:
|
|
199
|
+
points = qr_data.get('pts')
|
|
198
200
|
|
|
199
201
|
if question_num is None or points is None:
|
|
200
202
|
log.error("QR code missing required fields 'q' or 'pts'")
|
|
@@ -219,8 +221,9 @@ def regenerate_question_answer(
|
|
|
219
221
|
|
|
220
222
|
question_type = regen_data['question_type']
|
|
221
223
|
seed = regen_data['seed']
|
|
222
|
-
version = regen_data
|
|
224
|
+
version = regen_data.get('version')
|
|
223
225
|
config = regen_data.get('config', {})
|
|
226
|
+
context_extras = regen_data.get('context', {})
|
|
224
227
|
|
|
225
228
|
result['question_type'] = question_type
|
|
226
229
|
result['seed'] = seed
|
|
@@ -228,7 +231,10 @@ def regenerate_question_answer(
|
|
|
228
231
|
if config:
|
|
229
232
|
result['config'] = config
|
|
230
233
|
|
|
231
|
-
|
|
234
|
+
if version:
|
|
235
|
+
log.info(f"Question {question_num}: {question_type} (seed={seed}, version={version})")
|
|
236
|
+
else:
|
|
237
|
+
log.info(f"Question {question_num}: {question_type} (seed={seed})")
|
|
232
238
|
if config:
|
|
233
239
|
log.debug(f" Config params: {config}")
|
|
234
240
|
|
|
@@ -241,7 +247,7 @@ def regenerate_question_answer(
|
|
|
241
247
|
)
|
|
242
248
|
|
|
243
249
|
# Generate question with the specific seed
|
|
244
|
-
instance = question.instantiate(rng_seed=seed)
|
|
250
|
+
instance = question.instantiate(rng_seed=seed, **context_extras)
|
|
245
251
|
question_ast = question._build_question_ast(instance)
|
|
246
252
|
|
|
247
253
|
# Extract answers
|
|
@@ -477,14 +483,22 @@ def display_answer_summary(question_data: Dict[str, Any]) -> None:
|
|
|
477
483
|
if 'question_type' in question_data:
|
|
478
484
|
print(f"Type: {question_data['question_type']}")
|
|
479
485
|
print(f"Seed: {question_data['seed']}")
|
|
480
|
-
|
|
486
|
+
if question_data.get('version') is not None:
|
|
487
|
+
print(f"Version: {question_data['version']}")
|
|
481
488
|
|
|
482
489
|
if 'answer_objects' in question_data:
|
|
483
490
|
print("\nANSWERS:")
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
print(f"
|
|
491
|
+
answer_objects = question_data['answer_objects']
|
|
492
|
+
if isinstance(answer_objects, dict):
|
|
493
|
+
for key, answer_obj in answer_objects.items():
|
|
494
|
+
print(f" {key}: {answer_obj.value}")
|
|
495
|
+
if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
|
|
496
|
+
print(f" (tolerance: ±{answer_obj.tolerance})")
|
|
497
|
+
else:
|
|
498
|
+
for i, answer_obj in enumerate(answer_objects, start=1):
|
|
499
|
+
print(f" {i}: {answer_obj.value}")
|
|
500
|
+
if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
|
|
501
|
+
print(f" (tolerance: ±{answer_obj.tolerance})")
|
|
488
502
|
elif 'answers' in question_data:
|
|
489
503
|
print("\nANSWERS (raw Canvas format):")
|
|
490
504
|
print(f" Type: {question_data['answers']['kind']}")
|