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.
Files changed (31) hide show
  1. QuizGenerator/contentast.py +952 -82
  2. QuizGenerator/generate.py +45 -9
  3. QuizGenerator/misc.py +4 -554
  4. QuizGenerator/mixins.py +47 -25
  5. QuizGenerator/premade_questions/cst334/languages.py +139 -125
  6. QuizGenerator/premade_questions/cst334/math_questions.py +78 -66
  7. QuizGenerator/premade_questions/cst334/memory_questions.py +258 -144
  8. QuizGenerator/premade_questions/cst334/persistence_questions.py +71 -33
  9. QuizGenerator/premade_questions/cst334/process.py +554 -64
  10. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +32 -6
  11. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +59 -34
  12. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +27 -8
  13. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +53 -32
  14. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +228 -88
  15. QuizGenerator/premade_questions/cst463/models/attention.py +26 -10
  16. QuizGenerator/premade_questions/cst463/models/cnns.py +32 -19
  17. QuizGenerator/premade_questions/cst463/models/rnns.py +25 -12
  18. QuizGenerator/premade_questions/cst463/models/text.py +26 -11
  19. QuizGenerator/premade_questions/cst463/models/weight_counting.py +36 -22
  20. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +89 -109
  21. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +117 -51
  22. QuizGenerator/question.py +110 -15
  23. QuizGenerator/quiz.py +81 -24
  24. QuizGenerator/regenerate.py +98 -29
  25. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/METADATA +1 -1
  26. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/RECORD +29 -31
  27. QuizGenerator/README.md +0 -5
  28. QuizGenerator/logging.yaml +0 -55
  29. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/WHEEL +0 -0
  30. {quizgenerator-0.4.4.dist-info → quizgenerator-0.5.1.dist-info}/entry_points.txt +0 -0
  31. {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("--typst", action="store_true",
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
- tmp_tex = tempfile.NamedTemporaryFile('w')
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 generate_typst(typst_text, remove_previous=False):
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
- tmp_typ = tempfile.NamedTemporaryFile('w', suffix='.typ', delete=False)
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
- class Answer:
27
- DEFAULT_ROUNDING_DIGITS = 4
28
-
29
- class AnswerKind(enum.Enum):
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