QuizGenerator 0.9.0__py3-none-any.whl → 0.10.1__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 +2 -1
- QuizGenerator/canvas/canvas_interface.py +9 -6
- QuizGenerator/canvas/classes.py +0 -1
- QuizGenerator/contentast.py +32 -10
- QuizGenerator/generate.py +57 -11
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/misc.py +0 -8
- QuizGenerator/premade_questions/cst334/memory_questions.py +2 -3
- QuizGenerator/premade_questions/cst334/process.py +0 -1
- QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +10 -1
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +0 -1
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +2 -4
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +22 -20
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +1 -1
- QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +11 -1
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +0 -1
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +0 -1
- QuizGenerator/premade_questions/cst463/models/attention.py +1 -5
- QuizGenerator/premade_questions/cst463/models/cnns.py +1 -5
- QuizGenerator/premade_questions/cst463/models/rnns.py +1 -5
- QuizGenerator/premade_questions/cst463/models/text.py +1 -5
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +20 -3
- QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +7 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1 -9
- QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +7 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +0 -4
- QuizGenerator/qrcode_generator.py +116 -55
- QuizGenerator/question.py +30 -16
- QuizGenerator/quiz.py +1 -6
- QuizGenerator/regenerate.py +23 -9
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/METADATA +26 -17
- quizgenerator-0.10.1.dist-info/RECORD +52 -0
- quizgenerator-0.9.0.dist-info/RECORD +0 -50
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/WHEEL +0 -0
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import abc
|
|
2
1
|
import logging
|
|
3
|
-
import math
|
|
4
|
-
import keras
|
|
5
2
|
import numpy as np
|
|
6
3
|
from typing import List, Tuple
|
|
7
4
|
|
|
8
|
-
from QuizGenerator.question import
|
|
5
|
+
from QuizGenerator.question import QuestionRegistry
|
|
9
6
|
import QuizGenerator.contentast as ca
|
|
10
|
-
from QuizGenerator.constants import MathRanges
|
|
11
7
|
from QuizGenerator.mixins import TableQuestionMixin
|
|
12
8
|
|
|
13
9
|
from .matrices import MatrixQuestion
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import abc
|
|
2
1
|
import logging
|
|
3
|
-
import math
|
|
4
|
-
import keras
|
|
5
2
|
import numpy as np
|
|
6
3
|
from typing import List, Tuple
|
|
7
4
|
|
|
8
|
-
from QuizGenerator.question import
|
|
5
|
+
from QuizGenerator.question import QuestionRegistry
|
|
9
6
|
import QuizGenerator.contentast as ca
|
|
10
|
-
from QuizGenerator.constants import MathRanges
|
|
11
7
|
from .matrices import MatrixQuestion
|
|
12
8
|
|
|
13
9
|
log = logging.getLogger(__name__)
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import abc
|
|
2
1
|
import logging
|
|
3
|
-
import math
|
|
4
|
-
import keras
|
|
5
2
|
import numpy as np
|
|
6
3
|
from typing import List, Tuple
|
|
7
4
|
|
|
8
5
|
from .matrices import MatrixQuestion
|
|
9
|
-
from QuizGenerator.question import
|
|
6
|
+
from QuizGenerator.question import QuestionRegistry
|
|
10
7
|
import QuizGenerator.contentast as ca
|
|
11
|
-
from QuizGenerator.constants import MathRanges
|
|
12
8
|
from QuizGenerator.mixins import TableQuestionMixin
|
|
13
9
|
|
|
14
10
|
log = logging.getLogger(__name__)
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import abc
|
|
2
1
|
import logging
|
|
3
|
-
import math
|
|
4
|
-
import keras
|
|
5
2
|
import numpy as np
|
|
6
3
|
from typing import List, Tuple
|
|
7
4
|
|
|
8
5
|
from QuizGenerator.premade_questions.cst463.models.matrices import MatrixQuestion
|
|
9
|
-
from QuizGenerator.question import
|
|
6
|
+
from QuizGenerator.question import QuestionRegistry
|
|
10
7
|
import QuizGenerator.contentast as ca
|
|
11
|
-
from QuizGenerator.constants import MathRanges
|
|
12
8
|
from QuizGenerator.mixins import TableQuestionMixin
|
|
13
9
|
|
|
14
10
|
log = logging.getLogger(__name__)
|
|
@@ -1,19 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import abc
|
|
2
4
|
import logging
|
|
3
|
-
import math
|
|
4
5
|
import random
|
|
5
|
-
|
|
6
|
+
try:
|
|
7
|
+
import keras
|
|
8
|
+
except ImportError as exc:
|
|
9
|
+
keras = None
|
|
10
|
+
_KERAS_IMPORT_ERROR = exc
|
|
11
|
+
else:
|
|
12
|
+
_KERAS_IMPORT_ERROR = None
|
|
6
13
|
import numpy as np
|
|
7
14
|
from typing import List, Tuple
|
|
8
15
|
|
|
9
16
|
from QuizGenerator.question import Question, QuestionRegistry
|
|
10
17
|
import QuizGenerator.contentast as ca
|
|
11
|
-
from QuizGenerator.constants import MathRanges
|
|
12
18
|
|
|
13
19
|
log = logging.getLogger(__name__)
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
class WeightCounting(Question, abc.ABC):
|
|
23
|
+
@staticmethod
|
|
24
|
+
def _ensure_keras():
|
|
25
|
+
if keras is None:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"Keras is required for CST463 model questions. "
|
|
28
|
+
"Install with: pip install 'QuizGenerator[cst463]'"
|
|
29
|
+
) from _KERAS_IMPORT_ERROR
|
|
30
|
+
|
|
17
31
|
@staticmethod
|
|
18
32
|
@abc.abstractmethod
|
|
19
33
|
def get_model(rng: random.Random) -> keras.Model:
|
|
@@ -21,6 +35,7 @@ class WeightCounting(Question, abc.ABC):
|
|
|
21
35
|
|
|
22
36
|
@staticmethod
|
|
23
37
|
def model_to_python(model: keras.Model, fields=None, include_input=True):
|
|
38
|
+
WeightCounting._ensure_keras()
|
|
24
39
|
if fields is None:
|
|
25
40
|
fields = []
|
|
26
41
|
|
|
@@ -177,6 +192,7 @@ class WeightCounting_CNN(WeightCounting):
|
|
|
177
192
|
|
|
178
193
|
@staticmethod
|
|
179
194
|
def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
|
|
195
|
+
WeightCounting._ensure_keras()
|
|
180
196
|
input_size = rng.choice(np.arange(28, 32))
|
|
181
197
|
cnn_num_filters = rng.choice(2 ** np.arange(8))
|
|
182
198
|
cnn_kernel_size = rng.choice(1 + np.arange(10))
|
|
@@ -211,6 +227,7 @@ class WeightCounting_CNN(WeightCounting):
|
|
|
211
227
|
class WeightCounting_RNN(WeightCounting):
|
|
212
228
|
@staticmethod
|
|
213
229
|
def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
|
|
230
|
+
WeightCounting._ensure_keras()
|
|
214
231
|
timesteps = int(rng.choice(np.arange(20, 41)))
|
|
215
232
|
feature_size = int(rng.choice(np.arange(8, 65)))
|
|
216
233
|
|
|
@@ -3,18 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
import abc
|
|
4
4
|
import io
|
|
5
5
|
import logging
|
|
6
|
-
import math
|
|
7
6
|
import numpy as np
|
|
8
|
-
import uuid
|
|
9
|
-
import os
|
|
10
7
|
from typing import List, Tuple
|
|
11
8
|
|
|
12
9
|
import matplotlib.pyplot as plt
|
|
13
|
-
import matplotlib.patches as mpatches
|
|
14
10
|
|
|
15
11
|
import QuizGenerator.contentast as ca
|
|
16
12
|
from QuizGenerator.question import Question, QuestionRegistry
|
|
17
|
-
from QuizGenerator.mixins import TableQuestionMixin, BodyTemplatesMixin
|
|
18
13
|
from ..models.matrices import MatrixQuestion
|
|
19
14
|
|
|
20
15
|
log = logging.getLogger(__name__)
|
|
@@ -843,8 +838,7 @@ class BackpropGradientQuestion(SimpleNeuralNetworkBase):
|
|
|
843
838
|
# Compute intermediate values
|
|
844
839
|
dz2_da1 = self.W2[0, 0]
|
|
845
840
|
da1_dz1 = self._activation_derivative(self.z1[0])
|
|
846
|
-
|
|
847
|
-
|
|
841
|
+
|
|
848
842
|
grad = self._compute_gradient_W1(0, j)
|
|
849
843
|
|
|
850
844
|
if self.activation_function == self.ACTIVATION_SIGMOID:
|
|
@@ -1131,7 +1125,6 @@ class EndToEndTrainingQuestion(SimpleNeuralNetworkBase):
|
|
|
1131
1125
|
]))
|
|
1132
1126
|
|
|
1133
1127
|
# Hidden layer
|
|
1134
|
-
z1_0 = self.W1[0, 0] * self.X[0] + self.W1[0, 1] * self.X[1] + self.b1[0]
|
|
1135
1128
|
explanation.add_element(ca.Equation(
|
|
1136
1129
|
f"z_1 = w_{{11}} x_1 + w_{{12}} x_2 + b_1 = {self.W1[0,0]:.{self.param_digits}f} \\cdot {self.X[0]:.1f} + {self.W1[0,1]:.{self.param_digits}f} \\cdot {self.X[1]:.1f} + {self.b1[0]:.{self.param_digits}f} = {self.z1[0]:.4f}",
|
|
1137
1130
|
inline=False
|
|
@@ -1149,7 +1142,6 @@ class EndToEndTrainingQuestion(SimpleNeuralNetworkBase):
|
|
|
1149
1142
|
))
|
|
1150
1143
|
|
|
1151
1144
|
# Output (pre-activation)
|
|
1152
|
-
z2 = self.W2[0, 0] * self.a1[0] + self.W2[0, 1] * self.a1[1] + self.b2[0]
|
|
1153
1145
|
explanation.add_element(ca.Equation(
|
|
1154
1146
|
f"z_{{out}} = w_3 h_1 + w_4 h_2 + b_2 = {self.W2[0,0]:.{self.param_digits}f} \\cdot {self.a1[0]:.4f} + {self.W2[0,1]:.{self.param_digits}f} \\cdot {self.a1[1]:.4f} + {self.b2[0]:.{self.param_digits}f} = {self.z2[0]:.4f}",
|
|
1155
1147
|
inline=False
|
|
@@ -4,3 +4,10 @@ from .tensorflow_questions import (
|
|
|
4
4
|
RegularizationCalculationQuestion,
|
|
5
5
|
MomentumOptimizerQuestion
|
|
6
6
|
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ParameterCountingQuestion",
|
|
10
|
+
"ActivationFunctionComputationQuestion",
|
|
11
|
+
"RegularizationCalculationQuestion",
|
|
12
|
+
"MomentumOptimizerQuestion",
|
|
13
|
+
]
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import abc
|
|
4
|
-
import io
|
|
5
3
|
import logging
|
|
6
|
-
import re
|
|
7
4
|
import numpy as np
|
|
8
5
|
import sympy as sp
|
|
9
6
|
from typing import List, Tuple
|
|
@@ -900,7 +897,6 @@ class MomentumOptimizerQuestion(Question, TableQuestionMixin, BodyTemplatesMixin
|
|
|
900
897
|
# Show calculation for each component
|
|
901
898
|
digits = ca.Answer.DEFAULT_ROUNDING_DIGITS
|
|
902
899
|
for i in range(context.num_variables):
|
|
903
|
-
var_name = f"x_{i}"
|
|
904
900
|
# Round all intermediate values to avoid floating point precision issues
|
|
905
901
|
beta_times_v = round(context.momentum_beta * context.prev_velocity[i], digits)
|
|
906
902
|
one_minus_beta = round(1 - context.momentum_beta, digits)
|
|
@@ -9,17 +9,20 @@ 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
|
-
|
|
17
|
+
import tempfile
|
|
18
|
+
import zlib
|
|
19
|
+
from datetime import datetime
|
|
18
20
|
from pathlib import Path
|
|
19
21
|
from typing import Optional, Dict, Any
|
|
20
22
|
|
|
21
23
|
import segno
|
|
22
24
|
from cryptography.fernet import Fernet
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
26
|
|
|
24
27
|
log = logging.getLogger(__name__)
|
|
25
28
|
|
|
@@ -38,6 +41,22 @@ class QuestionQRCode:
|
|
|
38
41
|
|
|
39
42
|
# Error correction level: M = 15% recovery (balanced for compact encoded data)
|
|
40
43
|
ERROR_CORRECTION = 'M'
|
|
44
|
+
_generated_key: Optional[bytes] = None
|
|
45
|
+
V2_PREFIX = "v2."
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def _persist_generated_key(cls, key: bytes) -> None:
|
|
49
|
+
try:
|
|
50
|
+
os.makedirs("out/keys", exist_ok=True)
|
|
51
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
52
|
+
path = os.path.join("out", "keys", f"quiz_encryption_key-{timestamp}.log")
|
|
53
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
54
|
+
handle.write("QUIZ_ENCRYPTION_KEY=")
|
|
55
|
+
handle.write(key.decode("ascii"))
|
|
56
|
+
handle.write("\n")
|
|
57
|
+
log.warning(f"Wrote generated QUIZ_ENCRYPTION_KEY to {path}")
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
log.warning(f"Failed to persist generated QUIZ_ENCRYPTION_KEY: {exc}")
|
|
41
60
|
|
|
42
61
|
@classmethod
|
|
43
62
|
def get_encryption_key(cls) -> bytes:
|
|
@@ -61,67 +80,95 @@ class QuestionQRCode:
|
|
|
61
80
|
"QUIZ_ENCRYPTION_KEY not set! Generating temporary key. "
|
|
62
81
|
"Set this environment variable for production use!"
|
|
63
82
|
)
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
if cls._generated_key is None:
|
|
84
|
+
cls._generated_key = Fernet.generate_key()
|
|
85
|
+
os.environ["QUIZ_ENCRYPTION_KEY"] = cls._generated_key.decode("ascii")
|
|
86
|
+
cls._persist_generated_key(cls._generated_key)
|
|
87
|
+
return cls._generated_key
|
|
66
88
|
|
|
67
89
|
# Key should be stored as base64 string in env
|
|
68
90
|
return key_str.encode()
|
|
69
91
|
|
|
70
92
|
@classmethod
|
|
71
|
-
def
|
|
93
|
+
def _derive_aead_key(cls, key: bytes) -> bytes:
|
|
94
|
+
return hashlib.sha256(key).digest()
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def _encrypt_v2(cls, payload: Dict[str, Any], *, key: Optional[bytes] = None) -> str:
|
|
98
|
+
if key is None:
|
|
99
|
+
key = cls.get_encryption_key()
|
|
100
|
+
aead_key = cls._derive_aead_key(key)
|
|
101
|
+
aesgcm = AESGCM(aead_key)
|
|
102
|
+
nonce = os.urandom(12)
|
|
103
|
+
json_bytes = json.dumps(payload, separators=(',', ':'), ensure_ascii=False).encode("utf-8")
|
|
104
|
+
compressed = zlib.compress(json_bytes)
|
|
105
|
+
ciphertext = aesgcm.encrypt(nonce, compressed, None)
|
|
106
|
+
token = base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
|
|
107
|
+
return f"{cls.V2_PREFIX}{token}"
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def _decrypt_v2(cls, encrypted_data: str, *, key: Optional[bytes] = None) -> Dict[str, Any]:
|
|
111
|
+
if not encrypted_data.startswith(cls.V2_PREFIX):
|
|
112
|
+
raise ValueError("Not a v2 payload")
|
|
113
|
+
if key is None:
|
|
114
|
+
key = cls.get_encryption_key()
|
|
115
|
+
aead_key = cls._derive_aead_key(key)
|
|
116
|
+
token = encrypted_data[len(cls.V2_PREFIX):]
|
|
117
|
+
raw = base64.urlsafe_b64decode(token.encode("ascii"))
|
|
118
|
+
nonce, ciphertext = raw[:12], raw[12:]
|
|
119
|
+
aesgcm = AESGCM(aead_key)
|
|
120
|
+
compressed = aesgcm.decrypt(nonce, ciphertext, None)
|
|
121
|
+
json_bytes = zlib.decompress(compressed)
|
|
122
|
+
return json.loads(json_bytes.decode("utf-8"))
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def encrypt_question_data(cls, question_type: str, seed: int, version: Optional[str] = None,
|
|
72
126
|
config: Optional[Dict[str, Any]] = None,
|
|
127
|
+
context: Optional[Dict[str, Any]] = None,
|
|
128
|
+
points_value: Optional[float] = None,
|
|
73
129
|
key: Optional[bytes] = None) -> str:
|
|
74
130
|
"""
|
|
75
|
-
Encode question regeneration data
|
|
131
|
+
Encode question regeneration data for QR embedding.
|
|
76
132
|
|
|
77
133
|
Args:
|
|
78
134
|
question_type: Class name of the question (e.g., "VectorDotProduct")
|
|
79
135
|
seed: Random seed used to generate this specific question
|
|
80
|
-
version:
|
|
136
|
+
version: Optional question version string
|
|
81
137
|
config: Optional dictionary of configuration parameters
|
|
138
|
+
context: Optional dictionary of context extras
|
|
139
|
+
points_value: Optional points value (for redundancy)
|
|
82
140
|
key: Encryption key (uses environment key if None)
|
|
83
141
|
|
|
84
142
|
Returns:
|
|
85
|
-
str: Base64-encoded
|
|
143
|
+
str: Base64-encoded encrypted payload with v2 prefix
|
|
86
144
|
|
|
87
145
|
Example:
|
|
88
146
|
>>> encrypted = QuestionQRCode.encrypt_question_data("VectorDot", 12345, "1.0")
|
|
89
147
|
>>> print(encrypted)
|
|
90
148
|
'VmVjdG9yRG90OjEyMzQ1OjEuMA=='
|
|
91
149
|
"""
|
|
92
|
-
|
|
150
|
+
payload: Dict[str, Any] = {
|
|
151
|
+
"t": question_type,
|
|
152
|
+
"s": seed,
|
|
153
|
+
}
|
|
154
|
+
if points_value is not None:
|
|
155
|
+
payload["p"] = points_value
|
|
93
156
|
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
|
-
|
|
157
|
+
payload["c"] = config
|
|
158
|
+
if context:
|
|
159
|
+
payload["x"] = context
|
|
160
|
+
if version:
|
|
161
|
+
payload["v"] = version
|
|
162
|
+
|
|
163
|
+
encoded = cls._encrypt_v2(payload, key=key)
|
|
164
|
+
log.debug(f"Encoded question data v2: {question_type} seed={seed} ({len(encoded)} chars)")
|
|
118
165
|
return encoded
|
|
119
166
|
|
|
120
167
|
@classmethod
|
|
121
168
|
def decrypt_question_data(cls, encrypted_data: str,
|
|
122
169
|
key: Optional[bytes] = None) -> Dict[str, Any]:
|
|
123
170
|
"""
|
|
124
|
-
Decode question regeneration data from QR code.
|
|
171
|
+
Decode question regeneration data from QR code (v2 preferred, v1 fallback).
|
|
125
172
|
|
|
126
173
|
Args:
|
|
127
174
|
encrypted_data: Base64-encoded (optionally XOR-obfuscated) string from QR code
|
|
@@ -142,10 +189,24 @@ class QuestionQRCode:
|
|
|
142
189
|
key = cls.get_encryption_key()
|
|
143
190
|
|
|
144
191
|
try:
|
|
145
|
-
|
|
192
|
+
if encrypted_data.startswith(cls.V2_PREFIX):
|
|
193
|
+
payload = cls._decrypt_v2(encrypted_data, key=key)
|
|
194
|
+
result = {
|
|
195
|
+
"question_type": payload.get("t"),
|
|
196
|
+
"seed": int(payload.get("s")),
|
|
197
|
+
}
|
|
198
|
+
if "v" in payload:
|
|
199
|
+
result["version"] = payload.get("v")
|
|
200
|
+
if "c" in payload:
|
|
201
|
+
result["config"] = payload.get("c")
|
|
202
|
+
if "x" in payload:
|
|
203
|
+
result["context"] = payload.get("x")
|
|
204
|
+
if "p" in payload:
|
|
205
|
+
result["points"] = payload.get("p")
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
# V1 fallback (XOR obfuscation)
|
|
146
209
|
obfuscated = base64.urlsafe_b64decode(encrypted_data.encode())
|
|
147
|
-
|
|
148
|
-
# Reverse XOR obfuscation if key is provided
|
|
149
210
|
if key:
|
|
150
211
|
key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
|
|
151
212
|
data_bytes = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(obfuscated))
|
|
@@ -153,29 +214,26 @@ class QuestionQRCode:
|
|
|
153
214
|
data_bytes = obfuscated
|
|
154
215
|
|
|
155
216
|
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)}")
|
|
217
|
+
parts = data_str.split(':', 3)
|
|
218
|
+
if len(parts) < 2:
|
|
219
|
+
raise ValueError(f"Invalid encoded data format: expected at least 2 parts, got {len(parts)}")
|
|
161
220
|
|
|
162
221
|
question_type = parts[0]
|
|
163
222
|
seed_str = parts[1]
|
|
164
|
-
version = parts[2]
|
|
223
|
+
version = parts[2] if len(parts) >= 3 else None
|
|
165
224
|
|
|
166
225
|
result = {
|
|
167
226
|
"question_type": question_type,
|
|
168
227
|
"seed": int(seed_str),
|
|
169
|
-
"version": version
|
|
170
228
|
}
|
|
229
|
+
if version:
|
|
230
|
+
result["version"] = version
|
|
171
231
|
|
|
172
|
-
# Parse config JSON if present
|
|
173
232
|
if len(parts) == 4:
|
|
174
233
|
try:
|
|
175
234
|
result["config"] = json.loads(parts[3])
|
|
176
235
|
except json.JSONDecodeError as e:
|
|
177
236
|
log.warning(f"Failed to parse config JSON: {e}")
|
|
178
|
-
# Continue without config rather than failing
|
|
179
237
|
|
|
180
238
|
return result
|
|
181
239
|
|
|
@@ -215,24 +273,27 @@ class QuestionQRCode:
|
|
|
215
273
|
"""
|
|
216
274
|
data = {
|
|
217
275
|
"q": question_number,
|
|
218
|
-
"
|
|
276
|
+
"p": points_value
|
|
219
277
|
}
|
|
220
278
|
|
|
221
279
|
# 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
|
|
280
|
+
if all(k in extra_data for k in ['question_type', 'seed']):
|
|
224
281
|
config = extra_data.get('config', {})
|
|
282
|
+
context = extra_data.get('context', {})
|
|
225
283
|
encrypted = cls.encrypt_question_data(
|
|
226
284
|
extra_data['question_type'],
|
|
227
285
|
extra_data['seed'],
|
|
228
|
-
extra_data
|
|
229
|
-
config=config
|
|
286
|
+
extra_data.get('version'),
|
|
287
|
+
config=config,
|
|
288
|
+
context=context,
|
|
289
|
+
points_value=points_value
|
|
230
290
|
)
|
|
231
291
|
data['s'] = encrypted
|
|
232
292
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
293
|
+
extra_data = {
|
|
294
|
+
k: v for k, v in extra_data.items()
|
|
295
|
+
if k not in ['question_type', 'seed', 'version', 'config', 'context']
|
|
296
|
+
}
|
|
236
297
|
|
|
237
298
|
# Add any remaining extra metadata
|
|
238
299
|
data.update(extra_data)
|
QuizGenerator/question.py
CHANGED
|
@@ -11,16 +11,12 @@ import itertools
|
|
|
11
11
|
import os
|
|
12
12
|
import pathlib
|
|
13
13
|
import pkgutil
|
|
14
|
-
import pprint
|
|
15
14
|
import random
|
|
16
|
-
import re
|
|
17
15
|
import uuid
|
|
18
16
|
import types
|
|
19
17
|
import inspect
|
|
20
18
|
from types import MappingProxyType
|
|
21
19
|
|
|
22
|
-
import pypandoc
|
|
23
|
-
import yaml
|
|
24
20
|
from typing import List, Dict, Any, Tuple, Optional, Mapping, MutableMapping
|
|
25
21
|
import canvasapi.course, canvasapi.quiz
|
|
26
22
|
|
|
@@ -45,6 +41,7 @@ class RegenerationFlags:
|
|
|
45
41
|
generation_seed: Optional[int]
|
|
46
42
|
question_version: str
|
|
47
43
|
config_params: Dict[str, Any]
|
|
44
|
+
context_extras: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
48
45
|
|
|
49
46
|
|
|
50
47
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -94,7 +91,9 @@ class QuestionContext:
|
|
|
94
91
|
return self.rng_seed
|
|
95
92
|
if key == "rng":
|
|
96
93
|
return self.rng
|
|
97
|
-
|
|
94
|
+
if hasattr(self.data, "get"):
|
|
95
|
+
return self.data.get(key, default)
|
|
96
|
+
return default
|
|
98
97
|
|
|
99
98
|
def __contains__(self, key: object) -> bool:
|
|
100
99
|
if key in ("rng_seed", "rng"):
|
|
@@ -261,7 +260,12 @@ class QuestionRegistry:
|
|
|
261
260
|
# Load modules from the current directory
|
|
262
261
|
for _, module_name, _ in pkgutil.iter_modules([str(path)]):
|
|
263
262
|
# Import the module
|
|
264
|
-
|
|
263
|
+
try:
|
|
264
|
+
importlib.import_module(f"{package_prefix}.{module_name}")
|
|
265
|
+
except ImportError as e:
|
|
266
|
+
log.warning(
|
|
267
|
+
f"Skipping module '{package_prefix}.{module_name}' due to import error: {e}"
|
|
268
|
+
)
|
|
265
269
|
|
|
266
270
|
# Recursively load modules from subdirectories
|
|
267
271
|
for subdir in path.iterdir():
|
|
@@ -614,12 +618,6 @@ class Question(abc.ABC):
|
|
|
614
618
|
}
|
|
615
619
|
self.config_params = {k: v for k, v in kwargs.items() if k not in framework_params}
|
|
616
620
|
|
|
617
|
-
@classmethod
|
|
618
|
-
def from_yaml(cls, path_to_yaml):
|
|
619
|
-
with open(path_to_yaml) as fid:
|
|
620
|
-
question_dicts = yaml.safe_load_all(fid)
|
|
621
|
-
|
|
622
|
-
|
|
623
621
|
def instantiate(self, **kwargs) -> QuestionInstance:
|
|
624
622
|
"""
|
|
625
623
|
Instantiate a question once, returning content, answers, and regeneration metadata.
|
|
@@ -688,6 +686,20 @@ class Question(abc.ABC):
|
|
|
688
686
|
if isinstance(ctx, dict) and ctx.get("_config_params"):
|
|
689
687
|
config_params.update(ctx.get("_config_params"))
|
|
690
688
|
|
|
689
|
+
context_extras: Dict[str, Any] = {}
|
|
690
|
+
if isinstance(ctx, QuestionContext):
|
|
691
|
+
include_list = ctx.get("qr_include_list", None)
|
|
692
|
+
if isinstance(include_list, (list, tuple)):
|
|
693
|
+
for key in include_list:
|
|
694
|
+
if key in ctx:
|
|
695
|
+
context_extras[key] = ctx[key]
|
|
696
|
+
elif isinstance(ctx, dict):
|
|
697
|
+
include_list = ctx.get("qr_include_list", None)
|
|
698
|
+
if isinstance(include_list, (list, tuple)):
|
|
699
|
+
for key in include_list:
|
|
700
|
+
if key in ctx:
|
|
701
|
+
context_extras[key] = ctx[key]
|
|
702
|
+
|
|
691
703
|
instance = QuestionInstance(
|
|
692
704
|
body=components.body,
|
|
693
705
|
explanation=components.explanation,
|
|
@@ -701,7 +713,8 @@ class Question(abc.ABC):
|
|
|
701
713
|
question_class_name=self._get_registered_name(),
|
|
702
714
|
generation_seed=actual_seed,
|
|
703
715
|
question_version=self.VERSION,
|
|
704
|
-
config_params=config_params
|
|
716
|
+
config_params=config_params,
|
|
717
|
+
context_extras=context_extras
|
|
705
718
|
)
|
|
706
719
|
)
|
|
707
720
|
return instance
|
|
@@ -932,6 +945,7 @@ class Question(abc.ABC):
|
|
|
932
945
|
question_ast.generation_seed = instance.flags.generation_seed
|
|
933
946
|
question_ast.question_version = instance.flags.question_version
|
|
934
947
|
question_ast.config_params = dict(instance.flags.config_params)
|
|
948
|
+
question_ast.qr_context_extras = dict(instance.flags.context_extras)
|
|
935
949
|
|
|
936
950
|
return question_ast
|
|
937
951
|
|
|
@@ -1014,11 +1028,11 @@ class QuestionGroup():
|
|
|
1014
1028
|
|
|
1015
1029
|
def instantiate(self, *args, **kwargs):
|
|
1016
1030
|
|
|
1017
|
-
#
|
|
1018
|
-
random.
|
|
1031
|
+
# Use a local RNG to avoid global side effects.
|
|
1032
|
+
rng = random.Random(kwargs.get("rng_seed", None))
|
|
1019
1033
|
|
|
1020
1034
|
if not self.pick_once or self._current_question is None:
|
|
1021
|
-
self._current_question =
|
|
1035
|
+
self._current_question = rng.choice(self.questions)
|
|
1022
1036
|
|
|
1023
1037
|
def __getattr__(self, name):
|
|
1024
1038
|
if self._current_question is None or name == "generate":
|
QuizGenerator/quiz.py
CHANGED
|
@@ -2,15 +2,10 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import collections
|
|
5
|
-
import itertools
|
|
6
5
|
import logging
|
|
7
|
-
import os.path
|
|
8
6
|
import random
|
|
9
|
-
import shutil
|
|
10
|
-
import subprocess
|
|
11
|
-
import tempfile
|
|
12
7
|
from datetime import datetime
|
|
13
|
-
from typing import List
|
|
8
|
+
from typing import List
|
|
14
9
|
import re
|
|
15
10
|
|
|
16
11
|
import yaml
|