diff --git a/in2lambda/api/part.py b/in2lambda/api/part.py index eae51c9..47632d3 100644 --- a/in2lambda/api/part.py +++ b/in2lambda/api/part.py @@ -1,11 +1,30 @@ """A part of a question.""" -from dataclasses import dataclass +from dataclasses import dataclass, field + +from in2lambda.api.response_area import ResponseArea + + +@dataclass +class Branch: + """A (titled) branch of a structured tutorial or worked solution, which may branch further.""" + + title: str + content: str + branches: list["Branch"] = field(default_factory=list) @dataclass class Part: - """A part of a question as represented on Lambda Feedback.""" + """A part of a question as represented on Lambda Feedback. + + Markdown content for the question and supporting materials. Note that in ``tutorial`` and ``worked_solution`` fields, steps can by separated by a line with only ``---`` (or ``***``). + """ - text: str = "" + content: str = "" + response_areas: list[ResponseArea] = field(default_factory=list) + tutorial: str = "" + tutorial_branches: list[Branch] = field(default_factory=list) + answer: str = "" worked_solution: str = "" + worked_solution_branches: list[Branch] = field(default_factory=list) diff --git a/in2lambda/api/question.py b/in2lambda/api/question.py index 6ee42d4..cf3f108 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -1,7 +1,7 @@ """A full question with optional parts that's contained in a set.""" from dataclasses import dataclass, field -from typing import Union +from typing import Optional, Union import panflute as pf @@ -14,10 +14,12 @@ class Question: Each question has a title and is composed of a list of parts. + It also carries the settings Lambda Feedback keeps per question. + Examples: >>> from in2lambda.api.question import Question - >>> Question(title="Some title", main_text="Some text") - Question(title='Some title', parts=[], images=[], main_text='Some text') + >>> Question(title="Some title", master_content="Some text") + Question(title='Some title', parts=[], images=[], master_content='Some text') """ title: str = "" @@ -25,10 +27,10 @@ class Question: parts: list[Part] = field(default_factory=list) images: list[str] = field(default_factory=list) - # Use getter/setter methods for main_text without exposing private attribute. + # Use getter/setter methods for master_content without exposing private attribute. # See https://stackoverflow.com/a/61480946 - main_text: str = "" - _main_text: str = field(init=False, repr=False, default="") + master_content: str = "" + _master_content: str = field(init=False, repr=False, default="") _last_part: dict[str, int] = field( default_factory=lambda: {"solution": 0, "text": 0}, repr=False @@ -36,8 +38,23 @@ class Question: """Keeps track of the last question part that contains a solution / text.""" + # The settings below are left out of the repr, so that printing a question shows its + # content rather than nine lines of configuration. + + # The skill level may be an int as well as a float, because Lambda Feedback writes + # its exports in JavaScript, which writes the levels 0.0 and 1.0 as 0 and 1. + skill: Optional[Union[int, float]] = field(default=None, repr=False) + guidance: Optional[str] = field(default=None, repr=False) + duration_lower_bound: Optional[int] = field(default=None, repr=False) + duration_upper_bound: Optional[int] = field(default=None, repr=False) + publish: bool = field(default=False, repr=False) + display_final_answer: bool = field(default=True, repr=False) + display_worked_solution: bool = field(default=True, repr=False) + display_structured_tutorial: bool = field(default=True, repr=False) + display_chatbot: bool = field(default=False, repr=False) + @property - def main_text(self) -> str: + def master_content(self) -> str: r"""Main top-level question text. Setting the attribute multiple times appends to the current value with a newline. @@ -46,21 +63,21 @@ def main_text(self) -> str: Examples: >>> from in2lambda.api.question import Question >>> question = Question() - >>> question.main_text = "hello" - >>> question.main_text = "there" - >>> question.main_text + >>> question.master_content = "hello" + >>> question.master_content = "there" + >>> question.master_content 'hello\nthere' """ - return self._main_text + return self._master_content - @main_text.setter - def main_text(self, value: Union[pf.Element, str, property]) -> None: + @master_content.setter + def master_content(self, value: Union[pf.Element, str, property]) -> None: r"""Appends to the top-level main text, which starts off as an empty string. Args: value: A panflute element or string denoting what to append to the main text. - See example in main_text property. + See example in master_content property. """ # Converts the inputted value into a string, stored in text_value match value: @@ -69,13 +86,13 @@ def main_text(self, value: Union[pf.Element, str, property]) -> None: case property(): # Use default value when no value set at initialisation. # See https://stackoverflow.com/a/61480946 - text_value = self.main_text + text_value = self.master_content case pf.Element(): text_value = pf.stringify(value, False) - if self._main_text: - self._main_text += "\n" - self._main_text += text_value + if self._master_content: + self._master_content += "\n" + self._master_content += text_value def add_solution(self, elem: Union[pf.Element, str]) -> None: """Adds a worked solution to all question parts without one, or inserts a new empty part with the solution if all parts already have a solution. @@ -89,20 +106,20 @@ def add_solution(self, elem: Union[pf.Element, str]) -> None: >>> question.add_part_text("part a") >>> question.add_solution("part a solution") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='') + Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[])], images=[], master_content='') >>> question.add_part_text("part b") >>> question.add_part_text("part c") >>> question.add_solution("Solution for b") >>> # Note that since c doesn't have a solution, it's set to b's solution >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \ -Part(text='part b', worked_solution='Solution for b'), \ -Part(text='part c', worked_solution='Solution for b')], images=[], main_text='') + Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[]), \ +Part(content='part b', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Solution for b', worked_solution_branches=[]), \ +Part(content='part c', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Solution for b', worked_solution_branches=[])], images=[], master_content='') >>> question.add_solution("We now have a solution for c!") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \ -Part(text='part b', worked_solution='Solution for b'), \ -Part(text='part c', worked_solution='We now have a solution for c!')], images=[], main_text='') + Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[]), \ +Part(content='part b', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Solution for b', worked_solution_branches=[]), \ +Part(content='part c', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='We now have a solution for c!', worked_solution_branches=[])], images=[], master_content='') """ elem_text = elem if isinstance(elem, str) else pf.stringify(elem) @@ -131,19 +148,19 @@ def add_part_text(self, elem: Union[pf.Element, str]) -> None: >>> question.add_part_text("part a") >>> question.add_solution("part a solution") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='') + Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[])], images=[], master_content='') >>> # Supports adding the answer first. >>> question.add_solution("part b solution") >>> question.add_part_text("part b") >>> question - Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \ -Part(text='part b', worked_solution='part b solution')], images=[], main_text='') + Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[]), \ +Part(content='part b', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part b solution', worked_solution_branches=[])], images=[], master_content='') """ elem_text = elem if isinstance(elem, str) else pf.stringify(elem) if len(self.parts) == self._last_part["text"]: - self.parts.append(Part(text=elem_text)) + self.parts.append(Part(content=elem_text)) else: - self.parts[self._last_part["text"]].text = elem_text + self.parts[self._last_part["text"]].content = elem_text self._last_part["text"] += 1 diff --git a/in2lambda/api/response_area.py b/in2lambda/api/response_area.py new file mode 100644 index 0000000..b74de86 --- /dev/null +++ b/in2lambda/api/response_area.py @@ -0,0 +1,317 @@ +"""An area for student responses that will receive automated feedback.""" + +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, ClassVar, Optional, Union + + +def _new_id() -> str: + return str(uuid.uuid4()) + + +@dataclass +class InputSymbol: + """Defines symbols and how to enter them into a response area. + + Fields per symbol: + - ``symbol`` for display to users (markdown, typically dollar-delimited math latex) + - ``code`` for users to input (plan text, fed to the algorithm and also be rendered to users within backticks as `code`) + - ``aliases`` list of alternative code inputs, to be substituted by the `code` field when evaluating + - ``is_visible`` Boolean, whether to display input symbol info to users (in addition to always sending it to the evaluator to help parsing) + """ + + symbol: str + code: str + aliases: list[str] = field(default_factory=list) + is_visible: bool = True + + +@dataclass +class Test: + """Teacher author's assertions of how responses should be evaluated.""" + + # Its name would otherwise make pytest try to collect it wherever it is imported. + __test__ = False + + payload: str + is_correct: bool + id: str = field(default_factory=_new_id) + + +class Color(Enum): + """Acceptable colours for a feedback case.""" + + RED = "#ff5630" + AMBER = "#FFAB00" + GREEN = "#22C55E" + BLUE = "#0099c4" + GREY = "#C4CDD5" + + +@dataclass +class Case: + """A declared case for which bespoke feedback is prescribed.""" + + answer: str + feedback: str + is_correct: bool + params: Any = None + color: Optional[Color] = None + id: str = field(default_factory=_new_id) + + +@dataclass(kw_only=True) +class ResponseArea: + """Specifies the interaction when a student provides a response. + + Multiple response areas per part are allowed; if so, order is implicit. + Use a subclass for each response type. ``grade_params`` are passed to the + evaluation function unedited. + + Examples: + >>> from in2lambda.api.response_area import NumericUnits, Test + >>> area = NumericUnits( + ... answer="30 N", + ... grade_params={"rtol": 0.05}, + ... pre_text="$F=$", + ... tests=[Test("30 N", True)], + ... ) + >>> area.response_type, area.evaluation_function + ('NUMERIC_UNITS', 'compareExpressions') + """ + + response_type: ClassVar[str] + answer: Any = None + evaluation_function: str + grade_params: Optional[dict[str, Any]] = None + pre_text: str = "" + post_text: str = "" + content_after: str = "" + """Markdown shown after the box, before the next one.""" + check_button_text: str = "" + """Replaces the label of the check button; left out of the JSON when empty.""" + input_symbols: list[InputSymbol] = field(default_factory=list) + display_input_symbols: bool = False + live_preview: bool = False + include_in_pdf: bool = False + save_allowed: bool = False + separate_feedback: bool = True + common_feedback_color: str = "#C4CDD5" + correct_feedback_color: str = "#22C55E" + correct_feedback_prefix: str = "Correct" + incorrect_feedback_color: str = "#ff5630" + incorrect_feedback_prefix: str = "Incorrect" + tests: list[Test] = field(default_factory=list) + cases: list[Case] = field(default_factory=list) + + def _config(self) -> Optional[dict[str, Any]]: + """The response type's settings, as Lambda Feedback writes them.""" + return None + + def _grade_params(self) -> Optional[dict[str, Any]]: + """The parameters sent to the evaluation function.""" + return self.grade_params + + +@dataclass(kw_only=True) +class _Maths(ResponseArea): + answer: str = "" + allow_handwrite: bool = True + allow_photo: bool = True + enable_refinement: bool = True + + def _config(self) -> Optional[dict[str, Any]]: + return { + "allowHandwrite": self.allow_handwrite, + "allowPhoto": self.allow_photo, + "enableRefinement": self.enable_refinement, + } + + +@dataclass(kw_only=True) +class MathSingleLine(_Maths): + """A single symbolic expression.""" + + response_type: ClassVar[str] = "MATH_SINGLE_LINE" + evaluation_function: str = "compareExpressions" + + +@dataclass(kw_only=True) +class MathMultiLines(_Maths): + """Multiple lines of mathematical work.""" + + response_type: ClassVar[str] = "MATH_MULTI_LINES" + + +@dataclass(kw_only=True) +class Number(ResponseArea): + """A number.""" + + response_type: ClassVar[str] = "NUMBER" + answer: Optional[Union[int, float]] = None + evaluation_function: str = "isSimilar" + + +@dataclass(kw_only=True) +class NumericUnits(ResponseArea): + """A number with units, e.g. ``30 N``.""" + + response_type: ClassVar[str] = "NUMERIC_UNITS" + answer: str = "" + evaluation_function: str = "compareExpressions" + + def _grade_params(self) -> Optional[dict[str, Any]]: + # compareExpressions compares units only when told to. + return {"physical_quantity": True, **(self.grade_params or {})} + + +@dataclass(kw_only=True) +class Text(ResponseArea): + """A short piece of text.""" + + response_type: ClassVar[str] = "TEXT" + answer: str = "" + + +@dataclass(kw_only=True) +class Boolean(ResponseArea): + """True or false.""" + + response_type: ClassVar[str] = "BOOLEAN" + answer: bool = False + evaluation_function: str = "isExactEqual" + + +@dataclass(kw_only=True) +class MultipleChoice(ResponseArea): + """A choice among ``options``; ``answer`` marks each option correct or not.""" + + response_type: ClassVar[str] = "MULTIPLE_CHOICE" + answer: list[bool] = field(default_factory=list) + evaluation_function: str = "arrayEqual" + options: list[str] = field(default_factory=list) + single: bool = True + randomise: bool = False + + def _config(self) -> Optional[dict[str, Any]]: + return { + "options": self.options, + "randomise": self.randomise, + "single": self.single, + } + + +@dataclass(kw_only=True) +class Table(ResponseArea): + """A grid of cells, one row per row name and one column per column name.""" + + response_type: ClassVar[str] = "TABLE" + answer: list[list[str]] = field(default_factory=list) + evaluation_function: str = "arraySymbolicEqual" + row_names: list[str] = field(default_factory=list) + col_names: list[str] = field(default_factory=list) + + def _config(self) -> Optional[dict[str, Any]]: + return { + "rows": len(self.row_names), + "cols": len(self.col_names), + "rowNames": self.row_names, + "colNames": self.col_names, + } + + +@dataclass(kw_only=True) +class Matrix(ResponseArea): + """A table, with parentheses displayed around it, with the shape of ``answer``.""" + + response_type: ClassVar[str] = "MATRIX" + answer: list[list[str]] = field(default_factory=list) + evaluation_function: str = "arraySymbolicEqual" + + def _config(self) -> Optional[dict[str, Any]]: + return { + "rows": len(self.answer), + "cols": len(self.answer[0]) if self.answer else 0, + } + + +@dataclass(kw_only=True) +class Code(ResponseArea): + """A monaco code editor.""" + + response_type: ClassVar[str] = "CODE" + answer: str = "" + evaluation_function: str = "evaluatePython" + language: str = "python" + + def _config(self) -> Optional[dict[str, Any]]: + return {"language": self.language} + + +@dataclass(kw_only=True) +class Essay(ResponseArea): + """Large text box.""" + + response_type: ClassVar[str] = "ESSAY" + answer: Union[str, dict[str, str]] = "" + allow_draw: bool = False + allow_scan: bool = False + repeat_for_subjects: bool = False + allow_empty_submission: bool = False + + def _config(self) -> Optional[dict[str, Any]]: + return { + "allowDraw": self.allow_draw, + "allowScan": self.allow_scan, + "repeatForSubjects": self.repeat_for_subjects, + "allowEmptySubmission": self.allow_empty_submission, + } + + +@dataclass(kw_only=True) +class Likert(ResponseArea): + """A multiple choice with Likert scales.""" + + response_type: ClassVar[str] = "LIKERT" + answer: Union[ + list[Optional[Union[int, float]]], + dict[str, list[Optional[Union[int, float]]]], + None, + ] = None + statements: list[str] = field(default_factory=list) + options: Optional[list[str]] = None + repeat_for_subjects: bool = False + allow_empty_submission: bool = False + + def _config(self) -> Optional[dict[str, Any]]: + config: dict[str, Any] = { + "statements": self.statements, + "repeatForSubjects": self.repeat_for_subjects, + "allowEmptySubmission": self.allow_empty_submission, + } + if self.options is not None: + config["options"] = self.options + return config + + +@dataclass(kw_only=True) +class Images(ResponseArea): + """Upload images.""" + + response_type: ClassVar[str] = "IMAGES" + max_images: int = 3 + allowed_types: list[str] = field( + default_factory=lambda: ["image/jpg", "image/jpeg", "image/png"] + ) + max_size_mb: int = 10 + resize_max_side: int = 0 + """The longest side images are resized to; 0 leaves them as they are.""" + + def _config(self) -> Optional[dict[str, Any]]: + return { + "maxImages": self.max_images, + "allowedTypes": self.allowed_types, + "maxSizeMb": self.max_size_mb, + "resizeMaxSide": self.resize_max_side, + } diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py index 58c7a52..784f6cc 100644 --- a/in2lambda/api/set.py +++ b/in2lambda/api/set.py @@ -1,7 +1,7 @@ """Represents a list of questions.""" from dataclasses import dataclass, field -from typing import Union +from typing import Optional, Union import panflute as pf @@ -11,7 +11,11 @@ @dataclass class Set: - """Represents a list of questions.""" + """Represents a list of questions. + + A collection of questions. Contains set-level parameters including visibility, with + release dates (ISO 8601 time, omitted if unset), and survey status. + """ _name: str = field(default="set") _description: str = field(default="") @@ -28,6 +32,13 @@ class Set: _structuredTutorialVisibility: VisibilityController = field( default_factory=lambda: VisibilityController(VisibilityStatus.OPEN) ) + _chatbotVisibility: VisibilityController = field( + default_factory=lambda: VisibilityController(VisibilityStatus.HIDE) + ) + is_survey: bool = False + released_at: Optional[str] = None + hidden_at: Optional[str] = None + manually_hidden: bool = True questions: list[Question] = field(default_factory=list) _current_question_index = -1 @@ -45,11 +56,11 @@ def current_question(self) -> Question: Examples: >>> from in2lambda.api.set import Set >>> Set().current_question - Question(title='INVALID', parts=[], images=[], main_text='') + Question(title='INVALID', parts=[], images=[], master_content='') >>> s = Set() >>> s.add_question() >>> s.current_question - Question(title='', parts=[], images=[], main_text='') + Question(title='', parts=[], images=[], master_content='') """ return ( self.questions[self._current_question_index] @@ -58,14 +69,14 @@ def current_question(self) -> Question: ) def add_question( - self, title: str = "", main_text: Union[pf.Element, str] = pf.Str("") + self, title: str = "", master_content: Union[pf.Element, str] = pf.Str("") ) -> None: """Inserts a new question into the set. Args: title: An optional string for the title of the question. If no title is provided, the question title auto-increments i.e. Question 1, 2, etc. - main_text: An optional string or panflute element for the main question text. + master_content: An optional string or panflute element for the main question text. Examples: >>> from in2lambda.api.set import Set @@ -73,13 +84,13 @@ def add_question( >>> s = Set() >>> s.add_question("Some title", pf.Para(pf.Str("hello"), pf.Space, pf.Str("there"))) >>> s.questions - [Question(title='Some title', parts=[], images=[], main_text='hello there')] - >>> s.add_question(main_text="Normal string text") - >>> s.questions[1].main_text + [Question(title='Some title', parts=[], images=[], master_content='hello there')] + >>> s.add_question(master_content="Normal string text") + >>> s.questions[1].master_content 'Normal string text' """ question = Question(title=title) - question.main_text = main_text + question.master_content = master_content self.questions.append(question) def increment_current_question(self) -> None: @@ -102,8 +113,8 @@ def increment_current_question(self) -> None: >>> s.increment_current_question() >>> s.current_question.add_solution("Question 2 answer") >>> s.questions - [Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer')], images=[], main_text=''),\ - Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer')], images=[], main_text='')] + [Question(title='Question 1', parts=[Part(content='', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Question 1 answer', worked_solution_branches=[])], images=[], master_content=''),\ + Question(title='Question 2', parts=[Part(content='', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Question 2 answer', worked_solution_branches=[])], images=[], master_content='')] """ self._current_question_index += 1 @@ -180,6 +191,11 @@ def __repr__(self): f"_finalAnswerVisibility={str(self._finalAnswerVisibility)!r}, " f"_workedSolutionVisibility={str(self._workedSolutionVisibility)!r}, " f"_structuredTutorialVisibility={str(self._structuredTutorialVisibility)!r}, " + f"_chatbotVisibility={str(self._chatbotVisibility)!r}, " + f"is_survey={self.is_survey!r}, " + f"released_at={self.released_at!r}, " + f"hidden_at={self.hidden_at!r}, " + f"manually_hidden={self.manually_hidden!r}, " f"questions={self.questions!r}" f")" ) diff --git a/in2lambda/filters/PartPartSolSol/filter.py b/in2lambda/filters/PartPartSolSol/filter.py index b825161..25361a6 100644 --- a/in2lambda/filters/PartPartSolSol/filter.py +++ b/in2lambda/filters/PartPartSolSol/filter.py @@ -46,7 +46,7 @@ def pandoc_filter( # Solutions are in a Div if isinstance(elem, pf.Div): - set.add_question(main_text="\n".join(pandoc_filter.question)) + set.add_question(master_content="\n".join(pandoc_filter.question)) if hasattr(pandoc_filter, "parts"): for part in pandoc_filter.parts: set.current_question.add_part_text(part) diff --git a/in2lambda/filters/PartSolPartSol/filter.py b/in2lambda/filters/PartSolPartSol/filter.py index f9f3736..6a78057 100644 --- a/in2lambda/filters/PartSolPartSol/filter.py +++ b/in2lambda/filters/PartSolPartSol/filter.py @@ -54,7 +54,7 @@ def pandoc_filter( if isinstance(elem, pf.Div): pandoc_filter.solutions.append(pf.stringify(elem)) if pandoc_filter.question: - set.add_question(main_text="\n".join(pandoc_filter.question)) + set.add_question(master_content="\n".join(pandoc_filter.question)) set.current_question.add_solution(pf.stringify(elem)) set.current_question._last_part["solution"] -= 1 pandoc_filter.question = [] diff --git a/in2lambda/filters/PartsOneSol/filter.py b/in2lambda/filters/PartsOneSol/filter.py index efe685f..9c8111b 100755 --- a/in2lambda/filters/PartsOneSol/filter.py +++ b/in2lambda/filters/PartsOneSol/filter.py @@ -42,7 +42,7 @@ def pandoc_filter( isinstance(elem.prev, pf.Header) and pf.stringify(elem.prev) != "Solution" ): - set.add_question(main_text=elem) + set.add_question(master_content=elem) # Parts are denoted via ordered lists case pf.OrderedList: diff --git a/in2lambda/filters/PartsSepSol/filter.py b/in2lambda/filters/PartsSepSol/filter.py index 5da2429..59c2b46 100644 --- a/in2lambda/filters/PartsSepSol/filter.py +++ b/in2lambda/filters/PartsSepSol/filter.py @@ -67,7 +67,7 @@ def pandoc_filter( set.current_question.add_solution(spaced_blurb) # Add the main question text as the Lambda Feedback blurb elif not parsing_answers: - set.add_question(main_text=spaced_blurb) + set.add_question(master_content=spaced_blurb) # Add each part solution/text # For the solution, prepend any top level answer text to each part answer diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index dd85f23..3895b0d 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any +from in2lambda.api.part import Branch, Part +from in2lambda.api.response_area import Case, ResponseArea from in2lambda.api.set import Set MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json" @@ -31,6 +33,99 @@ def _zip_sorted_folder(folder_path, zip_path): zf.write(abs_path, arcname=rel_path) +def _case_to_json(case: Case) -> dict[str, Any]: + output = { + "id": case.id, + "answer": case.answer, + "feedback": case.feedback, + "isCorrect": case.is_correct, + "params": case.params, + } + if case.color is not None: + output["color"] = case.color.value + return output + + +def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]: + output = { + "orderNumber": order, + "contentAfter": area.content_after, + "preResponseText": area.pre_text, + "postResponseText": area.post_text, + "inputSymbols": [ + { + "symbol": symbol.symbol, + "code": symbol.code, + "aliases": symbol.aliases, + "isVisible": symbol.is_visible, + } + for symbol in area.input_symbols + ], + "displayInputSymbols": area.display_input_symbols, + "includeInPdf": area.include_in_pdf, + "saveAllowed": area.save_allowed, + "evaluationFunctionName": area.evaluation_function, + "livePreview": area.live_preview, + "gradeParams": area._grade_params(), + "separateFeedback": area.separate_feedback, + "commonFeedbackColor": area.common_feedback_color, + "correctFeedbackColor": area.correct_feedback_color, + "correctFeedbackPrefix": area.correct_feedback_prefix, + "incorrectFeedbackColor": area.incorrect_feedback_color, + "incorrectFeedbackPrefix": area.incorrect_feedback_prefix, + "tests": [ + { + "id": test.id, + "payload": test.payload, + "expectedResponse": {"isCorrect": test.is_correct}, + } + for test in area.tests + ], + "cases": [_case_to_json(case) for case in area.cases], + "response": { + "responseInput": { + "responseType": area.response_type, + "answer": area.answer, + "config": area._config(), + } + }, + } + if area.check_button_text: + output["checkButtonText"] = area.check_button_text + return output + + +def _branch_to_json(branch: Branch) -> dict[str, Any]: + return { + "title": branch.title, + "content": branch.content, + "children": [_branch_to_json(child) for child in branch.branches], + } + + +def _part_to_json(part: Part, order: int) -> dict[str, Any]: + output = { + "orderNumber": order, + "content": part.content, + "answerContent": part.answer, + "responseAreas": [ + _response_area_to_json(area, j) + for j, area in enumerate(part.response_areas) + ], + } + # Lambda Feedback leaves each key out of a part that has none. + for key, content, branches in ( + ("tutorial", part.tutorial, part.tutorial_branches), + ("workedSolution", part.worked_solution, part.worked_solution_branches), + ): + if content or branches: + output[key] = { + "content": content, + "children": [_branch_to_json(branch) for branch in branches], + } + return output + + def converter( question_template: dict[str, Any], set_template: dict[str, Any], @@ -65,6 +160,15 @@ def converter( set_template["structuredTutorialVisibility"] = str( SetQuestions._structuredTutorialVisibility.status ) + set_template["chatbotVisibility"] = str(SetQuestions._chatbotVisibility.status) + set_template["isSurvey"] = SetQuestions.is_survey + set_template["manuallyHidden"] = SetQuestions.manually_hidden + for key, value in { + "releasedAt": SetQuestions.released_at, + "hiddenAt": SetQuestions.hidden_at, + }.items(): + if value is not None: + set_template[key] = value # create the set file with open(f"{output_question}/set_{set_name}.json", "w") as file: json.dump(set_template, file) @@ -80,21 +184,29 @@ def converter( output["title"] = "Question " + str(i + 1) # add main text to the question file - output["masterContent"] = ListQuestions[i].main_text - - # add parts to the question file - if ListQuestions[i].parts: - output["parts"][0]["content"] = ListQuestions[i].parts[0].text - output["parts"][0]["workedSolution"]["content"] = ( - ListQuestions[i].parts[0].worked_solution - ) - for j in range(1, len(ListQuestions[i].parts)): - output["parts"].append(deepcopy(question_template["parts"][0])) - output["parts"][j]["content"] = ListQuestions[i].parts[j].text - output["parts"][j]["orderNumber"] = j - output["parts"][j]["workedSolution"]["content"] = ( - ListQuestions[i].parts[j].worked_solution - ) + output["masterContent"] = ListQuestions[i].master_content + + question = ListQuestions[i] + output["publish"] = question.publish + output["displayFinalAnswer"] = question.display_final_answer + output["displayWorkedSolution"] = question.display_worked_solution + output["displayStructuredTutorial"] = question.display_structured_tutorial + output["displayChatbot"] = question.display_chatbot + # An unset optional setting is left out of the JSON, so that in2lambda writes no + # value the author did not choose. + for key, value in { + "skill": question.skill, + "guidance": question.guidance, + "durationLowerBound": question.duration_lower_bound, + "durationUpperBound": question.duration_upper_bound, + }.items(): + if value is not None: + output[key] = value + + if question.parts: + output["parts"] = [ + _part_to_json(part, j) for j, part in enumerate(question.parts) + ] # Output file filename = ( diff --git a/in2lambda/json_convert/minimal_template_question.json b/in2lambda/json_convert/minimal_template_question.json index 1f96fc8..db97cf9 100644 --- a/in2lambda/json_convert/minimal_template_question.json +++ b/in2lambda/json_convert/minimal_template_question.json @@ -14,7 +14,6 @@ "answerContent": "", "responseAreas": [], "workedSolution": { - "title": "", "content": "Part worked solution here", "children": [] } diff --git a/in2lambda/main.py b/in2lambda/main.py index d5df1e2..76f0c63 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -100,9 +100,9 @@ def runner( >>> from in2lambda.main import runner >>> # Retrieve an example TeX file and run the given filter. >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS - Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='This is a sample question\n\n'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='HIDE', is_survey=False, released_at=None, hidden_at=None, manually_hidden=True, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='This is a sample question\n\n'), ...]) >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS - Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='Here is some preliminary question information that might be useful.'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='HIDE', is_survey=False, released_at=None, hidden_at=None, manually_hidden=True, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='Here is some preliminary question information that might be useful.'), ...]) """ # The list of questions for Lambda Feedback as a Python API. set_obj = Set() diff --git a/tests/fixtures/exports/introduction_to_lambda_feedback/question_000_Numeric_Questions.json b/tests/fixtures/exports/introduction_to_lambda_feedback/question_000_Numeric_Questions.json new file mode 100644 index 0000000..81f9c1a --- /dev/null +++ b/tests/fixtures/exports/introduction_to_lambda_feedback/question_000_Numeric_Questions.json @@ -0,0 +1 @@ +{"orderNumber":0,"title":"Numeric Questions","masterContent":"Here are some simple questions to help you experience how a student interacts with content in Lambda Feedback.","publish":true,"displayFinalAnswer":true,"displayStructuredTutorial":true,"displayWorkedSolution":true,"displayChatbot":true,"parts":[{"orderNumber":0,"content":"One common response area is \"Number\".\n\nSo to start off, what's 1 + 2?","answerContent":"","responseAreas":[{"orderNumber":1,"contentAfter":"This uses the \"isSimilar\" evaluation function to check if an answer and a student response were within an absolute tolerance of 0.05, which also supports relative tolerance.\n\nAlso demonstrated in this question is that you can add pre-text and post-text to a response area.","preResponseText":"Measured Speed =","postResponseText":"m/s","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"isSimilar","livePreview":false,"gradeParams":{"atol":0.05,"rtol":0},"separateFeedback":true,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[{"id":"71327321-d792-4c26-b41d-a01cdeff89ee","payload":2.5,"expectedResponse":{"isCorrect":true}},{"id":"575a7b6d-0432-4bcf-a953-30ac26d6404d","payload":2.55,"expectedResponse":{"isCorrect":true}},{"id":"3a748516-75a3-4e3a-9411-fe1512dfd234","payload":2.45,"expectedResponse":{"isCorrect":true}},{"id":"c1552a77-0ae0-42cf-8ce3-0c6ec0ee806b","payload":2.44,"expectedResponse":{"isCorrect":false}},{"id":"a5e80f26-47bf-4d42-8bc3-10c581e817c2","payload":2.56,"expectedResponse":{"isCorrect":false}}],"cases":[],"response":{"responseInput":{"responseType":"NUMBER","answer":2.5,"config":null}}},{"orderNumber":0,"contentAfter":"This uses the \"isExactEqual\" evaluation function to check if the answer is an exact match to the student's response. But what if we wanted to have some tolerance?\n\nFor example, a physics experiment measures the speed of a small robot as it moves across the floor. The theoretical speed is **2.50 m/s**, but the sensor has an absolute tolerance of 0.05 m/s. What would an example of a valid measured speed be?","preResponseText":"","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"isExactEqual","livePreview":false,"gradeParams":{"type":"int"},"separateFeedback":true,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[],"cases":[],"response":{"responseInput":{"responseType":"NUMBER","answer":3,"config":null}}}]}]} \ No newline at end of file diff --git a/tests/fixtures/exports/introduction_to_lambda_feedback/question_001_Mathematical_Expression_Questions.json b/tests/fixtures/exports/introduction_to_lambda_feedback/question_001_Mathematical_Expression_Questions.json new file mode 100644 index 0000000..fdfa2a4 --- /dev/null +++ b/tests/fixtures/exports/introduction_to_lambda_feedback/question_001_Mathematical_Expression_Questions.json @@ -0,0 +1 @@ +{"orderNumber":1,"title":"Mathematical Expression Questions","masterContent":"We also support evaluating mathematical expressions and physical quantities.","publish":true,"displayFinalAnswer":true,"displayStructuredTutorial":true,"displayWorkedSolution":true,"displayChatbot":true,"parts":[{"orderNumber":0,"content":"Rearrange the following expression in terms of c:\n\n$$\na = b + c\n$$","answerContent":"$$\nc = b - a\n$$","responseAreas":[{"orderNumber":0,"contentAfter":"","preResponseText":"$c$ = ","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":true,"saveAllowed":false,"evaluationFunctionName":"compareExpressions","livePreview":true,"gradeParams":{"atol":0,"rtol":0,"strict_syntax":false,"physical_quantity":false,"elementary_functions":true},"separateFeedback":true,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[{"id":"55851bb8-cc15-406c-a8db-872d6a849a6c","payload":"a-b","expectedResponse":{"isCorrect":true}}],"cases":[],"response":{"responseInput":{"responseType":"MATH_SINGLE_LINE","answer":"a-b","config":{"allowPhoto":false,"allowHandwrite":false,"enableRefinement":true}}}}]},{"orderNumber":2,"content":"A car travels 120 kilometres in 2 hours.\n\nWhat is the car's average speed?","answerContent":"","responseAreas":[{"orderNumber":1,"contentAfter":"","preResponseText":"","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":true,"saveAllowed":false,"evaluationFunctionName":"compareExpressions","livePreview":true,"gradeParams":{"atol":0,"rtol":0,"strict_syntax":false,"physical_quantity":true,"elementary_functions":true},"separateFeedback":true,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[{"id":"faca21fe-413e-4b4e-b9f1-c18a95832b64","payload":"16.67 m/s","expectedResponse":{"isCorrect":true}}],"cases":[],"response":{"responseInput":{"responseType":"MATH_SINGLE_LINE","answer":"80 km","config":{"allowPhoto":true,"allowHandwrite":true,"enableRefinement":true}}}},{"orderNumber":0,"contentAfter":"A boat has been travelling at a constant speed of 20 kilometres per hour for 4 hours. How far has it travelled?","preResponseText":"","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"compareExpressions","livePreview":false,"gradeParams":{"atol":0,"rtol":0,"strict_syntax":false,"physical_quantity":true,"elementary_functions":true},"separateFeedback":true,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[{"id":"c78e19cf-44ca-4126-ac87-c8743fbed811","payload":"60 km/h","expectedResponse":{"isCorrect":true}}],"cases":[],"response":{"responseInput":{"responseType":"NUMERIC_UNITS","answer":"60 km/h","config":null}}}],"tutorial":{"title":"","content":"Use the following to calculate the average speed:\n\n$$\n\\text{Speed} = \\frac{\\text{Distance}}{\\text{Time}}\n$$\n\nUse the following to convert km/h to m/s.\n\n- 1 km = 1000m\n- 1 hour = 3600s","children":[]}},{"orderNumber":1,"content":"*Students can also write their answers or take a picture of them*.\n\nTry answering this question:\n\nWhat is $\\tan(x)$ in terms of $\\sin(x)$ and $\\cos(x)$?","answerContent":"$$\n\\text{tan}(x) = \\frac{\\text{sin}(x)}{\\text{cos}(x)}\n$$","responseAreas":[{"orderNumber":0,"contentAfter":"These were evaluated by \"[compareExpressions](https://lambda-feedback.github.io/user-documentation/user\\_eval\\_function\\_docs/compareExpressions/)\", but what if we want to ask questions on physical qunatities? Checkout the next part to find out!","preResponseText":"$\\tan{x}=$","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":true,"saveAllowed":false,"evaluationFunctionName":"compareExpressions","livePreview":true,"gradeParams":{"atol":0,"rtol":0,"strict_syntax":false,"physical_quantity":false,"elementary_functions":true},"separateFeedback":true,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[{"id":"89cb75a5-e4ee-42da-a951-aabb5cdfda19","payload":"sin(x)/ cos(x)","expectedResponse":{"isCorrect":true}},{"id":"5ca1fa1b-1043-42e1-bb3d-262181d74153","payload":"(1* cos(x)* sin(x))/ cos(x)**2","expectedResponse":{"isCorrect":true}},{"id":"8b565c27-979f-4afc-ad0d-638aa7fbc27e","payload":"sin(x) * sec(x)","expectedResponse":{"isCorrect":true}},{"id":"8be153d3-df58-4c4b-99c6-7254d3c5bb0c","payload":"cos(x)/ sin(x)","expectedResponse":{"isCorrect":false}}],"cases":[{"id":"a061c365-32f6-4859-8437-bd3108b7c41b","answer":"cos(x)/ sin(x)","feedback":"Check the order of your fraction!","isCorrect":false,"params":{"atol":0,"rtol":0,"strict_syntax":false,"physical_quantity":false,"elementary_functions":true},"color":"#FFAB00"}],"response":{"responseInput":{"responseType":"MATH_SINGLE_LINE","answer":"sin(x)/cos(x)","config":{"allowPhoto":true,"allowHandwrite":true,"enableRefinement":true}}}}]}]} \ No newline at end of file diff --git a/tests/fixtures/exports/introduction_to_lambda_feedback/question_002_Other_Questions.json b/tests/fixtures/exports/introduction_to_lambda_feedback/question_002_Other_Questions.json new file mode 100644 index 0000000..a8e2276 --- /dev/null +++ b/tests/fixtures/exports/introduction_to_lambda_feedback/question_002_Other_Questions.json @@ -0,0 +1 @@ +{"orderNumber":2,"title":"Other Questions","masterContent":"Lambda Feedback supports a whole host of response areas, a whole list with descriptions can be found here: [https://lambda-feedback.github.io/user-documentation/teacher/reference/response\\\\\\_area\\\\\\_components/](https://lambda-feedback.github.io/user-documentation/teacher/reference/response\\\\\\_area\\\\\\_components/)","publish":true,"displayFinalAnswer":true,"displayStructuredTutorial":true,"displayWorkedSolution":true,"displayChatbot":true,"parts":[{"orderNumber":0,"content":"Examples of other types include matrices\n\nWhat is $A + B$? For the following matrices:\n\n$$\nA=\\left[\\begin{matrix}2&4a \\\\ a &3+a\\end{matrix}\\right],B=\\left[\\begin{matrix} 5&2a \\\\ 0 &1\\end{matrix}\\right]\n$$","answerContent":"$$\nA + B = \\left[\\begin{matrix}7 & 6a \\\\ a & 4+a \\end{matrix}\\right]\n$$","responseAreas":[{"orderNumber":0,"contentAfter":"This used \"arraySymbolicEqual\" to check if the student's reponse is equal to the answer.","preResponseText":"$A+B=$","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"arraySymbolicEqual","livePreview":false,"gradeParams":{"atol":0,"rtol":0,"strict_syntax":false,"elementary_functions":true},"separateFeedback":true,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[],"cases":[],"response":{"responseInput":{"responseType":"MATRIX","answer":[["7","6a"],["a","4+a"]],"config":{"cols":2,"rows":2}}}}],"workedSolution":{"title":"","content":"To add a matrix, add the corresponding elements in each matrix:\n\n---\n\n$$\nA + B = \\left[\\begin{matrix}2 + 5 & 4a + 2a \\\\ a + 0 & 3 + a \\end{matrix}\\right]\n$$\n\n---\n\n$$\nA + B = \\left[\\begin{matrix}7 & 6a \\\\ a & 4+a \\end{matrix}\\right]\n$$","children":[]}},{"orderNumber":1,"content":"Another question type is Essay, which is commonly evaluated with a ChatGPT based evaluation function.\n\nWrite a short sentence on approximately how many staff and students are at Imperial.","answerContent":"","responseAreas":[{"orderNumber":0,"contentAfter":"The feedback uses ChatGPT. Careful prompting is key as errors can easily slip in!","preResponseText":"","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"chatGPT (experimental)","livePreview":false,"gradeParams":{"model":"gpt-4o-mini","main_prompt":"The question is: Write a short sentence on approximately how many staff and students are at Imperial.The answer is Imperial has approximately 22000 students and 8000 staff but these numbers are always changing, so they are very approximate (e.g. +/- 1k students is the equally valid).","default_prompt":"Output a Boolean: True if the student is correct and False if the student is incorrect","feedback_prompt":"Give objective feedback. No please or thank you. Be constructive. Don't be too pedantic. Short answer."},"separateFeedback":false,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[{"id":"2ae524d3-26d5-4aef-b2e4-d22b2523e3e2","payload":"Imperial has approximately 22000 students and 8000 staff.","expectedResponse":{"isCorrect":true}}],"cases":[],"response":{"responseInput":{"responseType":"ESSAY","answer":"Imperial has approximately 22000 students and 8000 staff.","config":{"allowDraw":false,"allowScan":false,"repeatForSubjects":false,"allowEmptySubmission":false}}}}]}]} \ No newline at end of file diff --git a/tests/fixtures/exports/introduction_to_lambda_feedback/set_Introduction_to_Lambda_Feedback.json b/tests/fixtures/exports/introduction_to_lambda_feedback/set_Introduction_to_Lambda_Feedback.json new file mode 100644 index 0000000..7739f6b --- /dev/null +++ b/tests/fixtures/exports/introduction_to_lambda_feedback/set_Introduction_to_Lambda_Feedback.json @@ -0,0 +1 @@ +{"name":"Introduction to Lambda Feedback","description":"","isSurvey":false,"manuallyHidden":true,"finalAnswerVisibility":"OPEN_WITH_WARNINGS","workedSolutionVisibility":"OPEN_WITH_WARNINGS","structuredTutorialVisibility":"OPEN","chatbotVisibility":"OPEN"} \ No newline at end of file diff --git a/tests/fixtures/exports/ps1_part1_question1/question_PS_1_Part_I_Question_1.json b/tests/fixtures/exports/ps1_part1_question1/question_PS_1_Part_I_Question_1.json new file mode 100644 index 0000000..daa4dd4 --- /dev/null +++ b/tests/fixtures/exports/ps1_part1_question1/question_PS_1_Part_I_Question_1.json @@ -0,0 +1 @@ +{"orderNumber":0,"title":"PS 1 Part I Question 1\n","masterContent":"Draw truth tables to solve the following problems.","publish":true,"displayFinalAnswer":true,"displayStructuredTutorial":true,"displayWorkedSolution":true,"displayChatbot":true,"parts":[{"orderNumber":0,"content":"Prove that $\\lor$ is *symmetric*. In other words, prove that if $P$ and $Q$ are propositions, then $P\\lor Q\\implies Q\\lor P$.","answerContent":"","responseAreas":[{"orderNumber":0,"contentAfter":"","preResponseText":"","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"evaluateProof (experimental)","livePreview":true,"gradeParams":{"context":"-","model_name":"gpt-4o-mini"},"separateFeedback":false,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[],"cases":[],"response":{"responseInput":{"responseType":"MATH_MULTI_LINES","answer":"{\"question\": \"Draw truth tables to solve the following problems. Prove that $\\\\lor$ is \\\\emph{symmetric}. In other words, prove that if $P$ and $Q$ are propositions, then $P\\\\lor Q\\\\implies Q\\\\lor P$.\", \"answer\": \"We can just check all the cases. \\\\medskip \\\\begin{tabular}{|c|c|c|c|c|}\\\\hline $P$&$Q$&$P\\\\lor Q$&$Q\\\\lor P$&$(P\\\\lor Q)\\\\implies(Q\\\\lor P)$\\\\\\\\ \\\\hline {\\\\tt false}&{\\\\tt false}&{\\\\tt false}&{\\\\tt false}&{\\\\tt true}\\\\\\\\ \\\\hline {\\\\tt false}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}\\\\\\\\ \\\\hline {\\\\tt true}&{\\\\tt false}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}\\\\\\\\ \\\\hline {\\\\tt true}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}\\\\\\\\ \\\\hline \\\\end{tabular} \\\\medskip The fifth column is always {\\\\tt true}, hence $P\\\\lor Q\\\\implies Q\\\\lor P$ is always true.\"}","config":{"allowPhoto":true,"allowHandwrite":true,"enableRefinement":true}}}}]},{"orderNumber":2,"content":"Is $\\iff$ symmetric?\nIn other words, is it true that for all propositions $P$ and $Q$ we have\n$(P\\iff Q)\\implies (Q\\iff P)?$\nGive a proof or a counterexample.","answerContent":"","responseAreas":[{"orderNumber":0,"contentAfter":"","preResponseText":"","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"evaluateProof (experimental)","livePreview":true,"gradeParams":{"context":"-","model_name":"gpt-4.1-mini"},"separateFeedback":false,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[],"cases":[],"response":{"responseInput":{"responseType":"MATH_MULTI_LINES","answer":"{\"question\": \"Draw truth tables to solve the following problems. Is $\\\\iff$ symmetric? In other words, is it true that for all propositions~$P$ and~$Q$ we have $$(P\\\\iff Q)\\\\implies (Q\\\\iff P)?$$ Give a proof or a counterexample.\", \"answer\": \"It is symmetric, we can check all the cases. \\\\medskip \\\\begin{tabular}{|c|c|c|c|c|}\\\\hline $P$&$Q$&$P\\\\iff Q$&$Q\\\\iff P$&$(P\\\\iff Q)\\\\implies(Q\\\\iff P)$\\\\\\\\ \\\\hline {\\\\tt false}&{\\\\tt false}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}\\\\\\\\ \\\\hline {\\\\tt false}&{\\\\tt true}&{\\\\tt false}&{\\\\tt false}&{\\\\tt true}\\\\\\\\ \\\\hline {\\\\tt true}&{\\\\tt false}&{\\\\tt false}&{\\\\tt false}&{\\\\tt true}\\\\\\\\ \\\\hline {\\\\tt true}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}&{\\\\tt true}\\\\\\\\ \\\\hline \\\\end{tabular} \\\\medskip The fifth column is always true so $(P\\\\iff Q)\\\\implies(Q\\\\iff P)$ is always true. Previous part questions that can be used without proof: a) Prove that $\\\\lor$ is \\\\emph{symmetric}. In other words, prove that if $P$ and $Q$ are propositions, then $P\\\\lor Q\\\\implies Q\\\\lor P$. b) Is $\\\\implies$ symmetric? In other words, is it true that for all propositions~$P$ and~$Q$ we have $$(P\\\\implies Q)\\\\implies (Q\\\\implies P)?$$ Give a proof or a counterexample. \"}","config":{"allowPhoto":true,"allowHandwrite":true,"enableRefinement":true}}}}]},{"orderNumber":1,"content":"Is $\\implies$ symmetric? In other words, is it true that for all propositions $P$ and $Q$ we have\n$(P\\implies Q)\\implies (Q\\implies P)?$\nGive a proof or a counterexample.","answerContent":"","responseAreas":[{"orderNumber":0,"contentAfter":"","preResponseText":"","postResponseText":"","inputSymbols":[],"displayInputSymbols":false,"includeInPdf":false,"saveAllowed":false,"evaluationFunctionName":"evaluateProof (experimental)","livePreview":true,"gradeParams":{"context":"-","model_name":"gpt-4.1-mini"},"separateFeedback":false,"commonFeedbackColor":"#C4CDD5","correctFeedbackColor":"#22C55E","correctFeedbackPrefix":"Correct","incorrectFeedbackColor":"#FF5630","incorrectFeedbackPrefix":"Incorrect","tests":[],"cases":[],"response":{"responseInput":{"responseType":"MATH_MULTI_LINES","answer":"{\"question\": \"Draw truth tables to solve the following problems. Is $\\\\implies$ symmetric? In other words, is it true that for all propositions~$P$ and~$Q$ we have $$(P\\\\implies Q)\\\\implies (Q\\\\implies P)?$$ Give a proof or a counterexample.\", \"answer\": \"It is not. For $\\\\implies$ to be symmetric, we must have $(P\\\\implies Q)\\\\implies(Q\\\\implies P)$ being true whatever $P$ and~$Q$ are. But if $P$ is false and $Q$ is true, then $P\\\\implies Q$ is true, $Q\\\\implies P$ is false, and $(P\\\\implies Q)\\\\implies(Q\\\\implies P)$ is then false. Previous part questions that can be used without proof: a) Prove that $\\\\lor$ is \\\\emph{symmetric}. In other words, prove that if $P$ and $Q$ are propositions, then $P\\\\lor Q\\\\implies Q\\\\lor P$. \"}","config":{"allowPhoto":true,"allowHandwrite":true,"enableRefinement":true}}}}]}]} \ No newline at end of file diff --git a/tests/test_export_keys.py b/tests/test_export_keys.py new file mode 100644 index 0000000..c87e924 --- /dev/null +++ b/tests/test_export_keys.py @@ -0,0 +1,95 @@ +"""A set written by in2lambda must hold the keys a real Lambda Feedback export holds. + +The platform's export is the only definition of the format. A set using every field +in2lambda has is written out, and the keys of its files are compared with those of the +exports under ``fixtures/exports``. The keys of ``config`` are compared per response +type; those of ``gradeParams`` and a case's ``params`` belong to the evaluation function. +""" + +import json +from pathlib import Path + +from in2lambda.api import response_area +from in2lambda.api.part import Part +from in2lambda.api.question import Question +from in2lambda.api.response_area import Case, Color, InputSymbol, MathSingleLine, Test +from in2lambda.api.set import Set + +EXPORTS = sorted((Path(__file__).parent / "fixtures" / "exports").glob("*/")) + +# Lambda Feedback sometimes writes an empty title on a tutorial or worked solution; +# only its branches have titles, so in2lambda writes none. +NOT_WRITTEN = {".parts[].tutorial.title", ".parts[].workedSolution.title"} + + +def _keys(value, path=""): + """Every key path of a JSON value, with list items merged under ``[]``.""" + found = set() + if isinstance(value, dict): + for key, item in value.items(): + found.add(f"{path}.{key}") + if key not in ("config", "gradeParams", "params"): + found |= _keys(item, f"{path}.{key}") + elif isinstance(value, list): + for item in value: + found |= _keys(item, f"{path}[]") + return found + + +def _exported(pattern: str) -> list: + return [json.loads(f.read_text()) for e in EXPORTS for f in e.glob(pattern)] + + +def test_a_set_using_every_field_writes_the_keys_of_a_real_export(tmp_path) -> None: + area = MathSingleLine( + answer="2*x", + evaluation_function="compareExpressions", + input_symbols=[InputSymbol("$x$", "x", ["X"])], + tests=[Test("2*x", True)], + cases=[Case("x", "Twice that.", False, color=Color.AMBER)], + ) + question = Question( + title="Q", + parts=[ + Part( + content="Find it.", + response_areas=[area], + tutorial="Double it.", + answer="$2x$", + worked_solution="Since...", + ), + Part(content="No solution here."), + ], + skill=0.5, + guidance="A warm-up.", + duration_lower_bound=5, + duration_upper_bound=10, + ) + s = Set(released_at="2026-09-28T11:00:00.000Z", questions=[question]) + s.to_json(str(tmp_path)) + + written = tmp_path / "set" + exported_set = set().union(*map(_keys, _exported("set_*.json"))) + assert _keys(json.loads((written / "set_set.json").read_text())) == exported_set + (question_file,) = written.glob("question_*.json") + exported_question = set().union(*map(_keys, _exported("question_*.json"))) + assert ( + _keys(json.loads(question_file.read_text())) == exported_question - NOT_WRITTEN + ) + + +def test_each_response_type_writes_the_settings_of_a_real_export() -> None: + def subclasses(cls): + return [s for c in cls.__subclasses__() for s in [c, *subclasses(c)]] + + types = { + cls.response_type: cls + for cls in subclasses(response_area.ResponseArea) + if "response_type" in vars(cls) + } + for question in _exported("question_*.json"): + for part in question["parts"]: + for area in part["responseAreas"]: + response = area["response"]["responseInput"] + written = types[response["responseType"]](evaluation_function="") + assert set(written._config() or {}) == set(response["config"] or {}) diff --git a/tests/test_runner.py b/tests/test_runner.py index df31429..d514b67 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -26,7 +26,7 @@ def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> Non assert result.questions, f"{filter_name} produced no questions" for question in result.questions: # A question is only useful if it has top-level text or at least one part. - assert question.main_text or question.parts + assert question.master_content or question.parts @pytest.mark.parametrize("filter_name", BUILTIN_FILTERS)