Source code for pedal.core.feedback

"""
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" ]