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.
Files changed (25) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/canvas/canvas_interface.py +6 -2
  3. QuizGenerator/contentast.py +33 -11
  4. QuizGenerator/generate.py +51 -10
  5. QuizGenerator/logging.yaml +55 -0
  6. QuizGenerator/mixins.py +6 -2
  7. QuizGenerator/premade_questions/basic.py +49 -7
  8. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +92 -82
  9. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +68 -45
  10. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +238 -162
  11. QuizGenerator/premade_questions/cst463/models/attention.py +0 -1
  12. QuizGenerator/premade_questions/cst463/models/cnns.py +0 -1
  13. QuizGenerator/premade_questions/cst463/models/rnns.py +0 -1
  14. QuizGenerator/premade_questions/cst463/models/text.py +0 -1
  15. QuizGenerator/premade_questions/cst463/models/weight_counting.py +20 -1
  16. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +51 -45
  17. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +212 -215
  18. QuizGenerator/qrcode_generator.py +116 -54
  19. QuizGenerator/question.py +168 -23
  20. QuizGenerator/regenerate.py +23 -9
  21. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/METADATA +34 -22
  22. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/RECORD +25 -23
  23. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/WHEEL +0 -0
  24. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/entry_points.txt +0 -0
  25. {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 base64
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
- # Generate temporary key for development
65
- return Fernet.generate_key()
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 encrypt_question_data(cls, question_type: str, seed: int, version: str,
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 with optional simple obfuscation.
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: Question class version (e.g., "1.0")
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 (optionally XOR-obfuscated) data
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
- # Create compact data string, including config if provided
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
- # Serialize config as JSON and append to data string
95
- config_json = json.dumps(config, separators=(',', ':'))
96
- data_str = f"{question_type}:{seed}:{version}:{config_json}"
97
- else:
98
- data_str = f"{question_type}:{seed}:{version}"
99
- data_bytes = data_str.encode('utf-8')
100
-
101
- # Simple XOR obfuscation if key is provided (optional, for basic protection)
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
- # Decode from base64
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
- # Parse data string - can be 3 or 4 parts (4th is optional config)
158
- parts = data_str.split(':', 3) # Split into max 4 parts
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
- "pts": points_value
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', 'version']):
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['version'],
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
- # Remove the unencrypted data
234
- extra_data = {k: v for k, v in extra_data.items()
235
- if k not in ['question_type', 'seed', 'version', 'config']}
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
- module = importlib.import_module(f"{package_prefix}.{module_name}")
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(self, rng_seed=None, **kwargs):
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
- components = self.build(rng_seed=current_seed, context=ctx, **build_kwargs)
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
- def build(self, *, rng_seed=None, context=None, **kwargs) -> QuestionComponents:
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 = self._build_context(rng_seed=rng_seed, **kwargs)
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(self._build_body(context))
634
- explanation, explanation_answers = cls._normalize_build_output(self._build_explanation(context))
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
- def _build_context(self, *, rng_seed=None, **kwargs):
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 context dict and avoid persistent self.* state.
800
+ Override to return a QuestionContext and avoid persistent self.* state.
658
801
  """
659
802
  rng = random.Random(rng_seed)
660
- # Keep instance rng in sync for questions that still use self.rng.
661
- self.rng = rng
662
- return {
663
- "rng_seed": rng_seed,
664
- "rng": rng,
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
- def _build_body(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
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
- def _build_explanation(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
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
- # todo: Make work with rng_seed (or at least verify)
897
- random.seed(kwargs.get("rng_seed", None))
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 = random.choice(self.questions)
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":
@@ -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('pts')
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['version']
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
- log.info(f"Question {question_num}: {question_type} (seed={seed}, version={version})")
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
- print(f"Version: {question_data['version']}")
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
- for key, answer_obj in question_data['answer_objects'].items():
485
- print(f" {key}: {answer_obj.value}")
486
- if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
487
- print(f" (tolerance: ±{answer_obj.tolerance})")
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']}")