Writing Guided Activities (Finite State Machines)

What is a Guided Activity?

A guided activity is a series of steps that guides a user through a process in a specified way. Some examples:

  • a lesson sequence: presents the lessons in a courselet to the student one at a time, in order, while minimizing distractions from the rest of the generic courselets interface.
  • a live session: an instructor presents a series of exercises to students in a classroom. The whole class does the exercises at the same time, synchronized by what the instructor chooses, in real-time.
  • a randomized trial protocol: two distinct ways of teaching the same thing are defined as “treatments”. A randomized trial randomizes students to either one treatment or the other, and compares their learning gains. Note that this depends strongly on controlling exactly what sequence of materials each student gets shown, according to the “treatment” they were assigned to.

Drawing a Guided Activity as a Graph of Views and Transitions

A guided activity can be thought of as a pathway through some set of views (web pages) within the generic Socraticqs2 interface.

Specifically, a guided activity in Socraticqs2 is defined as a Finite State Machine (FSM) consisting of a graph of nodes connected by edges:

  • its nodes are pages or “views”, that is, each node shows one view from the generic Socraticqs2 interface. Each node in a FSM has a distinct name. An FSM always begins at its START node, and terminates when it reaches its END node. An FSMNode instance always has an fsm attribute that points to the FSM that it is part of.
  • its edges are transitions from one view (node) to another. Concretely, on a given page there may be one or more possible events such as the user submitting an answer to a question, clicking the Next button, etc. Each event in the generic interface has a defined name such as next, add, etc. A node can define what events will trigger which edge. Each edge has a distinct name. An FSMEdge instance always has fromNode and toNode attributes that point to the nodes that it connects.
  • while an FSM is running, it is represented by an FSMState record that points to the current node, and stores any data associated with this running FSM instance. An FSMState instance always keeps an fsmNode attribute that points to the current FSMNode.

An FSM works by intercepting events from the current view, and redirecting the user to a different next view (node) than the generic interface’s default next view.

Controlling What Happens Next

Node and edge behavior can either be defined statically, i.e. a specific node always shows a fixed path, or dynamically, by writing plug-in code that decides on the fly what it will do.

Static edge behavior

The simplest way to control what happens next at a given node is simply to provide a static edge that says what node a given event should transition to. When an event occurs, the FSM will look for an outgoing edge with the same name as that event, and will transition to whatever node that edge points to. Events with no matching edge will be ignored; i.e. no transition will occur, and the view will remain at the current page (node).

Dynamic edge behavior

Alternatively, an edge can decide dynamically what node it will transition to, if plug-in code is provided for that edge. The plug-in code can do whatever processing it wants, and then simply returns a node to transition to. Note that this is also useful for implementing consequences of an event, even if the edge always transitions to the same target node.

Dynamic Event Mapping

In some cases it may be useful to dynamically redirect a given event to different edges under different circumstances. A node can do this by supplying plug-in code for that event; its plug-in code do whatever processing it wants and call whatever edge it wants. Note this can also be used for implementing consequences of an event.

An FSM can call other FSMs as “subroutines”

Our FSM design is hierarchical, that is, an FSM node can “call” another FSM guided activity as a “subroutine”. This is important for modular FSM design. For example, a randomized trial FSM (that compares the effectiveness of two “treatments”) would not itself need to know how to perform either of the treatments, because it could just call a “treatment FSM” that performs the specified treatment, and when finished returns control to randomized trial FSM.

This hierarchical “subroutine calling” capability is handled by the FSMStack class, which acts as the main interface between Socraticqs2 code and FSM code. An FSMStack instance always keeps a state attribute that points to the current FSMState.

An Example: Lesson Sequence FSM

To make this tangible, let’s look at an example. The lessonseq FSM shows the student the sequence of core lessons for a courselet, in the order set by the instructor.

It consists of four nodes: LESSON, ASK, ASSESS, ERRORS (in addition to the mandatory START and END).

  • LESSON: presents an explanation.
  • ASK: asks the student a question
  • ASSESS: shows the answer and prompts the student for a self-assessment of their own answer.
  • ERRORS: prompts the student to categorize what error(s) they made in their answer.

Edges:

  • next: Typically, the next edge must see whether the next lesson is an explanation (LESSON) or question (ASK), or whether we’ve reached the END. This dynamic edge behavior is implemented by supplying a plug-in method next_edge(). By convention, plug-in edge methods always consist of the name of the edge followed by _edge.
  • error: if the student self-assessment indicates they got the answer wrong, the error event is directed to take the student to the ERRORS page.

Behind the scenes, there is a UnitStatus data structure that knows how to follow the sequence of lessons in that “unit” (courselet).

An FSM is written as a simple set of class definitions for nodes, with outgoing edges defined for each node. Here is minimalist code for the Lesson Sequence FSM:

class START(object):
    '''Initialize data for viewing a courselet, and go immediately
    to first lesson. '''
    def start_event(self, node, fsmStack, request, **kwargs):
        'event handler for START node'
        unit = fsmStack.state.get_data_attr('unit')
        fsmStack.state.title = 'Study: %s' % unit.title
        unitStatus = UnitStatus(unit=unit, user=request.user)
        unitStatus.save()
        fsmStack.state.set_data_attr('unitStatus', unitStatus)
        return fsmStack.state.transition(fsmStack, request, 'next',
                                         useCurrent=True, **kwargs)
    next_edge = next_lesson
    # node specification data goes here
    title = 'Start This Courselet'
    edges = (
            dict(name='next', toNode='LESSON', title='View Next Lesson'),
        )

class LESSON(object):
    '''View a lesson explanation. '''
    next_edge = next_lesson
    # node specification data goes here
    path = 'ct:lesson'
    title = 'View an explanation'
    edges = (
            dict(name='next', toNode='LESSON', title='View Next Lesson'),
        )

class ASK(object):
    # node specification data goes here
    path = 'ct:ul_respond'
    title = 'Answer this question'
    edges = (
            dict(name='next', toNode='ASSESS', title='Go to self-assessment'),
        )

class ASSESS(object):
    next_edge = next_lesson
    # node specification data goes here
    path = 'ct:assess'
    title = 'Assess your answer'
    edges = (
            dict(name='next', toNode='LESSON', title='View Next Lesson'),
            dict(name='error', toNode='ERRORS', title='Classify your error'),
        )

class ERRORS(object):
    next_edge = next_lesson
    # node specification data goes here
    path = 'ct:assess_errors'
    title = 'Classify your error(s)'
    edges = (
            dict(name='next', toNode='LESSON', title='View Next Lesson'),
        )

class END(object):
    # node specification data goes here
    path = 'ct:unit_tasks_student'
    title = 'Courselet core lessons completed'
    help = '''Congratulations!  You have completed the core lessons for this
    courselet.  See below for suggested next steps for what to study now in
    this courselet.'''

Notes:

  • start event: whenever an FSM first starts, its START node is sent a start event. This FSM defines a start_event() method on its START node, to receive this event and perform initialization of the data which the FSM must store (on its FSMState data). Specifically the method uses the FSMState get_data_attr() and set_data_attr() methods for binding data to the FSMState.
  • Note that this code represents a specification of the FSM, not the actual storage of the FSM in the database. Specifically, the nodes need do nothing more than specify what data needs to be saved for each node, and provide its plug-in code. Hence these are just generic Python class objects, not FSMNode objects. We will describe later how to load such an FSM specification into the database.
  • outgoing edges from a node must be specified in its edges attribute. Providing plug-in code for an edge is optional, and always follows the naming convention EDGENAME_edge(), where EDGENAME is the name of the edge.
  • a node typically specifies which generic view it will display via its path attribute. Paths are given using the Django URL “name” convention APPNAME:VIEWNAME; in our case ct is the name of our Django app, and the set of all VIEW names are listed in ct/urls.py.
  • note that a full-blown FSM specification would typically give a lot more useful node attributes such as a docstring description, help etc. that provide the user helpful guidance. See the FSMNode reference docs below for details.

Here is the code for the next_lesson() edge method used by many of these nodes. It uses UnitStatus and UnitLesson methods to determine what the next view should be:

def next_lesson(self, edge, fsmStack, request, useCurrent=False, **kwargs):
    'edge method that moves us to right state for next lesson (or END)'
    fsm = edge.fromNode.fsm
    unitStatus = fsmStack.state.get_data_attr('unitStatus')
    if useCurrent:
        nextUL = unitStatus.get_lesson()
    else:
        nextUL = unitStatus.start_next_lesson()
    if not nextUL:
        return fsm.get_node('END')
    elif nextUL.is_question():
        return fsm.get_node(name='ASK')
    else: # just a lesson to read
        return edge.toNode

Finally, an FSM specification typically ends with a brief FSMSpecification initialization that makes it easy to load the specification into the database. Here is an example for the lessonseq specification:

def get_specs():
    'get FSM specifications stored in this file'
    spec = FSMSpecification(name='lessonseq', hideTabs=True,
            title='Take the courselet core lessons',
            pluginNodes=[START, LESSON, ASK, ASSESS, ERRORS, END],
        )
    return (spec,)
  • this first provides a number of FSM attributes such as its name, user interface properties (in this case it specifies that the tabbed interface should be hidden while this FSM is running), etc.

  • it also lists the complete set of nodes in the FSM.

  • to actually load an FSMSpecification into the database, simply call its save_graph() method with the username who should “own” this FSM. (Typically, this would be an admin user):

    fsmSpec.save_graph('admin')
    
  • Any time an FSM structure or attributes changes, it must be reloaded to the database this way, to take effect.

  • note that FSM specifications and plug in code must be stored in Python source code files in the mysite/ct/fsm_plugin/ directory.

FSM Reference Documentation

Loading FSM specifications into the database

Since FSMs are loaded dynamically via a plug-in architecture, their specifications are stored in the database for real-time query by the application. Whenever an FSM specification changes (e.g. adding a new FSM or modifying an existing FSM), that specification must be loaded to the database. Whenever the database table is flushed, the set of FSM specifications must be loaded.

The standard way to do this is simply use fsm_deploy manage command.

To load all FSM specifications, use:

$ python manage.py fsm_deploy
FSM `fsm_name1` is deployed.
FSM `fsm_name2` is deployed.

FSM specifications will be deployed using admin user.

User admin is a conventional username for all FSMs developer to use.

To load a single FSM specification, use:

$ python manage.py fsm_deploy APP.fsm_plugin.MODULENAME
FSM `MODULENAME` is deployed.

Where APP.fsm_plugin.MODULENAME is the full path to the new / modified module. APP is the name of the Django app, and MODULENAME is the module name in the fsm_plugin directory.

Low-level functions to deploy FSMs

There are also two low-level functions exists in the fsmspec module for this purpose:

deploy(mod_path, username)

mod_path: full path of the module (typically of the form APP.fsm_plugin.MODULENAME where APP is the name of the Django app, and MODULENAME is the module name in that directory) containing the new / modified FSM to load.

username: for administrative purposes, an existing user in the database, who will be recorded as the owner of the FSM. At present this isn’t really used for anything, hence somewhat arbitrary.

deploy_all(username, ignore=('testme', '__init__', ), pattern='*/fsm_plugin/*.py')

username: same as above

ignore: list of modules in the fsm_plugin directory that should not be treated as FSM specifications.

pattern: glob search pattern for finding FSM plugin modules.

To load all FSM specifications using low-level functions, use:

$ python manage.py shell
>>> from fsm.fsmspec import deploy_all
>>> deploy_all('admin')

To load a single FSM specification using low-level functions, use:

$ python manage.py shell
>>> from fsm.fsmspec import deploy
>>> deploy('APP.fsm_plugin.MODULENAME', 'admin')

FSMSpecification

Provides an interface for loading a Guided Activity specification into the database. Displayed as an option in the Start Activity menu according to its fsmGroups attribute (see below).

Important attributes to set in your FSM specification:

name

the ID by which the FSM will be called. That is, FSMs are invoked by name. For example, when the user indicates they want to study the sequence of lessons in a courselet, the generic Socraticqs2 UI searches for an FSM named lessonseq.

title

Displayed in the Start Activity menu.

Also sets the title which will be displayed by default for the FSM, unless its plug-in code start_event() explicitly overrides that by writing a new value to the FSMState.title for this running FSM instance.

description

An explanation of what this Guided Activity does for the user, to be displayed in the Activity Center UI, as a tooltip for this option in the Start Activity menu, etc.

fsmGroups

Optional. A list of one or more user interface group names (just a string) that this FSM should be listed in, on the Start Activity menu.

Currently, there are two user interface groups:

  • 'teach/unit_tasks': displayed on the instructor unit_tasks page.
  • 'teach/unit/published': same as above, but only if unit published.
help

More detailed info to help the user understand what this FSM is for.

hideTabs

If set True, hide the generic tabbed interface while this FSM is running.

Not yet implemented: turning this option doesn’t actually do anything yet. If set True, block hyperlinks from being clickable while this FSM is running.

hideNav

If set True, hide the generic navigation bar options while this FSM is running.

save_graph(username, *args, **kwargs)

Load FSM specification into the database, owned by the specified username. Arguments are passed to FSM.save_graph() (see below).

FSM

Database object representing a Guided Activity. Displayed as an option in the Start Activity menu according to the fsmGroups attribute (see above). Actually stores the attributes listed above for an FSM specification. In addition, you can use the following in your code:

Useful methods:

get_node(name)

Get node in this FSM with specified name.

classmethod save_graph(klass, fsmData, nodeData, edgeData, username, fsmGroups=(), oldLabel='OLD')

this is a low-level call; in general you should use the higher level call FSMSpecification.save_graph() instead. Store FSM specification from node, edge graph by renaming any existing FSM with the same name, and creating new FSM. Note that ongoing activities using the old FSM will continue to work (following the old FSM spec), but any new activities will be created using the new FSM spec (since they request it by name). Returns the newly created FSM object.

FSMNode

Important attributes to set in your FSM specification:

name

Note that you set this in an FSM specification by simply naming the class definition that you write for a node.

title

Title for the current step to be displayed in the Activity Center UI etc.

description

Provides an explanation of what this step in the Guided Activity does for the user, to display in the Activity Center UI, etc. Note that you set this by simply giving a docstring in the class definition that you write for a node.

help

Instructions for this step that will be displayed to the user as an overlay at the top of the current page, in addition to the generic instructions that are always shown on that page. This enables you to customize very clearly what you want the user to look at and do when performing this step.

path

specifies which generic view it will display via its path attribute. Paths are given using the Django URL “name” convention APPNAME:VIEWNAME; in our case ct is the name of our Django app, and the set of all VIEW names are listed in ct/urls.py.

doLogging

If set True, records entry and exit timestamps for the user’s visit to this view. Log data are saved as ActivityEvent records. If the current FSMState instance already has an activity attribute pointing to the current ActivityLog, the event will be marked as part of that ActivityLog. Otherwise, an ActivityLog whose name matches the current FSM will be used (or created, if it does not already exist), and also set as the current FSMState.activity.

Note that Response and StudentError data created while an FSM is running are also automatically time stamped and bound to the current FSMState.activity.

Plug-in code:

You can supply four types of plug-in methods on a Node specification class:

  • help method: if a node needs to control exactly what help message should be displayed on any given view (page), you can supply a get_help() method to do so. It must return a help message as a string, based on the current page that is being requested, or None if the current page should be considered “off-path” (in which case a default message will be displayed).

    Note that even without such a method, the node’s help attribute will automatically be shown as the help message for the view associated with this node, and the “off-path” message will be shown on all other views. So the only case where you need to supply this method is when your node needs to supply help messages for multiple possible views.

    The get_help() method definition must be of the following form:

    class FOO(object):
        def get_help(self, node, state, request):
            'provide help messages for all views relevant to this stage.'
            hits = {'ct:wikipedia_concept':
                '''If this definition approximately matches the concept you
                want to teach about, click Add.  Otherwise click the browser
                Back button to go back to the Search concepts page.''',
    
                'ct:concept_teach':
                '''If this definition approximately matches the concept you
                want to teach about, click Add.  Otherwise click the browser
                Back button to go back to the Search concepts page.''',
    
                'ct:concept_lessons':
                '''If this definition approximately matches the concept you
                want to teach about, you can write a new Lesson about it
                below.'''
            }
            if state.fsm_on_path(request.path):
                return node.help
            if request.path.startswith(state.path):
                return hits.get(request.resolver_match.view_name, None)
    
  • path method: if a node must determine its URL dynamically, you can supply a get_path() method to do so. It must return a URL string of the form '/ct/some/path/'. Note the trailing /, required by convention in Django. The method definition must be of the following form:

    class FOO(object):
        def get_path(self, node, state, request, **kwargs):
            'get URL for next steps in this unit'
            unitStatus = state.get_data_attr('unitStatus')
            return unitStatus.unit.get_study_url(request.path)
    
  • event method: if a node needs to determine dynamically what edge to trigger in response to a given event, you can supply a method named EVENTNAME_event() to do so (where EVENTNAME is the name of the event you want it to intercept). It should call the desired edge transition directly, and return the result. The method definition must be of the following form:

    class FOO(object):
        def start_event(self, node, fsmStack, request, **kwargs):
            'event handler for START node'
            unit = fsmStack.state.get_data_attr('unit')
            fsmStack.state.title = 'Study: %s' % unit.title
            unitStatus = UnitStatus(unit=unit, user=request.user)
            unitStatus.save()
            fsmStack.state.set_data_attr('unitStatus', unitStatus)
            return fsmStack.state.transition(fsmStack, request, 'next',
                                             useCurrent=True, **kwargs)
    
  • edge method: if an edge needs to do something (such as save state data or decide dynamically what node to transition to), you can do so by supplying a method named EDGENAME_edge(), where EDGENAME is the name of the edge it should intercept. It must return an FSMNode to transition to. The method definition must be of the following form:

    class FOO(object):
        def next_edge(self, edge, fsmStack, request, **kwargs):
            # ... do some processing here, save some data on fsmStack.state...
            return edge.toNode # finally return target node
    

FSMEdge

Important attributes to set in your FSM specification:

name

Name of this edge. Must be unique among the set of outgoing edges from a given node.

toNode

The destination node of this edge. Must be specified as a string node name in an edge dict input to FSMSpecification.

title

Title for this edge to be displayed in the Activity Center UI etc.

description

Provides an explanation of what this transition does for the user, to display in the Activity Center UI, etc.

help

If provided, displayed as a tool-tip for the Activity Center button that will trigger this edge.

showOption

If set True, this edge will be listed as an additional option on the Activity Center view. This is an easy way to give the user multiple choices for “next steps” from a given node.

FSMState

Represents the current state of a running FSM instance.

useful attributes you can read / write from plug-in code:

user

The user running this FSM instance. Do not change this directly.

fsmNode

The current node. Do not change this directly.

activity

The current ActivityLog for logging timestamp data to. You can set this directly, e.g. in start event plug-in code.

title

The title which will be displayed for the FSM.

hideTabs

If set True, hide the generic tabbed interface while this FSM is running.

hideLinks

If set True, block hyperlinks from being clickable while this FSM is running.

hideNav

If set True, hide the generic navigation bar options while this FSM is running.

unitLesson

records what lesson or question the FSM is currently working with. This is so useful that it is part of the database definition of FSMState.

Other arbitrary data can be saved using the FSMState.data JSON blob storage using the following methods.

useful methods you can call from plug-in code:

get_data_attr(attr)

Retrieve the named attribute attr from the FSMState.data JSON blob, or KeyError if it does not exist.

set_data_attr(attr, value)

Store value as the named attribute attr on the FSMState.data JSON blob.

get_all_state_data()

Get a dictionary of named attributes from the current state, including both the unitLesson attribute, and attributes stored in the FSMState.data JSON blob.