diff --git a/VERSION b/VERSION index fc5d63b1..1ee71cdf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.26T17.51.31.713Z.b83cda8e.berickson.20260126.blacklist.by.domain +0.1.0+2026.01.15T20.46.52.349Z.863d72db.berickson.20260113.generic.lo.blocks.module diff --git a/modules/lo_blocks_module/README.md b/modules/lo_blocks_module/README.md new file mode 100644 index 00000000..b947503d --- /dev/null +++ b/modules/lo_blocks_module/README.md @@ -0,0 +1,12 @@ +# LO Blocks module + +Common items for how to work with LO Blocks + +## Reducers + +- **Page last visited**: Sets the time a student last visited a page and provides the most recent visited page +- **Event type counter**: Counts the events types each student producers per page +- **Time on task**: Records the time a student has spent on a page +- **Binned time on task**: Tracks time a student has spent on a page but binned into increments +- **Event timeline**: Simplified list of events a student has produced on each page + diff --git a/modules/lo_blocks_module/VERSION b/modules/lo_blocks_module/VERSION new file mode 100644 index 00000000..1ee71cdf --- /dev/null +++ b/modules/lo_blocks_module/VERSION @@ -0,0 +1 @@ +0.1.0+2026.01.15T20.46.52.349Z.863d72db.berickson.20260113.generic.lo.blocks.module diff --git a/modules/lo_blocks_module/lo_blocks_module/__init__.py b/modules/lo_blocks_module/lo_blocks_module/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/lo_blocks_module/lo_blocks_module/module.py b/modules/lo_blocks_module/lo_blocks_module/module.py new file mode 100644 index 00000000..f37cca07 --- /dev/null +++ b/modules/lo_blocks_module/lo_blocks_module/module.py @@ -0,0 +1,163 @@ +''' +LO Blocks module + +A Learning Observer module for handling LO Block items. +''' +import learning_observer.communication_protocol.query as q +from learning_observer.communication_protocol.integration import publish_function +import learning_observer.communication_protocol.util +from learning_observer.stream_analytics.helpers import KeyField, Scope + +import lo_blocks_module.reducers + +# Name for the module +NAME = 'LO Blocks module' + +COURSE_ROSTER = q.call('learning_observer.courseroster') +roster_echo_call = q.call('examples.roster_echo') + +@publish_function('examples.roster_echo') +def roster_echo(user_id): + return {'user_id': user_id, 'status': 'ok'} + + +EXECUTION_DAG = { + "execution_dag": { + "roster": COURSE_ROSTER(runtime=q.parameter("runtime"), course_id=q.parameter("course_id", required=True)), + "page_last_visited": q.select( + q.keys( + "lo_blocks_module.page_last_visited", + scope_fields={ + 'student': {'values': q.variable('roster'), 'path': 'user_id'}, + } + ), + fields={'last_visited': 'page'}, + ), + "event_type_counter": q.select( + q.keys( + "lo_blocks_module.event_type_counter", + scope_fields={ + 'student': {'values': q.variable('roster'), 'path': 'user_id'}, + 'page': {'values': q.variable('page_last_visited'), 'path': 'page'}, + } + ), + fields=q.SelectFields.All, + ), + "time_on_task": q.select( + q.keys( + "lo_blocks_module.time_on_task", + scope_fields={ + 'student': {'values': q.variable('roster'), 'path': 'user_id'}, + 'page': {'values': q.variable('page_last_visited'), 'path': 'page'}, + } + ), + fields=q.SelectFields.All, + ), + "binned_time_on_task": q.select( + q.keys( + "lo_blocks_module.binned_time_on_task", + scope_fields={ + 'student': {'values': q.variable('roster'), 'path': 'user_id'}, + 'page': {'values': q.variable('page_last_visited'), 'path': 'page'}, + } + ), + fields=q.SelectFields.All, + ), + "event_timeline": q.select( + q.keys( + "lo_blocks_module.event_timeline", + scope_fields={ + 'student': {'values': q.variable('roster'), 'path': 'user_id'}, + 'page': {'values': q.variable('page_last_visited'), 'path': 'page'}, + } + ), + fields=q.SelectFields.All, + ), + 'roster_map': q.map( + roster_echo_call, + values=q.variable('roster'), + value_path='user_id', + ), + }, + "exports": { + "page_last_visited": { + "returns": "page_last_visited", + "parameters": ["course_id"], + }, + "event_type_counter": { + "returns": "event_type_counter", + "parameters": ["course_id"], + }, + "time_on_task": { + "returns": "time_on_task", + "parameters": ["course_id"], + }, + "binned_time_on_task": { + "returns": "binned_time_on_task", + "parameters": ["course_id"], + }, + "event_timeline": { + "returns": "event_timeline", + "parameters": ["course_id"], + }, + 'roster_map': { + 'returns': 'roster_map', + 'parameters': ['course_id'], + }, + }, +} + +''' +Add reducers to the module. +''' +REDUCERS = [ + { + 'context': 'org.ets.sba', + 'scope': Scope([KeyField.STUDENT]), + 'function': lo_blocks_module.reducers.page_last_visited, + }, + { + 'context': 'org.ets.sba', + 'scope': lo_blocks_module.reducers.page_scope, + 'function': lo_blocks_module.reducers.event_type_counter, + }, + { + 'context': "org.ets.sba", + 'scope': lo_blocks_module.reducers.page_scope, + 'function': lo_blocks_module.reducers.time_on_task, + 'default': {'saved_ts': 0} + }, + { + 'context': "org.ets.sba", + 'scope': lo_blocks_module.reducers.page_scope, + 'function': lo_blocks_module.reducers.binned_time_on_task + }, + { + 'context': "org.ets.sba", + 'scope': lo_blocks_module.reducers.page_scope, + 'function': lo_blocks_module.reducers.event_timeline + }, +] + + +''' +The Course Dashboards are used to populate the modules +on the home screen. + +Note the icon uses Font Awesome v5 +''' +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/lo_blocks_module/dashboard/", + "icon": { + "type": "fas", + "icon": "fa-play-circle" + } +}] + +''' +Next js dashboards +''' +NEXTJS_PAGES = [ + # {'path': 'dashboard/'}, +] diff --git a/modules/lo_blocks_module/lo_blocks_module/reducers.py b/modules/lo_blocks_module/lo_blocks_module/reducers.py new file mode 100644 index 00000000..fbf94a7d --- /dev/null +++ b/modules/lo_blocks_module/lo_blocks_module/reducers.py @@ -0,0 +1,81 @@ +''' +This file defines reducers we wish to add to the incoming event +pipeline. The `learning_observer.stream_analytics` package includes +helper functions for Scoping the and setting the null state. +''' +import learning_observer.stream_analytics.time_on_task +from learning_observer.stream_analytics.helpers import student_event_reducer, kvs_pipeline, Scope, KeyField, EventField + +page_scope = Scope([KeyField.STUDENT, EventField('page')]) + +@student_event_reducer(null_state={}) +async def page_last_visited(event, internal_state): + page = event['client'].get('page') + if not page: + return False, False + internal_state[page] = event['server']['time'] + internal_state['last_visited'] = page + return internal_state, internal_state + + +@kvs_pipeline(scope=page_scope, null_state={}) +async def event_type_counter(event, internal_state): + ''' + An example of a per-student event counter + ''' + event_type = event['client']['event'] + if 'counts' not in internal_state: + internal_state['counts'] = {} + if event_type not in internal_state['counts']: + internal_state['counts'][event_type] = 0 + internal_state['counts'][event_type] += 1 + + return internal_state, internal_state + + +@kvs_pipeline(scope=page_scope) +async def time_on_task(event, internal_state): + internal_state = learning_observer.stream_analytics.time_on_task.apply_time_on_task( + internal_state, + event['server']['time'], + 60 + ) + return internal_state, internal_state + + +@kvs_pipeline(scope=page_scope) +async def binned_time_on_task(event, internal_state): + internal_state = learning_observer.stream_analytics.time_on_task.apply_binned_time_on_task( + internal_state, + event['server']['time'], + 60, + 600 + ) + return internal_state, internal_state + + +@kvs_pipeline(scope=page_scope, null_state={"events": []}) +async def event_timeline(event, internal_state): + """ + Collect a per-student timeline of events for later playback in dashboards. + """ + client = event.get("client", {}) + event_type = client.get("event") + metadata = client.get("metadata", {}) + timestamp = metadata.get("iso_ts") + + # Capture useful payload fields if present + entry = { + "event": event_type, + "timestamp": timestamp, + "id": client.get("id"), + "scope": client.get("scope"), + } + + # Add common payload values if they exist on the event + for key in ("value", "submitCount", "correct", "message", "showAnswer"): + if key in client: + entry[key] = client[key] + + internal_state.setdefault("events", []).append(entry) + return internal_state, internal_state diff --git a/modules/lo_blocks_module/pyproject.toml b/modules/lo_blocks_module/pyproject.toml new file mode 100644 index 00000000..8fe2f47a --- /dev/null +++ b/modules/lo_blocks_module/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/lo_blocks_module/setup.cfg b/modules/lo_blocks_module/setup.cfg new file mode 100644 index 00000000..c4c70b05 --- /dev/null +++ b/modules/lo_blocks_module/setup.cfg @@ -0,0 +1,14 @@ +[metadata] +name = LO Blocks module +version = file:VERSION +description = This is a generic module for understanding how write and read from reducers for the LO Blocks framework. + +[options] +packages = lo_blocks_module + +[options.package_data] +lo_blocks_module = assets/* + +[options.entry_points] +lo_modules = + lo_blocks_module = lo_blocks_module.module