QuizGenerator 0.4.4__py3-none-any.whl → 0.5.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 +952 -82
- QuizGenerator/generate.py +45 -9
- QuizGenerator/misc.py +4 -554
- QuizGenerator/mixins.py +47 -25
- QuizGenerator/premade_questions/cst334/languages.py +139 -125
- QuizGenerator/premade_questions/cst334/math_questions.py +78 -66
- QuizGenerator/premade_questions/cst334/memory_questions.py +258 -144
- QuizGenerator/premade_questions/cst334/persistence_questions.py +71 -33
- QuizGenerator/premade_questions/cst334/process.py +554 -64
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +32 -6
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +59 -34
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +27 -8
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +53 -32
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +228 -88
- QuizGenerator/premade_questions/cst463/models/attention.py +26 -10
- QuizGenerator/premade_questions/cst463/models/cnns.py +32 -19
- QuizGenerator/premade_questions/cst463/models/rnns.py +25 -12
- QuizGenerator/premade_questions/cst463/models/text.py +26 -11
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +36 -22
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +89 -109
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +117 -51
- QuizGenerator/question.py +110 -15
- QuizGenerator/quiz.py +81 -24
- QuizGenerator/regenerate.py +98 -29
- {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/METADATA +1 -1
- {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/RECORD +29 -31
- QuizGenerator/README.md +0 -5
- QuizGenerator/logging.yaml +0 -55
- {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/WHEEL +0 -0
- {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/generate.py
CHANGED
|
@@ -5,6 +5,7 @@ import random
|
|
|
5
5
|
import shutil
|
|
6
6
|
import subprocess
|
|
7
7
|
import tempfile
|
|
8
|
+
import re
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from dotenv import load_dotenv
|
|
10
11
|
from QuizGenerator.canvas.canvas_interface import CanvasInterface
|
|
@@ -41,8 +42,7 @@ def parse_args():
|
|
|
41
42
|
|
|
42
43
|
# PDF Flags
|
|
43
44
|
parser.add_argument("--num_pdfs", default=0, type=int, help="How many PDF quizzes to create")
|
|
44
|
-
parser.add_argument("--
|
|
45
|
-
help="Use Typst instead of LaTeX for PDF generation")
|
|
45
|
+
parser.add_argument("--latex", action="store_false", dest="typst", help="Use Typst instead of LaTeX for PDF generation")
|
|
46
46
|
|
|
47
47
|
subparsers = parser.add_subparsers(dest='command')
|
|
48
48
|
test_parser = subparsers.add_parser("TEST")
|
|
@@ -65,12 +65,20 @@ def test():
|
|
|
65
65
|
print("="*60)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
def generate_latex(latex_text, remove_previous=False):
|
|
68
|
+
def generate_latex(latex_text, remove_previous=False, name_prefix=None):
|
|
69
|
+
"""
|
|
70
|
+
Generate PDF from LaTeX source code.
|
|
69
71
|
|
|
72
|
+
Args:
|
|
73
|
+
latex_text: The LaTeX source code to compile
|
|
74
|
+
remove_previous: Whether to remove the 'out' directory before generating
|
|
75
|
+
name_prefix: Optional prefix for the temporary filename (e.g., quiz name)
|
|
76
|
+
"""
|
|
70
77
|
if remove_previous:
|
|
71
78
|
if os.path.exists('out'): shutil.rmtree('out')
|
|
72
79
|
|
|
73
|
-
|
|
80
|
+
prefix = f"{sanitize_filename(name_prefix)}-" if name_prefix else "tmp"
|
|
81
|
+
tmp_tex = tempfile.NamedTemporaryFile('w', prefix=prefix)
|
|
74
82
|
|
|
75
83
|
tmp_tex.write(latex_text)
|
|
76
84
|
|
|
@@ -98,11 +106,38 @@ def generate_latex(latex_text, remove_previous=False):
|
|
|
98
106
|
tmp_tex.close()
|
|
99
107
|
|
|
100
108
|
|
|
101
|
-
def
|
|
109
|
+
def sanitize_filename(name):
|
|
110
|
+
"""
|
|
111
|
+
Sanitize a quiz name for use as a filename prefix.
|
|
112
|
+
|
|
113
|
+
Converts spaces to underscores, removes special characters,
|
|
114
|
+
and limits length to avoid overly long filenames.
|
|
115
|
+
|
|
116
|
+
Example: "CST 334 Exam 4 (Fall 25)" -> "CST_334_Exam_4_Fall_25"
|
|
117
|
+
"""
|
|
118
|
+
# Replace spaces with underscores
|
|
119
|
+
sanitized = name.replace(' ', '_')
|
|
120
|
+
|
|
121
|
+
# Remove characters that aren't alphanumeric, underscore, or hyphen
|
|
122
|
+
sanitized = re.sub(r'[^\w\-]', '', sanitized)
|
|
123
|
+
|
|
124
|
+
# Limit length to avoid overly long filenames (keep first 50 chars)
|
|
125
|
+
if len(sanitized) > 50:
|
|
126
|
+
sanitized = sanitized[:50]
|
|
127
|
+
|
|
128
|
+
return sanitized
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def generate_typst(typst_text, remove_previous=False, name_prefix=None):
|
|
102
132
|
"""
|
|
103
133
|
Generate PDF from Typst source code.
|
|
104
134
|
|
|
105
135
|
Similar to generate_latex, but uses typst compiler instead of latexmk.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
typst_text: The Typst source code to compile
|
|
139
|
+
remove_previous: Whether to remove the 'out' directory before generating
|
|
140
|
+
name_prefix: Optional prefix for the temporary filename (e.g., quiz name)
|
|
106
141
|
"""
|
|
107
142
|
if remove_previous:
|
|
108
143
|
if os.path.exists('out'):
|
|
@@ -111,8 +146,9 @@ def generate_typst(typst_text, remove_previous=False):
|
|
|
111
146
|
# Ensure output directory exists
|
|
112
147
|
os.makedirs('out', exist_ok=True)
|
|
113
148
|
|
|
114
|
-
# Create temporary Typst file
|
|
115
|
-
|
|
149
|
+
# Create temporary Typst file with optional name prefix
|
|
150
|
+
prefix = f"{sanitize_filename(name_prefix)}-" if name_prefix else "tmp"
|
|
151
|
+
tmp_typ = tempfile.NamedTemporaryFile('w', suffix='.typ', delete=False, prefix=prefix)
|
|
116
152
|
|
|
117
153
|
try:
|
|
118
154
|
tmp_typ.write(typst_text)
|
|
@@ -190,11 +226,11 @@ def generate_quiz(
|
|
|
190
226
|
if use_typst:
|
|
191
227
|
# Generate using Typst
|
|
192
228
|
typst_text = quiz.get_quiz(rng_seed=pdf_seed, use_typst_measurement=use_typst_measurement).render("typst")
|
|
193
|
-
generate_typst(typst_text, remove_previous=(i==0))
|
|
229
|
+
generate_typst(typst_text, remove_previous=(i==0), name_prefix=quiz.name)
|
|
194
230
|
else:
|
|
195
231
|
# Generate using LaTeX (default)
|
|
196
232
|
latex_text = quiz.get_quiz(rng_seed=pdf_seed, use_typst_measurement=use_typst_measurement).render_latex()
|
|
197
|
-
generate_latex(latex_text, remove_previous=(i==0))
|
|
233
|
+
generate_latex(latex_text, remove_previous=(i==0), name_prefix=quiz.name)
|
|
198
234
|
|
|
199
235
|
if num_canvas > 0:
|
|
200
236
|
canvas_course.push_quiz_to_canvas(
|
QuizGenerator/misc.py
CHANGED
|
@@ -23,557 +23,7 @@ def fix_negative_zero(value):
|
|
|
23
23
|
return value
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
BLANK = "fill_in_multiple_blanks_question"
|
|
31
|
-
MULTIPLE_ANSWER = "multiple_answers_question"
|
|
32
|
-
ESSAY = "essay_question"
|
|
33
|
-
MULTIPLE_DROPDOWN = "multiple_dropdowns_question"
|
|
34
|
-
NUMERICAL_QUESTION = "numerical_question" # note: these can only be single answers as far as I can tell
|
|
35
|
-
|
|
36
|
-
class VariableKind(enum.Enum):
|
|
37
|
-
STR = enum.auto()
|
|
38
|
-
INT = enum.auto()
|
|
39
|
-
FLOAT = enum.auto()
|
|
40
|
-
BINARY = enum.auto()
|
|
41
|
-
HEX = enum.auto()
|
|
42
|
-
BINARY_OR_HEX = enum.auto()
|
|
43
|
-
AUTOFLOAT = enum.auto()
|
|
44
|
-
LIST = enum.auto()
|
|
45
|
-
VECTOR = enum.auto()
|
|
46
|
-
MATRIX = enum.auto()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def __init__(
|
|
50
|
-
self, key:str,
|
|
51
|
-
value,
|
|
52
|
-
kind : Answer.AnswerKind = AnswerKind.BLANK,
|
|
53
|
-
variable_kind : Answer.VariableKind = VariableKind.STR,
|
|
54
|
-
display=None,
|
|
55
|
-
length=None,
|
|
56
|
-
correct=True,
|
|
57
|
-
baffles=None,
|
|
58
|
-
pdf_only=False
|
|
59
|
-
):
|
|
60
|
-
self.key = key
|
|
61
|
-
self.value = value
|
|
62
|
-
self.kind = kind
|
|
63
|
-
self.variable_kind = variable_kind
|
|
64
|
-
# For list values in display, show the first option (or join them with /)
|
|
65
|
-
if display is not None:
|
|
66
|
-
self.display = display
|
|
67
|
-
elif isinstance(value, list) and variable_kind == Answer.VariableKind.STR:
|
|
68
|
-
self.display = value[0] if len(value) == 1 else " / ".join(value)
|
|
69
|
-
else:
|
|
70
|
-
self.display = value
|
|
71
|
-
self.length = length # Used for bits and hex to be printed appropriately
|
|
72
|
-
self.correct = correct
|
|
73
|
-
self.baffles = baffles
|
|
74
|
-
self.pdf_only = pdf_only
|
|
75
|
-
|
|
76
|
-
def get_for_canvas(self, single_answer=False) -> List[Dict]:
|
|
77
|
-
# If this answer is marked as PDF-only, don't send it to Canvas
|
|
78
|
-
if self.pdf_only:
|
|
79
|
-
return []
|
|
80
|
-
|
|
81
|
-
canvas_answers : List[Dict] = []
|
|
82
|
-
if self.variable_kind == Answer.VariableKind.BINARY:
|
|
83
|
-
canvas_answers = [
|
|
84
|
-
{
|
|
85
|
-
"blank_id": self.key,
|
|
86
|
-
"answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
|
|
87
|
-
"answer_weight": 100 if self.correct else 0,
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
"blank_id": self.key,
|
|
91
|
-
"answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
|
|
92
|
-
"answer_weight": 100 if self.correct else 0,
|
|
93
|
-
}
|
|
94
|
-
]
|
|
95
|
-
elif self.variable_kind == Answer.VariableKind.HEX:
|
|
96
|
-
canvas_answers = [
|
|
97
|
-
{
|
|
98
|
-
"blank_id": self.key,
|
|
99
|
-
"answer_text": f"{self.value:0{(self.length // 8) + 1 if self.length is not None else 0}X}",
|
|
100
|
-
"answer_weight": 100 if self.correct else 0,
|
|
101
|
-
},{
|
|
102
|
-
"blank_id": self.key,
|
|
103
|
-
"answer_text": f"0x{self.value:0{(self.length // 8) + 1 if self.length is not None else 0}X}",
|
|
104
|
-
"answer_weight": 100 if self.correct else 0,
|
|
105
|
-
}
|
|
106
|
-
]
|
|
107
|
-
elif self.variable_kind == Answer.VariableKind.BINARY_OR_HEX:
|
|
108
|
-
canvas_answers = [
|
|
109
|
-
{
|
|
110
|
-
"blank_id": self.key,
|
|
111
|
-
"answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
|
|
112
|
-
"answer_weight": 100 if self.correct else 0,
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
"blank_id": self.key,
|
|
116
|
-
"answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
|
|
117
|
-
"answer_weight": 100 if self.correct else 0,
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
"blank_id": self.key,
|
|
121
|
-
"answer_text": f"{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
|
|
122
|
-
"answer_weight": 100 if self.correct else 0,
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
"blank_id": self.key,
|
|
126
|
-
"answer_text": f"0x{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
|
|
127
|
-
"answer_weight": 100 if self.correct else 0,
|
|
128
|
-
},
|
|
129
|
-
{
|
|
130
|
-
"blank_id": self.key,
|
|
131
|
-
"answer_text": f"{self.value}",
|
|
132
|
-
"answer_weight": 100 if self.correct else 0,
|
|
133
|
-
},
|
|
134
|
-
|
|
135
|
-
]
|
|
136
|
-
elif self.variable_kind in [
|
|
137
|
-
Answer.VariableKind.AUTOFLOAT,
|
|
138
|
-
Answer.VariableKind.FLOAT,
|
|
139
|
-
Answer.VariableKind.INT
|
|
140
|
-
]:
|
|
141
|
-
if single_answer:
|
|
142
|
-
canvas_answers = [
|
|
143
|
-
{
|
|
144
|
-
"numerical_answer_type": "exact_answer",
|
|
145
|
-
"answer_text": round(self.value, self.DEFAULT_ROUNDING_DIGITS),
|
|
146
|
-
"answer_exact": round(self.value, self.DEFAULT_ROUNDING_DIGITS),
|
|
147
|
-
"answer_error_margin": 0.1,
|
|
148
|
-
"answer_weight": 100 if self.correct else 0,
|
|
149
|
-
}
|
|
150
|
-
]
|
|
151
|
-
else:
|
|
152
|
-
# Use the accepted_strings helper with settings that match the original AUTOFLOAT behavior
|
|
153
|
-
answer_strings = self.__class__.accepted_strings(
|
|
154
|
-
self.value,
|
|
155
|
-
allow_integer=True,
|
|
156
|
-
allow_simple_fraction=True,
|
|
157
|
-
max_denominator=3*4*5, # For process questions, these are the numbers of jobs we'd have
|
|
158
|
-
allow_mixed=True,
|
|
159
|
-
include_spaces=False,
|
|
160
|
-
include_fixed_even_if_integer=True
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
canvas_answers = [
|
|
164
|
-
{
|
|
165
|
-
"blank_id": self.key,
|
|
166
|
-
"answer_text": answer_string,
|
|
167
|
-
"answer_weight": 100 if self.correct else 0,
|
|
168
|
-
}
|
|
169
|
-
for answer_string in answer_strings
|
|
170
|
-
]
|
|
171
|
-
|
|
172
|
-
elif self.variable_kind == Answer.VariableKind.VECTOR:
|
|
173
|
-
|
|
174
|
-
# Get all answer variations
|
|
175
|
-
answer_variations = [
|
|
176
|
-
self.__class__.accepted_strings(dimension_value)
|
|
177
|
-
for dimension_value in self.value
|
|
178
|
-
]
|
|
179
|
-
|
|
180
|
-
canvas_answers = []
|
|
181
|
-
for combination in itertools.product(*answer_variations):
|
|
182
|
-
# Add parentheses format for all vectors: (1, 2, 3)
|
|
183
|
-
canvas_answers.append({
|
|
184
|
-
"blank_id" : self.key,
|
|
185
|
-
"answer_weight": 100 if self.correct else 0,
|
|
186
|
-
"answer_text": f"({', '.join(list(combination))})",
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
# Add non-parentheses format only for single-element vectors: 5
|
|
190
|
-
if len(combination) == 1:
|
|
191
|
-
canvas_answers.append(
|
|
192
|
-
{
|
|
193
|
-
"blank_id": self.key,
|
|
194
|
-
"answer_weight": 100 if self.correct else 0,
|
|
195
|
-
"answer_text": f"{', '.join(combination)}",
|
|
196
|
-
}
|
|
197
|
-
)
|
|
198
|
-
return canvas_answers
|
|
199
|
-
|
|
200
|
-
elif self.variable_kind == Answer.VariableKind.LIST:
|
|
201
|
-
canvas_answers = [
|
|
202
|
-
{
|
|
203
|
-
"blank_id": self.key,
|
|
204
|
-
"answer_text": ', '.join(map(str, possible_state)),
|
|
205
|
-
"answer_weight": 100 if self.correct else 0,
|
|
206
|
-
}
|
|
207
|
-
for possible_state in [self.value] #itertools.permutations(self.value)
|
|
208
|
-
]
|
|
209
|
-
|
|
210
|
-
else:
|
|
211
|
-
# For string answers, check if value is a list of acceptable alternatives
|
|
212
|
-
if isinstance(self.value, list):
|
|
213
|
-
canvas_answers = [
|
|
214
|
-
{
|
|
215
|
-
"blank_id": self.key,
|
|
216
|
-
"answer_text": str(alt),
|
|
217
|
-
"answer_weight": 100 if self.correct else 0,
|
|
218
|
-
}
|
|
219
|
-
for alt in self.value
|
|
220
|
-
]
|
|
221
|
-
else:
|
|
222
|
-
canvas_answers = [{
|
|
223
|
-
"blank_id": self.key,
|
|
224
|
-
"answer_text": self.value,
|
|
225
|
-
"answer_weight": 100 if self.correct else 0,
|
|
226
|
-
}]
|
|
227
|
-
|
|
228
|
-
if self.baffles is not None:
|
|
229
|
-
for baffle in self.baffles:
|
|
230
|
-
canvas_answers.append({
|
|
231
|
-
"blank_id": self.key,
|
|
232
|
-
"answer_text": baffle,
|
|
233
|
-
"answer_weight": 0,
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
return canvas_answers
|
|
237
|
-
|
|
238
|
-
def get_display_string(self) -> str:
|
|
239
|
-
"""
|
|
240
|
-
Get the formatted display string for this answer (for grading/answer keys).
|
|
241
|
-
|
|
242
|
-
Returns the answer in the most appropriate format for human readability:
|
|
243
|
-
- BINARY_OR_HEX: hex format (0x...)
|
|
244
|
-
- BINARY: binary format (0b...)
|
|
245
|
-
- HEX: hex format (0x...)
|
|
246
|
-
- AUTOFLOAT/FLOAT: rounded to DEFAULT_ROUNDING_DIGITS
|
|
247
|
-
- INT: integer
|
|
248
|
-
- STR/LIST/VECTOR: as-is
|
|
249
|
-
"""
|
|
250
|
-
if self.variable_kind == Answer.VariableKind.BINARY_OR_HEX:
|
|
251
|
-
# For binary_hex answers, show hex format (more compact and readable)
|
|
252
|
-
hex_digits = math.ceil(self.length / 4) if self.length is not None else 0
|
|
253
|
-
return f"0x{self.value:0{hex_digits}X}"
|
|
254
|
-
|
|
255
|
-
elif self.variable_kind == Answer.VariableKind.BINARY:
|
|
256
|
-
# Show binary format
|
|
257
|
-
return f"0b{self.value:0{self.length if self.length is not None else 0}b}"
|
|
258
|
-
|
|
259
|
-
elif self.variable_kind == Answer.VariableKind.HEX:
|
|
260
|
-
# Show hex format
|
|
261
|
-
hex_digits = (self.length // 4) + 1 if self.length is not None else 0
|
|
262
|
-
return f"0x{self.value:0{hex_digits}X}"
|
|
263
|
-
|
|
264
|
-
elif self.variable_kind == Answer.VariableKind.AUTOFLOAT:
|
|
265
|
-
# Round to default precision for readability
|
|
266
|
-
rounded = round(self.value, self.DEFAULT_ROUNDING_DIGITS)
|
|
267
|
-
return f"{fix_negative_zero(rounded)}"
|
|
268
|
-
|
|
269
|
-
elif self.variable_kind == Answer.VariableKind.FLOAT:
|
|
270
|
-
# Round to default precision
|
|
271
|
-
if isinstance(self.value, (list, tuple)):
|
|
272
|
-
rounded = round(self.value[0], self.DEFAULT_ROUNDING_DIGITS)
|
|
273
|
-
return f"{fix_negative_zero(rounded)}"
|
|
274
|
-
rounded = round(self.value, self.DEFAULT_ROUNDING_DIGITS)
|
|
275
|
-
return f"{fix_negative_zero(rounded)}"
|
|
276
|
-
|
|
277
|
-
elif self.variable_kind == Answer.VariableKind.INT:
|
|
278
|
-
return str(int(self.value))
|
|
279
|
-
|
|
280
|
-
elif self.variable_kind == Answer.VariableKind.LIST:
|
|
281
|
-
return ", ".join(str(v) for v in self.value)
|
|
282
|
-
|
|
283
|
-
elif self.variable_kind == Answer.VariableKind.VECTOR:
|
|
284
|
-
# Format as comma-separated rounded values
|
|
285
|
-
return ", ".join(str(fix_negative_zero(round(v, self.DEFAULT_ROUNDING_DIGITS))) for v in self.value)
|
|
286
|
-
|
|
287
|
-
else:
|
|
288
|
-
# Default: use display or value
|
|
289
|
-
return str(self.display if hasattr(self, 'display') else self.value)
|
|
290
|
-
|
|
291
|
-
def get_ast_element(self, label=None):
|
|
292
|
-
from QuizGenerator.contentast import ContentAST
|
|
293
|
-
|
|
294
|
-
return ContentAST.Answer(answer=self, label=label) # todo fix label
|
|
295
|
-
|
|
296
|
-
# Factory methods for common answer types
|
|
297
|
-
@classmethod
|
|
298
|
-
def binary_hex(cls, key: str, value: int, length: int = None, **kwargs) -> 'Answer':
|
|
299
|
-
"""Create an answer that accepts binary or hex format"""
|
|
300
|
-
return cls(
|
|
301
|
-
key=key,
|
|
302
|
-
value=value,
|
|
303
|
-
variable_kind=cls.VariableKind.BINARY_OR_HEX,
|
|
304
|
-
length=length,
|
|
305
|
-
**kwargs
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
@classmethod
|
|
309
|
-
def auto_float(cls, key: str, value: float, **kwargs) -> 'Answer':
|
|
310
|
-
"""Create an answer that accepts multiple float formats (decimal, fraction, mixed)"""
|
|
311
|
-
return cls(
|
|
312
|
-
key=key,
|
|
313
|
-
value=value,
|
|
314
|
-
variable_kind=cls.VariableKind.AUTOFLOAT,
|
|
315
|
-
**kwargs
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
@classmethod
|
|
319
|
-
def integer(cls, key: str, value: int, **kwargs) -> 'Answer':
|
|
320
|
-
"""Create an integer answer"""
|
|
321
|
-
return cls(
|
|
322
|
-
key=key,
|
|
323
|
-
value=value,
|
|
324
|
-
variable_kind=cls.VariableKind.INT,
|
|
325
|
-
**kwargs
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
@classmethod
|
|
329
|
-
def string(cls, key: str, value: str, **kwargs) -> 'Answer':
|
|
330
|
-
"""Create a string answer"""
|
|
331
|
-
return cls(
|
|
332
|
-
key=key,
|
|
333
|
-
value=value,
|
|
334
|
-
variable_kind=cls.VariableKind.STR,
|
|
335
|
-
**kwargs
|
|
336
|
-
)
|
|
337
|
-
|
|
338
|
-
@classmethod
|
|
339
|
-
def binary(cls, key: str, value: int, length: int = None, **kwargs) -> 'Answer':
|
|
340
|
-
"""Create a binary-only answer"""
|
|
341
|
-
return cls(
|
|
342
|
-
key=key,
|
|
343
|
-
value=value,
|
|
344
|
-
variable_kind=cls.VariableKind.BINARY,
|
|
345
|
-
length=length,
|
|
346
|
-
**kwargs
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
@classmethod
|
|
350
|
-
def hex_value(cls, key: str, value: int, length: int = None, **kwargs) -> 'Answer':
|
|
351
|
-
"""Create a hex-only answer"""
|
|
352
|
-
return cls(
|
|
353
|
-
key=key,
|
|
354
|
-
value=value,
|
|
355
|
-
variable_kind=cls.VariableKind.HEX,
|
|
356
|
-
length=length,
|
|
357
|
-
**kwargs
|
|
358
|
-
)
|
|
359
|
-
|
|
360
|
-
@classmethod
|
|
361
|
-
def float_value(cls, key: str, value: Tuple[float], **kwargs) -> 'Answer':
|
|
362
|
-
"""Create a simple float answer (no fraction conversion)"""
|
|
363
|
-
return cls(
|
|
364
|
-
key=key,
|
|
365
|
-
value=value,
|
|
366
|
-
variable_kind=cls.VariableKind.FLOAT,
|
|
367
|
-
**kwargs
|
|
368
|
-
)
|
|
369
|
-
|
|
370
|
-
@classmethod
|
|
371
|
-
def list_value(cls, key: str, value: list, **kwargs) -> 'Answer':
|
|
372
|
-
"""Create a list answer (comma-separated values)"""
|
|
373
|
-
return cls(
|
|
374
|
-
key=key,
|
|
375
|
-
value=value,
|
|
376
|
-
variable_kind=cls.VariableKind.LIST,
|
|
377
|
-
**kwargs
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
@classmethod
|
|
381
|
-
def vector_value(cls, key: str, value: List[float], **kwargs) -> 'Answer':
|
|
382
|
-
"""Create a simple float answer (no fraction conversion)"""
|
|
383
|
-
return cls(
|
|
384
|
-
key=key,
|
|
385
|
-
value=value,
|
|
386
|
-
variable_kind=cls.VariableKind.VECTOR,
|
|
387
|
-
**kwargs
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
@classmethod
|
|
391
|
-
def dropdown(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'Answer':
|
|
392
|
-
"""Create a dropdown answer with wrong answer choices (baffles)"""
|
|
393
|
-
return cls(
|
|
394
|
-
key=key,
|
|
395
|
-
value=value,
|
|
396
|
-
kind=cls.AnswerKind.MULTIPLE_DROPDOWN,
|
|
397
|
-
baffles=baffles,
|
|
398
|
-
**kwargs
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
@classmethod
|
|
402
|
-
def multiple_choice(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'Answer':
|
|
403
|
-
"""Create a multiple choice answer with wrong answer choices (baffles)"""
|
|
404
|
-
return cls(
|
|
405
|
-
key=key,
|
|
406
|
-
value=value,
|
|
407
|
-
kind=cls.AnswerKind.MULTIPLE_ANSWER,
|
|
408
|
-
baffles=baffles,
|
|
409
|
-
**kwargs
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
@classmethod
|
|
413
|
-
def essay(cls, key: str, **kwargs) -> 'Answer':
|
|
414
|
-
"""Create an essay question (no specific correct answer)"""
|
|
415
|
-
return cls(
|
|
416
|
-
key=key,
|
|
417
|
-
value="", # Essays don't have predetermined answers
|
|
418
|
-
kind=cls.AnswerKind.ESSAY,
|
|
419
|
-
**kwargs
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
@classmethod
|
|
423
|
-
def matrix(cls, key: str, value: np.array|List, **kwargs ):
|
|
424
|
-
return MatrixAnswer(
|
|
425
|
-
key=key,
|
|
426
|
-
value=value,
|
|
427
|
-
variable_kind=cls.VariableKind.MATRIX
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
@staticmethod
|
|
431
|
-
def _to_fraction(x):
|
|
432
|
-
"""Convert int/float/decimal.Decimal/fractions.Fraction/str('a/b' or decimal) to fractions.Fraction exactly."""
|
|
433
|
-
log.debug(f"x: {x} {x.__class__}")
|
|
434
|
-
if isinstance(x, fractions.Fraction):
|
|
435
|
-
return x
|
|
436
|
-
if isinstance(x, int):
|
|
437
|
-
return fractions.Fraction(x, 1)
|
|
438
|
-
if isinstance(x, decimal.Decimal):
|
|
439
|
-
# exact conversion of decimal.Decimal to fractions.Fraction
|
|
440
|
-
sign, digits, exp = x.as_tuple()
|
|
441
|
-
n = 0
|
|
442
|
-
for d in digits:
|
|
443
|
-
n = n * 10 + d
|
|
444
|
-
n = -n if sign else n
|
|
445
|
-
if exp >= 0:
|
|
446
|
-
return fractions.Fraction(n * (10 ** exp), 1)
|
|
447
|
-
else:
|
|
448
|
-
return fractions.Fraction(n, 10 ** (-exp))
|
|
449
|
-
if isinstance(x, str):
|
|
450
|
-
s = x.strip()
|
|
451
|
-
if '/' in s:
|
|
452
|
-
a, b = s.split('/', 1)
|
|
453
|
-
return fractions.Fraction(int(a.strip()), int(b.strip()))
|
|
454
|
-
return fractions.Fraction(decimal.Decimal(s))
|
|
455
|
-
# float or other numerics
|
|
456
|
-
return fractions.Fraction(decimal.Decimal(str(x)))
|
|
457
|
-
|
|
458
|
-
@staticmethod
|
|
459
|
-
def accepted_strings(
|
|
460
|
-
value,
|
|
461
|
-
*,
|
|
462
|
-
allow_integer=True, # allow "whole numbers as whole numbers"
|
|
463
|
-
allow_simple_fraction=True, # allow simple a/b when denominator small
|
|
464
|
-
max_denominator=720, # how "simple" the fraction is
|
|
465
|
-
allow_mixed=False, # also allow "1 1/2" for 3/2
|
|
466
|
-
include_spaces=False, # also accept "1 / 2"
|
|
467
|
-
include_fixed_even_if_integer=False # include "1.0000" when value is 1 and fixed_decimals is set
|
|
468
|
-
):
|
|
469
|
-
"""
|
|
470
|
-
Return a sorted list of strings you can paste into Canvas as alternate correct answers.
|
|
471
|
-
"""
|
|
472
|
-
decimal.getcontext().prec = max(34, (Answer.DEFAULT_ROUNDING_DIGITS or 0) + 10)
|
|
473
|
-
f = Answer._to_fraction(value)
|
|
474
|
-
outs = set()
|
|
475
|
-
|
|
476
|
-
# Integer form
|
|
477
|
-
if f.denominator == 1 and allow_integer:
|
|
478
|
-
outs.add(str(f.numerator))
|
|
479
|
-
if include_fixed_even_if_integer:
|
|
480
|
-
q = decimal.Decimal(1).scaleb(-Answer.DEFAULT_ROUNDING_DIGITS) # 1e-<fixed_decimals>
|
|
481
|
-
d = decimal.Decimal(f.numerator).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
482
|
-
outs.add(format(d, 'f'))
|
|
483
|
-
|
|
484
|
-
# Fixed-decimal form (exactly N decimals)
|
|
485
|
-
q = decimal.Decimal(1).scaleb(-Answer.DEFAULT_ROUNDING_DIGITS)
|
|
486
|
-
d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
487
|
-
outs.add(format(d, 'f'))
|
|
488
|
-
|
|
489
|
-
# Trimmed decimal (no trailing zeros; up to max_trimmed_decimals)
|
|
490
|
-
if Answer.DEFAULT_ROUNDING_DIGITS:
|
|
491
|
-
q = decimal.Decimal(1).scaleb(-Answer.DEFAULT_ROUNDING_DIGITS)
|
|
492
|
-
d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
493
|
-
s = format(d, 'f').rstrip('0').rstrip('.')
|
|
494
|
-
# ensure we keep leading zero like "0.5"
|
|
495
|
-
if s.startswith('.'):
|
|
496
|
-
s = '0' + s
|
|
497
|
-
if s == '-0': # tidy negative zero
|
|
498
|
-
s = '0'
|
|
499
|
-
outs.add(s)
|
|
500
|
-
|
|
501
|
-
# Simple fraction (reduced, with small denominator)
|
|
502
|
-
if allow_simple_fraction:
|
|
503
|
-
fr = f.limit_denominator(max_denominator)
|
|
504
|
-
if fr == f:
|
|
505
|
-
a, b = fr.numerator, fr.denominator
|
|
506
|
-
outs.add(f"{a}/{b}")
|
|
507
|
-
if include_spaces:
|
|
508
|
-
outs.add(f"{a} / {b}")
|
|
509
|
-
if allow_mixed and b != 1 and abs(a) > b:
|
|
510
|
-
sign = '-' if a < 0 else ''
|
|
511
|
-
A = abs(a)
|
|
512
|
-
whole, rem = divmod(A, b)
|
|
513
|
-
outs.add(f"{sign}{whole} {rem}/{b}")
|
|
514
|
-
|
|
515
|
-
return sorted(outs, key=lambda s: (len(s), s))
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
class MatrixAnswer(Answer):
|
|
519
|
-
def get_for_canvas(self, single_answer=False) -> List[Dict]:
|
|
520
|
-
canvas_answers = []
|
|
521
|
-
|
|
522
|
-
"""
|
|
523
|
-
The core idea is that we will walk through and generate each one for X_i,j
|
|
524
|
-
|
|
525
|
-
The big remaining question is how we will get all these names to the outside world.
|
|
526
|
-
It might have to be a pretty big challenge, or rather re-write.
|
|
527
|
-
"""
|
|
528
|
-
|
|
529
|
-
# The core idea is that we will be walking through and generating a per-index set of answers.
|
|
530
|
-
# Boy will this get messy. Poor canvas.
|
|
531
|
-
for i, j in np.ndindex(self.value.shape):
|
|
532
|
-
entry_strings = self.__class__.accepted_strings(
|
|
533
|
-
self.value[i,j],
|
|
534
|
-
allow_integer=True,
|
|
535
|
-
allow_simple_fraction=True,
|
|
536
|
-
max_denominator=3 * 4 * 5,
|
|
537
|
-
allow_mixed=True,
|
|
538
|
-
include_spaces=False,
|
|
539
|
-
include_fixed_even_if_integer=True
|
|
540
|
-
)
|
|
541
|
-
canvas_answers.extend(
|
|
542
|
-
[
|
|
543
|
-
{
|
|
544
|
-
"blank_id": f"{self.key}_{i}_{j}", # Give each an index associated with it so we can track it
|
|
545
|
-
"answer_text": answer_string,
|
|
546
|
-
"answer_weight": 100 if self.correct else 0,
|
|
547
|
-
}
|
|
548
|
-
for answer_string in entry_strings
|
|
549
|
-
]
|
|
550
|
-
)
|
|
551
|
-
|
|
552
|
-
return canvas_answers
|
|
553
|
-
|
|
554
|
-
def get_ast_element(self, label=None):
|
|
555
|
-
from QuizGenerator.contentast import ContentAST
|
|
556
|
-
|
|
557
|
-
log.debug(f"self.value: {self.value}")
|
|
558
|
-
|
|
559
|
-
data = [
|
|
560
|
-
[
|
|
561
|
-
ContentAST.Answer(
|
|
562
|
-
Answer.float_value(
|
|
563
|
-
key=f"{self.key}_{i}_{j}",
|
|
564
|
-
value=self.value[i,j]
|
|
565
|
-
)
|
|
566
|
-
)
|
|
567
|
-
for j in range(self.value.shape[1])
|
|
568
|
-
]
|
|
569
|
-
for i in range(self.value.shape[0])
|
|
570
|
-
]
|
|
571
|
-
table = ContentAST.Table(data)
|
|
572
|
-
|
|
573
|
-
if label is not None:
|
|
574
|
-
return ContentAST.Container([
|
|
575
|
-
ContentAST.Text(f"{label} = "),
|
|
576
|
-
table
|
|
577
|
-
])
|
|
578
|
-
else:
|
|
579
|
-
return table
|
|
26
|
+
# Backward compatibility: Answer and MatrixAnswer have moved to ContentAST
|
|
27
|
+
# Re-export them here so existing imports continue to work
|
|
28
|
+
Answer = ContentAST.Answer
|
|
29
|
+
MatrixAnswer = ContentAST.MatrixAnswer
|