QuizGenerator 0.9.0__py3-none-any.whl → 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- QuizGenerator/README.md +5 -0
- QuizGenerator/canvas/canvas_interface.py +6 -2
- QuizGenerator/contentast.py +32 -10
- QuizGenerator/generate.py +50 -9
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +21 -18
- QuizGenerator/premade_questions/cst463/models/attention.py +0 -1
- QuizGenerator/premade_questions/cst463/models/cnns.py +0 -1
- QuizGenerator/premade_questions/cst463/models/rnns.py +0 -1
- QuizGenerator/premade_questions/cst463/models/text.py +0 -1
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +20 -1
- QuizGenerator/qrcode_generator.py +116 -54
- QuizGenerator/question.py +30 -6
- QuizGenerator/regenerate.py +23 -9
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.0.dist-info}/METADATA +26 -17
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.0.dist-info}/RECORD +19 -17
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.0.dist-info}/WHEEL +0 -0
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.0.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.0.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/README.md
ADDED
|
@@ -37,8 +37,12 @@ NUM_WORKERS = 4
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
class CanvasInterface:
|
|
40
|
-
def __init__(self, *, prod=False):
|
|
41
|
-
|
|
40
|
+
def __init__(self, *, prod=False, env_path: str | None = None):
|
|
41
|
+
default_env = os.path.join(os.path.expanduser("~"), ".env")
|
|
42
|
+
if env_path and os.path.exists(env_path):
|
|
43
|
+
dotenv.load_dotenv(env_path)
|
|
44
|
+
elif os.path.exists(default_env):
|
|
45
|
+
dotenv.load_dotenv(default_env)
|
|
42
46
|
|
|
43
47
|
self.prod = prod
|
|
44
48
|
if self.prod:
|
QuizGenerator/contentast.py
CHANGED
|
@@ -23,6 +23,21 @@ import numpy as np
|
|
|
23
23
|
|
|
24
24
|
log = logging.getLogger(__name__)
|
|
25
25
|
|
|
26
|
+
_PANDOC_OK = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ensure_pandoc() -> bool:
|
|
30
|
+
global _PANDOC_OK
|
|
31
|
+
if _PANDOC_OK is not None:
|
|
32
|
+
return _PANDOC_OK
|
|
33
|
+
try:
|
|
34
|
+
pypandoc.get_pandoc_version()
|
|
35
|
+
_PANDOC_OK = True
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
_PANDOC_OK = False
|
|
38
|
+
log.warning(f"Pandoc not found or unusable: {exc}")
|
|
39
|
+
return _PANDOC_OK
|
|
40
|
+
|
|
26
41
|
|
|
27
42
|
"""
|
|
28
43
|
Content Abstract Syntax Tree - The core content system for quiz generation.
|
|
@@ -221,6 +236,9 @@ class Leaf(Element):
|
|
|
221
236
|
return html_output.strip()
|
|
222
237
|
|
|
223
238
|
case _:
|
|
239
|
+
if not _ensure_pandoc():
|
|
240
|
+
log.warning(f"Pandoc unavailable; returning raw markdown for {output_format} output.")
|
|
241
|
+
return str_to_convert
|
|
224
242
|
output = pypandoc.convert_text(
|
|
225
243
|
str_to_convert,
|
|
226
244
|
output_format,
|
|
@@ -549,16 +567,18 @@ class Question(Container):
|
|
|
549
567
|
|
|
550
568
|
# Build extra_data dict with regeneration metadata if available
|
|
551
569
|
extra_data = {}
|
|
552
|
-
if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed')
|
|
553
|
-
|
|
554
|
-
):
|
|
555
|
-
if self.question_class_name and self.generation_seed is not None and self.question_version:
|
|
570
|
+
if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed'):
|
|
571
|
+
if self.question_class_name and self.generation_seed is not None:
|
|
556
572
|
extra_data['question_type'] = self.question_class_name
|
|
557
573
|
extra_data['seed'] = self.generation_seed
|
|
558
|
-
|
|
574
|
+
if hasattr(self, 'question_version') and self.question_version:
|
|
575
|
+
extra_data['version'] = self.question_version
|
|
559
576
|
# Include question-specific configuration parameters if available
|
|
560
577
|
if hasattr(self, 'config_params') and self.config_params:
|
|
561
578
|
extra_data['config'] = self.config_params
|
|
579
|
+
# Include context-derived extras if available
|
|
580
|
+
if hasattr(self, 'qr_context_extras') and self.qr_context_extras:
|
|
581
|
+
extra_data['context'] = self.qr_context_extras
|
|
562
582
|
|
|
563
583
|
qr_path = QuestionQRCode.generate_qr_pdf(
|
|
564
584
|
self.question_number,
|
|
@@ -615,16 +635,18 @@ class Question(Container):
|
|
|
615
635
|
|
|
616
636
|
# Build extra_data dict with regeneration metadata if available
|
|
617
637
|
extra_data = {}
|
|
618
|
-
if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed')
|
|
619
|
-
|
|
620
|
-
):
|
|
621
|
-
if self.question_class_name and self.generation_seed is not None and self.question_version:
|
|
638
|
+
if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed'):
|
|
639
|
+
if self.question_class_name and self.generation_seed is not None:
|
|
622
640
|
extra_data['question_type'] = self.question_class_name
|
|
623
641
|
extra_data['seed'] = self.generation_seed
|
|
624
|
-
|
|
642
|
+
if hasattr(self, 'question_version') and self.question_version:
|
|
643
|
+
extra_data['version'] = self.question_version
|
|
625
644
|
# Include question-specific configuration parameters if available
|
|
626
645
|
if hasattr(self, 'config_params') and self.config_params:
|
|
627
646
|
extra_data['config'] = self.config_params
|
|
647
|
+
# Include context-derived extras if available
|
|
648
|
+
if hasattr(self, 'qr_context_extras') and self.qr_context_extras:
|
|
649
|
+
extra_data['context'] = self.qr_context_extras
|
|
628
650
|
|
|
629
651
|
# Generate QR code PNG
|
|
630
652
|
qr_path = QuestionQRCode.generate_qr_pdf(
|
QuizGenerator/generate.py
CHANGED
|
@@ -32,7 +32,18 @@ def parse_args():
|
|
|
32
32
|
|
|
33
33
|
parser.add_argument("--debug", action="store_true", help="Set logging level to debug")
|
|
34
34
|
|
|
35
|
-
parser.add_argument(
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--yaml",
|
|
37
|
+
dest="quiz_yaml",
|
|
38
|
+
default=None,
|
|
39
|
+
help="Path to quiz YAML configuration"
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--quiz_yaml",
|
|
43
|
+
dest="quiz_yaml",
|
|
44
|
+
default=None,
|
|
45
|
+
help=argparse.SUPPRESS # Backwards-compatible alias for --yaml
|
|
46
|
+
)
|
|
36
47
|
parser.add_argument("--seed", type=int, default=None,
|
|
37
48
|
help="Random seed for quiz generation (default: None for random)")
|
|
38
49
|
|
|
@@ -45,7 +56,10 @@ def parse_args():
|
|
|
45
56
|
|
|
46
57
|
# PDF Flags
|
|
47
58
|
parser.add_argument("--num_pdfs", default=0, type=int, help="How many PDF quizzes to create")
|
|
48
|
-
parser.add_argument("--latex", action="store_false", dest="typst", help="Use
|
|
59
|
+
parser.add_argument("--latex", action="store_false", dest="typst", help="Use LaTeX instead of Typst for PDF generation")
|
|
60
|
+
parser.set_defaults(typst=True)
|
|
61
|
+
parser.add_argument("--typst_measurement", action="store_true",
|
|
62
|
+
help="Use Typst measurement for layout optimization (experimental)")
|
|
49
63
|
|
|
50
64
|
# Testing flags
|
|
51
65
|
parser.add_argument("--test_all", type=int, default=0, metavar="N",
|
|
@@ -54,6 +68,8 @@ def parse_args():
|
|
|
54
68
|
help="Only test specific question types by name (use with --test_all)")
|
|
55
69
|
parser.add_argument("--strict", action="store_true",
|
|
56
70
|
help="With --test_all, skip PDF/Canvas generation if any questions fail")
|
|
71
|
+
parser.add_argument("--skip_missing_extras", action="store_true",
|
|
72
|
+
help="With --test_all, skip questions that fail due to missing optional dependencies")
|
|
57
73
|
|
|
58
74
|
subparsers = parser.add_subparsers(dest='command')
|
|
59
75
|
test_parser = subparsers.add_parser("TEST")
|
|
@@ -65,6 +81,10 @@ def parse_args():
|
|
|
65
81
|
log.error("Must provide course_id when pushing to canvas")
|
|
66
82
|
exit(8)
|
|
67
83
|
|
|
84
|
+
if args.test_all <= 0 and not args.quiz_yaml:
|
|
85
|
+
log.error("Must provide --yaml unless using --test_all")
|
|
86
|
+
exit(8)
|
|
87
|
+
|
|
68
88
|
return args
|
|
69
89
|
|
|
70
90
|
|
|
@@ -82,7 +102,8 @@ def test_all_questions(
|
|
|
82
102
|
use_typst: bool = True,
|
|
83
103
|
canvas_course=None,
|
|
84
104
|
strict: bool = False,
|
|
85
|
-
question_filter: list = None
|
|
105
|
+
question_filter: list = None,
|
|
106
|
+
skip_missing_extras: bool = False
|
|
86
107
|
):
|
|
87
108
|
"""
|
|
88
109
|
Test all registered questions by generating N variations of each.
|
|
@@ -129,6 +150,7 @@ def test_all_questions(
|
|
|
129
150
|
print("=" * 70)
|
|
130
151
|
|
|
131
152
|
failed_questions = []
|
|
153
|
+
skipped_questions = []
|
|
132
154
|
successful_questions = []
|
|
133
155
|
# Collect question instances for PDF/Canvas generation
|
|
134
156
|
test_question_instances = []
|
|
@@ -174,6 +196,14 @@ def test_all_questions(
|
|
|
174
196
|
# If we got here, the question works - save the instance
|
|
175
197
|
test_question_instances.append(question)
|
|
176
198
|
|
|
199
|
+
except ImportError as e:
|
|
200
|
+
if skip_missing_extras:
|
|
201
|
+
skipped_questions.append(question_name)
|
|
202
|
+
log.warning(f"Skipping {question_name} due to missing optional dependency: {e}")
|
|
203
|
+
question_failures = []
|
|
204
|
+
break
|
|
205
|
+
tb = traceback.format_exc()
|
|
206
|
+
question_failures.append(f" Variation {variation+1}: Generation failed - {e}\n{tb}")
|
|
177
207
|
except Exception as e:
|
|
178
208
|
tb = traceback.format_exc()
|
|
179
209
|
question_failures.append(f" Variation {variation+1}: Generation failed - {e}\n{tb}")
|
|
@@ -183,6 +213,8 @@ def test_all_questions(
|
|
|
183
213
|
for failure in question_failures:
|
|
184
214
|
print(failure)
|
|
185
215
|
failed_questions.append((question_name, question_failures))
|
|
216
|
+
elif question_name in skipped_questions:
|
|
217
|
+
print(" SKIPPED (missing optional dependency)")
|
|
186
218
|
else:
|
|
187
219
|
print(f" OK ({num_variations}/{num_variations} variations)")
|
|
188
220
|
successful_questions.append(question_name)
|
|
@@ -194,11 +226,17 @@ def test_all_questions(
|
|
|
194
226
|
print(f"Total question types: {total_questions}")
|
|
195
227
|
print(f"Successful: {len(successful_questions)}")
|
|
196
228
|
print(f"Failed: {len(failed_questions)}")
|
|
229
|
+
if skipped_questions:
|
|
230
|
+
print(f"Skipped (missing extras): {len(set(skipped_questions))}")
|
|
197
231
|
|
|
198
232
|
if failed_questions:
|
|
199
233
|
print("\nFailed questions:")
|
|
200
234
|
for name, failures in failed_questions:
|
|
201
235
|
print(f" - {name}: {len(failures)} failures")
|
|
236
|
+
if skipped_questions:
|
|
237
|
+
print("\nSkipped questions (missing extras):")
|
|
238
|
+
for name in sorted(set(skipped_questions)):
|
|
239
|
+
print(f" - {name}")
|
|
202
240
|
|
|
203
241
|
print("=" * 70)
|
|
204
242
|
|
|
@@ -367,14 +405,15 @@ def generate_quiz(
|
|
|
367
405
|
delete_assignment_group=False,
|
|
368
406
|
use_typst=False,
|
|
369
407
|
use_typst_measurement=False,
|
|
370
|
-
base_seed=None
|
|
408
|
+
base_seed=None,
|
|
409
|
+
env_path=None
|
|
371
410
|
):
|
|
372
411
|
|
|
373
412
|
quizzes = Quiz.from_yaml(path_to_quiz_yaml)
|
|
374
413
|
|
|
375
414
|
# Handle Canvas uploads with shared assignment group
|
|
376
415
|
if num_canvas > 0:
|
|
377
|
-
canvas_interface = CanvasInterface(prod=use_prod)
|
|
416
|
+
canvas_interface = CanvasInterface(prod=use_prod, env_path=env_path)
|
|
378
417
|
canvas_course = canvas_interface.get_course(course_id=course_id)
|
|
379
418
|
|
|
380
419
|
# Create assignment group once, with delete flag if specified
|
|
@@ -448,7 +487,7 @@ def main():
|
|
|
448
487
|
# Set up Canvas course if course_id provided
|
|
449
488
|
canvas_course = None
|
|
450
489
|
if args.course_id:
|
|
451
|
-
canvas_interface = CanvasInterface(prod=args.prod)
|
|
490
|
+
canvas_interface = CanvasInterface(prod=args.prod, env_path=args.env)
|
|
452
491
|
canvas_course = canvas_interface.get_course(course_id=args.course_id)
|
|
453
492
|
|
|
454
493
|
success = test_all_questions(
|
|
@@ -457,7 +496,8 @@ def main():
|
|
|
457
496
|
use_typst=getattr(args, 'typst', True),
|
|
458
497
|
canvas_course=canvas_course,
|
|
459
498
|
strict=args.strict,
|
|
460
|
-
question_filter=args.test_questions
|
|
499
|
+
question_filter=args.test_questions,
|
|
500
|
+
skip_missing_extras=args.skip_missing_extras
|
|
461
501
|
)
|
|
462
502
|
exit(0 if success else 1)
|
|
463
503
|
|
|
@@ -471,9 +511,10 @@ def main():
|
|
|
471
511
|
use_prod=args.prod,
|
|
472
512
|
course_id=args.course_id,
|
|
473
513
|
delete_assignment_group=getattr(args, 'delete_assignment_group', False),
|
|
474
|
-
use_typst=getattr(args, 'typst',
|
|
514
|
+
use_typst=getattr(args, 'typst', True),
|
|
475
515
|
use_typst_measurement=getattr(args, 'typst_measurement', False),
|
|
476
|
-
base_seed=getattr(args, 'seed', None)
|
|
516
|
+
base_seed=getattr(args, 'seed', None),
|
|
517
|
+
env_path=args.env
|
|
477
518
|
)
|
|
478
519
|
|
|
479
520
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
disable_existing_loggers: false
|
|
3
|
+
|
|
4
|
+
formatters:
|
|
5
|
+
standard:
|
|
6
|
+
format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
7
|
+
datefmt: '%Y-%m-%d %H:%M:%S'
|
|
8
|
+
|
|
9
|
+
detailed:
|
|
10
|
+
format: '%(asctime)s - %(name)s - %(levelname)s - %(module)s - %(funcName)s:%(lineno)d - %(message)s'
|
|
11
|
+
datefmt: '%Y-%m-%d %H:%M:%S'
|
|
12
|
+
|
|
13
|
+
simple:
|
|
14
|
+
format: '%(levelname)s - %(message)s'
|
|
15
|
+
|
|
16
|
+
handlers:
|
|
17
|
+
console:
|
|
18
|
+
class: logging.StreamHandler
|
|
19
|
+
level: INFO
|
|
20
|
+
formatter: standard
|
|
21
|
+
stream: ext://sys.stdout
|
|
22
|
+
|
|
23
|
+
file:
|
|
24
|
+
class: logging.FileHandler
|
|
25
|
+
level: INFO
|
|
26
|
+
formatter: detailed
|
|
27
|
+
filename: ${LOG_FILE:-teachingtools.log}
|
|
28
|
+
mode: a
|
|
29
|
+
|
|
30
|
+
error_file:
|
|
31
|
+
class: logging.FileHandler
|
|
32
|
+
level: ERROR
|
|
33
|
+
formatter: detailed
|
|
34
|
+
filename: ${ERROR_LOG_FILE:-teachingtools_errors.log}
|
|
35
|
+
mode: a
|
|
36
|
+
|
|
37
|
+
loggers:
|
|
38
|
+
QuizGenerator:
|
|
39
|
+
level: INFO
|
|
40
|
+
handlers: [console, file]
|
|
41
|
+
propagate: false
|
|
42
|
+
|
|
43
|
+
lms_interface:
|
|
44
|
+
level: INFO
|
|
45
|
+
handlers: [console, file]
|
|
46
|
+
propagate: false
|
|
47
|
+
|
|
48
|
+
canvasapi:
|
|
49
|
+
level: WARNING
|
|
50
|
+
handlers: [console]
|
|
51
|
+
propagate: false
|
|
52
|
+
|
|
53
|
+
root:
|
|
54
|
+
level: INFO
|
|
55
|
+
handlers: [console, file, error_file]
|
|
@@ -198,7 +198,8 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
198
198
|
@classmethod
|
|
199
199
|
def _generate_data(cls, context):
|
|
200
200
|
"""Generate regression data with continuous target values."""
|
|
201
|
-
context.data =
|
|
201
|
+
context.data = {}
|
|
202
|
+
context["data"] = []
|
|
202
203
|
|
|
203
204
|
for _ in range(context.num_samples):
|
|
204
205
|
sample = {}
|
|
@@ -227,7 +228,7 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
227
228
|
for _ in range(context.num_output_vars)
|
|
228
229
|
]
|
|
229
230
|
|
|
230
|
-
context
|
|
231
|
+
context["data"].append(sample)
|
|
231
232
|
|
|
232
233
|
@classmethod
|
|
233
234
|
def _calculate_losses(cls, context):
|
|
@@ -235,7 +236,7 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
235
236
|
context.individual_losses = []
|
|
236
237
|
total_loss = 0.0
|
|
237
238
|
|
|
238
|
-
for sample in context
|
|
239
|
+
for sample in context["data"]:
|
|
239
240
|
if context.num_output_vars == 1:
|
|
240
241
|
# Single output MSE: (y - p)^2
|
|
241
242
|
loss = (sample['true_values'] - sample['predictions']) ** 2
|
|
@@ -282,7 +283,7 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
282
283
|
headers.append("loss")
|
|
283
284
|
|
|
284
285
|
rows = []
|
|
285
|
-
for i, sample in enumerate(context
|
|
286
|
+
for i, sample in enumerate(context["data"]):
|
|
286
287
|
row = {}
|
|
287
288
|
|
|
288
289
|
# Input features as vector
|
|
@@ -315,7 +316,7 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
315
316
|
"""Show step-by-step MSE calculations."""
|
|
316
317
|
steps = ca.Section()
|
|
317
318
|
|
|
318
|
-
for i, sample in enumerate(context
|
|
319
|
+
for i, sample in enumerate(context["data"]):
|
|
319
320
|
steps.add_element(ca.Paragraph([f"Sample {i+1}:"]))
|
|
320
321
|
|
|
321
322
|
if context.num_output_vars == 1:
|
|
@@ -364,7 +365,7 @@ class LossQuestion_Linear(LossQuestion):
|
|
|
364
365
|
headers.append("loss")
|
|
365
366
|
|
|
366
367
|
rows = []
|
|
367
|
-
for i, sample in enumerate(context
|
|
368
|
+
for i, sample in enumerate(context["data"]):
|
|
368
369
|
row = []
|
|
369
370
|
|
|
370
371
|
# Input features
|
|
@@ -416,7 +417,8 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
416
417
|
@classmethod
|
|
417
418
|
def _generate_data(cls, context):
|
|
418
419
|
"""Generate binary classification data."""
|
|
419
|
-
context.data =
|
|
420
|
+
context.data = {}
|
|
421
|
+
context["data"] = []
|
|
420
422
|
|
|
421
423
|
for _ in range(context.num_samples):
|
|
422
424
|
sample = {}
|
|
@@ -433,7 +435,7 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
433
435
|
# Generate predicted probabilities (between 0 and 1, rounded to 3 decimal places)
|
|
434
436
|
sample['predictions'] = round(context.rng.uniform(0.1, 0.9), 3) # Avoid extreme values
|
|
435
437
|
|
|
436
|
-
context
|
|
438
|
+
context["data"].append(sample)
|
|
437
439
|
|
|
438
440
|
@classmethod
|
|
439
441
|
def _calculate_losses(cls, context):
|
|
@@ -441,7 +443,7 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
441
443
|
context.individual_losses = []
|
|
442
444
|
total_loss = 0.0
|
|
443
445
|
|
|
444
|
-
for sample in context
|
|
446
|
+
for sample in context["data"]:
|
|
445
447
|
y = sample['true_values']
|
|
446
448
|
p = sample['predictions']
|
|
447
449
|
|
|
@@ -475,7 +477,7 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
475
477
|
headers = ["x", "y", "p", "loss"]
|
|
476
478
|
|
|
477
479
|
rows = []
|
|
478
|
-
for i, sample in enumerate(context
|
|
480
|
+
for i, sample in enumerate(context["data"]):
|
|
479
481
|
row = {}
|
|
480
482
|
|
|
481
483
|
# Input features as vector
|
|
@@ -500,7 +502,7 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
500
502
|
"""Show step-by-step log-loss calculations."""
|
|
501
503
|
steps = ca.Section()
|
|
502
504
|
|
|
503
|
-
for i, sample in enumerate(context
|
|
505
|
+
for i, sample in enumerate(context["data"]):
|
|
504
506
|
y = sample['true_values']
|
|
505
507
|
p = sample['predictions']
|
|
506
508
|
loss = context.individual_losses[i]
|
|
@@ -522,7 +524,7 @@ class LossQuestion_Logistic(LossQuestion):
|
|
|
522
524
|
headers = ["x_0", "x_1", "y", "p", "loss"]
|
|
523
525
|
|
|
524
526
|
rows = []
|
|
525
|
-
for i, sample in enumerate(context
|
|
527
|
+
for i, sample in enumerate(context["data"]):
|
|
526
528
|
row = []
|
|
527
529
|
|
|
528
530
|
# Input features
|
|
@@ -585,7 +587,8 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
585
587
|
@classmethod
|
|
586
588
|
def _generate_data(cls, context):
|
|
587
589
|
"""Generate multi-class classification data."""
|
|
588
|
-
context.data =
|
|
590
|
+
context.data = {}
|
|
591
|
+
context["data"] = []
|
|
589
592
|
|
|
590
593
|
for _ in range(context.num_samples):
|
|
591
594
|
sample = {}
|
|
@@ -606,7 +609,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
606
609
|
prob_sum = sum(raw_probs)
|
|
607
610
|
sample['predictions'] = [round(p / prob_sum, 3) for p in raw_probs]
|
|
608
611
|
|
|
609
|
-
context
|
|
612
|
+
context["data"].append(sample)
|
|
610
613
|
|
|
611
614
|
@classmethod
|
|
612
615
|
def _calculate_losses(cls, context):
|
|
@@ -614,7 +617,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
614
617
|
context.individual_losses = []
|
|
615
618
|
total_loss = 0.0
|
|
616
619
|
|
|
617
|
-
for sample in context
|
|
620
|
+
for sample in context["data"]:
|
|
618
621
|
y_vec = sample['true_values']
|
|
619
622
|
p_vec = sample['predictions']
|
|
620
623
|
|
|
@@ -645,7 +648,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
645
648
|
headers = ["x", "y", "p", "loss"]
|
|
646
649
|
|
|
647
650
|
rows = []
|
|
648
|
-
for i, sample in enumerate(context
|
|
651
|
+
for i, sample in enumerate(context["data"]):
|
|
649
652
|
row = {}
|
|
650
653
|
|
|
651
654
|
# Input features as vector
|
|
@@ -672,7 +675,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
672
675
|
"""Show step-by-step cross-entropy calculations."""
|
|
673
676
|
steps = ca.Section()
|
|
674
677
|
|
|
675
|
-
for i, sample in enumerate(context
|
|
678
|
+
for i, sample in enumerate(context["data"]):
|
|
676
679
|
y_vec = sample['true_values']
|
|
677
680
|
p_vec = sample['predictions']
|
|
678
681
|
loss = context.individual_losses[i]
|
|
@@ -710,7 +713,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
|
|
|
710
713
|
headers = ["x_0", "x_1", "y", "p", "loss"]
|
|
711
714
|
|
|
712
715
|
rows = []
|
|
713
|
-
for i, sample in enumerate(context
|
|
716
|
+
for i, sample in enumerate(context["data"]):
|
|
714
717
|
row = []
|
|
715
718
|
|
|
716
719
|
# Input features
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import abc
|
|
2
4
|
import logging
|
|
3
5
|
import math
|
|
4
6
|
import random
|
|
5
|
-
|
|
7
|
+
try:
|
|
8
|
+
import keras
|
|
9
|
+
except ImportError as exc:
|
|
10
|
+
keras = None
|
|
11
|
+
_KERAS_IMPORT_ERROR = exc
|
|
12
|
+
else:
|
|
13
|
+
_KERAS_IMPORT_ERROR = None
|
|
6
14
|
import numpy as np
|
|
7
15
|
from typing import List, Tuple
|
|
8
16
|
|
|
@@ -14,6 +22,14 @@ log = logging.getLogger(__name__)
|
|
|
14
22
|
|
|
15
23
|
|
|
16
24
|
class WeightCounting(Question, abc.ABC):
|
|
25
|
+
@staticmethod
|
|
26
|
+
def _ensure_keras():
|
|
27
|
+
if keras is None:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"Keras is required for CST463 model questions. "
|
|
30
|
+
"Install with: pip install 'QuizGenerator[cst463]'"
|
|
31
|
+
) from _KERAS_IMPORT_ERROR
|
|
32
|
+
|
|
17
33
|
@staticmethod
|
|
18
34
|
@abc.abstractmethod
|
|
19
35
|
def get_model(rng: random.Random) -> keras.Model:
|
|
@@ -21,6 +37,7 @@ class WeightCounting(Question, abc.ABC):
|
|
|
21
37
|
|
|
22
38
|
@staticmethod
|
|
23
39
|
def model_to_python(model: keras.Model, fields=None, include_input=True):
|
|
40
|
+
WeightCounting._ensure_keras()
|
|
24
41
|
if fields is None:
|
|
25
42
|
fields = []
|
|
26
43
|
|
|
@@ -177,6 +194,7 @@ class WeightCounting_CNN(WeightCounting):
|
|
|
177
194
|
|
|
178
195
|
@staticmethod
|
|
179
196
|
def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
|
|
197
|
+
WeightCounting._ensure_keras()
|
|
180
198
|
input_size = rng.choice(np.arange(28, 32))
|
|
181
199
|
cnn_num_filters = rng.choice(2 ** np.arange(8))
|
|
182
200
|
cnn_kernel_size = rng.choice(1 + np.arange(10))
|
|
@@ -211,6 +229,7 @@ class WeightCounting_CNN(WeightCounting):
|
|
|
211
229
|
class WeightCounting_RNN(WeightCounting):
|
|
212
230
|
@staticmethod
|
|
213
231
|
def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
|
|
232
|
+
WeightCounting._ensure_keras()
|
|
214
233
|
timesteps = int(rng.choice(np.arange(20, 41)))
|
|
215
234
|
feature_size = int(rng.choice(np.arange(8, 65)))
|
|
216
235
|
|
|
@@ -9,17 +9,21 @@ The QR codes include encrypted data that allows regenerating question answers
|
|
|
9
9
|
without storing separate files, enabling efficient grading of randomized exams.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
12
14
|
import json
|
|
13
|
-
import tempfile
|
|
14
15
|
import logging
|
|
15
16
|
import os
|
|
16
|
-
import
|
|
17
|
+
import tempfile
|
|
18
|
+
import zlib
|
|
19
|
+
from datetime import datetime
|
|
17
20
|
from io import BytesIO
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
from typing import Optional, Dict, Any
|
|
20
23
|
|
|
21
24
|
import segno
|
|
22
25
|
from cryptography.fernet import Fernet
|
|
26
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
27
|
|
|
24
28
|
log = logging.getLogger(__name__)
|
|
25
29
|
|
|
@@ -38,6 +42,22 @@ class QuestionQRCode:
|
|
|
38
42
|
|
|
39
43
|
# Error correction level: M = 15% recovery (balanced for compact encoded data)
|
|
40
44
|
ERROR_CORRECTION = 'M'
|
|
45
|
+
_generated_key: Optional[bytes] = None
|
|
46
|
+
V2_PREFIX = "v2."
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _persist_generated_key(cls, key: bytes) -> None:
|
|
50
|
+
try:
|
|
51
|
+
os.makedirs("out/keys", exist_ok=True)
|
|
52
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
53
|
+
path = os.path.join("out", "keys", f"quiz_encryption_key-{timestamp}.log")
|
|
54
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
55
|
+
handle.write("QUIZ_ENCRYPTION_KEY=")
|
|
56
|
+
handle.write(key.decode("ascii"))
|
|
57
|
+
handle.write("\n")
|
|
58
|
+
log.warning(f"Wrote generated QUIZ_ENCRYPTION_KEY to {path}")
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
log.warning(f"Failed to persist generated QUIZ_ENCRYPTION_KEY: {exc}")
|
|
41
61
|
|
|
42
62
|
@classmethod
|
|
43
63
|
def get_encryption_key(cls) -> bytes:
|
|
@@ -61,67 +81,95 @@ class QuestionQRCode:
|
|
|
61
81
|
"QUIZ_ENCRYPTION_KEY not set! Generating temporary key. "
|
|
62
82
|
"Set this environment variable for production use!"
|
|
63
83
|
)
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
if cls._generated_key is None:
|
|
85
|
+
cls._generated_key = Fernet.generate_key()
|
|
86
|
+
os.environ["QUIZ_ENCRYPTION_KEY"] = cls._generated_key.decode("ascii")
|
|
87
|
+
cls._persist_generated_key(cls._generated_key)
|
|
88
|
+
return cls._generated_key
|
|
66
89
|
|
|
67
90
|
# Key should be stored as base64 string in env
|
|
68
91
|
return key_str.encode()
|
|
69
92
|
|
|
70
93
|
@classmethod
|
|
71
|
-
def
|
|
94
|
+
def _derive_aead_key(cls, key: bytes) -> bytes:
|
|
95
|
+
return hashlib.sha256(key).digest()
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def _encrypt_v2(cls, payload: Dict[str, Any], *, key: Optional[bytes] = None) -> str:
|
|
99
|
+
if key is None:
|
|
100
|
+
key = cls.get_encryption_key()
|
|
101
|
+
aead_key = cls._derive_aead_key(key)
|
|
102
|
+
aesgcm = AESGCM(aead_key)
|
|
103
|
+
nonce = os.urandom(12)
|
|
104
|
+
json_bytes = json.dumps(payload, separators=(',', ':'), ensure_ascii=False).encode("utf-8")
|
|
105
|
+
compressed = zlib.compress(json_bytes)
|
|
106
|
+
ciphertext = aesgcm.encrypt(nonce, compressed, None)
|
|
107
|
+
token = base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
|
|
108
|
+
return f"{cls.V2_PREFIX}{token}"
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def _decrypt_v2(cls, encrypted_data: str, *, key: Optional[bytes] = None) -> Dict[str, Any]:
|
|
112
|
+
if not encrypted_data.startswith(cls.V2_PREFIX):
|
|
113
|
+
raise ValueError("Not a v2 payload")
|
|
114
|
+
if key is None:
|
|
115
|
+
key = cls.get_encryption_key()
|
|
116
|
+
aead_key = cls._derive_aead_key(key)
|
|
117
|
+
token = encrypted_data[len(cls.V2_PREFIX):]
|
|
118
|
+
raw = base64.urlsafe_b64decode(token.encode("ascii"))
|
|
119
|
+
nonce, ciphertext = raw[:12], raw[12:]
|
|
120
|
+
aesgcm = AESGCM(aead_key)
|
|
121
|
+
compressed = aesgcm.decrypt(nonce, ciphertext, None)
|
|
122
|
+
json_bytes = zlib.decompress(compressed)
|
|
123
|
+
return json.loads(json_bytes.decode("utf-8"))
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def encrypt_question_data(cls, question_type: str, seed: int, version: Optional[str] = None,
|
|
72
127
|
config: Optional[Dict[str, Any]] = None,
|
|
128
|
+
context: Optional[Dict[str, Any]] = None,
|
|
129
|
+
points_value: Optional[float] = None,
|
|
73
130
|
key: Optional[bytes] = None) -> str:
|
|
74
131
|
"""
|
|
75
|
-
Encode question regeneration data
|
|
132
|
+
Encode question regeneration data for QR embedding.
|
|
76
133
|
|
|
77
134
|
Args:
|
|
78
135
|
question_type: Class name of the question (e.g., "VectorDotProduct")
|
|
79
136
|
seed: Random seed used to generate this specific question
|
|
80
|
-
version:
|
|
137
|
+
version: Optional question version string
|
|
81
138
|
config: Optional dictionary of configuration parameters
|
|
139
|
+
context: Optional dictionary of context extras
|
|
140
|
+
points_value: Optional points value (for redundancy)
|
|
82
141
|
key: Encryption key (uses environment key if None)
|
|
83
142
|
|
|
84
143
|
Returns:
|
|
85
|
-
str: Base64-encoded
|
|
144
|
+
str: Base64-encoded encrypted payload with v2 prefix
|
|
86
145
|
|
|
87
146
|
Example:
|
|
88
147
|
>>> encrypted = QuestionQRCode.encrypt_question_data("VectorDot", 12345, "1.0")
|
|
89
148
|
>>> print(encrypted)
|
|
90
149
|
'VmVjdG9yRG90OjEyMzQ1OjEuMA=='
|
|
91
150
|
"""
|
|
92
|
-
|
|
151
|
+
payload: Dict[str, Any] = {
|
|
152
|
+
"t": question_type,
|
|
153
|
+
"s": seed,
|
|
154
|
+
}
|
|
155
|
+
if points_value is not None:
|
|
156
|
+
payload["p"] = points_value
|
|
93
157
|
if config:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if key is None:
|
|
103
|
-
key = cls.get_encryption_key()
|
|
104
|
-
|
|
105
|
-
if key:
|
|
106
|
-
# Use first 16 bytes of key for simple XOR obfuscation
|
|
107
|
-
key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
|
|
108
|
-
# XOR each byte with repeating key pattern
|
|
109
|
-
obfuscated = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(data_bytes))
|
|
110
|
-
else:
|
|
111
|
-
obfuscated = data_bytes
|
|
112
|
-
|
|
113
|
-
# Base64 encode for compact representation
|
|
114
|
-
encoded = base64.urlsafe_b64encode(obfuscated).decode('utf-8')
|
|
115
|
-
|
|
116
|
-
log.debug(f"Encoded question data: {question_type} seed={seed} version={version} ({len(encoded)} chars)")
|
|
117
|
-
|
|
158
|
+
payload["c"] = config
|
|
159
|
+
if context:
|
|
160
|
+
payload["x"] = context
|
|
161
|
+
if version:
|
|
162
|
+
payload["v"] = version
|
|
163
|
+
|
|
164
|
+
encoded = cls._encrypt_v2(payload, key=key)
|
|
165
|
+
log.debug(f"Encoded question data v2: {question_type} seed={seed} ({len(encoded)} chars)")
|
|
118
166
|
return encoded
|
|
119
167
|
|
|
120
168
|
@classmethod
|
|
121
169
|
def decrypt_question_data(cls, encrypted_data: str,
|
|
122
170
|
key: Optional[bytes] = None) -> Dict[str, Any]:
|
|
123
171
|
"""
|
|
124
|
-
Decode question regeneration data from QR code.
|
|
172
|
+
Decode question regeneration data from QR code (v2 preferred, v1 fallback).
|
|
125
173
|
|
|
126
174
|
Args:
|
|
127
175
|
encrypted_data: Base64-encoded (optionally XOR-obfuscated) string from QR code
|
|
@@ -142,10 +190,24 @@ class QuestionQRCode:
|
|
|
142
190
|
key = cls.get_encryption_key()
|
|
143
191
|
|
|
144
192
|
try:
|
|
145
|
-
|
|
193
|
+
if encrypted_data.startswith(cls.V2_PREFIX):
|
|
194
|
+
payload = cls._decrypt_v2(encrypted_data, key=key)
|
|
195
|
+
result = {
|
|
196
|
+
"question_type": payload.get("t"),
|
|
197
|
+
"seed": int(payload.get("s")),
|
|
198
|
+
}
|
|
199
|
+
if "v" in payload:
|
|
200
|
+
result["version"] = payload.get("v")
|
|
201
|
+
if "c" in payload:
|
|
202
|
+
result["config"] = payload.get("c")
|
|
203
|
+
if "x" in payload:
|
|
204
|
+
result["context"] = payload.get("x")
|
|
205
|
+
if "p" in payload:
|
|
206
|
+
result["points"] = payload.get("p")
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
# V1 fallback (XOR obfuscation)
|
|
146
210
|
obfuscated = base64.urlsafe_b64decode(encrypted_data.encode())
|
|
147
|
-
|
|
148
|
-
# Reverse XOR obfuscation if key is provided
|
|
149
211
|
if key:
|
|
150
212
|
key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
|
|
151
213
|
data_bytes = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(obfuscated))
|
|
@@ -153,29 +215,26 @@ class QuestionQRCode:
|
|
|
153
215
|
data_bytes = obfuscated
|
|
154
216
|
|
|
155
217
|
data_str = data_bytes.decode('utf-8')
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if len(parts) < 3:
|
|
160
|
-
raise ValueError(f"Invalid encoded data format: expected at least 3 parts, got {len(parts)}")
|
|
218
|
+
parts = data_str.split(':', 3)
|
|
219
|
+
if len(parts) < 2:
|
|
220
|
+
raise ValueError(f"Invalid encoded data format: expected at least 2 parts, got {len(parts)}")
|
|
161
221
|
|
|
162
222
|
question_type = parts[0]
|
|
163
223
|
seed_str = parts[1]
|
|
164
|
-
version = parts[2]
|
|
224
|
+
version = parts[2] if len(parts) >= 3 else None
|
|
165
225
|
|
|
166
226
|
result = {
|
|
167
227
|
"question_type": question_type,
|
|
168
228
|
"seed": int(seed_str),
|
|
169
|
-
"version": version
|
|
170
229
|
}
|
|
230
|
+
if version:
|
|
231
|
+
result["version"] = version
|
|
171
232
|
|
|
172
|
-
# Parse config JSON if present
|
|
173
233
|
if len(parts) == 4:
|
|
174
234
|
try:
|
|
175
235
|
result["config"] = json.loads(parts[3])
|
|
176
236
|
except json.JSONDecodeError as e:
|
|
177
237
|
log.warning(f"Failed to parse config JSON: {e}")
|
|
178
|
-
# Continue without config rather than failing
|
|
179
238
|
|
|
180
239
|
return result
|
|
181
240
|
|
|
@@ -215,24 +274,27 @@ class QuestionQRCode:
|
|
|
215
274
|
"""
|
|
216
275
|
data = {
|
|
217
276
|
"q": question_number,
|
|
218
|
-
"
|
|
277
|
+
"p": points_value
|
|
219
278
|
}
|
|
220
279
|
|
|
221
280
|
# If question regeneration data provided, encrypt it
|
|
222
|
-
if all(k in extra_data for k in ['question_type', 'seed'
|
|
223
|
-
# Include config in encrypted data if present
|
|
281
|
+
if all(k in extra_data for k in ['question_type', 'seed']):
|
|
224
282
|
config = extra_data.get('config', {})
|
|
283
|
+
context = extra_data.get('context', {})
|
|
225
284
|
encrypted = cls.encrypt_question_data(
|
|
226
285
|
extra_data['question_type'],
|
|
227
286
|
extra_data['seed'],
|
|
228
|
-
extra_data
|
|
229
|
-
config=config
|
|
287
|
+
extra_data.get('version'),
|
|
288
|
+
config=config,
|
|
289
|
+
context=context,
|
|
290
|
+
points_value=points_value
|
|
230
291
|
)
|
|
231
292
|
data['s'] = encrypted
|
|
232
293
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
294
|
+
extra_data = {
|
|
295
|
+
k: v for k, v in extra_data.items()
|
|
296
|
+
if k not in ['question_type', 'seed', 'version', 'config', 'context']
|
|
297
|
+
}
|
|
236
298
|
|
|
237
299
|
# Add any remaining extra metadata
|
|
238
300
|
data.update(extra_data)
|
QuizGenerator/question.py
CHANGED
|
@@ -45,6 +45,7 @@ class RegenerationFlags:
|
|
|
45
45
|
generation_seed: Optional[int]
|
|
46
46
|
question_version: str
|
|
47
47
|
config_params: Dict[str, Any]
|
|
48
|
+
context_extras: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -94,7 +95,9 @@ class QuestionContext:
|
|
|
94
95
|
return self.rng_seed
|
|
95
96
|
if key == "rng":
|
|
96
97
|
return self.rng
|
|
97
|
-
|
|
98
|
+
if hasattr(self.data, "get"):
|
|
99
|
+
return self.data.get(key, default)
|
|
100
|
+
return default
|
|
98
101
|
|
|
99
102
|
def __contains__(self, key: object) -> bool:
|
|
100
103
|
if key in ("rng_seed", "rng"):
|
|
@@ -261,7 +264,12 @@ class QuestionRegistry:
|
|
|
261
264
|
# Load modules from the current directory
|
|
262
265
|
for _, module_name, _ in pkgutil.iter_modules([str(path)]):
|
|
263
266
|
# Import the module
|
|
264
|
-
|
|
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
|
+
)
|
|
265
273
|
|
|
266
274
|
# Recursively load modules from subdirectories
|
|
267
275
|
for subdir in path.iterdir():
|
|
@@ -688,6 +696,20 @@ class Question(abc.ABC):
|
|
|
688
696
|
if isinstance(ctx, dict) and ctx.get("_config_params"):
|
|
689
697
|
config_params.update(ctx.get("_config_params"))
|
|
690
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
|
+
|
|
691
713
|
instance = QuestionInstance(
|
|
692
714
|
body=components.body,
|
|
693
715
|
explanation=components.explanation,
|
|
@@ -701,7 +723,8 @@ class Question(abc.ABC):
|
|
|
701
723
|
question_class_name=self._get_registered_name(),
|
|
702
724
|
generation_seed=actual_seed,
|
|
703
725
|
question_version=self.VERSION,
|
|
704
|
-
config_params=config_params
|
|
726
|
+
config_params=config_params,
|
|
727
|
+
context_extras=context_extras
|
|
705
728
|
)
|
|
706
729
|
)
|
|
707
730
|
return instance
|
|
@@ -932,6 +955,7 @@ class Question(abc.ABC):
|
|
|
932
955
|
question_ast.generation_seed = instance.flags.generation_seed
|
|
933
956
|
question_ast.question_version = instance.flags.question_version
|
|
934
957
|
question_ast.config_params = dict(instance.flags.config_params)
|
|
958
|
+
question_ast.qr_context_extras = dict(instance.flags.context_extras)
|
|
935
959
|
|
|
936
960
|
return question_ast
|
|
937
961
|
|
|
@@ -1014,11 +1038,11 @@ class QuestionGroup():
|
|
|
1014
1038
|
|
|
1015
1039
|
def instantiate(self, *args, **kwargs):
|
|
1016
1040
|
|
|
1017
|
-
#
|
|
1018
|
-
random.
|
|
1041
|
+
# Use a local RNG to avoid global side effects.
|
|
1042
|
+
rng = random.Random(kwargs.get("rng_seed", None))
|
|
1019
1043
|
|
|
1020
1044
|
if not self.pick_once or self._current_question is None:
|
|
1021
|
-
self._current_question =
|
|
1045
|
+
self._current_question = rng.choice(self.questions)
|
|
1022
1046
|
|
|
1023
1047
|
def __getattr__(self, name):
|
|
1024
1048
|
if self._current_question is None or name == "generate":
|
QuizGenerator/regenerate.py
CHANGED
|
@@ -194,7 +194,9 @@ def regenerate_question_answer(
|
|
|
194
194
|
}
|
|
195
195
|
"""
|
|
196
196
|
question_num = qr_data.get('q')
|
|
197
|
-
points = qr_data.get('
|
|
197
|
+
points = qr_data.get('p')
|
|
198
|
+
if points is None:
|
|
199
|
+
points = qr_data.get('pts')
|
|
198
200
|
|
|
199
201
|
if question_num is None or points is None:
|
|
200
202
|
log.error("QR code missing required fields 'q' or 'pts'")
|
|
@@ -219,8 +221,9 @@ def regenerate_question_answer(
|
|
|
219
221
|
|
|
220
222
|
question_type = regen_data['question_type']
|
|
221
223
|
seed = regen_data['seed']
|
|
222
|
-
version = regen_data
|
|
224
|
+
version = regen_data.get('version')
|
|
223
225
|
config = regen_data.get('config', {})
|
|
226
|
+
context_extras = regen_data.get('context', {})
|
|
224
227
|
|
|
225
228
|
result['question_type'] = question_type
|
|
226
229
|
result['seed'] = seed
|
|
@@ -228,7 +231,10 @@ def regenerate_question_answer(
|
|
|
228
231
|
if config:
|
|
229
232
|
result['config'] = config
|
|
230
233
|
|
|
231
|
-
|
|
234
|
+
if version:
|
|
235
|
+
log.info(f"Question {question_num}: {question_type} (seed={seed}, version={version})")
|
|
236
|
+
else:
|
|
237
|
+
log.info(f"Question {question_num}: {question_type} (seed={seed})")
|
|
232
238
|
if config:
|
|
233
239
|
log.debug(f" Config params: {config}")
|
|
234
240
|
|
|
@@ -241,7 +247,7 @@ def regenerate_question_answer(
|
|
|
241
247
|
)
|
|
242
248
|
|
|
243
249
|
# Generate question with the specific seed
|
|
244
|
-
instance = question.instantiate(rng_seed=seed)
|
|
250
|
+
instance = question.instantiate(rng_seed=seed, **context_extras)
|
|
245
251
|
question_ast = question._build_question_ast(instance)
|
|
246
252
|
|
|
247
253
|
# Extract answers
|
|
@@ -477,14 +483,22 @@ def display_answer_summary(question_data: Dict[str, Any]) -> None:
|
|
|
477
483
|
if 'question_type' in question_data:
|
|
478
484
|
print(f"Type: {question_data['question_type']}")
|
|
479
485
|
print(f"Seed: {question_data['seed']}")
|
|
480
|
-
|
|
486
|
+
if question_data.get('version') is not None:
|
|
487
|
+
print(f"Version: {question_data['version']}")
|
|
481
488
|
|
|
482
489
|
if 'answer_objects' in question_data:
|
|
483
490
|
print("\nANSWERS:")
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
print(f"
|
|
491
|
+
answer_objects = question_data['answer_objects']
|
|
492
|
+
if isinstance(answer_objects, dict):
|
|
493
|
+
for key, answer_obj in answer_objects.items():
|
|
494
|
+
print(f" {key}: {answer_obj.value}")
|
|
495
|
+
if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
|
|
496
|
+
print(f" (tolerance: ±{answer_obj.tolerance})")
|
|
497
|
+
else:
|
|
498
|
+
for i, answer_obj in enumerate(answer_objects, start=1):
|
|
499
|
+
print(f" {i}: {answer_obj.value}")
|
|
500
|
+
if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
|
|
501
|
+
print(f" (tolerance: ±{answer_obj.tolerance})")
|
|
488
502
|
elif 'answers' in question_data:
|
|
489
503
|
print("\nANSWERS (raw Canvas format):")
|
|
490
504
|
print(f" Type: {question_data['answers']['kind']}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: QuizGenerator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Generate randomized quiz questions for Canvas LMS and PDF exams
|
|
5
5
|
Project-URL: Homepage, https://github.com/OtterDen-Lab/QuizGenerator
|
|
6
6
|
Project-URL: Documentation, https://github.com/OtterDen-Lab/QuizGenerator/tree/main/documentation
|
|
@@ -19,9 +19,7 @@ Classifier: Topic :: Education :: Testing
|
|
|
19
19
|
Requires-Python: >=3.12
|
|
20
20
|
Requires-Dist: canvasapi==3.2.0
|
|
21
21
|
Requires-Dist: cryptography>=41.0.0
|
|
22
|
-
Requires-Dist: graphviz>=0.21
|
|
23
22
|
Requires-Dist: jinja2==3.1.3
|
|
24
|
-
Requires-Dist: keras>=3.12.0
|
|
25
23
|
Requires-Dist: markdown>=3.9
|
|
26
24
|
Requires-Dist: matplotlib
|
|
27
25
|
Requires-Dist: pylatex>=1.4.2
|
|
@@ -32,7 +30,10 @@ Requires-Dist: pyyaml==6.0.1
|
|
|
32
30
|
Requires-Dist: requests==2.32.2
|
|
33
31
|
Requires-Dist: segno>=1.6.0
|
|
34
32
|
Requires-Dist: sympy>=1.14.0
|
|
35
|
-
|
|
33
|
+
Provides-Extra: cst463
|
|
34
|
+
Requires-Dist: graphviz>=0.21; extra == 'cst463'
|
|
35
|
+
Requires-Dist: keras>=3.12.0; extra == 'cst463'
|
|
36
|
+
Requires-Dist: tensorflow>=2.20.0; extra == 'cst463'
|
|
36
37
|
Provides-Extra: grading
|
|
37
38
|
Requires-Dist: pillow>=10.0.0; extra == 'grading'
|
|
38
39
|
Requires-Dist: pyzbar>=0.1.9; extra == 'grading'
|
|
@@ -60,18 +61,24 @@ pip install QuizGenerator
|
|
|
60
61
|
### System Requirements
|
|
61
62
|
|
|
62
63
|
- Python 3.12+
|
|
63
|
-
-
|
|
64
|
-
- Optional:
|
|
64
|
+
- [Typst](https://typst.app/) (default PDF renderer)
|
|
65
|
+
- Optional: LaTeX distribution with `latexmk` (if using `--latex`)
|
|
66
|
+
- Recommended: [Pandoc](https://pandoc.org/) (for markdown conversion)
|
|
65
67
|
|
|
66
68
|
### Optional Dependencies
|
|
67
69
|
|
|
68
70
|
```bash
|
|
69
71
|
# For QR code grading support
|
|
70
72
|
pip install QuizGenerator[grading]
|
|
73
|
+
|
|
74
|
+
# For CST463 machine learning questions
|
|
75
|
+
pip install QuizGenerator[cst463]
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
## Quick Start
|
|
74
79
|
|
|
80
|
+
Need a 2‑minute setup? See `documentation/quickstart.md`.
|
|
81
|
+
|
|
75
82
|
### 1. Create a quiz configuration (YAML)
|
|
76
83
|
|
|
77
84
|
```yaml
|
|
@@ -94,7 +101,7 @@ questions:
|
|
|
94
101
|
### 2. Generate PDFs
|
|
95
102
|
|
|
96
103
|
```bash
|
|
97
|
-
|
|
104
|
+
quizgen --yaml my_quiz.yaml --num_pdfs 3
|
|
98
105
|
```
|
|
99
106
|
|
|
100
107
|
PDFs will be created in the `out/` directory.
|
|
@@ -106,8 +113,8 @@ PDFs will be created in the `out/` directory.
|
|
|
106
113
|
# CANVAS_API_URL=https://canvas.instructure.com
|
|
107
114
|
# CANVAS_API_KEY=your_api_key_here
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
--
|
|
116
|
+
quizgen \
|
|
117
|
+
--yaml my_quiz.yaml \
|
|
111
118
|
--num_canvas 5 \
|
|
112
119
|
--course_id 12345
|
|
113
120
|
```
|
|
@@ -194,10 +201,9 @@ Notes:
|
|
|
194
201
|
|
|
195
202
|
## Documentation
|
|
196
203
|
|
|
197
|
-
- [Getting Started Guide](documentation/getting_started.md)
|
|
204
|
+
- [Getting Started Guide](documentation/getting_started.md)
|
|
198
205
|
- [Custom Questions Guide](documentation/custom_questions.md)
|
|
199
|
-
- [YAML Configuration Reference](documentation/yaml_config_guide.md)
|
|
200
|
-
- [PyPI Release Plan](documentation/pypi_release_plan.md)
|
|
206
|
+
- [YAML Configuration Reference](documentation/yaml_config_guide.md)
|
|
201
207
|
|
|
202
208
|
## Canvas Setup
|
|
203
209
|
|
|
@@ -216,25 +222,28 @@ CANVAS_API_KEY_prod=your_prod_api_key
|
|
|
216
222
|
2. Use `--prod` flag for production Canvas instance:
|
|
217
223
|
|
|
218
224
|
```bash
|
|
219
|
-
|
|
225
|
+
quizgen --prod --num_canvas 5 --course_id 12345
|
|
220
226
|
```
|
|
221
227
|
|
|
222
228
|
## Advanced Features
|
|
223
229
|
|
|
224
230
|
### Typst Support
|
|
225
231
|
|
|
226
|
-
|
|
232
|
+
Typst is the default for faster compilation. Use `--latex` to force LaTeX:
|
|
227
233
|
|
|
228
234
|
```bash
|
|
229
|
-
|
|
235
|
+
quizgen --latex --num_pdfs 3
|
|
230
236
|
```
|
|
231
237
|
|
|
238
|
+
Experimental: `--typst_measurement` uses Typst to measure question height for tighter layout.
|
|
239
|
+
It can change pagination and ordering, so use with care on finalized exams.
|
|
240
|
+
|
|
232
241
|
### Deterministic Generation
|
|
233
242
|
|
|
234
243
|
Use seeds for reproducible quizzes:
|
|
235
244
|
|
|
236
245
|
```bash
|
|
237
|
-
|
|
246
|
+
quizgen --seed 42 --num_pdfs 3
|
|
238
247
|
```
|
|
239
248
|
|
|
240
249
|
### QR Code Regeneration
|
|
@@ -258,7 +267,7 @@ QuizGenerator/
|
|
|
258
267
|
│ └── canvas/ # Canvas LMS integration
|
|
259
268
|
├── example_files/ # Example quiz configurations
|
|
260
269
|
├── documentation/ # User guides
|
|
261
|
-
└──
|
|
270
|
+
└── quizgen # CLI entry point
|
|
262
271
|
```
|
|
263
272
|
|
|
264
273
|
## Contributing
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
+
QuizGenerator/README.md,sha256=4n16gKyhIAKRBX4VKlpfcK0pyUYJ6Ht08MUsnwgxrZo,145
|
|
1
2
|
QuizGenerator/__init__.py,sha256=8EV-k90A3PNC8Cm2-ZquwNyVyvnwW1gs6u-nGictyhs,840
|
|
2
3
|
QuizGenerator/__main__.py,sha256=Dd9w4R0Unm3RiXztvR4Y_g9-lkWp6FHg-4VN50JbKxU,151
|
|
3
4
|
QuizGenerator/constants.py,sha256=AO-UWwsWPLb1k2JW6KP8rl9fxTcdT0rW-6XC6zfnDOs,4386
|
|
4
|
-
QuizGenerator/contentast.py,sha256=
|
|
5
|
-
QuizGenerator/generate.py,sha256=
|
|
5
|
+
QuizGenerator/contentast.py,sha256=NY6Y9gWbdinXaVGdgwN61UBF2nz_L2urqr-IPfR6tV0,88475
|
|
6
|
+
QuizGenerator/generate.py,sha256=qKYT-7HLUS1WSViDDSuJOIb5RwsTT3BuoUUZkoqSSus,17363
|
|
7
|
+
QuizGenerator/logging.yaml,sha256=VJCdh26D8e_PNUs4McvvP1ojz9EVjQNifJzfhEk1Mbo,1114
|
|
6
8
|
QuizGenerator/misc.py,sha256=MXrguUhhdrWSV4Hqdl4G21ktowODu1AcKy6-5mvy3aI,454
|
|
7
9
|
QuizGenerator/mixins.py,sha256=zXj2U94qNbIEusbwTnzRM1Z_zSybpvozWhveq-t5q2Q,15771
|
|
8
10
|
QuizGenerator/performance.py,sha256=CM3zLarJXN5Hfrl4-6JRBqD03j4BU1B2QW699HAr1Ds,7002
|
|
9
|
-
QuizGenerator/qrcode_generator.py,sha256=
|
|
10
|
-
QuizGenerator/question.py,sha256=
|
|
11
|
+
QuizGenerator/qrcode_generator.py,sha256=VMbAXFxJ_3KryqrgynCUPR5myeiu5CoroBfwwBsinCc,13386
|
|
12
|
+
QuizGenerator/question.py,sha256=BSopEaosCkJZ76etMv3Ue1zjzJpho-WnqS_8cQlZeac,38199
|
|
11
13
|
QuizGenerator/quiz.py,sha256=CEWy7FB7BZiK33s_wYs6MqGKDetc6htUaqvP3--2HzI,21621
|
|
12
|
-
QuizGenerator/regenerate.py,sha256=
|
|
14
|
+
QuizGenerator/regenerate.py,sha256=cDYZ9LwOSkX9P7W72O0c6RI8ORzS-DFnYhAdC5-7UrI,20375
|
|
13
15
|
QuizGenerator/typst_utils.py,sha256=JGQn_u5bEHd8HAtjAHuZoVJwLkx-Rd4ZCBWffwFZa3o,3136
|
|
14
16
|
QuizGenerator/canvas/__init__.py,sha256=TwFP_zgxPIlWtkvIqQ6mcvBNTL9swIH_rJl7DGKcvkQ,286
|
|
15
|
-
QuizGenerator/canvas/canvas_interface.py,sha256=
|
|
17
|
+
QuizGenerator/canvas/canvas_interface.py,sha256=5PS7sZeIwRuOGK9gXmfgP4eUYtgCqZ1JKD-S4mNvsbk,24798
|
|
16
18
|
QuizGenerator/canvas/classes.py,sha256=v_tQ8t_JJplU9sv2p4YctX45Fwed1nQ2HC1oC9BnDNw,7594
|
|
17
19
|
QuizGenerator/premade_questions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
20
|
QuizGenerator/premade_questions/basic.py,sha256=u6Viv__5HYWjLOknYt_jsBJTej5-cPd9FM1xsuwUQcQ,4613
|
|
@@ -27,24 +29,24 @@ QuizGenerator/premade_questions/cst463/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JC
|
|
|
27
29
|
QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py,sha256=sH2CUV6zK9FT3jWTn453ys6_JTrUKRtZnU8hK6RmImU,240
|
|
28
30
|
QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py,sha256=y_R26wUt7AIZVUoe3e_qpzkPdwMyo3mZjxLI504-YQw,13840
|
|
29
31
|
QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py,sha256=OssybIkHx4l8ryHGT9rqHUecta9qpItK4QYVHuuQLeo,11525
|
|
30
|
-
QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py,sha256=
|
|
32
|
+
QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py,sha256=PMuEAUJmN12t4ffHF0FoSbQNCLDfW5BKozWNHvoC6GU,24276
|
|
31
33
|
QuizGenerator/premade_questions/cst463/gradient_descent/misc.py,sha256=0R-nFeD3zsqJyde5CXWrF6Npjmpx6_HbzfCbThLi3os,2657
|
|
32
34
|
QuizGenerator/premade_questions/cst463/math_and_data/__init__.py,sha256=EbIaUrx7_aK9j3Gd8Mk08h9GocTq_0OoNu2trfNwaU8,202
|
|
33
35
|
QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py,sha256=4DLdo_8XDS_xtPA8R-wH4K0cKnMn4r5727Vszz8keTc,15565
|
|
34
36
|
QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py,sha256=VXQCLQEeNKxRDPn_fGW5nAPX-0betrZ8mURh0ElbNz0,12668
|
|
35
37
|
QuizGenerator/premade_questions/cst463/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
36
|
-
QuizGenerator/premade_questions/cst463/models/attention.py,sha256=
|
|
37
|
-
QuizGenerator/premade_questions/cst463/models/cnns.py,sha256=
|
|
38
|
+
QuizGenerator/premade_questions/cst463/models/attention.py,sha256=hb0sLVddxSKr4-bsIpAUyxITfcNaAGT-FppwwXXu52I,5430
|
|
39
|
+
QuizGenerator/premade_questions/cst463/models/cnns.py,sha256=p3hvQEsTsI2urPrZ-MZ63eIq0Dm6aarrLATVIiECc2A,6017
|
|
38
40
|
QuizGenerator/premade_questions/cst463/models/matrices.py,sha256=21eNXUcIjFNZTGH18oH-x3MbCa4buNGQGer3a5yL85c,1938
|
|
39
|
-
QuizGenerator/premade_questions/cst463/models/rnns.py,sha256=
|
|
40
|
-
QuizGenerator/premade_questions/cst463/models/text.py,sha256=
|
|
41
|
-
QuizGenerator/premade_questions/cst463/models/weight_counting.py,sha256=
|
|
41
|
+
QuizGenerator/premade_questions/cst463/models/rnns.py,sha256=9pPzkrNviKD479NVgVvkoMhXtaLX0m17IIGpYKXXAwk,6660
|
|
42
|
+
QuizGenerator/premade_questions/cst463/models/text.py,sha256=j6ClSF4AztgFWyvuhjBCW5mKvQL1AdizfhpF3iCjtt4,6592
|
|
43
|
+
QuizGenerator/premade_questions/cst463/models/weight_counting.py,sha256=NriO0cukIw3HKYkm62p4PPm1qdH9wmDepEfoBzrmxR4,7402
|
|
42
44
|
QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py,sha256=pmyCezO-20AFEQC6MR7KnAsaU9TcgZYsGQOMVkRZ-U8,149
|
|
43
45
|
QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py,sha256=j2f5LFmme-2rSgJzcb8nZJ1_hnZaL-S4lXSnIbpoH_E,43010
|
|
44
46
|
QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py,sha256=G1gEHtG4KakYgi8ZXSYYhX6bQRtnm2tZVGx36d63Nmo,173
|
|
45
47
|
QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py,sha256=t0ghv6o8AsZaIwVHFu07Ozebwuv6Rf18D3g_-Jz4O74,31309
|
|
46
|
-
quizgenerator-0.
|
|
47
|
-
quizgenerator-0.
|
|
48
|
-
quizgenerator-0.
|
|
49
|
-
quizgenerator-0.
|
|
50
|
-
quizgenerator-0.
|
|
48
|
+
quizgenerator-0.10.0.dist-info/METADATA,sha256=zxcZ0akBMSH9jjA-sdpO55tZUlN4pfeSpIqFs9m864M,8483
|
|
49
|
+
quizgenerator-0.10.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
50
|
+
quizgenerator-0.10.0.dist-info/entry_points.txt,sha256=aOIdRdw26xY8HkxOoKHBnUPe2mwGv5Ti3U1zojb6zxQ,98
|
|
51
|
+
quizgenerator-0.10.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
52
|
+
quizgenerator-0.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|