"""
Simple data classes for storing feedback to present to learners.
"""
__all__ = ['Feedback', 'FeedbackKind', 'FeedbackCategory',
"CompositeFeedbackFunction",
"FeedbackResponse"]
from pedal.core.formatting import wrap_fields
from pedal.core.location import Location
from pedal.core.report import MAIN_REPORT
from pedal.core.feedback_category import FeedbackKind, FeedbackCategory, FeedbackStatus
PEDAL_DEVELOPERS = ["Austin Cory Bart <acbart@udel.edu>",
"Luke Gusukuma <lukesg08@vt.edu>"]
class FeedbackRegistry:
def __init__(self):
self._registered_feedback = {}
[docs]
class Feedback:
"""
A class for storing raw feedback.
Attributes:
label (str): An internal name for this specific piece of feedback. The
label should be an underscore-separated string following the same
conventions as names in Python. They do not have to be globally
unique, but labels should be as unique as possible (especially
within a category).
tool (str, optional): An internal name for indicating the tool that created
this feedback. Should be taken directly from the Tool itself. If `None`, then
this was not created by a tool but directly by the control script.
category (str): A human-presentable name showable to the learner, indicating what
sort of feedback this falls into (e.g., "runtime", "syntax", "algorithm").
More than one feedback will be in a category, but a feedback cannot be in
more than one category.
kind (str): The pedagogical role of this feedback, e.g., "misconception", "mistake",
"hint", "constraint". Usually, a piece of Feedback is pointing out a mistake,
but feedback can also be used for various other purposes.
justification (str): An instructor-facing string briefly describing why this
feedback was selected. Serves as a "TL;DR" for this feedback category, useful
for debugging why a piece of feedback appeared.
justification_template (str): A markdown-formatted message template that will
be used if a ``justification`` is None. Any ``fields`` will be injected
into the template IF the ``condition`` is met.
priority (str): An indication of how important this feedback is relative to other types
of feedback in the same category. Might be "high/medium/low". Exactly how this
gets used is up to the resolver, but typically it helps break ties.
valence (int): Indicates whether this is negative, positive, or neutral feedback.
Either 1, -1, or 0.
title (str, optional): A formal, student-facing title for this feedback. If None, indicates
that the :py:attr:`~pedal.core.feedback.Feedback.label` should be used instead.
message (str): A markdown-formatted message (aka also supporting HTML) that could be rendered
to the user.
message_template (str): A markdown-formatted message template that will
be used if a ``message`` is None. Any ``fields`` will be injected
into the template IF the ``condition`` is met.
fields (Dict[str,Any]): The raw data that was used to interpolate the
template to produce the message.
location (:py:attr:`~pedal.core.location.Location` or int): Information
about specific locations relevant to this message.
score (int): A numeric score to modify the students' total score, indicating their overall performance.
It is ultimately up to the Resolver to decide how to combine all the different scores; a typical
strategy would be to add all the scores together for any non-muted feedback.
correct (bool): Indicates that the entire submission should be considered correct (success) and that the
task is now finished.
muted (bool): Whether this piece of feedback is something that should be shown to a student. There are
various use cases for muted feedback: they can serve as flags for later conditionals, suppressed
default kinds of feedback, or perhaps feedback that is interesting for analysis but not pedagogically
helpful to give to the student. They will still contribute to overall score, but not to the correctness
of the submission.
unscored (bool): Whether this piece of feedback contributes to the score/correctness.
else_message (str): A string to render as a message when a
NEGATIVE valence feedback is NOT triggered, or a POSITIVE valence
feedback IS triggered.
else_message_template (str): Similar to the ``message_template``, but for the ``else_message``.
activate (bool): Used for default feedback objects without a custom
condition, to indicate whether they should be considered triggered.
Defaults to True; setting this to False means that the feedback
object will be deactivated. Note that most inheriting Feedback
Functions will not respect this parameter.
author (List[str]): A list of names/emails that indicate who created this piece of feedback. They can be
either names, emails, or combinations in the style of `"Cory Bart <acbart@udel.edu>"`.
version (str): A version string in the style of Semantic Version (semvar) such as `"0.0.1"`. The last (third)
digit should be incremented for small bug fixes/changes. The middle (second) digit should be used for more
serious and intense changes. The first digit should be incremented when changes are made on exposure to
learners or some other evidence-based motivation.
tags (list[:py:class:`~pedal.core.tag.Tag`]): Any tags that you want to
attach to this feedback.
parent (int, str, or `pedal.core.feedback.Feedback`): Information about
what logical grouping within the submission this belongs to.
Various tools can chunk up a submission (e.g., by section), they
can use this field to keep track of how that decision was made.
Resolvers can also use this information to organize feedback or to
report multiple categories.
report (:py:class:`~pedal.core.report.Report`): The Report object to
attach this feedback to. Defaults to MAIN_REPORT. Unspecified fields
will be filled in by inspecting the current Feedback Function
context.
"""
POSITIVE_VALENCE = 1
NEUTRAL_VALENCE = 0
NEGATIVE_VALENCE = -1
CATEGORIES = FeedbackCategory
KINDS = FeedbackKind
DEFAULT_FEEDBACK_MESSAGE = "No feedback message provided"
DEFAULT_JUSTIFICATION_MESSAGE = "No justification provided"
DEFAULT_ELSE_MESSAGE = None
label = None
category = None
justification = None
justification_template = None
constant_fields = None
fields = None
field_names = None
kind = None
title = None
message = None
message_template = None
else_message = None
else_message_template = None
priority = None
valence = None
location = None
score = None
correct = None
muted = None
unscored = None
tool = None
version = '1.0.0'
author = PEDAL_DEVELOPERS
tags = None
parent = None
activate: bool
_status: str
_exception: Exception or None
_met_condition: bool
_stored_args: tuple
_stored_kwargs: dict
_override_backups = None
_pools = {}
resolved_score = None
unused_message = None
# MAIN_REPORT
def __init__(self, *args, label=None,
category=None, justification=None,
fields=None, field_names=None,
kind=None, title=None,
message=None, message_template=None,
else_message=None, else_message_template=None,
priority=None, valence=None,
location=None, score=None, correct=None,
muted=None, unscored=None,
tool=None, version=None, author=None,
tags=None, parent=None, report=MAIN_REPORT,
delay_condition=False, activate=True,
**kwargs):
# Internal data
self.report = report
# Metadata
if label is not None:
self.label = label
else:
self.label = self.__class__.__name__
# Condition
if category is not None:
self.category = category
if justification is not None:
self.justification = justification
self.activate = activate
# Response
if kind is not None:
self.kind = kind
if priority is not None:
self.priority = priority
if valence is not None:
self.valence = valence
# Presentation
if fields is not None:
self.fields = fields
else:
self.fields = {}
if self.constant_fields is not None:
self.fields.update(self.constant_fields)
if field_names is not None:
self.field_names = field_names
# TODO: Should this be taken from `fields` if nothing is provided?
if title is not None:
self.title = title
elif self.title is None:
self.title = label
if message is not None:
self.message = message
if message_template is not None:
self.message_template = message_template
if else_message is not None:
self.else_message = else_message
if else_message_template is not None:
self.else_message_template = else_message_template
# Locations
if isinstance(location, int):
location = Location(location)
# TODO: Handle tuples (Line, Col) and (Filename, Line, Col), and
# possibly lists thereof
if location is not None:
self.location = location
# Result
if score is not None:
self.score = score
if correct is not None:
self.correct = correct
if muted is not None:
self.muted = muted
if unscored is not None:
self.unscored = unscored
# Metadata
if tool is not None:
self.tool = tool
if version is not None:
self.version = version
if author is not None:
self.author = author
if tags is not None:
self.tags = tags
# Organizational
if parent is not None:
self.parent = parent
if self.parent is None:
# Might inherit from Report's current group
self.parent = self.report.get_current_group()
if self.field_names is not None:
for field_name in self.field_names:
self.fields[field_name] = kwargs.get(field_name)
for key, value in kwargs.items():
self.fields[key] = value
if 'location' not in self.fields and self.location is not None:
self.fields['location'] = self.location
# Potentially delay the condition check
self._stored_args = args
self._stored_kwargs = kwargs
if delay_condition:
self._met_condition = False
self._status = FeedbackStatus.DELAYED
else:
self._handle_condition()
def _handle_condition(self):
""" Actually handle the condition check, updating message and report. """
# Self-attach to a given report?
self._exception = None
try:
self._met_condition = self.condition(*self._stored_args, **self._stored_kwargs)
# Generate the message field as needed
self.justification = self._get_justification(self._met_condition)
if self._met_condition:
self.message = self._get_message()
self._status = FeedbackStatus.ACTIVE
else:
self.else_message = self._get_else_message()
self.message = self.else_message
try:
self.unused_message = self._get_message()
except Exception:
self.unused_message = ""
self._status = FeedbackStatus.INACTIVE
except Exception as e:
self._met_condition = False
self._exception = e
self._status = FeedbackStatus.ERROR
if self.report is not None:
if self._met_condition:
self.report.add_feedback(self)
else:
self.report.add_ignored_feedback(self)
# Free up these temporary fields after condition is handled
# del self._stored_args
# del self._stored_kwargs
if self._exception is not None:
raise self._exception
[docs]
def condition(self, *args, **kwargs):
"""
Detect if this feedback is present in the code.
Defaults to true through the `activate` parameter.
Returns:
bool: Whether this feedback's condition was detected.
"""
return self.activate
def _get_message(self):
"""
Determines the appropriate value for the message. It will attempt
to use this instance's message, but if it's not available then it will
try to generate one from the message_template. Then, it returns a
generic message.
You can override this to create a truly dynamic message, if you want.
Returns:
str: The message for this feedback.
"""
if self.message is not None:
return self.message
if self.message_template is not None:
fields = wrap_fields(self.report.format, self.fields)
return self.message_template.format(**fields)
return self.DEFAULT_FEEDBACK_MESSAGE
def _get_else_message(self):
"""
Determines the appropriate value for the else_message. It will attempt
to use this instance's else_message, but if it's not available then it will
try to generate one from the else_message_template. Then, it returns ``None``
instead (since usually we don't provide positive feedback).
You can override this to create a truly dynamic else_message, if you want.
Returns:
str: The else_message for this feedback.
"""
if self.else_message is not None:
return self.else_message
if self.else_message_template is not None:
fields = wrap_fields(self.report.format, self.fields)
return self.else_message_template.format(**fields)
return self.DEFAULT_ELSE_MESSAGE
def _get_justification(self, met_condition):
"""
Determines the appropriate value for the justification. It first checks
if there is a `justification` already present, but if it's not available
then it will attempt to generate one from the `justification_template`. Then,
it returns a generic message.
You can override this to create a truly dynamic justification, if you want.
Returns:
str: The justification for this feedback.
"""
if self.justification is not None:
if isinstance(self.justification, str):
if met_condition:
return self.justification
else:
return "The following condition was not met: " + self.justification
else:
met, unmet = self.justification
return met if met_condition else unmet
if self.justification_template is not None:
if isinstance(self.justification_template, str):
template = "The following condition was not met: " + self.justification_template
else:
met, unmet = self.justification_template
template = met if met_condition else unmet
fields = wrap_fields(self.report.format, self.fields)
return template.format(**fields)
return self.DEFAULT_JUSTIFICATION_MESSAGE
def _get_child_feedback(self, feedback, active):
""" Callback function that Reports will call when a new piece of
feedback is being considered. By default, does nothing. This is useful
for :py:class:`pedal.core.group.FeedbackGroup`, most other feedbacks
will just not care.
The ``active`` parameter controls whether the condition for the
feedback was met. """
def __xor__(self, other):
"""
Allows you to have two mutually exclusive conditions. Only one condition will be activated.
Args:
other:
Returns:
"""
if isinstance(other, Feedback):
self.muted = bool(self) and not bool(other)
self.unscored = self.muted
other.muted = bool(other) and not bool(self)
other.unscored = other.muted
if isinstance(other, bool):
self.muted = bool(self) and not bool(other)
self.unscored = self.muted
def __rxor__(self, other):
return self.__xor__(other)
def __bool__(self):
return bool(self._met_condition)
def __str__(self):
"""
Create a string representation of this piece of Feedback for quick, dev purposes.
Returns:
str: String representation
"""
return "<Feedback ({},{})>".format(self.category, self.label)
def __repr__(self):
"""
Create a string representation of this piece of Feedback that displays considerably more information.
Returns:
str: String representation with more data
"""
metadata = ""
if self.tool is not None:
metadata += ", tool=" + self.tool
if self.category is not None:
metadata += ", category=" + self.category
if self.priority is not None:
metadata += ", priority=" + self.priority
if self.parent is not None:
metadata += ", parent=" + str(self.parent.label)
if self.fields is not None:
metadata += ", " + ", ".join(f"{field}={value!r}" for field, value in self.fields.items())
return "Feedback({}{})".format(self.label, metadata)
[docs]
def update_location(self, location):
""" Updates both the fields and location attribute.
TODO: Handle less information intelligently. """
if isinstance(location, int):
location = Location(location)
self.location = location
self.fields['location'] = location
def _fields_to_json(self):
return self.fields.copy()
def to_json(self):
return {
'correct': self.correct,
'score': self.score,
'title': self.title,
'message': self.message,
'label': self.label,
'active': bool(self),
'muted': self.muted,
'unscored': self.unscored,
'category': self.category,
'kind': self.kind,
'valence': self.valence,
'version': self.version,
'fields': self._fields_to_json(),
'justification': self.justification,
'priority': self.priority,
'location': self.location.to_json() if self.location is not None else None
}
@classmethod
def override(cls, report=MAIN_REPORT, **fields):
if cls._override_backups is None:
cls._override_backups = {}
for field, new_value in fields.items():
if field not in cls._override_backups:
cls._override_backups[field] = getattr(cls, field)
setattr(cls, field, new_value)
report.override_feedback(cls)
@classmethod
def _restore_overrides(cls):
for field, old_value in cls._override_backups.items():
setattr(cls, field, old_value)
cls._override_backups.clear()
@classmethod
def override_for_pool(cls, pool, **fields):
if isinstance(pool, str):
pool = [pool]
for each_pool in pool:
if each_pool not in cls._pools:
cls._pools[each_pool] = {}
cls._pools[each_pool].update(fields)
def _finalize(self):
# TODO: Move this to be earlier, when we create a feedback.
# Otherwise there might be fields that are not interpolated correctly!
possible_overrides = self._pools.get(self.report.chosen_pool)
if possible_overrides:
for key, value in possible_overrides.items():
setattr(self, key, value)
[docs]
class FeedbackResponse(Feedback):
"""
An extension of :py:class:`~pedal.core.feedback.Feedback` that is meant
to indicate that the class should not have any condition behind its
response.
"""
[docs]
def CompositeFeedbackFunction(*functions):
"""
Decorator for functions that return multiple types of feedback functions.
Args:
functions (callable): A list of callable functions.
Returns:
callable: The decorated function.
"""
def CompositeFeedbackFunction_with_attrs(function):
"""
Args:
function:
Returns:
"""
CompositeFeedbackFunction_with_attrs.functions = functions
return function
return CompositeFeedbackFunction_with_attrs
class FeedbackGroup(Feedback):
"""
An extension of :py:class:`~pedal.core.feedback.Feedback` that is meant
to indicate that this class will start a new Group context within the report
and do something interesting with any children it gets.
"""
DEFAULT_CATEGORY_PRIORITY = [
"highest",
# Static
Feedback.CATEGORIES.SYNTAX,
Feedback.CATEGORIES.MISTAKES,
Feedback.CATEGORIES.INSTRUCTOR,
Feedback.CATEGORIES.ALGORITHMIC,
# Dynamic
Feedback.CATEGORIES.RUNTIME,
Feedback.CATEGORIES.STUDENT,
Feedback.CATEGORIES.SPECIFICATION,
Feedback.CATEGORIES.POSITIVE,
Feedback.CATEGORIES.INSTRUCTIONS,
Feedback.CATEGORIES.UNKNOWN,
"lowest"
]