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