QuizGenerator 0.4.3__py3-none-any.whl → 0.5.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 +949 -80
- QuizGenerator/generate.py +44 -7
- 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 +51 -20
- 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 +126 -53
- QuizGenerator/question.py +110 -15
- QuizGenerator/quiz.py +74 -23
- QuizGenerator/regenerate.py +98 -29
- {quizgenerator-0.4.3.dist-info → quizgenerator-0.5.0.dist-info}/METADATA +1 -1
- {quizgenerator-0.4.3.dist-info → quizgenerator-0.5.0.dist-info}/RECORD +29 -31
- QuizGenerator/README.md +0 -5
- QuizGenerator/logging.yaml +0 -55
- {quizgenerator-0.4.3.dist-info → quizgenerator-0.5.0.dist-info}/WHEEL +0 -0
- {quizgenerator-0.4.3.dist-info → quizgenerator-0.5.0.dist-info}/entry_points.txt +0 -0
- {quizgenerator-0.4.3.dist-info → quizgenerator-0.5.0.dist-info}/licenses/LICENSE +0 -0
QuizGenerator/contentast.py
CHANGED
|
@@ -664,15 +664,19 @@ class ContentAST:
|
|
|
664
664
|
- Organizing complex question content
|
|
665
665
|
|
|
666
666
|
Example:
|
|
667
|
-
def
|
|
667
|
+
def _get_body(self):
|
|
668
668
|
body = ContentAST.Section()
|
|
669
|
+
answers = []
|
|
669
670
|
body.add_element(ContentAST.Paragraph(["Calculate the determinant:"]))
|
|
670
671
|
|
|
671
672
|
matrix_data = [[1, 2], [3, 4]]
|
|
672
673
|
body.add_element(ContentAST.Matrix(data=matrix_data, bracket_type="v"))
|
|
673
674
|
|
|
674
|
-
|
|
675
|
-
|
|
675
|
+
# Answer extends Leaf - add directly to body
|
|
676
|
+
ans = ContentAST.Answer.integer("det", self.determinant, label="Determinant")
|
|
677
|
+
answers.append(ans)
|
|
678
|
+
body.add_element(ans)
|
|
679
|
+
return body, answers
|
|
676
680
|
"""
|
|
677
681
|
pass
|
|
678
682
|
|
|
@@ -729,23 +733,41 @@ class ContentAST:
|
|
|
729
733
|
if self.hide_from_latex:
|
|
730
734
|
return ""
|
|
731
735
|
|
|
732
|
-
#
|
|
733
|
-
content
|
|
734
|
-
|
|
735
|
-
|
|
736
|
+
# Extract code blocks, render them, and replace with placeholders
|
|
737
|
+
# This prevents the # escaping from affecting content inside code blocks
|
|
738
|
+
code_blocks = []
|
|
739
|
+
|
|
740
|
+
def save_code_block(match):
|
|
741
|
+
code_content = match.group(1).strip()
|
|
742
|
+
# Escape quotes for Typst raw() function
|
|
743
|
+
escaped_content = code_content.replace('"', r'\"')
|
|
744
|
+
rendered_block = f"""
|
|
736
745
|
#box(
|
|
737
|
-
raw("
|
|
746
|
+
raw("{escaped_content}",
|
|
738
747
|
block: true
|
|
739
748
|
)
|
|
740
749
|
)
|
|
741
|
-
"""
|
|
750
|
+
"""
|
|
751
|
+
placeholder = f"__CODE_BLOCK_{len(code_blocks)}__"
|
|
752
|
+
code_blocks.append(rendered_block)
|
|
753
|
+
return placeholder
|
|
754
|
+
|
|
755
|
+
# Replace code blocks with placeholders
|
|
756
|
+
content = re.sub(
|
|
757
|
+
r"```\s*(.*)\s*```",
|
|
758
|
+
save_code_block,
|
|
742
759
|
self.content,
|
|
743
760
|
flags=re.DOTALL
|
|
744
761
|
)
|
|
745
762
|
|
|
746
|
-
# In Typst, # starts code/function calls, so we need to escape it
|
|
763
|
+
# In Typst, # starts code/function calls, so we need to escape it in regular text
|
|
764
|
+
# (but not in code blocks, which are now placeholders)
|
|
747
765
|
content = content.replace("# ", r"\# ")
|
|
748
|
-
|
|
766
|
+
|
|
767
|
+
# Restore code blocks
|
|
768
|
+
for i, block in enumerate(code_blocks):
|
|
769
|
+
content = content.replace(f"__CODE_BLOCK_{i}__", block)
|
|
770
|
+
|
|
749
771
|
if self.emphasis:
|
|
750
772
|
content = f"*{content}*"
|
|
751
773
|
return content
|
|
@@ -903,8 +925,19 @@ class ContentAST:
|
|
|
903
925
|
# Convert subscripts and superscripts from LaTeX to Typst
|
|
904
926
|
# LaTeX uses braces: b_{out}, x_{10}, x^{2}
|
|
905
927
|
# Typst uses parentheses for multi-char: b_(out), x_(10), x^(2)
|
|
906
|
-
|
|
907
|
-
|
|
928
|
+
# Multi-character text subscripts need quotes: L_{base} -> L_("base")
|
|
929
|
+
# But numbers don't: x_{10} -> x_(10)
|
|
930
|
+
def convert_sub_super(match):
|
|
931
|
+
content = match.group(1)
|
|
932
|
+
prefix = match.group(0)[0] # '_' or '^'
|
|
933
|
+
# If it's purely numeric or a single char, no quotes needed
|
|
934
|
+
if content.isdigit() or len(content) == 1:
|
|
935
|
+
return f'{prefix}({content})'
|
|
936
|
+
# If it's multi-char text, quote it
|
|
937
|
+
return f'{prefix}("{content}")'
|
|
938
|
+
|
|
939
|
+
latex_str = re.sub(r'_{([^}]+)}', convert_sub_super, latex_str)
|
|
940
|
+
latex_str = re.sub(r'\^{([^}]+)}', convert_sub_super, latex_str)
|
|
908
941
|
|
|
909
942
|
# Convert LaTeX Greek letters to Typst syntax (remove backslash)
|
|
910
943
|
greek_letters = [
|
|
@@ -922,19 +955,66 @@ class ContentAST:
|
|
|
922
955
|
latex_str = latex_str.replace(r'\nabla', 'nabla')
|
|
923
956
|
latex_str = latex_str.replace(r'\times', 'times')
|
|
924
957
|
latex_str = latex_str.replace(r'\cdot', 'dot')
|
|
925
|
-
latex_str = latex_str.replace(r'\partial', '
|
|
958
|
+
latex_str = latex_str.replace(r'\partial', 'partial')
|
|
959
|
+
latex_str = latex_str.replace(r'\sum', 'sum')
|
|
960
|
+
latex_str = latex_str.replace(r'\prod', 'product')
|
|
961
|
+
latex_str = latex_str.replace(r'\int', 'integral')
|
|
962
|
+
latex_str = latex_str.replace(r'\ln', 'ln')
|
|
963
|
+
latex_str = latex_str.replace(r'\log', 'log')
|
|
964
|
+
latex_str = latex_str.replace(r'\exp', 'exp')
|
|
965
|
+
latex_str = latex_str.replace(r'\sin', 'sin')
|
|
966
|
+
latex_str = latex_str.replace(r'\cos', 'cos')
|
|
967
|
+
latex_str = latex_str.replace(r'\tan', 'tan')
|
|
968
|
+
latex_str = latex_str.replace(r'\max', 'max')
|
|
969
|
+
latex_str = latex_str.replace(r'\min', 'min')
|
|
970
|
+
latex_str = latex_str.replace(r'\sqrt', 'sqrt')
|
|
971
|
+
# Convert \text{...} to "..." for Typst
|
|
972
|
+
latex_str = re.sub(r'\\text\{([^}]*)\}', r'"\1"', latex_str)
|
|
973
|
+
# Convert \frac{a}{b} to frac(a, b) for Typst
|
|
974
|
+
latex_str = re.sub(r'\\frac\{([^}]*)\}\{([^}]*)\}', r'frac(\1, \2)', latex_str)
|
|
975
|
+
|
|
976
|
+
# Handle matrix environments (bmatrix, pmatrix, vmatrix, Vmatrix, Bmatrix, matrix)
|
|
977
|
+
# Map bracket types to Typst delimiters
|
|
978
|
+
bracket_map = {
|
|
979
|
+
'bmatrix': '\"[\"', # square brackets
|
|
980
|
+
'pmatrix': '\"(\"', # parentheses (default)
|
|
981
|
+
'vmatrix': '\"|\"', # single vertical bars (determinant)
|
|
982
|
+
'Vmatrix': '\"||\"', # double vertical bars (norm)
|
|
983
|
+
'Bmatrix': '\"{\"', # curly braces
|
|
984
|
+
'matrix': None, # no brackets
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
for env_type, delim in bracket_map.items():
|
|
988
|
+
pattern = rf'\\begin\{{{env_type}\}}(.*?)\\end\{{{env_type}\}}'
|
|
989
|
+
|
|
990
|
+
def make_replacer(delimiter):
|
|
991
|
+
def replace_matrix(match):
|
|
992
|
+
content = match.group(1)
|
|
993
|
+
# Split rows by \\ and columns by &
|
|
994
|
+
rows = content.split(r'\\')
|
|
995
|
+
rows = [r.strip() for r in rows if r.strip()]
|
|
996
|
+
|
|
997
|
+
# Check if it's a vector (single column) or matrix
|
|
998
|
+
is_vector = all('&' not in row for row in rows)
|
|
999
|
+
|
|
1000
|
+
if is_vector:
|
|
1001
|
+
# Single column - use semicolons to separate rows in mat()
|
|
1002
|
+
elements = "; ".join(rows)
|
|
1003
|
+
else:
|
|
1004
|
+
# Multiple columns - replace & with , and rows with ;
|
|
1005
|
+
formatted_rows = [row.replace('&', ',').strip() for row in rows]
|
|
1006
|
+
elements = "; ".join(formatted_rows)
|
|
926
1007
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1008
|
+
if delimiter:
|
|
1009
|
+
return f"mat(delim: {delimiter}, {elements})"
|
|
1010
|
+
else:
|
|
1011
|
+
return f"mat({elements})"
|
|
1012
|
+
return replace_matrix
|
|
930
1013
|
|
|
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)})"
|
|
1014
|
+
latex_str = re.sub(pattern, make_replacer(delim), latex_str, flags=re.DOTALL)
|
|
936
1015
|
|
|
937
|
-
|
|
1016
|
+
# Handle \left\| ... \right\| for norms
|
|
1017
|
+
latex_str = re.sub(r'\\\|', '||', latex_str)
|
|
938
1018
|
|
|
939
1019
|
return latex_str
|
|
940
1020
|
|
|
@@ -955,6 +1035,125 @@ class ContentAST:
|
|
|
955
1035
|
|
|
956
1036
|
return cls('\n'.join(equation_lines))
|
|
957
1037
|
|
|
1038
|
+
class MathExpression(Leaf):
|
|
1039
|
+
"""
|
|
1040
|
+
Compose multiple math elements into a single format-independent expression.
|
|
1041
|
+
|
|
1042
|
+
This allows mixing ContentAST elements (like Matrix) with math operators
|
|
1043
|
+
and symbols, with each part rendering appropriately for the target format.
|
|
1044
|
+
|
|
1045
|
+
Example:
|
|
1046
|
+
# Vector magnitude: ||v|| =
|
|
1047
|
+
body.add_element(ContentAST.MathExpression([
|
|
1048
|
+
"||",
|
|
1049
|
+
ContentAST.Matrix(data=[[1], [2], [3]], bracket_type="b"),
|
|
1050
|
+
"|| = "
|
|
1051
|
+
]))
|
|
1052
|
+
|
|
1053
|
+
# Vector addition: a + b =
|
|
1054
|
+
body.add_element(ContentAST.MathExpression([
|
|
1055
|
+
ContentAST.Matrix(data=[[1], [2]], bracket_type="b"),
|
|
1056
|
+
" + ",
|
|
1057
|
+
ContentAST.Matrix(data=[[3], [4]], bracket_type="b"),
|
|
1058
|
+
" = "
|
|
1059
|
+
]))
|
|
1060
|
+
|
|
1061
|
+
Parts can be:
|
|
1062
|
+
- Strings: rendered as-is (math operators, symbols, etc.)
|
|
1063
|
+
- ContentAST elements: call their render method for the target format
|
|
1064
|
+
"""
|
|
1065
|
+
def __init__(self, parts, inline=False):
|
|
1066
|
+
super().__init__("[math_expression]")
|
|
1067
|
+
self.parts = parts # List of strings and/or ContentAST elements
|
|
1068
|
+
self.inline = inline
|
|
1069
|
+
|
|
1070
|
+
def _render_parts(self, output_format, **kwargs):
|
|
1071
|
+
"""Render all parts for the given output format."""
|
|
1072
|
+
rendered = []
|
|
1073
|
+
for part in self.parts:
|
|
1074
|
+
if isinstance(part, str):
|
|
1075
|
+
# Convert LaTeX operators to Typst if needed
|
|
1076
|
+
if output_format == ContentAST.OutputFormat.TYPST:
|
|
1077
|
+
part = part.replace(r'\cdot', ' dot ')
|
|
1078
|
+
part = part.replace(r'\times', ' times ')
|
|
1079
|
+
part = part.replace(r'\div', ' div ')
|
|
1080
|
+
rendered.append(part)
|
|
1081
|
+
elif isinstance(part, ContentAST.Element):
|
|
1082
|
+
# Use dedicated math_content methods if available (cleaner than stripping delimiters)
|
|
1083
|
+
if output_format == ContentAST.OutputFormat.HTML:
|
|
1084
|
+
# For HTML (MathJax), use LaTeX math content
|
|
1085
|
+
if hasattr(part, 'math_content_latex'):
|
|
1086
|
+
rendered.append(part.math_content_latex())
|
|
1087
|
+
else:
|
|
1088
|
+
# Fallback: try to extract from render_html
|
|
1089
|
+
html = part.render_html(**kwargs)
|
|
1090
|
+
html = re.sub(r"<[^>]+>", "", html)
|
|
1091
|
+
html = re.sub(r"^\$\$?\s*\\displaystyle\s*", "", html)
|
|
1092
|
+
html = re.sub(r"^\$\$?\s*", "", html)
|
|
1093
|
+
html = re.sub(r"\s*\$\$?$", "", html)
|
|
1094
|
+
rendered.append(html)
|
|
1095
|
+
elif output_format == ContentAST.OutputFormat.TYPST:
|
|
1096
|
+
if hasattr(part, 'math_content_typst'):
|
|
1097
|
+
rendered.append(part.math_content_typst())
|
|
1098
|
+
else:
|
|
1099
|
+
# Fallback: try to extract from render_typst
|
|
1100
|
+
typst = part.render_typst(**kwargs)
|
|
1101
|
+
typst = re.sub(r"^\s*\$\s*", "", typst)
|
|
1102
|
+
typst = re.sub(r"\s*\$\s*$", "", typst)
|
|
1103
|
+
rendered.append(typst)
|
|
1104
|
+
elif output_format == ContentAST.OutputFormat.LATEX:
|
|
1105
|
+
if hasattr(part, 'math_content_latex'):
|
|
1106
|
+
rendered.append(part.math_content_latex())
|
|
1107
|
+
else:
|
|
1108
|
+
latex = part.render_latex(**kwargs)
|
|
1109
|
+
latex = re.sub(r"^\\begin\{flushleft\}", "", latex)
|
|
1110
|
+
latex = re.sub(r"\\end\{flushleft\}$", "", latex)
|
|
1111
|
+
latex = re.sub(r"^\\\[", "", latex)
|
|
1112
|
+
latex = re.sub(r"\\\]$", "", latex)
|
|
1113
|
+
latex = re.sub(r"^\$", "", latex)
|
|
1114
|
+
latex = re.sub(r"\$~?$", "", latex)
|
|
1115
|
+
rendered.append(latex)
|
|
1116
|
+
elif output_format == ContentAST.OutputFormat.MARKDOWN:
|
|
1117
|
+
if hasattr(part, 'math_content_latex'):
|
|
1118
|
+
rendered.append(part.math_content_latex())
|
|
1119
|
+
else:
|
|
1120
|
+
md = part.render_markdown(**kwargs)
|
|
1121
|
+
md = re.sub(r"^\$", "", md)
|
|
1122
|
+
md = re.sub(r"\$$", "", md)
|
|
1123
|
+
rendered.append(md)
|
|
1124
|
+
else:
|
|
1125
|
+
# Convert to string as fallback
|
|
1126
|
+
rendered.append(str(part))
|
|
1127
|
+
return "".join(rendered)
|
|
1128
|
+
|
|
1129
|
+
def render_markdown(self, **kwargs):
|
|
1130
|
+
content = self._render_parts(ContentAST.OutputFormat.MARKDOWN, **kwargs)
|
|
1131
|
+
if self.inline:
|
|
1132
|
+
return f"${content}$"
|
|
1133
|
+
else:
|
|
1134
|
+
return f"$\\displaystyle {content}$"
|
|
1135
|
+
|
|
1136
|
+
def render_html(self, **kwargs):
|
|
1137
|
+
content = self._render_parts(ContentAST.OutputFormat.HTML, **kwargs)
|
|
1138
|
+
if self.inline:
|
|
1139
|
+
return f"\\({content}\\)"
|
|
1140
|
+
else:
|
|
1141
|
+
return f"<div class='math'>$$ \\displaystyle {content} \\; $$</div>"
|
|
1142
|
+
|
|
1143
|
+
def render_latex(self, **kwargs):
|
|
1144
|
+
content = self._render_parts(ContentAST.OutputFormat.LATEX, **kwargs)
|
|
1145
|
+
if self.inline:
|
|
1146
|
+
return f"${content}$~"
|
|
1147
|
+
else:
|
|
1148
|
+
return f"\\begin{{flushleft}}${content}$\\end{{flushleft}}"
|
|
1149
|
+
|
|
1150
|
+
def render_typst(self, **kwargs):
|
|
1151
|
+
content = self._render_parts(ContentAST.OutputFormat.TYPST, **kwargs)
|
|
1152
|
+
if self.inline:
|
|
1153
|
+
return f"${content}$"
|
|
1154
|
+
else:
|
|
1155
|
+
return f" $ {content} $ "
|
|
1156
|
+
|
|
958
1157
|
class Matrix(Leaf):
|
|
959
1158
|
"""
|
|
960
1159
|
Mathematical matrix renderer for consistent cross-format display.
|
|
@@ -1038,6 +1237,42 @@ class ContentAST:
|
|
|
1038
1237
|
matrix_content = r" \\ ".join(rows)
|
|
1039
1238
|
return f"\\begin{{{bracket_type}matrix}} {matrix_content} \\end{{{bracket_type}matrix}}"
|
|
1040
1239
|
|
|
1240
|
+
def math_content_latex(self):
|
|
1241
|
+
"""Return raw LaTeX math content without delimiters (for use in MathExpression)."""
|
|
1242
|
+
return ContentAST.Matrix.to_latex(self.data, self.bracket_type)
|
|
1243
|
+
|
|
1244
|
+
def math_content_typst(self):
|
|
1245
|
+
"""Return raw Typst math content without delimiters (for use in MathExpression)."""
|
|
1246
|
+
# Build matrix content
|
|
1247
|
+
rows = []
|
|
1248
|
+
for row in self.data:
|
|
1249
|
+
rows.append(", ".join(str(cell) for cell in row))
|
|
1250
|
+
|
|
1251
|
+
# Check if it's a vector (single column)
|
|
1252
|
+
is_vector = all(len(row) == 1 for row in self.data)
|
|
1253
|
+
|
|
1254
|
+
if is_vector:
|
|
1255
|
+
# Use vec() for vectors
|
|
1256
|
+
matrix_content = ", ".join(str(row[0]) for row in self.data)
|
|
1257
|
+
result = f"vec({matrix_content})"
|
|
1258
|
+
else:
|
|
1259
|
+
# Use mat() for matrices with semicolons separating rows
|
|
1260
|
+
matrix_content = "; ".join(rows)
|
|
1261
|
+
result = f"mat({matrix_content})"
|
|
1262
|
+
|
|
1263
|
+
# Add bracket delimiters if needed
|
|
1264
|
+
if self.bracket_type == "b": # square brackets
|
|
1265
|
+
matrix_content_inner = ", ".join(str(row[0]) for row in self.data) if is_vector else "; ".join(rows)
|
|
1266
|
+
result = f"mat(delim: \"[\", {matrix_content_inner})" if not is_vector else f"vec(delim: \"[\", {matrix_content_inner})"
|
|
1267
|
+
elif self.bracket_type == "v": # vertical bars (determinant)
|
|
1268
|
+
result = f"mat(delim: \"|\", {'; '.join(rows)})"
|
|
1269
|
+
elif self.bracket_type == "V": # double vertical bars (norm)
|
|
1270
|
+
result = f"mat(delim: \"||\", {'; '.join(rows)})"
|
|
1271
|
+
elif self.bracket_type == "B": # curly braces
|
|
1272
|
+
result = f"mat(delim: \"{{\", {'; '.join(rows)})"
|
|
1273
|
+
|
|
1274
|
+
return result
|
|
1275
|
+
|
|
1041
1276
|
def render_markdown(self, **kwargs):
|
|
1042
1277
|
matrix_env = "smallmatrix" if self.inline else f"{self.bracket_type}matrix"
|
|
1043
1278
|
rows = []
|
|
@@ -1233,79 +1468,716 @@ class ContentAST:
|
|
|
1233
1468
|
|
|
1234
1469
|
class Answer(Leaf):
|
|
1235
1470
|
"""
|
|
1236
|
-
|
|
1471
|
+
Unified answer class combining data storage, Canvas export, and rendering.
|
|
1472
|
+
|
|
1473
|
+
Extends ContentAST.Leaf to integrate seamlessly with the ContentAST tree while
|
|
1474
|
+
maintaining all Canvas export functionality.
|
|
1237
1475
|
|
|
1238
1476
|
CRITICAL: Use this for ALL answer inputs in questions.
|
|
1239
1477
|
Creates appropriate input fields that work across both PDF and Canvas formats.
|
|
1240
1478
|
In PDF, renders as blank lines for students to fill in.
|
|
1241
1479
|
In HTML/Canvas, can display the answer for checking.
|
|
1242
1480
|
|
|
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
1481
|
Example:
|
|
1249
1482
|
# Basic answer field
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
-
))
|
|
1483
|
+
ans = ContentAST.Answer.integer("result", 42, label="Result", unit="MB")
|
|
1484
|
+
body.add_element(ans)
|
|
1485
|
+
answers.append(ans) # Track for Canvas export
|
|
1261
1486
|
"""
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1487
|
+
|
|
1488
|
+
DEFAULT_ROUNDING_DIGITS = 4
|
|
1489
|
+
|
|
1490
|
+
class AnswerKind(enum.Enum):
|
|
1491
|
+
BLANK = "fill_in_multiple_blanks_question"
|
|
1492
|
+
MULTIPLE_ANSWER = "multiple_answers_question"
|
|
1493
|
+
ESSAY = "essay_question"
|
|
1494
|
+
MULTIPLE_DROPDOWN = "multiple_dropdowns_question"
|
|
1495
|
+
NUMERICAL_QUESTION = "numerical_question"
|
|
1496
|
+
|
|
1497
|
+
class VariableKind(enum.Enum):
|
|
1498
|
+
STR = enum.auto()
|
|
1499
|
+
INT = enum.auto()
|
|
1500
|
+
FLOAT = enum.auto()
|
|
1501
|
+
BINARY = enum.auto()
|
|
1502
|
+
HEX = enum.auto()
|
|
1503
|
+
BINARY_OR_HEX = enum.auto()
|
|
1504
|
+
AUTOFLOAT = enum.auto()
|
|
1505
|
+
LIST = enum.auto()
|
|
1506
|
+
VECTOR = enum.auto()
|
|
1507
|
+
MATRIX = enum.auto()
|
|
1508
|
+
|
|
1509
|
+
def __init__(
|
|
1510
|
+
self,
|
|
1511
|
+
key=None, # Can be str (new pattern) or Answer object (old wrapper pattern)
|
|
1512
|
+
value=None,
|
|
1513
|
+
kind: 'ContentAST.Answer.AnswerKind' = None,
|
|
1514
|
+
variable_kind: 'ContentAST.Answer.VariableKind' = None,
|
|
1515
|
+
# Data fields (from misc.Answer)
|
|
1516
|
+
display=None,
|
|
1517
|
+
length=None,
|
|
1518
|
+
correct=True,
|
|
1519
|
+
baffles=None,
|
|
1520
|
+
pdf_only=False,
|
|
1521
|
+
# Rendering fields (from ContentAST.Answer)
|
|
1522
|
+
label: str = "",
|
|
1523
|
+
unit: str = "",
|
|
1524
|
+
blank_length=5,
|
|
1525
|
+
# Backward compatibility for old wrapper pattern
|
|
1526
|
+
answer=None # Old pattern: ContentAST.Answer(answer=misc_answer_obj)
|
|
1527
|
+
):
|
|
1528
|
+
# BACKWARD COMPATIBILITY: Handle old wrapper pattern
|
|
1529
|
+
# Old: ContentAST.Answer(Answer.string("key", "value"))
|
|
1530
|
+
# Old: ContentAST.Answer(answer=some_answer_obj, label="...")
|
|
1531
|
+
if answer is not None or (key is not None and isinstance(key, ContentAST.Answer)):
|
|
1532
|
+
# Old wrapper pattern detected
|
|
1533
|
+
wrapped_answer = answer if answer is not None else key
|
|
1534
|
+
|
|
1535
|
+
if wrapped_answer is None:
|
|
1536
|
+
raise ValueError("Must provide either 'key' and 'value', or 'answer' parameter")
|
|
1537
|
+
|
|
1538
|
+
# Copy all fields from wrapped answer
|
|
1539
|
+
super().__init__(content=label if label else wrapped_answer.label)
|
|
1540
|
+
self.key = wrapped_answer.key
|
|
1541
|
+
self.value = wrapped_answer.value
|
|
1542
|
+
self.kind = wrapped_answer.kind
|
|
1543
|
+
self.variable_kind = wrapped_answer.variable_kind
|
|
1544
|
+
self.display = wrapped_answer.display
|
|
1545
|
+
self.length = wrapped_answer.length
|
|
1546
|
+
self.correct = wrapped_answer.correct
|
|
1547
|
+
self.baffles = wrapped_answer.baffles
|
|
1548
|
+
self.pdf_only = wrapped_answer.pdf_only
|
|
1549
|
+
|
|
1550
|
+
# Use provided rendering fields or copy from wrapped answer
|
|
1551
|
+
self.label = label if label else wrapped_answer.label
|
|
1552
|
+
self.unit = unit if unit else (wrapped_answer.unit if hasattr(wrapped_answer, 'unit') else "")
|
|
1553
|
+
self.blank_length = blank_length if blank_length != 5 else (wrapped_answer.blank_length if hasattr(wrapped_answer, 'blank_length') else 5)
|
|
1554
|
+
return
|
|
1555
|
+
|
|
1556
|
+
# NEW PATTERN: Normal construction
|
|
1557
|
+
if key is None:
|
|
1558
|
+
raise ValueError("Must provide 'key' parameter for new Answer pattern, or 'answer' parameter for old wrapper pattern")
|
|
1559
|
+
|
|
1560
|
+
# Initialize Leaf with label as content
|
|
1561
|
+
super().__init__(content=label if label else "")
|
|
1562
|
+
|
|
1563
|
+
# Data fields
|
|
1564
|
+
self.key = key
|
|
1565
|
+
self.value = value
|
|
1566
|
+
self.kind = kind if kind is not None else ContentAST.Answer.AnswerKind.BLANK
|
|
1567
|
+
self.variable_kind = variable_kind if variable_kind is not None else ContentAST.Answer.VariableKind.STR
|
|
1568
|
+
|
|
1569
|
+
# For list values in display, show the first option (or join them with /)
|
|
1570
|
+
if display is not None:
|
|
1571
|
+
self.display = display
|
|
1572
|
+
elif isinstance(value, list) and self.variable_kind == ContentAST.Answer.VariableKind.STR:
|
|
1573
|
+
self.display = value[0] if len(value) == 1 else " / ".join(value)
|
|
1574
|
+
else:
|
|
1575
|
+
self.display = value
|
|
1576
|
+
|
|
1577
|
+
self.length = length # Used for bits and hex to be printed appropriately
|
|
1578
|
+
self.correct = correct
|
|
1579
|
+
self.baffles = baffles
|
|
1580
|
+
self.pdf_only = pdf_only
|
|
1581
|
+
|
|
1582
|
+
# Rendering fields
|
|
1266
1583
|
self.label = label
|
|
1267
1584
|
self.unit = unit
|
|
1268
|
-
self.
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1585
|
+
self.blank_length = blank_length
|
|
1586
|
+
|
|
1587
|
+
# Canvas export methods (from misc.Answer)
|
|
1588
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
1589
|
+
"""Generate Canvas answer dictionaries based on variable_kind."""
|
|
1590
|
+
import itertools
|
|
1591
|
+
import math
|
|
1592
|
+
import decimal
|
|
1593
|
+
import fractions
|
|
1594
|
+
|
|
1595
|
+
# If this answer is PDF-only, don't send it to Canvas
|
|
1596
|
+
if self.pdf_only:
|
|
1597
|
+
return []
|
|
1598
|
+
|
|
1599
|
+
canvas_answers = []
|
|
1600
|
+
|
|
1601
|
+
if self.variable_kind == ContentAST.Answer.VariableKind.BINARY:
|
|
1602
|
+
canvas_answers = [
|
|
1603
|
+
{
|
|
1604
|
+
"blank_id": self.key,
|
|
1605
|
+
"answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
|
|
1606
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
"blank_id": self.key,
|
|
1610
|
+
"answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
|
|
1611
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1612
|
+
}
|
|
1613
|
+
]
|
|
1614
|
+
|
|
1615
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.HEX:
|
|
1616
|
+
canvas_answers = [
|
|
1617
|
+
{
|
|
1618
|
+
"blank_id": self.key,
|
|
1619
|
+
"answer_text": f"{self.value:0{(self.length // 8) + 1 if self.length is not None else 0}X}",
|
|
1620
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1621
|
+
},
|
|
1622
|
+
{
|
|
1623
|
+
"blank_id": self.key,
|
|
1624
|
+
"answer_text": f"0x{self.value:0{(self.length // 8) + 1 if self.length is not None else 0}X}",
|
|
1625
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1626
|
+
}
|
|
1627
|
+
]
|
|
1628
|
+
|
|
1629
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.BINARY_OR_HEX:
|
|
1630
|
+
canvas_answers = [
|
|
1631
|
+
{
|
|
1632
|
+
"blank_id": self.key,
|
|
1633
|
+
"answer_text": f"{self.value:0{self.length if self.length is not None else 0}b}",
|
|
1634
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
"blank_id": self.key,
|
|
1638
|
+
"answer_text": f"0b{self.value:0{self.length if self.length is not None else 0}b}",
|
|
1639
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1640
|
+
},
|
|
1641
|
+
{
|
|
1642
|
+
"blank_id": self.key,
|
|
1643
|
+
"answer_text": f"{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
|
|
1644
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
"blank_id": self.key,
|
|
1648
|
+
"answer_text": f"0x{self.value:0{math.ceil(self.length / 8) if self.length is not None else 0}X}",
|
|
1649
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1650
|
+
},
|
|
1651
|
+
{
|
|
1652
|
+
"blank_id": self.key,
|
|
1653
|
+
"answer_text": f"{self.value}",
|
|
1654
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1655
|
+
},
|
|
1656
|
+
]
|
|
1657
|
+
|
|
1658
|
+
elif self.variable_kind in [
|
|
1659
|
+
ContentAST.Answer.VariableKind.AUTOFLOAT,
|
|
1660
|
+
ContentAST.Answer.VariableKind.FLOAT,
|
|
1661
|
+
ContentAST.Answer.VariableKind.INT
|
|
1662
|
+
]:
|
|
1663
|
+
if single_answer:
|
|
1664
|
+
canvas_answers = [
|
|
1665
|
+
{
|
|
1666
|
+
"numerical_answer_type": "exact_answer",
|
|
1667
|
+
"answer_text": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
|
|
1668
|
+
"answer_exact": round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS),
|
|
1669
|
+
"answer_error_margin": 0.1,
|
|
1670
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1671
|
+
}
|
|
1672
|
+
]
|
|
1673
|
+
else:
|
|
1674
|
+
# Use the accepted_strings helper
|
|
1675
|
+
answer_strings = ContentAST.Answer.accepted_strings(
|
|
1676
|
+
self.value,
|
|
1677
|
+
allow_integer=True,
|
|
1678
|
+
allow_simple_fraction=True,
|
|
1679
|
+
max_denominator=60,
|
|
1680
|
+
allow_mixed=True,
|
|
1681
|
+
include_spaces=False,
|
|
1682
|
+
include_fixed_even_if_integer=True
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
canvas_answers = [
|
|
1686
|
+
{
|
|
1687
|
+
"blank_id": self.key,
|
|
1688
|
+
"answer_text": answer_string,
|
|
1689
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1690
|
+
}
|
|
1691
|
+
for answer_string in answer_strings
|
|
1692
|
+
]
|
|
1693
|
+
|
|
1694
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.VECTOR:
|
|
1695
|
+
# Get all answer variations
|
|
1696
|
+
answer_variations = [
|
|
1697
|
+
ContentAST.Answer.accepted_strings(dimension_value)
|
|
1698
|
+
for dimension_value in self.value
|
|
1699
|
+
]
|
|
1700
|
+
|
|
1701
|
+
canvas_answers = []
|
|
1702
|
+
for combination in itertools.product(*answer_variations):
|
|
1703
|
+
# Add parentheses format
|
|
1704
|
+
canvas_answers.append({
|
|
1705
|
+
"blank_id": self.key,
|
|
1706
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1707
|
+
"answer_text": f"({', '.join(list(combination))})",
|
|
1708
|
+
})
|
|
1709
|
+
|
|
1710
|
+
# Add non-parentheses format for single-element vectors
|
|
1711
|
+
if len(combination) == 1:
|
|
1712
|
+
canvas_answers.append({
|
|
1713
|
+
"blank_id": self.key,
|
|
1714
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1715
|
+
"answer_text": f"{', '.join(combination)}",
|
|
1716
|
+
})
|
|
1717
|
+
return canvas_answers
|
|
1718
|
+
|
|
1719
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.LIST:
|
|
1720
|
+
canvas_answers = [
|
|
1721
|
+
{
|
|
1722
|
+
"blank_id": self.key,
|
|
1723
|
+
"answer_text": ', '.join(map(str, possible_state)),
|
|
1724
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1725
|
+
}
|
|
1726
|
+
for possible_state in [self.value]
|
|
1727
|
+
]
|
|
1728
|
+
|
|
1273
1729
|
else:
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1730
|
+
# For string answers, check if value is a list of acceptable alternatives
|
|
1731
|
+
if isinstance(self.value, list):
|
|
1732
|
+
canvas_answers = [
|
|
1733
|
+
{
|
|
1734
|
+
"blank_id": self.key,
|
|
1735
|
+
"answer_text": str(alt),
|
|
1736
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1737
|
+
}
|
|
1738
|
+
for alt in self.value
|
|
1739
|
+
]
|
|
1740
|
+
else:
|
|
1741
|
+
canvas_answers = [{
|
|
1742
|
+
"blank_id": self.key,
|
|
1743
|
+
"answer_text": self.value,
|
|
1744
|
+
"answer_weight": 100 if self.correct else 0,
|
|
1745
|
+
}]
|
|
1746
|
+
|
|
1747
|
+
# Add baffles (incorrect answer choices)
|
|
1748
|
+
if self.baffles is not None:
|
|
1749
|
+
for baffle in self.baffles:
|
|
1750
|
+
canvas_answers.append({
|
|
1751
|
+
"blank_id": self.key,
|
|
1752
|
+
"answer_text": baffle,
|
|
1753
|
+
"answer_weight": 0,
|
|
1754
|
+
})
|
|
1755
|
+
|
|
1756
|
+
return canvas_answers
|
|
1757
|
+
|
|
1758
|
+
def get_display_string(self) -> str:
|
|
1759
|
+
"""Get the formatted display string for this answer (for grading/answer keys)."""
|
|
1760
|
+
import math
|
|
1761
|
+
|
|
1762
|
+
def fix_negative_zero(x):
|
|
1763
|
+
"""Fix -0.0 display issue."""
|
|
1764
|
+
return 0.0 if x == 0 else x
|
|
1765
|
+
|
|
1766
|
+
if self.variable_kind == ContentAST.Answer.VariableKind.BINARY_OR_HEX:
|
|
1767
|
+
hex_digits = math.ceil(self.length / 4) if self.length is not None else 0
|
|
1768
|
+
return f"0x{self.value:0{hex_digits}X}"
|
|
1769
|
+
|
|
1770
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.BINARY:
|
|
1771
|
+
return f"0b{self.value:0{self.length if self.length is not None else 0}b}"
|
|
1772
|
+
|
|
1773
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.HEX:
|
|
1774
|
+
hex_digits = (self.length // 4) + 1 if self.length is not None else 0
|
|
1775
|
+
return f"0x{self.value:0{hex_digits}X}"
|
|
1776
|
+
|
|
1777
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.AUTOFLOAT:
|
|
1778
|
+
rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
1779
|
+
return f"{fix_negative_zero(rounded)}"
|
|
1780
|
+
|
|
1781
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.FLOAT:
|
|
1782
|
+
if isinstance(self.value, (list, tuple)):
|
|
1783
|
+
rounded = round(self.value[0], ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
1784
|
+
return f"{fix_negative_zero(rounded)}"
|
|
1785
|
+
rounded = round(self.value, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
1786
|
+
return f"{fix_negative_zero(rounded)}"
|
|
1787
|
+
|
|
1788
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.INT:
|
|
1789
|
+
return str(int(self.value))
|
|
1790
|
+
|
|
1791
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.LIST:
|
|
1792
|
+
return ", ".join(str(v) for v in self.value)
|
|
1793
|
+
|
|
1794
|
+
elif self.variable_kind == ContentAST.Answer.VariableKind.VECTOR:
|
|
1795
|
+
def fix_negative_zero(x):
|
|
1796
|
+
return 0.0 if x == 0 else x
|
|
1797
|
+
return ", ".join(str(fix_negative_zero(round(v, ContentAST.Answer.DEFAULT_ROUNDING_DIGITS))) for v in self.value)
|
|
1798
|
+
|
|
1799
|
+
else:
|
|
1800
|
+
return str(self.display if hasattr(self, 'display') else self.value)
|
|
1801
|
+
|
|
1802
|
+
# Rendering methods (override Leaf's defaults)
|
|
1803
|
+
def render_markdown(self, **kwargs):
|
|
1804
|
+
return f"{self.label + (':' if len(self.label) > 0 else '')} [{self.key}] {self.unit}".strip()
|
|
1805
|
+
|
|
1277
1806
|
def render_html(self, show_answers=False, can_be_numerical=False, **kwargs):
|
|
1278
1807
|
if can_be_numerical:
|
|
1279
1808
|
return f"Calculate {self.label}"
|
|
1280
|
-
if show_answers
|
|
1281
|
-
|
|
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
|
-
|
|
1809
|
+
if show_answers:
|
|
1810
|
+
answer_display = self.get_display_string()
|
|
1287
1811
|
label_part = f"{self.label}:" if self.label else ""
|
|
1288
1812
|
unit_part = f" {self.unit}" if self.unit else ""
|
|
1289
1813
|
return f"{label_part} <strong>{answer_display}</strong>{unit_part}".strip()
|
|
1290
1814
|
else:
|
|
1291
|
-
# Default behavior: show [key]
|
|
1292
1815
|
return self.render_markdown(**kwargs)
|
|
1293
|
-
|
|
1816
|
+
|
|
1294
1817
|
def render_latex(self, **kwargs):
|
|
1295
|
-
return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.
|
|
1296
|
-
|
|
1818
|
+
return fr"{self.label + (':' if len(self.label) > 0 else '')} \answerblank{{{self.blank_length}}} {self.unit}".strip()
|
|
1819
|
+
|
|
1297
1820
|
def render_typst(self, **kwargs):
|
|
1298
1821
|
"""Render answer blank as an underlined space in Typst."""
|
|
1299
|
-
|
|
1300
|
-
# Width is based on self.length (in cm)
|
|
1301
|
-
blank_width = self.length * 0.75 # Convert character length to cm
|
|
1822
|
+
blank_width = self.blank_length * 0.75 # Convert character length to cm
|
|
1302
1823
|
blank = f"#fillline(width: {blank_width}cm)"
|
|
1303
|
-
|
|
1824
|
+
|
|
1304
1825
|
label_part = f"{self.label}:" if self.label else ""
|
|
1305
1826
|
unit_part = f" {self.unit}" if self.unit else ""
|
|
1306
|
-
|
|
1827
|
+
|
|
1307
1828
|
return f"{label_part} {blank}{unit_part}".strip()
|
|
1308
|
-
|
|
1829
|
+
|
|
1830
|
+
# Factory methods for common answer types
|
|
1831
|
+
@classmethod
|
|
1832
|
+
def binary_hex(cls, key: str, value: int, length: int = None, **kwargs) -> 'ContentAST.Answer':
|
|
1833
|
+
"""Create an answer that accepts binary or hex format"""
|
|
1834
|
+
return cls(
|
|
1835
|
+
key=key,
|
|
1836
|
+
value=value,
|
|
1837
|
+
variable_kind=cls.VariableKind.BINARY_OR_HEX,
|
|
1838
|
+
length=length,
|
|
1839
|
+
**kwargs
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
@classmethod
|
|
1843
|
+
def auto_float(cls, key: str, value: float, **kwargs) -> 'ContentAST.Answer':
|
|
1844
|
+
"""Create an answer that accepts multiple float formats (decimal, fraction, mixed)"""
|
|
1845
|
+
return cls(
|
|
1846
|
+
key=key,
|
|
1847
|
+
value=value,
|
|
1848
|
+
variable_kind=cls.VariableKind.AUTOFLOAT,
|
|
1849
|
+
**kwargs
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
@classmethod
|
|
1853
|
+
def integer(cls, key: str, value: int, **kwargs) -> 'ContentAST.Answer':
|
|
1854
|
+
"""Create an integer answer"""
|
|
1855
|
+
return cls(
|
|
1856
|
+
key=key,
|
|
1857
|
+
value=value,
|
|
1858
|
+
variable_kind=cls.VariableKind.INT,
|
|
1859
|
+
**kwargs
|
|
1860
|
+
)
|
|
1861
|
+
|
|
1862
|
+
@classmethod
|
|
1863
|
+
def string(cls, key: str, value: str, **kwargs) -> 'ContentAST.Answer':
|
|
1864
|
+
"""Create a string answer"""
|
|
1865
|
+
return cls(
|
|
1866
|
+
key=key,
|
|
1867
|
+
value=value,
|
|
1868
|
+
variable_kind=cls.VariableKind.STR,
|
|
1869
|
+
**kwargs
|
|
1870
|
+
)
|
|
1871
|
+
|
|
1872
|
+
@classmethod
|
|
1873
|
+
def binary(cls, key: str, value: int, length: int = None, **kwargs) -> 'ContentAST.Answer':
|
|
1874
|
+
"""Create a binary-only answer"""
|
|
1875
|
+
return cls(
|
|
1876
|
+
key=key,
|
|
1877
|
+
value=value,
|
|
1878
|
+
variable_kind=cls.VariableKind.BINARY,
|
|
1879
|
+
length=length,
|
|
1880
|
+
**kwargs
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
@classmethod
|
|
1884
|
+
def hex_value(cls, key: str, value: int, length: int = None, **kwargs) -> 'ContentAST.Answer':
|
|
1885
|
+
"""Create a hex-only answer"""
|
|
1886
|
+
return cls(
|
|
1887
|
+
key=key,
|
|
1888
|
+
value=value,
|
|
1889
|
+
variable_kind=cls.VariableKind.HEX,
|
|
1890
|
+
length=length,
|
|
1891
|
+
**kwargs
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
@classmethod
|
|
1895
|
+
def float_value(cls, key: str, value, **kwargs) -> 'ContentAST.Answer':
|
|
1896
|
+
"""Create a simple float answer (no fraction conversion)"""
|
|
1897
|
+
return cls(
|
|
1898
|
+
key=key,
|
|
1899
|
+
value=value,
|
|
1900
|
+
variable_kind=cls.VariableKind.FLOAT,
|
|
1901
|
+
**kwargs
|
|
1902
|
+
)
|
|
1903
|
+
|
|
1904
|
+
@classmethod
|
|
1905
|
+
def list_value(cls, key: str, value: list, **kwargs) -> 'ContentAST.Answer':
|
|
1906
|
+
"""Create a list answer (comma-separated values)"""
|
|
1907
|
+
return cls(
|
|
1908
|
+
key=key,
|
|
1909
|
+
value=value,
|
|
1910
|
+
variable_kind=cls.VariableKind.LIST,
|
|
1911
|
+
**kwargs
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
@classmethod
|
|
1915
|
+
def vector_value(cls, key: str, value: List[float], **kwargs) -> 'ContentAST.Answer':
|
|
1916
|
+
"""Create a vector answer"""
|
|
1917
|
+
return cls(
|
|
1918
|
+
key=key,
|
|
1919
|
+
value=value,
|
|
1920
|
+
variable_kind=cls.VariableKind.VECTOR,
|
|
1921
|
+
**kwargs
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
@classmethod
|
|
1925
|
+
def dropdown(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
|
|
1926
|
+
"""Create a dropdown answer with wrong answer choices (baffles)"""
|
|
1927
|
+
return cls(
|
|
1928
|
+
key=key,
|
|
1929
|
+
value=value,
|
|
1930
|
+
kind=cls.AnswerKind.MULTIPLE_DROPDOWN,
|
|
1931
|
+
baffles=baffles,
|
|
1932
|
+
**kwargs
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
@classmethod
|
|
1936
|
+
def multiple_choice(cls, key: str, value: str, baffles: list = None, **kwargs) -> 'ContentAST.Answer':
|
|
1937
|
+
"""Create a multiple choice answer with wrong answer choices (baffles)"""
|
|
1938
|
+
return cls(
|
|
1939
|
+
key=key,
|
|
1940
|
+
value=value,
|
|
1941
|
+
kind=cls.AnswerKind.MULTIPLE_ANSWER,
|
|
1942
|
+
baffles=baffles,
|
|
1943
|
+
**kwargs
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
@classmethod
|
|
1947
|
+
def essay(cls, key: str, **kwargs) -> 'ContentAST.Answer':
|
|
1948
|
+
"""Create an essay question (no specific correct answer)"""
|
|
1949
|
+
return cls(
|
|
1950
|
+
key=key,
|
|
1951
|
+
value="", # Essays don't have predetermined answers
|
|
1952
|
+
kind=cls.AnswerKind.ESSAY,
|
|
1953
|
+
**kwargs
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
@classmethod
|
|
1957
|
+
def matrix(cls, key: str, value, **kwargs):
|
|
1958
|
+
"""Create a matrix answer (returns MatrixAnswer instance)"""
|
|
1959
|
+
return ContentAST.MatrixAnswer(
|
|
1960
|
+
key=key,
|
|
1961
|
+
value=value,
|
|
1962
|
+
variable_kind=cls.VariableKind.MATRIX,
|
|
1963
|
+
**kwargs
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
# Static helper methods
|
|
1967
|
+
@staticmethod
|
|
1968
|
+
def _to_fraction(x):
|
|
1969
|
+
"""Convert int/float/decimal.Decimal/fractions.Fraction/str to fractions.Fraction exactly."""
|
|
1970
|
+
import fractions
|
|
1971
|
+
import decimal
|
|
1972
|
+
|
|
1973
|
+
if isinstance(x, fractions.Fraction):
|
|
1974
|
+
return x
|
|
1975
|
+
if isinstance(x, int):
|
|
1976
|
+
return fractions.Fraction(x, 1)
|
|
1977
|
+
if isinstance(x, decimal.Decimal):
|
|
1978
|
+
# exact conversion of decimal.Decimal to fractions.Fraction
|
|
1979
|
+
sign, digits, exp = x.as_tuple()
|
|
1980
|
+
n = 0
|
|
1981
|
+
for d in digits:
|
|
1982
|
+
n = n * 10 + d
|
|
1983
|
+
n = -n if sign else n
|
|
1984
|
+
if exp >= 0:
|
|
1985
|
+
return fractions.Fraction(n * (10 ** exp), 1)
|
|
1986
|
+
else:
|
|
1987
|
+
return fractions.Fraction(n, 10 ** (-exp))
|
|
1988
|
+
if isinstance(x, str):
|
|
1989
|
+
s = x.strip()
|
|
1990
|
+
if '/' in s:
|
|
1991
|
+
a, b = s.split('/', 1)
|
|
1992
|
+
return fractions.Fraction(int(a.strip()), int(b.strip()))
|
|
1993
|
+
return fractions.Fraction(decimal.Decimal(s))
|
|
1994
|
+
# float or other numerics
|
|
1995
|
+
return fractions.Fraction(decimal.Decimal(str(x)))
|
|
1996
|
+
|
|
1997
|
+
@staticmethod
|
|
1998
|
+
def accepted_strings(
|
|
1999
|
+
value,
|
|
2000
|
+
*,
|
|
2001
|
+
allow_integer=True,
|
|
2002
|
+
allow_simple_fraction=True,
|
|
2003
|
+
max_denominator=720,
|
|
2004
|
+
allow_mixed=False,
|
|
2005
|
+
include_spaces=False,
|
|
2006
|
+
include_fixed_even_if_integer=False
|
|
2007
|
+
):
|
|
2008
|
+
"""Return a sorted list of strings you can paste into Canvas as alternate correct answers."""
|
|
2009
|
+
import decimal
|
|
2010
|
+
import fractions
|
|
2011
|
+
|
|
2012
|
+
decimal.getcontext().prec = max(34, (ContentAST.Answer.DEFAULT_ROUNDING_DIGITS or 0) + 10)
|
|
2013
|
+
f = ContentAST.Answer._to_fraction(value)
|
|
2014
|
+
outs = set()
|
|
2015
|
+
|
|
2016
|
+
# Integer form
|
|
2017
|
+
if f.denominator == 1 and allow_integer:
|
|
2018
|
+
outs.add(str(f.numerator))
|
|
2019
|
+
if include_fixed_even_if_integer:
|
|
2020
|
+
q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
2021
|
+
d = decimal.Decimal(f.numerator).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
2022
|
+
outs.add(format(d, 'f'))
|
|
2023
|
+
|
|
2024
|
+
# Fixed-decimal form
|
|
2025
|
+
q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
2026
|
+
d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
2027
|
+
outs.add(format(d, 'f'))
|
|
2028
|
+
|
|
2029
|
+
# Trimmed decimal
|
|
2030
|
+
if ContentAST.Answer.DEFAULT_ROUNDING_DIGITS:
|
|
2031
|
+
q = decimal.Decimal(1).scaleb(-ContentAST.Answer.DEFAULT_ROUNDING_DIGITS)
|
|
2032
|
+
d = (decimal.Decimal(f.numerator) / decimal.Decimal(f.denominator)).quantize(q, rounding=decimal.ROUND_HALF_UP)
|
|
2033
|
+
s = format(d, 'f').rstrip('0').rstrip('.')
|
|
2034
|
+
if s.startswith('.'):
|
|
2035
|
+
s = '0' + s
|
|
2036
|
+
if s == '-0':
|
|
2037
|
+
s = '0'
|
|
2038
|
+
outs.add(s)
|
|
2039
|
+
|
|
2040
|
+
# Simple fraction
|
|
2041
|
+
if allow_simple_fraction:
|
|
2042
|
+
fr = f.limit_denominator(max_denominator)
|
|
2043
|
+
if fr == f:
|
|
2044
|
+
a, b = fr.numerator, fr.denominator
|
|
2045
|
+
outs.add(f"{a}/{b}")
|
|
2046
|
+
if include_spaces:
|
|
2047
|
+
outs.add(f"{a} / {b}")
|
|
2048
|
+
if allow_mixed and b != 1 and abs(a) > b:
|
|
2049
|
+
sign = '-' if a < 0 else ''
|
|
2050
|
+
A = abs(a)
|
|
2051
|
+
whole, rem = divmod(A, b)
|
|
2052
|
+
outs.add(f"{sign}{whole} {rem}/{b}")
|
|
2053
|
+
|
|
2054
|
+
return sorted(outs, key=lambda s: (len(s), s))
|
|
2055
|
+
|
|
2056
|
+
class MatrixAnswer(Answer):
|
|
2057
|
+
"""
|
|
2058
|
+
Matrix answers generate multiple blank_ids (e.g., M_0_0, M_0_1, M_1_0, M_1_1).
|
|
2059
|
+
"""
|
|
2060
|
+
|
|
2061
|
+
def get_for_canvas(self, single_answer=False) -> List[dict]:
|
|
2062
|
+
"""Generate Canvas answers for each matrix element."""
|
|
2063
|
+
import numpy as np
|
|
2064
|
+
|
|
2065
|
+
canvas_answers = []
|
|
2066
|
+
|
|
2067
|
+
# Generate a per-index set of answers for each matrix element
|
|
2068
|
+
for i, j in np.ndindex(self.value.shape):
|
|
2069
|
+
entry_strings = ContentAST.Answer.accepted_strings(
|
|
2070
|
+
self.value[i, j],
|
|
2071
|
+
allow_integer=True,
|
|
2072
|
+
allow_simple_fraction=True,
|
|
2073
|
+
max_denominator=60,
|
|
2074
|
+
allow_mixed=True,
|
|
2075
|
+
include_spaces=False,
|
|
2076
|
+
include_fixed_even_if_integer=True
|
|
2077
|
+
)
|
|
2078
|
+
canvas_answers.extend([
|
|
2079
|
+
{
|
|
2080
|
+
"blank_id": f"{self.key}_{i}_{j}", # Indexed per cell
|
|
2081
|
+
"answer_text": answer_string,
|
|
2082
|
+
"answer_weight": 100 if self.correct else 0,
|
|
2083
|
+
}
|
|
2084
|
+
for answer_string in entry_strings
|
|
2085
|
+
])
|
|
2086
|
+
|
|
2087
|
+
return canvas_answers
|
|
2088
|
+
|
|
2089
|
+
def render_html(self, **kwargs):
|
|
2090
|
+
"""Render as table of answer blanks."""
|
|
2091
|
+
# Create sub-Answer for each cell
|
|
2092
|
+
data = [
|
|
2093
|
+
[
|
|
2094
|
+
ContentAST.Answer.float_value(
|
|
2095
|
+
key=f"{self.key}_{i}_{j}",
|
|
2096
|
+
value=self.value[i, j],
|
|
2097
|
+
blank_length=5
|
|
2098
|
+
)
|
|
2099
|
+
for j in range(self.value.shape[1])
|
|
2100
|
+
]
|
|
2101
|
+
for i in range(self.value.shape[0])
|
|
2102
|
+
]
|
|
2103
|
+
table = ContentAST.Table(data)
|
|
2104
|
+
|
|
2105
|
+
if self.label:
|
|
2106
|
+
return ContentAST.Container([
|
|
2107
|
+
ContentAST.Text(f"{self.label} = "),
|
|
2108
|
+
table
|
|
2109
|
+
]).render_html(**kwargs)
|
|
2110
|
+
return table.render_html(**kwargs)
|
|
2111
|
+
|
|
2112
|
+
def render_latex(self, **kwargs):
|
|
2113
|
+
"""Render as LaTeX table of answer blanks."""
|
|
2114
|
+
# Create sub-Answer for each cell
|
|
2115
|
+
data = [
|
|
2116
|
+
[
|
|
2117
|
+
ContentAST.Answer.float_value(
|
|
2118
|
+
key=f"{self.key}_{i}_{j}",
|
|
2119
|
+
value=self.value[i, j],
|
|
2120
|
+
blank_length=5
|
|
2121
|
+
)
|
|
2122
|
+
for j in range(self.value.shape[1])
|
|
2123
|
+
]
|
|
2124
|
+
for i in range(self.value.shape[0])
|
|
2125
|
+
]
|
|
2126
|
+
table = ContentAST.Table(data)
|
|
2127
|
+
|
|
2128
|
+
if self.label:
|
|
2129
|
+
return ContentAST.Container([
|
|
2130
|
+
ContentAST.Text(f"{self.label} = "),
|
|
2131
|
+
table
|
|
2132
|
+
]).render_latex(**kwargs)
|
|
2133
|
+
return table.render_latex(**kwargs)
|
|
2134
|
+
|
|
2135
|
+
def render_markdown(self, **kwargs):
|
|
2136
|
+
"""Render as markdown table of answer blanks."""
|
|
2137
|
+
# Create sub-Answer for each cell
|
|
2138
|
+
data = [
|
|
2139
|
+
[
|
|
2140
|
+
ContentAST.Answer.float_value(
|
|
2141
|
+
key=f"{self.key}_{i}_{j}",
|
|
2142
|
+
value=self.value[i, j],
|
|
2143
|
+
blank_length=5
|
|
2144
|
+
)
|
|
2145
|
+
for j in range(self.value.shape[1])
|
|
2146
|
+
]
|
|
2147
|
+
for i in range(self.value.shape[0])
|
|
2148
|
+
]
|
|
2149
|
+
table = ContentAST.Table(data)
|
|
2150
|
+
|
|
2151
|
+
if self.label:
|
|
2152
|
+
return ContentAST.Container([
|
|
2153
|
+
ContentAST.Text(f"{self.label} = "),
|
|
2154
|
+
table
|
|
2155
|
+
]).render_markdown(**kwargs)
|
|
2156
|
+
return table.render_markdown(**kwargs)
|
|
2157
|
+
|
|
2158
|
+
def render_typst(self, **kwargs):
|
|
2159
|
+
"""Render as Typst table of answer blanks."""
|
|
2160
|
+
# Create sub-Answer for each cell
|
|
2161
|
+
data = [
|
|
2162
|
+
[
|
|
2163
|
+
ContentAST.Answer.float_value(
|
|
2164
|
+
key=f"{self.key}_{i}_{j}",
|
|
2165
|
+
value=self.value[i, j],
|
|
2166
|
+
blank_length=5
|
|
2167
|
+
)
|
|
2168
|
+
for j in range(self.value.shape[1])
|
|
2169
|
+
]
|
|
2170
|
+
for i in range(self.value.shape[0])
|
|
2171
|
+
]
|
|
2172
|
+
table = ContentAST.Table(data)
|
|
2173
|
+
|
|
2174
|
+
if self.label:
|
|
2175
|
+
return ContentAST.Container([
|
|
2176
|
+
ContentAST.Text(f"{self.label} = "),
|
|
2177
|
+
table
|
|
2178
|
+
]).render_typst(**kwargs)
|
|
2179
|
+
return table.render_typst(**kwargs)
|
|
2180
|
+
|
|
1309
2181
|
class LineBreak(Text):
|
|
1310
2182
|
def __init__(self, *args, **kwargs):
|
|
1311
2183
|
super().__init__("\n\n")
|
|
@@ -1745,18 +2617,15 @@ class ContentAST:
|
|
|
1745
2617
|
- Better visual grouping of related answers
|
|
1746
2618
|
|
|
1747
2619
|
Example:
|
|
1748
|
-
# Multiple related answers
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
]
|
|
1753
|
-
answer_block = ContentAST.AnswerBlock(answers)
|
|
2620
|
+
# Multiple related answers - Answer extends Leaf, use factory methods
|
|
2621
|
+
memory_ans = ContentAST.Answer.integer("memory", self.memory_value, label="Memory used", unit="MB")
|
|
2622
|
+
time_ans = ContentAST.Answer.auto_float("time", self.time_value, label="Execution time", unit="ms")
|
|
2623
|
+
answer_block = ContentAST.AnswerBlock([memory_ans, time_ans])
|
|
1754
2624
|
body.add_element(answer_block)
|
|
1755
2625
|
|
|
1756
2626
|
# Single answer with better spacing
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
)
|
|
2627
|
+
result_ans = ContentAST.Answer.integer("result", self.result_value, label="Final result")
|
|
2628
|
+
single_answer = ContentAST.AnswerBlock(result_ans)
|
|
1760
2629
|
"""
|
|
1761
2630
|
def __init__(self, answers: ContentAST.Answer|List[ContentAST.Answer]):
|
|
1762
2631
|
if not isinstance(answers, list):
|
|
@@ -1920,9 +2789,9 @@ class ContentAST:
|
|
|
1920
2789
|
# Add to main content - only appears in PDF
|
|
1921
2790
|
body.add_element(latex_only)
|
|
1922
2791
|
"""
|
|
1923
|
-
|
|
2792
|
+
|
|
1924
2793
|
def render(self, output_format: ContentAST.OutputFormat, **kwargs):
|
|
1925
|
-
if output_format
|
|
2794
|
+
if output_format not in ("latex", "typst"):
|
|
1926
2795
|
return ""
|
|
1927
2796
|
return super().render(output_format=output_format, **kwargs)
|
|
1928
2797
|
|