QuizGenerator 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- QuizGenerator/README.md +5 -0
- QuizGenerator/__init__.py +27 -0
- QuizGenerator/__main__.py +7 -0
- QuizGenerator/canvas/__init__.py +13 -0
- QuizGenerator/canvas/canvas_interface.py +627 -0
- QuizGenerator/canvas/classes.py +235 -0
- QuizGenerator/constants.py +149 -0
- QuizGenerator/contentast.py +1955 -0
- QuizGenerator/generate.py +253 -0
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/misc.py +579 -0
- QuizGenerator/mixins.py +548 -0
- QuizGenerator/performance.py +202 -0
- QuizGenerator/premade_questions/__init__.py +0 -0
- QuizGenerator/premade_questions/basic.py +103 -0
- QuizGenerator/premade_questions/cst334/__init__.py +1 -0
- QuizGenerator/premade_questions/cst334/languages.py +391 -0
- QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
- QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
- QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
- QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
- QuizGenerator/premade_questions/cst334/process.py +648 -0
- QuizGenerator/premade_questions/cst463/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
- QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
- QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
- QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
- QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
- QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
- QuizGenerator/premade_questions/cst463/models/text.py +203 -0
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
- QuizGenerator/qrcode_generator.py +293 -0
- QuizGenerator/question.py +715 -0
- QuizGenerator/quiz.py +467 -0
- QuizGenerator/regenerate.py +472 -0
- QuizGenerator/typst_utils.py +113 -0
- quizgenerator-0.4.2.dist-info/METADATA +265 -0
- quizgenerator-0.4.2.dist-info/RECORD +52 -0
- quizgenerator-0.4.2.dist-info/WHEEL +4 -0
- quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
- quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
QuizGenerator/quiz.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!env python
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import collections
|
|
5
|
+
import itertools
|
|
6
|
+
import logging
|
|
7
|
+
import os.path
|
|
8
|
+
import random
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import List, Dict, Optional
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from QuizGenerator.contentast import ContentAST
|
|
18
|
+
from QuizGenerator.question import Question, QuestionRegistry, QuestionGroup
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Quiz:
|
|
24
|
+
"""
|
|
25
|
+
A quiz object that will build up questions and output them in a range of formats (hopefully)
|
|
26
|
+
It should be that a single quiz object can contain multiples -- essentially it builds up from the questions and then can generate a variety of questions.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
INTEREST_THRESHOLD = 1.0
|
|
30
|
+
|
|
31
|
+
def __init__(self, name, questions: List[dict|Question], practice, *args, **kwargs):
|
|
32
|
+
self.name = name
|
|
33
|
+
self.questions = questions
|
|
34
|
+
self.instructions = kwargs.get("instructions", "")
|
|
35
|
+
|
|
36
|
+
# Parse description with ContentAST if provided
|
|
37
|
+
raw_description = kwargs.get("description", None)
|
|
38
|
+
if raw_description:
|
|
39
|
+
# Create a ContentAST document from the description text
|
|
40
|
+
desc_doc = ContentAST.Document()
|
|
41
|
+
desc_doc.add_element(ContentAST.Paragraph([raw_description]))
|
|
42
|
+
self.description = desc_doc.render("html")
|
|
43
|
+
else:
|
|
44
|
+
self.description = None
|
|
45
|
+
|
|
46
|
+
self.question_sort_order = None
|
|
47
|
+
self.practice = practice
|
|
48
|
+
self.preserve_order_point_values = set() # Point values that should preserve question order
|
|
49
|
+
|
|
50
|
+
# Plan: right now we just take in questions and then assume they have a score and a "generate" button
|
|
51
|
+
|
|
52
|
+
def __iter__(self):
|
|
53
|
+
def sort_func(q):
|
|
54
|
+
if self.question_sort_order is not None:
|
|
55
|
+
try:
|
|
56
|
+
return (-q.points_value, self.question_sort_order.index(q.topic))
|
|
57
|
+
except ValueError:
|
|
58
|
+
return (-q.points_value, float('inf'))
|
|
59
|
+
return -q.points_value
|
|
60
|
+
return iter(sorted(self.questions, key=sort_func))
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_yaml(cls, path_to_yaml) -> List[Quiz]:
|
|
64
|
+
|
|
65
|
+
quizes_loaded : List[Quiz] = []
|
|
66
|
+
|
|
67
|
+
with open(path_to_yaml) as fid:
|
|
68
|
+
list_of_exam_dicts = list(yaml.safe_load_all(fid))
|
|
69
|
+
|
|
70
|
+
for exam_dict in list_of_exam_dicts:
|
|
71
|
+
# Load custom question modules if specified (Option 3: Quick-and-dirty approach)
|
|
72
|
+
# Users can add custom question types by importing Python modules in their YAML:
|
|
73
|
+
# custom_modules:
|
|
74
|
+
# - my_custom_questions.scheduling
|
|
75
|
+
# - university_standard_questions
|
|
76
|
+
custom_modules = exam_dict.get("custom_modules", [])
|
|
77
|
+
if custom_modules:
|
|
78
|
+
import importlib
|
|
79
|
+
for module_name in custom_modules:
|
|
80
|
+
try:
|
|
81
|
+
importlib.import_module(module_name)
|
|
82
|
+
log.info(f"Loaded custom question module: {module_name}")
|
|
83
|
+
except ImportError as e:
|
|
84
|
+
log.error(f"Failed to import custom module '{module_name}': {e}")
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Get general quiz information from the dictionary
|
|
89
|
+
name = exam_dict.get("name", f"Unnamed Exam ({datetime.now().strftime('%a %b %d %I:%M %p')})")
|
|
90
|
+
practice = exam_dict.get("practice", False)
|
|
91
|
+
description = exam_dict.get("description", None)
|
|
92
|
+
sort_order = list(map(lambda t: Question.Topic.from_string(t), exam_dict.get("sort order", [])))
|
|
93
|
+
sort_order = sort_order + list(filter(lambda t: t not in sort_order, Question.Topic))
|
|
94
|
+
|
|
95
|
+
# Load questions from the quiz dictionary
|
|
96
|
+
questions_for_exam = []
|
|
97
|
+
# Track point values where order should be preserved (for layout optimization)
|
|
98
|
+
preserve_order_point_values = set()
|
|
99
|
+
|
|
100
|
+
for question_value, question_definitions in exam_dict["questions"].items():
|
|
101
|
+
# todo: I can also add in "extra credit" and "mix-ins" as other keys to indicate extra credit or questions that can go anywhere
|
|
102
|
+
log.info(f"Parsing {question_value} point questions")
|
|
103
|
+
|
|
104
|
+
# Check for point-value-level config
|
|
105
|
+
point_config = question_definitions.pop("_config", {})
|
|
106
|
+
if point_config.get("preserve_order", False):
|
|
107
|
+
preserve_order_point_values.add(question_value)
|
|
108
|
+
log.info(f" Point value {question_value} will preserve question order")
|
|
109
|
+
|
|
110
|
+
def make_question(q_name, q_data, **kwargs):
|
|
111
|
+
# Build up the kwargs that we're going to pass in
|
|
112
|
+
# todo: this is currently a mess due to legacy things, so before I tell others to use this make it cleaner
|
|
113
|
+
kwargs= {
|
|
114
|
+
"name": q_name,
|
|
115
|
+
"points_value": question_value,
|
|
116
|
+
**q_data.get("kwargs", {}),
|
|
117
|
+
**q_data,
|
|
118
|
+
**kwargs,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# If we are passed in a topic then use it, otherwise don't set it which will have it set to a default
|
|
122
|
+
if "topic" in q_data:
|
|
123
|
+
kwargs["topic"] = Question.Topic.from_string(q_data.get("topic", "Misc"))
|
|
124
|
+
|
|
125
|
+
# Add in a default, where if it isn't specified we're going to simply assume it is a text
|
|
126
|
+
question_class = q_data.get("class", "FromText")
|
|
127
|
+
|
|
128
|
+
new_question = QuestionRegistry.create(
|
|
129
|
+
question_class,
|
|
130
|
+
**kwargs
|
|
131
|
+
)
|
|
132
|
+
return new_question
|
|
133
|
+
|
|
134
|
+
for q_name, q_data in question_definitions.items():
|
|
135
|
+
# Set defaults for config
|
|
136
|
+
question_config = {
|
|
137
|
+
"group" : False,
|
|
138
|
+
"num_to_pick" : 1,
|
|
139
|
+
"random_per_student" : False,
|
|
140
|
+
"repeat": 1,
|
|
141
|
+
"topic": "MISC"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Update config if it exists
|
|
145
|
+
question_config.update(
|
|
146
|
+
q_data.get("_config", {})
|
|
147
|
+
)
|
|
148
|
+
q_data.pop("_config", None)
|
|
149
|
+
q_data.pop("pick", None) # todo: don't use this anymore
|
|
150
|
+
q_data.pop("repeat", None) # todo: don't use this anymore
|
|
151
|
+
|
|
152
|
+
# Check if it is a question group
|
|
153
|
+
if question_config["group"]:
|
|
154
|
+
|
|
155
|
+
# todo: Find a way to allow for "num_to_pick" to ensure lack of duplicates when using duplicates.
|
|
156
|
+
# It's probably going to be somewhere in the instantiate and get_attr fields, with "_current_questions"
|
|
157
|
+
# But will require changing how we add concrete questions (but that'll just be everything returns a list
|
|
158
|
+
questions_for_exam.append(
|
|
159
|
+
QuestionGroup(
|
|
160
|
+
questions_in_group=[
|
|
161
|
+
make_question(name, data | {"topic" : question_config["topic"]}) for name, data in q_data.items()
|
|
162
|
+
],
|
|
163
|
+
pick_once=(not question_config["random_per_student"])
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
else: # Then this is just a single question
|
|
168
|
+
questions_for_exam.extend([
|
|
169
|
+
make_question(
|
|
170
|
+
q_name,
|
|
171
|
+
q_data,
|
|
172
|
+
rng_seed_offset=repeat_number
|
|
173
|
+
)
|
|
174
|
+
for repeat_number in range(question_config["repeat"])
|
|
175
|
+
])
|
|
176
|
+
log.debug(f"len(questions_for_exam): {len(questions_for_exam)}")
|
|
177
|
+
quiz_from_yaml = cls(name, questions_for_exam, practice, description=description)
|
|
178
|
+
quiz_from_yaml.set_sort_order(sort_order)
|
|
179
|
+
quiz_from_yaml.preserve_order_point_values = preserve_order_point_values
|
|
180
|
+
quizes_loaded.append(quiz_from_yaml)
|
|
181
|
+
return quizes_loaded
|
|
182
|
+
|
|
183
|
+
def _estimate_question_height(self, question, use_typst_measurement=False, **kwargs) -> float:
|
|
184
|
+
"""
|
|
185
|
+
Estimate the rendered height of a question for layout optimization.
|
|
186
|
+
Returns height in centimeters.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
question: Question object to measure
|
|
190
|
+
use_typst_measurement: If True, use Typst's layout engine for exact measurement
|
|
191
|
+
**kwargs: Additional arguments passed to question rendering
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Height in centimeters
|
|
195
|
+
"""
|
|
196
|
+
# Try Typst measurement if requested and available
|
|
197
|
+
if use_typst_measurement:
|
|
198
|
+
from QuizGenerator.typst_utils import measure_typst_content, check_typst_available
|
|
199
|
+
|
|
200
|
+
if check_typst_available():
|
|
201
|
+
try:
|
|
202
|
+
# Render question to Typst
|
|
203
|
+
question_ast = question.get_question(**kwargs)
|
|
204
|
+
|
|
205
|
+
# Get just the content body (without the #question wrapper which adds spacing)
|
|
206
|
+
typst_body = question_ast.body.render("typst", **kwargs)
|
|
207
|
+
|
|
208
|
+
# Measure the content
|
|
209
|
+
measured_height = measure_typst_content(typst_body, page_width_cm=18.0)
|
|
210
|
+
|
|
211
|
+
if measured_height is not None:
|
|
212
|
+
# Add base height for question formatting (header, line, etc.) ~1.5cm
|
|
213
|
+
# Plus the spacing parameter
|
|
214
|
+
total_height = 1.5 + measured_height + question.spacing
|
|
215
|
+
log.debug(f"Typst measurement: {question.name} = {total_height:.2f}cm (content: {measured_height:.2f}cm, spacing: {question.spacing}cm)")
|
|
216
|
+
return total_height
|
|
217
|
+
else:
|
|
218
|
+
log.debug(f"Typst measurement failed for {question.name}, falling back to heuristics")
|
|
219
|
+
except Exception as e:
|
|
220
|
+
log.warning(f"Error during Typst measurement: {e}, falling back to heuristics")
|
|
221
|
+
else:
|
|
222
|
+
log.debug("Typst not available, using heuristic estimation")
|
|
223
|
+
|
|
224
|
+
# Fallback: Use heuristic estimation (original implementation)
|
|
225
|
+
# Base height for question header, borders, and minimal content
|
|
226
|
+
# Each question has: horizontal rule, question number line, and minipage wrapper
|
|
227
|
+
base_height = 1.5 # cm
|
|
228
|
+
|
|
229
|
+
# The spacing parameter directly controls \vspace{} in cm
|
|
230
|
+
spacing_height = question.spacing # cm
|
|
231
|
+
|
|
232
|
+
# Estimate content height by rendering to LaTeX and analyzing structure
|
|
233
|
+
question_ast = question.get_question(**kwargs)
|
|
234
|
+
latex_content = question_ast.render("latex")
|
|
235
|
+
|
|
236
|
+
# Count content that adds height (rough estimates in cm)
|
|
237
|
+
content_height = 0.0
|
|
238
|
+
|
|
239
|
+
# Tables add significant height (~0.5cm per row as rough estimate)
|
|
240
|
+
table_count = latex_content.count('\\begin{tabular}')
|
|
241
|
+
content_height += table_count * 3.0 # Assume ~3cm per table on average
|
|
242
|
+
|
|
243
|
+
# Matrices add height
|
|
244
|
+
matrix_count = latex_content.count('\\begin{') - table_count # Rough matrix count
|
|
245
|
+
content_height += matrix_count * 2.0 # ~2cm per matrix
|
|
246
|
+
|
|
247
|
+
# Code blocks (verbatim) add significant height
|
|
248
|
+
verbatim_count = latex_content.count('\\begin{verbatim}')
|
|
249
|
+
content_height += verbatim_count * 4.0 # ~4cm per code block
|
|
250
|
+
|
|
251
|
+
# Count paragraphs and text blocks (very rough estimate)
|
|
252
|
+
# Each ~500 characters of text ≈ 1cm of height
|
|
253
|
+
char_count = len(latex_content)
|
|
254
|
+
content_height += (char_count / 500.0) * 0.5
|
|
255
|
+
|
|
256
|
+
# Total estimated height
|
|
257
|
+
total_height = base_height + spacing_height + content_height
|
|
258
|
+
|
|
259
|
+
return total_height
|
|
260
|
+
|
|
261
|
+
def _optimize_question_order(self, questions, **kwargs) -> List[Question]:
|
|
262
|
+
"""
|
|
263
|
+
Optimize question ordering to minimize PDF length while respecting point-value tiers.
|
|
264
|
+
Uses bin-packing heuristics to reorder questions within each point-value group.
|
|
265
|
+
"""
|
|
266
|
+
# Group questions by point value
|
|
267
|
+
from collections import defaultdict
|
|
268
|
+
point_groups = defaultdict(list)
|
|
269
|
+
|
|
270
|
+
for question in questions:
|
|
271
|
+
point_groups[question.points_value].append(question)
|
|
272
|
+
|
|
273
|
+
# Track which point values should preserve order (from config)
|
|
274
|
+
preserve_order_for = kwargs.pop('preserve_order_for', set())
|
|
275
|
+
|
|
276
|
+
# For each point group, estimate heights and apply bin-packing optimization
|
|
277
|
+
optimized_questions = []
|
|
278
|
+
is_first_page = True # Track if we're packing the first page
|
|
279
|
+
|
|
280
|
+
log.debug("Optimizing question order for PDF layout...")
|
|
281
|
+
|
|
282
|
+
for points in sorted(point_groups.keys(), reverse=True):
|
|
283
|
+
group = point_groups[points]
|
|
284
|
+
|
|
285
|
+
# Check if this point tier should preserve order
|
|
286
|
+
if points in preserve_order_for:
|
|
287
|
+
# Sort by topic only (preserve original order)
|
|
288
|
+
group.sort(key=lambda q: self.question_sort_order.index(q.topic))
|
|
289
|
+
optimized_questions.extend(group)
|
|
290
|
+
log.debug(f" {points}pt questions: {len(group)} questions (order preserved by config)")
|
|
291
|
+
# After adding preserved-order questions, we're likely past the first page
|
|
292
|
+
is_first_page = False
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# If only 1-2 questions, no optimization needed
|
|
296
|
+
if len(group) <= 2:
|
|
297
|
+
# Still sort by topic for consistency
|
|
298
|
+
group.sort(key=lambda q: self.question_sort_order.index(q.topic))
|
|
299
|
+
optimized_questions.extend(group)
|
|
300
|
+
log.debug(f" {points}pt questions: {len(group)} questions (no optimization needed)")
|
|
301
|
+
is_first_page = False
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# Estimate height for each question
|
|
305
|
+
question_heights = [(q, self._estimate_question_height(q, **kwargs)) for q in group]
|
|
306
|
+
|
|
307
|
+
# Sort by height descending to identify large and small questions
|
|
308
|
+
question_heights.sort(key=lambda x: x[1], reverse=True)
|
|
309
|
+
|
|
310
|
+
log.debug(f" Question heights for {points}pt questions:")
|
|
311
|
+
for q, h in question_heights:
|
|
312
|
+
log.debug(f" {q.name}: {h:.1f}cm (spacing={q.spacing}cm)")
|
|
313
|
+
|
|
314
|
+
# Calculate page capacity in centimeters
|
|
315
|
+
# A typical A4 page with margins has ~25cm of usable height
|
|
316
|
+
# After accounting for headers and separators, estimate ~22cm per page
|
|
317
|
+
base_page_capacity = 22.0 # cm
|
|
318
|
+
|
|
319
|
+
# First page has header (title + name line) which takes ~3cm
|
|
320
|
+
first_page_capacity = base_page_capacity - 3.0 if is_first_page else base_page_capacity
|
|
321
|
+
|
|
322
|
+
# Better bin-packing strategy: interleave large and small questions
|
|
323
|
+
# Strategy: Start each page with the largest unplaced question, then fill with smaller ones
|
|
324
|
+
bins = []
|
|
325
|
+
placed = [False] * len(question_heights)
|
|
326
|
+
|
|
327
|
+
while not all(placed):
|
|
328
|
+
# Determine capacity for this page
|
|
329
|
+
page_capacity = first_page_capacity if len(bins) == 0 and is_first_page else base_page_capacity
|
|
330
|
+
|
|
331
|
+
# Find the largest unplaced question to start a new page
|
|
332
|
+
new_page = []
|
|
333
|
+
page_height = 0
|
|
334
|
+
|
|
335
|
+
for i, (question, height) in enumerate(question_heights):
|
|
336
|
+
if not placed[i]:
|
|
337
|
+
new_page.append(question)
|
|
338
|
+
page_height = height
|
|
339
|
+
placed[i] = True
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
# Now try to fill the remaining space with smaller questions
|
|
343
|
+
for i, (question, height) in enumerate(question_heights):
|
|
344
|
+
if not placed[i] and page_height + height <= page_capacity:
|
|
345
|
+
new_page.append(question)
|
|
346
|
+
page_height += height
|
|
347
|
+
placed[i] = True
|
|
348
|
+
|
|
349
|
+
bins.append((new_page, page_height))
|
|
350
|
+
|
|
351
|
+
log.debug(f" {points}pt questions: {len(group)} questions packed into {len(bins)} pages")
|
|
352
|
+
for i, (page_questions, height) in enumerate(bins):
|
|
353
|
+
log.debug(f" Page {i+1}: {height:.1f}cm with {len(page_questions)} questions: {[q.name for q in page_questions]}")
|
|
354
|
+
|
|
355
|
+
# Flatten bins back to ordered list
|
|
356
|
+
for bin_contents, _ in bins:
|
|
357
|
+
optimized_questions.extend(bin_contents)
|
|
358
|
+
|
|
359
|
+
# After packing questions, we're no longer on the first page
|
|
360
|
+
is_first_page = False
|
|
361
|
+
|
|
362
|
+
return optimized_questions
|
|
363
|
+
|
|
364
|
+
def get_quiz(self, **kwargs) -> ContentAST.Document:
|
|
365
|
+
quiz = ContentAST.Document(title=self.name)
|
|
366
|
+
|
|
367
|
+
# Extract master RNG seed (if provided) and remove from kwargs
|
|
368
|
+
master_seed = kwargs.pop('rng_seed', None)
|
|
369
|
+
|
|
370
|
+
# Check if optimization is requested (default: True)
|
|
371
|
+
optimize_layout = kwargs.pop('optimize_layout', True)
|
|
372
|
+
|
|
373
|
+
if optimize_layout:
|
|
374
|
+
# Use optimized ordering, passing preserve_order config
|
|
375
|
+
ordered_questions = self._optimize_question_order(
|
|
376
|
+
self.questions,
|
|
377
|
+
preserve_order_for=self.preserve_order_point_values,
|
|
378
|
+
**kwargs
|
|
379
|
+
)
|
|
380
|
+
else:
|
|
381
|
+
# Use simple ordering by point value and topic
|
|
382
|
+
ordered_questions = sorted(
|
|
383
|
+
self.questions,
|
|
384
|
+
key=lambda q: (-q.points_value, self.question_sort_order.index(q.topic))
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Generate questions with sequential numbering for QR codes
|
|
388
|
+
# Use master seed to generate unique per-question seeds
|
|
389
|
+
if master_seed is not None:
|
|
390
|
+
# Create RNG from master seed to generate per-question seeds
|
|
391
|
+
master_rng = random.Random(master_seed)
|
|
392
|
+
|
|
393
|
+
for question_number, question in enumerate(ordered_questions, start=1):
|
|
394
|
+
# Generate a unique seed for this question from the master seed
|
|
395
|
+
if master_seed is not None:
|
|
396
|
+
question_seed = master_rng.randint(0, 2**31 - 1)
|
|
397
|
+
question_ast = question.get_question(rng_seed=question_seed, **kwargs)
|
|
398
|
+
else:
|
|
399
|
+
question_ast = question.get_question(**kwargs)
|
|
400
|
+
|
|
401
|
+
# Add question number to the AST for QR code generation
|
|
402
|
+
question_ast.question_number = question_number
|
|
403
|
+
quiz.add_element(question_ast)
|
|
404
|
+
|
|
405
|
+
return quiz
|
|
406
|
+
|
|
407
|
+
def describe(self):
|
|
408
|
+
|
|
409
|
+
# Print out title
|
|
410
|
+
print(f"Title: {self.name}")
|
|
411
|
+
total_points = sum(map(lambda q: q.points_value, self.questions))
|
|
412
|
+
total_questions = len(self.questions)
|
|
413
|
+
|
|
414
|
+
# Print out overall information
|
|
415
|
+
print(f"{total_points} points total, {total_questions} questions")
|
|
416
|
+
|
|
417
|
+
# Print out the per-value information
|
|
418
|
+
points_counter = collections.Counter([q.points_value for q in self.questions])
|
|
419
|
+
for points in sorted(points_counter.keys(), reverse=True):
|
|
420
|
+
print(f"{points_counter.get(points)} x {points}points")
|
|
421
|
+
|
|
422
|
+
# Either get the sort order or default to the order in the enum class
|
|
423
|
+
sort_order = self.question_sort_order
|
|
424
|
+
if sort_order is None:
|
|
425
|
+
sort_order = Question.Topic
|
|
426
|
+
|
|
427
|
+
# Build per-topic information
|
|
428
|
+
|
|
429
|
+
topic_information = {}
|
|
430
|
+
topic_strings = {}
|
|
431
|
+
for topic in sort_order:
|
|
432
|
+
topic_strings = {"name": topic.name}
|
|
433
|
+
|
|
434
|
+
question_count = len(list(map(lambda q: q.points_value, filter(lambda q: q.topic == topic, self.questions))))
|
|
435
|
+
topic_points = sum(map(lambda q: q.points_value, filter(lambda q: q.topic == topic, self.questions)))
|
|
436
|
+
|
|
437
|
+
# If we have questions add in some states, otherwise mark them as empty
|
|
438
|
+
if question_count != 0:
|
|
439
|
+
topic_strings["count_str"] = f"{question_count} questions ({ 100 * question_count / total_questions:0.1f}%)"
|
|
440
|
+
topic_strings["points_str"] = f"{topic_points:2} points ({ 100 * topic_points / total_points:0.1f}%)"
|
|
441
|
+
else:
|
|
442
|
+
topic_strings["count_str"] = "--"
|
|
443
|
+
topic_strings["points_str"] = "--"
|
|
444
|
+
|
|
445
|
+
topic_information[topic] = topic_strings
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# Get padding string lengths
|
|
449
|
+
paddings = collections.defaultdict(lambda: 0)
|
|
450
|
+
for field in topic_strings.keys():
|
|
451
|
+
paddings[field] = max(len(information[field]) for information in topic_information.values())
|
|
452
|
+
|
|
453
|
+
# Print out topics information using the padding
|
|
454
|
+
for topic in sort_order:
|
|
455
|
+
topic_strings = topic_information[topic]
|
|
456
|
+
print(f"{topic_strings['name']:{paddings['name']}} : {topic_strings['count_str']:{paddings['count_str']}} : {topic_strings['points_str']:{paddings['points_str']}}")
|
|
457
|
+
|
|
458
|
+
def set_sort_order(self, sort_order):
|
|
459
|
+
self.question_sort_order = sort_order
|
|
460
|
+
|
|
461
|
+
def main():
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
if __name__ == "__main__":
|
|
466
|
+
main()
|
|
467
|
+
|