QuizGenerator 0.7.0__py3-none-any.whl → 0.8.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/contentast.py +6 -6
- QuizGenerator/generate.py +2 -1
- QuizGenerator/mixins.py +14 -100
- QuizGenerator/premade_questions/basic.py +24 -29
- QuizGenerator/premade_questions/cst334/languages.py +100 -99
- QuizGenerator/premade_questions/cst334/math_questions.py +112 -122
- QuizGenerator/premade_questions/cst334/memory_questions.py +621 -621
- QuizGenerator/premade_questions/cst334/persistence_questions.py +137 -163
- QuizGenerator/premade_questions/cst334/process.py +312 -322
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +34 -35
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +41 -36
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +48 -41
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +285 -520
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +149 -126
- QuizGenerator/premade_questions/cst463/models/attention.py +44 -50
- QuizGenerator/premade_questions/cst463/models/cnns.py +43 -47
- QuizGenerator/premade_questions/cst463/models/matrices.py +61 -11
- QuizGenerator/premade_questions/cst463/models/rnns.py +48 -50
- QuizGenerator/premade_questions/cst463/models/text.py +65 -67
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +47 -46
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +100 -156
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +93 -141
- QuizGenerator/question.py +273 -202
- QuizGenerator/quiz.py +8 -5
- QuizGenerator/regenerate.py +128 -19
- {quizgenerator-0.7.0.dist-info → quizgenerator-0.8.0.dist-info}/METADATA +30 -2
- {quizgenerator-0.7.0.dist-info → quizgenerator-0.8.0.dist-info}/RECORD +30 -30
- {quizgenerator-0.7.0.dist-info → quizgenerator-0.8.0.dist-info}/WHEEL +0 -0
- {quizgenerator-0.7.0.dist-info → quizgenerator-0.8.0.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.7.0.dist-info → quizgenerator-0.8.0.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/question.py
CHANGED
|
@@ -22,7 +22,6 @@ from typing import List, Dict, Any, Tuple, Optional
|
|
|
22
22
|
import canvasapi.course, canvasapi.quiz
|
|
23
23
|
|
|
24
24
|
import QuizGenerator.contentast as ca
|
|
25
|
-
from QuizGenerator.performance import timer, PerformanceTracker
|
|
26
25
|
|
|
27
26
|
import logging
|
|
28
27
|
log = logging.getLogger(__name__)
|
|
@@ -36,6 +35,29 @@ class QuestionComponents:
|
|
|
36
35
|
explanation: ca.Element
|
|
37
36
|
|
|
38
37
|
|
|
38
|
+
@dataclasses.dataclass(frozen=True)
|
|
39
|
+
class RegenerationFlags:
|
|
40
|
+
"""Minimal metadata needed to regenerate a question instance."""
|
|
41
|
+
question_class_name: str
|
|
42
|
+
generation_seed: Optional[int]
|
|
43
|
+
question_version: str
|
|
44
|
+
config_params: Dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass(frozen=True)
|
|
48
|
+
class QuestionInstance:
|
|
49
|
+
"""Fully-instantiated question with content, answers, and regeneration metadata."""
|
|
50
|
+
body: ca.Element
|
|
51
|
+
explanation: ca.Element
|
|
52
|
+
answers: List[ca.Answer]
|
|
53
|
+
answer_kind: ca.Answer.CanvasAnswerKind
|
|
54
|
+
can_be_numerical: bool
|
|
55
|
+
value: float
|
|
56
|
+
spacing: float
|
|
57
|
+
topic: "Question.Topic"
|
|
58
|
+
flags: RegenerationFlags
|
|
59
|
+
|
|
60
|
+
|
|
39
61
|
# Spacing presets for questions
|
|
40
62
|
SPACING_PRESETS = {
|
|
41
63
|
"NONE": 0,
|
|
@@ -147,7 +169,7 @@ class QuestionRegistry:
|
|
|
147
169
|
raise ValueError(f"Unknown question type: {question_type}")
|
|
148
170
|
|
|
149
171
|
new_question : Question = cls._registry[question_key](**kwargs)
|
|
150
|
-
# Note: Don't
|
|
172
|
+
# Note: Don't build context here - instantiate() handles it
|
|
151
173
|
# Calling it here would consume RNG calls and break QR code regeneration
|
|
152
174
|
return new_question
|
|
153
175
|
|
|
@@ -212,21 +234,21 @@ class QuestionRegistry:
|
|
|
212
234
|
class RegenerableChoiceMixin:
|
|
213
235
|
"""
|
|
214
236
|
Mixin for questions that need to make random choices from enums/lists that are:
|
|
215
|
-
1. Different across multiple
|
|
237
|
+
1. Different across multiple builds (when the same Question instance is reused for multiple PDFs)
|
|
216
238
|
2. Reproducible from QR code config_params
|
|
217
239
|
|
|
218
240
|
The Problem:
|
|
219
241
|
------------
|
|
220
242
|
When generating multiple PDFs, Quiz.from_yaml() creates Question instances ONCE.
|
|
221
|
-
These instances are then
|
|
243
|
+
These instances are then built multiple times with different RNG seeds.
|
|
222
244
|
If a question randomly selects an algorithm/policy in __init__(), all PDFs get the same choice
|
|
223
245
|
because __init__() only runs once with an unseeded RNG.
|
|
224
246
|
|
|
225
247
|
The Solution:
|
|
226
248
|
-------------
|
|
227
249
|
1. In __init__(): Register choices with fixed values (if provided) or None (for random)
|
|
228
|
-
2. In
|
|
229
|
-
3. Result: Each
|
|
250
|
+
2. In _build_context(): Make random selections using the seeded RNG, store in config_params
|
|
251
|
+
3. Result: Each build gets a different random choice, and it's captured for QR codes
|
|
230
252
|
|
|
231
253
|
Usage Example:
|
|
232
254
|
--------------
|
|
@@ -240,11 +262,11 @@ class RegenerableChoiceMixin:
|
|
|
240
262
|
self.register_choice('scheduler_kind', self.Kind, scheduler_kind, kwargs)
|
|
241
263
|
super().__init__(**kwargs)
|
|
242
264
|
|
|
243
|
-
def
|
|
244
|
-
|
|
265
|
+
def _build_context(self, rng_seed=None, **kwargs):
|
|
266
|
+
self.rng.seed(rng_seed)
|
|
245
267
|
# Get the choice (randomly selected or from config_params)
|
|
246
268
|
self.scheduler_algorithm = self.get_choice('scheduler_kind', self.Kind)
|
|
247
|
-
# ... rest of
|
|
269
|
+
# ... rest of build logic
|
|
248
270
|
"""
|
|
249
271
|
|
|
250
272
|
def __init__(self, *args, **kwargs):
|
|
@@ -281,7 +303,7 @@ class RegenerableChoiceMixin:
|
|
|
281
303
|
def get_choice(self, param_name: str, enum_class: type[enum.Enum]) -> enum.Enum:
|
|
282
304
|
"""
|
|
283
305
|
Get the choice for a registered parameter.
|
|
284
|
-
Should be called in
|
|
306
|
+
Should be called in _build_context() AFTER seeding the RNG.
|
|
285
307
|
|
|
286
308
|
Args:
|
|
287
309
|
param_name: The parameter name registered earlier
|
|
@@ -294,7 +316,7 @@ class RegenerableChoiceMixin:
|
|
|
294
316
|
if choice_info is None:
|
|
295
317
|
raise ValueError(f"Choice '{param_name}' not registered. Call register_choice() in __init__() first.")
|
|
296
318
|
|
|
297
|
-
# Check for temporary fixed value (set during backoff loop in
|
|
319
|
+
# Check for temporary fixed value (set during backoff loop in instantiate())
|
|
298
320
|
fixed_value = choice_info.get('_temp_fixed_value', choice_info['fixed_value'])
|
|
299
321
|
|
|
300
322
|
# CRITICAL: Always consume an RNG call to keep RNG state synchronized between
|
|
@@ -339,30 +361,47 @@ class RegenerableChoiceMixin:
|
|
|
339
361
|
self.config_params[param_name] = random_choice.name
|
|
340
362
|
return random_choice
|
|
341
363
|
|
|
364
|
+
def pre_instantiate(self, base_seed, **kwargs):
|
|
365
|
+
if not (hasattr(self, '_regenerable_choices') and self._regenerable_choices):
|
|
366
|
+
return
|
|
367
|
+
choice_rng = random.Random(base_seed)
|
|
368
|
+
for param_name, choice_info in self._regenerable_choices.items():
|
|
369
|
+
if choice_info['fixed_value'] is None:
|
|
370
|
+
enum_class = choice_info['enum_class']
|
|
371
|
+
random_choice = choice_rng.choice(list(enum_class))
|
|
372
|
+
# Temporarily set this as the fixed value so all builds use it
|
|
373
|
+
choice_info['_temp_fixed_value'] = random_choice.name
|
|
374
|
+
# Store in config_params
|
|
375
|
+
self.config_params[param_name] = random_choice.name
|
|
376
|
+
|
|
377
|
+
def post_instantiate(self, instance, **kwargs):
|
|
378
|
+
if not (hasattr(self, '_regenerable_choices') and self._regenerable_choices):
|
|
379
|
+
return
|
|
380
|
+
for param_name, choice_info in self._regenerable_choices.items():
|
|
381
|
+
if '_temp_fixed_value' in choice_info:
|
|
382
|
+
del choice_info['_temp_fixed_value']
|
|
342
383
|
|
|
343
384
|
class Question(abc.ABC):
|
|
344
385
|
"""
|
|
345
386
|
Base class for all quiz questions with cross-format rendering support.
|
|
346
387
|
|
|
347
388
|
CRITICAL: When implementing Question subclasses, ALWAYS use content AST elements
|
|
348
|
-
for all content in
|
|
389
|
+
for all content in _build_body() and _build_explanation() methods.
|
|
349
390
|
|
|
350
391
|
NEVER create manual LaTeX, HTML, or Markdown strings. The content AST system
|
|
351
392
|
ensures consistent rendering across PDF/LaTeX and Canvas/HTML formats.
|
|
352
393
|
|
|
353
|
-
|
|
354
|
-
-
|
|
355
|
-
-
|
|
356
|
-
|
|
357
|
-
Note: get_body() and get_explanation() are provided for backward compatibility
|
|
358
|
-
and call the _get_* methods, returning just the first element of the tuple.
|
|
394
|
+
Primary extension points:
|
|
395
|
+
- build(...): Simple path. Override to generate body + explanation in one place.
|
|
396
|
+
- _build_context/_build_body/_build_explanation: Context path.
|
|
397
|
+
Default _build_context is required for all questions.
|
|
359
398
|
|
|
360
399
|
Required Class Attributes:
|
|
361
400
|
- VERSION (str): Question version number (e.g., "1.0")
|
|
362
401
|
Increment when RNG logic changes to ensure reproducibility
|
|
363
402
|
|
|
364
403
|
Content AST Usage Examples:
|
|
365
|
-
def
|
|
404
|
+
def _build_body(cls, context):
|
|
366
405
|
body = ca.Section()
|
|
367
406
|
answers = []
|
|
368
407
|
body.add_element(ca.Paragraph(["Calculate the matrix:"]))
|
|
@@ -375,7 +414,7 @@ class Question(abc.ABC):
|
|
|
375
414
|
ans = ca.Answer.integer("result", 42, label="Result")
|
|
376
415
|
answers.append(ans)
|
|
377
416
|
body.add_element(ans)
|
|
378
|
-
return body
|
|
417
|
+
return body
|
|
379
418
|
|
|
380
419
|
Common Content AST Elements:
|
|
381
420
|
- ca.Paragraph: Text blocks
|
|
@@ -392,7 +431,7 @@ class Question(abc.ABC):
|
|
|
392
431
|
- Do NOT increment for:
|
|
393
432
|
* Cosmetic changes (formatting, wording)
|
|
394
433
|
* Bug fixes that don't affect answer generation
|
|
395
|
-
* Changes to
|
|
434
|
+
* Changes to _build_explanation() only
|
|
396
435
|
|
|
397
436
|
See existing questions in premade_questions/ for patterns and examples.
|
|
398
437
|
"""
|
|
@@ -480,14 +519,10 @@ class Question(abc.ABC):
|
|
|
480
519
|
|
|
481
520
|
self.extra_attrs = kwargs # clear page, etc.
|
|
482
521
|
|
|
483
|
-
self.answers = {}
|
|
484
522
|
self.possible_variations = float('inf')
|
|
485
523
|
|
|
486
524
|
self.rng_seed_offset = kwargs.get("rng_seed_offset", 0)
|
|
487
525
|
|
|
488
|
-
# Component caching for unified Answer architecture
|
|
489
|
-
self._components: QuestionComponents = None
|
|
490
|
-
|
|
491
526
|
# To be used throughout when generating random things
|
|
492
527
|
self.rng = random.Random()
|
|
493
528
|
|
|
@@ -504,214 +539,260 @@ class Question(abc.ABC):
|
|
|
504
539
|
with open(path_to_yaml) as fid:
|
|
505
540
|
question_dicts = yaml.safe_load_all(fid)
|
|
506
541
|
|
|
507
|
-
|
|
542
|
+
|
|
543
|
+
def instantiate(self, **kwargs) -> QuestionInstance:
|
|
508
544
|
"""
|
|
509
|
-
|
|
510
|
-
:param kwargs:
|
|
511
|
-
:return: (ca.Question) Containing question.
|
|
545
|
+
Instantiate a question once, returning content, answers, and regeneration metadata.
|
|
512
546
|
"""
|
|
513
547
|
# Generate the question, retrying with incremented seeds until we get an interesting one
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if choice_info['fixed_value'] is None:
|
|
524
|
-
# No fixed value - pick randomly and store it as fixed for this get_question() call
|
|
525
|
-
enum_class = choice_info['enum_class']
|
|
526
|
-
random_choice = choice_rng.choice(list(enum_class))
|
|
527
|
-
# Temporarily set this as the fixed value so all refresh() calls use it
|
|
528
|
-
choice_info['_temp_fixed_value'] = random_choice.name
|
|
529
|
-
# Store in config_params
|
|
530
|
-
self.config_params[param_name] = random_choice.name
|
|
548
|
+
base_seed = kwargs.get("rng_seed", None)
|
|
549
|
+
build_kwargs = dict(kwargs)
|
|
550
|
+
build_kwargs.pop("rng_seed", None)
|
|
551
|
+
# Include config params so build() implementations can access YAML-provided settings.
|
|
552
|
+
build_kwargs = {**self.config_params, **build_kwargs}
|
|
553
|
+
|
|
554
|
+
# Pre-select any regenerable choices using the base seed
|
|
555
|
+
# This ensures the policy/algorithm stays constant across backoff attempts
|
|
556
|
+
self.pre_instantiate(base_seed, **kwargs)
|
|
531
557
|
|
|
558
|
+
instance = None
|
|
559
|
+
try:
|
|
532
560
|
backoff_counter = 0
|
|
533
561
|
is_interesting = False
|
|
562
|
+
ctx = None
|
|
534
563
|
while not is_interesting:
|
|
535
564
|
# Increment seed for each backoff attempt to maintain deterministic behavior
|
|
536
565
|
current_seed = None if base_seed is None else base_seed + backoff_counter
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
566
|
+
ctx = self._build_context(
|
|
567
|
+
rng_seed=current_seed,
|
|
568
|
+
**self.config_params
|
|
569
|
+
)
|
|
570
|
+
is_interesting = self.is_interesting_ctx(ctx)
|
|
540
571
|
backoff_counter += 1
|
|
541
572
|
|
|
542
|
-
#
|
|
543
|
-
if
|
|
544
|
-
for param_name, choice_info in self._regenerable_choices.items():
|
|
545
|
-
if '_temp_fixed_value' in choice_info:
|
|
546
|
-
del choice_info['_temp_fixed_value']
|
|
573
|
+
# Store the actual seed used and question metadata for QR code generation
|
|
574
|
+
actual_seed = None if base_seed is None else base_seed + backoff_counter - 1
|
|
547
575
|
|
|
548
|
-
|
|
549
|
-
body = self.get_body()
|
|
576
|
+
components = self.build(rng_seed=current_seed, context=ctx, **build_kwargs)
|
|
550
577
|
|
|
551
|
-
|
|
552
|
-
|
|
578
|
+
# Collect answers from explicit lists and inline AST
|
|
579
|
+
inline_body_answers = self._collect_answers_from_ast(components.body)
|
|
580
|
+
answers = self._merge_answers(
|
|
581
|
+
components.answers,
|
|
582
|
+
inline_body_answers
|
|
583
|
+
)
|
|
553
584
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
585
|
+
can_be_numerical = self._can_be_numerical_from_answers(answers)
|
|
586
|
+
|
|
587
|
+
config_params = dict(self.config_params)
|
|
588
|
+
if isinstance(ctx, dict) and ctx.get("_config_params"):
|
|
589
|
+
config_params.update(ctx.get("_config_params"))
|
|
590
|
+
|
|
591
|
+
instance = QuestionInstance(
|
|
592
|
+
body=components.body,
|
|
593
|
+
explanation=components.explanation,
|
|
594
|
+
answers=answers,
|
|
595
|
+
answer_kind=self.answer_kind,
|
|
596
|
+
can_be_numerical=can_be_numerical,
|
|
597
|
+
value=self.points_value,
|
|
598
|
+
spacing=self.spacing,
|
|
599
|
+
topic=self.topic,
|
|
600
|
+
flags=RegenerationFlags(
|
|
601
|
+
question_class_name=self._get_registered_name(),
|
|
602
|
+
generation_seed=actual_seed,
|
|
603
|
+
question_version=self.VERSION,
|
|
604
|
+
config_params=config_params
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
return instance
|
|
608
|
+
finally:
|
|
609
|
+
self.post_instantiate(instance, **kwargs)
|
|
565
610
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
question_ast.question_class_name = self._get_registered_name()
|
|
569
|
-
question_ast.generation_seed = actual_seed
|
|
570
|
-
question_ast.question_version = self.VERSION
|
|
571
|
-
# Make a copy of config_params so each question AST has its own
|
|
572
|
-
# (important when the same Question instance is reused for multiple PDFs)
|
|
573
|
-
question_ast.config_params = dict(self.config_params)
|
|
611
|
+
def pre_instantiate(self, base_seed, **kwargs):
|
|
612
|
+
pass
|
|
574
613
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
@abc.abstractmethod
|
|
578
|
-
def get_body(self, **kwargs) -> ca.Section:
|
|
579
|
-
"""
|
|
580
|
-
Gets the body of the question during generation
|
|
581
|
-
:param kwargs:
|
|
582
|
-
:return: (ca.Section) Containing question body
|
|
583
|
-
"""
|
|
614
|
+
def post_instantiate(self, instance, **kwargs):
|
|
584
615
|
pass
|
|
585
|
-
|
|
586
|
-
def
|
|
616
|
+
|
|
617
|
+
def build(self, *, rng_seed=None, context=None, **kwargs) -> QuestionComponents:
|
|
587
618
|
"""
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
:return: (ca.Section) Containing question explanation or None
|
|
619
|
+
Build question content (body, answers, explanation) for a given seed.
|
|
620
|
+
|
|
621
|
+
This should only generate content; metadata like points/spacing belong in instantiate().
|
|
592
622
|
"""
|
|
593
|
-
|
|
594
|
-
if
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
#
|
|
598
|
-
|
|
599
|
-
|
|
623
|
+
cls = self.__class__
|
|
624
|
+
if context is None:
|
|
625
|
+
context = self._build_context(rng_seed=rng_seed, **kwargs)
|
|
626
|
+
|
|
627
|
+
# Build body + explanation. Each may return just an Element or (Element, answers).
|
|
628
|
+
body, body_answers = cls._normalize_build_output(self._build_body(context))
|
|
629
|
+
explanation, explanation_answers = cls._normalize_build_output(self._build_explanation(context))
|
|
630
|
+
|
|
631
|
+
# Collect inline answers from both body and explanation.
|
|
632
|
+
inline_body_answers = cls._collect_answers_from_ast(body)
|
|
633
|
+
inline_explanation_answers = cls._collect_answers_from_ast(explanation)
|
|
634
|
+
|
|
635
|
+
answers = cls._merge_answers(
|
|
636
|
+
body_answers,
|
|
637
|
+
explanation_answers,
|
|
638
|
+
inline_body_answers,
|
|
639
|
+
inline_explanation_answers
|
|
600
640
|
)
|
|
601
641
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
642
|
+
return QuestionComponents(
|
|
643
|
+
body=body,
|
|
644
|
+
answers=answers,
|
|
645
|
+
explanation=explanation
|
|
646
|
+
)
|
|
606
647
|
|
|
607
|
-
|
|
608
|
-
Tuple of (body_ast, answers_list)
|
|
648
|
+
def _build_context(self, *, rng_seed=None, **kwargs):
|
|
609
649
|
"""
|
|
610
|
-
|
|
611
|
-
body = self.get_body()
|
|
612
|
-
return body, []
|
|
650
|
+
Build the deterministic context for a question instance.
|
|
613
651
|
|
|
614
|
-
|
|
652
|
+
Override to return a context dict and avoid persistent self.* state.
|
|
615
653
|
"""
|
|
616
|
-
|
|
617
|
-
|
|
654
|
+
rng = random.Random(rng_seed)
|
|
655
|
+
# Keep instance rng in sync for questions that still use self.rng.
|
|
656
|
+
self.rng = rng
|
|
657
|
+
return {
|
|
658
|
+
"rng_seed": rng_seed,
|
|
659
|
+
"rng": rng,
|
|
660
|
+
}
|
|
618
661
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
"""
|
|
622
|
-
return
|
|
623
|
-
[ca.Text("[Please reach out to your professor for clarification]")]
|
|
624
|
-
), []
|
|
662
|
+
@classmethod
|
|
663
|
+
def is_interesting_ctx(cls, context) -> bool:
|
|
664
|
+
"""Context-aware hook; defaults to existing is_interesting()."""
|
|
665
|
+
return True
|
|
625
666
|
|
|
626
|
-
def
|
|
627
|
-
"""
|
|
628
|
-
|
|
667
|
+
def _build_body(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
|
|
668
|
+
"""Context-aware body builder."""
|
|
669
|
+
raise NotImplementedError("Questions must implement _build_body().")
|
|
629
670
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
""
|
|
633
|
-
# Build body with its answers
|
|
634
|
-
body, body_answers = self._get_body()
|
|
671
|
+
def _build_explanation(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
|
|
672
|
+
"""Context-aware explanation builder."""
|
|
673
|
+
raise NotImplementedError("Questions must implement _build_explanation().")
|
|
635
674
|
|
|
636
|
-
|
|
637
|
-
|
|
675
|
+
@classmethod
|
|
676
|
+
def _collect_answers_from_ast(cls, element: ca.Element) -> List[ca.Answer]:
|
|
677
|
+
"""Traverse AST and collect embedded Answer elements."""
|
|
678
|
+
answers: List[ca.Answer] = []
|
|
679
|
+
|
|
680
|
+
def visit(node):
|
|
681
|
+
if node is None:
|
|
682
|
+
return
|
|
683
|
+
if isinstance(node, ca.Answer):
|
|
684
|
+
answers.append(node)
|
|
685
|
+
return
|
|
686
|
+
if isinstance(node, ca.Table):
|
|
687
|
+
if getattr(node, "headers", None):
|
|
688
|
+
for header in node.headers:
|
|
689
|
+
visit(header)
|
|
690
|
+
for row in node.data:
|
|
691
|
+
for cell in row:
|
|
692
|
+
visit(cell)
|
|
693
|
+
return
|
|
694
|
+
if isinstance(node, ca.TableGroup):
|
|
695
|
+
for _, table in node.tables:
|
|
696
|
+
visit(table)
|
|
697
|
+
return
|
|
698
|
+
if isinstance(node, ca.Container):
|
|
699
|
+
for child in node.elements:
|
|
700
|
+
visit(child)
|
|
701
|
+
return
|
|
702
|
+
if isinstance(node, (list, tuple)):
|
|
703
|
+
for child in node:
|
|
704
|
+
visit(child)
|
|
705
|
+
|
|
706
|
+
visit(element)
|
|
707
|
+
return answers
|
|
638
708
|
|
|
639
|
-
|
|
640
|
-
|
|
709
|
+
@classmethod
|
|
710
|
+
def _merge_answers(cls, *answer_lists: List[ca.Answer]) -> List[ca.Answer]:
|
|
711
|
+
"""Merge answers while preserving order and removing duplicates by key/id."""
|
|
712
|
+
merged: List[ca.Answer] = []
|
|
713
|
+
seen: set[str] = set()
|
|
714
|
+
|
|
715
|
+
for answers in answer_lists:
|
|
716
|
+
for ans in answers:
|
|
717
|
+
key = getattr(ans, "key", None)
|
|
718
|
+
if key is None:
|
|
719
|
+
key = str(id(ans))
|
|
720
|
+
if key in seen:
|
|
721
|
+
continue
|
|
722
|
+
seen.add(key)
|
|
723
|
+
merged.append(ans)
|
|
724
|
+
return merged
|
|
641
725
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
726
|
+
@classmethod
|
|
727
|
+
def _can_be_numerical_from_answers(cls, answers: List[ca.Answer]) -> bool:
|
|
728
|
+
return (
|
|
729
|
+
len(answers) == 1
|
|
730
|
+
and isinstance(answers[0], ca.AnswerTypes.Float)
|
|
646
731
|
)
|
|
647
732
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
list(itertools.chain(*[a.get_for_canvas(single_answer=True) for a in answers]))
|
|
668
|
-
)
|
|
733
|
+
@classmethod
|
|
734
|
+
def _normalize_build_output(
|
|
735
|
+
cls,
|
|
736
|
+
result: ca.Element | Tuple[ca.Element, List[ca.Answer]]
|
|
737
|
+
) -> Tuple[ca.Element, List[ca.Answer]]:
|
|
738
|
+
if isinstance(result, tuple):
|
|
739
|
+
body, answers = result
|
|
740
|
+
return body, list(answers or [])
|
|
741
|
+
return result, []
|
|
742
|
+
|
|
743
|
+
def _answers_for_canvas(
|
|
744
|
+
self,
|
|
745
|
+
answers: List[ca.Answer],
|
|
746
|
+
can_be_numerical: bool
|
|
747
|
+
) -> Tuple[ca.Answer.CanvasAnswerKind, List[Dict[str, Any]]]:
|
|
748
|
+
if len(answers) == 0:
|
|
749
|
+
return (ca.Answer.CanvasAnswerKind.ESSAY, [])
|
|
750
|
+
|
|
751
|
+
if can_be_numerical:
|
|
669
752
|
return (
|
|
670
|
-
|
|
671
|
-
list(itertools.chain(*[a.get_for_canvas() for a in answers]))
|
|
753
|
+
ca.Answer.CanvasAnswerKind.NUMERICAL_QUESTION,
|
|
754
|
+
list(itertools.chain(*[a.get_for_canvas(single_answer=True) for a in answers]))
|
|
672
755
|
)
|
|
673
756
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
757
|
+
return (
|
|
758
|
+
self.answer_kind,
|
|
759
|
+
list(itertools.chain(*[a.get_for_canvas() for a in answers]))
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def _build_question_ast(self, instance: QuestionInstance) -> ca.Question:
|
|
763
|
+
question_ast = ca.Question(
|
|
764
|
+
body=instance.body,
|
|
765
|
+
explanation=instance.explanation,
|
|
766
|
+
value=instance.value,
|
|
767
|
+
spacing=instance.spacing,
|
|
768
|
+
topic=instance.topic,
|
|
769
|
+
can_be_numerical=instance.can_be_numerical
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# Attach regeneration metadata to the question AST
|
|
773
|
+
question_ast.question_class_name = instance.flags.question_class_name
|
|
774
|
+
question_ast.generation_seed = instance.flags.generation_seed
|
|
775
|
+
question_ast.question_version = instance.flags.question_version
|
|
776
|
+
question_ast.config_params = dict(instance.flags.config_params)
|
|
777
|
+
|
|
778
|
+
return question_ast
|
|
685
779
|
|
|
686
|
-
return (ca.Answer.CanvasAnswerKind.ESSAY, [])
|
|
687
|
-
|
|
688
780
|
def refresh(self, rng_seed=None, *args, **kwargs):
|
|
689
|
-
"
|
|
690
|
-
This base implementation simply resets everything.
|
|
691
|
-
:param rng_seed: random number generator seed to use when regenerating question
|
|
692
|
-
:param *args:
|
|
693
|
-
:param **kwargs:
|
|
694
|
-
:return: bool - True if the generated question is interesting, False otherwise
|
|
695
|
-
"""
|
|
696
|
-
self.answers = {}
|
|
697
|
-
self._components = None # Clear component cache
|
|
698
|
-
# Seed the RNG directly with the provided seed (no offset)
|
|
699
|
-
self.rng.seed(rng_seed)
|
|
700
|
-
# Note: We don't call is_interesting() here because child classes need to
|
|
701
|
-
# generate their workloads first. Child classes should call it at the end
|
|
702
|
-
# of their refresh() and return the result.
|
|
703
|
-
return self.is_interesting() # Default: assume interesting if no override
|
|
781
|
+
raise NotImplementedError("refresh() has been removed; use _build_context().")
|
|
704
782
|
|
|
705
783
|
def is_interesting(self) -> bool:
|
|
706
784
|
return True
|
|
707
785
|
|
|
708
786
|
def get__canvas(self, course: canvasapi.course.Course, quiz : canvasapi.quiz.Quiz, interest_threshold=1.0, *args, **kwargs):
|
|
709
|
-
#
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
question_type, answers = self.
|
|
787
|
+
# Instantiate once for both content and answers
|
|
788
|
+
instance = self.instantiate(**kwargs)
|
|
789
|
+
log.debug("got question instance")
|
|
790
|
+
|
|
791
|
+
questionAST = self._build_question_ast(instance)
|
|
792
|
+
question_type, answers = self._answers_for_canvas(
|
|
793
|
+
instance.answers,
|
|
794
|
+
instance.can_be_numerical
|
|
795
|
+
)
|
|
715
796
|
|
|
716
797
|
# Define a helper function for uploading images to canvas
|
|
717
798
|
def image_upload(img_data) -> str:
|
|
@@ -732,11 +813,8 @@ class Question(abc.ABC):
|
|
|
732
813
|
return f"/courses/{course.id}/files/{f['id']}/preview"
|
|
733
814
|
|
|
734
815
|
# Render AST to HTML for Canvas
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
with timer("ast_render_explanation", question_name=self.name, question_type=self.__class__.__name__):
|
|
739
|
-
explanation_html = questionAST.explanation.render("html", upload_func=image_upload)
|
|
816
|
+
question_html = questionAST.render("html", upload_func=image_upload)
|
|
817
|
+
explanation_html = questionAST.explanation.render("html", upload_func=image_upload)
|
|
740
818
|
|
|
741
819
|
# Build appropriate dictionary to send to canvas
|
|
742
820
|
return {
|
|
@@ -748,13 +826,6 @@ class Question(abc.ABC):
|
|
|
748
826
|
"neutral_comments_html": explanation_html
|
|
749
827
|
}
|
|
750
828
|
|
|
751
|
-
def can_be_numerical(self):
|
|
752
|
-
if (len(self.answers.values()) == 1
|
|
753
|
-
and isinstance(list(self.answers.values())[0], ca.AnswerTypes.Float)
|
|
754
|
-
):
|
|
755
|
-
return True
|
|
756
|
-
return False
|
|
757
|
-
|
|
758
829
|
def _get_registered_name(self) -> str:
|
|
759
830
|
"""
|
|
760
831
|
Get the registered name for this question class.
|