QuizGenerator 0.7.1__py3-none-any.whl → 0.8.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/contentast.py +48 -15
- 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 -328
- 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 -521
- 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 +310 -202
- QuizGenerator/quiz.py +8 -5
- QuizGenerator/regenerate.py +14 -6
- {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/METADATA +30 -2
- {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/RECORD +30 -30
- {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/WHEEL +0 -0
- {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.7.1.dist-info → quizgenerator-0.8.1.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,48 @@ 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):
|
|
385
|
+
AUTO_ENTRY_WARNINGS = True
|
|
344
386
|
"""
|
|
345
387
|
Base class for all quiz questions with cross-format rendering support.
|
|
346
388
|
|
|
347
389
|
CRITICAL: When implementing Question subclasses, ALWAYS use content AST elements
|
|
348
|
-
for all content in
|
|
390
|
+
for all content in _build_body() and _build_explanation() methods.
|
|
349
391
|
|
|
350
392
|
NEVER create manual LaTeX, HTML, or Markdown strings. The content AST system
|
|
351
393
|
ensures consistent rendering across PDF/LaTeX and Canvas/HTML formats.
|
|
352
394
|
|
|
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.
|
|
395
|
+
Primary extension points:
|
|
396
|
+
- build(...): Simple path. Override to generate body + explanation in one place.
|
|
397
|
+
- _build_context/_build_body/_build_explanation: Context path.
|
|
398
|
+
Default _build_context is required for all questions.
|
|
359
399
|
|
|
360
400
|
Required Class Attributes:
|
|
361
401
|
- VERSION (str): Question version number (e.g., "1.0")
|
|
362
402
|
Increment when RNG logic changes to ensure reproducibility
|
|
363
403
|
|
|
364
404
|
Content AST Usage Examples:
|
|
365
|
-
def
|
|
405
|
+
def _build_body(cls, context):
|
|
366
406
|
body = ca.Section()
|
|
367
407
|
answers = []
|
|
368
408
|
body.add_element(ca.Paragraph(["Calculate the matrix:"]))
|
|
@@ -375,7 +415,7 @@ class Question(abc.ABC):
|
|
|
375
415
|
ans = ca.Answer.integer("result", 42, label="Result")
|
|
376
416
|
answers.append(ans)
|
|
377
417
|
body.add_element(ans)
|
|
378
|
-
return body
|
|
418
|
+
return body
|
|
379
419
|
|
|
380
420
|
Common Content AST Elements:
|
|
381
421
|
- ca.Paragraph: Text blocks
|
|
@@ -392,7 +432,7 @@ class Question(abc.ABC):
|
|
|
392
432
|
- Do NOT increment for:
|
|
393
433
|
* Cosmetic changes (formatting, wording)
|
|
394
434
|
* Bug fixes that don't affect answer generation
|
|
395
|
-
* Changes to
|
|
435
|
+
* Changes to _build_explanation() only
|
|
396
436
|
|
|
397
437
|
See existing questions in premade_questions/ for patterns and examples.
|
|
398
438
|
"""
|
|
@@ -480,14 +520,10 @@ class Question(abc.ABC):
|
|
|
480
520
|
|
|
481
521
|
self.extra_attrs = kwargs # clear page, etc.
|
|
482
522
|
|
|
483
|
-
self.answers = {}
|
|
484
523
|
self.possible_variations = float('inf')
|
|
485
524
|
|
|
486
525
|
self.rng_seed_offset = kwargs.get("rng_seed_offset", 0)
|
|
487
526
|
|
|
488
|
-
# Component caching for unified Answer architecture
|
|
489
|
-
self._components: QuestionComponents = None
|
|
490
|
-
|
|
491
527
|
# To be used throughout when generating random things
|
|
492
528
|
self.rng = random.Random()
|
|
493
529
|
|
|
@@ -504,214 +540,296 @@ class Question(abc.ABC):
|
|
|
504
540
|
with open(path_to_yaml) as fid:
|
|
505
541
|
question_dicts = yaml.safe_load_all(fid)
|
|
506
542
|
|
|
507
|
-
|
|
543
|
+
|
|
544
|
+
def instantiate(self, **kwargs) -> QuestionInstance:
|
|
508
545
|
"""
|
|
509
|
-
|
|
510
|
-
:param kwargs:
|
|
511
|
-
:return: (ca.Question) Containing question.
|
|
546
|
+
Instantiate a question once, returning content, answers, and regeneration metadata.
|
|
512
547
|
"""
|
|
513
548
|
# 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
|
|
549
|
+
base_seed = kwargs.get("rng_seed", None)
|
|
550
|
+
build_kwargs = dict(kwargs)
|
|
551
|
+
build_kwargs.pop("rng_seed", None)
|
|
552
|
+
# Include config params so build() implementations can access YAML-provided settings.
|
|
553
|
+
build_kwargs = {**self.config_params, **build_kwargs}
|
|
554
|
+
|
|
555
|
+
# Pre-select any regenerable choices using the base seed
|
|
556
|
+
# This ensures the policy/algorithm stays constant across backoff attempts
|
|
557
|
+
self.pre_instantiate(base_seed, **kwargs)
|
|
531
558
|
|
|
559
|
+
instance = None
|
|
560
|
+
try:
|
|
532
561
|
backoff_counter = 0
|
|
533
562
|
is_interesting = False
|
|
563
|
+
ctx = None
|
|
534
564
|
while not is_interesting:
|
|
535
565
|
# Increment seed for each backoff attempt to maintain deterministic behavior
|
|
536
566
|
current_seed = None if base_seed is None else base_seed + backoff_counter
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
567
|
+
ctx = self._build_context(
|
|
568
|
+
rng_seed=current_seed,
|
|
569
|
+
**self.config_params
|
|
570
|
+
)
|
|
571
|
+
is_interesting = self.is_interesting_ctx(ctx)
|
|
540
572
|
backoff_counter += 1
|
|
541
573
|
|
|
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']
|
|
574
|
+
# Store the actual seed used and question metadata for QR code generation
|
|
575
|
+
actual_seed = None if base_seed is None else base_seed + backoff_counter - 1
|
|
547
576
|
|
|
548
|
-
|
|
549
|
-
body = self.get_body()
|
|
577
|
+
components = self.build(rng_seed=current_seed, context=ctx, **build_kwargs)
|
|
550
578
|
|
|
551
|
-
|
|
552
|
-
|
|
579
|
+
# Collect answers from explicit lists and inline AST
|
|
580
|
+
inline_body_answers = self._collect_answers_from_ast(components.body)
|
|
581
|
+
answers = self._merge_answers(
|
|
582
|
+
components.answers,
|
|
583
|
+
inline_body_answers
|
|
584
|
+
)
|
|
553
585
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
586
|
+
can_be_numerical = self._can_be_numerical_from_answers(answers)
|
|
587
|
+
|
|
588
|
+
if self.AUTO_ENTRY_WARNINGS:
|
|
589
|
+
warnings = self._entry_warnings_from_answers(answers)
|
|
590
|
+
components.body = self._append_entry_warnings(components.body, warnings)
|
|
591
|
+
|
|
592
|
+
config_params = dict(self.config_params)
|
|
593
|
+
if isinstance(ctx, dict) and ctx.get("_config_params"):
|
|
594
|
+
config_params.update(ctx.get("_config_params"))
|
|
595
|
+
|
|
596
|
+
instance = QuestionInstance(
|
|
597
|
+
body=components.body,
|
|
598
|
+
explanation=components.explanation,
|
|
599
|
+
answers=answers,
|
|
600
|
+
answer_kind=self.answer_kind,
|
|
601
|
+
can_be_numerical=can_be_numerical,
|
|
602
|
+
value=self.points_value,
|
|
603
|
+
spacing=self.spacing,
|
|
604
|
+
topic=self.topic,
|
|
605
|
+
flags=RegenerationFlags(
|
|
606
|
+
question_class_name=self._get_registered_name(),
|
|
607
|
+
generation_seed=actual_seed,
|
|
608
|
+
question_version=self.VERSION,
|
|
609
|
+
config_params=config_params
|
|
610
|
+
)
|
|
611
|
+
)
|
|
612
|
+
return instance
|
|
613
|
+
finally:
|
|
614
|
+
self.post_instantiate(instance, **kwargs)
|
|
565
615
|
|
|
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)
|
|
616
|
+
def pre_instantiate(self, base_seed, **kwargs):
|
|
617
|
+
pass
|
|
574
618
|
|
|
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
|
-
"""
|
|
619
|
+
def post_instantiate(self, instance, **kwargs):
|
|
584
620
|
pass
|
|
585
|
-
|
|
586
|
-
def
|
|
621
|
+
|
|
622
|
+
def build(self, *, rng_seed=None, context=None, **kwargs) -> QuestionComponents:
|
|
587
623
|
"""
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
:return: (ca.Section) Containing question explanation or None
|
|
624
|
+
Build question content (body, answers, explanation) for a given seed.
|
|
625
|
+
|
|
626
|
+
This should only generate content; metadata like points/spacing belong in instantiate().
|
|
592
627
|
"""
|
|
593
|
-
|
|
594
|
-
if
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
#
|
|
598
|
-
|
|
599
|
-
|
|
628
|
+
cls = self.__class__
|
|
629
|
+
if context is None:
|
|
630
|
+
context = self._build_context(rng_seed=rng_seed, **kwargs)
|
|
631
|
+
|
|
632
|
+
# 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))
|
|
635
|
+
|
|
636
|
+
# Collect inline answers from both body and explanation.
|
|
637
|
+
inline_body_answers = cls._collect_answers_from_ast(body)
|
|
638
|
+
inline_explanation_answers = cls._collect_answers_from_ast(explanation)
|
|
639
|
+
|
|
640
|
+
answers = cls._merge_answers(
|
|
641
|
+
body_answers,
|
|
642
|
+
explanation_answers,
|
|
643
|
+
inline_body_answers,
|
|
644
|
+
inline_explanation_answers
|
|
600
645
|
)
|
|
601
646
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
647
|
+
return QuestionComponents(
|
|
648
|
+
body=body,
|
|
649
|
+
answers=answers,
|
|
650
|
+
explanation=explanation
|
|
651
|
+
)
|
|
606
652
|
|
|
607
|
-
|
|
608
|
-
Tuple of (body_ast, answers_list)
|
|
653
|
+
def _build_context(self, *, rng_seed=None, **kwargs):
|
|
609
654
|
"""
|
|
610
|
-
|
|
611
|
-
body = self.get_body()
|
|
612
|
-
return body, []
|
|
655
|
+
Build the deterministic context for a question instance.
|
|
613
656
|
|
|
614
|
-
|
|
657
|
+
Override to return a context dict and avoid persistent self.* state.
|
|
615
658
|
"""
|
|
616
|
-
|
|
617
|
-
|
|
659
|
+
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
|
+
}
|
|
618
666
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
"""
|
|
622
|
-
return
|
|
623
|
-
[ca.Text("[Please reach out to your professor for clarification]")]
|
|
624
|
-
), []
|
|
667
|
+
@classmethod
|
|
668
|
+
def is_interesting_ctx(cls, context) -> bool:
|
|
669
|
+
"""Context-aware hook; defaults to existing is_interesting()."""
|
|
670
|
+
return True
|
|
625
671
|
|
|
626
|
-
def
|
|
627
|
-
"""
|
|
628
|
-
|
|
672
|
+
def _build_body(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
|
|
673
|
+
"""Context-aware body builder."""
|
|
674
|
+
raise NotImplementedError("Questions must implement _build_body().")
|
|
629
675
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
""
|
|
633
|
-
# Build body with its answers
|
|
634
|
-
body, body_answers = self._get_body()
|
|
676
|
+
def _build_explanation(self, context) -> ca.Element | Tuple[ca.Element, List[ca.Answer]]:
|
|
677
|
+
"""Context-aware explanation builder."""
|
|
678
|
+
raise NotImplementedError("Questions must implement _build_explanation().")
|
|
635
679
|
|
|
636
|
-
|
|
637
|
-
|
|
680
|
+
@classmethod
|
|
681
|
+
def _collect_answers_from_ast(cls, element: ca.Element) -> List[ca.Answer]:
|
|
682
|
+
"""Traverse AST and collect embedded Answer elements."""
|
|
683
|
+
answers: List[ca.Answer] = []
|
|
684
|
+
|
|
685
|
+
def visit(node):
|
|
686
|
+
if node is None:
|
|
687
|
+
return
|
|
688
|
+
if isinstance(node, ca.Answer):
|
|
689
|
+
answers.append(node)
|
|
690
|
+
return
|
|
691
|
+
if isinstance(node, ca.Table):
|
|
692
|
+
if getattr(node, "headers", None):
|
|
693
|
+
for header in node.headers:
|
|
694
|
+
visit(header)
|
|
695
|
+
for row in node.data:
|
|
696
|
+
for cell in row:
|
|
697
|
+
visit(cell)
|
|
698
|
+
return
|
|
699
|
+
if isinstance(node, ca.TableGroup):
|
|
700
|
+
for _, table in node.tables:
|
|
701
|
+
visit(table)
|
|
702
|
+
return
|
|
703
|
+
if isinstance(node, ca.Container):
|
|
704
|
+
for child in node.elements:
|
|
705
|
+
visit(child)
|
|
706
|
+
return
|
|
707
|
+
if isinstance(node, (list, tuple)):
|
|
708
|
+
for child in node:
|
|
709
|
+
visit(child)
|
|
710
|
+
|
|
711
|
+
visit(element)
|
|
712
|
+
return answers
|
|
638
713
|
|
|
639
|
-
|
|
640
|
-
|
|
714
|
+
@classmethod
|
|
715
|
+
def _merge_answers(cls, *answer_lists: List[ca.Answer]) -> List[ca.Answer]:
|
|
716
|
+
"""Merge answers while preserving order and removing duplicates by key/id."""
|
|
717
|
+
merged: List[ca.Answer] = []
|
|
718
|
+
seen: set[str] = set()
|
|
719
|
+
|
|
720
|
+
for answers in answer_lists:
|
|
721
|
+
for ans in answers:
|
|
722
|
+
key = getattr(ans, "key", None)
|
|
723
|
+
if key is None:
|
|
724
|
+
key = str(id(ans))
|
|
725
|
+
if key in seen:
|
|
726
|
+
continue
|
|
727
|
+
seen.add(key)
|
|
728
|
+
merged.append(ans)
|
|
729
|
+
return merged
|
|
641
730
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
731
|
+
@classmethod
|
|
732
|
+
def _entry_warnings_from_answers(cls, answers: List[ca.Answer]) -> List[str]:
|
|
733
|
+
warnings: List[str] = []
|
|
734
|
+
seen: set[str] = set()
|
|
735
|
+
for answer in answers:
|
|
736
|
+
warning = None
|
|
737
|
+
if hasattr(answer.__class__, "get_entry_warning"):
|
|
738
|
+
warning = answer.__class__.get_entry_warning()
|
|
739
|
+
if not warning:
|
|
740
|
+
continue
|
|
741
|
+
if isinstance(warning, str):
|
|
742
|
+
warning_list = [warning]
|
|
743
|
+
else:
|
|
744
|
+
warning_list = list(warning)
|
|
745
|
+
for item in warning_list:
|
|
746
|
+
if item and item not in seen:
|
|
747
|
+
warnings.append(item)
|
|
748
|
+
seen.add(item)
|
|
749
|
+
return warnings
|
|
750
|
+
|
|
751
|
+
@classmethod
|
|
752
|
+
def _append_entry_warnings(cls, body: ca.Element, warnings: List[str]) -> ca.Element:
|
|
753
|
+
if not warnings:
|
|
754
|
+
return body
|
|
755
|
+
notes_lines = ["**Notes for answer entry**", ""]
|
|
756
|
+
notes_lines.extend(f"- {warning}" for warning in warnings)
|
|
757
|
+
warning_elements = ca.OnlyHtml([ca.Text("\n".join(notes_lines))])
|
|
758
|
+
if isinstance(body, ca.Container):
|
|
759
|
+
body.add_element(warning_elements)
|
|
760
|
+
return body
|
|
761
|
+
return ca.Section([body, warning_elements])
|
|
762
|
+
|
|
763
|
+
@classmethod
|
|
764
|
+
def _can_be_numerical_from_answers(cls, answers: List[ca.Answer]) -> bool:
|
|
765
|
+
return (
|
|
766
|
+
len(answers) == 1
|
|
767
|
+
and isinstance(answers[0], ca.AnswerTypes.Float)
|
|
646
768
|
)
|
|
647
769
|
|
|
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
|
-
)
|
|
770
|
+
@classmethod
|
|
771
|
+
def _normalize_build_output(
|
|
772
|
+
cls,
|
|
773
|
+
result: ca.Element | Tuple[ca.Element, List[ca.Answer]]
|
|
774
|
+
) -> Tuple[ca.Element, List[ca.Answer]]:
|
|
775
|
+
if isinstance(result, tuple):
|
|
776
|
+
body, answers = result
|
|
777
|
+
return body, list(answers or [])
|
|
778
|
+
return result, []
|
|
779
|
+
|
|
780
|
+
def _answers_for_canvas(
|
|
781
|
+
self,
|
|
782
|
+
answers: List[ca.Answer],
|
|
783
|
+
can_be_numerical: bool
|
|
784
|
+
) -> Tuple[ca.Answer.CanvasAnswerKind, List[Dict[str, Any]]]:
|
|
785
|
+
if len(answers) == 0:
|
|
786
|
+
return (ca.Answer.CanvasAnswerKind.ESSAY, [])
|
|
787
|
+
|
|
788
|
+
if can_be_numerical:
|
|
669
789
|
return (
|
|
670
|
-
|
|
671
|
-
list(itertools.chain(*[a.get_for_canvas() for a in answers]))
|
|
790
|
+
ca.Answer.CanvasAnswerKind.NUMERICAL_QUESTION,
|
|
791
|
+
list(itertools.chain(*[a.get_for_canvas(single_answer=True) for a in answers]))
|
|
672
792
|
)
|
|
673
793
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
794
|
+
return (
|
|
795
|
+
self.answer_kind,
|
|
796
|
+
list(itertools.chain(*[a.get_for_canvas() for a in answers]))
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
def _build_question_ast(self, instance: QuestionInstance) -> ca.Question:
|
|
800
|
+
question_ast = ca.Question(
|
|
801
|
+
body=instance.body,
|
|
802
|
+
explanation=instance.explanation,
|
|
803
|
+
value=instance.value,
|
|
804
|
+
spacing=instance.spacing,
|
|
805
|
+
topic=instance.topic,
|
|
806
|
+
can_be_numerical=instance.can_be_numerical
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# Attach regeneration metadata to the question AST
|
|
810
|
+
question_ast.question_class_name = instance.flags.question_class_name
|
|
811
|
+
question_ast.generation_seed = instance.flags.generation_seed
|
|
812
|
+
question_ast.question_version = instance.flags.question_version
|
|
813
|
+
question_ast.config_params = dict(instance.flags.config_params)
|
|
814
|
+
|
|
815
|
+
return question_ast
|
|
685
816
|
|
|
686
|
-
return (ca.Answer.CanvasAnswerKind.ESSAY, [])
|
|
687
|
-
|
|
688
817
|
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
|
|
818
|
+
raise NotImplementedError("refresh() has been removed; use _build_context().")
|
|
704
819
|
|
|
705
820
|
def is_interesting(self) -> bool:
|
|
706
821
|
return True
|
|
707
822
|
|
|
708
823
|
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.
|
|
824
|
+
# Instantiate once for both content and answers
|
|
825
|
+
instance = self.instantiate(**kwargs)
|
|
826
|
+
log.debug("got question instance")
|
|
827
|
+
|
|
828
|
+
questionAST = self._build_question_ast(instance)
|
|
829
|
+
question_type, answers = self._answers_for_canvas(
|
|
830
|
+
instance.answers,
|
|
831
|
+
instance.can_be_numerical
|
|
832
|
+
)
|
|
715
833
|
|
|
716
834
|
# Define a helper function for uploading images to canvas
|
|
717
835
|
def image_upload(img_data) -> str:
|
|
@@ -732,11 +850,8 @@ class Question(abc.ABC):
|
|
|
732
850
|
return f"/courses/{course.id}/files/{f['id']}/preview"
|
|
733
851
|
|
|
734
852
|
# 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)
|
|
853
|
+
question_html = questionAST.render("html", upload_func=image_upload)
|
|
854
|
+
explanation_html = questionAST.explanation.render("html", upload_func=image_upload)
|
|
740
855
|
|
|
741
856
|
# Build appropriate dictionary to send to canvas
|
|
742
857
|
return {
|
|
@@ -748,13 +863,6 @@ class Question(abc.ABC):
|
|
|
748
863
|
"neutral_comments_html": explanation_html
|
|
749
864
|
}
|
|
750
865
|
|
|
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
866
|
def _get_registered_name(self) -> str:
|
|
759
867
|
"""
|
|
760
868
|
Get the registered name for this question class.
|