QuizGenerator 0.4.2__py3-none-any.whl → 0.6.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/contentast.py +809 -117
- QuizGenerator/generate.py +219 -11
- QuizGenerator/misc.py +0 -556
- QuizGenerator/mixins.py +50 -29
- QuizGenerator/premade_questions/basic.py +3 -3
- QuizGenerator/premade_questions/cst334/languages.py +183 -175
- QuizGenerator/premade_questions/cst334/math_questions.py +81 -70
- QuizGenerator/premade_questions/cst334/memory_questions.py +262 -165
- QuizGenerator/premade_questions/cst334/persistence_questions.py +83 -60
- QuizGenerator/premade_questions/cst334/process.py +558 -79
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +39 -13
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +61 -36
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +29 -10
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +2 -2
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +60 -43
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +173 -326
- QuizGenerator/premade_questions/cst463/models/attention.py +29 -14
- QuizGenerator/premade_questions/cst463/models/cnns.py +32 -20
- QuizGenerator/premade_questions/cst463/models/rnns.py +28 -15
- QuizGenerator/premade_questions/cst463/models/text.py +29 -15
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +38 -30
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +91 -111
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +128 -55
- QuizGenerator/question.py +114 -20
- QuizGenerator/quiz.py +81 -24
- QuizGenerator/regenerate.py +98 -29
- {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/METADATA +1 -1
- {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/RECORD +31 -33
- QuizGenerator/README.md +0 -5
- QuizGenerator/logging.yaml +0 -55
- {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/WHEEL +0 -0
- {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.4.2.dist-info → quizgenerator-0.6.0.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/contentast.py
CHANGED
|
@@ -2,23 +2,28 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import abc
|
|
4
4
|
import enum
|
|
5
|
-
import re
|
|
6
5
|
import textwrap
|
|
7
6
|
from io import BytesIO
|
|
8
7
|
from typing import List, Callable
|
|
9
8
|
|
|
10
|
-
import numpy
|
|
11
9
|
import pypandoc
|
|
12
10
|
import markdown
|
|
13
11
|
|
|
14
|
-
# from QuizGenerator.misc import Answer
|
|
15
|
-
|
|
16
12
|
from QuizGenerator.qrcode_generator import QuestionQRCode
|
|
17
13
|
import re
|
|
18
14
|
|
|
19
15
|
import logging
|
|
16
|
+
import os
|
|
17
|
+
import uuid
|
|
18
|
+
import itertools
|
|
19
|
+
import math
|
|
20
|
+
import decimal
|
|
21
|
+
import fractions
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
20
24
|
log = logging.getLogger(__name__)
|
|
21
25
|
|
|
26
|
+
|
|
22
27
|
class ContentAST:
|
|
23
28
|
"""
|
|
24
29
|
Content Abstract Syntax Tree - The core content system for quiz generation.
|
|
@@ -664,15 +669,19 @@ class ContentAST:
|
|
|
664
669
|
- Organizing complex question content
|
|
665
670
|
|
|
666
671
|
Example:
|
|
667
|
-
def
|
|
672
|
+
def _get_body(self):
|
|
668
673
|
body = ContentAST.Section()
|
|
674
|
+
answers = []
|
|
669
675
|
body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
|
|
670
676
|
|
|
671
677
|
matrix_data = [[1, 2], [3, 4]]
|
|
672
678
|
body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
|
|
673
679
|
|
|
674
|
-
|
|
675
|
-
|
|
680
|
+
# Answer extends Leaf - add directly to body
|
|
681
|
+
ans = ContentAST.Answer.integer("det", self.determinant, label="Determinant")
|
|
682
|
+
answers.append(ans)
|
|
683
|
+
body.add_element(ans)
|
|
684
|
+
return body, answers
|
|
676
685
|
"""
|
|
677
686
|
pass
|
|
678
687
|
|
|
@@ -729,23 +738,41 @@ class ContentAST:
|
|
|
729
738
|
if self.hide_from_latex:
|
|
730
739
|
return ""
|
|
731
740
|
|
|
732
|
-
#
|
|
733
|
-
content
|
|
734
|
-
|
|
735
|
-
|
|
741
|
+
# Extract code blocks, render them, and replace with placeholders
|
|
742
|
+
# This prevents the # escaping from affecting content inside code blocks
|
|
743
|
+
code_blocks = []
|
|
744
|
+
|
|
745
|
+
def save_code_block(match):
|
|
746
|
+
code_content = match.group(1).strip()
|
|
747
|
+
# Escape quotes for Typst raw() function
|
|
748
|
+
escaped_content = code_content.replace('"', r'\"')
|
|
749
|
+
rendered_block = f"""
|
|
736
750
|
#box(
|
|
737
|
-
raw("
|
|
751
|
+
raw("{escaped_content}",
|
|
738
752
|
block: true
|
|
739
753
|
)
|
|
740
754
|
)
|
|
741
|
-
"""
|
|
755
|
+
"""
|
|
756
|
+
placeholder = f"__CODE_BLOCK_{len(code_blocks)}__"
|
|
757
|
+
code_blocks.append(rendered_block)
|
|
758
|
+
return placeholder
|
|
759
|
+
|
|
760
|
+
# Replace code blocks with placeholders
|
|
761
|
+
content = re.sub(
|
|
762
|
+
r"```\s*(.*)\s*```",
|
|
763
|
+
save_code_block,
|
|
742
764
|
self.content,
|
|
743
765
|
flags=re.DOTALL
|
|
744
766
|
)
|
|
745
767
|
|
|
746
|
-
# In Typst, # starts code/function calls, so we need to escape it
|
|
768
|
+
# In Typst, # starts code/function calls, so we need to escape it in regular text
|
|
769
|
+
# (but not in code blocks, which are now placeholders)
|
|
747
770
|
content = content.replace("# ", r"\# ")
|
|
748
|
-
|
|
771
|
+
|
|
772
|
+
# Restore code blocks
|
|
773
|
+
for i, block in enumerate(code_blocks):
|
|
774
|
+
content = content.replace(f"__CODE_BLOCK_{i}__", block)
|
|
775
|
+
|
|
749
776
|
if self.emphasis:
|
|
750
777
|
content = f"*{content}*"
|
|
751
778
|
return content
|
|
@@ -903,8 +930,19 @@ class ContentAST:
|
|
|
903
930
|
# Convert subscripts and superscripts from LaTeX to Typst
|
|
904
931
|
# LaTeX uses braces: b_{out}, x_{10}, x^{2}
|
|
905
932
|
# Typst uses parentheses for multi-char: b_(out), x_(10), x^(2)
|
|
906
|
-
|
|
907
|
-
|
|
933
|
+
# Multi-character text subscripts need quotes: L_{base} -> L_("base")
|
|
934
|
+
# But numbers don't: x_{10} -> x_(10)
|
|
935
|
+
def convert_sub_super(match):
|
|
936
|
+
content = match.group(1)
|
|
937
|
+
prefix = match.group(0)[0] # '_' or '^'
|
|
938
|
+
# If it's purely numeric or a single char, no quotes needed
|
|
939
|
+
if content.isdigit() or len(content) == 1:
|
|
940
|
+
return f'{prefix}({content})'
|
|
941
|
+
# If it's multi-char text, quote it
|
|
942
|
+
return f'{prefix}("{content}")'
|
|
943
|
+
|
|
944
|
+
latex_str = re.sub(r'_{([^}]+)}', convert_sub_super, latex_str)
|
|
945
|
+
latex_str = re.sub(r'\^{([^}]+)}', convert_sub_super, latex_str)
|
|
908
946
|
|
|
909
947
|
# Convert LaTeX Greek letters to Typst syntax (remove backslash)
|
|
910
948
|
greek_letters = [
|
|
@@ -922,19 +960,66 @@ class ContentAST:
|
|
|
922
960
|
latex_str = latex_str.replace(r'\nabla', 'nabla')
|
|
923
961
|
latex_str = latex_str.replace(r'\times', 'times')
|
|
924
962
|
latex_str = latex_str.replace(r'\cdot', 'dot')
|
|
925
|
-
latex_str = latex_str.replace(r'\partial', '
|
|
963
|
+
latex_str = latex_str.replace(r'\partial', 'partial')
|
|
964
|
+
latex_str = latex_str.replace(r'\sum', 'sum')
|
|
965
|
+
latex_str = latex_str.replace(r'\prod', 'product')
|
|
966
|
+
latex_str = latex_str.replace(r'\int', 'integral')
|
|
967
|
+
latex_str = latex_str.replace(r'\ln', 'ln')
|
|
968
|
+
latex_str = latex_str.replace(r'\log', 'log')
|
|
969
|
+
latex_str = latex_str.replace(r'\exp', 'exp')
|
|
970
|
+
latex_str = latex_str.replace(r'\sin', 'sin')
|
|
971
|
+
latex_str = latex_str.replace(r'\cos', 'cos')
|
|
972
|
+
latex_str = latex_str.replace(r'\tan', 'tan')
|
|
973
|
+
latex_str = latex_str.replace(r'\max', 'max')
|
|
974
|
+
latex_str = latex_str.replace(r'\min', 'min')
|
|
975
|
+
latex_str = latex_str.replace(r'\sqrt', 'sqrt')
|
|
976
|
+
# Convert \text{...} to "..." for Typst
|
|
977
|
+
latex_str = re.sub(r'\\text\{([^}]*)\}', r'"\1"', latex_str)
|
|
978
|
+
# Convert \frac{a}{b} to frac(a, b) for Typst
|
|
979
|
+
latex_str = re.sub(r'\\frac\{([^}]*)\}\{([^}]*)\}', r'frac(\1, \2)', latex_str)
|
|
980
|
+
|
|
981
|
+
# Handle matrix environments (bmatrix, pmatrix, vmatrix, Vmatrix, Bmatrix, matrix)
|
|
982
|
+
# Map bracket types to Typst delimiters
|
|
983
|
+
bracket_map = {
|
|
984
|
+
'bmatrix': '\"[\"', # square brackets
|
|
985
|
+
'pmatrix': '\"(\"', # parentheses (default)
|
|
986
|
+
'vmatrix': '\"|\"', # single vertical bars (determinant)
|
|
987
|
+
'Vmatrix': '\"||\"', # double vertical bars (norm)
|
|
988
|
+
'Bmatrix': '\"{\"', # curly braces
|
|
989
|
+
'matrix': None, # no brackets
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
for env_type, delim in bracket_map.items():
|
|
993
|
+
pattern = rf'\\begin\{{{env_type}\}}(.*?)\\end\{{{env_type}\}}'
|
|
994
|
+
|
|
995
|
+
def make_replacer(delimiter):
|
|
996
|
+
def replace_matrix(match):
|
|
997
|
+
content = match.group(1)
|
|
998
|
+
# Split rows by \\ and columns by &
|
|
999
|
+
rows = content.split(r'\\')
|
|
1000
|
+
rows = [r.strip() for r in rows if r.strip()]
|
|
1001
|
+
|
|
1002
|
+
# Check if it's a vector (single column) or matrix
|
|
1003
|
+
is_vector = all('&' not in row for row in rows)
|
|
1004
|
+
|
|
1005
|
+
if is_vector:
|
|
1006
|
+
# Single column - use semicolons to separate rows in mat()
|
|
1007
|
+
elements = "; ".join(rows)
|
|
1008
|
+
else:
|
|
1009
|
+
# Multiple columns - replace & with , and rows with ;
|
|
1010
|
+
formatted_rows = [row.replace('&', ',').strip() for row in rows]
|
|
1011
|
+
elements = "; ".join(formatted_rows)
|
|
926
1012
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1013
|
+
if delimiter:
|
|
1014
|
+
return f"mat(delim: {delimiter}, {elements})"
|
|
1015
|
+
else:
|
|
1016
|
+
return f"mat({elements})"
|
|
1017
|
+
return replace_matrix
|
|
930
1018
|
|
|
931
|
-
|
|
932
|
-
content = match.group(1)
|
|
933
|
-
elements = content.split(r'\\')
|
|
934
|
-
elements = [e.strip() for e in elements if e.strip()]
|
|
935
|
-
return f"vec({', '.join(elements)})"
|
|
1019
|
+
latex_str = re.sub(pattern, make_replacer(delim), latex_str, flags=re.DOTALL)
|
|
936
1020
|
|
|
937
|
-
|
|
1021
|
+
# Handle \left\| ... \right\| for norms
|
|
1022
|
+
latex_str = re.sub(r'\\\|', '||', latex_str)
|
|
938
1023
|
|
|
939
1024
|
return latex_str
|
|
940
1025
|
|
|
@@ -955,6 +1040,125 @@ class ContentAST:
|
|
|
955
1040
|
|
|
956
1041
|
return cls('\n'.join(equation_lines))
|
|
957
1042
|
|
|
1043
|
+
class MathExpression(Leaf):
|
|
1044
|
+
"""
|
|
1045
|
+
Compose multiple math elements into a single format-independent expression.
|
|
1046
|
+
|
|
1047
|
+
This allows mixing ContentAST elements (like Matrix) with math operators
|
|
1048
|
+
and symbols, with each part rendering appropriately for the target format.
|
|
1049
|
+
|
|
1050
|
+
Example:
|
|
1051
|
+
# Vector magnitude: ||v|| =
|
|
1052
|
+
body.add_element(ContentAST.MathExpression([
|
|
1053
|
+
"||",
|
|
1054
|
+
ContentAST.Matrix(data=[[1], [2], [3]], bracket_type="b"),
|
|
1055
|
+
"|| = "
|
|
1056
|
+
]))
|
|
1057
|
+
|
|
1058
|
+
# Vector addition: a + b =
|
|
1059
|
+
body.add_element(ContentAST.MathExpression([
|
|
1060
|
+
ContentAST.Matrix(data=[[1], [2]], bracket_type="b"),
|
|
1061
|
+
" + ",
|
|
1062
|
+
ContentAST.Matrix(data=[[3], [4]], bracket_type="b"),
|
|
1063
|
+
" = "
|
|
1064
|
+
]))
|
|
1065
|
+
|
|
1066
|
+
Parts can be:
|
|
1067
|
+
- Strings: rendered as-is (math operators, symbols, etc.)
|
|
1068
|
+
- ContentAST elements: call their render method for the target format
|
|
1069
|
+
"""
|
|
1070
|
+
def __init__(self, parts, inline=False):
|
|
1071
|
+
super().__init__("[math_expression]")
|
|
1072
|
+
self.parts = parts # List of strings and/or ContentAST elements
|
|
1073
|
+
self.inline = inline
|
|
1074
|
+
|
|
1075
|
+
def _render_parts(self, output_format, **kwargs):
|
|
1076
|
+
"""Render all parts for the given output format."""
|
|
1077
|
+
rendered = []
|
|
1078
|
+
for part in self.parts:
|
|
1079
|
+
if isinstance(part, str):
|
|
1080
|
+
# Convert LaTeX operators to Typst if needed
|
|
1081
|
+
if output_format == ContentAST.OutputFormat.TYPST:
|
|
1082
|
+
part = part.replace(r'\cdot', ' dot ')
|
|
1083
|
+
part = part.replace(r'\times', ' times ')
|
|
1084
|
+
part = part.replace(r'\div', ' div ')
|
|
1085
|
+
rendered.append(part)
|
|
1086
|
+
elif isinstance(part, ContentAST.Element):
|
|
1087
|
+
# Use dedicated math_content methods if available (cleaner than stripping delimiters)
|
|
1088
|
+
if output_format == ContentAST.OutputFormat.HTML:
|
|
1089
|
+
# For HTML (MathJax), use LaTeX math content
|
|
1090
|
+
if hasattr(part, 'math_content_latex'):
|
|
1091
|
+
rendered.append(part.math_content_latex())
|
|
1092
|
+
else:
|
|
1093
|
+
# Fallback: try to extract from render_html
|
|
1094
|
+
html = part.render_html(**kwargs)
|
|
1095
|
+
html = re.sub(r"<[^>]+>", "", html)
|
|
1096
|
+
html = re.sub(r"^\$\$?\s*\\displaystyle\s*", "", html)
|
|
1097
|
+
html = re.sub(r"^\$\$?\s*", "", html)
|
|
1098
|
+
html = re.sub(r"\s*\$\$?$", "", html)
|
|
1099
|
+
rendered.append(html)
|
|
1100
|
+
elif output_format == ContentAST.OutputFormat.TYPST:
|
|
1101
|
+
if hasattr(part, 'math_content_typst'):
|
|
1102
|
+
rendered.append(part.math_content_typst())
|
|
1103
|
+
else:
|
|
1104
|
+
# Fallback: try to extract from render_typst
|
|
1105
|
+
typst = part.render_typst(**kwargs)
|
|
1106
|
+
typst = re.sub(r"^\s*\$\s*", "", typst)
|
|
1107
|
+
typst = re.sub(r"\s*\$\s*$", "", typst)
|
|
1108
|
+
rendered.append(typst)
|
|
1109
|
+
elif output_format == ContentAST.OutputFormat.LATEX:
|
|
1110
|
+
if hasattr(part, 'math_content_latex'):
|
|
1111
|
+
rendered.append(part.math_content_latex())
|
|
1112
|
+
else:
|
|
1113
|
+
latex = part.render_latex(**kwargs)
|
|
1114
|
+
latex = re.sub(r"^\\begin\{flushleft\}", "", latex)
|
|
1115
|
+
latex = re.sub(r"\\end\{flushleft\}$", "", latex)
|
|
1116
|
+
latex = re.sub(r"^\\\[", "", latex)
|
|
1117
|
+
latex = re.sub(r"\\\]$", "", latex)
|
|
1118
|
+
latex = re.sub(r"^\$", "", latex)
|
|
1119
|
+
latex = re.sub(r"\$~?$", "", latex)
|
|
1120
|
+
rendered.append(latex)
|
|
1121
|
+
elif output_format == ContentAST.OutputFormat.MARKDOWN:
|
|
1122
|
+
if hasattr(part, 'math_content_latex'):
|
|
1123
|
+
rendered.append(part.math_content_latex())
|
|
1124
|
+
else:
|
|
1125
|
+
md = part.render_markdown(**kwargs)
|
|
1126
|
+
md = re.sub(r"^\$", "", md)
|
|
1127
|
+
md = re.sub(r"\$$", "", md)
|
|
1128
|
+
rendered.append(md)
|
|
1129
|
+
else:
|
|
1130
|
+
# Convert to string as fallback
|
|
1131
|
+
rendered.append(str(part))
|
|
1132
|
+
return "".join(rendered)
|
|
1133
|
+
|
|
1134
|
+
def render_markdown(self, **kwargs):
|
|
1135
|
+
content = self._render_parts(ContentAST.OutputFormat.MARKDOWN, **kwargs)
|
|
1136
|
+
if self.inline:
|
|
1137
|
+
return f"${content}$"
|
|
1138
|
+
else:
|
|
1139
|
+
return f"$\\displaystyle {content}$"
|
|
1140
|
+
|
|
1141
|
+
def render_html(self, **kwargs):
|
|
1142
|
+
content = self._render_parts(ContentAST.OutputFormat.HTML, **kwargs)
|
|
1143
|
+
if self.inline:
|
|
1144
|
+
return f"\\({content}\\)"
|
|
1145
|
+
else:
|
|
1146
|
+
return f"<div class='math'>$$ \\displaystyle {content} \\; $$</div>"
|
|
1147
|
+
|
|
1148
|
+
def render_latex(self, **kwargs):
|
|
1149
|
+
content = self._render_parts(ContentAST.OutputFormat.LATEX, **kwargs)
|
|
1150
|
+
if self.inline:
|
|
1151
|
+
return f"${content}$~"
|
|
1152
|
+
else:
|
|
1153
|
+
return f"\\begin{{flushleft}}${content}$\\end{{flushleft}}"
|
|
1154
|
+
|
|
1155
|
+
def render_typst(self, **kwargs):
|
|
1156
|
+
content = self._render_parts(ContentAST.OutputFormat.TYPST, **kwargs)
|
|
1157
|
+
if self.inline:
|
|
1158
|
+
return f"${content}$"
|
|
1159
|
+
else:
|
|
1160
|
+
return f" $ {content} $ "
|
|
1161
|
+
|
|
958
1162
|
class Matrix(Leaf):
|
|
959
1163
|
"""
|
|
960
1164
|
Mathematical matrix renderer for consistent cross-format display.
|
|
@@ -999,7 +1203,6 @@ class ContentAST:
|
|
|
999
1203
|
super().__init__("[matrix]")
|
|
1000
1204
|
|
|
1001
1205
|
# Convert numpy ndarray to list format if needed
|
|
1002
|
-
import numpy as np
|
|
1003
1206
|
if isinstance(data, np.ndarray):
|
|
1004
1207
|
if data.ndim == 1:
|
|
1005
1208
|
# 1D array: convert to column vector [[v1], [v2], [v3]]
|
|
@@ -1038,6 +1241,42 @@ class ContentAST:
|
|
|
1038
1241
|
matrix_content = r" \\ ".join(rows)
|
|
1039
1242
|
return f"\\begin{{{bracket_type}matrix}} {matrix_content} \\end{{{bracket_type}matrix}}"
|
|
1040
1243
|
|
|
1244
|
+
def math_content_latex(self):
|
|
1245
|
+
"""Return raw LaTeX math content without delimiters (for use in MathExpression)."""
|
|
1246
|
+
return ContentAST.Matrix.to_latex(self.data, self.bracket_type)
|
|
1247
|
+
|
|
1248
|
+
def math_content_typst(self):
|
|
1249
|
+
"""Return raw Typst math content without delimiters (for use in MathExpression)."""
|
|
1250
|
+
# Build matrix content
|
|
1251
|
+
rows = []
|
|
1252
|
+
for row in self.data:
|
|
1253
|
+
rows.append(", ".join(str(cell) for cell in row))
|
|
1254
|
+
|
|
1255
|
+
# Check if it's a vector (single column)
|
|
1256
|
+
is_vector = all(len(row) == 1 for row in self.data)
|
|
1257
|
+
|
|
1258
|
+
if is_vector:
|
|
1259
|
+
# Use vec() for vectors
|
|
1260
|
+
matrix_content = ", ".join(str(row[0]) for row in self.data)
|
|
1261
|
+
result = f"vec({matrix_content})"
|
|
1262
|
+
else:
|
|
1263
|
+
# Use mat() for matrices with semicolons separating rows
|
|
1264
|
+
matrix_content = "; ".join(rows)
|
|
1265
|
+
result = f"mat({matrix_content})"
|
|
1266
|
+
|
|
1267
|
+
# Add bracket delimiters if needed
|
|
1268
|
+
if self.bracket_type == "b": # square brackets
|
|
1269
|
+
matrix_content_inner = ", ".join(str(row[0]) for row in self.data) if is_vector else "; ".join(rows)
|
|
1270
|
+
result = f"mat(delim: \"[\", {matrix_content_inner})" if not is_vector else f"vec(delim: \"[\", {matrix_content_inner})"
|
|
1271
|
+
elif self.bracket_type == "v": # vertical bars (determinant)
|
|
1272
|
+
result = f"mat(delim: \"|\", {'; '.join(rows)})"
|
|
1273
|
+
elif self.bracket_type == "V": # double vertical bars (norm)
|
|
1274
|
+
result = f"mat(delim: \"||\", {'; '.join(rows)})"
|
|
1275
|
+
elif self.bracket_type == "B": # curly braces
|
|
1276
|
+
result = f"mat(delim: \"{{\", {'; '.join(rows)})"
|
|
1277
|
+
|
|
1278
|
+
return result
|
|
1279
|
+
|
|
1041
1280
|
def render_markdown(self, **kwargs):
|
|
1042
1281
|
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
1043
1282
|
rows = []
|
|
@@ -1053,7 +1292,7 @@ class ContentAST:
|
|
|
1053
1292
|
def render_html(self, **kwargs):
|
|
1054
1293
|
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
1055
1294
|
rows = []
|
|
1056
|
-
if isinstance(self.data,
|
|
1295
|
+
if isinstance(self.data, np.ndarray):
|
|
1057
1296
|
data = self.data.tolist()
|
|
1058
1297
|
else:
|
|
1059
1298
|
data = self.data
|
|
@@ -1154,8 +1393,6 @@ class ContentAST:
|
|
|
1154
1393
|
def _ensure_image_saved(self):
|
|
1155
1394
|
"""Save image data to file if not already saved."""
|
|
1156
1395
|
if self.path is None:
|
|
1157
|
-
import os
|
|
1158
|
-
import uuid
|
|
1159
1396
|
|
|
1160
1397
|
# Create imgs directory if it doesn't exist (use absolute path)
|
|
1161
1398
|
img_dir = os.path.abspath("imgs")
|
|
@@ -1231,81 +1468,6 @@ class ContentAST:
|
|
|
1231
1468
|
|
|
1232
1469
|
return "\n".join(result)
|
|
1233
1470
|
|
|
1234
|
-
class Answer(Leaf):
|
|
1235
|
-
"""
|
|
1236
|
-
Answer input field that renders as blanks in PDF and shows answers in HTML.
|
|
1237
|
-
|
|
1238
|
-
CRITICAL: Use this for ALL answer inputs in questions.
|
|
1239
|
-
Creates appropriate input fields that work across both PDF and Canvas formats.
|
|
1240
|
-
In PDF, renders as blank lines for students to fill in.
|
|
1241
|
-
In HTML/Canvas, can display the answer for checking.
|
|
1242
|
-
|
|
1243
|
-
When to use:
|
|
1244
|
-
- Any place where students need to input an answer
|
|
1245
|
-
- Numerical answers, short text answers, etc.
|
|
1246
|
-
- Questions requiring fill-in-the-blank responses
|
|
1247
|
-
|
|
1248
|
-
Example:
|
|
1249
|
-
# Basic answer field
|
|
1250
|
-
body.add_element(ContentAST.Answer(
|
|
1251
|
-
answer=self.answer,
|
|
1252
|
-
label="Result",
|
|
1253
|
-
unit="MB"
|
|
1254
|
-
))
|
|
1255
|
-
|
|
1256
|
-
# Multiple choice or complex answers
|
|
1257
|
-
body.add_element(ContentAST.Answer(
|
|
1258
|
-
answer=[self.answer_a, self.answer_b],
|
|
1259
|
-
label="Choose the best answer"
|
|
1260
|
-
))
|
|
1261
|
-
"""
|
|
1262
|
-
|
|
1263
|
-
def __init__(self, answer, label: str = "", unit: str = "", blank_length=5):
|
|
1264
|
-
super().__init__(label)
|
|
1265
|
-
self.answer = answer
|
|
1266
|
-
self.label = label
|
|
1267
|
-
self.unit = unit
|
|
1268
|
-
self.length = blank_length
|
|
1269
|
-
|
|
1270
|
-
def render_markdown(self, **kwargs):
|
|
1271
|
-
if not isinstance(self.answer, list):
|
|
1272
|
-
key_to_display = self.answer.key
|
|
1273
|
-
else:
|
|
1274
|
-
key_to_display = self.answer[0].key
|
|
1275
|
-
return f"{self.label + (':' if len(self.label) > 0 else '')} [{key_to_display}] {self.unit}".strip()
|
|
1276
|
-
|
|
1277
|
-
def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
|
|
1278
|
-
if can_be_numerical:
|
|
1279
|
-
return f"Calculate {self.label}"
|
|
1280
|
-
if show_answers and self.answer:
|
|
1281
|
-
# Show actual answer value using formatted display string
|
|
1282
|
-
if not isinstance(self.answer, list):
|
|
1283
|
-
answer_display = self.answer.get_display_string()
|
|
1284
|
-
else:
|
|
1285
|
-
answer_display = ", ".join(a.get_display_string() for a in self.answer)
|
|
1286
|
-
|
|
1287
|
-
label_part = f"{self.label}:" if self.label else ""
|
|
1288
|
-
unit_part = f" {self.unit}" if self.unit else ""
|
|
1289
|
-
return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
|
|
1290
|
-
else:
|
|
1291
|
-
# Default behavior: show [key]
|
|
1292
|
-
return self.render_markdown(**kwargs)
|
|
1293
|
-
|
|
1294
|
-
def render_latex(self, **kwargs):
|
|
1295
|
-
return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.length}}} {self.unit}".strip()
|
|
1296
|
-
|
|
1297
|
-
def render_typst(self, **kwargs):
|
|
1298
|
-
"""Render answer blank as an underlined space in Typst."""
|
|
1299
|
-
# Use the fillline function defined in TYPST_HEADER
|
|
1300
|
-
# Width is based on self.length (in cm)
|
|
1301
|
-
blank_width = self.length * 0.75 # Convert character length to cm
|
|
1302
|
-
blank = f"#fillline(width: {blank_width}cm)"
|
|
1303
|
-
|
|
1304
|
-
label_part = f"{self.label}:" if self.label else ""
|
|
1305
|
-
unit_part = f" {self.unit}" if self.unit else ""
|
|
1306
|
-
|
|
1307
|
-
return f"{label_part} {blank}{unit_part}".strip()
|
|
1308
|
-
|
|
1309
1471
|
class LineBreak(Text):
|
|
1310
1472
|
def __init__(self, *args, **kwargs):
|
|
1311
1473
|
super().__init__("\n\n")
|
|
@@ -1745,18 +1907,15 @@ class ContentAST:
|
|
|
1745
1907
|
- Better visual grouping of related answers
|
|
1746
1908
|
|
|
1747
1909
|
Example:
|
|
1748
|
-
# Multiple related answers
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
]
|
|
1753
|
-
answer_block = ContentAST.AnswerBlock(answers)
|
|
1910
|
+
# Multiple related answers - Answer extends Leaf, use factory methods
|
|
1911
|
+
memory_ans = ContentAST.Answer.integer("memory", self.memory_value, label="Memory used", unit="MB")
|
|
1912
|
+
time_ans = ContentAST.Answer.auto_float("time", self.time_value, label="Execution time", unit="ms")
|
|
1913
|
+
answer_block = ContentAST.AnswerBlock([memory_ans, time_ans])
|
|
1754
1914
|
body.add_element(answer_block)
|
|
1755
1915
|
|
|
1756
1916
|
# Single answer with better spacing
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
)
|
|
1917
|
+
result_ans = ContentAST.Answer.integer("result", self.result_value, label="Final result")
|
|
1918
|
+
single_answer = ContentAST.AnswerBlock(result_ans)
|
|
1760
1919
|
"""
|
|
1761
1920
|
def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
|
|
1762
1921
|
if not isinstance(answers, list):
|
|
@@ -1920,9 +2079,9 @@ class ContentAST:
|
|
|
1920
2079
|
# Add to main content - only appears in PDF
|
|
1921
2080
|
body.add_element(latex_only)
|
|
1922
2081
|
"""
|
|
1923
|
-
|
|
2082
|
+
|
|
1924
2083
|
def render(self, output_format: ContentAST.OutputFormat, **kwargs):
|
|
1925
|
-
if output_format
|
|
2084
|
+
if output_format not in ("latex", "typst"):
|
|
1926
2085
|
return ""
|
|
1927
2086
|
return super().render(output_format=output_format, **kwargs)
|
|
1928
2087
|
|
|
@@ -1952,4 +2111,537 @@ class ContentAST:
|
|
|
1952
2111
|
if output_format != "html":
|
|
1953
2112
|
return ""
|
|
1954
2113
|
return super().render(output_format, **kwargs)
|
|
2114
|
+
|
|
2115
|
+
class Answer(Leaf):
|
|
2116
|
+
"""
|
|
2117
|
+
Unified answer class combining data storage, Canvas export, and rendering.
|
|
2118
|
+
|
|
2119
|
+
Extends ContentAST.Leaf to integrate seamlessly with the ContentAST tree while
|
|
2120
|
+
maintaining all Canvas export functionality.
|
|
2121
|
+
|
|
2122
|
+
CRITICAL: Use this for ALL answer inputs in questions.
|
|
2123
|
+
Creates appropriate input fields that work across both PDF and Canvas formats.
|
|
2124
|
+
In PDF, renders as blank lines for students to fill in.
|
|
2125
|
+
In HTML/Canvas, can display the answer for checking.
|
|
2126
|
+
|
|
2127
|
+
Example:
|
|
2128
|
+
# Basic answer field
|
|
2129
|
+
ans = ContentAST.Answer.integer("result", 42, label="Result", unit="MB")
|
|
2130
|
+
body.add_element(ans)
|
|
2131
|
+
answers.append(ans) # Track for Canvas export
|
|
2132
|
+
"""
|
|
2133
|
+
|
|
2134
|
+
DEFAULT_ROUNDING_DIGITS = 4
|
|
2135
|
+
|
|
2136
|
+
class CanvasAnswerKind(enum.Enum):
|
|
2137
|
+
BLANK = "fill_in_multiple_blanks_question"
|
|
2138
|
+
MULTIPLE_ANSWER = "multiple_answers_question"
|
|
2139
|
+
ESSAY = "essay_question"
|
|
2140
|
+
MULTIPLE_DROPDOWN = "multiple_dropdowns_question"
|
|
2141
|
+
NUMERICAL_QUESTION = "numerical_question"
|
|
2142
|
+
|
|
2143
|
+
class VariableKind(enum.Enum):
|
|
2144
|
+
STR = enum.auto()
|
|
2145
|
+
INT = enum.auto()
|
|
2146
|
+
FLOAT = enum.auto()
|
|
2147
|
+
BINARY = enum.auto()
|
|
2148
|
+
HEX = enum.auto()
|
|
2149
|
+
BINARY_OR_HEX = enum.auto()
|
|
2150
|
+
AUTOFLOAT = enum.auto()
|
|
2151
|
+
LIST = enum.auto()
|
|
2152
|
+
VECTOR = enum.auto()
|
|
2153
|
+
MATRIX = enum.auto()
|
|
2154
|
+
|
|
2155
|
+
def __init__(
|
|
2156
|
+
self,
|
|
2157
|
+
value=None,
|
|
2158
|
+
kind: ContentAST.Answer.CanvasAnswerKind = None,
|
|
2159
|
+
variable_kind: ContentAST.Answer.VariableKind = None,
|
|
2160
|
+
*,
|
|
2161
|
+
# Data fields (from misc.Answer)
|
|
2162
|
+
display=None,
|
|
2163
|
+
length=None,
|
|
2164
|
+
correct=True,
|
|
2165
|
+
baffles=None,
|
|
2166
|
+
pdf_only=False,
|
|
2167
|
+
# Rendering fields (from ContentAST.Answer)
|
|
2168
|
+
label: str = "",
|
|
2169
|
+
unit: str = "",
|
|
2170
|
+
blank_length=5,
|
|
2171
|
+
):
|
|
2172
|
+
|
|
2173
|
+
# Initialize Leaf with label as content
|
|
2174
|
+
super().__init__(content=label if label else "")
|
|
2175
|
+
|
|
2176
|
+
# Data fields
|
|
2177
|
+
self.key = str(uuid.uuid4())
|
|
2178
|
+
self.value = value
|
|
2179
|
+
self.kind = kind if kind is not None else ContentAST.Answer.CanvasAnswerKind.BLANK
|
|
2180
|
+
self.variable_kind = variable_kind if variable_kind is not None else ContentAST.Answer.VariableKind.STR
|
|
2181
|
+
|
|
2182
|
+
# For list values in display, show the first option (or join them with /)
|
|
2183
|
+
if display is not None:
|
|
2184
|
+
self.display = display
|
|
2185
|
+
elif isinstance(value, list) and isinstance(self.variable_kind, AnswerTypes.String):
|
|
2186
|
+
self.display = value[0] if len(value) == 1 else " / ".join(value)
|
|
2187
|
+
else:
|
|
2188
|
+
self.display = value
|
|
2189
|
+
|
|
2190
|
+
self.length = length # Used for bits and hex to be printed appropriately
|
|
2191
|
+
self.correct = correct
|
|
2192
|
+
self.baffles = baffles
|
|
2193
|
+
self.pdf_only = pdf_only
|
|
2194
|
+
|
|
2195
|
+
# Rendering fields
|
|
2196
|
+
self.label = label
|
|
2197
|
+
self.unit = unit
|
|
2198
|
+
self.blank_length = blank_length
|
|
2199
|
+
|
|
2200
|
+
# Canvas export methods (from misc.Answer)
|
|
2201
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
2202
|
+
"""Generate Canvas answer dictionaries based on variable_kind."""
|
|
2203
|
+
|
|
2204
|
+
# If this answer is PDF-only, don't send it to Canvas
|
|
2205
|
+
if self.pdf_only:
|
|
2206
|
+
return []
|
|
2207
|
+
|
|
2208
|
+
canvas_answers = []
|
|
2209
|
+
if isinstance(self.value, list):
|
|
2210
|
+
canvas_answers = [
|
|
2211
|
+
{
|
|
2212
|
+
"blank_id": self.key,
|
|
2213
|
+
"answer_text": str(alt),
|
|
2214
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2215
|
+
}
|
|
2216
|
+
for alt in self.value
|
|
2217
|
+
]
|
|
2218
|
+
else:
|
|
2219
|
+
canvas_answers = [{
|
|
2220
|
+
"blank_id": self.key,
|
|
2221
|
+
"answer_text": self.value,
|
|
2222
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2223
|
+
}]
|
|
2224
|
+
|
|
2225
|
+
# Add baffles (incorrect answer choices)
|
|
2226
|
+
if self.baffles is not None:
|
|
2227
|
+
for baffle in self.baffles:
|
|
2228
|
+
canvas_answers.append({
|
|
2229
|
+
"blank_id": self.key,
|
|
2230
|
+
"answer_text": baffle,
|
|
2231
|
+
"answer_weight": 0,
|
|
2232
|
+
})
|
|
2233
|
+
|
|
2234
|
+
return canvas_answers
|
|
2235
|
+
|
|
2236
|
+
def get_display_string(self) -> str:
|
|
2237
|
+
"""Get the formatted display string for this answer (for grading/answer keys)."""
|
|
2238
|
+
return str(self.display if hasattr(self, 'display') else self.value)
|
|
2239
|
+
|
|
2240
|
+
# Rendering methods (override Leaf's defaults)
|
|
2241
|
+
def render_markdown(self, **kwargs):
|
|
2242
|
+
return f"{self.label + (':' if len(self.label) > 0 else '')} [{self.key}] {self.unit}".strip()
|
|
2243
|
+
|
|
2244
|
+
def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
|
|
2245
|
+
if can_be_numerical:
|
|
2246
|
+
return f"Calculate {self.label}"
|
|
2247
|
+
if show_answers:
|
|
2248
|
+
answer_display = self.get_display_string()
|
|
2249
|
+
label_part = f"{self.label}:" if self.label else ""
|
|
2250
|
+
unit_part = f" {self.unit}" if self.unit else ""
|
|
2251
|
+
return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
|
|
2252
|
+
else:
|
|
2253
|
+
return self.render_markdown(**kwargs)
|
|
2254
|
+
|
|
2255
|
+
def render_latex(self, **kwargs):
|
|
2256
|
+
return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
|
|
2257
|
+
|
|
2258
|
+
def render_typst(self, **kwargs):
|
|
2259
|
+
"""Render answer blank as an underlined space in Typst."""
|
|
2260
|
+
blank_width = self.blank_length * 0.75 # Convert character length to cm
|
|
2261
|
+
blank = f"#fillline(width: {blank_width}cm)"
|
|
2262
|
+
|
|
2263
|
+
label_part = f"{self.label}:" if self.label else ""
|
|
2264
|
+
unit_part = f" {self.unit}" if self.unit else ""
|
|
2265
|
+
|
|
2266
|
+
return f"{label_part} {blank}{unit_part}".strip()
|
|
2267
|
+
|
|
2268
|
+
# Factory methods for common answer types
|
|
2269
|
+
@classmethod
|
|
2270
|
+
def dropdown(cls, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
|
|
2271
|
+
"""Create a dropdown answer with wrong answer choices (baffles)"""
|
|
2272
|
+
return cls(
|
|
2273
|
+
value=value,
|
|
2274
|
+
kind=cls.CanvasAnswerKind.MULTIPLE_DROPDOWN,
|
|
2275
|
+
baffles=baffles,
|
|
2276
|
+
**kwargs
|
|
2277
|
+
)
|
|
2278
|
+
|
|
2279
|
+
@classmethod
|
|
2280
|
+
def multiple_choice(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
|
|
2281
|
+
"""Create a multiple choice answer with wrong answer choices (baffles)"""
|
|
2282
|
+
return cls(
|
|
2283
|
+
value=value,
|
|
2284
|
+
kind=cls.CanvasAnswerKind.MULTIPLE_ANSWER,
|
|
2285
|
+
baffles=baffles,
|
|
2286
|
+
**kwargs
|
|
2287
|
+
)
|
|
2288
|
+
|
|
2289
|
+
# Static helper methods
|
|
2290
|
+
@staticmethod
|
|
2291
|
+
def _to_fraction(x):
|
|
2292
|
+
"""Convert int/float/decimal.Decimal/fractions.Fraction/str to fractions.Fraction exactly."""
|
|
2293
|
+
if isinstance(x, fractions.Fraction):
|
|
2294
|
+
return x
|
|
2295
|
+
if isinstance(x, int):
|
|
2296
|
+
return fractions.Fraction(x, 1)
|
|
2297
|
+
if isinstance(x, decimal.Decimal):
|
|
2298
|
+
# exact conversion of decimal.Decimal to fractions.Fraction
|
|
2299
|
+
sign, digits, exp = x.as_tuple()
|
|
2300
|
+
n = 0
|
|
2301
|
+
for d in digits:
|
|
2302
|
+
n = n * 10 + d
|
|
2303
|
+
n = -n if sign else n
|
|
2304
|
+
if exp >= 0:
|
|
2305
|
+
return fractions.Fraction(n * (10 ** exp), 1)
|
|
2306
|
+
else:
|
|
2307
|
+
return fractions.Fraction(n, 10 ** (-exp))
|
|
2308
|
+
if isinstance(x, str):
|
|
2309
|
+
s = x.strip()
|
|
2310
|
+
if '/' in s:
|
|
2311
|
+
a, b = s.split('/', 1)
|
|
2312
|
+
return fractions.Fraction(int(a.strip()), int(b.strip()))
|
|
2313
|
+
return fractions.Fraction(decimal.Decimal(s))
|
|
2314
|
+
# float or other numerics
|
|
2315
|
+
return fractions.Fraction(decimal.Decimal(str(x)))
|
|
2316
|
+
|
|
2317
|
+
@staticmethod
|
|
2318
|
+
def accepted_strings(
|
|
2319
|
+
value,
|
|
2320
|
+
*,
|
|
2321
|
+
allow_integer=True,
|
|
2322
|
+
allow_simple_fraction=True,
|
|
2323
|
+
max_denominator=720,
|
|
2324
|
+
allow_mixed=False,
|
|
2325
|
+
include_spaces=False,
|
|
2326
|
+
include_fixed_even_if_integer=False
|
|
2327
|
+
):
|
|
2328
|
+
"""Return a sorted list of strings you can paste into Canvas as alternate correct answers."""
|
|
2329
|
+
decimal.getcontext().prec = max(34, (ContentAST.Answer.DEFAULT_ROUNDING_DIGITS or 0) + 10)
|
|
2330
|
+
f = ContentAST.Answer._to_fraction(value)
|
|
2331
|
+
outs = set()
|
|
2332
|
+
|
|
2333
|
+
# Integer form
|
|
2334
|
+
if f.denominator == 1 and allow_integer:
|
|
2335
|
+
outs.add(str(f.numerator))
|
|
2336
|
+
if include_fixed_even_if_integer:
|
|
2337
|
+
q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
2338
|
+
d = decimal.Decimal(f.numerator).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
2339
|
+
outs.add(format(d, 'f'))
|
|
2340
|
+
|
|
2341
|
+
# Fixed-decimal form
|
|
2342
|
+
q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
2343
|
+
d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
2344
|
+
outs.add(format(d, 'f'))
|
|
2345
|
+
|
|
2346
|
+
# Trimmed decimal
|
|
2347
|
+
if ContentAST.Answer.DEFAULT_ROUNDING_DIGITS:
|
|
2348
|
+
q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
2349
|
+
d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
2350
|
+
s = format(d, 'f').rstrip('0').rstrip('.')
|
|
2351
|
+
if s.startswith('.'):
|
|
2352
|
+
s = '0' + s
|
|
2353
|
+
if s == '-0':
|
|
2354
|
+
s = '0'
|
|
2355
|
+
outs.add(s)
|
|
2356
|
+
|
|
2357
|
+
# Simple fraction
|
|
2358
|
+
if allow_simple_fraction:
|
|
2359
|
+
fr = f.limit_denominator(max_denominator)
|
|
2360
|
+
if fr == f:
|
|
2361
|
+
a, b = fr.numerator, fr.denominator
|
|
2362
|
+
outs.add(f"{a}/{b}")
|
|
2363
|
+
if include_spaces:
|
|
2364
|
+
outs.add(f"{a} / {b}")
|
|
2365
|
+
if allow_mixed and b != 1 and abs(a) > b:
|
|
2366
|
+
sign = '-' if a < 0 else ''
|
|
2367
|
+
A = abs(a)
|
|
2368
|
+
whole, rem = divmod(A, b)
|
|
2369
|
+
outs.add(f"{sign}{whole} {rem}/{b}")
|
|
2370
|
+
|
|
2371
|
+
return sorted(outs, key=lambda s: (len(s), s))
|
|
2372
|
+
|
|
2373
|
+
@staticmethod
|
|
2374
|
+
def fix_negative_zero(x):
|
|
2375
|
+
"""Fix -0.0 display issue."""
|
|
2376
|
+
return 0.0 if x == 0 else x
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
class AnswerTypes:
|
|
2380
|
+
# Multibase answers that can accept either hex, binary or decimal
|
|
2381
|
+
class MultiBase(ContentAST.Answer):
|
|
2382
|
+
"""
|
|
2383
|
+
These are answers that can accept answers in any sort of format, and default to displaying in hex when written out.
|
|
2384
|
+
This will be the parent class for Binary, Hex, and Integer answers most likely.
|
|
2385
|
+
"""
|
|
2386
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
2387
|
+
|
|
2388
|
+
canvas_answers = [
|
|
2389
|
+
{
|
|
2390
|
+
"blank_id": self.key,
|
|
2391
|
+
"answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
|
|
2392
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2393
|
+
},
|
|
2394
|
+
{
|
|
2395
|
+
"blank_id": self.key,
|
|
2396
|
+
"answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
|
|
2397
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2398
|
+
},
|
|
2399
|
+
{
|
|
2400
|
+
"blank_id": self.key,
|
|
2401
|
+
"answer_text": f"{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
|
|
2402
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2403
|
+
},
|
|
2404
|
+
{
|
|
2405
|
+
"blank_id": self.key,
|
|
2406
|
+
"answer_text": f"0x{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
|
|
2407
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2408
|
+
},
|
|
2409
|
+
{
|
|
2410
|
+
"blank_id": self.key,
|
|
2411
|
+
"answer_text": f"{self.value}",
|
|
2412
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2413
|
+
},
|
|
2414
|
+
]
|
|
2415
|
+
|
|
2416
|
+
return canvas_answers
|
|
2417
|
+
|
|
2418
|
+
def get_display_string(self) -> str:
|
|
2419
|
+
# This is going to be the default for multi-base answers, but may change later.
|
|
2420
|
+
hex_digits = (self.length // 4) + 1 if self.length is not None else 0
|
|
2421
|
+
return f"0x{self.value:0{hex_digits}X}"
|
|
2422
|
+
|
|
2423
|
+
class Hex(MultiBase):
|
|
2424
|
+
pass
|
|
2425
|
+
|
|
2426
|
+
class Binary(MultiBase):
|
|
2427
|
+
def get_display_string(self) -> str:
|
|
2428
|
+
return f"0b{self.value:0{self.length if self.length is not None else 0}b}"
|
|
2429
|
+
|
|
2430
|
+
class Decimal(MultiBase):
|
|
2431
|
+
def get_display_string(self) -> str:
|
|
2432
|
+
return f"{self.value:0{self.length if self.length is not None else 0}}"
|
|
2433
|
+
|
|
2434
|
+
# Concrete type answers
|
|
2435
|
+
class Float(ContentAST.Answer):
|
|
2436
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
2437
|
+
if single_answer:
|
|
2438
|
+
canvas_answers = [
|
|
2439
|
+
{
|
|
2440
|
+
"numerical_answer_type": "exact_answer",
|
|
2441
|
+
"answer_text": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
|
|
2442
|
+
"answer_exact": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
|
|
2443
|
+
"answer_error_margin": 0.1,
|
|
2444
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2445
|
+
}
|
|
2446
|
+
]
|
|
2447
|
+
else:
|
|
2448
|
+
# Use the accepted_strings helper
|
|
2449
|
+
answer_strings = ContentAST.Answer.accepted_strings(
|
|
2450
|
+
self.value,
|
|
2451
|
+
allow_integer=True,
|
|
2452
|
+
allow_simple_fraction=True,
|
|
2453
|
+
max_denominator=60,
|
|
2454
|
+
allow_mixed=True,
|
|
2455
|
+
include_spaces=False,
|
|
2456
|
+
include_fixed_even_if_integer=True
|
|
2457
|
+
)
|
|
2458
|
+
|
|
2459
|
+
canvas_answers = [
|
|
2460
|
+
{
|
|
2461
|
+
"blank_id": self.key,
|
|
2462
|
+
"answer_text": answer_string,
|
|
2463
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2464
|
+
}
|
|
2465
|
+
for answer_string in answer_strings
|
|
2466
|
+
]
|
|
2467
|
+
return canvas_answers
|
|
2468
|
+
|
|
2469
|
+
def get_display_string(self) -> str:
|
|
2470
|
+
rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
2471
|
+
return f"{self.fix_negative_zero(rounded)}"
|
|
2472
|
+
|
|
2473
|
+
class Int(ContentAST.Answer):
|
|
2474
|
+
|
|
2475
|
+
# Canvas export methods (from misc.Answer)
|
|
2476
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
2477
|
+
canvas_answers = [
|
|
2478
|
+
{
|
|
2479
|
+
"blank_id": self.key,
|
|
2480
|
+
"answer_text": str(int(self.value)),
|
|
2481
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2482
|
+
}
|
|
2483
|
+
]
|
|
2484
|
+
return canvas_answers
|
|
2485
|
+
|
|
2486
|
+
# Open Ended
|
|
2487
|
+
class OpenEnded(ContentAST.Answer):
|
|
2488
|
+
def __init__(self, *args, **kwargs):
|
|
2489
|
+
super().__init__(*args, **kwargs)
|
|
2490
|
+
self.kind=ContentAST.Answer.CanvasAnswerKind.ESSAY
|
|
2491
|
+
|
|
2492
|
+
class String(ContentAST.Answer):
|
|
2493
|
+
pass
|
|
2494
|
+
|
|
2495
|
+
class List(ContentAST.Answer):
|
|
2496
|
+
def __init__(self, order_matters=True, *args, **kwargs):
|
|
2497
|
+
super().__init__(*args, **kwargs)
|
|
2498
|
+
self.order_matters = order_matters
|
|
2499
|
+
|
|
2500
|
+
def get_for_canvas(self, single_answer=False):
|
|
2501
|
+
if self.order_matters:
|
|
2502
|
+
canvas_answers = [
|
|
2503
|
+
{
|
|
2504
|
+
"blank_id": self.key,
|
|
2505
|
+
"answer_text": ', '.join(map(str, self.value)),
|
|
2506
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
"blank_id": self.key,
|
|
2510
|
+
"answer_text": ','.join(map(str, self.value)),
|
|
2511
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2512
|
+
}
|
|
2513
|
+
]
|
|
2514
|
+
else:
|
|
2515
|
+
canvas_answers = []
|
|
2516
|
+
|
|
2517
|
+
# With spaces
|
|
2518
|
+
canvas_answers.extend([
|
|
2519
|
+
{
|
|
2520
|
+
"blank_id": self.key,
|
|
2521
|
+
"answer_text": ', '.join(map(str, possible_state)),
|
|
2522
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2523
|
+
}
|
|
2524
|
+
for possible_state in itertools.permutations(self.value)
|
|
2525
|
+
])
|
|
2526
|
+
|
|
2527
|
+
# Without spaces
|
|
2528
|
+
canvas_answers.extend([
|
|
2529
|
+
{
|
|
2530
|
+
"blank_id": self.key,
|
|
2531
|
+
"answer_text": ','.join(map(str, possible_state)),
|
|
2532
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2533
|
+
}
|
|
2534
|
+
for possible_state in itertools.permutations(self.value)
|
|
2535
|
+
])
|
|
2536
|
+
return canvas_answers
|
|
2537
|
+
|
|
2538
|
+
def get_display_string(self) -> str:
|
|
2539
|
+
"""Get the formatted display string for this answer (for grading/answer keys)."""
|
|
2540
|
+
return ", ".join(str(v) for v in self.value)
|
|
2541
|
+
|
|
2542
|
+
# Math types
|
|
2543
|
+
class Vector(ContentAST.Answer):
|
|
2544
|
+
"""
|
|
2545
|
+
These are self-contained vectors that will go in a single answer block
|
|
2546
|
+
"""
|
|
2547
|
+
|
|
2548
|
+
# Canvas export methods (from misc.Answer)
|
|
2549
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
2550
|
+
# Get all answer variations
|
|
2551
|
+
answer_variations = [
|
|
2552
|
+
ContentAST.Answer.accepted_strings(dimension_value)
|
|
2553
|
+
for dimension_value in self.value
|
|
2554
|
+
]
|
|
2555
|
+
|
|
2556
|
+
canvas_answers = []
|
|
2557
|
+
for combination in itertools.product(*answer_variations):
|
|
2558
|
+
# Add without anything surrounding
|
|
2559
|
+
canvas_answers.extend([
|
|
2560
|
+
{
|
|
2561
|
+
"blank_id": self.key,
|
|
2562
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2563
|
+
"answer_text": f"{', '.join(combination)}",
|
|
2564
|
+
},
|
|
2565
|
+
{
|
|
2566
|
+
"blank_id": self.key,
|
|
2567
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2568
|
+
"answer_text": f"{','.join(combination)}",
|
|
2569
|
+
},
|
|
2570
|
+
# Add parentheses format
|
|
2571
|
+
{
|
|
2572
|
+
"blank_id": self.key,
|
|
2573
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2574
|
+
"answer_text": f"({', '.join(list(combination))})",
|
|
2575
|
+
},
|
|
2576
|
+
{
|
|
2577
|
+
"blank_id": self.key,
|
|
2578
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2579
|
+
"answer_text": f"({','.join(list(combination))})",
|
|
2580
|
+
},
|
|
2581
|
+
# Add square brackets
|
|
2582
|
+
{
|
|
2583
|
+
"blank_id": self.key,
|
|
2584
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2585
|
+
"answer_text": f"[{', '.join(list(combination))}]",
|
|
2586
|
+
},
|
|
2587
|
+
{
|
|
2588
|
+
"blank_id": self.key,
|
|
2589
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2590
|
+
"answer_text": f"[{','.join(list(combination))}]",
|
|
2591
|
+
}
|
|
2592
|
+
])
|
|
2593
|
+
return canvas_answers
|
|
2594
|
+
|
|
2595
|
+
def get_display_string(self) -> str:
|
|
2596
|
+
return ", ".join(
|
|
2597
|
+
str(self.fix_negative_zero(round(v, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS))).rstrip('0').rstrip('.') for v in self.value
|
|
2598
|
+
)
|
|
2599
|
+
|
|
2600
|
+
class CompoundAnswers(ContentAST.Answer):
|
|
2601
|
+
pass
|
|
2602
|
+
"""
|
|
2603
|
+
Going forward, this might make a lot of sense to have a SubAnswer class that we can iterate over.
|
|
2604
|
+
We would convert into this shared format and just iterate over it whenever we need to.
|
|
2605
|
+
"""
|
|
2606
|
+
|
|
2607
|
+
class Matrix(CompoundAnswers):
|
|
2608
|
+
"""
|
|
2609
|
+
Matrix answers generate multiple blank_ids (e.g., M_0_0, M_0_1, M_1_0, M_1_1).
|
|
2610
|
+
"""
|
|
2611
|
+
|
|
2612
|
+
def __init__(self, value, *args, **kwargs):
|
|
2613
|
+
super().__init__(value=value, *args, **kwargs)
|
|
2614
|
+
|
|
2615
|
+
self.data = [
|
|
2616
|
+
[
|
|
2617
|
+
AnswerTypes.Float(
|
|
2618
|
+
value=self.value[i, j],
|
|
2619
|
+
blank_length=5
|
|
2620
|
+
)
|
|
2621
|
+
for j in range(self.value.shape[1])
|
|
2622
|
+
]
|
|
2623
|
+
for i in range(self.value.shape[0])
|
|
2624
|
+
]
|
|
2625
|
+
|
|
2626
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
2627
|
+
"""Generate Canvas answers for each matrix element."""
|
|
2628
|
+
canvas_answers = []
|
|
2629
|
+
|
|
2630
|
+
for sub_answer in itertools.chain.from_iterable(self.data):
|
|
2631
|
+
canvas_answers.extend(sub_answer.get_for_canvas())
|
|
2632
|
+
|
|
2633
|
+
return canvas_answers
|
|
2634
|
+
|
|
2635
|
+
def render(self, *args, **kwargs) -> str:
|
|
2636
|
+
table = ContentAST.Table(self.data)
|
|
2637
|
+
|
|
2638
|
+
if self.label:
|
|
2639
|
+
return ContentAST.Container(
|
|
2640
|
+
[
|
|
2641
|
+
ContentAST.Text(f"{self.label} = "),
|
|
2642
|
+
table
|
|
2643
|
+
]
|
|
2644
|
+
).render(*args, **kwargs)
|
|
2645
|
+
return table.render(*args, **kwargs)
|
|
2646
|
+
|
|
1955
2647
|
|