Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,6 @@ Click on a set in teacher mode. The arrow next to the "Add Question" button allo

Choose the zip file you wish to upload, and the question should appear! 🎉

Imported questions arrive published with every display setting on, and the set's own visibility settings still apply. The Python API can set each of these per question.

![Importing Question from file in Teacher Mode](_static/images/import-teacher.png)
14 changes: 12 additions & 2 deletions in2lambda/api/part.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
"""A part of a question."""

from dataclasses import dataclass
from dataclasses import dataclass, field

from in2lambda.api.response_area import ResponseArea


@dataclass
class Part:
"""A part of a question as represented on Lambda Feedback."""
"""A part of a question as represented on Lambda Feedback.

``worked_solution`` is markdown; a line holding only ``---`` (or ``***``) splits it
into the steps students go through one at a time. ``answer`` is the final answer
shown to students, and ``response_areas`` the boxes, in order, that mark what they
type.
"""

text: str = ""
worked_solution: str = ""
answer: str = ""
response_areas: list[ResponseArea] = field(default_factory=list)
43 changes: 32 additions & 11 deletions in2lambda/api/question.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -14,10 +14,17 @@ 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: its skill level,
guidance for students, expected duration in minutes, whether it is published, and
whether students may see the final answer, worked solution, structured tutorial
and chatbot. Unset skill, guidance and durations are left out of the JSON.

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", publish=False).publish
False
"""

title: str = ""
Expand All @@ -36,6 +43,20 @@ class Question:
"""Keeps track of the last question part that contains a solution /
text."""

# Settings are left out of the repr so that printing a question still shows its
# content rather than nine lines of configuration.
# An int too: Lambda Feedback's export is written by JavaScript, which writes the
# lowest and highest skill levels 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=True, 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=True, repr=False)

@property
def main_text(self) -> str:
r"""Main top-level question text.
Expand Down Expand Up @@ -89,20 +110,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(text='part a', worked_solution='part a solution', answer='', response_areas=[])], images=[], main_text='')
>>> 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(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \
Part(text='part b', worked_solution='Solution for b', answer='', response_areas=[]), \
Part(text='part c', worked_solution='Solution for b', answer='', response_areas=[])], images=[], main_text='')
>>> 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(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \
Part(text='part b', worked_solution='Solution for b', answer='', response_areas=[]), \
Part(text='part c', worked_solution='We now have a solution for c!', answer='', response_areas=[])], images=[], main_text='')
"""
elem_text = elem if isinstance(elem, str) else pf.stringify(elem)

Expand Down Expand Up @@ -131,13 +152,13 @@ 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(text='part a', worked_solution='part a solution', answer='', response_areas=[])], images=[], main_text='')
>>> # 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(text='part a', worked_solution='part a solution', answer='', response_areas=[]), \
Part(text='part b', worked_solution='part b solution', answer='', response_areas=[])], images=[], main_text='')
"""
elem_text = elem if isinstance(elem, str) else pf.stringify(elem)

Expand Down
95 changes: 95 additions & 0 deletions in2lambda/api/response_area.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""An answer box in a part, with how Lambda Feedback marks what is typed into it."""

import uuid
from dataclasses import dataclass, field
from typing import Any


def _new_id() -> str:
return str(uuid.uuid4())


@dataclass
class InputSymbol:
"""A symbol students may type, and what the evaluation function reads it as.

``symbol`` is what students see, ``code`` what the evaluator reads, and ``aliases``
other spellings accepted for it.
"""

symbol: str
code: str
aliases: list[str] = field(default_factory=list)
is_visible: bool = True


@dataclass
class Test:
"""An author's check of the marking: a response and whether it should be correct."""

# 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)


@dataclass
class Case:
"""A response that is shown tailored ``feedback``, and may be marked correct."""

answer: str
feedback: str
is_correct: bool
params: Any = None
id: str = field(default_factory=_new_id)


@dataclass
class ResponseArea:
"""An answer box as represented on Lambda Feedback.

Its position among a part's areas is its order, so it holds no order number.
``config`` and ``grade_params`` depend on ``response_type`` and are kept as Lambda
Feedback writes them. The feedback colours and prefixes default to what Lambda
Feedback fills in.

Examples:
>>> from in2lambda.api.response_area import ResponseArea, Test
>>> area = ResponseArea(
... response_type="NUMERIC_UNITS",
... answer="30 N",
... evaluation_function="comparePhysicalQuantities",
... grade_params={"rtol": 0.05},
... pre_text="$F=$",
... tests=[Test("30 N", True)],
... )
>>> area.tests[0].payload, area.tests[0].is_correct
('30 N', True)
"""

response_type: str = "MATH_SINGLE_LINE"
"""``MATH_SINGLE_LINE``, ``NUMERIC_UNITS`` or ``MULTIPLE_CHOICE``."""
answer: str | list[bool] = ""
"""The correct answer; for multiple choice, one boolean per option."""
config: dict[str, Any] | None = None
evaluation_function: str = "symbolicEqual"
grade_params: dict[str, Any] | None = None
pre_text: str = ""
post_text: str = ""
content_after: str = ""
"""Markdown shown after the box, before the next one."""
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)
10 changes: 5 additions & 5 deletions in2lambda/api/set.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,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(text='', worked_solution='Question 1 answer', answer='', response_areas=[])], images=[], main_text=''),\
Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer', answer='', response_areas=[])], images=[], main_text='')]
"""
self._current_question_index += 1

Expand Down Expand Up @@ -146,9 +146,9 @@ def from_json(cls, path: str) -> "Set":
"""Loads a Lambda Feedback export, as a folder or a zip, into a Set.

Only what the Set holds is read: the name, description, visibilities, and each
question's title, main text, parts, worked solutions and images. A zip is
extracted to a temporary directory that is not removed afterwards, because the
loaded images point into it.
question's title, main text, parts, worked solutions, images and settings. A
zip is extracted to a temporary directory that is not removed afterwards,
because the loaded images point into it.

Args:
path: The exported set's folder or zip.
Expand Down
Loading
Loading