Skip to content

Commit

Permalink
added support for short-answer questions
Browse files Browse the repository at this point in the history
  • Loading branch information
gpoore committed May 25, 2020
1 parent 1aab3b1 commit a438f57
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 53 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
## v0.3.0 (2020-??-??)

* Added support for multiple-answers questions.
* Added support for short-answer questions.
* Added support for file-upload questions.
* Added support for setting question titles and point values (#9).
* Added support for text regions outside questions.
Expand Down
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ text2qti converts
[Markdown](https://daringfireball.net/projects/markdown/)-based plain text
files into quizzes in QTI format (version 1.2), which can be imported by
[Canvas](https://www.instructure.com/canvas/) and other educational software.
It supports multiple-choice, true/false, multiple-answers, numerical, essay,
and file-upload questions. It includes basic support for LaTeX math within
Markdown, and allows a limited subset of
[siunitx](https://ctan.org/pkg/siunitx) notation for units and for numbers in
scientific notation.
It supports multiple-choice, true/false, multiple-answers, numerical,
short-answer (fill-in-the-blank), essay, and file-upload questions. It
includes basic support for LaTeX math within Markdown, and allows a limited
subset of [siunitx](https://ctan.org/pkg/siunitx) notation for units and for
numbers in scientific notation.



Expand Down Expand Up @@ -69,7 +69,7 @@ b) 1
[ ] Smilodon fatalis
```

**Numerical questions** are indicated by an equals sign followed by one or
**Numerical questions** use an equals sign followed by one or
more spaces or tabs followed by the numerical answer. Acceptable answers can
be designated as a range of the form `[<min>, <max>]` or as a correct answer
with a specified acceptable margin of error `<ans> +- <margin>`. When the
Expand All @@ -94,6 +94,18 @@ must be greater than or equal to 0.0001 (1e-4).
= 5
```

**Short-answer (fill-in-the-blank) questions** use an asterisk followed by one
or more spaces or tabs followed by an answer. Multiple acceptable answers can
be given. Answers are restricted to a single line each.
```
1. Who lives at the North Pole?
* Santa
* Santa Claus
* Father Christmas
* Saint Nicholas
* Saint Nick
```

**Essay questions** are indicated by a sequence of three or more underscores.
They only support general question feedback.

Expand All @@ -111,7 +123,6 @@ circumflex accents. They only support general question feedback.
^^^^
```


**Text regions** outside of questions are supported. Note that unlike all
other text, titles like text region titles are treated as plain text, not
Markdown, due to the QTI format. Also note that Canvas may ignore the text
Expand Down
82 changes: 59 additions & 23 deletions text2qti/quiz.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
'mctf_incorrect_choice': r'[a-zA-Z]\)',
'multans_correct_choice': r'\[\*\]',
'multans_incorrect_choice': r'\[ ?\]',
'shortans_correct_choice': r'\*',
'feedback': r'\.\.\.',
'correct_feedback': r'\+',
'incorrect_feedback': r'\-',
Expand Down Expand Up @@ -67,7 +68,7 @@
no_content = set(['essay', 'upload', 'start_group', 'end_group', 'start_code', 'end_code'])
# whether parser needs to check for multi-line content
multi_line = set([x for x in start_patterns
if x not in no_content and not any(y in x for y in ('pick', 'points', 'numerical'))])
if x not in no_content and not any(y in x for y in ('pick', 'points', 'numerical', 'shortans'))])
# whether parser needs to check for multi-paragraph content
multi_para = set([x for x in start_patterns
if x not in no_content and not any(y in x for y in ('title', 'pick', 'points', 'numerical'))])
Expand Down Expand Up @@ -135,15 +136,23 @@ class Choice(object):
The presence of feedback does not affect the id.
'''
def __init__(self, text: str, *,
correct: bool, question_hash_digest: bytes, md: Markdown):
correct: bool, shortans=False,
question_hash_digest: bytes, md: Markdown):
self.choice_raw = text
self.choice_html_xml = md.md_to_html_xml(text)
if shortans:
self.choice_xml = md.xml_escape(text)
else:
self.choice_html_xml = md.md_to_html_xml(text)
self.correct = correct
self.shortans = shortans
self.feedback_raw: Optional[str] = None
self.feedback_html_xml: Optional[str] = None
# ID is based on hash of choice XML as well as question XML. This
# gives different IDs for identical choices in different questions.
self.id = hashlib.blake2b(self.choice_html_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64]
if shortans:
self.id = hashlib.blake2b(self.choice_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64]
else:
self.id = hashlib.blake2b(self.choice_html_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64]
self.md = md

def append_feedback(self, text: str):
Expand Down Expand Up @@ -262,6 +271,20 @@ def append_mctf_incorrect_choice(self, text: str):
self._choice_set.add(choice.choice_html_xml)
self.choices.append(choice)

def append_shortans_correct_choice(self, text: str):
if self.type is None:
self.type = 'short_answer_question'
if self.choices:
raise Text2qtiError(f'Question type "{self.type}" is not compatible with existing choices')
elif self.type != 'short_answer_question':
raise Text2qtiError(f'Question type "{self.type}" does not support short answer')
choice = Choice(text, correct=True, shortans=True, question_hash_digest=self.hash_digest, md=self.md)
if choice.choice_xml in self._choice_set:
raise Text2qtiError('Duplicate choice for question')
self._choice_set.add(choice.choice_xml)
self.choices.append(choice)
self.correct_choices += 1

def append_multans_correct_choice(self, text: str):
if self.type is None:
self.type = 'multiple_answers_question'
Expand Down Expand Up @@ -408,6 +431,9 @@ def finalize(self):
raise Text2qtiError('Question must specify a correct choice')
if self.correct_choices > 1:
raise Text2qtiError('Question must specify only one correct choice')
elif self.type == 'short_answer_question':
if not self.choices:
raise Text2qtiError('Question must provide at least one answer')
elif self.type == 'multiple_answers_question':
# There must be at least one choice for the type to be set, so
# don't need to check for zero choices
Expand Down Expand Up @@ -697,7 +723,7 @@ def _run_code(self, executable: str, code: str) -> str:

def append_quiz_title(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if self.title_raw is not None:
raise Text2qtiError('Quiz title has already been given')
if self.questions_and_delims:
Expand All @@ -709,7 +735,7 @@ def append_quiz_title(self, text: str):

def append_quiz_description(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if self.description_raw is not None:
raise Text2qtiError('Quiz description has already been given')
if self.questions_and_delims:
Expand All @@ -719,7 +745,7 @@ def append_quiz_description(self, text: str):

def append_text_title(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if self.questions_and_delims:
last_question_or_delim = self.questions_and_delims[-1]
if isinstance(last_question_or_delim, Question):
Expand All @@ -730,7 +756,7 @@ def append_text_title(self, text: str):

def append_text(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if self.questions_and_delims:
last_question_or_delim = self.questions_and_delims[-1]
if isinstance(last_question_or_delim, Question):
Expand Down Expand Up @@ -777,7 +803,7 @@ def append_question_points(self, text: str):

def append_feedback(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have feedback without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -787,7 +813,7 @@ def append_feedback(self, text: str):

def append_correct_feedback(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have feedback without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -797,7 +823,7 @@ def append_correct_feedback(self, text: str):

def append_incorrect_feedback(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have feedback without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -807,7 +833,7 @@ def append_incorrect_feedback(self, text: str):

def append_mctf_correct_choice(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have a choice without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -817,17 +843,27 @@ def append_mctf_correct_choice(self, text: str):

def append_mctf_incorrect_choice(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have a choice without a question')
last_question_or_delim = self.questions_and_delims[-1]
if not isinstance(last_question_or_delim, Question):
raise Text2qtiError('Cannot have a choice without a question')
last_question_or_delim.append_mctf_incorrect_choice(text)

def append_shortans_correct_choice(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have an answer without a question')
last_question_or_delim = self.questions_and_delims[-1]
if not isinstance(last_question_or_delim, Question):
raise Text2qtiError('Cannot have an answer without a question')
last_question_or_delim.append_shortans_correct_choice(text)

def append_multans_correct_choice(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have a choice without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -837,7 +873,7 @@ def append_multans_correct_choice(self, text: str):

def append_multans_incorrect_choice(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have a choice without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -847,7 +883,7 @@ def append_multans_incorrect_choice(self, text: str):

def append_essay(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have an essay response without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -857,7 +893,7 @@ def append_essay(self, text: str):

def append_upload(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have an upload response without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -867,7 +903,7 @@ def append_upload(self, text: str):

def append_numerical(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if not self.questions_and_delims:
raise Text2qtiError('Cannot have a numerical response without a question')
last_question_or_delim = self.questions_and_delims[-1]
Expand All @@ -877,7 +913,7 @@ def append_numerical(self, text: str):

def append_start_group(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if text:
raise ValueError
if self._current_group is not None:
Expand All @@ -892,7 +928,7 @@ def append_start_group(self, text: str):

def append_end_group(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if text:
raise ValueError
if self._current_group is None:
Expand All @@ -907,14 +943,14 @@ def append_end_group(self, text: str):

def append_group_pick(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if self._current_group is None:
raise Text2qtiError('No question group for setting properties')
self._current_group.append_group_pick(text)

def append_group_points_per_question(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if self._current_group is None:
raise Text2qtiError('No question group for setting properties')
self._current_group.append_group_points_per_question(text)
Expand All @@ -927,7 +963,7 @@ def append_end_code(self, text: str):

def append_unknown(self, text: str):
if self._next_question_attr:
raise Text2qtiError('Expected question; question title and/or points were given but not used')
raise Text2qtiError('Expected question; question title and/or points were set but not used')
if text and not text.isspace():
match = start_missing_whitespace_re.match(text)
if match:
Expand Down
2 changes: 1 addition & 1 deletion text2qti/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-

from .fmtversion import get_version_plus_info
__version__, __version_info__ = get_version_plus_info(0, 3, 0, 'dev', 8)
__version__, __version_info__ = get_version_plus_info(0, 3, 0, 'dev', 9)
Loading

0 comments on commit a438f57

Please sign in to comment.